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 layout and intrinsic sizing (Phase 16)

Form controls now participate in inline layout as atomic inline-level
boxes with correct intrinsic dimensions:
- Text inputs: width from size attribute (default 20 chars)
- Textareas: width/height from cols/rows attributes
- Buttons: sized to fit label text + padding
- Checkboxes/radio buttons: 13x13 CSS px
- Select dropdowns: width from longest option text
- CSS width/height properties override intrinsic sizes

Also adds:
- UA stylesheet rules for fieldset, legend, and form controls
- Fieldset/legend block layout with legend overlapping top border
- AtomicInline variant in inline formatting context
- Per-line max height tracking for lines with tall atomic boxes
- 15 new tests covering all form control types

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

+686 -12
+664 -12
crates/layout/src/lib.rs
··· 459 459 } 460 460 461 461 // --------------------------------------------------------------------------- 462 + // Form control intrinsic sizing 463 + // --------------------------------------------------------------------------- 464 + 465 + /// Default padding for text inputs and selects (top/bottom, left/right). 466 + const FORM_CONTROL_PADDING_TB: f32 = 1.0; 467 + const FORM_CONTROL_PADDING_LR: f32 = 2.0; 468 + 469 + /// Default border width for text inputs and selects. 470 + const FORM_CONTROL_BORDER: f32 = 2.0; 471 + 472 + /// Fixed size for checkboxes and radio buttons (CSS px). 473 + const CHECK_RADIO_SIZE: f32 = 13.0; 474 + 475 + /// Compute intrinsic (width, height) for a form control element, or `None` if 476 + /// the node is not a form control that needs intrinsic sizing. 477 + /// 478 + /// Sizes follow the HTML spec defaults: 479 + /// - `<input type="text">`: width from `size` attribute (default 20 chars), 480 + /// height from font metrics + padding. 481 + /// - `<textarea>`: width from `cols` (default 20), height from `rows` (default 2). 482 + /// - `<button>`: sized to fit label text + padding. 483 + /// - `<input type="checkbox">` / `<input type="radio">`: 13×13 CSS px. 484 + /// - `<select>`: width to fit longest option, height like single-line text input. 485 + fn form_control_intrinsic_size(node: NodeId, doc: &Document, font_size: f32) -> Option<(f32, f32)> { 486 + let tag = doc.tag_name(node)?; 487 + match tag { 488 + "input" => { 489 + let input_type = doc.get_attribute(node, "type").unwrap_or("text"); 490 + match input_type { 491 + "checkbox" | "radio" => Some((CHECK_RADIO_SIZE, CHECK_RADIO_SIZE)), 492 + "hidden" => None, // hidden inputs have no visual representation 493 + "submit" | "reset" | "button" => { 494 + // Button-like inputs: width based on value text. 495 + let label = match input_type { 496 + "submit" => doc.get_attribute(node, "value").unwrap_or("Submit"), 497 + "reset" => doc.get_attribute(node, "value").unwrap_or("Reset"), 498 + _ => doc.get_attribute(node, "value").unwrap_or(""), 499 + }; 500 + let char_width = font_size * 0.6; 501 + let text_width = label.len() as f32 * char_width; 502 + let padding_lr = 8.0; 503 + let padding_tb = 2.0; 504 + let border = FORM_CONTROL_BORDER; 505 + let width = text_width + padding_lr * 2.0 + border * 2.0; 506 + let height = font_size + padding_tb * 2.0 + border * 2.0; 507 + Some((width, height)) 508 + } 509 + // text, password, email, url, search, tel, number, and other 510 + // text-like input types. 511 + _ => { 512 + let size: u32 = doc 513 + .get_attribute(node, "size") 514 + .and_then(|s| s.parse().ok()) 515 + .unwrap_or(20); 516 + let char_width = font_size * 0.6; 517 + let padding = FORM_CONTROL_PADDING_LR; 518 + let border = FORM_CONTROL_BORDER; 519 + let width = size as f32 * char_width + padding * 2.0 + border * 2.0; 520 + let height = font_size + FORM_CONTROL_PADDING_TB * 2.0 + border * 2.0; 521 + Some((width, height)) 522 + } 523 + } 524 + } 525 + "textarea" => { 526 + let cols: u32 = doc 527 + .get_attribute(node, "cols") 528 + .and_then(|s| s.parse().ok()) 529 + .unwrap_or(20); 530 + let rows: u32 = doc 531 + .get_attribute(node, "rows") 532 + .and_then(|s| s.parse().ok()) 533 + .unwrap_or(2); 534 + let char_width = font_size * 0.6; 535 + let line_height = font_size * 1.2; 536 + let padding_lr = FORM_CONTROL_PADDING_LR; 537 + let padding_tb = FORM_CONTROL_PADDING_TB; 538 + let border = FORM_CONTROL_BORDER; 539 + let width = cols as f32 * char_width + padding_lr * 2.0 + border * 2.0; 540 + let height = rows as f32 * line_height + padding_tb * 2.0 + border * 2.0; 541 + Some((width, height)) 542 + } 543 + "select" => { 544 + // Width: fit the longest <option> text, or a default. 545 + let mut max_len: usize = 0; 546 + let mut child = doc.first_child(node); 547 + while let Some(c) = child { 548 + match doc.tag_name(c) { 549 + Some("option") => { 550 + let text_len = option_text_length(doc, c); 551 + if text_len > max_len { 552 + max_len = text_len; 553 + } 554 + } 555 + Some("optgroup") => { 556 + // Scan options inside optgroup. 557 + let mut opt = doc.first_child(c); 558 + while let Some(o) = opt { 559 + if doc.tag_name(o) == Some("option") { 560 + let text_len = option_text_length(doc, o); 561 + if text_len > max_len { 562 + max_len = text_len; 563 + } 564 + } 565 + opt = doc.next_sibling(o); 566 + } 567 + } 568 + _ => {} 569 + } 570 + child = doc.next_sibling(c); 571 + } 572 + if max_len == 0 { 573 + max_len = 10; // default minimum width 574 + } 575 + let char_width = font_size * 0.6; 576 + let dropdown_arrow = 20.0; // space for dropdown arrow 577 + let padding = FORM_CONTROL_PADDING_LR; 578 + let border = FORM_CONTROL_BORDER; 579 + let width = max_len as f32 * char_width + dropdown_arrow + padding * 2.0 + border * 2.0; 580 + let height = font_size + FORM_CONTROL_PADDING_TB * 2.0 + border * 2.0; 581 + Some((width, height)) 582 + } 583 + "button" => { 584 + // Button: sized to fit text content + padding. 585 + let text = collect_text_content(doc, node); 586 + let label = if text.trim().is_empty() { 587 + "Submit" 588 + } else { 589 + text.trim() 590 + }; 591 + let char_width = font_size * 0.6; 592 + let text_width = label.len() as f32 * char_width; 593 + let padding_lr = 8.0; 594 + let padding_tb = 2.0; 595 + let border = FORM_CONTROL_BORDER; 596 + let width = text_width + padding_lr * 2.0 + border * 2.0; 597 + let height = font_size + padding_tb * 2.0 + border * 2.0; 598 + Some((width, height)) 599 + } 600 + _ => None, 601 + } 602 + } 603 + 604 + /// Get the text length of an <option> element's content. 605 + fn option_text_length(doc: &Document, option_node: NodeId) -> usize { 606 + let mut len = 0; 607 + let mut child = doc.first_child(option_node); 608 + while let Some(c) = child { 609 + if let NodeData::Text { data } = doc.node_data(c) { 610 + len += data.trim().len(); 611 + } 612 + child = doc.next_sibling(c); 613 + } 614 + len 615 + } 616 + 617 + /// Collect all descendant text content of a node into a single string. 618 + fn collect_text_content(doc: &Document, node: NodeId) -> String { 619 + let mut result = String::new(); 620 + collect_text_recursive(doc, node, &mut result); 621 + result 622 + } 623 + 624 + fn collect_text_recursive(doc: &Document, node: NodeId, result: &mut String) { 625 + if let NodeData::Text { data } = doc.node_data(node) { 626 + result.push_str(data); 627 + return; 628 + } 629 + let mut child = doc.first_child(node); 630 + while let Some(c) = child { 631 + collect_text_recursive(doc, c, result); 632 + child = doc.next_sibling(c); 633 + } 634 + } 635 + 636 + // --------------------------------------------------------------------------- 462 637 // Build layout tree from styled tree 463 638 // --------------------------------------------------------------------------- 464 639 ··· 573 748 // Check for replaced element (e.g., <img>). 574 749 if let Some(&(w, h)) = image_sizes.get(&node) { 575 750 b.replaced_size = Some((w, h)); 751 + } 752 + 753 + // Form controls are atomic inline-level boxes with intrinsic sizes. 754 + if b.replaced_size.is_none() { 755 + if let Some(size) = form_control_intrinsic_size(node, doc, style.font_size) { 756 + b.replaced_size = Some(size); 757 + } 576 758 } 577 759 578 760 // Relative position offsets are resolved in compute_layout ··· 953 1135 b.rect.width = (b.rect.width - SCROLLBAR_WIDTH).max(0.0); 954 1136 } 955 1137 956 - // Replaced elements (e.g., <img>) have intrinsic dimensions. 1138 + // Replaced elements (e.g., <img>, form controls) have intrinsic dimensions. 1139 + // Explicit CSS width/height override the intrinsic values. 957 1140 if let Some((rw, rh)) = b.replaced_size { 958 - b.rect.width = rw.min(b.rect.width); 959 - b.rect.height = rh; 1141 + if matches!(b.css_width, LengthOrAuto::Auto) { 1142 + b.rect.width = rw.min(b.rect.width); 1143 + } 1144 + // else: content_width already resolved from explicit CSS above. 1145 + 1146 + if matches!(b.css_height, LengthOrAuto::Auto) { 1147 + b.rect.height = rh; 1148 + } else { 1149 + let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom; 1150 + b.rect.height = match b.css_height { 1151 + LengthOrAuto::Length(h) => match b.box_sizing { 1152 + BoxSizing::ContentBox => h.max(0.0), 1153 + BoxSizing::BorderBox => (h - vertical_extra).max(0.0), 1154 + }, 1155 + LengthOrAuto::Percentage(p) => { 1156 + let resolved = p / 100.0 * viewport_height; 1157 + match b.box_sizing { 1158 + BoxSizing::ContentBox => resolved.max(0.0), 1159 + BoxSizing::BorderBox => (resolved - vertical_extra).max(0.0), 1160 + } 1161 + } 1162 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1163 + let resolved = resolve_length_against(b.css_height, viewport_height); 1164 + match b.box_sizing { 1165 + BoxSizing::ContentBox => resolved.max(0.0), 1166 + BoxSizing::BorderBox => (resolved - vertical_extra).max(0.0), 1167 + } 1168 + } 1169 + LengthOrAuto::Auto => unreachable!(), 1170 + }; 1171 + } 960 1172 set_sticky_constraints(b); 961 1173 layout_abspos_children(b, abs_cb, viewport_width, viewport_height, font, doc); 962 1174 apply_relative_offset(b, available_width, viewport_height); ··· 964 1176 } 965 1177 966 1178 match &b.box_type { 967 - BoxType::Block(_) | BoxType::Anonymous => { 1179 + BoxType::Block(node_id) => { 1180 + let is_fieldset = doc.tag_name(*node_id) == Some("fieldset"); 1181 + if matches!(b.display, Display::Grid | Display::InlineGrid) { 1182 + layout_grid_children(b, viewport_width, viewport_height, font, doc, abs_cb); 1183 + } else if matches!(b.display, Display::Flex | Display::InlineFlex) { 1184 + layout_flex_children(b, viewport_width, viewport_height, font, doc, abs_cb); 1185 + } else if has_block_children(b) || has_float_children(b) { 1186 + layout_block_children(b, viewport_width, viewport_height, font, doc, abs_cb); 1187 + } else { 1188 + layout_inline_children(b, font, doc, float_ctx); 1189 + } 1190 + if is_fieldset { 1191 + adjust_fieldset_legend(b, doc); 1192 + } 1193 + } 1194 + BoxType::Anonymous => { 968 1195 if matches!(b.display, Display::Grid | Display::InlineGrid) { 969 1196 layout_grid_children(b, viewport_width, viewport_height, font, doc, abs_cb); 970 1197 } else if matches!(b.display, Display::Flex | Display::InlineFlex) { ··· 3355 3582 padding_right: f32, 3356 3583 border_right: f32, 3357 3584 }, 3585 + /// An atomic inline-level box (e.g., form control) that participates in 3586 + /// inline layout as an indivisible unit with fixed dimensions. 3587 + AtomicInline { 3588 + width: f32, 3589 + height: f32, 3590 + font_size: f32, 3591 + }, 3358 3592 } 3359 3593 3360 3594 /// A pending fragment on the current line. ··· 3405 3639 } 3406 3640 } 3407 3641 3642 + // Form controls with replaced_size are atomic inline boxes. 3643 + if let Some((w, h)) = child.replaced_size { 3644 + items.push(InlineItemKind::AtomicInline { 3645 + width: w, 3646 + height: h, 3647 + font_size: child.font_size, 3648 + }); 3649 + continue; 3650 + } 3651 + 3408 3652 items.push(InlineItemKind::InlineStart { 3409 3653 margin_left: child.margin.left, 3410 3654 padding_left: child.padding.left, ··· 3475 3719 } 3476 3720 3477 3721 // Build line boxes, respecting float-narrowed available widths. 3478 - let mut all_lines: Vec<(Vec<PendingFragment>, f32, f32)> = Vec::new(); // (fragments, line_left_offset, line_available_width) 3722 + // Each entry: (fragments, line_left_offset, line_available_width, max_line_height). 3723 + let mut all_lines: Vec<(Vec<PendingFragment>, f32, f32, f32)> = Vec::new(); 3479 3724 let mut current_line: Vec<PendingFragment> = Vec::new(); 3480 3725 let mut cursor_x: f32 = 0.0; 3481 3726 let mut line_y = parent.rect.y; 3727 + let mut current_line_max_height: f32 = line_height; 3482 3728 3483 3729 // Compute the available width for the current line, narrowed by floats. 3484 3730 let line_avail = |y: f32| -> (f32, f32) { ··· 3508 3754 3509 3755 // If this word doesn't fit and the line isn't empty, break. 3510 3756 if cursor_x > 0.0 && cursor_x + word_width > current_line_width { 3757 + let used_height = current_line_max_height; 3511 3758 all_lines.push(( 3512 3759 std::mem::take(&mut current_line), 3513 3760 current_line_offset, 3514 3761 current_line_width, 3762 + used_height, 3515 3763 )); 3516 - line_y += line_height; 3764 + line_y += used_height; 3765 + current_line_max_height = line_height; 3517 3766 let (off, w) = line_avail(line_y); 3518 3767 current_line_offset = off; 3519 3768 current_line_width = w; ··· 3541 3790 } 3542 3791 } 3543 3792 InlineItemKind::ForcedBreak => { 3793 + let used_height = current_line_max_height; 3544 3794 all_lines.push(( 3545 3795 std::mem::take(&mut current_line), 3546 3796 current_line_offset, 3547 3797 current_line_width, 3798 + used_height, 3548 3799 )); 3549 - line_y += line_height; 3800 + line_y += used_height; 3801 + current_line_max_height = line_height; 3550 3802 let (off, w) = line_avail(line_y); 3551 3803 current_line_offset = off; 3552 3804 current_line_width = w; ··· 3566 3818 } => { 3567 3819 cursor_x += margin_right + padding_right + border_right; 3568 3820 } 3821 + InlineItemKind::AtomicInline { 3822 + width, 3823 + height, 3824 + font_size, 3825 + } => { 3826 + // Atomic inline box: treat like a single indivisible word. 3827 + if cursor_x > 0.0 && cursor_x + width > current_line_width { 3828 + let used_height = current_line_max_height; 3829 + all_lines.push(( 3830 + std::mem::take(&mut current_line), 3831 + current_line_offset, 3832 + current_line_width, 3833 + used_height, 3834 + )); 3835 + line_y += used_height; 3836 + current_line_max_height = line_height; 3837 + let (off, w) = line_avail(line_y); 3838 + current_line_offset = off; 3839 + current_line_width = w; 3840 + cursor_x = 0.0; 3841 + } 3842 + 3843 + // Update max line height to accommodate tall atomic boxes. 3844 + if *height > current_line_max_height { 3845 + current_line_max_height = *height; 3846 + } 3847 + 3848 + // Represent the atomic box as a placeholder fragment. 3849 + // The actual rendering uses the LayoutBox's replaced_size. 3850 + current_line.push(PendingFragment { 3851 + text: String::new(), 3852 + x: cursor_x, 3853 + width: *width, 3854 + font_size: *font_size, 3855 + color: Color::new(0, 0, 0, 0), 3856 + text_decoration: TextDecoration::None, 3857 + background_color: Color::new(0, 0, 0, 0), 3858 + }); 3859 + cursor_x += width; 3860 + } 3569 3861 } 3570 3862 } 3571 3863 3572 3864 // Flush the last line. 3573 3865 if !current_line.is_empty() { 3574 - all_lines.push((current_line, current_line_offset, current_line_width)); 3866 + all_lines.push(( 3867 + current_line, 3868 + current_line_offset, 3869 + current_line_width, 3870 + current_line_max_height, 3871 + )); 3575 3872 } 3576 3873 3577 3874 if all_lines.is_empty() { ··· 3584 3881 let mut y = parent.rect.y; 3585 3882 let num_lines = all_lines.len(); 3586 3883 3587 - for (line_idx, (line_fragments, line_offset, line_avail_w)) in all_lines.iter().enumerate() { 3884 + for (line_idx, (line_fragments, line_offset, line_avail_w, max_h)) in 3885 + all_lines.iter().enumerate() 3886 + { 3588 3887 if line_fragments.is_empty() { 3589 - y += line_height; 3888 + y += *max_h; 3590 3889 continue; 3591 3890 } 3592 3891 ··· 3614 3913 }); 3615 3914 } 3616 3915 3617 - y += line_height; 3916 + y += *max_h; 3618 3917 } 3619 3918 3620 - parent.rect.height = num_lines as f32 * line_height; 3919 + let total_height: f32 = all_lines.iter().map(|(_, _, _, h)| h).sum(); 3920 + parent.rect.height = total_height; 3621 3921 parent.lines = text_lines; 3622 3922 } 3623 3923 ··· 3959 4259 tree.height = tree.root.margin_box_height(); 3960 4260 } 3961 4261 4262 + // --------------------------------------------------------------------------- 4263 + // Fieldset / Legend layout adjustment 4264 + // --------------------------------------------------------------------------- 4265 + 4266 + /// Adjust fieldset layout: if the first in-flow child is a `<legend>`, shift 4267 + /// it upward to overlap the fieldset's top border, per the HTML spec. 4268 + fn adjust_fieldset_legend(fieldset: &mut LayoutBox, doc: &Document) { 4269 + // Find the legend child index. 4270 + let legend_idx = fieldset.children.iter().position(|child| { 4271 + if let BoxType::Block(node_id) = &child.box_type { 4272 + doc.tag_name(*node_id) == Some("legend") 4273 + } else { 4274 + false 4275 + } 4276 + }); 4277 + 4278 + let legend_idx = match legend_idx { 4279 + Some(i) => i, 4280 + None => return, 4281 + }; 4282 + 4283 + let border_top = fieldset.border.top; 4284 + if border_top <= 0.0 { 4285 + return; // Nothing to overlap. 4286 + } 4287 + 4288 + let legend = &mut fieldset.children[legend_idx]; 4289 + 4290 + // The legend's height (including margin box). 4291 + let legend_margin_height = legend.margin.top 4292 + + legend.border.top 4293 + + legend.padding.top 4294 + + legend.rect.height 4295 + + legend.padding.bottom 4296 + + legend.border.bottom 4297 + + legend.margin.bottom; 4298 + 4299 + // Shift the legend upward by half the border width so it straddles the 4300 + // top border line. Center it vertically over the border. 4301 + let shift_up = (legend_margin_height / 2.0).min(border_top / 2.0 + legend_margin_height / 2.0); 4302 + offset_subtree(legend, 0.0, -shift_up); 4303 + } 4304 + 3962 4305 #[cfg(test)] 3963 4306 mod tests { 3964 4307 use super::*; ··· 7217 7560 // Second div should be clean. 7218 7561 let second_div_box = &body_box.children[1]; 7219 7562 assert!(!second_div_box.dirty, "second div should be clean"); 7563 + } 7564 + 7565 + // --- Form control layout tests --- 7566 + 7567 + #[test] 7568 + fn text_input_has_intrinsic_size() { 7569 + let mut doc = Document::new(); 7570 + let root = doc.root(); 7571 + let html = doc.create_element("html"); 7572 + let body = doc.create_element("body"); 7573 + let p = doc.create_element("p"); 7574 + let input = doc.create_element("input"); 7575 + doc.set_attribute(input, "type", "text"); 7576 + doc.append_child(root, html); 7577 + doc.append_child(html, body); 7578 + doc.append_child(body, p); 7579 + doc.append_child(p, input); 7580 + 7581 + let tree = layout_doc(&doc); 7582 + let body_box = &tree.root.children[0]; 7583 + let p_box = &body_box.children[0]; 7584 + 7585 + // The p should have positive dimensions from the input's intrinsic size. 7586 + assert!( 7587 + p_box.rect.width > 0.0, 7588 + "p containing input should have positive width" 7589 + ); 7590 + assert!( 7591 + p_box.rect.height > 0.0, 7592 + "p containing input should have positive height" 7593 + ); 7594 + } 7595 + 7596 + #[test] 7597 + fn text_input_default_size_20_chars() { 7598 + // Default text input: size=20 characters. 7599 + let font_size = 16.0; 7600 + let char_width = font_size * 0.6; 7601 + let expected_width = 7602 + 20.0 * char_width + FORM_CONTROL_PADDING_LR * 2.0 + FORM_CONTROL_BORDER * 2.0; 7603 + 7604 + let size = form_control_intrinsic_size_for_test("input", "text", &[], font_size); 7605 + let (w, h) = size.expect("text input should have intrinsic size"); 7606 + assert!( 7607 + (w - expected_width).abs() < 0.1, 7608 + "text input width: expected ~{expected_width}, got {w}" 7609 + ); 7610 + assert!(h > 0.0, "text input height should be positive"); 7611 + } 7612 + 7613 + #[test] 7614 + fn text_input_custom_size_attribute() { 7615 + let font_size = 16.0; 7616 + let char_width = font_size * 0.6; 7617 + let expected_width = 7618 + 40.0 * char_width + FORM_CONTROL_PADDING_LR * 2.0 + FORM_CONTROL_BORDER * 2.0; 7619 + 7620 + let size = 7621 + form_control_intrinsic_size_for_test("input", "text", &[("size", "40")], font_size); 7622 + let (w, _) = size.expect("input with size=40 should have intrinsic size"); 7623 + assert!( 7624 + (w - expected_width).abs() < 0.1, 7625 + "input size=40 width: expected ~{expected_width}, got {w}" 7626 + ); 7627 + } 7628 + 7629 + #[test] 7630 + fn checkbox_fixed_size() { 7631 + let size = form_control_intrinsic_size_for_test("input", "checkbox", &[], 16.0); 7632 + let (w, h) = size.expect("checkbox should have intrinsic size"); 7633 + assert_eq!(w, CHECK_RADIO_SIZE); 7634 + assert_eq!(h, CHECK_RADIO_SIZE); 7635 + } 7636 + 7637 + #[test] 7638 + fn radio_fixed_size() { 7639 + let size = form_control_intrinsic_size_for_test("input", "radio", &[], 16.0); 7640 + let (w, h) = size.expect("radio should have intrinsic size"); 7641 + assert_eq!(w, CHECK_RADIO_SIZE); 7642 + assert_eq!(h, CHECK_RADIO_SIZE); 7643 + } 7644 + 7645 + #[test] 7646 + fn hidden_input_no_size() { 7647 + let size = form_control_intrinsic_size_for_test("input", "hidden", &[], 16.0); 7648 + assert!( 7649 + size.is_none(), 7650 + "hidden input should not have intrinsic size" 7651 + ); 7652 + } 7653 + 7654 + #[test] 7655 + fn textarea_default_20x2() { 7656 + let font_size = 16.0; 7657 + let char_width = font_size * 0.6; 7658 + let line_height = font_size * 1.2; 7659 + let expected_width = 7660 + 20.0 * char_width + FORM_CONTROL_PADDING_LR * 2.0 + FORM_CONTROL_BORDER * 2.0; 7661 + let expected_height = 7662 + 2.0 * line_height + FORM_CONTROL_PADDING_TB * 2.0 + FORM_CONTROL_BORDER * 2.0; 7663 + 7664 + let size = form_control_intrinsic_size_for_test("textarea", "", &[], font_size); 7665 + let (w, h) = size.expect("textarea should have intrinsic size"); 7666 + assert!( 7667 + (w - expected_width).abs() < 0.1, 7668 + "textarea width: expected ~{expected_width}, got {w}" 7669 + ); 7670 + assert!( 7671 + (h - expected_height).abs() < 0.1, 7672 + "textarea height: expected ~{expected_height}, got {h}" 7673 + ); 7674 + } 7675 + 7676 + #[test] 7677 + fn textarea_custom_rows_cols() { 7678 + let font_size = 16.0; 7679 + let char_width = font_size * 0.6; 7680 + let line_height = font_size * 1.2; 7681 + let expected_width = 7682 + 40.0 * char_width + FORM_CONTROL_PADDING_LR * 2.0 + FORM_CONTROL_BORDER * 2.0; 7683 + let expected_height = 7684 + 5.0 * line_height + FORM_CONTROL_PADDING_TB * 2.0 + FORM_CONTROL_BORDER * 2.0; 7685 + 7686 + let size = form_control_intrinsic_size_for_test( 7687 + "textarea", 7688 + "", 7689 + &[("cols", "40"), ("rows", "5")], 7690 + font_size, 7691 + ); 7692 + let (w, h) = size.expect("textarea should have intrinsic size"); 7693 + assert!( 7694 + (w - expected_width).abs() < 0.1, 7695 + "textarea cols=40 width: expected ~{expected_width}, got {w}" 7696 + ); 7697 + assert!( 7698 + (h - expected_height).abs() < 0.1, 7699 + "textarea rows=5 height: expected ~{expected_height}, got {h}" 7700 + ); 7701 + } 7702 + 7703 + #[test] 7704 + fn submit_button_intrinsic_size() { 7705 + let size = form_control_intrinsic_size_for_test("input", "submit", &[], 16.0); 7706 + let (w, h) = size.expect("submit button should have intrinsic size"); 7707 + assert!(w > 0.0, "submit button width should be positive"); 7708 + assert!(h > 0.0, "submit button height should be positive"); 7709 + } 7710 + 7711 + #[test] 7712 + fn button_element_intrinsic_size() { 7713 + let mut doc = Document::new(); 7714 + let button = doc.create_element("button"); 7715 + let text = doc.create_text("Click me"); 7716 + doc.append_child(button, text); 7717 + 7718 + let size = form_control_intrinsic_size(button, &doc, 16.0); 7719 + let (w, h) = size.expect("button should have intrinsic size"); 7720 + assert!(w > 0.0, "button width should be positive"); 7721 + assert!(h > 0.0, "button height should be positive"); 7722 + } 7723 + 7724 + #[test] 7725 + fn select_intrinsic_size() { 7726 + let mut doc = Document::new(); 7727 + let select = doc.create_element("select"); 7728 + let opt1 = doc.create_element("option"); 7729 + let opt1_text = doc.create_text("Short"); 7730 + let opt2 = doc.create_element("option"); 7731 + let opt2_text = doc.create_text("A longer option text"); 7732 + doc.append_child(select, opt1); 7733 + doc.append_child(opt1, opt1_text); 7734 + doc.append_child(select, opt2); 7735 + doc.append_child(opt2, opt2_text); 7736 + 7737 + let size = form_control_intrinsic_size(select, &doc, 16.0); 7738 + let (w, h) = size.expect("select should have intrinsic size"); 7739 + // Width should accommodate the longest option. 7740 + assert!(w > 0.0, "select width should be positive"); 7741 + assert!(h > 0.0, "select height should be positive"); 7742 + 7743 + // Longer option text should make it wider than a select with short options only. 7744 + let mut doc2 = Document::new(); 7745 + let select2 = doc2.create_element("select"); 7746 + let opt = doc2.create_element("option"); 7747 + let opt_text = doc2.create_text("Hi"); 7748 + doc2.append_child(select2, opt); 7749 + doc2.append_child(opt, opt_text); 7750 + let size2 = form_control_intrinsic_size(select2, &doc2, 16.0); 7751 + let (w2, _) = size2.expect("select should have intrinsic size"); 7752 + assert!( 7753 + w > w2, 7754 + "select with longer options ({w}) should be wider than short ({w2})" 7755 + ); 7756 + } 7757 + 7758 + #[test] 7759 + fn fieldset_is_block() { 7760 + let mut doc = Document::new(); 7761 + let root = doc.root(); 7762 + let html = doc.create_element("html"); 7763 + let body = doc.create_element("body"); 7764 + let fieldset = doc.create_element("fieldset"); 7765 + let legend = doc.create_element("legend"); 7766 + let legend_text = doc.create_text("Personal Info"); 7767 + let input = doc.create_element("input"); 7768 + doc.set_attribute(input, "type", "text"); 7769 + doc.append_child(root, html); 7770 + doc.append_child(html, body); 7771 + doc.append_child(body, fieldset); 7772 + doc.append_child(fieldset, legend); 7773 + doc.append_child(legend, legend_text); 7774 + doc.append_child(fieldset, input); 7775 + 7776 + let tree = layout_doc(&doc); 7777 + let body_box = &tree.root.children[0]; 7778 + let fieldset_box = &body_box.children[0]; 7779 + 7780 + assert!( 7781 + matches!(fieldset_box.box_type, BoxType::Block(_)), 7782 + "fieldset should be a block box" 7783 + ); 7784 + assert!( 7785 + fieldset_box.rect.width > 0.0, 7786 + "fieldset should have positive width" 7787 + ); 7788 + assert!( 7789 + fieldset_box.rect.height > 0.0, 7790 + "fieldset should have positive height" 7791 + ); 7792 + } 7793 + 7794 + #[test] 7795 + fn css_width_overrides_intrinsic() { 7796 + let mut doc = Document::new(); 7797 + let root = doc.root(); 7798 + let html = doc.create_element("html"); 7799 + let body = doc.create_element("body"); 7800 + let p = doc.create_element("p"); 7801 + let input = doc.create_element("input"); 7802 + doc.set_attribute(input, "type", "text"); 7803 + doc.set_attribute(input, "style", "width: 300px"); 7804 + doc.append_child(root, html); 7805 + doc.append_child(html, body); 7806 + doc.append_child(body, p); 7807 + doc.append_child(p, input); 7808 + 7809 + let font = test_font(); 7810 + let sheets = extract_stylesheets(&doc); 7811 + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); 7812 + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); 7813 + 7814 + // Find the input box by looking for replaced_size. 7815 + let has_replaced = tree.root.iter().any(|b| b.replaced_size.is_some()); 7816 + assert!(has_replaced, "input should have replaced_size set"); 7817 + 7818 + // The p should be laid out with the input contributing to its height. 7819 + let body_box = &tree.root.children[0]; 7820 + let p_box = &body_box.children[0]; 7821 + assert!( 7822 + p_box.rect.height > 0.0, 7823 + "p containing input should have positive height" 7824 + ); 7825 + } 7826 + 7827 + #[test] 7828 + fn form_controls_are_atomic_inline() { 7829 + // When multiple form controls are in a p, they should lay out side by side. 7830 + let mut doc = Document::new(); 7831 + let root = doc.root(); 7832 + let html = doc.create_element("html"); 7833 + let body = doc.create_element("body"); 7834 + let p = doc.create_element("p"); 7835 + let input1 = doc.create_element("input"); 7836 + doc.set_attribute(input1, "type", "text"); 7837 + doc.set_attribute(input1, "size", "5"); 7838 + let input2 = doc.create_element("input"); 7839 + doc.set_attribute(input2, "type", "text"); 7840 + doc.set_attribute(input2, "size", "5"); 7841 + doc.append_child(root, html); 7842 + doc.append_child(html, body); 7843 + doc.append_child(body, p); 7844 + doc.append_child(p, input1); 7845 + doc.append_child(p, input2); 7846 + 7847 + let tree = layout_doc(&doc); 7848 + let body_box = &tree.root.children[0]; 7849 + let p_box = &body_box.children[0]; 7850 + 7851 + // The p should have positive dimensions. 7852 + assert!(p_box.rect.width > 0.0, "p should have positive width"); 7853 + assert!(p_box.rect.height > 0.0, "p should have positive height"); 7854 + } 7855 + 7856 + /// Helper: create a DOM with a single form element and compute its intrinsic size. 7857 + fn form_control_intrinsic_size_for_test( 7858 + tag: &str, 7859 + input_type: &str, 7860 + attrs: &[(&str, &str)], 7861 + font_size: f32, 7862 + ) -> Option<(f32, f32)> { 7863 + let mut doc = Document::new(); 7864 + let el = doc.create_element(tag); 7865 + if tag == "input" && !input_type.is_empty() { 7866 + doc.set_attribute(el, "type", input_type); 7867 + } 7868 + for (name, value) in attrs { 7869 + doc.set_attribute(el, name, value); 7870 + } 7871 + form_control_intrinsic_size(el, &doc, font_size) 7220 7872 } 7221 7873 }
+22
crates/style/src/computed.rs
··· 824 824 u { 825 825 text-decoration: underline; 826 826 } 827 + 828 + fieldset { 829 + display: block; 830 + margin-left: 2px; 831 + margin-right: 2px; 832 + padding-top: 0.35em; 833 + padding-right: 0.75em; 834 + padding-bottom: 0.625em; 835 + padding-left: 0.75em; 836 + border-width: 2px; 837 + border-style: groove; 838 + } 839 + 840 + legend { 841 + display: block; 842 + padding-left: 2px; 843 + padding-right: 2px; 844 + } 845 + 846 + input, textarea, select, button { 847 + display: inline; 848 + } 827 849 "#; 828 850 829 851 // ---------------------------------------------------------------------------