render: honor generated controls and border resets
This commit is contained in:
1 parent
2c12b5a03a
commit
9ec43134ae
4 files changed
+181
-35
No files matched your search
@@ -152,15 +152,15 @@ impl Stylesheet {
|
|||||||
for &(_, _, rule) in &matched {
|
for &(_, _, rule) in &matched {
|
||||||
let expanded = substitute_vars(&rule.normal_decls, props, 0);
|
let expanded = substitute_vars(&rule.normal_decls, props, 0);
|
||||||
crate::style::apply_declarations(&mut style, &expanded);
|
crate::style::apply_declarations(&mut style, &expanded);
|
||||||
if let Some(value) = extract_string_content(&expanded) {
|
if let Some(value) = extract_content(&expanded, tree, nid) {
|
||||||
content = Some(value);
|
content = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for &(_, _, rule) in &matched {
|
for &(_, _, rule) in &matched {
|
||||||
let expanded = substitute_vars(&rule.important_decls, props, 0);
|
let expanded = substitute_vars(&rule.important_decls, props, 0);
|
||||||
crate::style::apply_declarations(&mut style, &expanded);
|
crate::style::apply_declarations(&mut style, &expanded);
|
||||||
if let Some(value) = extract_string_content(&expanded) {
|
if let Some(value) = extract_content(&expanded, tree, nid) {
|
||||||
content = Some(value);
|
content = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
style.before_content = content;
|
style.before_content = content;
|
||||||
@@ -447,30 +447,44 @@ fn strip_pseudo_element<'a>(selector: &'a str, which: &str) -> Option<&'a str> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pull a plain quoted-string `content` value out of a declaration list
|
/// Return the final valid `content` declaration in a declaration list.
|
||||||
/// (`content: ", "` -> `Some(", ")`). Deliberately narrow: real `content`
|
///
|
||||||
/// also accepts `counter()`, `attr()`, `url()` and concatenations of these,
|
/// The outer option says whether a declaration was found; the inner option is
|
||||||
/// none of which apply here (no counters or `attr()`-driven content on the
|
/// the generated text (`none`/`normal` suppress the pseudo). Along with quoted
|
||||||
/// pages this targets), so anything other than a single quoted string is
|
/// strings, support the common `attr(name)` form used by component-library
|
||||||
/// left unhandled rather than guessed at. When `content` is declared more
|
/// buttons and badges. The attribute is resolved against the originating
|
||||||
/// than once (the `content: X; content: X / Y` accessible-alt-text pattern),
|
/// element, as CSS generated content requires.
|
||||||
/// the last one wins, matching normal CSS declaration order.
|
fn extract_content(decls: &str, tree: &DomTree, nid: NodeId) -> Option<Option<String>> {
|
||||||
fn extract_string_content(decls: &str) -> Option<String> {
|
|
||||||
let mut result = None;
|
let mut result = None;
|
||||||
let mut search_from = 0;
|
for raw in crate::style::split_declarations(decls) {
|
||||||
while let Some(rel_idx) = decls[search_from..].find("content") {
|
let Some((name, value)) = raw.split_once(':') else { continue };
|
||||||
let idx = search_from + rel_idx;
|
if !name.trim().eq_ignore_ascii_case("content") {
|
||||||
search_from = idx + "content".len();
|
|
||||||
let after = decls[search_from..].trim_start();
|
|
||||||
let Some(after_colon) = after.strip_prefix(':') else { continue };
|
|
||||||
let after_colon = after_colon.trim_start();
|
|
||||||
let Some(quote) = after_colon.chars().next() else { continue };
|
|
||||||
if quote != '"' && quote != '\'' {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let rest = &after_colon[quote.len_utf8()..];
|
let value = value.trim();
|
||||||
let Some(end) = rest.find(quote) else { continue };
|
if value.eq_ignore_ascii_case("none") || value.eq_ignore_ascii_case("normal") {
|
||||||
result = Some(unescape_css_string(&rest[..end]));
|
result = Some(None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parsed = if let Some(quote) =
|
||||||
|
value.chars().next().filter(|ch| matches!(ch, '"' | '\''))
|
||||||
|
{
|
||||||
|
let rest = &value[quote.len_utf8()..];
|
||||||
|
rest.find(quote).map(|end| unescape_css_string(&rest[..end]))
|
||||||
|
} else if let Some(rest) = value.strip_prefix("attr(") {
|
||||||
|
rest.find(')').map(|end| {
|
||||||
|
tree.get_node(nid)
|
||||||
|
.and_then(|node| {
|
||||||
|
node.get_attribute(rest[..end].trim()).map(str::to_owned)
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(parsed) = parsed {
|
||||||
|
result = Some(Some(parsed));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -1164,6 +1178,22 @@ fn split_media_query_list(query: &str) -> Vec<&str> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_content_resolves_host_attributes_and_resets() {
|
||||||
|
let tree = obscura_dom::parse_html(
|
||||||
|
r#"<button id="cta" data-label="Get Started"></button>"#,
|
||||||
|
);
|
||||||
|
let cta = tree.query_selector("#cta").unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
extract_content("content:attr(data-label)", &tree, cta),
|
||||||
|
Some(Some("Get Started".to_string()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract_content(r#"content:"fallback";content:none"#, &tree, cta),
|
||||||
|
Some(None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keyframes_extract_only_the_final_animation_state() {
|
fn keyframes_extract_only_the_final_animation_state() {
|
||||||
let css = r#"
|
let css = r#"
|
||||||
|
|||||||
@@ -356,9 +356,11 @@ fn cascade_walk(
|
|||||||
sheet.pseudo_styles(tree, matcher, id, &style, &this_props);
|
sheet.pseudo_styles(tree, matcher, id, &style, &this_props);
|
||||||
style.before_content = before_pseudo
|
style.before_content = before_pseudo
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
.filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute))
|
||||||
.and_then(|pseudo| pseudo.before_content.clone());
|
.and_then(|pseudo| pseudo.before_content.clone());
|
||||||
style.after_content = after_pseudo
|
style.after_content = after_pseudo
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
.filter(|pseudo| pseudo.position != Some(taffy::Position::Absolute))
|
||||||
.and_then(|pseudo| pseudo.before_content.clone());
|
.and_then(|pseudo| pseudo.before_content.clone());
|
||||||
style.before_pseudo = before_pseudo.map(Box::new);
|
style.before_pseudo = before_pseudo.map(Box::new);
|
||||||
style.after_pseudo = after_pseudo.map(Box::new);
|
style.after_pseudo = after_pseudo.map(Box::new);
|
||||||
|
|||||||
@@ -271,7 +271,10 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(pseudo) = style.before_pseudo.as_deref() {
|
for pseudo in [style.before_pseudo.as_deref(), style.after_pseudo.as_deref()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
paint_positioned_pseudo(
|
paint_positioned_pseudo(
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
pseudo,
|
pseudo,
|
||||||
@@ -1788,12 +1791,16 @@ fn paint_positioned_pseudo(
|
|||||||
None => Some(rect),
|
None => Some(rect),
|
||||||
};
|
};
|
||||||
let Some(visible) = visible else { return };
|
let Some(visible) = visible else { return };
|
||||||
let Some(sk_rect) = Rect::from_xywh(visible.x, visible.y, visible.width, visible.height) else {
|
let path = if style.border_radius > 0.5 {
|
||||||
return;
|
rounded_rect_path(visible.x, visible.y, visible.width, visible.height, style.border_radius)
|
||||||
};
|
} else {
|
||||||
|
Rect::from_xywh(visible.x, visible.y, visible.width, visible.height).and_then(|rect| {
|
||||||
let mut builder = PathBuilder::new();
|
let mut builder = PathBuilder::new();
|
||||||
builder.push_rect(sk_rect);
|
builder.push_rect(rect);
|
||||||
let Some(path) = builder.finish() else { return };
|
builder.finish()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let Some(path) = path else { return };
|
||||||
if let Some(color) = style.background_color {
|
if let Some(color) = style.background_color {
|
||||||
let mut paint = Paint::default();
|
let mut paint = Paint::default();
|
||||||
paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3]));
|
paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3]));
|
||||||
@@ -1808,6 +1815,37 @@ fn paint_positioned_pseudo(
|
|||||||
if let Some((angle, stops)) = &style.background_gradient {
|
if let Some((angle, stops)) = &style.background_gradient {
|
||||||
paint_linear_gradient(pixmap, &path, &rect, *angle, stops);
|
paint_linear_gradient(pixmap, &path, &rect, *angle, stops);
|
||||||
}
|
}
|
||||||
|
if let Some(content) = style.before_content.as_deref().filter(|content| !content.is_empty()) {
|
||||||
|
let font_size = style.font_size.unwrap_or(16.0);
|
||||||
|
let bold = crate::style::used_font_weight(style) >= 600;
|
||||||
|
let text_width = measure_text(content, font_size, bold, style.font_family.as_deref());
|
||||||
|
let line_height = crate::inline::used_line_height(style);
|
||||||
|
let x = match style.justify_content {
|
||||||
|
Some(taffy::JustifyContent::CENTER) => rect.x + (rect.width - text_width) / 2.0,
|
||||||
|
Some(taffy::JustifyContent::FLEX_END | taffy::JustifyContent::END) => {
|
||||||
|
rect.x + rect.width - style.padding.right - text_width
|
||||||
|
}
|
||||||
|
_ => rect.x + style.padding.left,
|
||||||
|
};
|
||||||
|
let y = match style.align_items {
|
||||||
|
Some(taffy::AlignItems::CENTER) => rect.y + (rect.height - line_height) / 2.0,
|
||||||
|
Some(taffy::AlignItems::FLEX_END | taffy::AlignItems::END) => {
|
||||||
|
rect.y + rect.height - style.padding.bottom - line_height
|
||||||
|
}
|
||||||
|
_ => rect.y + style.padding.top,
|
||||||
|
};
|
||||||
|
draw_text(
|
||||||
|
pixmap,
|
||||||
|
content,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
style.color.unwrap_or([0, 0, 0, 255]),
|
||||||
|
font_size,
|
||||||
|
bold,
|
||||||
|
style.font_family.as_deref(),
|
||||||
|
Some(visible),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch every `<img>` once (seeding `cache` for the paint pass) and record its
|
/// Fetch every `<img>` once (seeding `cache` for the paint pass) and record its
|
||||||
@@ -2874,6 +2912,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paints_positioned_attr_content_over_the_host_background() {
|
||||||
|
let tree = parse_html(
|
||||||
|
r#"<html><head><style>
|
||||||
|
body { margin:0 }
|
||||||
|
#cta {
|
||||||
|
position:relative; width:120px; height:40px; border:0;
|
||||||
|
padding:0; color:transparent; background:red;
|
||||||
|
}
|
||||||
|
#cta::before {
|
||||||
|
content:attr(data-label);
|
||||||
|
position:absolute; inset:1px;
|
||||||
|
display:flex; align-items:center; justify-content:center;
|
||||||
|
border-radius:4px; color:black; background:white;
|
||||||
|
}
|
||||||
|
</style></head><body>
|
||||||
|
<button id="cta" data-label="Get Started">Get Started</button>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
let pixmap = paint_dom(&tree, (140.0, 60.0), None).expect("pixmap");
|
||||||
|
let inner = pixmap.pixel(5, 5).expect("inner pixel");
|
||||||
|
assert_eq!(
|
||||||
|
(inner.red(), inner.green(), inner.blue()),
|
||||||
|
(255, 255, 255),
|
||||||
|
"the generated box must cover the red host background"
|
||||||
|
);
|
||||||
|
let dark_pixels = (35..85)
|
||||||
|
.flat_map(|x| (8..32).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 > 10, "generated attr() text must be painted");
|
||||||
|
}
|
||||||
|
|
||||||
#[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
|
||||||
|
|||||||
@@ -409,6 +409,13 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"border" => {
|
"border" => {
|
||||||
|
if value.split_whitespace().any(|token| {
|
||||||
|
token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden")
|
||||||
|
}) {
|
||||||
|
style.border = Edges::default();
|
||||||
|
style.border_color = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
for p in value.split_whitespace() {
|
for p in value.split_whitespace() {
|
||||||
if let Some(c) = parse_color(p) {
|
if let Some(c) = parse_color(p) {
|
||||||
style.border_color = Some(c);
|
style.border_color = Some(c);
|
||||||
@@ -418,10 +425,18 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"border-width" => { if let Some(e) = edges(value) { style.border = e; } }
|
"border-width" => { if let Some(e) = edges(value) { style.border = e; } }
|
||||||
"border-top-width" | "border-top" => set_edge(&mut style.border, Side::Top, px(value)),
|
"border-top-width" | "border-top" => {
|
||||||
"border-right-width" | "border-right" => set_edge(&mut style.border, Side::Right, px(value)),
|
set_edge(&mut style.border, Side::Top, border_side_width(value))
|
||||||
"border-bottom-width" | "border-bottom" => set_edge(&mut style.border, Side::Bottom, px(value)),
|
}
|
||||||
"border-left-width" | "border-left" => set_edge(&mut style.border, Side::Left, px(value)),
|
"border-right-width" | "border-right" => {
|
||||||
|
set_edge(&mut style.border, Side::Right, border_side_width(value))
|
||||||
|
}
|
||||||
|
"border-bottom-width" | "border-bottom" => {
|
||||||
|
set_edge(&mut style.border, Side::Bottom, border_side_width(value))
|
||||||
|
}
|
||||||
|
"border-left-width" | "border-left" => {
|
||||||
|
set_edge(&mut style.border, Side::Left, border_side_width(value))
|
||||||
|
}
|
||||||
"background-color" => style.background_color = parse_color(value),
|
"background-color" => style.background_color = parse_color(value),
|
||||||
"background" => {
|
"background" => {
|
||||||
// A shorthand resets every omitted background longhand to its
|
// A shorthand resets every omitted background longhand to its
|
||||||
@@ -1906,6 +1921,16 @@ fn hsl_to_rgba(h: f32, s: f32, l: f32, a: u8) -> [u8; 4] {
|
|||||||
|
|
||||||
enum Side { Top, Right, Bottom, Left }
|
enum Side { Top, Right, Bottom, Left }
|
||||||
|
|
||||||
|
fn border_side_width(value: &str) -> Option<f32> {
|
||||||
|
if value.split_whitespace().any(|token| {
|
||||||
|
token.eq_ignore_ascii_case("none") || token.eq_ignore_ascii_case("hidden")
|
||||||
|
}) {
|
||||||
|
Some(0.0)
|
||||||
|
} else {
|
||||||
|
px(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn set_edge(edges: &mut Edges, side: Side, v: Option<f32>) {
|
fn set_edge(edges: &mut Edges, side: Side, v: Option<f32>) {
|
||||||
let v = match v { Some(v) => v, None => return };
|
let v = match v { Some(v) => v, None => return };
|
||||||
match side {
|
match side {
|
||||||
@@ -3503,6 +3528,21 @@ mod tests {
|
|||||||
assert_eq!(collapsed.border_collapse, Some(true));
|
assert_eq!(collapsed.border_collapse, Some(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn border_none_clears_native_and_per_side_widths() {
|
||||||
|
let input = compute_style("input", Some("border:none"));
|
||||||
|
assert_eq!(input.border, Edges::default());
|
||||||
|
|
||||||
|
let side = compute_style(
|
||||||
|
"div",
|
||||||
|
Some("border:3px solid red;border-left:none"),
|
||||||
|
);
|
||||||
|
assert_eq!(side.border.top, 3.0);
|
||||||
|
assert_eq!(side.border.right, 3.0);
|
||||||
|
assert_eq!(side.border.bottom, 3.0);
|
||||||
|
assert_eq!(side.border.left, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn item_self_alignment_parses_and_resets() {
|
fn item_self_alignment_parses_and_resets() {
|
||||||
let aligned = compute_style(
|
let aligned = compute_style(
|
||||||
|
|||||||
Reference in new issue
Block a user