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 form control rendering (Phase 16)

Paint native-style form controls into the rendering pipeline. Each form
control type gets a distinguishable visual appearance rendered using
existing FillRect and DrawGlyphs paint commands.

Layout crate changes:
- Add FormControlInfo struct and FormControlType enum to carry control
type, value text, checked/disabled state through the layout tree
- Populate form_control field during layout tree construction from DOM
attributes (tag name, type, value, checked, disabled, selected)
- Fix inline replaced element positioning: update child rects after
inline layout so atomic inline boxes (images, form controls) have
correct positions and dimensions

Render crate changes:
- Paint text inputs with inset border, white background, value text
- Paint password fields with bullet character masking
- Paint checkboxes as bordered squares with diagonal checkmark when checked
- Paint radio buttons as circles (scanline approximation) with filled
dot when selected
- Paint buttons (submit/reset/button) with raised border and centered
label text
- Paint select dropdowns with border, selected option text, and
triangular dropdown arrow indicator
- Paint textarea with same style as text input
- Render disabled controls with dimmed background and text colors

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

+1494 -15
+429
crates/layout/src/lib.rs
··· 67 67 pub background_color: Color, 68 68 } 69 69 70 + /// The type of form control for rendering purposes. 71 + #[derive(Debug, Clone, PartialEq)] 72 + pub enum FormControlType { 73 + /// `<input type="text">` and similar text-like inputs (email, url, search, tel, number). 74 + TextInput, 75 + /// `<input type="password">`. 76 + Password, 77 + /// `<input type="checkbox">`. 78 + Checkbox, 79 + /// `<input type="radio">`. 80 + Radio, 81 + /// `<input type="submit">`. 82 + Submit, 83 + /// `<input type="reset">`. 84 + Reset, 85 + /// `<input type="button">` or `<button>`. 86 + Button, 87 + /// `<textarea>`. 88 + Textarea, 89 + /// `<select>`. 90 + Select, 91 + } 92 + 93 + /// Information about a form control needed for rendering. 94 + /// 95 + /// Populated during layout tree construction and consumed by the renderer 96 + /// to paint native-style form control appearances. 97 + #[derive(Debug, Clone)] 98 + pub struct FormControlInfo { 99 + pub control_type: FormControlType, 100 + /// Display text (input value, button label, or selected option text). 101 + pub value: String, 102 + /// Whether the control is checked (checkboxes, radio buttons). 103 + pub checked: bool, 104 + /// Whether the control is disabled. 105 + pub disabled: bool, 106 + } 107 + 70 108 /// A box in the layout tree with dimensions and child boxes. 71 109 #[derive(Debug)] 72 110 pub struct LayoutBox { ··· 96 134 pub line_height: f32, 97 135 /// For replaced elements (e.g., `<img>`): content dimensions (width, height). 98 136 pub replaced_size: Option<(f32, f32)>, 137 + /// Form control rendering information (type, value, state). 138 + pub form_control: Option<FormControlInfo>, 99 139 /// CSS `position` property. 100 140 pub position: Position, 101 141 /// CSS `z-index` property (None = auto). ··· 205 245 text_align: style.text_align, 206 246 line_height: style.line_height, 207 247 replaced_size: None, 248 + form_control: None, 208 249 position: style.position, 209 250 z_index: style.z_index, 210 251 relative_offset: (0.0, 0.0), ··· 601 642 } 602 643 } 603 644 645 + /// Build `FormControlInfo` from the DOM for a form control element. 646 + /// 647 + /// Returns `None` for non-form-control elements and hidden inputs. 648 + fn build_form_control_info(node: NodeId, doc: &Document) -> Option<FormControlInfo> { 649 + let tag = doc.tag_name(node)?; 650 + match tag { 651 + "input" => { 652 + let input_type = doc.get_attribute(node, "type").unwrap_or("text"); 653 + let disabled = doc.get_attribute(node, "disabled").is_some(); 654 + let checked = doc.get_attribute(node, "checked").is_some(); 655 + match input_type { 656 + "hidden" => None, 657 + "checkbox" => Some(FormControlInfo { 658 + control_type: FormControlType::Checkbox, 659 + value: String::new(), 660 + checked, 661 + disabled, 662 + }), 663 + "radio" => Some(FormControlInfo { 664 + control_type: FormControlType::Radio, 665 + value: String::new(), 666 + checked, 667 + disabled, 668 + }), 669 + "submit" => { 670 + let value = doc 671 + .get_attribute(node, "value") 672 + .unwrap_or("Submit") 673 + .to_string(); 674 + Some(FormControlInfo { 675 + control_type: FormControlType::Submit, 676 + value, 677 + checked: false, 678 + disabled, 679 + }) 680 + } 681 + "reset" => { 682 + let value = doc 683 + .get_attribute(node, "value") 684 + .unwrap_or("Reset") 685 + .to_string(); 686 + Some(FormControlInfo { 687 + control_type: FormControlType::Reset, 688 + value, 689 + checked: false, 690 + disabled, 691 + }) 692 + } 693 + "button" => { 694 + let value = doc.get_attribute(node, "value").unwrap_or("").to_string(); 695 + Some(FormControlInfo { 696 + control_type: FormControlType::Button, 697 + value, 698 + checked: false, 699 + disabled, 700 + }) 701 + } 702 + "password" => { 703 + let value = doc.get_attribute(node, "value").unwrap_or("").to_string(); 704 + Some(FormControlInfo { 705 + control_type: FormControlType::Password, 706 + value, 707 + checked: false, 708 + disabled, 709 + }) 710 + } 711 + // text, email, url, search, tel, number, etc. 712 + _ => { 713 + let value = doc.get_attribute(node, "value").unwrap_or("").to_string(); 714 + Some(FormControlInfo { 715 + control_type: FormControlType::TextInput, 716 + value, 717 + checked: false, 718 + disabled, 719 + }) 720 + } 721 + } 722 + } 723 + "textarea" => { 724 + let disabled = doc.get_attribute(node, "disabled").is_some(); 725 + let value = collect_text_content(doc, node); 726 + Some(FormControlInfo { 727 + control_type: FormControlType::Textarea, 728 + value, 729 + checked: false, 730 + disabled, 731 + }) 732 + } 733 + "select" => { 734 + let disabled = doc.get_attribute(node, "disabled").is_some(); 735 + // Find the selected or first option's text. 736 + let value = first_selected_option_text(doc, node); 737 + Some(FormControlInfo { 738 + control_type: FormControlType::Select, 739 + value, 740 + checked: false, 741 + disabled, 742 + }) 743 + } 744 + "button" => { 745 + let disabled = doc.get_attribute(node, "disabled").is_some(); 746 + let text = collect_text_content(doc, node); 747 + let value = if text.trim().is_empty() { 748 + "Submit".to_string() 749 + } else { 750 + text.trim().to_string() 751 + }; 752 + Some(FormControlInfo { 753 + control_type: FormControlType::Button, 754 + value, 755 + checked: false, 756 + disabled, 757 + }) 758 + } 759 + _ => None, 760 + } 761 + } 762 + 763 + /// Get the display text for the first selected (or first) `<option>` in a `<select>`. 764 + fn first_selected_option_text(doc: &Document, select: NodeId) -> String { 765 + let mut first_option_text: Option<String> = None; 766 + let mut child = doc.first_child(select); 767 + while let Some(c) = child { 768 + let result = match doc.tag_name(c) { 769 + Some("option") => scan_option(doc, c, &mut first_option_text), 770 + Some("optgroup") => { 771 + let mut opt = doc.first_child(c); 772 + let mut found = None; 773 + while let Some(o) = opt { 774 + if doc.tag_name(o) == Some("option") { 775 + if let Some(text) = scan_option(doc, o, &mut first_option_text) { 776 + found = Some(text); 777 + break; 778 + } 779 + } 780 + opt = doc.next_sibling(o); 781 + } 782 + found 783 + } 784 + _ => None, 785 + }; 786 + if let Some(text) = result { 787 + return text; 788 + } 789 + child = doc.next_sibling(c); 790 + } 791 + first_option_text.unwrap_or_default() 792 + } 793 + 794 + /// Check if an `<option>` is selected. Returns `Some(text)` if selected, 795 + /// and records the first option's text as a fallback. 796 + fn scan_option( 797 + doc: &Document, 798 + option: NodeId, 799 + first_option_text: &mut Option<String>, 800 + ) -> Option<String> { 801 + let text = collect_text_content(doc, option).trim().to_string(); 802 + if first_option_text.is_none() { 803 + *first_option_text = Some(text.clone()); 804 + } 805 + if doc.get_attribute(option, "selected").is_some() { 806 + Some(text) 807 + } else { 808 + None 809 + } 810 + } 811 + 604 812 /// Get the text length of an <option> element's content. 605 813 fn option_text_length(doc: &Document, option_node: NodeId) -> usize { 606 814 let mut len = 0; ··· 756 964 b.replaced_size = Some(size); 757 965 } 758 966 } 967 + 968 + // Populate form control rendering info. 969 + b.form_control = build_form_control_info(node, doc); 759 970 760 971 // Relative position offsets are resolved in compute_layout 761 972 // where the containing block dimensions are known. ··· 3919 4130 let total_height: f32 = all_lines.iter().map(|(_, _, _, h)| h).sum(); 3920 4131 parent.rect.height = total_height; 3921 4132 parent.lines = text_lines; 4133 + 4134 + // Update child rects for inline replaced elements (images, form controls). 4135 + // The inline layout positions them as placeholder fragments in the parent's 4136 + // text lines, but the child LayoutBox rects are not set. Walk the tree and 4137 + // match each replaced child to its placeholder line. 4138 + update_replaced_child_rects(parent); 4139 + } 4140 + 4141 + /// After inline layout, update the rects of children with `replaced_size` 4142 + /// to match the positions computed during line layout. Placeholder fragments 4143 + /// are identified as text lines with empty text and zero-alpha color. 4144 + fn update_replaced_child_rects(parent: &mut LayoutBox) { 4145 + let placeholder_positions: Vec<(f32, f32, f32)> = parent 4146 + .lines 4147 + .iter() 4148 + .filter(|line| line.text.is_empty() && line.color.a == 0) 4149 + .map(|line| (line.x, line.y, line.width)) 4150 + .collect(); 4151 + 4152 + if placeholder_positions.is_empty() { 4153 + return; 4154 + } 4155 + 4156 + let mut pos_idx = 0; 4157 + update_replaced_rects_recursive(&mut parent.children, &placeholder_positions, &mut pos_idx); 4158 + } 4159 + 4160 + fn update_replaced_rects_recursive( 4161 + children: &mut [LayoutBox], 4162 + positions: &[(f32, f32, f32)], 4163 + pos_idx: &mut usize, 4164 + ) { 4165 + for child in children.iter_mut() { 4166 + if let Some((w, h)) = child.replaced_size { 4167 + if *pos_idx < positions.len() { 4168 + let (x, y, _width) = positions[*pos_idx]; 4169 + child.rect.x = x; 4170 + child.rect.y = y; 4171 + child.rect.width = w; 4172 + child.rect.height = h; 4173 + *pos_idx += 1; 4174 + } 4175 + } else { 4176 + // Recurse into inline children to find nested replaced elements. 4177 + update_replaced_rects_recursive(&mut child.children, positions, pos_idx); 4178 + } 4179 + } 3922 4180 } 3923 4181 3924 4182 /// Compute the horizontal offset for text alignment. ··· 7869 8127 doc.set_attribute(el, name, value); 7870 8128 } 7871 8129 form_control_intrinsic_size(el, &doc, font_size) 8130 + } 8131 + 8132 + // --- FormControlInfo tests --- 8133 + 8134 + #[test] 8135 + fn form_control_info_text_input() { 8136 + let mut doc = Document::new(); 8137 + let el = doc.create_element("input"); 8138 + doc.set_attribute(el, "type", "text"); 8139 + doc.set_attribute(el, "value", "hello"); 8140 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8141 + assert_eq!(info.control_type, FormControlType::TextInput); 8142 + assert_eq!(info.value, "hello"); 8143 + assert!(!info.checked); 8144 + assert!(!info.disabled); 8145 + } 8146 + 8147 + #[test] 8148 + fn form_control_info_password() { 8149 + let mut doc = Document::new(); 8150 + let el = doc.create_element("input"); 8151 + doc.set_attribute(el, "type", "password"); 8152 + doc.set_attribute(el, "value", "secret"); 8153 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8154 + assert_eq!(info.control_type, FormControlType::Password); 8155 + assert_eq!(info.value, "secret"); 8156 + } 8157 + 8158 + #[test] 8159 + fn form_control_info_checkbox_checked() { 8160 + let mut doc = Document::new(); 8161 + let el = doc.create_element("input"); 8162 + doc.set_attribute(el, "type", "checkbox"); 8163 + doc.set_attribute(el, "checked", ""); 8164 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8165 + assert_eq!(info.control_type, FormControlType::Checkbox); 8166 + assert!(info.checked); 8167 + } 8168 + 8169 + #[test] 8170 + fn form_control_info_checkbox_unchecked() { 8171 + let mut doc = Document::new(); 8172 + let el = doc.create_element("input"); 8173 + doc.set_attribute(el, "type", "checkbox"); 8174 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8175 + assert_eq!(info.control_type, FormControlType::Checkbox); 8176 + assert!(!info.checked); 8177 + } 8178 + 8179 + #[test] 8180 + fn form_control_info_radio() { 8181 + let mut doc = Document::new(); 8182 + let el = doc.create_element("input"); 8183 + doc.set_attribute(el, "type", "radio"); 8184 + doc.set_attribute(el, "checked", ""); 8185 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8186 + assert_eq!(info.control_type, FormControlType::Radio); 8187 + assert!(info.checked); 8188 + } 8189 + 8190 + #[test] 8191 + fn form_control_info_submit_default() { 8192 + let mut doc = Document::new(); 8193 + let el = doc.create_element("input"); 8194 + doc.set_attribute(el, "type", "submit"); 8195 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8196 + assert_eq!(info.control_type, FormControlType::Submit); 8197 + assert_eq!(info.value, "Submit"); 8198 + } 8199 + 8200 + #[test] 8201 + fn form_control_info_reset_default() { 8202 + let mut doc = Document::new(); 8203 + let el = doc.create_element("input"); 8204 + doc.set_attribute(el, "type", "reset"); 8205 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8206 + assert_eq!(info.control_type, FormControlType::Reset); 8207 + assert_eq!(info.value, "Reset"); 8208 + } 8209 + 8210 + #[test] 8211 + fn form_control_info_disabled() { 8212 + let mut doc = Document::new(); 8213 + let el = doc.create_element("input"); 8214 + doc.set_attribute(el, "type", "text"); 8215 + doc.set_attribute(el, "disabled", ""); 8216 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8217 + assert!(info.disabled); 8218 + } 8219 + 8220 + #[test] 8221 + fn form_control_info_hidden_none() { 8222 + let mut doc = Document::new(); 8223 + let el = doc.create_element("input"); 8224 + doc.set_attribute(el, "type", "hidden"); 8225 + assert!( 8226 + build_form_control_info(el, &doc).is_none(), 8227 + "hidden input should not produce FormControlInfo" 8228 + ); 8229 + } 8230 + 8231 + #[test] 8232 + fn form_control_info_textarea() { 8233 + let mut doc = Document::new(); 8234 + let el = doc.create_element("textarea"); 8235 + let text = doc.create_text("My text"); 8236 + doc.append_child(el, text); 8237 + let info = build_form_control_info(el, &doc).expect("should produce info"); 8238 + assert_eq!(info.control_type, FormControlType::Textarea); 8239 + assert_eq!(info.value, "My text"); 8240 + } 8241 + 8242 + #[test] 8243 + fn form_control_info_select_selected() { 8244 + let mut doc = Document::new(); 8245 + let root = doc.root(); 8246 + let select = doc.create_element("select"); 8247 + let opt1 = doc.create_element("option"); 8248 + let t1 = doc.create_text("A"); 8249 + doc.append_child(opt1, t1); 8250 + let opt2 = doc.create_element("option"); 8251 + doc.set_attribute(opt2, "selected", ""); 8252 + let t2 = doc.create_text("B"); 8253 + doc.append_child(opt2, t2); 8254 + doc.append_child(select, opt1); 8255 + doc.append_child(select, opt2); 8256 + doc.append_child(root, select); 8257 + let info = build_form_control_info(select, &doc).expect("should produce info"); 8258 + assert_eq!(info.control_type, FormControlType::Select); 8259 + assert_eq!(info.value, "B"); 8260 + } 8261 + 8262 + #[test] 8263 + fn form_control_info_select_first_fallback() { 8264 + let mut doc = Document::new(); 8265 + let root = doc.root(); 8266 + let select = doc.create_element("select"); 8267 + let opt1 = doc.create_element("option"); 8268 + let t1 = doc.create_text("First"); 8269 + doc.append_child(opt1, t1); 8270 + let opt2 = doc.create_element("option"); 8271 + let t2 = doc.create_text("Second"); 8272 + doc.append_child(opt2, t2); 8273 + doc.append_child(select, opt1); 8274 + doc.append_child(select, opt2); 8275 + doc.append_child(root, select); 8276 + let info = build_form_control_info(select, &doc).expect("should produce info"); 8277 + assert_eq!(info.value, "First"); 8278 + } 8279 + 8280 + #[test] 8281 + fn form_control_info_button_element() { 8282 + let mut doc = Document::new(); 8283 + let root = doc.root(); 8284 + let btn = doc.create_element("button"); 8285 + let text = doc.create_text("Click"); 8286 + doc.append_child(btn, text); 8287 + doc.append_child(root, btn); 8288 + let info = build_form_control_info(btn, &doc).expect("should produce info"); 8289 + assert_eq!(info.control_type, FormControlType::Button); 8290 + assert_eq!(info.value, "Click"); 8291 + } 8292 + 8293 + #[test] 8294 + fn form_control_info_button_empty_defaults() { 8295 + let mut doc = Document::new(); 8296 + let root = doc.root(); 8297 + let btn = doc.create_element("button"); 8298 + doc.append_child(root, btn); 8299 + let info = build_form_control_info(btn, &doc).expect("should produce info"); 8300 + assert_eq!(info.value, "Submit"); 7872 8301 } 7873 8302 }
+1065 -15
crates/render/src/lib.rs
··· 12 12 use we_css::values::Color; 13 13 use we_dom::NodeId; 14 14 use we_image::pixel::Image; 15 - use we_layout::{BoxType, LayoutBox, LayoutTree, Rect, TextLine, SCROLLBAR_WIDTH}; 15 + use we_layout::{ 16 + BoxType, FormControlInfo, FormControlType, LayoutBox, LayoutTree, Rect, TextLine, 17 + SCROLLBAR_WIDTH, 18 + }; 16 19 use we_platform::metal::{ClearColor, CommandQueue, Device, MetalLayer}; 17 20 use we_style::computed::{ 18 21 BorderStyle, LengthOrAuto, Overflow, Position, TextDecoration, Visibility, WillChange, ··· 186 189 } 187 190 188 191 if visible { 189 - paint_background(layout_box, list, tx, ty); 190 - paint_borders(layout_box, list, tx, ty); 192 + if let Some(ref fc) = layout_box.form_control { 193 + // Form controls paint their own background, borders, and content. 194 + paint_form_control(layout_box, fc, list, tx, ty); 195 + } else { 196 + paint_background(layout_box, list, tx, ty); 197 + paint_borders(layout_box, list, tx, ty); 191 198 192 - // Emit image paint command for replaced elements. 193 - if let Some((rw, rh)) = layout_box.replaced_size { 194 - if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { 195 - list.push(PaintCommand::DrawImage { 196 - x: layout_box.rect.x + tx, 197 - y: layout_box.rect.y + ty, 198 - width: rw, 199 - height: rh, 200 - node_id, 201 - }); 199 + // Emit image paint command for replaced elements. 200 + if let Some((rw, rh)) = layout_box.replaced_size { 201 + if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { 202 + list.push(PaintCommand::DrawImage { 203 + x: layout_box.rect.x + tx, 204 + y: layout_box.rect.y + ty, 205 + width: rw, 206 + height: rh, 207 + node_id, 208 + }); 209 + } 202 210 } 203 - } 204 211 205 - paint_text(layout_box, list, tx, ty); 212 + paint_text(layout_box, list, tx, ty); 213 + } 206 214 } 207 215 208 216 // Determine if this box is scrollable. ··· 630 638 height: thumb_height, 631 639 color: SCROLLBAR_THUMB_COLOR, 632 640 }); 641 + } 642 + 643 + // --------------------------------------------------------------------------- 644 + // Form control painting 645 + // --------------------------------------------------------------------------- 646 + 647 + /// Colors for native-style form control rendering. 648 + const FC_BORDER_COLOR: Color = Color { 649 + r: 118, 650 + g: 118, 651 + b: 118, 652 + a: 255, 653 + }; 654 + const FC_BG_COLOR: Color = Color { 655 + r: 255, 656 + g: 255, 657 + b: 255, 658 + a: 255, 659 + }; 660 + const FC_BUTTON_BG: Color = Color { 661 + r: 239, 662 + g: 239, 663 + b: 239, 664 + a: 255, 665 + }; 666 + const FC_BUTTON_BORDER_LIGHT: Color = Color { 667 + r: 200, 668 + g: 200, 669 + b: 200, 670 + a: 255, 671 + }; 672 + const FC_BUTTON_BORDER_DARK: Color = Color { 673 + r: 133, 674 + g: 133, 675 + b: 133, 676 + a: 255, 677 + }; 678 + const FC_CHECK_COLOR: Color = Color { 679 + r: 0, 680 + g: 0, 681 + b: 0, 682 + a: 255, 683 + }; 684 + const FC_DISABLED_BG: Color = Color { 685 + r: 235, 686 + g: 235, 687 + b: 228, 688 + a: 255, 689 + }; 690 + const FC_DISABLED_TEXT: Color = Color { 691 + r: 169, 692 + g: 169, 693 + b: 169, 694 + a: 255, 695 + }; 696 + const FC_TEXT_COLOR: Color = Color { 697 + r: 0, 698 + g: 0, 699 + b: 0, 700 + a: 255, 701 + }; 702 + 703 + /// Paint a form control with native-style appearance. 704 + fn paint_form_control( 705 + layout_box: &LayoutBox, 706 + fc: &FormControlInfo, 707 + list: &mut DisplayList, 708 + tx: f32, 709 + ty: f32, 710 + ) { 711 + match fc.control_type { 712 + FormControlType::TextInput | FormControlType::Password => { 713 + paint_text_input(layout_box, fc, list, tx, ty); 714 + } 715 + FormControlType::Textarea => { 716 + paint_textarea(layout_box, fc, list, tx, ty); 717 + } 718 + FormControlType::Checkbox => { 719 + paint_checkbox(layout_box, fc, list, tx, ty); 720 + } 721 + FormControlType::Radio => { 722 + paint_radio(layout_box, fc, list, tx, ty); 723 + } 724 + FormControlType::Submit | FormControlType::Reset | FormControlType::Button => { 725 + paint_button(layout_box, fc, list, tx, ty); 726 + } 727 + FormControlType::Select => { 728 + paint_select(layout_box, fc, list, tx, ty); 729 + } 730 + } 731 + } 732 + 733 + /// Paint a text input or password field: inset border, white background, value text. 734 + fn paint_text_input( 735 + layout_box: &LayoutBox, 736 + fc: &FormControlInfo, 737 + list: &mut DisplayList, 738 + tx: f32, 739 + ty: f32, 740 + ) { 741 + let bb = border_box(layout_box); 742 + let x = bb.x + tx; 743 + let y = bb.y + ty; 744 + let w = bb.width; 745 + let h = bb.height; 746 + 747 + let bg = if fc.disabled { 748 + FC_DISABLED_BG 749 + } else { 750 + FC_BG_COLOR 751 + }; 752 + let text_color = if fc.disabled { 753 + FC_DISABLED_TEXT 754 + } else { 755 + FC_TEXT_COLOR 756 + }; 757 + 758 + // Background 759 + list.push(PaintCommand::FillRect { 760 + x, 761 + y, 762 + width: w, 763 + height: h, 764 + color: bg, 765 + }); 766 + 767 + // Inset border: darker on top/left, lighter on bottom/right. 768 + let border = 1.0f32; 769 + // Top 770 + list.push(PaintCommand::FillRect { 771 + x, 772 + y, 773 + width: w, 774 + height: border, 775 + color: FC_BUTTON_BORDER_DARK, 776 + }); 777 + // Left 778 + list.push(PaintCommand::FillRect { 779 + x, 780 + y, 781 + width: border, 782 + height: h, 783 + color: FC_BUTTON_BORDER_DARK, 784 + }); 785 + // Bottom 786 + list.push(PaintCommand::FillRect { 787 + x, 788 + y: y + h - border, 789 + width: w, 790 + height: border, 791 + color: FC_BUTTON_BORDER_LIGHT, 792 + }); 793 + // Right 794 + list.push(PaintCommand::FillRect { 795 + x: x + w - border, 796 + y, 797 + width: border, 798 + height: h, 799 + color: FC_BUTTON_BORDER_LIGHT, 800 + }); 801 + 802 + // Value text (clipped to content area) 803 + if !fc.value.is_empty() { 804 + let display_text = if fc.control_type == FormControlType::Password { 805 + "\u{2022}".repeat(fc.value.len()) // bullet characters 806 + } else { 807 + fc.value.clone() 808 + }; 809 + let font_size = layout_box.font_size; 810 + let text_x = layout_box.rect.x + tx; 811 + let text_y = layout_box.rect.y + ty; 812 + list.push(PaintCommand::DrawGlyphs { 813 + line: TextLine { 814 + text: display_text, 815 + x: text_x, 816 + y: text_y, 817 + width: layout_box.rect.width, 818 + font_size, 819 + color: text_color, 820 + text_decoration: TextDecoration::None, 821 + background_color: Color::new(0, 0, 0, 0), 822 + }, 823 + font_size, 824 + color: text_color, 825 + }); 826 + } 827 + } 828 + 829 + /// Paint a textarea: same style as text input but taller. 830 + fn paint_textarea( 831 + layout_box: &LayoutBox, 832 + fc: &FormControlInfo, 833 + list: &mut DisplayList, 834 + tx: f32, 835 + ty: f32, 836 + ) { 837 + // Reuse text input painting — same visual style. 838 + paint_text_input(layout_box, fc, list, tx, ty); 839 + } 840 + 841 + /// Paint a checkbox: small bordered square with optional checkmark. 842 + fn paint_checkbox( 843 + layout_box: &LayoutBox, 844 + fc: &FormControlInfo, 845 + list: &mut DisplayList, 846 + tx: f32, 847 + ty: f32, 848 + ) { 849 + let bb = border_box(layout_box); 850 + let x = bb.x + tx; 851 + let y = bb.y + ty; 852 + let size = bb.width.min(bb.height); 853 + 854 + let bg = if fc.disabled { 855 + FC_DISABLED_BG 856 + } else { 857 + FC_BG_COLOR 858 + }; 859 + 860 + // Background 861 + list.push(PaintCommand::FillRect { 862 + x, 863 + y, 864 + width: size, 865 + height: size, 866 + color: bg, 867 + }); 868 + 869 + // Border 870 + let border = 1.0f32; 871 + let bc = FC_BORDER_COLOR; 872 + // Top 873 + list.push(PaintCommand::FillRect { 874 + x, 875 + y, 876 + width: size, 877 + height: border, 878 + color: bc, 879 + }); 880 + // Bottom 881 + list.push(PaintCommand::FillRect { 882 + x, 883 + y: y + size - border, 884 + width: size, 885 + height: border, 886 + color: bc, 887 + }); 888 + // Left 889 + list.push(PaintCommand::FillRect { 890 + x, 891 + y, 892 + width: border, 893 + height: size, 894 + color: bc, 895 + }); 896 + // Right 897 + list.push(PaintCommand::FillRect { 898 + x: x + size - border, 899 + y, 900 + width: border, 901 + height: size, 902 + color: bc, 903 + }); 904 + 905 + // Checkmark when checked: draw a simple check using two rectangles 906 + // forming an "L" rotated, approximated as two thick lines. 907 + if fc.checked { 908 + let check_color = if fc.disabled { 909 + FC_DISABLED_TEXT 910 + } else { 911 + FC_CHECK_COLOR 912 + }; 913 + let inset = (size * 0.2).max(2.0); 914 + let thick = (size * 0.15).max(1.5); 915 + 916 + // Short leg of check (going down-right from left) 917 + let leg_x = x + inset; 918 + let leg_y = y + size * 0.45; 919 + let leg_w = size * 0.25; 920 + let leg_h = size * 0.35; 921 + list.push(PaintCommand::FillRect { 922 + x: leg_x, 923 + y: leg_y, 924 + width: thick, 925 + height: leg_h, 926 + color: check_color, 927 + }); 928 + 929 + // Long leg of check (going up-right) 930 + let arm_x = leg_x + thick; 931 + let arm_y = y + inset; 932 + list.push(PaintCommand::FillRect { 933 + x: arm_x, 934 + y: arm_y, 935 + width: leg_w, 936 + height: thick, 937 + color: check_color, 938 + }); 939 + 940 + // Diagonal approximation: draw from bottom-left to top-right as 941 + // a series of small filled rects. 942 + // Short descending leg: from (inset, center) to (center, bottom-inset) 943 + let cx = x + size * 0.38; 944 + let cy = y + size - inset; 945 + let steps = ((size * 0.3) as i32).max(2); 946 + for i in 0..steps { 947 + let t = i as f32 / steps as f32; 948 + let rx = leg_x + t * (cx - leg_x); 949 + let ry = leg_y + t * (cy - leg_y); 950 + list.push(PaintCommand::FillRect { 951 + x: rx, 952 + y: ry, 953 + width: thick, 954 + height: thick, 955 + color: check_color, 956 + }); 957 + } 958 + // Long ascending leg: from (center, bottom-inset) to (right-inset, top+inset) 959 + let ex = x + size - inset; 960 + let ey = y + inset; 961 + let steps2 = ((size * 0.6) as i32).max(3); 962 + for i in 0..=steps2 { 963 + let t = i as f32 / steps2 as f32; 964 + let rx = cx + t * (ex - cx); 965 + let ry = cy + t * (ey - cy); 966 + list.push(PaintCommand::FillRect { 967 + x: rx, 968 + y: ry, 969 + width: thick, 970 + height: thick, 971 + color: check_color, 972 + }); 973 + } 974 + } 975 + } 976 + 977 + /// Paint a radio button: bordered circle approximation with optional filled dot. 978 + fn paint_radio( 979 + layout_box: &LayoutBox, 980 + fc: &FormControlInfo, 981 + list: &mut DisplayList, 982 + tx: f32, 983 + ty: f32, 984 + ) { 985 + let bb = border_box(layout_box); 986 + let x = bb.x + tx; 987 + let y = bb.y + ty; 988 + let size = bb.width.min(bb.height); 989 + let cx = x + size / 2.0; 990 + let cy = y + size / 2.0; 991 + let radius = size / 2.0; 992 + 993 + let bg = if fc.disabled { 994 + FC_DISABLED_BG 995 + } else { 996 + FC_BG_COLOR 997 + }; 998 + 999 + // Draw circle approximation using filled rects in a circular pattern. 1000 + // Outer circle (border). 1001 + paint_circle_filled(list, cx, cy, radius, FC_BORDER_COLOR); 1002 + // Inner circle (background). 1003 + paint_circle_filled(list, cx, cy, radius - 1.0, bg); 1004 + 1005 + // Selected dot. 1006 + if fc.checked { 1007 + let dot_color = if fc.disabled { 1008 + FC_DISABLED_TEXT 1009 + } else { 1010 + FC_CHECK_COLOR 1011 + }; 1012 + paint_circle_filled(list, cx, cy, radius * 0.4, dot_color); 1013 + } 1014 + } 1015 + 1016 + /// Approximate a filled circle using horizontal FillRect scanlines. 1017 + fn paint_circle_filled(list: &mut DisplayList, cx: f32, cy: f32, radius: f32, color: Color) { 1018 + if radius <= 0.0 { 1019 + return; 1020 + } 1021 + let r = radius as i32; 1022 + for dy in -r..=r { 1023 + let dx = (radius * radius - (dy as f32 * dy as f32)).sqrt(); 1024 + if dx > 0.0 { 1025 + list.push(PaintCommand::FillRect { 1026 + x: cx - dx, 1027 + y: cy + dy as f32, 1028 + width: dx * 2.0, 1029 + height: 1.0, 1030 + color, 1031 + }); 1032 + } 1033 + } 1034 + } 1035 + 1036 + /// Paint a button (submit, reset, or generic): raised border with centered label. 1037 + fn paint_button( 1038 + layout_box: &LayoutBox, 1039 + fc: &FormControlInfo, 1040 + list: &mut DisplayList, 1041 + tx: f32, 1042 + ty: f32, 1043 + ) { 1044 + let bb = border_box(layout_box); 1045 + let x = bb.x + tx; 1046 + let y = bb.y + ty; 1047 + let w = bb.width; 1048 + let h = bb.height; 1049 + 1050 + let bg = if fc.disabled { 1051 + FC_DISABLED_BG 1052 + } else { 1053 + FC_BUTTON_BG 1054 + }; 1055 + let text_color = if fc.disabled { 1056 + FC_DISABLED_TEXT 1057 + } else { 1058 + FC_TEXT_COLOR 1059 + }; 1060 + 1061 + // Background 1062 + list.push(PaintCommand::FillRect { 1063 + x, 1064 + y, 1065 + width: w, 1066 + height: h, 1067 + color: bg, 1068 + }); 1069 + 1070 + // Raised border: lighter on top/left, darker on bottom/right. 1071 + let border = 1.0f32; 1072 + // Top (light) 1073 + list.push(PaintCommand::FillRect { 1074 + x, 1075 + y, 1076 + width: w, 1077 + height: border, 1078 + color: FC_BUTTON_BORDER_LIGHT, 1079 + }); 1080 + // Left (light) 1081 + list.push(PaintCommand::FillRect { 1082 + x, 1083 + y, 1084 + width: border, 1085 + height: h, 1086 + color: FC_BUTTON_BORDER_LIGHT, 1087 + }); 1088 + // Bottom (dark) 1089 + list.push(PaintCommand::FillRect { 1090 + x, 1091 + y: y + h - border, 1092 + width: w, 1093 + height: border, 1094 + color: FC_BUTTON_BORDER_DARK, 1095 + }); 1096 + // Right (dark) 1097 + list.push(PaintCommand::FillRect { 1098 + x: x + w - border, 1099 + y, 1100 + width: border, 1101 + height: h, 1102 + color: FC_BUTTON_BORDER_DARK, 1103 + }); 1104 + 1105 + // Centered label text 1106 + if !fc.value.is_empty() { 1107 + let font_size = layout_box.font_size; 1108 + let char_width = font_size * 0.6; 1109 + let text_width = fc.value.len() as f32 * char_width; 1110 + let text_x = x + (w - text_width) / 2.0; 1111 + let text_y = y + (h - font_size) / 2.0; 1112 + list.push(PaintCommand::DrawGlyphs { 1113 + line: TextLine { 1114 + text: fc.value.clone(), 1115 + x: text_x, 1116 + y: text_y, 1117 + width: text_width, 1118 + font_size, 1119 + color: text_color, 1120 + text_decoration: TextDecoration::None, 1121 + background_color: Color::new(0, 0, 0, 0), 1122 + }, 1123 + font_size, 1124 + color: text_color, 1125 + }); 1126 + } 1127 + } 1128 + 1129 + /// Paint a select dropdown: bordered rectangle with selected text and dropdown arrow. 1130 + fn paint_select( 1131 + layout_box: &LayoutBox, 1132 + fc: &FormControlInfo, 1133 + list: &mut DisplayList, 1134 + tx: f32, 1135 + ty: f32, 1136 + ) { 1137 + let bb = border_box(layout_box); 1138 + let x = bb.x + tx; 1139 + let y = bb.y + ty; 1140 + let w = bb.width; 1141 + let h = bb.height; 1142 + 1143 + let bg = if fc.disabled { 1144 + FC_DISABLED_BG 1145 + } else { 1146 + FC_BUTTON_BG 1147 + }; 1148 + let text_color = if fc.disabled { 1149 + FC_DISABLED_TEXT 1150 + } else { 1151 + FC_TEXT_COLOR 1152 + }; 1153 + 1154 + // Background 1155 + list.push(PaintCommand::FillRect { 1156 + x, 1157 + y, 1158 + width: w, 1159 + height: h, 1160 + color: bg, 1161 + }); 1162 + 1163 + // Border 1164 + let border = 1.0f32; 1165 + let bc = FC_BORDER_COLOR; 1166 + // Top 1167 + list.push(PaintCommand::FillRect { 1168 + x, 1169 + y, 1170 + width: w, 1171 + height: border, 1172 + color: bc, 1173 + }); 1174 + // Bottom 1175 + list.push(PaintCommand::FillRect { 1176 + x, 1177 + y: y + h - border, 1178 + width: w, 1179 + height: border, 1180 + color: bc, 1181 + }); 1182 + // Left 1183 + list.push(PaintCommand::FillRect { 1184 + x, 1185 + y, 1186 + width: border, 1187 + height: h, 1188 + color: bc, 1189 + }); 1190 + // Right 1191 + list.push(PaintCommand::FillRect { 1192 + x: x + w - border, 1193 + y, 1194 + width: border, 1195 + height: h, 1196 + color: bc, 1197 + }); 1198 + 1199 + // Selected option text 1200 + let font_size = layout_box.font_size; 1201 + let text_x = layout_box.rect.x + tx; 1202 + let text_y = layout_box.rect.y + ty; 1203 + if !fc.value.is_empty() { 1204 + list.push(PaintCommand::DrawGlyphs { 1205 + line: TextLine { 1206 + text: fc.value.clone(), 1207 + x: text_x, 1208 + y: text_y, 1209 + width: layout_box.rect.width - 20.0, // leave room for arrow 1210 + font_size, 1211 + color: text_color, 1212 + text_decoration: TextDecoration::None, 1213 + background_color: Color::new(0, 0, 0, 0), 1214 + }, 1215 + font_size, 1216 + color: text_color, 1217 + }); 1218 + } 1219 + 1220 + // Dropdown arrow (▼) as a small triangle approximated with rects. 1221 + let arrow_size = font_size * 0.4; 1222 + let arrow_x = x + w - 14.0; 1223 + let arrow_y = y + (h - arrow_size) / 2.0; 1224 + let arrow_color = if fc.disabled { 1225 + FC_DISABLED_TEXT 1226 + } else { 1227 + FC_TEXT_COLOR 1228 + }; 1229 + // Approximate downward triangle with progressively narrower rects. 1230 + let rows = (arrow_size as i32).max(3); 1231 + for i in 0..rows { 1232 + let t = i as f32 / rows as f32; 1233 + let row_width = arrow_size * (1.0 - t); 1234 + let row_x = arrow_x + (arrow_size - row_width) / 2.0; 1235 + list.push(PaintCommand::FillRect { 1236 + x: row_x, 1237 + y: arrow_y + i as f32, 1238 + width: row_width, 1239 + height: 1.0, 1240 + color: arrow_color, 1241 + }); 1242 + } 633 1243 } 634 1244 635 1245 /// An axis-aligned clip rectangle. ··· 2618 3228 2619 3229 backend.resize_software(20, 20); 2620 3230 assert_eq!(backend.pixels().unwrap().len(), 20 * 20 * 4); 3231 + } 3232 + 3233 + // --- Form control rendering tests --- 3234 + 3235 + #[test] 3236 + fn text_input_renders_with_border_and_background() { 3237 + let html_str = r#"<!DOCTYPE html> 3238 + <html><head><style>body { margin: 0; }</style></head> 3239 + <body><input type="text" value="hello"></body></html>"#; 3240 + let doc = we_html::parse_html(html_str); 3241 + let tree = layout_doc(&doc); 3242 + let list = build_display_list(&tree); 3243 + 3244 + // Should have FillRect commands (background + border) for the input. 3245 + let fill_count = list 3246 + .iter() 3247 + .filter(|c| matches!(c, PaintCommand::FillRect { .. })) 3248 + .count(); 3249 + assert!( 3250 + fill_count >= 5, 3251 + "text input should have background + 4 border rects, got {fill_count}" 3252 + ); 3253 + 3254 + // Should have a DrawGlyphs for the value text. 3255 + let has_value_text = list 3256 + .iter() 3257 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "hello")); 3258 + assert!( 3259 + has_value_text, 3260 + "text input should render its value text 'hello'" 3261 + ); 3262 + } 3263 + 3264 + #[test] 3265 + fn password_input_renders_bullets() { 3266 + let html_str = r#"<!DOCTYPE html> 3267 + <html><head><style>body { margin: 0; }</style></head> 3268 + <body><input type="password" value="abc"></body></html>"#; 3269 + let doc = we_html::parse_html(html_str); 3270 + let tree = layout_doc(&doc); 3271 + let list = build_display_list(&tree); 3272 + 3273 + // Should render bullet characters instead of actual value. 3274 + let has_bullets = list.iter().any(|c| { 3275 + matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "\u{2022}\u{2022}\u{2022}") 3276 + }); 3277 + assert!( 3278 + has_bullets, 3279 + "password input should render bullet characters" 3280 + ); 3281 + 3282 + // Should NOT contain the actual password text. 3283 + let has_plain = list 3284 + .iter() 3285 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "abc")); 3286 + assert!( 3287 + !has_plain, 3288 + "password input should not render plaintext value" 3289 + ); 3290 + } 3291 + 3292 + #[test] 3293 + fn checkbox_unchecked_renders_empty_box() { 3294 + let html_str = r#"<!DOCTYPE html> 3295 + <html><head><style>body { margin: 0; }</style></head> 3296 + <body><input type="checkbox"></body></html>"#; 3297 + let doc = we_html::parse_html(html_str); 3298 + let tree = layout_doc(&doc); 3299 + let list = build_display_list(&tree); 3300 + 3301 + // Unchecked checkbox: background + 4 border rects, no checkmark. 3302 + let fill_count = list 3303 + .iter() 3304 + .filter(|c| matches!(c, PaintCommand::FillRect { .. })) 3305 + .count(); 3306 + assert!( 3307 + fill_count >= 5, 3308 + "unchecked checkbox should have background + borders, got {fill_count}" 3309 + ); 3310 + 3311 + // Should not have any visible text (only placeholder empty fragments are allowed). 3312 + let has_visible_text = list 3313 + .iter() 3314 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if !line.text.is_empty())); 3315 + assert!( 3316 + !has_visible_text, 3317 + "unchecked checkbox should not have any visible text glyphs" 3318 + ); 3319 + } 3320 + 3321 + #[test] 3322 + fn checkbox_checked_renders_checkmark() { 3323 + let html_str = r#"<!DOCTYPE html> 3324 + <html><head><style>body { margin: 0; }</style></head> 3325 + <body><input type="checkbox" checked></body></html>"#; 3326 + let doc = we_html::parse_html(html_str); 3327 + let tree = layout_doc(&doc); 3328 + let list = build_display_list(&tree); 3329 + 3330 + // Checked checkbox has more FillRects than unchecked (checkmark lines). 3331 + let fill_count = list 3332 + .iter() 3333 + .filter(|c| matches!(c, PaintCommand::FillRect { .. })) 3334 + .count(); 3335 + assert!( 3336 + fill_count > 5, 3337 + "checked checkbox should have extra rects for checkmark, got {fill_count}" 3338 + ); 3339 + } 3340 + 3341 + #[test] 3342 + fn radio_unchecked_renders_circle() { 3343 + let html_str = r#"<!DOCTYPE html> 3344 + <html><head><style>body { margin: 0; }</style></head> 3345 + <body><input type="radio"></body></html>"#; 3346 + let doc = we_html::parse_html(html_str); 3347 + let tree = layout_doc(&doc); 3348 + let list = build_display_list(&tree); 3349 + 3350 + // Radio button uses scanline circles → many FillRects. 3351 + let fill_count = list 3352 + .iter() 3353 + .filter(|c| matches!(c, PaintCommand::FillRect { .. })) 3354 + .count(); 3355 + assert!( 3356 + fill_count >= 5, 3357 + "radio button should have circle scanline rects, got {fill_count}" 3358 + ); 3359 + } 3360 + 3361 + #[test] 3362 + fn radio_checked_renders_dot() { 3363 + let html_str = r#"<!DOCTYPE html> 3364 + <html><head><style>body { margin: 0; }</style></head> 3365 + <body><input type="radio" checked></body></html>"#; 3366 + let doc = we_html::parse_html(html_str); 3367 + let tree = layout_doc(&doc); 3368 + let list = build_display_list(&tree); 3369 + 3370 + // Checked radio has more rects (dot circle). 3371 + let unchecked_html = r#"<!DOCTYPE html> 3372 + <html><head><style>body { margin: 0; }</style></head> 3373 + <body><input type="radio"></body></html>"#; 3374 + let unchecked_doc = we_html::parse_html(unchecked_html); 3375 + let unchecked_tree = layout_doc(&unchecked_doc); 3376 + let unchecked_list = build_display_list(&unchecked_tree); 3377 + 3378 + let checked_fills = list 3379 + .iter() 3380 + .filter(|c| matches!(c, PaintCommand::FillRect { .. })) 3381 + .count(); 3382 + let unchecked_fills = unchecked_list 3383 + .iter() 3384 + .filter(|c| matches!(c, PaintCommand::FillRect { .. })) 3385 + .count(); 3386 + assert!( 3387 + checked_fills > unchecked_fills, 3388 + "checked radio ({checked_fills} fills) should have more rects than unchecked ({unchecked_fills})" 3389 + ); 3390 + } 3391 + 3392 + #[test] 3393 + fn submit_button_renders_label() { 3394 + let html_str = r#"<!DOCTYPE html> 3395 + <html><head><style>body { margin: 0; }</style></head> 3396 + <body><input type="submit" value="Go"></body></html>"#; 3397 + let doc = we_html::parse_html(html_str); 3398 + let tree = layout_doc(&doc); 3399 + let list = build_display_list(&tree); 3400 + 3401 + let has_label = list 3402 + .iter() 3403 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Go")); 3404 + assert!(has_label, "submit button should render 'Go' label"); 3405 + } 3406 + 3407 + #[test] 3408 + fn submit_button_default_label() { 3409 + let html_str = r#"<!DOCTYPE html> 3410 + <html><head><style>body { margin: 0; }</style></head> 3411 + <body><input type="submit"></body></html>"#; 3412 + let doc = we_html::parse_html(html_str); 3413 + let tree = layout_doc(&doc); 3414 + let list = build_display_list(&tree); 3415 + 3416 + let has_label = list 3417 + .iter() 3418 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Submit")); 3419 + assert!( 3420 + has_label, 3421 + "submit button without value should render 'Submit'" 3422 + ); 3423 + } 3424 + 3425 + #[test] 3426 + fn button_element_renders_label() { 3427 + let html_str = r#"<!DOCTYPE html> 3428 + <html><head><style>body { margin: 0; }</style></head> 3429 + <body><button>Click Me</button></body></html>"#; 3430 + let doc = we_html::parse_html(html_str); 3431 + let tree = layout_doc(&doc); 3432 + let list = build_display_list(&tree); 3433 + 3434 + let has_label = list 3435 + .iter() 3436 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Click Me")); 3437 + assert!(has_label, "button element should render 'Click Me' label"); 3438 + } 3439 + 3440 + #[test] 3441 + fn select_renders_selected_option() { 3442 + let html_str = r#"<!DOCTYPE html> 3443 + <html><head><style>body { margin: 0; }</style></head> 3444 + <body><select> 3445 + <option>Apple</option> 3446 + <option selected>Banana</option> 3447 + <option>Cherry</option> 3448 + </select></body></html>"#; 3449 + let doc = we_html::parse_html(html_str); 3450 + let tree = layout_doc(&doc); 3451 + let list = build_display_list(&tree); 3452 + 3453 + let has_selected = list 3454 + .iter() 3455 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Banana")); 3456 + assert!( 3457 + has_selected, 3458 + "select should render the selected option 'Banana'" 3459 + ); 3460 + } 3461 + 3462 + #[test] 3463 + fn select_renders_first_option_when_none_selected() { 3464 + let html_str = r#"<!DOCTYPE html> 3465 + <html><head><style>body { margin: 0; }</style></head> 3466 + <body><select> 3467 + <option>First</option> 3468 + <option>Second</option> 3469 + </select></body></html>"#; 3470 + let doc = we_html::parse_html(html_str); 3471 + let tree = layout_doc(&doc); 3472 + let list = build_display_list(&tree); 3473 + 3474 + let has_first = list 3475 + .iter() 3476 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "First")); 3477 + assert!( 3478 + has_first, 3479 + "select should render first option 'First' when none selected" 3480 + ); 3481 + } 3482 + 3483 + #[test] 3484 + fn disabled_input_renders_dimmed() { 3485 + let html_str = r#"<!DOCTYPE html> 3486 + <html><head><style>body { margin: 0; }</style></head> 3487 + <body><input type="text" value="dimmed" disabled></body></html>"#; 3488 + let doc = we_html::parse_html(html_str); 3489 + let tree = layout_doc(&doc); 3490 + let list = build_display_list(&tree); 3491 + 3492 + // Disabled text should use dimmed color (169, 169, 169). 3493 + let has_dimmed_text = list.iter().any(|c| { 3494 + matches!(c, PaintCommand::DrawGlyphs { color, .. } 3495 + if color.r == 169 && color.g == 169 && color.b == 169) 3496 + }); 3497 + assert!( 3498 + has_dimmed_text, 3499 + "disabled input should render text with dimmed color" 3500 + ); 3501 + 3502 + // Disabled background should be grayish (235, 235, 228). 3503 + let has_disabled_bg = list.iter().any(|c| { 3504 + matches!(c, PaintCommand::FillRect { color, .. } 3505 + if color.r == 235 && color.g == 235 && color.b == 228) 3506 + }); 3507 + assert!( 3508 + has_disabled_bg, 3509 + "disabled input should have dimmed background" 3510 + ); 3511 + } 3512 + 3513 + #[test] 3514 + fn textarea_renders_with_value() { 3515 + let html_str = r#"<!DOCTYPE html> 3516 + <html><head><style>body { margin: 0; }</style></head> 3517 + <body><textarea>Some text</textarea></body></html>"#; 3518 + let doc = we_html::parse_html(html_str); 3519 + let tree = layout_doc(&doc); 3520 + let list = build_display_list(&tree); 3521 + 3522 + let has_text = list.iter().any(|c| { 3523 + matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text.contains("Some text")) 3524 + }); 3525 + assert!(has_text, "textarea should render its text content"); 3526 + } 3527 + 3528 + #[test] 3529 + fn form_controls_software_render_produces_pixels() { 3530 + let html_str = r#"<!DOCTYPE html> 3531 + <html><head><style>body { margin: 0; }</style></head> 3532 + <body> 3533 + <input type="text" value="Name"> 3534 + <input type="checkbox" checked> 3535 + <input type="radio" checked> 3536 + <input type="submit" value="OK"> 3537 + <select><option>A</option></select> 3538 + </body></html>"#; 3539 + let doc = we_html::parse_html(html_str); 3540 + let font = test_font(); 3541 + let tree = layout_doc(&doc); 3542 + let mut renderer = Renderer::new(800, 200); 3543 + renderer.paint(&tree, &font, &HashMap::new()); 3544 + 3545 + let pixels = renderer.pixels(); 3546 + let has_non_white = pixels.chunks_exact(4).any(|p| p != [255, 255, 255, 255]); 3547 + assert!( 3548 + has_non_white, 3549 + "form controls should produce non-white pixels" 3550 + ); 3551 + } 3552 + 3553 + #[test] 3554 + fn form_control_info_populated_in_layout() { 3555 + let html_str = r#"<!DOCTYPE html> 3556 + <html><head><style>body { margin: 0; }</style></head> 3557 + <body> 3558 + <input type="text" value="test"> 3559 + <input type="checkbox" checked> 3560 + <input type="submit" value="Go"> 3561 + </body></html>"#; 3562 + let doc = we_html::parse_html(html_str); 3563 + let tree = layout_doc(&doc); 3564 + 3565 + // Find form control boxes. 3566 + let form_controls: Vec<_> = tree 3567 + .root 3568 + .iter() 3569 + .filter(|b| b.form_control.is_some()) 3570 + .collect(); 3571 + 3572 + assert!( 3573 + form_controls.len() >= 3, 3574 + "should have at least 3 form controls in layout, got {}", 3575 + form_controls.len() 3576 + ); 3577 + 3578 + // Check text input info. 3579 + let text_input = form_controls.iter().find(|b| { 3580 + b.form_control 3581 + .as_ref() 3582 + .map_or(false, |fc| fc.control_type == FormControlType::TextInput) 3583 + }); 3584 + assert!(text_input.is_some(), "should have a TextInput control"); 3585 + let text_fc = text_input.unwrap().form_control.as_ref().unwrap(); 3586 + assert_eq!(text_fc.value, "test"); 3587 + assert!(!text_fc.checked); 3588 + assert!(!text_fc.disabled); 3589 + 3590 + // Check checkbox info. 3591 + let checkbox = form_controls.iter().find(|b| { 3592 + b.form_control 3593 + .as_ref() 3594 + .map_or(false, |fc| fc.control_type == FormControlType::Checkbox) 3595 + }); 3596 + assert!(checkbox.is_some(), "should have a Checkbox control"); 3597 + let cb_fc = checkbox.unwrap().form_control.as_ref().unwrap(); 3598 + assert!(cb_fc.checked); 3599 + 3600 + // Check submit info. 3601 + let submit = form_controls.iter().find(|b| { 3602 + b.form_control 3603 + .as_ref() 3604 + .map_or(false, |fc| fc.control_type == FormControlType::Submit) 3605 + }); 3606 + assert!(submit.is_some(), "should have a Submit control"); 3607 + let sub_fc = submit.unwrap().form_control.as_ref().unwrap(); 3608 + assert_eq!(sub_fc.value, "Go"); 3609 + } 3610 + 3611 + #[test] 3612 + fn hidden_input_has_no_form_control() { 3613 + let html_str = r#"<!DOCTYPE html> 3614 + <html><body><input type="hidden" value="secret"></body></html>"#; 3615 + let doc = we_html::parse_html(html_str); 3616 + let tree = layout_doc(&doc); 3617 + 3618 + // Hidden inputs should have no form_control info and no replaced_size. 3619 + let hidden_controls: Vec<_> = tree 3620 + .root 3621 + .iter() 3622 + .filter(|b| b.form_control.is_some()) 3623 + .collect(); 3624 + // None of the form controls should be from a hidden input. 3625 + for b in &hidden_controls { 3626 + let fc = b.form_control.as_ref().unwrap(); 3627 + assert_ne!( 3628 + fc.control_type, 3629 + FormControlType::TextInput, 3630 + "hidden input should not produce a form control" 3631 + ); 3632 + } 3633 + } 3634 + 3635 + #[test] 3636 + fn select_dropdown_arrow_rendered() { 3637 + let html_str = r#"<!DOCTYPE html> 3638 + <html><head><style>body { margin: 0; }</style></head> 3639 + <body><select><option>Item</option></select></body></html>"#; 3640 + let doc = we_html::parse_html(html_str); 3641 + let tree = layout_doc(&doc); 3642 + let list = build_display_list(&tree); 3643 + 3644 + // Select should have many FillRects (background + border + arrow scanlines). 3645 + let fill_count = list 3646 + .iter() 3647 + .filter(|c| matches!(c, PaintCommand::FillRect { .. })) 3648 + .count(); 3649 + assert!( 3650 + fill_count >= 8, 3651 + "select should have border + background + arrow rects, got {fill_count}" 3652 + ); 3653 + } 3654 + 3655 + #[test] 3656 + fn reset_button_default_label() { 3657 + let html_str = r#"<!DOCTYPE html> 3658 + <html><head><style>body { margin: 0; }</style></head> 3659 + <body><input type="reset"></body></html>"#; 3660 + let doc = we_html::parse_html(html_str); 3661 + let tree = layout_doc(&doc); 3662 + let list = build_display_list(&tree); 3663 + 3664 + let has_label = list 3665 + .iter() 3666 + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Reset")); 3667 + assert!( 3668 + has_label, 3669 + "reset button without value should render 'Reset'" 3670 + ); 2621 3671 } 2622 3672 }