render: size and paint native selects
This commit is contained in:
1 parent
9c71700824
commit
35c54936d5
3 files changed
+209
-3
No files matched your search
@@ -1090,15 +1090,79 @@ pub(crate) fn layout_dom_with_web_fonts(
|
|||||||
// top-down values are known.
|
// top-down values are known.
|
||||||
propagate_border_spacing(tree, &mut styles);
|
propagate_border_spacing(tree, &mut styles);
|
||||||
|
|
||||||
// Resolve native input-control intrinsic border-box geometry after
|
// Resolve native form-control intrinsic border-box geometry after
|
||||||
// inheritance and author cascading. Text-like inputs use the HTML
|
// inheritance and author cascading. Text-like inputs use the HTML
|
||||||
// `size` attribute (20 by default) and the control's own computed
|
// `size` attribute (20 by default) and the control's own computed
|
||||||
// font; author CSS widths/heights remain authoritative. Without this
|
// font; author CSS widths/heights remain authoritative. Without this
|
||||||
// replaced-control sizing, every input is an empty auto-sized leaf
|
// replaced-control sizing, inputs/selects are empty auto-sized leaves
|
||||||
// (0px tall and often stretched to its container).
|
// (0px tall and often stretched to their container).
|
||||||
for (&id, style) in styles.iter_mut() {
|
for (&id, style) in styles.iter_mut() {
|
||||||
let Some(node) = tree.get_node(id) else { continue };
|
let Some(node) = tree.get_node(id) else { continue };
|
||||||
let Some(element) = node.as_element() else { continue };
|
let Some(element) = node.as_element() else { continue };
|
||||||
|
if element.local.as_ref() == "select" {
|
||||||
|
let option_labels: Vec<String> = tree
|
||||||
|
.descendants(id)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|option_id| {
|
||||||
|
tree.get_node(*option_id)
|
||||||
|
.map_or(false, |option| {
|
||||||
|
option
|
||||||
|
.as_element()
|
||||||
|
.map_or(false, |name| name.local.as_ref() == "option")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|option_id| tree.text_content(option_id).trim().to_string())
|
||||||
|
.collect();
|
||||||
|
let font_size = style.font_size.unwrap_or(13.333_333).max(1.0);
|
||||||
|
let bold = crate::style::used_font_weight(style) >= 600;
|
||||||
|
let label_width = option_labels
|
||||||
|
.iter()
|
||||||
|
.map(|label| {
|
||||||
|
text_width(
|
||||||
|
label,
|
||||||
|
font_size,
|
||||||
|
bold,
|
||||||
|
style.font_family.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.fold(0.0f32, f32::max);
|
||||||
|
let horizontal_edges = style.padding.left
|
||||||
|
+ style.padding.right
|
||||||
|
+ style.border.left
|
||||||
|
+ style.border.right;
|
||||||
|
let vertical_edges = style.padding.top
|
||||||
|
+ style.padding.bottom
|
||||||
|
+ style.border.top
|
||||||
|
+ style.border.bottom;
|
||||||
|
let rows = node
|
||||||
|
.get_attribute("size")
|
||||||
|
.and_then(|value| value.parse::<usize>().ok())
|
||||||
|
.filter(|&value| value > 1)
|
||||||
|
.unwrap_or(1) as f32;
|
||||||
|
let intrinsic_width = label_width + horizontal_edges;
|
||||||
|
let intrinsic_height =
|
||||||
|
crate::inline::used_line_height(style).max(1.0) * rows
|
||||||
|
+ vertical_edges;
|
||||||
|
if style.width == crate::Dimension::Auto {
|
||||||
|
style.width = crate::Dimension::Px(
|
||||||
|
if style.box_sizing == crate::BoxSizing::ContentBox {
|
||||||
|
label_width
|
||||||
|
} else {
|
||||||
|
intrinsic_width
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if style.height == crate::Dimension::Auto {
|
||||||
|
style.height = crate::Dimension::Px(
|
||||||
|
if style.box_sizing == crate::BoxSizing::ContentBox {
|
||||||
|
(intrinsic_height - vertical_edges).max(0.0)
|
||||||
|
} else {
|
||||||
|
intrinsic_height
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if element.local.as_ref() != "input" {
|
if element.local.as_ref() != "input" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -5095,4 +5159,23 @@ mod tests {
|
|||||||
assert_eq!(pseudo.line_height, host.line_height);
|
assert_eq!(pseudo.line_height, host.line_height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_select_sizes_for_its_widest_option() {
|
||||||
|
let tree = parse_html(
|
||||||
|
r#"<select id="language">
|
||||||
|
<option selected>En</option>
|
||||||
|
<option>Brazilian Portuguese</option>
|
||||||
|
</select>"#,
|
||||||
|
);
|
||||||
|
let laid = layout_dom(&tree, (500.0, 200.0));
|
||||||
|
let select = tree.get_element_by_id("language").unwrap();
|
||||||
|
let rect = laid.rects[&select];
|
||||||
|
|
||||||
|
assert!(rect.width > 120.0, "widest option should set width: {rect:?}");
|
||||||
|
assert!(
|
||||||
|
(18.0..=22.0).contains(&rect.height),
|
||||||
|
"closed native select should have one-line control height: {rect:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -469,6 +469,52 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A closed native `<select>` paints only its selected option. Options
|
||||||
|
// themselves are popup content (`display:none` in the layout tree),
|
||||||
|
// so the label and disclosure arrow belong to the atomic control.
|
||||||
|
if name.local.as_ref() == "select" {
|
||||||
|
if let Some(label) = selected_option_label(tree, nid) {
|
||||||
|
let fsize = style.font_size.unwrap_or(13.333_333);
|
||||||
|
let line_height = crate::inline::used_line_height(style);
|
||||||
|
let text_x = rect.x + style.border.left + style.padding.left;
|
||||||
|
let text_y = rect.y + (rect.height - line_height) / 2.0;
|
||||||
|
draw_text(
|
||||||
|
&mut pixmap,
|
||||||
|
&label,
|
||||||
|
text_x,
|
||||||
|
text_y,
|
||||||
|
style.color.unwrap_or([0, 0, 0, 255]),
|
||||||
|
fsize,
|
||||||
|
crate::style::used_font_weight(style) >= 600,
|
||||||
|
style.font_family.as_deref(),
|
||||||
|
Some(visible_rect),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if rect.width >= 12.0 && rect.height >= 8.0 {
|
||||||
|
let center_x = rect.x + rect.width - style.border.right - 8.0;
|
||||||
|
let center_y = rect.y + rect.height / 2.0;
|
||||||
|
let mut arrow = PathBuilder::new();
|
||||||
|
arrow.move_to(center_x - 3.5, center_y - 2.0);
|
||||||
|
arrow.line_to(center_x + 3.5, center_y - 2.0);
|
||||||
|
arrow.line_to(center_x, center_y + 2.5);
|
||||||
|
arrow.close();
|
||||||
|
if let Some(arrow) = arrow.finish() {
|
||||||
|
let mut arrow_paint = Paint::default();
|
||||||
|
let color = style.color.unwrap_or([0, 0, 0, 255]);
|
||||||
|
arrow_paint.set_color(Color::from_rgba8(
|
||||||
|
color[0], color[1], color[2], color[3],
|
||||||
|
));
|
||||||
|
pixmap.fill_path(
|
||||||
|
&arrow,
|
||||||
|
&arrow_paint,
|
||||||
|
FillRule::Winding,
|
||||||
|
Transform::identity(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// An empty text `<input>`/`<textarea>` shows its `placeholder`
|
// An empty text `<input>`/`<textarea>` shows its `placeholder`
|
||||||
// attribute as muted text; there is no DOM text node for it (it is
|
// attribute as muted text; there is no DOM text node for it (it is
|
||||||
// not real content), so paint it directly from the attribute instead
|
// not real content), so paint it directly from the attribute instead
|
||||||
@@ -677,6 +723,33 @@ fn list_marker_text(tree: &DomTree, nid: obscura_dom::tree::NodeId, style: Optio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn selected_option_label(
|
||||||
|
tree: &DomTree,
|
||||||
|
select: obscura_dom::tree::NodeId,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut first = None;
|
||||||
|
for option_id in tree.descendants(select) {
|
||||||
|
let Some(option) = tree.get_node(option_id) else { continue };
|
||||||
|
if option
|
||||||
|
.as_element()
|
||||||
|
.map_or(true, |name| name.local.as_ref() != "option")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let label = option
|
||||||
|
.get_attribute("label")
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| tree.text_content(option_id).trim().to_string());
|
||||||
|
if first.is_none() {
|
||||||
|
first = Some(label.clone());
|
||||||
|
}
|
||||||
|
if option.get_attribute("selected").is_some() {
|
||||||
|
return Some(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first
|
||||||
|
}
|
||||||
|
|
||||||
/// Render `tree` at `viewport` to PNG bytes (RGBA 8-bit). Returns None if the
|
/// Render `tree` at `viewport` to PNG bytes (RGBA 8-bit). Returns None if the
|
||||||
/// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`.
|
/// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`.
|
||||||
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Vec<u8>> {
|
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Vec<u8>> {
|
||||||
@@ -3092,6 +3165,32 @@ mod tests {
|
|||||||
assert_eq!(red_pixels, 0, "::before must not bleed through ::after");
|
assert_eq!(red_pixels, 0, "::before must not bleed through ::after");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_select_paints_only_the_selected_label_and_arrow() {
|
||||||
|
let tree = parse_html(
|
||||||
|
r#"<html><body style="margin:0">
|
||||||
|
<select id="theme">
|
||||||
|
<option>Light</option>
|
||||||
|
<option selected>Dark</option>
|
||||||
|
</select>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
let pixmap = paint_dom(&tree, (160.0, 60.0), None).expect("pixmap");
|
||||||
|
let dark_pixels = (0..120)
|
||||||
|
.flat_map(|x| (0..30).map(move |y| (x, y)))
|
||||||
|
.filter(|&(x, y)| {
|
||||||
|
let pixel = pixmap.pixel(x, y).unwrap();
|
||||||
|
pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
dark_pixels > 20,
|
||||||
|
"selected label, border, and disclosure arrow should paint"
|
||||||
|
);
|
||||||
|
let select = tree.get_element_by_id("theme").unwrap();
|
||||||
|
assert_eq!(selected_option_label(&tree, select).as_deref(), Some("Dark"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn later_element_paints_over_earlier() {
|
fn later_element_paints_over_earlier() {
|
||||||
// A blue div nested inside a red one: both cover the origin, and blue
|
// A blue div nested inside a red one: both cover the origin, and blue
|
||||||
|
|||||||
@@ -115,6 +115,30 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
|
|||||||
style.display = Display::Inline;
|
style.display = Display::Inline;
|
||||||
style.is_inline_block = true;
|
style.is_inline_block = true;
|
||||||
style.text_align = Some(taffy::AlignItems::CENTER);
|
style.text_align = Some(taffy::AlignItems::CENTER);
|
||||||
|
} else if tag == "select" {
|
||||||
|
// A closed native select is an atomic inline control. Its option
|
||||||
|
// children belong to the popup (and remain display:none in our frame
|
||||||
|
// tree); the select itself gets intrinsic geometry and paints the
|
||||||
|
// selected label.
|
||||||
|
style.display = Display::Inline;
|
||||||
|
style.is_inline_block = true;
|
||||||
|
style.font_size = Some(13.333_333);
|
||||||
|
style.font_family = Some("arial".to_string());
|
||||||
|
style.line_height = Some(crate::LineHeight::Normal);
|
||||||
|
style.padding = Edges {
|
||||||
|
top: 1.0,
|
||||||
|
right: 20.0,
|
||||||
|
bottom: 1.0,
|
||||||
|
left: 2.0,
|
||||||
|
};
|
||||||
|
style.border = Edges {
|
||||||
|
top: 1.0,
|
||||||
|
right: 1.0,
|
||||||
|
bottom: 1.0,
|
||||||
|
left: 1.0,
|
||||||
|
};
|
||||||
|
style.border_color = Some([118, 118, 118, 255]);
|
||||||
|
style.background_color = Some([255, 255, 255, 255]);
|
||||||
} else if tag == "input" {
|
} else if tag == "input" {
|
||||||
// Native text controls are atomic inline-level boxes with their own
|
// Native text controls are atomic inline-level boxes with their own
|
||||||
// platform font and intrinsic border-box dimensions; they do not
|
// platform font and intrinsic border-box dimensions; they do not
|
||||||
|
|||||||
Reference in new issue
Block a user