diff --git a/crates/core/elidex-ecs/src/components.rs b/crates/core/elidex-ecs/src/components.rs index 132c1eeea..b21e22b15 100644 --- a/crates/core/elidex-ecs/src/components.rs +++ b/crates/core/elidex-ecs/src/components.rs @@ -276,6 +276,21 @@ impl InlineStyle { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PseudoElementMarker; +/// Resolved list-item marker text (CSS Lists 3 §4.6 implicit `list-item` +/// counter, §4.7 `counter()` formatting) for a `display: list-item` element. +/// +/// Written by the pre-layout generated-content pass (`elidex-style`) as the +/// single source of marker text (One-issue-one-way: render no longer runs the +/// counter machine to derive it), read by render's marker paint. The pass +/// **reconciles** this component every resolve — inserting it on a visible-type +/// list-item, removing it otherwise — so a stale value never survives an element +/// that stops being a list-item (the same explicit-clear discipline as +/// [`InlineFlow`]; element entities persist across re-resolves, unlike pseudo +/// entities which are regenerated). Render owns the `visibility` paint guard, so +/// this is written independently of the element's visibility. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListItemMarker(pub String); + /// Dynamic element state flags for CSS pseudo-class matching. /// /// Tracks whether an element is hovered, focused, active, or a link. diff --git a/crates/core/elidex-ecs/src/lib.rs b/crates/core/elidex-ecs/src/lib.rs index 8b1a59e69..aa3b8c808 100644 --- a/crates/core/elidex-ecs/src/lib.rs +++ b/crates/core/elidex-ecs/src/lib.rs @@ -24,9 +24,10 @@ pub use components::{ AnonymousTableMarker, AssociatedDocument, AttrData, AttrEntityCache, Attributes, BackgroundImages, BaseFrozenUrl, CommentData, DialogReturnValue, DocTypeData, DocumentBaseUrl, ElementState, IframeData, ImageData, InlineFlow, InlineFlowLine, InlineFlowRun, InlineStyle, - IsModalDialog, LinkStylesheet, LoadingAttribute, Namespace, NodeKind, OutputDefaultValue, - OutputValueOverride, PseudoElementMarker, ScrollState, ShadowHost, ShadowRoot, ShadowRootMode, - SlotAssignment, SlotAssignmentMode, SlottedMarker, TagType, TemplateContent, TextContent, + IsModalDialog, LinkStylesheet, ListItemMarker, LoadingAttribute, Namespace, NodeKind, + OutputDefaultValue, OutputValueOverride, PseudoElementMarker, ScrollState, ShadowHost, + ShadowRoot, ShadowRootMode, SlotAssignment, SlotAssignmentMode, SlottedMarker, TagType, + TemplateContent, TextContent, }; pub use dom::equality::{ DOCUMENT_POSITION_CONTAINED_BY, DOCUMENT_POSITION_CONTAINS, DOCUMENT_POSITION_DISCONNECTED, diff --git a/crates/core/elidex-render/src/builder/inline.rs b/crates/core/elidex-render/src/builder/inline.rs index e39e83d91..45e1cfabf 100644 --- a/crates/core/elidex-render/src/builder/inline.rs +++ b/crates/core/elidex-render/src/builder/inline.rs @@ -2,11 +2,10 @@ use elidex_ecs::{EcsDom, Entity, InlineFlow, PseudoElementMarker, TextContent}; use elidex_plugin::{ - ComputedStyle, ContentItem, ContentValue, CssColor, Direction, Display, - FontStyle as PluginFontStyle, LayoutBox, Point, TextAlign, TextDecorationLine, - TextDecorationStyle, TextOrientation, TextTransform, Visibility, WritingMode, + ComputedStyle, CssColor, Direction, Display, FontStyle as PluginFontStyle, LayoutBox, Point, + TextAlign, TextDecorationLine, TextDecorationStyle, TextOrientation, TextTransform, Visibility, + WritingMode, }; -use elidex_style::counter::CounterState; use elidex_text::FontDatabase; use crate::display_list::{DisplayItem, DisplayList, GlyphEntry}; @@ -90,7 +89,6 @@ pub(crate) fn emit_inline_run( font_db: &FontDatabase, font_cache: &mut FontCache, dl: &mut DisplayList, - counter_state: &CounterState, expected_generation: Option, ) { // Converged path: if layout persisted an `InlineFlow` on this run's start @@ -143,7 +141,7 @@ pub(crate) fn emit_inline_run( return; }; - let segments = collect_styled_inline_text(dom, run, &parent_style, 0, counter_state); + let segments = collect_styled_inline_text(dom, run, &parent_style, 0); if segments.is_empty() { return; } @@ -173,7 +171,6 @@ fn collect_styled_inline_text( entities: &[Entity], parent_style: &ComputedStyle, depth: u32, - counter_state: &CounterState, ) -> Vec { if depth >= MAX_INLINE_DEPTH { return Vec::new(); @@ -189,14 +186,17 @@ fn collect_styled_inline_text( // (children can override visibility). let visible = style.visibility == Visibility::Visible; - // Pseudo-element: emit text with own style (skip child recursion). - // If the content property contains counter() / counters(), evaluate - // them using the current counter state instead of the placeholder text. + // Pseudo-element: emit its resolved generated text with its own style + // (skip child recursion). `content` — including counter() / counters() + // — has already been resolved into the pseudo's `TextContent` by the + // pre-layout generated-content pass (the single resolver), so render + // just reads it. if dom.world().get::<&PseudoElementMarker>(entity).is_ok() { if visible { - let text = resolve_pseudo_text(dom, entity, &style, counter_state); - if !text.is_empty() { - segments.push(StyledTextSegment::from_style(text, &style)); + if let Ok(tc) = dom.world().get::<&TextContent>(entity) { + if !tc.0.is_empty() { + segments.push(StyledTextSegment::from_style(tc.0.clone(), &style)); + } } } continue; @@ -208,7 +208,6 @@ fn collect_styled_inline_text( &children, &style, depth + 1, - counter_state, )); continue; } @@ -885,70 +884,3 @@ fn emit_repeating_decoration( count += 1; } } - -/// Resolve pseudo-element text, evaluating `counter()` / `counters()` if present. -/// -/// If the pseudo-element's content property contains counter items, they are -/// evaluated using the current `CounterState`. Otherwise, the pre-set -/// `TextContent` is returned as-is. -fn resolve_pseudo_text( - dom: &EcsDom, - entity: Entity, - style: &ComputedStyle, - counter_state: &CounterState, -) -> String { - if let ContentValue::Items(ref items) = style.content { - let has_counter = items.iter().any(|item| { - matches!( - item, - ContentItem::Counter { .. } | ContentItem::Counters { .. } - ) - }); - if has_counter { - return resolve_content_items_with_counters(items, entity, dom, counter_state); - } - } - // No counter items — use the pre-set TextContent. - dom.world() - .get::<&TextContent>(entity) - .map_or_else(|_| String::new(), |tc| tc.0.clone()) -} - -/// Evaluate content items, resolving `counter()` and `counters()` with the given state. -fn resolve_content_items_with_counters( - items: &[ContentItem], - entity: Entity, - dom: &EcsDom, - counter_state: &CounterState, -) -> String { - use std::fmt::Write; - let mut result = String::new(); - for item in items { - match item { - ContentItem::String(s) => result.push_str(s), - ContentItem::Attr(name) => { - if let Ok(attrs) = dom.world().get::<&elidex_ecs::Attributes>(entity) { - if let Some(val) = attrs.get(name) { - result.push_str(val); - } - } - } - ContentItem::Counter { name, style } => { - write!(result, "{}", counter_state.evaluate_counter(name, *style)).unwrap(); - } - ContentItem::Counters { - name, - separator, - style, - } => { - write!( - result, - "{}", - counter_state.evaluate_counters(name, separator, *style) - ) - .unwrap(); - } - } - } - result -} diff --git a/crates/core/elidex-render/src/builder/mod.rs b/crates/core/elidex-render/src/builder/mod.rs index 29c64fd68..5e09b6af5 100644 --- a/crates/core/elidex-render/src/builder/mod.rs +++ b/crates/core/elidex-render/src/builder/mod.rs @@ -152,6 +152,7 @@ pub fn build_display_list_with_scroll( caret_visible, scroll_offset, counter_state: CounterState::new(), + paged: false, expected_generation: None, continuation_entities: None, }; @@ -225,6 +226,7 @@ pub fn build_paged_display_lists( caret_visible: false, scroll_offset: elidex_plugin::Vector::::ZERO, counter_state, + paged: true, expected_generation: None, continuation_entities: None, }; @@ -399,6 +401,7 @@ pub fn build_paged_display_lists_interleaved( caret_visible: false, scroll_offset: elidex_plugin::Vector::::ZERO, counter_state, + paged: true, expected_generation: Some(generation), continuation_entities: if continuations.is_empty() { None diff --git a/crates/core/elidex-render/src/builder/tests/counter.rs b/crates/core/elidex-render/src/builder/tests/counter.rs index a1b92292f..329eeaf8d 100644 --- a/crates/core/elidex-render/src/builder/tests/counter.rs +++ b/crates/core/elidex-render/src/builder/tests/counter.rs @@ -62,6 +62,7 @@ fn list_marker_uses_counter_state() { } let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // Decimal markers should produce Text items (not shapes). let has_shape = dl.0.iter().any(|i| { @@ -176,6 +177,7 @@ fn nested_ol_counter_reset() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // Both outer and inner disc markers should be rendered. let marker_count = @@ -267,6 +269,7 @@ fn counter_in_content_property() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // The pseudo-element should resolve counter(chapter) to "1" and produce @@ -398,6 +401,7 @@ fn counters_concatenation() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // The counter state should produce "1.1" (outer=1, inner=1 joined by "."). @@ -464,6 +468,7 @@ fn start_attribute_on_ol() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // Should emit decimal text marker "5." (if fonts available). let has_shape = dl.0.iter().any(|i| { @@ -552,20 +557,24 @@ fn custom_counter_increment() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let _dl = build_display_list(&dom, &font_db); // Counter value for item2 should be 4 (0 + 2 + 2). // Compilation and execution without panics confirms counter increment by 2 works. } // --------------------------------------------------------------------------- -// 7. display_none_skips_counter_scope — display:none still processes counters +// 7. display_none_li_emits_no_marker — a display:none list-item paints no marker +// (CSS Lists 3 §4.5: it also does not affect the counter — value asserted in +// elidex-style's `display_none_li_skips_counter_css_lists_4_5`). // --------------------------------------------------------------------------- #[test] #[allow(unused_must_use)] -fn display_none_processes_counter() { - // display:none elements should still have their counter properties - // processed during the walk (counter state updated even if not painted). +fn display_none_li_emits_no_marker() { + // A display:none list-item generates no box, so render paints no marker for it + // (and per §4.5 it does not increment list-item — the pre-layout pass enforces + // this; here we only assert the painted marker count). let mut dom = elidex_ecs::EcsDom::new(); let root = dom.create_document_root(); let ol = dom.create_element("ol", Attributes::default()); @@ -615,7 +624,8 @@ fn display_none_processes_counter() { }, ); - // li2: display:none, counter still increments to 2. + // li2: display:none — generates no box, paints no marker, and per §4.5 does + // not affect the counter. dom.world_mut().insert_one( li2_hidden, elidex_plugin::ComputedStyle { @@ -626,7 +636,8 @@ fn display_none_processes_counter() { }, ); - // li3: visible, counter increments to 3. + // li3: visible, counter increments to 2 (li2 was skipped per §4.5); a disc + // marker is painted regardless of the value. dom.world_mut().insert_one( li3, elidex_plugin::ComputedStyle { @@ -645,6 +656,7 @@ fn display_none_processes_counter() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // Only li1 and li3 should produce markers (li2 is display:none). let marker_count = diff --git a/crates/core/elidex-render/src/builder/tests/display_types.rs b/crates/core/elidex-render/src/builder/tests/display_types.rs index fc2312c95..1667e3937 100644 --- a/crates/core/elidex-render/src/builder/tests/display_types.rs +++ b/crates/core/elidex-render/src/builder/tests/display_types.rs @@ -306,6 +306,7 @@ fn list_item_disc_emits_marker() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // Disc marker should emit a RoundedRect. let has_marker = @@ -357,6 +358,7 @@ fn list_item_square_emits_solid_rect_marker() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // The first SolidRect with a very small width is the square marker. let small_rects: Vec<_> = dl @@ -414,6 +416,7 @@ fn list_item_none_no_marker() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // list-style-type: none should not emit any marker shapes. let has_rounded = @@ -465,6 +468,7 @@ fn list_item_circle_emits_stroked_marker() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // Circle marker should emit StrokedRoundedRect (outline), not filled RoundedRect. let has_stroked = dl.0.iter().any(|i| { @@ -531,6 +535,7 @@ fn list_item_decimal_emits_text_marker() { ); let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // Decimal marker emits Text items (if fonts are available) or nothing // (graceful fallback). It should never emit shape-based markers. @@ -609,6 +614,7 @@ fn list_item_counter_increments() { } let font_db = elidex_text::FontDatabase::new(); + elidex_style::resolve_generated_content(&mut dom); let dl = build_display_list(&dom, &font_db); // Both list items should emit a marker (RoundedRect for disc). let marker_count = diff --git a/crates/core/elidex-render/src/builder/walk.rs b/crates/core/elidex-render/src/builder/walk.rs index b51b5488d..297342c24 100644 --- a/crates/core/elidex-render/src/builder/walk.rs +++ b/crates/core/elidex-render/src/builder/walk.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use elidex_ecs::{ - Attributes, BackgroundImages, EcsDom, Entity, ImageData, TagType, TemplateContent, + BackgroundImages, EcsDom, Entity, ImageData, ListItemMarker, TemplateContent, MAX_ANCESTOR_DEPTH, }; use elidex_form::FormControlState; @@ -15,7 +15,7 @@ use elidex_plugin::{ Visibility, }; use elidex_plugin::{Point, Vector}; -use elidex_style::counter::{apply_implicit_list_counters, CounterState}; +use elidex_style::counter::{apply_implicit_list_counters_from_dom, CounterState}; use elidex_text::FontDatabase; use crate::display_list::{DisplayItem, DisplayList}; @@ -41,12 +41,22 @@ pub(crate) struct PaintContext<'a> { /// in `build_display_list_with_scroll()`. Fixed elements re-push this /// value after their `PopScrollOffset`/walk/`PushScrollOffset` sequence. pub(crate) scroll_offset: Vector, - /// CSS counter state machine (CSS Lists Level 3 §5–7). + /// CSS counter state machine (CSS Lists Level 3 §4 Automatic Numbering). /// - /// Tracks counter scopes during tree walk. Counters are created by - /// `counter-reset`, modified by `counter-increment` and `counter-set`, - /// and evaluated by `counter()` / `counters()` functions. + /// Retained ONLY to populate document counters for paged-media margin boxes + /// (`emit_margin_boxes`): per-page running-header counter values depend on + /// post-pagination page assignment, so they cannot be precomputed pre-layout. + /// Document generated content (pseudo `content`, list-item markers) is resolved + /// once before layout by `elidex-style` and read from components — render no + /// longer derives it here. Mutated only when `paged` (see [`Self::paged`]). pub(crate) counter_state: CounterState, + /// Whether this walk feeds a paged-media display list. + /// + /// Gates the `counter_state` mutation: only the paged builders populate + /// document counters for margin boxes. Off the paged path the counter machine + /// would do unused work (markers/pseudo read pre-layout components), so its + /// reset/increment processing is skipped (scope push/pop stay for balance). + pub(crate) paged: bool, /// Expected layout generation for per-fragment paged media rendering. /// /// When `Some(gen)`, the walk skips entities whose `LayoutBox.layout_generation` @@ -99,37 +109,34 @@ pub(crate) fn walk( } } - // CSS counter scope: push scope and process counter properties on entry. + // CSS counter scope (CSS Lists 3 §4.3 Nested Counters and Scope): push scope + // on entry / pop on exit unconditionally to keep the scope stack balanced. + // The counter MUTATION (reset/set/increment) runs only on the paged path — + // its sole remaining consumer is paged-media margin boxes; document content + // (pseudo text, list markers) is resolved pre-layout and read from components. ctx.counter_state.push_scope(); - if let Ok(mut style) = ctx - .dom - .world() - .get::<&ComputedStyle>(entity) - .map(|s| (*s).clone()) - { - // Apply implicit list-item counters for
    ,
      ,
    • (CSS Lists L3 §5). - if let Ok(tag) = ctx.dom.world().get::<&TagType>(entity) { - let attrs = ctx - .dom - .world() - .get::<&Attributes>(entity) - .ok() - .map(|a| (*a).clone()) - .unwrap_or_default(); - let li_count = if tag.0 == "ol" { - elidex_style::counter::count_li_children(ctx.dom, entity) - } else { - 0 - }; - apply_implicit_list_counters(&mut style, &tag.0, &attrs, li_count); + if ctx.paged { + if let Some(mut style) = ctx + .dom + .world() + .get::<&ComputedStyle>(entity) + .ok() + .map(|s| (*s).clone()) + // CSS Lists 3 §4.5: an element with `display: none` generates no box and + // cannot set/reset/increment a counter — skip its counter processing + // (matching the pre-layout pass, so paged document counters agree). + .filter(|style| style.display != Display::None) + { + // Apply implicit list-item counters for
        ,
          ,
        • (CSS Lists 3 §4.6). + apply_implicit_list_counters_from_dom(ctx.dom, entity, &mut style); + // CSS Fragmentation L3 §4: suppress counter-increment on continuation + // entities (those that started on a previous page fragment). + let is_continuation = ctx + .continuation_entities + .as_ref() + .is_some_and(|set| set.contains(&entity)); + ctx.counter_state.process_element(&style, is_continuation); } - // CSS Fragmentation L3 §4: suppress counter-increment on continuation - // entities (those that started on a previous page fragment). - let is_continuation = ctx - .continuation_entities - .as_ref() - .is_some_and(|set| set.contains(&entity)); - ctx.counter_state.process_element(&style, is_continuation); } // Fetch ComputedStyle once for display/visibility/painting checks. @@ -380,7 +387,6 @@ fn paint_stacking_context_layers( ctx.font_db, ctx.font_cache, ctx.dl, - &ctx.counter_state, ctx.expected_generation, ); inline_run.clear(); @@ -397,7 +403,6 @@ fn paint_stacking_context_layers( ctx.font_db, ctx.font_cache, ctx.dl, - &ctx.counter_state, ctx.expected_generation, ); } @@ -453,7 +458,6 @@ fn paint_non_sc( ctx.font_db, ctx.font_cache, ctx.dl, - &ctx.counter_state, ctx.expected_generation, ); inline_run.clear(); @@ -478,7 +482,6 @@ fn paint_non_sc( ctx.font_db, ctx.font_cache, ctx.dl, - &ctx.counter_state, ctx.expected_generation, ); } @@ -546,19 +549,21 @@ fn is_viewport_fixed(dom: &EcsDom, entity: Entity, in_transform: bool) -> bool { /// Emit a list marker for a block child if it is a `list-item` with a visible marker. /// -/// The counter value is obtained from the `CounterState` which has already been -/// updated by `process_element()` during the tree walk. This replaces the -/// previous hardcoded `list_counter: usize` with proper CSS counter evaluation. +/// The marker text is resolved pre-layout (CSS Lists 3 §4.6 implicit `list-item` +/// counter, §4.7 `counter()` formatting) and stored in the [`ListItemMarker`] +/// component by `elidex-style`'s generated-content pass — the single source of +/// marker text. Render reads it here, owning only the `visibility` paint guard. fn maybe_emit_list_marker(ctx: &mut PaintContext, child: Entity) { if let Ok(child_style) = ctx.dom.world().get::<&ComputedStyle>(child) { if child_style.display == Display::ListItem && child_style.list_style_type != ListStyleType::None && child_style.visibility == Visibility::Visible { + let marker_text = match ctx.dom.world().get::<&ListItemMarker>(child) { + Ok(m) => m.0.clone(), + Err(_) => return, + }; if let Ok(child_lb) = ctx.dom.world().get::<&LayoutBox>(child) { - let marker_text = ctx - .counter_state - .evaluate_counter("list-item", child_style.list_style_type); emit_list_marker_with_counter( &child_lb, &child_style, diff --git a/crates/css/elidex-style/src/counter.rs b/crates/css/elidex-style/src/counter.rs index f30f17234..6e8a757d8 100644 --- a/crates/css/elidex-style/src/counter.rs +++ b/crates/css/elidex-style/src/counter.rs @@ -1,8 +1,8 @@ -//! CSS Counter state machine (CSS Lists Level 3 §5–7). +//! CSS Counter state machine (CSS Lists 3 §4 Automatic Numbering With Counters). //! -//! Tracks counter scopes during display list tree walk. Counters are created -//! by `counter-reset`, modified by `counter-increment` and `counter-set`, -//! and evaluated by `counter()` / `counters()` functions. +//! Tracks counter scopes during a document-order tree walk. Counters are created +//! by `counter-reset` (§4.1), modified by `counter-increment` / `counter-set` +//! (§4.2), scoped per §4.3, and evaluated by `counter()` / `counters()` (§4.7). use std::collections::HashMap; @@ -13,7 +13,7 @@ use elidex_plugin::{ComputedStyle, CounterResetEntry, ListStyleType}; struct CounterInstance { scope_depth: usize, value: i32, - /// Whether this counter counts in reverse (CSS Lists L3 §5.1). + /// Whether this counter counts in reverse (CSS Lists 3 §4.1 `reversed()`). reversed: bool, } @@ -44,7 +44,7 @@ impl CounterState { } /// Decrement scope depth on element exit, removing any counter entries - /// created at this depth (CSS Lists L3 §5.1). + /// created at this depth (CSS Lists 3 §4.3 Nested Counters and Scope). pub fn pop_scope(&mut self) { let depth = self.scope_depth; self.counters.retain(|_, stack| { @@ -76,7 +76,7 @@ impl CounterState { /// Process counter properties from a computed style. /// - /// Order of operations per CSS Lists L3 §5.4: reset → set → increment. + /// Order of operations per CSS Lists 3 §4.1–§4.2: reset → set → increment. /// /// `is_continuation` suppresses increment (used for fragmentation continuations). pub fn process_element(&mut self, style: &ComputedStyle, is_continuation: bool) { @@ -92,7 +92,7 @@ impl CounterState { }); } - // Phase 2: counter-set — overwrites top value or creates if missing (§5.3). + // Phase 2: counter-set — overwrites top value or creates if missing (§4.2). for (name, value) in &style.counter_set { let stack = self.counters.entry(name.clone()).or_default(); if let Some(top) = stack.last_mut() { @@ -106,8 +106,8 @@ impl CounterState { } } - // Phase 3: counter-increment — adds to top value or creates then increments (§5.2). - // For reversed counters (CSS Lists L3 §5.1), the increment is negated. + // Phase 3: counter-increment — adds to top value or creates then increments (§4.2). + // For reversed counters (CSS Lists 3 §4.1), the increment is negated. if !is_continuation { for (name, value) in &style.counter_increment { let stack = self.counters.entry(name.clone()).or_default(); @@ -226,7 +226,8 @@ const ROMAN_TABLE: &[(i32, &str, &str)] = &[ ]; /// Convert a value to a Roman numeral string. Returns the decimal -/// representation for non-positive values (CSS Lists L3 §7). +/// representation for non-positive values (CSS Counter Styles 3 §6 Simple +/// Predefined Counter Styles). fn to_roman(value: i32, upper: bool) -> String { if value <= 0 { return value.to_string(); @@ -244,7 +245,7 @@ fn to_roman(value: i32, upper: bool) -> String { } /// Convert a value to alphabetic representation (1=a, 26=z, 27=aa, ...). -/// Non-positive values fall back to decimal (CSS Lists L3 §7). +/// Non-positive values fall back to decimal (CSS Counter Styles 3 §6). fn to_alpha(value: i32, base: u8) -> String { if value <= 0 { return value.to_string(); @@ -262,9 +263,9 @@ fn to_alpha(value: i32, base: u8) -> String { /// Add implicit `list-item` counter operations for `
            ` and `
          1. ` elements. /// -/// Per CSS Lists L3 §5: `
              ` implicitly resets the `list-item` counter, -/// and `
            1. ` implicitly increments it by 1. Explicit `counter-reset` / -/// `counter-increment` on these elements suppresses the implicit behavior. +/// Per CSS Lists 3 §4.6 (The Implicit list-item Counter): `
                ` implicitly +/// resets the `list-item` counter, and `
              1. ` implicitly increments it by 1. +/// Explicit `counter-reset` / `counter-increment` suppresses the implicit behavior. /// Count `
              2. ` children of an element for reversed counter initialization. /// /// Per HTML §4.4.8, a `
                  ` without `start` has its counter set to @@ -286,9 +287,9 @@ pub fn count_li_children(dom: &elidex_ecs::EcsDom, entity: elidex_ecs::Entity) - /// Add implicit `list-item` counter operations for `
                    `, `
                      `, `
                    • `. /// -/// Per CSS Lists L3 §5: `
                        ` implicitly resets the `list-item` counter, +/// Per CSS Lists 3 §4.6: `
                          ` implicitly resets the `list-item` counter, /// `
                        1. ` implicitly increments it. The `reversed` attribute on `
                            ` creates -/// a reversed counter (CSS Lists L3 §5.1) with implicit decrement of -1. +/// a reversed counter (CSS Lists 3 §4.1 `reversed()`) with implicit decrement of -1. /// /// `li_count` is the number of `
                          1. ` children, used for reversed counter /// initial value when no `start` attribute is present (HTML §4.4.8). @@ -367,6 +368,47 @@ pub fn apply_implicit_list_counters( } } +/// Apply implicit `list-item` counters by reading the element's tag + attributes +/// from the DOM (CSS Lists 3 §4.6). Self-guarding: a no-op for non-`ol`/`ul`/`li` +/// tags. Shared by the style cascade walk, the pre-layout generated-content pass, +/// and render's paged counter walk so the tag/attrs/`li`-count marshalling lives +/// in one place. +pub fn apply_implicit_list_counters_from_dom( + dom: &elidex_ecs::EcsDom, + entity: elidex_ecs::Entity, + style: &mut ComputedStyle, +) { + let Ok(tag) = dom + .world() + .get::<&elidex_ecs::TagType>(entity) + .map(|t| t.0.clone()) + else { + return; + }; + // Only
                              /
                                /
                              • carry implicit list counters; bail before touching + // Attributes for every other element (this runs on every restyle, for every + // element, from the cascade walk). + if !matches!(tag.as_str(), "ol" | "ul" | "li") { + return; + } + // Only
                                  (start/reversed) and
                                1. (value) read attributes;
                                    reads none. + let attrs = if matches!(tag.as_str(), "ol" | "li") { + dom.world() + .get::<&elidex_ecs::Attributes>(entity) + .ok() + .map(|a| (*a).clone()) + .unwrap_or_default() + } else { + elidex_ecs::Attributes::default() + }; + let li_count = if tag == "ol" { + count_li_children(dom, entity) + } else { + 0 + }; + apply_implicit_list_counters(style, &tag, &attrs, li_count); +} + #[cfg(test)] mod tests { use super::*; @@ -561,7 +603,7 @@ mod tests { fn counter_set_creates_if_not_exists() { let mut cs = CounterState::new(); - // counter-set on a non-existent counter creates it (§5.3). + // counter-set on a non-existent counter creates it (§4.2). let mut style = ComputedStyle::default(); style.counter_set.push(("newc".to_string(), 7)); cs.push_scope(); diff --git a/crates/css/elidex-style/src/generated_content.rs b/crates/css/elidex-style/src/generated_content.rs new file mode 100644 index 000000000..1c19f5383 --- /dev/null +++ b/crates/css/elidex-style/src/generated_content.rs @@ -0,0 +1,485 @@ +//! Pre-layout generated-content resolution — the single source of CSS +//! generated text (One-issue-one-way). +//! +//! Runs as the final phase of style resolution, after the cascade walk has +//! attached `ComputedStyle` and spawned the `::before`/`::after` pseudo-element +//! entities. A single document-order walk drives the CSS counter state machine +//! (CSS Lists 3 §4 Automatic Numbering With Counters) and resolves all +//! generated text, writing results to ECS components that **both** layout and +//! render read: +//! +//! - pseudo-element `content` (CSS Content 3 §2 Generated Content Values) → +//! overwrites the pseudo entity's [`TextContent`]; +//! - list-item marker text (CSS Lists 3 §4.6 The Implicit list-item Counter, +//! §4.7 the `counter()` function) → a reconciled [`ListItemMarker`] component +//! on the list-item element. +//! +//! Because this resolves counters once, before layout, layout measures the +//! *resolved* text (not a `[counter:…]` placeholder) and render no longer runs +//! a counter machine for document content — it reads the components. +//! (Paged-media margin-box counters stay in render: per-page running-header +//! values require post-pagination page assignment and cannot be precomputed.) + +use std::fmt::Write as _; + +use elidex_ecs::{ + Attributes, EcsDom, Entity, ListItemMarker, PseudoElementMarker, TagType, TextContent, + MAX_ANCESTOR_DEPTH, +}; +use elidex_plugin::{ComputedStyle, ContentItem, ContentValue, Display, ListStyleType}; + +use crate::counter::{apply_implicit_list_counters_from_dom, CounterState}; +use crate::walk::find_roots; + +/// Resolve CSS counters + generated content (pseudo `content`, list-item +/// markers) in document order, writing the results to ECS components. +/// +/// Invoked once per style resolution (as the final phase of +/// [`resolve_styles_with_compat`](crate::resolve_styles_with_compat)), after the +/// cascade walk (so pseudo entities exist and every element carries +/// `ComputedStyle`) and before layout (so layout reads resolved text). Exposed +/// for callers that build a styled DOM directly and need only this phase. +pub fn resolve_generated_content(dom: &mut EcsDom) { + // A fresh counter state per root: `find_roots` may return multiple disconnected + // trees (the fallback scan), which behave as independent documents — counter + // scope (CSS Lists 3 §4.3) must not leak across them. + for root in find_roots(dom) { + let mut state = CounterState::new(); + resolve_tree(dom, root, &mut state, 0); + } +} + +/// Document-order recursion: drive the counter machine and resolve generated +/// content for `entity`, then recurse into composed children. +fn resolve_tree(dom: &mut EcsDom, entity: Entity, state: &mut CounterState, depth: usize) { + // Match the cap convention of the other document-order walks (render's `walk`, + // layout) — `> MAX` with an initial depth of 0 — so generated-content + // resolution covers exactly the depths layout/render process (not one shallower). + if depth > MAX_ANCESTOR_DEPTH { + return; + } + + // Cheap probe: extract only the Copy marker fields + whether this element + // carries any explicit counter property, releasing the `ComputedStyle` borrow + // before any `&mut`. The common (non-counter, non-list) element never clones + // the full `ComputedStyle` — this pass runs on every restyle. + // + // Style-less entities — the document root (no `TagType`), text nodes — carry + // no counters or generated content of their own, but their children must + // still be visited in document order (the root is style-less yet contains the + // whole tree). Recurse without opening a scope; text nodes simply have no + // children. (Mirrors render's walk, which recurses style-less nodes.) + let Some((display, list_style_type, has_counter_props)) = + dom.world().get::<&ComputedStyle>(entity).ok().map(|s| { + ( + s.display, + s.list_style_type, + !s.counter_reset.is_empty() + || !s.counter_set.is_empty() + || !s.counter_increment.is_empty(), + ) + }) + else { + for child in dom.composed_children(entity) { + resolve_tree(dom, child, state, depth + 1); + } + return; + }; + + // RECONCILE the list-item marker (CSS Lists 3 §4.6/§4.7): a marker is written + // ONLY for a visible-type list-item (below, after counter processing). Remove + // any stale marker from a prior resolve for every OTHER element NOW — before the + // display:none / display:contents early-returns — so the "insert-or-remove every + // pass" invariant holds on ALL exit paths (element entities persist across + // re-resolves; same explicit-clear discipline as `InlineFlow`). Visibility is + // render's paint concern; the marker text is written independently of it. + let is_visible_list_item = + display == Display::ListItem && list_style_type != ListStyleType::None; + if !is_visible_list_item { + let _ = dom.world_mut().remove_one::(entity); + } + + // CSS Lists 3 §4.5 (Counters in elements that do not generate boxes): an + // element with `display: none` cannot set, reset, or increment a counter — + // and it generates no subtree to render. Skip it entirely. + if display == Display::None { + return; + } + + // CSS Display 3 §2.5 + §4.5: a `display: contents` element generates no box + // of its own, so its own counter properties have no effect, but its children + // participate in the parent's formatting context. Recurse children in the + // current scope without pushing one or processing this element's counters + // (matches layout/render `composed_children_flat` contents-flattening). + if display == Display::Contents { + for child in dom.composed_children(entity) { + resolve_tree(dom, child, state, depth + 1); + } + return; + } + + // CSS Lists 3 §4.3 (Nested Counters and Scope): each box opens a scope. + state.push_scope(); + + let is_list_tag = dom + .world() + .get::<&TagType>(entity) + .is_ok_and(|t| matches!(t.0.as_str(), "ol" | "ul" | "li")); + + // CSS Lists 3 §4.1/§4.2/§4.6: process counter-reset (incl. reversed) → set → + // increment, but only clone the style when there is actual counter influence — + // an explicit counter-* property or an implicit list counter (ol/ul/li). For + // everything else the empty counter vecs make `process_element` a no-op, so the + // clone is skipped. `apply_implicit_list_counters_from_dom` is **load-bearing**, + // not redundant: the parallel cascade path (`walk::walk_children`, `parallel` + // feature) inserts the style WITHOUT baking implicit counters — only the + // sequential path bakes them; its `already_has` guard makes this idempotent when + // they were. No fragmentation continuation pre-layout (paged continuation, which + // suppresses re-increment, is handled by render's per-fragment walk). + if is_list_tag || has_counter_props { + if let Ok(s) = dom.world().get::<&ComputedStyle>(entity) { + let mut style = (*s).clone(); + apply_implicit_list_counters_from_dom(dom, entity, &mut style); + state.process_element(&style, false); + } + } + + // CSS Lists 3 §4.6/§4.7: write the resolved marker text for a visible-type + // list-item (the stale-removal half of the reconcile ran early, above, so it + // also covers the display:none / display:contents exit paths). + if is_visible_list_item { + let text = state.evaluate_counter("list-item", list_style_type); + let _ = dom.world_mut().insert_one(entity, ListItemMarker(text)); + } + + // CSS Content 3 §2: resolve pseudo-element `content` into TextContent. + if dom.world().get::<&PseudoElementMarker>(entity).is_ok() { + let text = resolve_pseudo_content(dom, entity, state); + let _ = dom.world_mut().insert_one(entity, TextContent(text)); + } + + // Recurse children in composed document order (::before is the first child, + // ::after the last — so each resolves with the right counter state). + for child in dom.composed_children(entity) { + resolve_tree(dom, child, state, depth + 1); + } + + state.pop_scope(); +} + +/// Resolve a pseudo-element's `content` value (CSS Content 3 §2). +/// +/// `attr()` (CSS Content 3 §2.1) refers to an attribute of the **originating +/// element** — the pseudo's parent — not the pseudo entity itself. +fn resolve_pseudo_content(dom: &EcsDom, entity: Entity, state: &CounterState) -> String { + let Ok(style) = dom.world().get::<&ComputedStyle>(entity) else { + return String::new(); + }; + let ContentValue::Items(items) = &style.content else { + return String::new(); + }; + let originating = dom.get_parent(entity); + let mut result = String::new(); + for item in items { + match item { + ContentItem::String(s) => result.push_str(s), + ContentItem::Attr(name) => { + if let Some(orig) = originating { + if let Ok(attrs) = dom.world().get::<&Attributes>(orig) { + if let Some(val) = attrs.get(name) { + result.push_str(val); + } + } + } + } + ContentItem::Counter { name, style: ls } => { + let _ = write!(result, "{}", state.evaluate_counter(name, *ls)); + } + ContentItem::Counters { + name, + separator, + style: ls, + } => { + let _ = write!(result, "{}", state.evaluate_counters(name, separator, *ls)); + } + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use elidex_ecs::Attributes; + use elidex_plugin::{CounterResetEntry, ListStyleType}; + + /// Build `
                                      ` with `n` `
                                    1. ` children, each `display: list-item` decimal. + /// Returns `(dom, ol, [li…])`. Counter props are implicit (set by the pass via + /// `apply_implicit_list_counters` from the ol/li tags). + fn ol_with_items(n: usize) -> (EcsDom, Entity, Vec) { + let mut dom = EcsDom::new(); + let root = dom.create_document_root(); + let ol = dom.create_element("ol", Attributes::default()); + let _ = dom.world_mut().insert_one( + ol, + ComputedStyle { + display: Display::Block, + ..Default::default() + }, + ); + let _ = dom.append_child(root, ol); + let mut lis = Vec::new(); + for _ in 0..n { + let li = dom.create_element("li", Attributes::default()); + let _ = dom.world_mut().insert_one( + li, + ComputedStyle { + display: Display::ListItem, + list_style_type: ListStyleType::Decimal, + ..Default::default() + }, + ); + let _ = dom.append_child(ol, li); + lis.push(li); + } + (dom, ol, lis) + } + + fn marker(dom: &EcsDom, e: Entity) -> Option { + dom.world() + .get::<&elidex_ecs::ListItemMarker>(e) + .ok() + .map(|m| m.0.clone()) + } + + #[test] + fn list_item_markers_number_in_document_order() { + let (mut dom, _ol, lis) = ol_with_items(3); + resolve_generated_content(&mut dom); + assert_eq!(marker(&dom, lis[0]).as_deref(), Some("1")); + assert_eq!(marker(&dom, lis[1]).as_deref(), Some("2")); + assert_eq!(marker(&dom, lis[2]).as_deref(), Some("3")); + } + + #[test] + fn display_none_li_skips_counter_css_lists_4_5() { + // CSS Lists 3 §4.5: a display:none element cannot increment the counter. + let (mut dom, _ol, lis) = ol_with_items(3); + // Make the middle li display:none. + let _ = dom.world_mut().insert_one( + lis[1], + ComputedStyle { + display: Display::None, + list_style_type: ListStyleType::Decimal, + ..Default::default() + }, + ); + resolve_generated_content(&mut dom); + assert_eq!(marker(&dom, lis[0]).as_deref(), Some("1")); + // The display:none li gets no marker and does NOT advance the counter… + assert_eq!(marker(&dom, lis[1]), None, "display:none li → no marker"); + // …so the third li is "2", not "3". + assert_eq!( + marker(&dom, lis[2]).as_deref(), + Some("2"), + "display:none li must not increment list-item (§4.5)" + ); + } + + #[test] + fn marker_reconciled_removed_when_no_longer_list_item() { + let (mut dom, _ol, lis) = ol_with_items(1); + resolve_generated_content(&mut dom); + assert_eq!(marker(&dom, lis[0]).as_deref(), Some("1")); + // The li becomes a plain block (e.g. display change) — re-resolve must + // clear the stale ListItemMarker (slice-1 reconcile discipline). + let _ = dom.world_mut().insert_one( + lis[0], + ComputedStyle { + display: Display::Block, + ..Default::default() + }, + ); + resolve_generated_content(&mut dom); + assert_eq!( + marker(&dom, lis[0]), + None, + "stale ListItemMarker must be removed on gate-out" + ); + } + + #[test] + fn marker_removed_when_element_becomes_display_none() { + // Copilot PR#273 R2: the reconcile-remove must fire even on the + // display:none / display:contents early-return paths (the remove now runs + // before those returns). + for hidden in [Display::None, Display::Contents] { + let (mut dom, _ol, lis) = ol_with_items(1); + resolve_generated_content(&mut dom); + assert_eq!(marker(&dom, lis[0]).as_deref(), Some("1")); + let _ = dom.world_mut().insert_one( + lis[0], + ComputedStyle { + display: hidden, + ..Default::default() + }, + ); + resolve_generated_content(&mut dom); + assert_eq!( + marker(&dom, lis[0]), + None, + "stale marker must be removed when the element becomes {hidden:?}" + ); + } + } + + #[test] + fn counters_independent_across_roots() { + // Copilot PR#273 R2: a fresh CounterState per root — disconnected trees are + // independent documents, so `list-item` must restart, not leak across them. + let mut dom = EcsDom::new(); + let mut lis = Vec::new(); + for _ in 0..2 { + // Parentless
                                        → a separate root (find_roots fallback scan). + let ol = dom.create_element("ol", Attributes::default()); + let _ = dom.world_mut().insert_one( + ol, + ComputedStyle { + display: Display::Block, + ..Default::default() + }, + ); + let li = dom.create_element("li", Attributes::default()); + let _ = dom.world_mut().insert_one( + li, + ComputedStyle { + display: Display::ListItem, + list_style_type: ListStyleType::Decimal, + ..Default::default() + }, + ); + let _ = dom.append_child(ol, li); + lis.push(li); + } + resolve_generated_content(&mut dom); + assert_eq!(marker(&dom, lis[0]).as_deref(), Some("1")); + assert_eq!( + marker(&dom, lis[1]).as_deref(), + Some("1"), + "second root's list-item must restart at 1 (no cross-root counter leak)" + ); + } + + #[test] + fn nested_ol_resets_inner_counter() { + //
                                        — inner li restarts at 1. + let (mut dom, _ol, lis) = ol_with_items(2); + let inner_ol = dom.create_element("ol", Attributes::default()); + let _ = dom.world_mut().insert_one( + inner_ol, + ComputedStyle { + display: Display::Block, + ..Default::default() + }, + ); + let _ = dom.append_child(lis[1], inner_ol); + let inner_li = dom.create_element("li", Attributes::default()); + let _ = dom.world_mut().insert_one( + inner_li, + ComputedStyle { + display: Display::ListItem, + list_style_type: ListStyleType::Decimal, + ..Default::default() + }, + ); + let _ = dom.append_child(inner_ol, inner_li); + resolve_generated_content(&mut dom); + assert_eq!(marker(&dom, lis[0]).as_deref(), Some("1")); + assert_eq!(marker(&dom, lis[1]).as_deref(), Some("2")); + assert_eq!( + marker(&dom, inner_li).as_deref(), + Some("1"), + "nested
                                          resets the list-item counter (§4.3 scope)" + ); + } + + /// Build `

                                          ` with a `::before` pseudo whose `content` is `items`. Returns + /// `(dom, pseudo)`. The originating `

                                          ` carries `attrs` for attr() tests. + fn p_with_before(items: Vec, attrs: Attributes) -> (EcsDom, Entity) { + let mut dom = EcsDom::new(); + let root = dom.create_document_root(); + let p = dom.create_element("p", attrs); + let _ = dom.world_mut().insert_one( + p, + ComputedStyle { + display: Display::Block, + ..Default::default() + }, + ); + let _ = dom.append_child(root, p); + // The pseudo entity, as the cascade would spawn it (empty text + marker). + let pseudo = dom.create_text(String::new()); + let _ = dom.world_mut().insert_one( + pseudo, + ComputedStyle { + display: Display::Inline, + content: ContentValue::Items(items), + ..Default::default() + }, + ); + let _ = dom + .world_mut() + .insert_one(pseudo, elidex_ecs::PseudoElementMarker); + let first = dom.get_first_child(p); + if let Some(fc) = first { + let _ = dom.insert_before(p, pseudo, fc); + } else { + let _ = dom.append_child(p, pseudo); + } + (dom, pseudo) + } + + fn pseudo_text(dom: &EcsDom, e: Entity) -> String { + dom.world() + .get::<&TextContent>(e) + .map(|t| t.0.clone()) + .unwrap_or_default() + } + + #[test] + fn pseudo_counter_content_resolved() { + // p { counter-reset: c 4 } p::before { content: counter(c) } + let (mut dom, pseudo) = p_with_before( + vec![ContentItem::Counter { + name: "c".to_string(), + style: ListStyleType::Decimal, + }], + Attributes::default(), + ); + // Put the counter-reset on the originating

                                          . + let p = dom.get_parent(pseudo).unwrap(); + let _ = dom.world_mut().insert_one( + p, + ComputedStyle { + display: Display::Block, + counter_reset: vec![CounterResetEntry::new("c", 4)], + ..Default::default() + }, + ); + resolve_generated_content(&mut dom); + assert_eq!(pseudo_text(&dom, pseudo), "4"); + } + + #[test] + fn pseudo_attr_resolves_against_originating_element() { + let mut attrs = Attributes::default(); + attrs.set("data-x", "hi"); + let (mut dom, pseudo) = p_with_before(vec![ContentItem::Attr("data-x".to_string())], attrs); + resolve_generated_content(&mut dom); + assert_eq!( + pseudo_text(&dom, pseudo), + "hi", + "attr() in pseudo content resolves against the originating element" + ); + } +} diff --git a/crates/css/elidex-style/src/lib.rs b/crates/css/elidex-style/src/lib.rs index 1aceab3ee..7c131ce5a 100644 --- a/crates/css/elidex-style/src/lib.rs +++ b/crates/css/elidex-style/src/lib.rs @@ -14,6 +14,7 @@ pub mod cascade; pub mod counter; +pub mod generated_content; pub mod inherit; #[cfg(feature = "parallel")] mod parallel; @@ -33,6 +34,7 @@ use elidex_ecs::{EcsDom, Entity}; use elidex_plugin::{ComputedStyle, CssPropertyRegistry, CssValue, Size}; pub use elidex_plugin::ViewportOverflow; +pub use generated_content::resolve_generated_content; pub use resolve::{dimension_to_css_value, get_computed_with_registry}; /// Build the CSS property registry with all standard property handlers. @@ -139,6 +141,12 @@ pub fn resolve_styles_with_compat( walk_tree(dom, root, &all_sheets, &default_parent, &mut state); } + // Final phase: resolve CSS counters + generated content (pseudo `content`, + // list-item markers) in document order now that the cascade has spawned + // pseudo entities and attached every `ComputedStyle`. Writes resolved text + // to ECS components layout + render read (single source — One-issue-one-way). + generated_content::resolve_generated_content(dom); + // Propagate root overflow to viewport (CSS Overflow L3 §3.1). walk::propagate_root_overflow(dom) } diff --git a/crates/css/elidex-style/src/pseudo.rs b/crates/css/elidex-style/src/pseudo.rs index 99aff7283..c13c59d5c 100644 --- a/crates/css/elidex-style/src/pseudo.rs +++ b/crates/css/elidex-style/src/pseudo.rs @@ -1,10 +1,8 @@ //! Pseudo-element (`::before`/`::after`) generation for style resolution. -use std::fmt::Write; - use elidex_css::{PseudoElement, Stylesheet}; -use elidex_ecs::{Attributes, EcsDom, Entity, PseudoElementMarker}; -use elidex_plugin::{ComputedStyle, ContentItem, ContentValue, Display}; +use elidex_ecs::{EcsDom, Entity, PseudoElementMarker}; +use elidex_plugin::{ComputedStyle, ContentValue, Display}; use crate::cascade::collect_and_cascade_pseudo; use crate::resolve::{build_computed_style, ResolveContext}; @@ -44,11 +42,9 @@ pub(crate) fn generate_pseudo_entity( // CSS Generated Content §2: on pseudo-elements, `content: normal` computes // to `none`. Both `normal` and `none` suppress generation. - let text = match &pe_style.content { - ContentValue::Items(ref items) => resolve_content_text(items, entity, dom), - // Normal or None → no generation. - _ => return, - }; + if !matches!(pe_style.content, ContentValue::Items(_)) { + return; + } // Create the pseudo-element entity with inline display. let mut style = pe_style; @@ -57,9 +53,12 @@ pub(crate) fn generate_pseudo_entity( style.display = Display::Inline; } - // Use create_text() to ensure the entity has a TreeRelation component, - // which is required for EcsDom tree operations (append_child, destroy_entity, etc.). - let pe_entity = dom.create_text(text); + // Create the entity with empty text — the pre-layout generated-content pass + // (`generated_content::resolve_generated_content`) is the single resolver of + // `content` (string / attr() / counter() / counters()) and fills the + // `TextContent` in document order. `create_text` gives the entity a + // `TreeRelation` (required for `append_child` / `destroy_entity`). + let pe_entity = dom.create_text(String::new()); let _ = dom.world_mut().insert_one(pe_entity, style); let _ = dom.world_mut().insert_one(pe_entity, PseudoElementMarker); @@ -78,31 +77,3 @@ pub(crate) fn generate_pseudo_entity( } } } - -/// Resolve content items to a text string. -fn resolve_content_text(items: &[ContentItem], entity: Entity, dom: &EcsDom) -> String { - let mut result = String::new(); - for item in items { - match item { - ContentItem::String(s) => result.push_str(s), - ContentItem::Attr(name) => { - if let Ok(attrs) = dom.world().get::<&Attributes>(entity) { - if let Some(val) = attrs.get(name) { - result.push_str(val); - } - } - } - // Counter values require counter state from the document tree; - // placeholder output until counter evaluation is implemented. - ContentItem::Counter { name, .. } => { - write!(result, "[counter:{name}]").unwrap(); - } - ContentItem::Counters { - name, separator, .. - } => { - write!(result, "[counters:{name},{separator}]").unwrap(); - } - } - } - result -} diff --git a/crates/css/elidex-style/src/resolve/box_model/mod.rs b/crates/css/elidex-style/src/resolve/box_model/mod.rs index d25d85fb0..b4d7e8f03 100644 --- a/crates/css/elidex-style/src/resolve/box_model/mod.rs +++ b/crates/css/elidex-style/src/resolve/box_model/mod.rs @@ -486,7 +486,7 @@ fn parse_counters_keyword(k: &str) -> Option { /// Resolve `counter-reset` from cascade winners. /// -/// Supports `reversed(name)` syntax (CSS Lists L3 §5.1) encoded as +/// Supports `reversed(name)` syntax (CSS Lists 3 §4.1) encoded as /// `Keyword("reversed:name")` by the CSS parser. fn resolve_counter_reset( winners: &PropertyMap<'_>, diff --git a/crates/css/elidex-style/src/walk.rs b/crates/css/elidex-style/src/walk.rs index 0db333332..2b42ccca7 100644 --- a/crates/css/elidex-style/src/walk.rs +++ b/crates/css/elidex-style/src/walk.rs @@ -340,21 +340,8 @@ fn resolve_and_attach_style( // Resolve values → ComputedStyle. let mut style = build_computed_style(&winners, parent_style, &element_ctx); - // Apply implicit list-item counters for

                                            ,
                                              ,
                                            • (CSS Lists L3 §5). - if let Ok(tag) = dom.world().get::<&TagType>(entity) { - let attrs = dom - .world() - .get::<&Attributes>(entity) - .ok() - .map(|a| (*a).clone()) - .unwrap_or_default(); - let li_count = if tag.0 == "ol" { - crate::counter::count_li_children(dom, entity) - } else { - 0 - }; - crate::counter::apply_implicit_list_counters(&mut style, &tag.0, &attrs, li_count); - } + // Apply implicit list-item counters for
                                                ,
                                                  ,
                                                • (CSS Lists 3 §4.6). + crate::counter::apply_implicit_list_counters_from_dom(dom, entity, &mut style); // Attach ComputedStyle to the entity. let _ = dom.world_mut().insert_one(entity, style.clone()); diff --git a/crates/layout/elidex-layout-block/src/inline/mod.rs b/crates/layout/elidex-layout-block/src/inline/mod.rs index ac1ef3f91..d82dc3f8a 100644 --- a/crates/layout/elidex-layout-block/src/inline/mod.rs +++ b/crates/layout/elidex-layout-block/src/inline/mod.rs @@ -125,11 +125,14 @@ fn is_atomic_inline(display: Display) -> bool { } /// Whether an inline run contains members that make its render-side treatment -/// diverge from layout's IFC membership, so slice 1 must **not** persist an +/// diverge from layout's IFC membership, so layout must **not** persist an /// `InlineFlow` for it (render falls back to its own collect/collapse/emit). /// -/// - `has_pseudo`: a pseudo-element run whose `content` may carry `counter()` -/// that render resolves but layout measures from raw `TextContent` (slice 3). +/// (Slice 3 removed `has_pseudo`: pseudo-element `content` — including +/// `counter()` — is now resolved into the pseudo's `TextContent` by the +/// pre-layout generated-content pass, so layout measures the resolved text and +/// pseudo runs persist like any other text run, subject to the gates below.) +/// /// - `has_relpos_sticky`: a `position: relative`/`sticky` inline — in-flow in /// layout's IFC (CSS 2 §9.4.3) but painted in render's Layer 6 (slice 3p). /// - `has_atomic`: an `inline-block`/`-flex`/`-grid`/`-table` — an `Atomic` @@ -148,7 +151,6 @@ fn is_atomic_inline(display: Display) -> bool { #[allow(clippy::struct_excessive_bools)] #[derive(Default, Clone, Copy)] pub(crate) struct RunComplexity { - pub has_pseudo: bool, pub has_relpos_sticky: bool, pub has_atomic: bool, pub has_bidi: bool, @@ -219,11 +221,23 @@ fn collect_inline_items_inner( }); continue; } - // Pseudo-element: use text directly with own style (skip child recursion). + // Pseudo-element: use its resolved generated text directly with its + // own style (skip child recursion). The pre-layout generated-content + // pass has already resolved `content` (incl. counter()) into the + // pseudo's `TextContent`, so layout measures the real text. The run is + // still gated out of `InlineFlow` if it needs bidi reordering (slice 4) + // or text-transform (render transforms before shaping) — the same + // gates the text-node branch applies, here against the pseudo's own + // computed text-transform and its resolved text. if dom.world().get::<&PseudoElementMarker>(child).is_ok() { - complexity.has_pseudo = true; if let Ok(tc) = dom.world().get::<&TextContent>(child) { if !tc.0.is_empty() { + if style.text_transform != TextTransform::None { + complexity.has_text_transform = true; + } + if elidex_text::text_has_rtl(&tc.0) { + complexity.has_bidi = true; + } items.push(InlineItem::Text(StyledRun::from_style( child, tc.0.clone(), @@ -584,7 +598,6 @@ pub fn layout_inline_context_fragmented( // swap (below) + a writing-mode-aware render consume path. let persist_flow = frag_constraint.is_none() && parent_style.text_align != TextAlign::Justify - && !complexity.has_pseudo && !complexity.has_relpos_sticky && !complexity.has_atomic && !complexity.has_bidi diff --git a/crates/layout/elidex-layout-block/src/inline/tests/inline_flow.rs b/crates/layout/elidex-layout-block/src/inline/tests/inline_flow.rs index fe86bc490..585e9dfce 100644 --- a/crates/layout/elidex-layout-block/src/inline/tests/inline_flow.rs +++ b/crates/layout/elidex-layout-block/src/inline/tests/inline_flow.rs @@ -252,7 +252,11 @@ fn gate_excludes_relative_positioned_inline() { } #[test] -fn gate_excludes_pseudo_element() { +fn persists_pseudo_element_flow() { + // Slice 3: pseudo `content` (incl. counter()) is resolved into the pseudo's + // `TextContent` by the pre-layout generated-content pass, so layout measures + // the real text and a plain-LTR, non-transformed pseudo run now persists like + // any text run (the slice-1 `has_pseudo` gate is gone). Render consumes it. let Some((mut dom, parent, style, font_db)) = setup_inline_test("") else { return; }; @@ -268,7 +272,7 @@ fn gate_excludes_pseudo_element() { let _ = dom.world_mut().insert_one(pseudo, PseudoElementMarker); let _ = dom .world_mut() - .insert_one(pseudo, TextContent("•".to_string())); + .insert_one(pseudo, TextContent("AB".to_string())); dom.append_child(parent, pseudo); let children = dom.composed_children(parent); @@ -282,9 +286,16 @@ fn gate_excludes_pseudo_element() { &env(&font_db), ); + let flow = dom + .world() + .get::<&InlineFlow>(key) + .expect("plain-LTR pseudo run with resolved text must persist (slice 3)"); assert!( - dom.world().get::<&InlineFlow>(key).is_err(), - "pseudo/generated content is resolved by render (counters) → must not persist in slice 1" + flow.lines + .iter() + .flat_map(|l| &l.runs) + .any(|r| r.text.contains("AB")), + "persisted flow should carry the pseudo's resolved generated text" ); }