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 select dropdown widget (Phase 16)

Add interactive dropdown popup for <select> elements with full keyboard
navigation, mouse interaction, type-ahead search, and multiple select
support as a scrollable listbox.

DOM (crates/dom):
- Add select open/close state tracking (select_open, select_highlighted)
- Add SelectOptionInfo struct for option metadata
- Add select_options(), selected_index(), select_option_at() helpers
- Add type-ahead search with 1-second timeout buffer
- Tests for all select DOM operations

Layout (crates/layout):
- Extend FormControlInfo with dropdown_open, options, highlighted_index,
multiple, visible_size fields
- Add SelectOption struct for render-side option data
- Support <select multiple> sizing with size attribute

Render (crates/render):
- Two-phase rendering: collect open dropdowns during paint, render
overlays after main pass (ensures dropdowns paint on top)
- Dropdown overlay with shadow, border, highlighted option, optgroup
labels
- Multiple select listbox rendering with selection highlighting
- paint_rect_border helper, dropdown_menu_geometry public API

Browser (crates/browser):
- Click to open/close single-select dropdown
- Click on dropdown option to select it
- Click outside dropdown to dismiss
- Keyboard: Space/Enter to open/close, Up/Down to navigate options,
Escape to close, Home/End for first/last option
- Arrow keys cycle selection on closed single-select
- Multiple select: click to select, Cmd+Click to toggle, Up/Down
keyboard navigation, Space to toggle
- Type-ahead character search in open dropdown
- Hit-testing for dropdown overlay menu
- Close dropdowns on Tab navigation

JS bridge (crates/js):
- select.value getter/setter
- select.selectedIndex getter/setter
- select.options (HTMLOptionsCollection)
- select.selectedOptions
- select.type ("select-one" / "select-multiple")
- option.selected getter/setter
- input.checked, input.type, element.disabled getter/setters
- Tests for JS select interface

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

+1784 -53
+485 -8
crates/browser/src/main.rs
··· 317 317 const KEY_CODE_END: u16 = 119; 318 318 const KEY_CODE_RETURN: u16 = 36; 319 319 const KEY_CODE_SPACE: u16 = 49; 320 + const KEY_CODE_ESCAPE: u16 = 53; 320 321 const KEY_CODE_A: u16 = 0; 321 322 const KEY_CODE_C: u16 = 8; 322 323 const KEY_CODE_V: u16 = 9; ··· 456 457 } 457 458 } 458 459 460 + /// Returns true if `node` is a `<select>` element. 461 + fn is_select(doc: &we_dom::Document, node: NodeId) -> bool { 462 + doc.tag_name(node) == Some("select") 463 + } 464 + 465 + /// Returns true if `node` is a `<select multiple>` element. 466 + fn is_select_multiple(doc: &we_dom::Document, node: NodeId) -> bool { 467 + doc.tag_name(node) == Some("select") && doc.get_attribute(node, "multiple").is_some() 468 + } 469 + 470 + /// Toggle the dropdown menu for a single-select element. 471 + fn toggle_select_dropdown(doc: &mut we_dom::Document, node: NodeId) { 472 + if doc.is_select_open(node) { 473 + doc.close_select(node); 474 + } else { 475 + doc.close_all_selects(); // Close any other open dropdown first. 476 + doc.open_select(node); 477 + } 478 + } 479 + 480 + /// Select an option in the dropdown by flat index. Closes the dropdown and returns true 481 + /// if a selection was made (triggers change event detection). 482 + fn select_dropdown_option(doc: &mut we_dom::Document, node: NodeId, flat_index: usize) -> bool { 483 + let old_value = first_selected_text(doc, node); 484 + if doc.select_option_at(node, flat_index).is_some() { 485 + let new_value = first_selected_text(doc, node); 486 + doc.close_select(node); 487 + // Return true if value actually changed (for change event). 488 + old_value != new_value 489 + } else { 490 + false 491 + } 492 + } 493 + 494 + /// Get the text of the first selected option (for change detection). 495 + fn first_selected_text(doc: &we_dom::Document, select: NodeId) -> String { 496 + let options = doc.select_options(select); 497 + for opt in &options { 498 + if opt.is_group_label { 499 + continue; 500 + } 501 + if opt.selected { 502 + return opt.text.clone(); 503 + } 504 + } 505 + // Fallback: first non-group option 506 + for opt in &options { 507 + if !opt.is_group_label { 508 + return opt.text.clone(); 509 + } 510 + } 511 + String::new() 512 + } 513 + 514 + /// Move the highlighted option up or down in an open dropdown. 515 + fn move_select_highlight(doc: &mut we_dom::Document, node: NodeId, forward: bool) { 516 + let options = doc.select_options(node); 517 + if options.is_empty() { 518 + return; 519 + } 520 + let current = doc.select_highlighted_index(node).unwrap_or(0); 521 + let len = options.len(); 522 + 523 + // Find next non-disabled, non-group option. 524 + let mut next = current; 525 + for _ in 0..len { 526 + next = if forward { 527 + (next + 1) % len 528 + } else { 529 + (next + len - 1) % len 530 + }; 531 + if !options[next].is_group_label && !options[next].disabled { 532 + doc.set_select_highlighted(node, next); 533 + return; 534 + } 535 + } 536 + } 537 + 538 + /// Cycle the selected option for a closed single-select with arrow keys. 539 + fn cycle_select_option(state: &mut BrowserState, node: NodeId, forward: bool) { 540 + let options = state.page.doc.select_options(node); 541 + if options.is_empty() { 542 + return; 543 + } 544 + 545 + // Find current selection among selectable options. 546 + let selectable: Vec<usize> = options 547 + .iter() 548 + .enumerate() 549 + .filter(|(_, o)| !o.is_group_label && !o.disabled) 550 + .map(|(i, _)| i) 551 + .collect(); 552 + if selectable.is_empty() { 553 + return; 554 + } 555 + 556 + let current_pos = selectable 557 + .iter() 558 + .position(|&i| options[i].selected) 559 + .unwrap_or(0); 560 + 561 + let next_pos = if forward { 562 + (current_pos + 1).min(selectable.len() - 1) 563 + } else { 564 + current_pos.saturating_sub(1) 565 + }; 566 + 567 + if next_pos != current_pos { 568 + let flat_idx = selectable[next_pos]; 569 + state.page.doc.select_option_at(node, flat_idx); 570 + } 571 + } 572 + 573 + /// Hit-test the dropdown overlay for an open select element. 574 + /// Returns `Some(flat_index)` of the option at (view_x, view_y), or None. 575 + fn hit_test_dropdown( 576 + state: &BrowserState, 577 + select_node: NodeId, 578 + view_x: f32, 579 + view_y: f32, 580 + ) -> Option<usize> { 581 + // We need to find the select element's screen position from layout. 582 + let viewport_width = state.bitmap.width() as f32; 583 + let viewport_height = state.bitmap.height() as f32; 584 + 585 + let styled = we_style::computed::resolve_styles( 586 + &state.page.doc, 587 + std::slice::from_ref(&state.page.stylesheet), 588 + (viewport_width, viewport_height), 589 + )?; 590 + 591 + let mut img_sizes = image_sizes(&state.page.images); 592 + let svg_sizes = collect_svg_sizes(&state.page.doc); 593 + img_sizes.extend(svg_sizes); 594 + we_browser::iframe_loader::collect_iframe_sizes(&state.page.doc, &mut img_sizes); 595 + 596 + let tree = we_layout::layout( 597 + &styled, 598 + &state.page.doc, 599 + viewport_width, 600 + viewport_height, 601 + &state.font, 602 + &img_sizes, 603 + ); 604 + 605 + // Find the select's layout box position. 606 + let (sel_x, sel_y, sel_w, sel_h, font_size) = 607 + find_select_screen_pos(&tree.root, select_node, 0.0, -state.page_scroll_y)?; 608 + 609 + let options = state.page.doc.select_options(select_node); 610 + let (menu_x, menu_y, menu_w, menu_h, line_height) = 611 + we_render::dropdown_menu_geometry(sel_x, sel_y, sel_w, sel_h, font_size, options.len()); 612 + 613 + // Check if point is within the menu. 614 + if view_x >= menu_x && view_x < menu_x + menu_w && view_y >= menu_y && view_y < menu_y + menu_h 615 + { 616 + let padding = 4.0f32; 617 + let border = 1.0f32; 618 + let relative_y = view_y - menu_y - border - padding; 619 + if relative_y >= 0.0 { 620 + let index = (relative_y / line_height) as usize; 621 + if index < options.len() { 622 + return Some(index); 623 + } 624 + } 625 + } 626 + None 627 + } 628 + 629 + /// Find the screen-space border box of a select element in the layout tree. 630 + /// Returns `(x, y, width, height, font_size)`. 631 + fn find_select_screen_pos( 632 + layout_box: &we_layout::LayoutBox, 633 + target: NodeId, 634 + parent_x: f32, 635 + parent_y: f32, 636 + ) -> Option<(f32, f32, f32, f32, f32)> { 637 + let bx = parent_x + layout_box.rect.x - layout_box.padding.left - layout_box.border.left; 638 + let by = parent_y + layout_box.rect.y - layout_box.padding.top - layout_box.border.top; 639 + let bw = layout_box.rect.width 640 + + layout_box.padding.left 641 + + layout_box.padding.right 642 + + layout_box.border.left 643 + + layout_box.border.right; 644 + let bh = layout_box.rect.height 645 + + layout_box.padding.top 646 + + layout_box.padding.bottom 647 + + layout_box.border.top 648 + + layout_box.border.bottom; 649 + 650 + // Check if this is the target select. 651 + if let we_layout::BoxType::Block(node) | we_layout::BoxType::Inline(node) = layout_box.box_type 652 + { 653 + if node == target && layout_box.form_control.is_some() { 654 + return Some((bx, by, bw, bh, layout_box.font_size)); 655 + } 656 + } 657 + 658 + // Recurse into children. 659 + for child in &layout_box.children { 660 + if let Some(pos) = find_select_screen_pos( 661 + child, 662 + target, 663 + parent_x + layout_box.rect.x, 664 + parent_y + layout_box.rect.y, 665 + ) { 666 + return Some(pos); 667 + } 668 + } 669 + None 670 + } 671 + 672 + /// Check if any select dropdown is open and return the node ID. 673 + fn find_open_select(doc: &we_dom::Document) -> Option<NodeId> { 674 + for i in 0..doc.len() { 675 + let node = NodeId::from_index(i); 676 + if doc.tag_name(node) == Some("select") && doc.is_select_open(node) { 677 + return Some(node); 678 + } 679 + } 680 + None 681 + } 682 + 459 683 /// Re-render the page and mark the view as needing display. 460 684 fn rerender(state: &mut BrowserState) { 461 685 let viewport_width = state.bitmap.width() as f32; ··· 496 720 None => return, 497 721 }; 498 722 723 + // Handle select element keyboard interactions. 724 + if is_select(&state.page.doc, focused) { 725 + if state.page.doc.get_attribute(focused, "disabled").is_some() { 726 + return; 727 + } 728 + 729 + let is_open = state.page.doc.is_select_open(focused); 730 + let is_multiple = is_select_multiple(&state.page.doc, focused); 731 + 732 + if is_open && !is_multiple { 733 + // Dropdown is open: handle navigation and selection. 734 + match key_code { 735 + KEY_CODE_ESCAPE => { 736 + state.page.doc.close_select(focused); 737 + rerender(state); 738 + } 739 + KEY_CODE_RETURN | KEY_CODE_SPACE => { 740 + // Select the highlighted option and close. 741 + if let Some(idx) = state.page.doc.select_highlighted_index(focused) { 742 + select_dropdown_option(&mut state.page.doc, focused, idx); 743 + } else { 744 + state.page.doc.close_select(focused); 745 + } 746 + rerender(state); 747 + } 748 + KEY_CODE_UP => { 749 + move_select_highlight(&mut state.page.doc, focused, false); 750 + rerender(state); 751 + } 752 + KEY_CODE_DOWN => { 753 + move_select_highlight(&mut state.page.doc, focused, true); 754 + rerender(state); 755 + } 756 + KEY_CODE_HOME => { 757 + // Jump to first selectable option. 758 + let options = state.page.doc.select_options(focused); 759 + for (i, opt) in options.iter().enumerate() { 760 + if !opt.is_group_label && !opt.disabled { 761 + state.page.doc.set_select_highlighted(focused, i); 762 + break; 763 + } 764 + } 765 + rerender(state); 766 + } 767 + KEY_CODE_END => { 768 + // Jump to last selectable option. 769 + let options = state.page.doc.select_options(focused); 770 + for (i, opt) in options.iter().enumerate().rev() { 771 + if !opt.is_group_label && !opt.disabled { 772 + state.page.doc.set_select_highlighted(focused, i); 773 + break; 774 + } 775 + } 776 + rerender(state); 777 + } 778 + _ => { 779 + // Type-ahead: printable characters jump to matching option. 780 + if !mods.command && !mods.control { 781 + for ch in chars.chars() { 782 + if !ch.is_control() { 783 + let now_ms = std::time::SystemTime::now() 784 + .duration_since(std::time::UNIX_EPOCH) 785 + .map(|d| d.as_millis() as u64) 786 + .unwrap_or(0); 787 + if let Some(idx) = 788 + state.page.doc.select_type_ahead_char(focused, ch, now_ms) 789 + { 790 + state.page.doc.set_select_highlighted(focused, idx); 791 + rerender(state); 792 + } 793 + } 794 + } 795 + } 796 + } 797 + } 798 + } else if !is_multiple { 799 + // Single-select, dropdown closed. 800 + match key_code { 801 + KEY_CODE_SPACE | KEY_CODE_RETURN => { 802 + toggle_select_dropdown(&mut state.page.doc, focused); 803 + rerender(state); 804 + } 805 + KEY_CODE_UP | KEY_CODE_LEFT => { 806 + cycle_select_option(state, focused, false); 807 + rerender(state); 808 + } 809 + KEY_CODE_DOWN | KEY_CODE_RIGHT => { 810 + cycle_select_option(state, focused, true); 811 + rerender(state); 812 + } 813 + _ => {} 814 + } 815 + } else { 816 + // Multiple select (listbox). 817 + match key_code { 818 + KEY_CODE_UP => { 819 + let options = state.page.doc.select_options(focused); 820 + let current = state 821 + .page 822 + .doc 823 + .select_highlighted_index(focused) 824 + .unwrap_or(0); 825 + let len = options.len(); 826 + let mut next = current; 827 + for _ in 0..len { 828 + next = if next > 0 { next - 1 } else { len - 1 }; 829 + if !options[next].is_group_label && !options[next].disabled { 830 + state.page.doc.set_select_highlighted(focused, next); 831 + break; 832 + } 833 + } 834 + rerender(state); 835 + } 836 + KEY_CODE_DOWN => { 837 + let options = state.page.doc.select_options(focused); 838 + let current = state 839 + .page 840 + .doc 841 + .select_highlighted_index(focused) 842 + .unwrap_or(0); 843 + let len = options.len(); 844 + let mut next = current; 845 + for _ in 0..len { 846 + next = (next + 1) % len; 847 + if !options[next].is_group_label && !options[next].disabled { 848 + state.page.doc.set_select_highlighted(focused, next); 849 + break; 850 + } 851 + } 852 + rerender(state); 853 + } 854 + KEY_CODE_SPACE => { 855 + // Toggle selection of highlighted option. 856 + if let Some(idx) = state.page.doc.select_highlighted_index(focused) { 857 + let options = state.page.doc.select_options(focused); 858 + if let Some(opt) = options.get(idx) { 859 + if !opt.is_group_label && !opt.disabled { 860 + let opt_node = opt.node; 861 + if state.page.doc.get_attribute(opt_node, "selected").is_some() 862 + { 863 + state.page.doc.remove_attribute(opt_node, "selected"); 864 + } else { 865 + state.page.doc.set_attribute(opt_node, "selected", ""); 866 + } 867 + } 868 + } 869 + } 870 + rerender(state); 871 + } 872 + _ => {} 873 + } 874 + } 875 + return; 876 + } 877 + 499 878 // Handle checkbox/radio keyboard interactions. 500 879 if let Some(input_type) = checkbox_or_radio_type(&state.page.doc, focused) { 501 880 if state.page.doc.get_attribute(focused, "disabled").is_some() { ··· 770 1149 // Record focus change (for old element's `change` event). 771 1150 let _ = old_focus; 772 1151 1152 + // Close any open dropdown when tabbing away. 1153 + state.page.doc.close_all_selects(); 1154 + 773 1155 state.page.doc.set_active_element(Some(next), true); 774 1156 rerender(state); 775 1157 }); 776 1158 } 777 1159 778 - /// Handle mouse-down events — focus text inputs and position cursor. 779 - fn handle_mouse_down(x: f64, y: f64, click_count: u32, _mods: appkit::KeyModifiers) { 1160 + /// Handle mouse-down events — focus text inputs, position cursor, interact with selects. 1161 + fn handle_mouse_down(x: f64, y: f64, click_count: u32, mods: appkit::KeyModifiers) { 780 1162 STATE.with(|state| { 781 1163 let mut state = state.borrow_mut(); 782 1164 let state = match state.as_mut() { ··· 787 1169 let view_x = x as f32; 788 1170 let view_y = y as f32 + state.page_scroll_y; 789 1171 1172 + // First, check if an open dropdown was clicked. 1173 + if let Some(open_select) = find_open_select(&state.page.doc) { 1174 + if let Some(flat_index) = hit_test_dropdown(state, open_select, view_x, view_y) { 1175 + // Clicked on a dropdown option. 1176 + let _changed = select_dropdown_option(&mut state.page.doc, open_select, flat_index); 1177 + rerender(state); 1178 + return; 1179 + } 1180 + // Clicked outside the dropdown — close it. 1181 + state.page.doc.close_select(open_select); 1182 + // Fall through to normal hit-testing below (the click might be on a 1183 + // different control). 1184 + } 1185 + 790 1186 // Hit-test: find the form control element at (view_x, view_y). 791 1187 let viewport_width = state.bitmap.width() as f32; 792 1188 let viewport_height = state.bitmap.height() as f32; 793 1189 794 - // We need to do layout to find which element was clicked. 795 - // Re-use the last layout by doing a hit-test on the layout tree. 796 1190 let styled = match we_style::computed::resolve_styles( 797 1191 &state.page.doc, 798 1192 std::slice::from_ref(&state.page.stylesheet), ··· 823 1217 if checkbox_or_radio_type(&state.page.doc, node).is_some() { 824 1218 // Checkbox or radio button click. 825 1219 activate_control(state, node); 1220 + } else if is_select(&state.page.doc, node) && !is_select_multiple(&state.page.doc, node) 1221 + { 1222 + // Single-select click: toggle dropdown. 1223 + if state.page.doc.get_attribute(node, "disabled").is_none() { 1224 + state.page.doc.set_active_element(Some(node), false); 1225 + toggle_select_dropdown(&mut state.page.doc, node); 1226 + } 1227 + } else if is_select_multiple(&state.page.doc, node) { 1228 + // Multiple-select click: select/toggle option in listbox. 1229 + if state.page.doc.get_attribute(node, "disabled").is_none() { 1230 + state.page.doc.set_active_element(Some(node), false); 1231 + handle_multi_select_click(state, node, view_y, mods); 1232 + } 826 1233 } else if is_text_editable(&state.page.doc, node) { 827 1234 // Text input click. 828 1235 let was_focused = state.page.doc.active_element() == Some(node); ··· 830 1237 ensure_input_state(&mut state.page.doc, node); 831 1238 832 1239 if !was_focused { 833 - // First click to focus — select all and record focus value. 834 1240 if let Some(is) = state.page.doc.input_states.get_mut(node) { 835 1241 is.select_all(); 836 1242 is.record_focus_value(); 837 1243 } 838 1244 } else if click_count >= 2 { 839 - // Double-click: select word at cursor position. 840 1245 let char_width = font_size * 0.6; 841 1246 let char_idx = if char_width > 0.0 { 842 1247 (local_x / char_width) as usize ··· 848 1253 is.select_word_at(byte_pos); 849 1254 } 850 1255 } else { 851 - // Single click: position cursor. 852 1256 let char_width = font_size * 0.6; 853 1257 let char_idx = if char_width > 0.0 { 854 1258 (local_x / char_width).round() as usize ··· 861 1265 } 862 1266 } 863 1267 } else { 864 - // Other form controls (button, select, etc.) — just focus. 1268 + // Other form controls (button, etc.) — just focus. 865 1269 state.page.doc.set_active_element(Some(node), false); 866 1270 } 867 1271 rerender(state); ··· 878 1282 rerender(state); 879 1283 } else if state.page.doc.active_element().is_some() { 880 1284 // Clicked outside any form control or label — blur. 1285 + state.page.doc.close_all_selects(); 881 1286 state.page.doc.set_active_element(None, false); 882 1287 rerender(state); 883 1288 } 884 1289 } 885 1290 }); 1291 + } 1292 + 1293 + /// Handle a click on a `<select multiple>` listbox. 1294 + fn handle_multi_select_click( 1295 + state: &mut BrowserState, 1296 + node: NodeId, 1297 + view_y: f32, 1298 + mods: appkit::KeyModifiers, 1299 + ) { 1300 + // Find the select's screen position to determine which option was clicked. 1301 + let viewport_width = state.bitmap.width() as f32; 1302 + let viewport_height = state.bitmap.height() as f32; 1303 + 1304 + let styled = match we_style::computed::resolve_styles( 1305 + &state.page.doc, 1306 + std::slice::from_ref(&state.page.stylesheet), 1307 + (viewport_width, viewport_height), 1308 + ) { 1309 + Some(s) => s, 1310 + None => return, 1311 + }; 1312 + 1313 + let mut img_sizes = image_sizes(&state.page.images); 1314 + let svg_sizes = collect_svg_sizes(&state.page.doc); 1315 + img_sizes.extend(svg_sizes); 1316 + we_browser::iframe_loader::collect_iframe_sizes(&state.page.doc, &mut img_sizes); 1317 + 1318 + let tree = we_layout::layout( 1319 + &styled, 1320 + &state.page.doc, 1321 + viewport_width, 1322 + viewport_height, 1323 + &state.font, 1324 + &img_sizes, 1325 + ); 1326 + 1327 + if let Some((sel_x, sel_y, _sel_w, _sel_h, font_size)) = 1328 + find_select_screen_pos(&tree.root, node, 0.0, -state.page_scroll_y) 1329 + { 1330 + let line_height = font_size * 1.2; 1331 + let padding = 2.0f32; 1332 + let border = 1.0f32; 1333 + let relative_y = view_y - sel_y - border - padding; 1334 + if relative_y >= 0.0 { 1335 + let index = (relative_y / line_height) as usize; 1336 + let options = state.page.doc.select_options(node); 1337 + if index < options.len() && !options[index].is_group_label && !options[index].disabled { 1338 + let opt_node = options[index].node; 1339 + if mods.command { 1340 + // Cmd+Click: toggle individual option. 1341 + if state.page.doc.get_attribute(opt_node, "selected").is_some() { 1342 + state.page.doc.remove_attribute(opt_node, "selected"); 1343 + } else { 1344 + state.page.doc.set_attribute(opt_node, "selected", ""); 1345 + } 1346 + } else { 1347 + // Plain click: select only this option. 1348 + for opt in &options { 1349 + if opt.is_group_label { 1350 + continue; 1351 + } 1352 + if opt.node == opt_node { 1353 + state.page.doc.set_attribute(opt.node, "selected", ""); 1354 + } else { 1355 + state.page.doc.remove_attribute(opt.node, "selected"); 1356 + } 1357 + } 1358 + } 1359 + } 1360 + } 1361 + let _ = sel_x; 1362 + } 886 1363 } 887 1364 888 1365 /// Handle mouse-dragged events — extend text selection.
+420 -1
crates/dom/src/lib.rs
··· 8 8 9 9 pub mod input_state; 10 10 11 - use std::collections::HashSet; 11 + use std::collections::{HashMap, HashSet}; 12 12 use std::fmt; 13 13 14 14 use we_memory::intern::Atom; ··· 60 60 Comment { data: String }, 61 61 } 62 62 63 + /// Information about a single option (or optgroup label) in a `<select>`. 64 + #[derive(Debug, Clone)] 65 + pub struct SelectOptionInfo { 66 + /// The DOM node for this option (or optgroup). 67 + pub node: NodeId, 68 + /// The display text (option text content, or optgroup label). 69 + pub text: String, 70 + /// The value attribute (or text content if no value attribute). 71 + pub value: String, 72 + /// Whether this option is disabled. 73 + pub disabled: bool, 74 + /// Whether this option is currently selected. 75 + pub selected: bool, 76 + /// True if this entry is an optgroup label (not selectable). 77 + pub is_group_label: bool, 78 + } 79 + 63 80 /// A node in the DOM tree, with links to parent, children, and siblings. 64 81 #[derive(Debug)] 65 82 struct Node { ··· 83 100 pub input_states: InputStateMap, 84 101 /// Set of checkbox elements in the indeterminate state (visual only). 85 102 indeterminate: HashSet<NodeId>, 103 + /// Set of `<select>` elements whose dropdown menu is currently open. 104 + select_open: HashSet<NodeId>, 105 + /// Highlighted (hovered/keyboard-navigated) option index per open `<select>`. 106 + select_highlighted: HashMap<NodeId, usize>, 107 + /// Type-ahead search buffer per `<select>`: (accumulated chars, last keystroke time in ms). 108 + select_type_ahead: HashMap<NodeId, (String, u64)>, 86 109 } 87 110 88 111 impl fmt::Debug for Document { ··· 112 135 focus_visible: false, 113 136 input_states: InputStateMap::new(), 114 137 indeterminate: HashSet::new(), 138 + select_open: HashSet::new(), 139 + select_highlighted: HashMap::new(), 140 + select_type_ahead: HashMap::new(), 115 141 } 116 142 } 117 143 ··· 631 657 members.push(candidate); 632 658 } 633 659 members 660 + } 661 + 662 + // --- Select dropdown helpers --- 663 + 664 + /// Returns true if the `<select>` element's dropdown menu is currently open. 665 + pub fn is_select_open(&self, node: NodeId) -> bool { 666 + self.select_open.contains(&node) 667 + } 668 + 669 + /// Open the dropdown menu for a `<select>` element. 670 + pub fn open_select(&mut self, node: NodeId) { 671 + self.select_open.insert(node); 672 + // Initialize highlighted index to the currently selected option. 673 + let selected_idx = self.selected_index(node).unwrap_or(0); 674 + self.select_highlighted.insert(node, selected_idx); 675 + self.select_type_ahead.remove(&node); 676 + } 677 + 678 + /// Close the dropdown menu for a `<select>` element. 679 + pub fn close_select(&mut self, node: NodeId) { 680 + self.select_open.remove(&node); 681 + self.select_highlighted.remove(&node); 682 + self.select_type_ahead.remove(&node); 683 + } 684 + 685 + /// Close all open select dropdown menus. 686 + pub fn close_all_selects(&mut self) { 687 + self.select_open.clear(); 688 + self.select_highlighted.clear(); 689 + self.select_type_ahead.clear(); 690 + } 691 + 692 + /// Returns the highlighted option index for an open `<select>`, if any. 693 + pub fn select_highlighted_index(&self, node: NodeId) -> Option<usize> { 694 + self.select_highlighted.get(&node).copied() 695 + } 696 + 697 + /// Set the highlighted option index for an open `<select>`. 698 + pub fn set_select_highlighted(&mut self, node: NodeId, index: usize) { 699 + self.select_highlighted.insert(node, index); 700 + } 701 + 702 + /// Collect all selectable options from a `<select>` element. 703 + /// Returns a flat list of `(NodeId, text, disabled, is_group_label, group_label)`. 704 + pub fn select_options(&self, select: NodeId) -> Vec<SelectOptionInfo> { 705 + let mut options = Vec::new(); 706 + let mut child = self.first_child(select); 707 + while let Some(c) = child { 708 + match self.tag_name(c) { 709 + Some("option") => { 710 + let text = self.collect_descendant_text(c).trim().to_string(); 711 + let disabled = self.get_attribute(c, "disabled").is_some(); 712 + let selected = self.get_attribute(c, "selected").is_some(); 713 + let value = self 714 + .get_attribute(c, "value") 715 + .map(|v| v.to_string()) 716 + .unwrap_or_else(|| text.clone()); 717 + options.push(SelectOptionInfo { 718 + node: c, 719 + text, 720 + value, 721 + disabled, 722 + selected, 723 + is_group_label: false, 724 + }); 725 + } 726 + Some("optgroup") => { 727 + let label = self.get_attribute(c, "label").unwrap_or("").to_string(); 728 + let group_disabled = self.get_attribute(c, "disabled").is_some(); 729 + options.push(SelectOptionInfo { 730 + node: c, 731 + text: label, 732 + value: String::new(), 733 + disabled: true, 734 + selected: false, 735 + is_group_label: true, 736 + }); 737 + let mut opt = self.first_child(c); 738 + while let Some(o) = opt { 739 + if self.tag_name(o) == Some("option") { 740 + let text = self.collect_descendant_text(o).trim().to_string(); 741 + let disabled = 742 + group_disabled || self.get_attribute(o, "disabled").is_some(); 743 + let selected = self.get_attribute(o, "selected").is_some(); 744 + let value = self 745 + .get_attribute(o, "value") 746 + .map(|v| v.to_string()) 747 + .unwrap_or_else(|| text.clone()); 748 + options.push(SelectOptionInfo { 749 + node: o, 750 + text, 751 + value, 752 + disabled, 753 + selected, 754 + is_group_label: false, 755 + }); 756 + } 757 + opt = self.next_sibling(o); 758 + } 759 + } 760 + _ => {} 761 + } 762 + child = self.next_sibling(c); 763 + } 764 + options 765 + } 766 + 767 + /// Get the index of the first selected option (0-based among non-group entries). 768 + pub fn selected_index(&self, select: NodeId) -> Option<usize> { 769 + let options = self.select_options(select); 770 + let mut selectable_idx = 0; 771 + for opt in &options { 772 + if opt.is_group_label { 773 + continue; 774 + } 775 + if opt.selected { 776 + return Some(selectable_idx); 777 + } 778 + selectable_idx += 1; 779 + } 780 + // If no option is explicitly selected, the first option is implicitly selected. 781 + if options.iter().any(|o| !o.is_group_label) { 782 + Some(0) 783 + } else { 784 + None 785 + } 786 + } 787 + 788 + /// Select option at `flat_index` (index into the flat options list including group labels). 789 + /// Deselects all other options. Returns the text of the newly selected option. 790 + pub fn select_option_at(&mut self, select: NodeId, flat_index: usize) -> Option<String> { 791 + let options = self.select_options(select); 792 + let target = options.get(flat_index)?; 793 + if target.is_group_label || target.disabled { 794 + return None; 795 + } 796 + let target_node = target.node; 797 + let result_text = target.text.clone(); 798 + 799 + // Deselect all, then select the target. 800 + for opt in &options { 801 + if opt.is_group_label { 802 + continue; 803 + } 804 + if opt.node == target_node { 805 + self.set_attribute(opt.node, "selected", ""); 806 + } else { 807 + self.remove_attribute(opt.node, "selected"); 808 + } 809 + } 810 + Some(result_text) 811 + } 812 + 813 + /// Collect all descendant text content of a node into one string. 814 + fn collect_descendant_text(&self, node: NodeId) -> String { 815 + let mut result = String::new(); 816 + self.collect_text(node, &mut result); 817 + result 818 + } 819 + 820 + /// Type-ahead: record a character for type-ahead search. 821 + /// Returns the index (in the flat options list) of the matching option, if any. 822 + /// `now_ms` is a monotonic timestamp in milliseconds. 823 + pub fn select_type_ahead_char( 824 + &mut self, 825 + select: NodeId, 826 + ch: char, 827 + now_ms: u64, 828 + ) -> Option<usize> { 829 + let (buf, last_time) = self 830 + .select_type_ahead 831 + .entry(select) 832 + .or_insert_with(|| (String::new(), 0)); 833 + 834 + // Reset buffer if more than 1 second since last keystroke. 835 + if now_ms.saturating_sub(*last_time) > 1000 { 836 + buf.clear(); 837 + } 838 + buf.push(ch.to_ascii_lowercase()); 839 + *last_time = now_ms; 840 + 841 + let search = buf.clone(); 842 + let options = self.select_options(select); 843 + for (i, opt) in options.iter().enumerate() { 844 + if opt.is_group_label || opt.disabled { 845 + continue; 846 + } 847 + if opt.text.to_ascii_lowercase().starts_with(&search) { 848 + return Some(i); 849 + } 850 + } 851 + None 634 852 } 635 853 636 854 /// Returns true if the element can receive focus. ··· 1718 1936 let mut doc = Document::new(); 1719 1937 let div = doc.create_element("div"); 1720 1938 assert!(doc.radio_group_members(div).is_empty()); 1939 + } 1940 + 1941 + // --- Select dropdown tests --- 1942 + 1943 + /// Build a `<select>` with options for testing. 1944 + fn build_select_with_options(doc: &mut Document) -> (NodeId, Vec<NodeId>) { 1945 + let root = doc.root(); 1946 + let select = doc.create_element("select"); 1947 + doc.append_child(root, select); 1948 + 1949 + let opt_a = doc.create_element("option"); 1950 + let text_a = doc.create_text("Apple"); 1951 + doc.append_child(opt_a, text_a); 1952 + doc.set_attribute(opt_a, "value", "apple"); 1953 + doc.append_child(select, opt_a); 1954 + 1955 + let opt_b = doc.create_element("option"); 1956 + let text_b = doc.create_text("Banana"); 1957 + doc.append_child(opt_b, text_b); 1958 + doc.set_attribute(opt_b, "value", "banana"); 1959 + doc.append_child(select, opt_b); 1960 + 1961 + let opt_c = doc.create_element("option"); 1962 + let text_c = doc.create_text("Cherry"); 1963 + doc.append_child(opt_c, text_c); 1964 + doc.set_attribute(opt_c, "value", "cherry"); 1965 + doc.set_attribute(opt_c, "selected", ""); 1966 + doc.append_child(select, opt_c); 1967 + 1968 + (select, vec![opt_a, opt_b, opt_c]) 1969 + } 1970 + 1971 + #[test] 1972 + fn select_options_collects_all() { 1973 + let mut doc = Document::new(); 1974 + let (select, _opts) = build_select_with_options(&mut doc); 1975 + let options = doc.select_options(select); 1976 + assert_eq!(options.len(), 3); 1977 + assert_eq!(options[0].text, "Apple"); 1978 + assert_eq!(options[1].text, "Banana"); 1979 + assert_eq!(options[2].text, "Cherry"); 1980 + assert!(!options[0].selected); 1981 + assert!(!options[1].selected); 1982 + assert!(options[2].selected); 1983 + } 1984 + 1985 + #[test] 1986 + fn select_selected_index() { 1987 + let mut doc = Document::new(); 1988 + let (select, _opts) = build_select_with_options(&mut doc); 1989 + // Cherry (index 2) is selected. 1990 + assert_eq!(doc.selected_index(select), Some(2)); 1991 + } 1992 + 1993 + #[test] 1994 + fn select_selected_index_default() { 1995 + let mut doc = Document::new(); 1996 + let root = doc.root(); 1997 + let select = doc.create_element("select"); 1998 + doc.append_child(root, select); 1999 + 2000 + let opt = doc.create_element("option"); 2001 + let text = doc.create_text("First"); 2002 + doc.append_child(opt, text); 2003 + doc.append_child(select, opt); 2004 + 2005 + // No explicit selection — defaults to index 0. 2006 + assert_eq!(doc.selected_index(select), Some(0)); 2007 + } 2008 + 2009 + #[test] 2010 + fn select_option_at() { 2011 + let mut doc = Document::new(); 2012 + let (select, opts) = build_select_with_options(&mut doc); 2013 + 2014 + // Select "Banana" at flat index 1. 2015 + let result = doc.select_option_at(select, 1); 2016 + assert_eq!(result, Some("Banana".to_string())); 2017 + 2018 + // Verify attributes updated. 2019 + assert!(doc.get_attribute(opts[0], "selected").is_none()); 2020 + assert!(doc.get_attribute(opts[1], "selected").is_some()); 2021 + assert!(doc.get_attribute(opts[2], "selected").is_none()); 2022 + } 2023 + 2024 + #[test] 2025 + fn select_open_close() { 2026 + let mut doc = Document::new(); 2027 + let (select, _) = build_select_with_options(&mut doc); 2028 + 2029 + assert!(!doc.is_select_open(select)); 2030 + doc.open_select(select); 2031 + assert!(doc.is_select_open(select)); 2032 + // Highlighted index should default to selected index. 2033 + assert_eq!(doc.select_highlighted_index(select), Some(2)); 2034 + 2035 + doc.close_select(select); 2036 + assert!(!doc.is_select_open(select)); 2037 + assert_eq!(doc.select_highlighted_index(select), None); 2038 + } 2039 + 2040 + #[test] 2041 + fn select_close_all() { 2042 + let mut doc = Document::new(); 2043 + let (s1, _) = build_select_with_options(&mut doc); 2044 + let s2 = doc.create_element("select"); 2045 + doc.append_child(doc.root(), s2); 2046 + 2047 + doc.open_select(s1); 2048 + doc.open_select(s2); 2049 + assert!(doc.is_select_open(s1)); 2050 + assert!(doc.is_select_open(s2)); 2051 + 2052 + doc.close_all_selects(); 2053 + assert!(!doc.is_select_open(s1)); 2054 + assert!(!doc.is_select_open(s2)); 2055 + } 2056 + 2057 + #[test] 2058 + fn select_optgroup() { 2059 + let mut doc = Document::new(); 2060 + let root = doc.root(); 2061 + let select = doc.create_element("select"); 2062 + doc.append_child(root, select); 2063 + 2064 + let group = doc.create_element("optgroup"); 2065 + doc.set_attribute(group, "label", "Fruits"); 2066 + doc.append_child(select, group); 2067 + 2068 + let opt = doc.create_element("option"); 2069 + let text = doc.create_text("Apple"); 2070 + doc.append_child(opt, text); 2071 + doc.append_child(group, opt); 2072 + 2073 + let options = doc.select_options(select); 2074 + assert_eq!(options.len(), 2); 2075 + assert!(options[0].is_group_label); 2076 + assert_eq!(options[0].text, "Fruits"); 2077 + assert!(!options[1].is_group_label); 2078 + assert_eq!(options[1].text, "Apple"); 2079 + } 2080 + 2081 + #[test] 2082 + fn select_disabled_optgroup() { 2083 + let mut doc = Document::new(); 2084 + let root = doc.root(); 2085 + let select = doc.create_element("select"); 2086 + doc.append_child(root, select); 2087 + 2088 + let group = doc.create_element("optgroup"); 2089 + doc.set_attribute(group, "label", "Fruits"); 2090 + doc.set_attribute(group, "disabled", ""); 2091 + doc.append_child(select, group); 2092 + 2093 + let opt = doc.create_element("option"); 2094 + let text = doc.create_text("Apple"); 2095 + doc.append_child(opt, text); 2096 + doc.append_child(group, opt); 2097 + 2098 + let options = doc.select_options(select); 2099 + // Option inside disabled optgroup should be disabled. 2100 + assert!(options[1].disabled); 2101 + 2102 + // Cannot select a disabled option. 2103 + let result = doc.select_option_at(select, 1); 2104 + assert_eq!(result, None); 2105 + } 2106 + 2107 + #[test] 2108 + fn select_type_ahead() { 2109 + let mut doc = Document::new(); 2110 + let (select, _) = build_select_with_options(&mut doc); 2111 + 2112 + // Type 'b' should match Banana (index 1). 2113 + let result = doc.select_type_ahead_char(select, 'b', 1000); 2114 + assert_eq!(result, Some(1)); 2115 + 2116 + // Type 'a' within 1 second: search becomes "ba" -> Banana. 2117 + let result = doc.select_type_ahead_char(select, 'a', 1500); 2118 + assert_eq!(result, Some(1)); 2119 + 2120 + // Type 'c' after 1 second timeout: search resets to "c" -> Cherry. 2121 + let result = doc.select_type_ahead_char(select, 'c', 3000); 2122 + assert_eq!(result, Some(2)); 2123 + } 2124 + 2125 + #[test] 2126 + fn select_option_value_fallback() { 2127 + let mut doc = Document::new(); 2128 + let root = doc.root(); 2129 + let select = doc.create_element("select"); 2130 + doc.append_child(root, select); 2131 + 2132 + // Option without explicit value attribute: value = text content. 2133 + let opt = doc.create_element("option"); 2134 + let text = doc.create_text("Hello"); 2135 + doc.append_child(opt, text); 2136 + doc.append_child(select, opt); 2137 + 2138 + let options = doc.select_options(select); 2139 + assert_eq!(options[0].value, "Hello"); 1721 2140 } 1722 2141 }
+350
crates/js/src/dom_bridge.rs
··· 1315 1315 Some(create_style_object(gc, shapes, bridge, node_id, &style_str)) 1316 1316 } 1317 1317 1318 + // ── Form control properties ───────────── 1319 + "value" => { 1320 + // For <select>, return the value of the first selected option. 1321 + // For <input>/<textarea>, return the value attribute. 1322 + if doc.tag_name(node_id) == Some("select") { 1323 + let options = doc.select_options(node_id); 1324 + for opt in &options { 1325 + if opt.is_group_label { 1326 + continue; 1327 + } 1328 + if opt.selected { 1329 + return Some(Value::String(opt.value.clone())); 1330 + } 1331 + } 1332 + // First non-group option as fallback. 1333 + for opt in &options { 1334 + if !opt.is_group_label { 1335 + return Some(Value::String(opt.value.clone())); 1336 + } 1337 + } 1338 + Some(Value::String(String::new())) 1339 + } else if doc.tag_name(node_id) == Some("input") 1340 + || doc.tag_name(node_id) == Some("textarea") 1341 + { 1342 + let val = doc 1343 + .get_attribute(node_id, "value") 1344 + .unwrap_or("") 1345 + .to_string(); 1346 + Some(Value::String(val)) 1347 + } else { 1348 + None 1349 + } 1350 + } 1351 + "selectedIndex" => { 1352 + if doc.tag_name(node_id) != Some("select") { 1353 + return None; 1354 + } 1355 + let idx = doc 1356 + .selected_index(node_id) 1357 + .map(|i| i as f64) 1358 + .unwrap_or(-1.0); 1359 + Some(Value::Number(idx)) 1360 + } 1361 + "options" => { 1362 + if doc.tag_name(node_id) != Some("select") { 1363 + return None; 1364 + } 1365 + let options = doc.select_options(node_id); 1366 + let option_nodes: Vec<NodeId> = options 1367 + .iter() 1368 + .filter(|o| !o.is_group_label) 1369 + .map(|o| o.node) 1370 + .collect(); 1371 + drop(doc); 1372 + Some(make_wrapper_array(&option_nodes, gc, shapes, bridge, None)) 1373 + } 1374 + "selectedOptions" => { 1375 + if doc.tag_name(node_id) != Some("select") { 1376 + return None; 1377 + } 1378 + let options = doc.select_options(node_id); 1379 + let selected_nodes: Vec<NodeId> = options 1380 + .iter() 1381 + .filter(|o| !o.is_group_label && o.selected) 1382 + .map(|o| o.node) 1383 + .collect(); 1384 + drop(doc); 1385 + Some(make_wrapper_array( 1386 + &selected_nodes, 1387 + gc, 1388 + shapes, 1389 + bridge, 1390 + None, 1391 + )) 1392 + } 1393 + "selected" => { 1394 + // HTMLOptionElement.selected 1395 + if doc.tag_name(node_id) != Some("option") { 1396 + return None; 1397 + } 1398 + Some(Value::Boolean( 1399 + doc.get_attribute(node_id, "selected").is_some(), 1400 + )) 1401 + } 1402 + "disabled" => { 1403 + if !matches!( 1404 + doc.tag_name(node_id), 1405 + Some("input" | "select" | "textarea" | "button" | "option" | "optgroup") 1406 + ) { 1407 + return None; 1408 + } 1409 + Some(Value::Boolean( 1410 + doc.get_attribute(node_id, "disabled").is_some(), 1411 + )) 1412 + } 1413 + "checked" => { 1414 + if doc.tag_name(node_id) != Some("input") { 1415 + return None; 1416 + } 1417 + Some(Value::Boolean( 1418 + doc.get_attribute(node_id, "checked").is_some(), 1419 + )) 1420 + } 1421 + "type" => { 1422 + if doc.tag_name(node_id) == Some("input") { 1423 + let t = doc 1424 + .get_attribute(node_id, "type") 1425 + .unwrap_or("text") 1426 + .to_string(); 1427 + Some(Value::String(t)) 1428 + } else if doc.tag_name(node_id) == Some("select") { 1429 + let is_multi = doc.get_attribute(node_id, "multiple").is_some(); 1430 + Some(Value::String( 1431 + if is_multi { 1432 + "select-multiple" 1433 + } else { 1434 + "select-one" 1435 + } 1436 + .to_string(), 1437 + )) 1438 + } else { 1439 + None 1440 + } 1441 + } 1442 + 1318 1443 _ => None, 1319 1444 } 1320 1445 } ··· 1479 1604 .borrow_mut() 1480 1605 .set_attribute(node_id, "class", &class_val); 1481 1606 set_builtin_prop(gc, shapes, gc_ref, "className", Value::String(class_val)); 1607 + true 1608 + } 1609 + "value" => { 1610 + let mut doc = bridge.document.borrow_mut(); 1611 + if doc.tag_name(node_id) == Some("select") { 1612 + // Setting select.value: find the option with matching value and select it. 1613 + let target_val = val.to_js_string(gc); 1614 + let options = doc.select_options(node_id); 1615 + for (i, opt) in options.iter().enumerate() { 1616 + if opt.is_group_label { 1617 + continue; 1618 + } 1619 + if opt.value == target_val { 1620 + doc.select_option_at(node_id, i); 1621 + return true; 1622 + } 1623 + } 1624 + true 1625 + } else if doc.tag_name(node_id) == Some("input") 1626 + || doc.tag_name(node_id) == Some("textarea") 1627 + { 1628 + let v = val.to_js_string(gc); 1629 + doc.set_attribute(node_id, "value", &v); 1630 + true 1631 + } else { 1632 + false 1633 + } 1634 + } 1635 + "selectedIndex" => { 1636 + let mut doc = bridge.document.borrow_mut(); 1637 + if doc.tag_name(node_id) != Some("select") { 1638 + return false; 1639 + } 1640 + if let Value::Number(n) = val { 1641 + let idx = *n as i64; 1642 + if idx < 0 { 1643 + // Deselect all. 1644 + let options = doc.select_options(node_id); 1645 + for opt in &options { 1646 + if !opt.is_group_label { 1647 + doc.remove_attribute(opt.node, "selected"); 1648 + } 1649 + } 1650 + } else { 1651 + // Select the option at the given selectable index. 1652 + let options = doc.select_options(node_id); 1653 + let mut selectable_idx = 0i64; 1654 + for (flat_i, opt) in options.iter().enumerate() { 1655 + if opt.is_group_label { 1656 + continue; 1657 + } 1658 + if selectable_idx == idx { 1659 + doc.select_option_at(node_id, flat_i); 1660 + break; 1661 + } 1662 + selectable_idx += 1; 1663 + } 1664 + } 1665 + true 1666 + } else { 1667 + false 1668 + } 1669 + } 1670 + "selected" => { 1671 + let mut doc = bridge.document.borrow_mut(); 1672 + if doc.tag_name(node_id) != Some("option") { 1673 + return false; 1674 + } 1675 + match val { 1676 + Value::Boolean(true) => { 1677 + doc.set_attribute(node_id, "selected", ""); 1678 + } 1679 + _ => { 1680 + doc.remove_attribute(node_id, "selected"); 1681 + } 1682 + } 1683 + true 1684 + } 1685 + "checked" => { 1686 + let mut doc = bridge.document.borrow_mut(); 1687 + if doc.tag_name(node_id) != Some("input") { 1688 + return false; 1689 + } 1690 + match val { 1691 + Value::Boolean(true) => { 1692 + doc.set_attribute(node_id, "checked", ""); 1693 + } 1694 + _ => { 1695 + doc.remove_attribute(node_id, "checked"); 1696 + } 1697 + } 1698 + true 1699 + } 1700 + "disabled" => { 1701 + let mut doc = bridge.document.borrow_mut(); 1702 + match val { 1703 + Value::Boolean(true) => { 1704 + doc.set_attribute(node_id, "disabled", ""); 1705 + } 1706 + _ => { 1707 + doc.remove_attribute(node_id, "disabled"); 1708 + } 1709 + } 1482 1710 true 1483 1711 } 1484 1712 _ => false, ··· 4492 4720 let taken = vm.take_local_storage().unwrap(); 4493 4721 assert_eq!(taken.get_item("preloaded"), Some("yes")); 4494 4722 assert_eq!(taken.get_item("added"), Some("byjs")); 4723 + } 4724 + 4725 + // --- Select element JS interface tests --- 4726 + 4727 + #[test] 4728 + fn test_select_value() { 4729 + let result = eval_with_doc( 4730 + r#"<html><body> 4731 + <select id="s"> 4732 + <option value="a">Apple</option> 4733 + <option value="b" selected>Banana</option> 4734 + <option value="c">Cherry</option> 4735 + </select> 4736 + </body></html>"#, 4737 + r#"document.getElementById("s").value"#, 4738 + ) 4739 + .unwrap(); 4740 + match result { 4741 + Value::String(s) => assert_eq!(s, "b"), 4742 + v => panic!("expected 'b', got {v:?}"), 4743 + } 4744 + } 4745 + 4746 + #[test] 4747 + fn test_select_selected_index() { 4748 + let result = eval_with_doc( 4749 + r#"<html><body> 4750 + <select id="s"> 4751 + <option>Apple</option> 4752 + <option selected>Banana</option> 4753 + <option>Cherry</option> 4754 + </select> 4755 + </body></html>"#, 4756 + r#"document.getElementById("s").selectedIndex"#, 4757 + ) 4758 + .unwrap(); 4759 + match result { 4760 + Value::Number(n) => assert_eq!(n, 1.0), 4761 + v => panic!("expected 1, got {v:?}"), 4762 + } 4763 + } 4764 + 4765 + #[test] 4766 + fn test_select_options_length() { 4767 + let result = eval_with_doc( 4768 + r#"<html><body> 4769 + <select id="s"> 4770 + <option>A</option> 4771 + <option>B</option> 4772 + <option>C</option> 4773 + </select> 4774 + </body></html>"#, 4775 + r#"document.getElementById("s").options.length"#, 4776 + ) 4777 + .unwrap(); 4778 + match result { 4779 + Value::Number(n) => assert_eq!(n, 3.0), 4780 + v => panic!("expected 3, got {v:?}"), 4781 + } 4782 + } 4783 + 4784 + #[test] 4785 + fn test_select_type() { 4786 + let result = eval_with_doc( 4787 + r#"<html><body><select id="s"><option>A</option></select></body></html>"#, 4788 + r#"document.getElementById("s").type"#, 4789 + ) 4790 + .unwrap(); 4791 + match result { 4792 + Value::String(s) => assert_eq!(s, "select-one"), 4793 + v => panic!("expected 'select-one', got {v:?}"), 4794 + } 4795 + } 4796 + 4797 + #[test] 4798 + fn test_select_multiple_type() { 4799 + let result = eval_with_doc( 4800 + r#"<html><body><select id="s" multiple><option>A</option></select></body></html>"#, 4801 + r#"document.getElementById("s").type"#, 4802 + ) 4803 + .unwrap(); 4804 + match result { 4805 + Value::String(s) => assert_eq!(s, "select-multiple"), 4806 + v => panic!("expected 'select-multiple', got {v:?}"), 4807 + } 4808 + } 4809 + 4810 + #[test] 4811 + fn test_select_default_value() { 4812 + // No selected attribute: defaults to first option. 4813 + let result = eval_with_doc( 4814 + r#"<html><body> 4815 + <select id="s"> 4816 + <option value="first">First</option> 4817 + <option value="second">Second</option> 4818 + </select> 4819 + </body></html>"#, 4820 + r#"document.getElementById("s").value"#, 4821 + ) 4822 + .unwrap(); 4823 + match result { 4824 + Value::String(s) => assert_eq!(s, "first"), 4825 + v => panic!("expected 'first', got {v:?}"), 4826 + } 4827 + } 4828 + 4829 + #[test] 4830 + fn test_option_selected_property() { 4831 + let result = eval_with_doc( 4832 + r#"<html><body> 4833 + <select id="s"> 4834 + <option id="a">A</option> 4835 + <option id="b" selected>B</option> 4836 + </select> 4837 + </body></html>"#, 4838 + r#"document.getElementById("b").selected"#, 4839 + ) 4840 + .unwrap(); 4841 + match result { 4842 + Value::Boolean(b) => assert!(b), 4843 + v => panic!("expected true, got {v:?}"), 4844 + } 4495 4845 } 4496 4846 }
+103 -2
crates/layout/src/lib.rs
··· 90 90 Select, 91 91 } 92 92 93 + /// Information about a single option in a `<select>` dropdown, for rendering. 94 + #[derive(Debug, Clone)] 95 + pub struct SelectOption { 96 + /// The display text. 97 + pub text: String, 98 + /// Whether this option is disabled. 99 + pub disabled: bool, 100 + /// Whether this option is currently selected. 101 + pub selected: bool, 102 + /// True if this is an optgroup label (not selectable). 103 + pub is_group_label: bool, 104 + } 105 + 93 106 /// Information about a form control needed for rendering. 94 107 /// 95 108 /// Populated during layout tree construction and consumed by the renderer ··· 111 124 pub cursor: usize, 112 125 /// Selection anchor byte offset (equal to cursor if no selection). 113 126 pub selection_anchor: usize, 127 + /// Whether the select dropdown menu is open. 128 + pub dropdown_open: bool, 129 + /// Flat list of options for `<select>` rendering. 130 + pub options: Vec<SelectOption>, 131 + /// Index of the highlighted option in the dropdown. 132 + pub highlighted_index: Option<usize>, 133 + /// Whether this is a `<select multiple>`. 134 + pub multiple: bool, 135 + /// The `size` attribute value for `<select multiple>`. 136 + pub visible_size: usize, 114 137 } 115 138 116 139 /// A box in the layout tree with dimensions and child boxes. ··· 590 613 Some((width, height)) 591 614 } 592 615 "select" => { 616 + let is_multiple = doc.get_attribute(node, "multiple").is_some(); 617 + let size: usize = doc 618 + .get_attribute(node, "size") 619 + .and_then(|s| s.parse().ok()) 620 + .unwrap_or(if is_multiple { 4 } else { 1 }); 593 621 // Width: fit the longest <option> text, or a default. 594 622 let mut max_len: usize = 0; 595 623 let mut child = doc.first_child(node); ··· 622 650 max_len = 10; // default minimum width 623 651 } 624 652 let char_width = font_size * 0.6; 625 - let dropdown_arrow = 20.0; // space for dropdown arrow 653 + let dropdown_arrow = if is_multiple { 0.0 } else { 20.0 }; 626 654 let padding = FORM_CONTROL_PADDING_LR; 627 655 let border = FORM_CONTROL_BORDER; 628 656 let width = max_len as f32 * char_width + dropdown_arrow + padding * 2.0 + border * 2.0; 629 - let height = font_size + FORM_CONTROL_PADDING_TB * 2.0 + border * 2.0; 657 + let line_height = font_size * 1.2; 658 + let height = if is_multiple || size > 1 { 659 + // Multiple select or explicit size: show `size` rows. 660 + size as f32 * line_height + FORM_CONTROL_PADDING_TB * 2.0 + border * 2.0 661 + } else { 662 + font_size + FORM_CONTROL_PADDING_TB * 2.0 + border * 2.0 663 + }; 630 664 Some((width, height)) 631 665 } 632 666 "button" => { ··· 674 708 indeterminate: doc.is_indeterminate(node), 675 709 cursor: 0, 676 710 selection_anchor: 0, 711 + dropdown_open: false, 712 + options: Vec::new(), 713 + highlighted_index: None, 714 + multiple: false, 715 + visible_size: 0, 677 716 }), 678 717 "radio" => Some(FormControlInfo { 679 718 control_type: FormControlType::Radio, ··· 684 723 indeterminate: false, 685 724 cursor: 0, 686 725 selection_anchor: 0, 726 + dropdown_open: false, 727 + options: Vec::new(), 728 + highlighted_index: None, 729 + multiple: false, 730 + visible_size: 0, 687 731 }), 688 732 "submit" => { 689 733 let value = doc ··· 699 743 indeterminate: false, 700 744 cursor: 0, 701 745 selection_anchor: 0, 746 + dropdown_open: false, 747 + options: Vec::new(), 748 + highlighted_index: None, 749 + multiple: false, 750 + visible_size: 0, 702 751 }) 703 752 } 704 753 "reset" => { ··· 715 764 indeterminate: false, 716 765 cursor: 0, 717 766 selection_anchor: 0, 767 + dropdown_open: false, 768 + options: Vec::new(), 769 + highlighted_index: None, 770 + multiple: false, 771 + visible_size: 0, 718 772 }) 719 773 } 720 774 "button" => { ··· 728 782 indeterminate: false, 729 783 cursor: 0, 730 784 selection_anchor: 0, 785 + dropdown_open: false, 786 + options: Vec::new(), 787 + highlighted_index: None, 788 + multiple: false, 789 + visible_size: 0, 731 790 }) 732 791 } 733 792 "password" => { ··· 747 806 indeterminate: false, 748 807 cursor, 749 808 selection_anchor: anchor, 809 + dropdown_open: false, 810 + options: Vec::new(), 811 + highlighted_index: None, 812 + multiple: false, 813 + visible_size: 0, 750 814 }) 751 815 } 752 816 // text, email, url, search, tel, number, etc. ··· 767 831 indeterminate: false, 768 832 cursor, 769 833 selection_anchor: anchor, 834 + dropdown_open: false, 835 + options: Vec::new(), 836 + highlighted_index: None, 837 + multiple: false, 838 + visible_size: 0, 770 839 }) 771 840 } 772 841 } ··· 789 858 indeterminate: false, 790 859 cursor, 791 860 selection_anchor: anchor, 861 + dropdown_open: false, 862 + options: Vec::new(), 863 + highlighted_index: None, 864 + multiple: false, 865 + visible_size: 0, 792 866 }) 793 867 } 794 868 "select" => { 795 869 let disabled = doc.get_attribute(node, "disabled").is_some(); 870 + let is_multiple = doc.get_attribute(node, "multiple").is_some(); 871 + let size: usize = doc 872 + .get_attribute(node, "size") 873 + .and_then(|s| s.parse().ok()) 874 + .unwrap_or(if is_multiple { 4 } else { 1 }); 796 875 // Find the selected or first option's text. 797 876 let value = first_selected_option_text(doc, node); 877 + let dropdown_open = doc.is_select_open(node); 878 + let highlighted_index = doc.select_highlighted_index(node); 879 + let dom_options = doc.select_options(node); 880 + let options: Vec<SelectOption> = dom_options 881 + .iter() 882 + .map(|o| SelectOption { 883 + text: o.text.clone(), 884 + disabled: o.disabled, 885 + selected: o.selected, 886 + is_group_label: o.is_group_label, 887 + }) 888 + .collect(); 798 889 Some(FormControlInfo { 799 890 control_type: FormControlType::Select, 800 891 value, ··· 804 895 indeterminate: false, 805 896 cursor: 0, 806 897 selection_anchor: 0, 898 + dropdown_open, 899 + options, 900 + highlighted_index, 901 + multiple: is_multiple, 902 + visible_size: size, 807 903 }) 808 904 } 809 905 "button" => { ··· 823 919 indeterminate: false, 824 920 cursor: 0, 825 921 selection_anchor: 0, 922 + dropdown_open: false, 923 + options: Vec::new(), 924 + highlighted_index: None, 925 + multiple: false, 926 + visible_size: 0, 826 927 }) 827 928 } 828 929 _ => None,
+426 -42
crates/render/src/lib.rs
··· 13 13 use we_dom::NodeId; 14 14 use we_image::pixel::Image; 15 15 use we_layout::{ 16 - BoxType, FormControlInfo, FormControlType, LayoutBox, LayoutTree, Rect, TextLine, 16 + BoxType, FormControlInfo, FormControlType, LayoutBox, LayoutTree, Rect, SelectOption, TextLine, 17 17 SCROLLBAR_WIDTH, 18 18 }; 19 19 use we_platform::metal::{ClearColor, CommandQueue, Device, MetalLayer}; ··· 92 92 /// A flat list of paint commands in painter's order. 93 93 pub type DisplayList = Vec<PaintCommand>; 94 94 95 + /// Pending dropdown overlay collected during the main paint pass. 96 + struct PendingDropdown { 97 + /// Screen position of the select control. 98 + x: f32, 99 + y: f32, 100 + width: f32, 101 + height: f32, 102 + font_size: f32, 103 + options: Vec<SelectOption>, 104 + highlighted_index: Option<usize>, 105 + } 106 + 95 107 /// Build a display list from a layout tree. 96 108 /// 97 109 /// Walks the tree in depth-first pre-order (painter's order): ··· 109 121 scroll_state: &ScrollState, 110 122 ) -> DisplayList { 111 123 let mut list = DisplayList::new(); 112 - paint_box(&tree.root, &mut list, (0.0, 0.0), scroll_state, 0.0); 124 + let mut dropdowns = Vec::new(); 125 + paint_box( 126 + &tree.root, 127 + &mut list, 128 + (0.0, 0.0), 129 + scroll_state, 130 + 0.0, 131 + &mut dropdowns, 132 + ); 133 + for dd in &dropdowns { 134 + paint_dropdown_overlay(dd, &mut list); 135 + } 113 136 list 114 137 } 115 138 ··· 122 145 scroll_state: &ScrollState, 123 146 ) -> DisplayList { 124 147 let mut list = DisplayList::new(); 148 + let mut dropdowns = Vec::new(); 125 149 paint_box( 126 150 &tree.root, 127 151 &mut list, 128 152 (0.0, -page_scroll_y), 129 153 scroll_state, 130 154 0.0, 155 + &mut dropdowns, 131 156 ); 157 + for dd in &dropdowns { 158 + paint_dropdown_overlay(dd, &mut list); 159 + } 132 160 list 133 161 } 134 162 ··· 143 171 list: &mut DisplayList, 144 172 ) { 145 173 list.clear(); 146 - paint_box(&tree.root, list, (0.0, -page_scroll_y), scroll_state, 0.0); 174 + let mut dropdowns = Vec::new(); 175 + paint_box( 176 + &tree.root, 177 + list, 178 + (0.0, -page_scroll_y), 179 + scroll_state, 180 + 0.0, 181 + &mut dropdowns, 182 + ); 183 + for dd in &dropdowns { 184 + paint_dropdown_overlay(dd, list); 185 + } 186 + } 187 + 188 + /// Compute the dropdown menu geometry for an open single-select. 189 + /// 190 + /// Given the select control's screen-space border box and its options, 191 + /// returns `(menu_x, menu_y, menu_width, menu_height, line_height)`. 192 + pub fn dropdown_menu_geometry( 193 + select_x: f32, 194 + select_y: f32, 195 + select_w: f32, 196 + select_h: f32, 197 + font_size: f32, 198 + option_count: usize, 199 + ) -> (f32, f32, f32, f32, f32) { 200 + let line_height = font_size * 1.2; 201 + let padding = 4.0f32; 202 + let border = 1.0f32; 203 + let menu_height = option_count as f32 * line_height + padding * 2.0 + border * 2.0; 204 + let menu_width = select_w.max(100.0); 205 + let menu_x = select_x; 206 + let menu_y = select_y + select_h; 207 + (menu_x, menu_y, menu_width, menu_height, line_height) 147 208 } 148 209 149 210 /// Returns `true` if a box is positioned (absolute or fixed). ··· 170 231 translate: (f32, f32), 171 232 scroll_state: &ScrollState, 172 233 sticky_ref_screen_y: f32, 234 + dropdowns: &mut Vec<PendingDropdown>, 173 235 ) { 174 236 let visible = layout_box.visibility == Visibility::Visible; 175 237 let tx = translate.0; ··· 192 254 if let Some(ref fc) = layout_box.form_control { 193 255 // Form controls paint their own background, borders, and content. 194 256 paint_form_control(layout_box, fc, list, tx, ty); 257 + // Collect open dropdown overlays for painting after the main pass. 258 + if fc.control_type == FormControlType::Select && fc.dropdown_open { 259 + let bb = border_box(layout_box); 260 + dropdowns.push(PendingDropdown { 261 + x: bb.x + tx, 262 + y: bb.y + ty, 263 + width: bb.width, 264 + height: bb.height, 265 + font_size: layout_box.font_size, 266 + options: fc.options.clone(), 267 + highlighted_index: fc.highlighted_index, 268 + }); 269 + } 195 270 } else { 196 271 paint_background(layout_box, list, tx, ty); 197 272 paint_borders(layout_box, list, tx, ty); ··· 281 356 child_translate, 282 357 scroll_state, 283 358 child_sticky_ref, 359 + dropdowns, 284 360 ); 285 361 } 286 362 287 363 // Paint in-flow children in tree order. 288 364 for child in &layout_box.children { 289 365 if !is_positioned(child) { 290 - paint_child(child, list, child_translate, scroll_state, child_sticky_ref); 366 + paint_child( 367 + child, 368 + list, 369 + child_translate, 370 + scroll_state, 371 + child_sticky_ref, 372 + dropdowns, 373 + ); 291 374 } 292 375 } 293 376 ··· 299 382 child_translate, 300 383 scroll_state, 301 384 child_sticky_ref, 385 + dropdowns, 302 386 ); 303 387 } 304 388 } else { 305 389 // No positioned children — paint all in tree order. 306 390 for child in &layout_box.children { 307 - paint_child(child, list, child_translate, scroll_state, child_sticky_ref); 391 + paint_child( 392 + child, 393 + list, 394 + child_translate, 395 + scroll_state, 396 + child_sticky_ref, 397 + dropdowns, 398 + ); 308 399 } 309 400 } 310 401 ··· 329 420 child_translate: (f32, f32), 330 421 scroll_state: &ScrollState, 331 422 sticky_ref_screen_y: f32, 423 + dropdowns: &mut Vec<PendingDropdown>, 332 424 ) { 333 425 if child.position == Position::Sticky { 334 426 let adjusted = compute_sticky_translate(child, child_translate, sticky_ref_screen_y); 335 - paint_box(child, list, adjusted, scroll_state, sticky_ref_screen_y); 427 + paint_box( 428 + child, 429 + list, 430 + adjusted, 431 + scroll_state, 432 + sticky_ref_screen_y, 433 + dropdowns, 434 + ); 336 435 } else { 337 436 paint_box( 338 437 child, ··· 340 439 child_translate, 341 440 scroll_state, 342 441 sticky_ref_screen_y, 442 + dropdowns, 343 443 ); 344 444 } 345 445 } ··· 720 820 b: 0, 721 821 a: 255, 722 822 }; 823 + /// Dropdown menu background. 824 + const FC_DROPDOWN_BG: Color = Color { 825 + r: 255, 826 + g: 255, 827 + b: 255, 828 + a: 255, 829 + }; 830 + /// Dropdown highlighted option background. 831 + const FC_DROPDOWN_HIGHLIGHT: Color = Color { 832 + r: 0, 833 + g: 95, 834 + b: 204, 835 + a: 255, 836 + }; 837 + /// Dropdown highlighted option text color. 838 + const FC_DROPDOWN_HIGHLIGHT_TEXT: Color = Color { 839 + r: 255, 840 + g: 255, 841 + b: 255, 842 + a: 255, 843 + }; 844 + /// Dropdown border color. 845 + const FC_DROPDOWN_BORDER: Color = Color { 846 + r: 180, 847 + g: 180, 848 + b: 180, 849 + a: 255, 850 + }; 851 + /// Dropdown shadow color (semi-transparent). 852 + const FC_DROPDOWN_SHADOW: Color = Color { 853 + r: 0, 854 + g: 0, 855 + b: 0, 856 + a: 40, 857 + }; 858 + /// Optgroup label text color. 859 + const FC_OPTGROUP_TEXT: Color = Color { 860 + r: 100, 861 + g: 100, 862 + b: 100, 863 + a: 255, 864 + }; 723 865 724 866 /// Paint a form control with native-style appearance. 725 867 fn paint_form_control( ··· 1264 1406 } 1265 1407 } 1266 1408 1267 - /// Paint a select dropdown: bordered rectangle with selected text and dropdown arrow. 1409 + /// Paint a select control: either a dropdown button (single) or a listbox (multiple). 1268 1410 fn paint_select( 1411 + layout_box: &LayoutBox, 1412 + fc: &FormControlInfo, 1413 + list: &mut DisplayList, 1414 + tx: f32, 1415 + ty: f32, 1416 + ) { 1417 + if fc.multiple { 1418 + paint_select_listbox(layout_box, fc, list, tx, ty); 1419 + } else { 1420 + paint_select_button(layout_box, fc, list, tx, ty); 1421 + } 1422 + } 1423 + 1424 + /// Paint a single-select dropdown button: bordered rectangle with selected text and arrow. 1425 + fn paint_select_button( 1269 1426 layout_box: &LayoutBox, 1270 1427 fc: &FormControlInfo, 1271 1428 list: &mut DisplayList, ··· 1299 1456 }); 1300 1457 1301 1458 // Border 1302 - let border = 1.0f32; 1303 - let bc = FC_BORDER_COLOR; 1304 - // Top 1305 - list.push(PaintCommand::FillRect { 1306 - x, 1307 - y, 1308 - width: w, 1309 - height: border, 1310 - color: bc, 1311 - }); 1312 - // Bottom 1313 - list.push(PaintCommand::FillRect { 1314 - x, 1315 - y: y + h - border, 1316 - width: w, 1317 - height: border, 1318 - color: bc, 1319 - }); 1320 - // Left 1321 - list.push(PaintCommand::FillRect { 1322 - x, 1323 - y, 1324 - width: border, 1325 - height: h, 1326 - color: bc, 1327 - }); 1328 - // Right 1329 - list.push(PaintCommand::FillRect { 1330 - x: x + w - border, 1331 - y, 1332 - width: border, 1333 - height: h, 1334 - color: bc, 1335 - }); 1459 + paint_rect_border(list, x, y, w, h, 1.0, FC_BORDER_COLOR); 1336 1460 1337 1461 // Selected option text 1338 1462 let font_size = layout_box.font_size; ··· 1364 1488 } else { 1365 1489 FC_TEXT_COLOR 1366 1490 }; 1367 - // Approximate downward triangle with progressively narrower rects. 1368 1491 let rows = (arrow_size as i32).max(3); 1369 1492 for i in 0..rows { 1370 1493 let t = i as f32 / rows as f32; ··· 1377 1500 height: 1.0, 1378 1501 color: arrow_color, 1379 1502 }); 1503 + } 1504 + } 1505 + 1506 + /// Paint a multiple-select listbox: bordered white box showing visible options. 1507 + fn paint_select_listbox( 1508 + layout_box: &LayoutBox, 1509 + fc: &FormControlInfo, 1510 + list: &mut DisplayList, 1511 + tx: f32, 1512 + ty: f32, 1513 + ) { 1514 + let bb = border_box(layout_box); 1515 + let x = bb.x + tx; 1516 + let y = bb.y + ty; 1517 + let w = bb.width; 1518 + let h = bb.height; 1519 + 1520 + let bg = if fc.disabled { 1521 + FC_DISABLED_BG 1522 + } else { 1523 + FC_BG_COLOR 1524 + }; 1525 + let text_color = if fc.disabled { 1526 + FC_DISABLED_TEXT 1527 + } else { 1528 + FC_TEXT_COLOR 1529 + }; 1530 + 1531 + // Background 1532 + list.push(PaintCommand::FillRect { 1533 + x, 1534 + y, 1535 + width: w, 1536 + height: h, 1537 + color: bg, 1538 + }); 1539 + 1540 + // Border 1541 + paint_rect_border(list, x, y, w, h, 1.0, FC_BORDER_COLOR); 1542 + 1543 + // Paint visible options 1544 + let font_size = layout_box.font_size; 1545 + let line_height = font_size * 1.2; 1546 + let padding = 2.0f32; 1547 + let mut oy = y + 1.0 + padding; 1548 + 1549 + for (i, opt) in fc.options.iter().enumerate() { 1550 + if oy + line_height > y + h - 1.0 { 1551 + break; // Clipped 1552 + } 1553 + 1554 + let opt_text_color = if opt.disabled { 1555 + FC_DISABLED_TEXT 1556 + } else if opt.selected { 1557 + FC_DROPDOWN_HIGHLIGHT_TEXT 1558 + } else { 1559 + text_color 1560 + }; 1561 + 1562 + // Highlight selected options 1563 + if opt.selected && !opt.is_group_label { 1564 + list.push(PaintCommand::FillRect { 1565 + x: x + 1.0, 1566 + y: oy, 1567 + width: w - 2.0, 1568 + height: line_height, 1569 + color: FC_DROPDOWN_HIGHLIGHT, 1570 + }); 1571 + } 1572 + 1573 + // Highlighted (keyboard-navigated) option 1574 + if fc.highlighted_index == Some(i) && !opt.is_group_label { 1575 + paint_rect_border( 1576 + list, 1577 + x + 1.0, 1578 + oy, 1579 + w - 2.0, 1580 + line_height, 1581 + 1.0, 1582 + FC_FOCUS_RING_COLOR, 1583 + ); 1584 + } 1585 + 1586 + let indent = if opt.is_group_label { 0.0 } else { 4.0 }; 1587 + let opt_color = if opt.is_group_label { 1588 + FC_OPTGROUP_TEXT 1589 + } else { 1590 + opt_text_color 1591 + }; 1592 + 1593 + list.push(PaintCommand::DrawGlyphs { 1594 + line: TextLine { 1595 + text: opt.text.clone(), 1596 + x: x + padding + indent + 1.0, 1597 + y: oy, 1598 + width: w - padding * 2.0 - indent - 2.0, 1599 + font_size: if opt.is_group_label { 1600 + font_size * 0.9 1601 + } else { 1602 + font_size 1603 + }, 1604 + color: opt_color, 1605 + text_decoration: TextDecoration::None, 1606 + background_color: Color::new(0, 0, 0, 0), 1607 + }, 1608 + font_size: if opt.is_group_label { 1609 + font_size * 0.9 1610 + } else { 1611 + font_size 1612 + }, 1613 + color: opt_color, 1614 + }); 1615 + 1616 + oy += line_height; 1617 + } 1618 + } 1619 + 1620 + /// Paint a simple rectangular border (4 sides). 1621 + fn paint_rect_border( 1622 + list: &mut DisplayList, 1623 + x: f32, 1624 + y: f32, 1625 + w: f32, 1626 + h: f32, 1627 + border: f32, 1628 + color: Color, 1629 + ) { 1630 + // Top 1631 + list.push(PaintCommand::FillRect { 1632 + x, 1633 + y, 1634 + width: w, 1635 + height: border, 1636 + color, 1637 + }); 1638 + // Bottom 1639 + list.push(PaintCommand::FillRect { 1640 + x, 1641 + y: y + h - border, 1642 + width: w, 1643 + height: border, 1644 + color, 1645 + }); 1646 + // Left 1647 + list.push(PaintCommand::FillRect { 1648 + x, 1649 + y, 1650 + width: border, 1651 + height: h, 1652 + color, 1653 + }); 1654 + // Right 1655 + list.push(PaintCommand::FillRect { 1656 + x: x + w - border, 1657 + y, 1658 + width: border, 1659 + height: h, 1660 + color, 1661 + }); 1662 + } 1663 + 1664 + /// Paint a dropdown overlay popup for an open single-select. 1665 + /// Called after the main paint pass so it renders on top of all content. 1666 + fn paint_dropdown_overlay(dd: &PendingDropdown, list: &mut DisplayList) { 1667 + let font_size = dd.font_size; 1668 + let line_height = font_size * 1.2; 1669 + let padding = 4.0f32; 1670 + let border = 1.0f32; 1671 + 1672 + let option_count = dd.options.len(); 1673 + let menu_height = option_count as f32 * line_height + padding * 2.0 + border * 2.0; 1674 + let menu_width = dd.width.max(100.0); 1675 + 1676 + // Position below the select control. 1677 + let menu_x = dd.x; 1678 + let menu_y = dd.y + dd.height; 1679 + 1680 + // Shadow (offset 2px down, slightly larger) 1681 + list.push(PaintCommand::FillRect { 1682 + x: menu_x + 2.0, 1683 + y: menu_y + 2.0, 1684 + width: menu_width, 1685 + height: menu_height, 1686 + color: FC_DROPDOWN_SHADOW, 1687 + }); 1688 + 1689 + // Background 1690 + list.push(PaintCommand::FillRect { 1691 + x: menu_x, 1692 + y: menu_y, 1693 + width: menu_width, 1694 + height: menu_height, 1695 + color: FC_DROPDOWN_BG, 1696 + }); 1697 + 1698 + // Border 1699 + paint_rect_border( 1700 + list, 1701 + menu_x, 1702 + menu_y, 1703 + menu_width, 1704 + menu_height, 1705 + border, 1706 + FC_DROPDOWN_BORDER, 1707 + ); 1708 + 1709 + // Paint options 1710 + let mut oy = menu_y + border + padding; 1711 + for (i, opt) in dd.options.iter().enumerate() { 1712 + let is_highlighted = dd.highlighted_index == Some(i); 1713 + 1714 + // Highlight background 1715 + if is_highlighted && !opt.is_group_label && !opt.disabled { 1716 + list.push(PaintCommand::FillRect { 1717 + x: menu_x + border, 1718 + y: oy, 1719 + width: menu_width - border * 2.0, 1720 + height: line_height, 1721 + color: FC_DROPDOWN_HIGHLIGHT, 1722 + }); 1723 + } 1724 + 1725 + let text_color = if opt.is_group_label { 1726 + FC_OPTGROUP_TEXT 1727 + } else if opt.disabled { 1728 + FC_DISABLED_TEXT 1729 + } else if is_highlighted { 1730 + FC_DROPDOWN_HIGHLIGHT_TEXT 1731 + } else { 1732 + FC_TEXT_COLOR 1733 + }; 1734 + 1735 + let indent = if opt.is_group_label { 1736 + padding 1737 + } else { 1738 + // Indent options within optgroups a bit more. 1739 + padding + 8.0 1740 + }; 1741 + 1742 + let opt_font_size = if opt.is_group_label { 1743 + font_size * 0.85 1744 + } else { 1745 + font_size 1746 + }; 1747 + 1748 + list.push(PaintCommand::DrawGlyphs { 1749 + line: TextLine { 1750 + text: opt.text.clone(), 1751 + x: menu_x + indent, 1752 + y: oy, 1753 + width: menu_width - indent - padding, 1754 + font_size: opt_font_size, 1755 + color: text_color, 1756 + text_decoration: TextDecoration::None, 1757 + background_color: Color::new(0, 0, 0, 0), 1758 + }, 1759 + font_size: opt_font_size, 1760 + color: text_color, 1761 + }); 1762 + 1763 + oy += line_height; 1380 1764 } 1381 1765 } 1382 1766