Fix hydration, placeholders, and variable font geometry

This commit is contained in:
SGavrl committed 2026-08-04 03:18:09 +00:00
1 parent 66f04d0f43
commit b4d11b4c9e
9 files changed
+386 -95

No files matched your search

+96 -12
View File
@@ -2310,6 +2310,7 @@ pub struct Stylesheet {
/// generated text and positioned decorative boxes.
before_rules: PseudoRuleMap,
after_rules: PseudoRuleMap,
placeholder_rules: PseudoRuleMap,
}
const MAX_STYLESHEET_CACHE_SOURCE_BYTES: usize = 8 * 1024 * 1024;
@@ -2357,8 +2358,10 @@ impl StylesheetCache {
let source_bytes = sources
.iter()
.try_fold(0usize, |total, source| total.checked_add(source.len()));
let compiled_rules =
sheet.rules.len() + sheet.before_rules.rules.len() + sheet.after_rules.rules.len();
let compiled_rules = sheet.rules.len()
+ sheet.before_rules.rules.len()
+ sheet.after_rules.rules.len()
+ sheet.placeholder_rules.rules.len();
if source_bytes.is_some_and(|bytes| bytes <= MAX_STYLESHEET_CACHE_SOURCE_BYTES)
&& compiled_rules <= MAX_STYLESHEET_CACHE_RULES
{
@@ -2422,6 +2425,7 @@ impl Stylesheet {
.rules
.iter()
.chain(&self.after_rules.rules)
.chain(&self.placeholder_rules.rules)
.any(|rule| rule.container_condition_id != ContainerConditionId::NONE)
}
@@ -2508,6 +2512,9 @@ impl Stylesheet {
nid,
)
});
let supports_placeholder = node.as_element().is_some_and(|element| {
matches!(element.local.as_ref(), "input" | "textarea")
});
normal_match
|| self
.before_rules
@@ -2515,6 +2522,10 @@ impl Stylesheet {
|| self
.after_rules
.node_matches_container_query_rule(tree, matcher, nid)
|| (supports_placeholder
&& self
.placeholder_rules
.node_matches_container_query_rule(tree, matcher, nid))
}
pub(crate) fn container_condition_depth(&self) -> usize {
@@ -2578,6 +2589,7 @@ impl Stylesheet {
candidate_slot_count: 0,
before_rules: PseudoRuleMap::default(),
after_rules: PseudoRuleMap::default(),
placeholder_rules: PseudoRuleMap::default(),
};
let mut order = 0usize;
let mut layers = LayerRegistry::default();
@@ -2717,6 +2729,45 @@ impl Stylesheet {
order += 1;
continue;
}
if let Some(base) = strip_pseudo_element(sel_trim, "placeholder") {
if let Some(sel) = tree.compile_rule_selector(base) {
note_selector_for_invalidation(
&mut sheet.invalidation_map,
base,
order,
);
note_declaration_attribute_dependencies(
&mut sheet.invalidation_map,
&decls,
order,
);
let (normal_decls, important_decls) =
crate::style::partition_declarations(&decls);
let normal_flags = declaration_stream_flags(&normal_decls);
let important_flags = declaration_stream_flags(&important_decls);
let specificity = sel.specificity();
sheet.placeholder_rules.push(PseudoRule {
sel,
specificity,
normal_decls,
important_decls,
normal_flags,
important_flags,
candidate_slot: NO_CANDIDATE_SLOT,
order,
container_condition_id,
layer,
});
} else if selector_requires_conservative_tracking(base) {
note_selector_for_invalidation(
&mut sheet.invalidation_map,
base,
order,
);
}
order += 1;
continue;
}
let Some(sel) = tree.compile_rule_selector(&selector) else {
if selector_requires_conservative_tracking(&selector) {
note_selector_for_invalidation(
@@ -2793,19 +2844,25 @@ impl Stylesheet {
props: &HashMap<String, String>,
host_style: &LayoutStyle,
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
self.pseudo_styles_internal(tree, matcher, nid, props, host_style, None)
let (before, after, _) =
self.pseudo_styles_internal(tree, matcher, nid, props, host_style, None);
(before, after)
}
pub(crate) fn pseudo_styles_with_container_queries(
pub(crate) fn all_pseudo_styles(
&self,
tree: &DomTree,
matcher: &mut Matcher,
nid: NodeId,
props: &HashMap<String, String>,
host_style: &LayoutStyle,
evaluator: &mut ContainerQueryEvaluator<'_>,
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
self.pseudo_styles_internal(tree, matcher, nid, props, host_style, Some(evaluator))
evaluator: Option<&mut ContainerQueryEvaluator<'_>>,
) -> (
Option<LayoutStyle>,
Option<LayoutStyle>,
Option<LayoutStyle>,
) {
self.pseudo_styles_internal(tree, matcher, nid, props, host_style, evaluator)
}
fn pseudo_styles_internal(
@@ -2816,8 +2873,12 @@ impl Stylesheet {
props: &HashMap<String, String>,
host_style: &LayoutStyle,
mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>,
) -> (Option<LayoutStyle>, Option<LayoutStyle>) {
let mut build = |rules: &PseudoRuleMap, matcher: &mut Matcher| {
) -> (
Option<LayoutStyle>,
Option<LayoutStyle>,
Option<LayoutStyle>,
) {
let mut build = |rules: &PseudoRuleMap, matcher: &mut Matcher, is_placeholder: bool| {
let mut normal_matched: Vec<(u32, usize, usize)> = Vec::new();
let mut important_matched: Vec<(u32, usize, usize)> = Vec::new();
if rules.candidate_slot_count != 0 {
@@ -2945,6 +3006,11 @@ impl Stylesheet {
..Default::default()
};
style.color_scheme_dark = host_style.color_scheme_dark;
if is_placeholder {
// Chromium's light native-control placeholder color. Author
// declarations cascade over this UA-origin initial value.
style.color = Some([117, 117, 117, 255]);
}
let inherited_color_scheme_dark = host_style.color_scheme_dark;
let mut generated_content = None;
for &(_, _, index) in &normal_matched {
@@ -3001,15 +3067,33 @@ impl Stylesheet {
.as_ref()
.map(|items| generated_content_with_zero_counters(items));
style.generated_content = generated_content;
if style.generated_content.is_some() || style.content_image.is_some() {
if is_placeholder {
// `color` is inherited on the pseudo. The declaration parser
// represents `inherit` as None, so resolve it against the
// originating control after the author cascade.
if style.color.is_none() {
style.color = host_style.color;
}
Some(style)
} else if style.generated_content.is_some() || style.content_image.is_some() {
Some(style)
} else {
None
}
};
let supports_placeholder = tree
.get_node(nid)
.is_some_and(|node| {
node.as_element().is_some_and(|element| {
matches!(element.local.as_ref(), "input" | "textarea")
})
});
(
build(&self.before_rules, matcher),
build(&self.after_rules, matcher),
build(&self.before_rules, matcher, false),
build(&self.after_rules, matcher, false),
supports_placeholder
.then(|| build(&self.placeholder_rules, matcher, true))
.flatten(),
)
}
+9 -13
View File
@@ -2266,19 +2266,14 @@ fn cascade_walk(
custom_properties.insert(id, this_props.clone());
style.is_replaced_box |= style.content_image.is_some();
style.has_replaced_sizing |= style.content_image.is_some();
let (mut before_pseudo, mut after_pseudo) =
if let Some(evaluator) = container_evaluator.as_deref_mut() {
sheet.pseudo_styles_with_container_queries(
tree,
matcher,
id,
&this_props,
&style,
evaluator,
)
} else {
sheet.pseudo_styles(tree, matcher, id, &this_props, &style)
};
let (mut before_pseudo, mut after_pseudo, placeholder_pseudo) = sheet.all_pseudo_styles(
tree,
matcher,
id,
&this_props,
&style,
container_evaluator.as_deref_mut(),
);
for pseudo in [&mut before_pseudo, &mut after_pseudo]
.into_iter()
.flatten()
@@ -2296,6 +2291,7 @@ fn cascade_walk(
.and_then(|pseudo| pseudo.before_content.clone());
style.before_pseudo = before_pseudo.map(Box::new);
style.after_pseudo = after_pseudo.map(Box::new);
style.placeholder_pseudo = placeholder_pseudo.map(Box::new);
descendant_color_scheme_dark = style.color_scheme_dark;
styles.insert(id, style);
}
+34
View File
@@ -4120,6 +4120,40 @@ mod tests {
wuff::decompress_woff2(&compressed).expect("decompress variable-font fixture")
}
#[test]
fn variable_font_multi_axis_shaping_preserves_space_advance() {
let mut engine = TextEngine::new_with_web_fonts(&[WebFont {
data: variable_font_fixture(),
family: Some("Obscura VF Test".into()),
weight: Some((100, 900)),
italic: Some(false),
}]);
let tree = obscura_dom::parse_html("<p id='copy'>Tools and</p>");
let copy = tree.get_element_by_id("copy").unwrap();
let mut style = LayoutStyle::default();
style.display = Display::Block;
style.font_family = Some("Obscura VF Test".into());
style.font_weight = Some("700".into());
style.font_size = Some(32.0);
style.font_optical_sizing = Some(crate::FontOpticalSizing::Auto);
let item = engine
.try_build(&tree, copy, &HashMap::from([(copy, style)]))
.unwrap();
engine.finalize(item, (0.0, 0.0), 600.0, None);
let shaped_text = engine.item_text(item).to_string();
let space_advance = engine.items[item]
.buffer
.layout_runs()
.flat_map(|run| run.glyphs.iter())
.find(|glyph| &shaped_text[glyph.start..glyph.end] == " ")
.map(|glyph| glyph.w)
.expect("shaped space glyph");
assert!(
space_advance > 5.0,
"setting wght and opsz must not repeatedly remap avar coordinates and collapse the space advance: {space_advance}"
);
}
fn isolated_fallback_engine() -> (TextEngine, cosmic_text::fontdb::ID, cosmic_text::fontdb::ID)
{
let mut engine = TextEngine::new_with_web_fonts(&[
+3
View File
@@ -1198,6 +1198,9 @@ pub struct LayoutStyle {
/// positioned decorative boxes for layout-independent painting.
pub before_pseudo: Option<Box<LayoutStyle>>,
pub after_pseudo: Option<Box<LayoutStyle>>,
/// Computed author style for the native text-control `::placeholder`
/// pseudo-element. Its anonymous glyphs are painted by the control.
pub placeholder_pseudo: Option<Box<LayoutStyle>>,
/// True for `inline-block`/`inline-flex`/`inline-grid`: participates in
/// the surrounding inline flow from the outside, like plain `inline`
+106 -15
View File
@@ -3669,27 +3669,40 @@ fn paint_laid_dom_scrolled(
let has_value = node
.get_attribute("value")
.map(|v| !v.is_empty())
.unwrap_or(false);
.unwrap_or(false)
|| (name.local.as_ref() == "textarea"
&& !tree.text_content(nid).is_empty());
if !has_value {
if let Some(placeholder) = node.get_attribute("placeholder") {
if !placeholder.is_empty() {
let fsize = style.font_size.unwrap_or(16.0);
let text_x = rect.x + style.padding.left + style.border.left;
let text_y = rect.y + style.padding.top + style.border.top;
draw_text(
&mut pixmap,
placeholder,
text_x,
text_y,
[117, 117, 117, 255],
fsize,
false,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
clip,
element_clip_mask,
raster_scale,
);
let placeholder_style = style.placeholder_pseudo.as_deref();
let mut color = placeholder_style
.and_then(|pseudo| pseudo.color)
.unwrap_or([117, 117, 117, 255]);
let opacity = placeholder_style
.and_then(|pseudo| pseudo.opacity)
.unwrap_or(1.0)
.clamp(0.0, 1.0);
color[3] = ((color[3] as f32) * opacity).round() as u8;
if color[3] != 0 {
draw_text(
&mut pixmap,
placeholder,
text_x,
text_y,
color,
fsize,
false,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
clip,
element_clip_mask,
raster_scale,
);
}
}
}
}
@@ -9139,6 +9152,84 @@ mod tests {
use crate::dom::layout_dom_with_web_fonts;
use obscura_dom::tree_sink::parse_html;
#[test]
fn native_placeholders_honor_default_author_color_opacity_and_value_state() {
let tree = parse_html(
r#"<html><head><style>
html,body { margin:0; background:white }
input { display:block; box-sizing:border-box; width:180px; height:30px;
padding:0; border:0; font-size:20px; background:white }
#colored::placeholder { color:rgb(255,0,0) }
#hidden::placeholder { opacity:0 }
#inherited { color:rgb(0,0,255) }
#inherited::placeholder { color:inherit; opacity:.5 }
</style></head><body>
<input id="default" placeholder="default">
<input id="colored" placeholder="colored">
<input id="hidden" placeholder="hidden">
<input id="filled" placeholder="must not paint" value="actual">
<input id="inherited" placeholder="inherited">
</body></html>"#,
);
let mut resources = RenderResourceCache::default();
let mut prepared = prepare_dom(&tree, (200.0, 130.0), None, &mut resources)
.expect("placeholder layout");
let node = |selector| tree.query_selector(selector).unwrap().unwrap();
assert!(
prepared.layout().styles[&node("#default")]
.placeholder_pseudo
.is_none(),
"the native default must not require an authored pseudo rule"
);
assert_eq!(
prepared.layout().styles[&node("#colored")]
.placeholder_pseudo
.as_deref()
.and_then(|style| style.color),
Some([255, 0, 0, 255])
);
assert_eq!(
prepared.layout().styles[&node("#hidden")]
.placeholder_pseudo
.as_deref()
.and_then(|style| style.opacity),
Some(0.0)
);
let inherited = prepared.layout().styles[&node("#inherited")]
.placeholder_pseudo
.as_deref()
.expect("inherited placeholder style");
assert_eq!(inherited.color, Some([0, 0, 255, 255]));
assert_eq!(inherited.opacity, Some(0.5));
let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0))
.expect("placeholder paint");
let non_white = |top: u32| {
(top..top + 30)
.flat_map(|y| (0..180).map(move |x| (x, y)))
.filter(|&(x, y)| {
let pixel = pixmap.pixel(x, y).unwrap();
pixel.red() < 245 || pixel.green() < 245 || pixel.blue() < 245
})
.count()
};
assert!(non_white(0) > 10, "the native default placeholder must paint");
assert!(
(30..60).any(|y| (0..180).any(|x| {
let pixel = pixmap.pixel(x, y).unwrap();
pixel.red() > 200 && pixel.green() < 80 && pixel.blue() < 80
})),
"the authored placeholder color must reach glyph paint"
);
assert_eq!(non_white(60), 0, "opacity:0 must suppress placeholder glyphs");
assert_eq!(
non_white(90),
0,
"a non-empty control value must suppress placeholder glyphs"
);
}
#[test]
fn cache_only_mode_does_not_load_or_negative_cache_unknown_urls() {
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));