Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions crates/core/elidex-ecs/src/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions crates/core/elidex-ecs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
94 changes: 13 additions & 81 deletions crates/core/elidex-render/src/builder/inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<u32>,
) {
// Converged path: if layout persisted an `InlineFlow` on this run's start
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -173,7 +171,6 @@ fn collect_styled_inline_text(
entities: &[Entity],
parent_style: &ComputedStyle,
depth: u32,
counter_state: &CounterState,
) -> Vec<StyledTextSegment> {
if depth >= MAX_INLINE_DEPTH {
return Vec::new();
Expand All @@ -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;
Expand All @@ -208,7 +208,6 @@ fn collect_styled_inline_text(
&children,
&style,
depth + 1,
counter_state,
));
continue;
}
Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions crates/core/elidex-render/src/builder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -225,6 +226,7 @@ pub fn build_paged_display_lists(
caret_visible: false,
scroll_offset: elidex_plugin::Vector::<f32>::ZERO,
counter_state,
paged: true,
expected_generation: None,
continuation_entities: None,
};
Expand Down Expand Up @@ -399,6 +401,7 @@ pub fn build_paged_display_lists_interleaved(
caret_visible: false,
scroll_offset: elidex_plugin::Vector::<f32>::ZERO,
counter_state,
paged: true,
expected_generation: Some(generation),
continuation_entities: if continuations.is_empty() {
None
Expand Down
24 changes: 18 additions & 6 deletions crates/core/elidex-render/src/builder/tests/counter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ".").
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 =
Expand Down
6 changes: 6 additions & 0 deletions crates/core/elidex-render/src/builder/tests/display_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 =
Expand Down
Loading
Loading