From e2fada8b842c5a127c2319619b9d5522ae8c0fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 13:44:29 +0300 Subject: [PATCH 01/50] Resolve abspos baseline fallback against the containing grid An absolutely positioned box shares no baseline, so `baseline` and `last baseline` use their fallback edge. The static-position path resolved that edge in the box's own direction/writing mode; Chrome, Firefox and Safari resolve it to the grid's start/end. In-flow grid items keep the self-start/self-end fallback. The native baseline regression pinned the old child-relative edge for an LTR child in an RTL or vertical-rl grid; it now expects the grid's end. Evidence (simulator WPT rig, local core, Emscripten 6.0.10): - 8 FAIL -> PASS at 0 differing pixels: grid-abspos-staticpos- justify-self-rtl-{002,004}, justify-self-rtl-last-baseline-{002,004}, align-self-vertWM-{002,004}, align-self-vertWM-last-baseline-{002,004} - Full 1,100-case run: 442 PASS, 54 FAIL, 1 BLOCKED, 603 SKIP, 0 ERROR (baseline 434 PASS, 62 FAIL); no previously passing test changed status - run-css-block-and-flexbasis.sh: ALL PASS Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/test/test_css_block_and_flexbasis_main.cpp | 5 +++-- packages/engine/ui/layout.cpp | 11 +++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core/test/test_css_block_and_flexbasis_main.cpp b/packages/core/test/test_css_block_and_flexbasis_main.cpp index 1bcbb54..1ee0978 100644 --- a/packages/core/test/test_css_block_and_flexbasis_main.cpp +++ b/packages/core/test/test_css_block_and_flexbasis_main.cpp @@ -4372,11 +4372,12 @@ void testBaselineAlignment() StyleSheet::instance().registerRule("baseline-child", "align-self", "last baseline"); StyleSheet::instance().registerRule("baseline-child", "justify-self", "baseline last"); tree.mount(root, 300, 200); + // The fallback end belongs to the grid, not the child's own + // direction or writing mode (WPT grid-abspos-staticpos-*-002). const bool reversed = std::strcmp(mode, "ltr") != 0; - const bool endAtLeft = reversed && !ownWritingMode; const int areaLeft = positioned && !small ? (reversed ? 21 : 5) : 3; const int areaWidth = small ? 2 : positioned ? 20 : 40; - const int x = areaLeft + (endAtLeft ? 0 : areaWidth - 8); + const int x = areaLeft + (reversed ? 0 : areaWidth - 8); const int y = small ? -1 : positioned ? 19 : 37; expectEqual(tree.node(child).layout.x - tree.node(grid).layout.x, x, "absolute baseline fallback uses logical horizontal end without overflow clamp"); expectEqual(tree.node(child).layout.y - tree.node(grid).layout.y, y, "absolute baseline fallback uses logical vertical end without overflow clamp"); diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 4c2bdd4..0e289d0 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -541,13 +541,16 @@ void applyPreferredRatio(Node &node, int availableWidth, int availableHeight) int baselineFallbackAlignment(const Node &parent, const Node &child, int alignment, int free, bool horizontal, bool staticPosition) { - // Baseline self-alignment follows the subject's writing mode. Resolving - // automatic insets uses the fallback edge without overflow safety; actual - // in-flow self-alignment uses the safe fallback instead (CSS Position 3.5.1). + // In-flow baseline self-alignment falls back to self-start/self-end in the + // subject's writing mode. An absolutely positioned box shares no baseline; + // browsers resolve its fallback to the containing box's start/end, even when + // the box's own direction or writing mode differs. Resolving automatic insets + // uses the fallback edge without overflow safety; actual in-flow + // self-alignment uses the safe fallback instead (CSS Position 3.5.1). if (!staticPosition && free < 0) return gridAxisReversed(parent, horizontal) ? 2 : 6; const bool last = (alignment & 15) == kAlignLastBaseline; - return gridAxisReversed(child, horizontal) != last ? 2 : 6; + return gridAxisReversed(staticPosition ? parent : child, horizontal) != last ? 2 : 6; } // Self-relative edges use the subject's writing mode, even when it differs From b87178a49b99ccd05eca0a302d0af90fc134ea19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 15:25:05 +0300 Subject: [PATCH 02/50] Saturate position offsets instead of truncating them to int16 Top/right/bottom/left are stored as int16 with kUnset (-32768) meaning auto. Resolved lengths saturate at int32, and storing them truncated the low 16 bits: -65536px and -99999999999px became 0, 99999999999px became -1 and -40000px became 25536, so "hidden far off-screen" content painted at the page edge. Both offset sinks (class/selector rules and inline style) now keep kUnset and clamp every other length to [-32767, 32767]. A resolved length of exactly -32768px still reads as auto, as before. Evidence (simulator WPT rig, local core, Emscripten 6.0.10): - FAIL -> PASS at 0 differing pixels: css/css-position/position-absolute-large-negative-inset.html - Full 1,100-case run: 443 PASS, 53 FAIL, 1 BLOCKED, 603 SKIP, 0 ERROR (previous 442 PASS, 54 FAIL); no previously passing test changed status - run-css-block-and-flexbasis.sh: ALL PASS Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/node_model.h | 6 ++++++ packages/engine/ui/style.cpp | 8 ++++---- packages/engine/ui/tree_style.cpp | 16 ++++++++-------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 9c34ea6..16e8da4 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -34,6 +34,12 @@ using style_color_t = gea::framework::graphics::pixel::native_t; #endif inline constexpr int kMaxNodes = GEA_EMBEDDED_MAX_NODES; inline constexpr int kUnset = -32768; +// Position offsets are int16 with kUnset meaning auto. Saturate every other +// length so a huge inset stays far away instead of wrapping or reading as auto. +inline int16_t storedPositionOffset(int value) +{ + return static_cast(value == kUnset ? kUnset : std::clamp(value, -32767, 32767)); +} // Internal value carrier for CSS z-index:auto; numeric stack levels stay int16. inline constexpr int kZIndexAuto = INT32_MIN; // Origins keep percentage permille values unchanged; absolute pixel lengths diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 89b6eec..9521650 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -8070,7 +8070,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::Position: if (value == kPositionFixed) treeState().fixedPositionUsed = true; style.position = value; return true; case Property::Top: #if GEA_CSS_POSITION_TOP - GEA_CSS_POSITION_PX_0(style) = value; + GEA_CSS_POSITION_PX_0(style) = storedPositionOffset(value); #if GEA_CSS_POSITION_TOP_PERCENT GEA_CSS_POSITION_PERCENT_0(style) = kUnset; #endif @@ -8080,7 +8080,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #endif case Property::Right: #if GEA_CSS_POSITION_RIGHT - GEA_CSS_POSITION_PX_1(style) = value; + GEA_CSS_POSITION_PX_1(style) = storedPositionOffset(value); #if GEA_CSS_POSITION_RIGHT_PERCENT GEA_CSS_POSITION_PERCENT_1(style) = kUnset; #endif @@ -8090,7 +8090,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #endif case Property::Bottom: #if GEA_CSS_POSITION_BOTTOM - GEA_CSS_POSITION_PX_2(style) = value; + GEA_CSS_POSITION_PX_2(style) = storedPositionOffset(value); #if GEA_CSS_POSITION_BOTTOM_PERCENT GEA_CSS_POSITION_PERCENT_2(style) = kUnset; #endif @@ -8100,7 +8100,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #endif case Property::Left: #if GEA_CSS_POSITION_LEFT - GEA_CSS_POSITION_PX_3(style) = value; + GEA_CSS_POSITION_PX_3(style) = storedPositionOffset(value); #if GEA_CSS_POSITION_LEFT_PERCENT GEA_CSS_POSITION_PERCENT_3(style) = kUnset; #endif diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index de2c5a5..c538f89 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -618,8 +618,8 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::Position: if (value == kPositionFixed) state.fixedPositionUsed = true; if (style.position != value) { style.position = value; changed = 1; } break; case Property::Top: #if GEA_CSS_POSITION_TOP - if (GEA_CSS_POSITION_PX_0(style) != value || GEA_CSS_POSITION_PERCENT_0(style) != kUnset) { - GEA_CSS_POSITION_PX_0(style) = value; + if (GEA_CSS_POSITION_PX_0(style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_0(style) != kUnset) { + GEA_CSS_POSITION_PX_0(style) = storedPositionOffset(value); #if GEA_CSS_POSITION_TOP_PERCENT GEA_CSS_POSITION_PERCENT_0(style) = kUnset; #endif @@ -629,8 +629,8 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn break; case Property::Right: #if GEA_CSS_POSITION_RIGHT - if (GEA_CSS_POSITION_PX_1(style) != value || GEA_CSS_POSITION_PERCENT_1(style) != kUnset) { - GEA_CSS_POSITION_PX_1(style) = value; + if (GEA_CSS_POSITION_PX_1(style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_1(style) != kUnset) { + GEA_CSS_POSITION_PX_1(style) = storedPositionOffset(value); #if GEA_CSS_POSITION_RIGHT_PERCENT GEA_CSS_POSITION_PERCENT_1(style) = kUnset; #endif @@ -640,8 +640,8 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn break; case Property::Bottom: #if GEA_CSS_POSITION_BOTTOM - if (GEA_CSS_POSITION_PX_2(style) != value || GEA_CSS_POSITION_PERCENT_2(style) != kUnset) { - GEA_CSS_POSITION_PX_2(style) = value; + if (GEA_CSS_POSITION_PX_2(style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_2(style) != kUnset) { + GEA_CSS_POSITION_PX_2(style) = storedPositionOffset(value); #if GEA_CSS_POSITION_BOTTOM_PERCENT GEA_CSS_POSITION_PERCENT_2(style) = kUnset; #endif @@ -651,8 +651,8 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn break; case Property::Left: #if GEA_CSS_POSITION_LEFT - if (GEA_CSS_POSITION_PX_3(style) != value || GEA_CSS_POSITION_PERCENT_3(style) != kUnset) { - GEA_CSS_POSITION_PX_3(style) = value; + if (GEA_CSS_POSITION_PX_3(style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_3(style) != kUnset) { + GEA_CSS_POSITION_PX_3(style) = storedPositionOffset(value); #if GEA_CSS_POSITION_LEFT_PERCENT GEA_CSS_POSITION_PERCENT_3(style) = kUnset; #endif From 776d3254bf11ab2d032fa7a5251ed987551e515f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 16:33:59 +0300 Subject: [PATCH 03/50] Use the baseline fallback when flex baselines run along the cross axis Baseline alignment needs baselines that cross the cross axis. When a flex item's inline axis runs along the cross axis instead (a column, or a row of a vertical container), CSS Align uses the fallback alignment: safe self-start/self-end in the item's own writing mode, which wrap-reverse does not flip. Gea still ran baseline alignment there, so a lone item stayed at the flex-relative cross start and wrap-reverse moved it to the wrong edge. Such items now resolve through baselineFallbackAlignment, stay out of the line's shared baseline, and skip the wrap-reverse baseline mirror. Only real flex containers are affected; inline formatting rows are unchanged. Chrome and Safari pass both tests; Firefox does not. Evidence (simulator WPT rig, local core, Emscripten 6.0.10): - FAIL -> PASS at 0 differing pixels: css/css-flexbox/alignment/ flex-align-baseline-column-vert-{lr,rl}-rtl-wrap-reverse.html - Full 1,100-case run: 445 PASS, 51 FAIL, 1 BLOCKED, 603 SKIP, 0 ERROR (previous 443 PASS, 53 FAIL); no previously passing test changed status - run-css-block-and-flexbasis.sh: ALL PASS Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 0e289d0..1ff2a2c 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1920,7 +1920,7 @@ class FlexLayoutPass { if (isRow_) { for (int i = 0; i < line.count; i++) { Node &probe = nodes[children_[line.start + i]]; - if (probe.computedStyle().margin_auto & 5) continue; + if ((probe.computedStyle().margin_auto & 5) || usesBaselineFallback(probe)) continue; if (crossAlignFor(probe) == kAlignLastBaseline) { const int baseline = childBaseline(probe, true); lineMaxLastDescent = std::max(lineMaxLastDescent, probe.computedStyle().margin[2] + probe.layout.height - baseline); @@ -1955,7 +1955,7 @@ class FlexLayoutPass { // Baselines are distances from physical top. Reversing the line // order must not mirror each item's baseline inside that line. const int alignment = crossAlignFor(childNode) & 15; - if (isRow_ && reverseCross && (alignment == 5 || alignment == kAlignLastBaseline)) + if (isRow_ && reverseCross && (alignment == 5 || alignment == kAlignLastBaseline) && !usesBaselineFallback(childNode)) crossPosition = crossOffset + line.crossSize - (crossPosition - crossOffset) - crossSize; } @@ -1990,6 +1990,17 @@ class FlexLayoutPass { return inlineRow_ ? 5 : node_.computedStyle().align_items; } + // Baseline alignment needs baselines that cross the cross axis. When a flex + // item's inline axis runs along the cross axis instead (a column, or a row + // of a vertical container), it uses the fallback: self-start/self-end in its + // own writing mode, which wrap-reverse does not flip. + bool usesBaselineFallback(const Node &childNode) const + { + const int keyword = crossAlignFor(childNode) & 15; + return node_.computedStyle().display == kDisplayFlex && !inlineRow_ && (keyword == 5 || keyword == kAlignLastBaseline) && + (writingMode(childNode) == 0) == !isRow_; + } + void positionCrossAxisChild(int child, Node &childNode, int lineCrossSize, int crossMarginBefore, int crossMarginAfter, int crossTotal, int *crossPosition, int lineMaxBaseline, int lineLastBaseline) { #if GEA_CSS_FLEX_WRAP @@ -1999,6 +2010,8 @@ class FlexLayoutPass { #endif const int alignment = crossAlignFor(childNode); int align = physicalSelfAlignment(node_, childNode, alignment, lineCrossSize - crossTotal, !isRow_); + if (align < 0 && usesBaselineFallback(childNode)) + align = baselineFallbackAlignment(node_, childNode, alignment, lineCrossSize - crossTotal, !isRow_, false); if (align >= 0) { const bool reversed = gridAxisReversed(node_, !isRow_) != (wrapReversed); if (reversed) align = align == 2 ? 6 : 2; From 1e8dc17a1ba2f3d5250ce13449ec09beded781ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 16:54:22 +0300 Subject: [PATCH 04/50] Apply align-self to abspos static positions inside a line box When an absolutely positioned box takes its static position from inline layout, its static-position rectangle spans the line box's block extent (CSS Position 3, 4.1), and align-self aligns the box within it. Gea kept only the line's top edge, so the box stuck to the top of the line for every align-self value. Inline layout now records the line box height with the static position (0 for a line that is not laid out yet), and alignedAbsoluteOffset applies an explicit align-self along the block axis of horizontal-tb containers. align-self: auto keeps the previous start placement. Evidence (simulator WPT rig, local core, Emscripten 6.0.10): - FAIL -> PASS at 0 differing pixels: css/css-align/abspos/align-self-static-position-005.html - Full 1,100-case run: 446 PASS, 50 FAIL, 1 BLOCKED, 603 SKIP, 0 ERROR (previous 445 PASS, 51 FAIL); no previously passing test changed status - run-css-block-and-flexbasis.sh: ALL PASS; run-gea-retained-absolute-subtree.sh: exit 0 Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 26 +++++++++++++++++++++----- packages/engine/ui/tree_state.h | 3 +++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 1ff2a2c..f1c51c6 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1009,14 +1009,15 @@ class FlexLayoutPass { const int baseline = continuing ? continuedBaseline : lineTop + lineAscent; lineTop = baseline - lineAscent; + int lineCross = lineAscent + lineDescent; + if (lineCross < 0) lineCross = 0; + const int actualLineCross = lineCross; for (int id : lineStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(lineTop); + position.lineHeight = clampInt16(actualLineCross); position.valid = true; } - int lineCross = lineAscent + lineDescent; - if (lineCross < 0) lineCross = 0; - const int actualLineCross = lineCross; if (i == 0) firstLineHeight = lineCross; // The one case the flex path's expandCrossSizes is observable here: a // lone line box in a block with a definite height fills it, so @@ -1086,6 +1087,7 @@ class FlexLayoutPass { for (int id : trailingStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(continuedBaseline - inheritedAscent); + position.lineHeight = 0; position.valid = true; } } @@ -1105,6 +1107,7 @@ class FlexLayoutPass { for (int id : trailingStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(lineTop); + position.lineHeight = 0; position.valid = true; } } @@ -1122,6 +1125,7 @@ class FlexLayoutPass { for (int id : emptyStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(contentTop); + position.lineHeight = 0; position.valid = true; } } @@ -3948,8 +3952,20 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod } if (cursor >= 0) continuationOrigin = boxInset(treeNodes[cursor].computedStyle(), 3) - inlineOffset; } - return (horizontal ? rare->inlineStaticPosition.x + continuationOrigin + childNode.computedStyle().margin[3] - : rare->inlineStaticPosition.y + childNode.computedStyle().margin[0]); + if (horizontal) return rare->inlineStaticPosition.x + continuationOrigin + childNode.style.margin[3]; + // The static-position rectangle spans the line box's block extent, so an + // explicit align-self aligns within it (CSS Position 3, 4.1). + int y = rare->inlineStaticPosition.y + childNode.style.margin[0]; + const int align = childNode.style.align_self; + if (align >= 0 && writingMode(parent) == 0) { + const int free = rare->inlineStaticPosition.lineHeight - childNode.layout.height - + childNode.computedStyle().margin[0] - childNode.computedStyle().margin[2]; + const int self = physicalSelfAlignment(parent, childNode, align, free, false); + const int used = self >= 0 ? self : usedAlignment(align, free); + if (used == 1) y += free / 2; + else if (used == 2) y += free; + } + return y; } const auto &persistent = layoutState(childId); if (parent.computedStyle().display == kDisplayBlock && !parent.computedStyle().flex_direction_explicit && diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index 12cd7d0..36c32d9 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -113,6 +113,9 @@ struct FirstLineFragment { struct InlineStaticPosition { std::int16_t x = 0; std::int16_t y = 0; + // Block extent of the line box that holds the static position; 0 when the + // box is anchored to a line that has not been laid out yet. + std::int16_t lineHeight = 0; bool continuationLine = false; bool valid = false; }; From f6f05d3c6b61e5df7bbf699246373ee08903f10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 20:54:34 +0300 Subject: [PATCH 05/50] Skip node-dependent background placement for detached boxes ViewRenderer::recordBox accepts boxes outside the tree, for which ViewGeometry::nodeIndex returns -1. backgroundPlacement still read treeState().nodes[nodeId] for local attachments and non-border-box origins, i.e. nodes[-1]. The css-3d-cube native test paints such a box; depending on memory layout the out-of-bounds read raised SIGBUS (KERN_PROTECTION_FAILURE in backgroundPlacement, via recordBackgroundGrid). Detached boxes now skip the scroll and edge adjustments. Evidence: run-gea-css-3d-cube-pipeline.sh no longer crashes (5/5 runs); it still reports the same positive-z assertion as e6115c3. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/style.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 9521650..790086d 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -17616,19 +17616,21 @@ BackgroundPlacement StyleValues::backgroundPlacement(const ComputedStyle &style, if (const auto *v = entry(r.bg_attachment_list)) out.attachment = v->a; if (const auto *v = entry(r.bg_origin_list)) out.origin = v->a; if (const auto *v = entry(r.bg_repeat_list)) { out.repeatX = v->a; out.repeatY = v->b; } - const Node &node = treeState().nodes[nodeId]; + const auto &state = treeState(); + // Detached boxes (nodeIndex() == -1) have no scroll or edge state to read. + const Node *node = nodeId >= 0 && nodeId < state.nodeCount ? &state.nodes[nodeId] : nullptr; if (out.attachment == 1) { - x = y = 0; width = treeState().mountedWidth; height = treeState().mountedHeight; - } else { + x = y = 0; width = state.mountedWidth; height = state.mountedHeight; + } else if (node) { if (out.attachment == 2) { - width = std::max(width, node.layout.scroll_content_width); - height = std::max(height, node.layout.scroll_content_height); - x -= node.layout.scroll_x; y -= node.layout.scroll_y; + width = std::max(width, node->layout.scroll_content_width); + height = std::max(height, node->layout.scroll_content_height); + x -= node->layout.scroll_x; y -= node->layout.scroll_y; } if (out.origin != 0) { int inset[4]; - for (int i = 0; i < 4; ++i) inset[i] = computedBorderWidth(node.computedStyle(), i) + - (out.origin == 2 ? std::max(0, node.computedStyle().padding[i]) : 0); + for (int i = 0; i < 4; ++i) inset[i] = computedBorderWidth(node->computedStyle(), i) + + (out.origin == 2 ? std::max(0, node->computedStyle().padding[i]) : 0); x += inset[3]; y += inset[0]; width -= inset[1]+inset[3]; height -= inset[0]+inset[2]; } } From b6f42590004d81a9dd4b2b46bb6511b87735ef93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 20:54:48 +0300 Subject: [PATCH 06/50] Clip local backgrounds of scroll containers to the padding box A background-attachment: local layer scrolls with a scroll container's contents, so its painting area is the scrollport: border-box clipping behaves as padding-box, while content-box is unchanged (upstream attachment-local-clipping-color-1/2/3). backgroundClip now resolves that for hidden/auto/scroll overflow, so color and image layers, and the opaque-border fast path, all see the padding box. Before, a translucent border of such a box blended over its own background. With the next commit this passes css/css-backgrounds/background-attachment-local-hidden.html (Chrome and Firefox pass it; Safari does not). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/style.cpp | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 790086d..d705eb1 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -17598,9 +17598,19 @@ int StyleValues::backgroundClip(const ComputedStyle &style, int layer) { const auto handle = rstyle(style).bg_clip; const auto &lists = backgroundClipLists(); - if (!handle || handle > lists.size()) return 0; - const auto &list = lists[handle - 1]; - return list[std::max(0, layer) % list.size()]; + int clip = 0; + if (handle && handle <= lists.size()) { + const auto &list = lists[handle - 1]; + clip = list[std::max(0, layer) % list.size()]; + } + // A local background scrolls with a scroll container's contents, so its + // painting area is the scrollport: border-box clipping acts as padding-box. + if (clip != 0 || !overflowEstablishesContext(style)) return clip; + const int attachments = rstyle(style).bg_attachment_list; + const auto &placements = backgroundPlacementLists(); + if (attachments < 0 || attachments >= static_cast(placements.size()) || placements[attachments].empty()) return clip; + const auto &list = placements[attachments]; + return list[std::max(0, layer) % list.size()].a == 2 ? 1 : clip; } BackgroundPlacement StyleValues::backgroundPlacement(const ComputedStyle &style, int nodeId, int layer, From 5256e47f1407b1c29d27b9d213a19ef818e6fcec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 20:54:48 +0300 Subject: [PATCH 07/50] Paint each rounded-border row once as outer minus inner span The non-antialiased Canvas::strokeRoundedRect drew four edge rectangles and four corner arcs that overlapped where they met, and the arcs included the pixel on the inner radius. Translucent borders therefore blended twice at the joins: a square 10px rgba border had dark corner squares, a rounded one had dark seams, and the ring bulged 1px into the padding area at each tangent point. Each row is now painted once as the outer shape's span minus the inner (padding-edge) shape's span, using fillRoundedRect's scanline, so the ring is the exact complement of a fill of the inner box. Opaque borders only change along the inner curves (about 25 pixels per corner for a 34px radius and 10px width), where the ring now keeps its width. The antialiased and percent-radius paths are unchanged. Evidence (simulator WPT rig, local core, Emscripten 6.0.10): - FAIL -> PASS at 0 differing pixels: css/css-backgrounds/background-attachment-local-hidden.html - Full 1,100-case run: 447 PASS, 49 FAIL, 1 BLOCKED, 603 SKIP, 0 ERROR (previous 446 PASS, 50 FAIL); no previously passing test changed status. The failing border-radius-clipping-with-transform-001 changed by 432 ring pixels on both sides (4580 -> 4816 differing). - Native: css-block-and-flexbasis, canvas-rounded-rect-alpha, css-border-relief, transformed-rounded-rect, retained-absolute-subtree, css-background-text, css-first-line-background, text-mask-coverage all pass. App pipelines match e6115c3 (analog-clock, button-tetris, canvas-3d, bouncing-balls-jsx, reactive-counter-fonts pass; app-launcher, css-3d-cube, stopwatch and style-viewport-metrics fail identically there). - No device timing was measured. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/canvas.cpp | 94 ++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 49 deletions(-) diff --git a/packages/engine/canvas.cpp b/packages/engine/canvas.cpp index 208a975..e0e107d 100644 --- a/packages/engine/canvas.cpp +++ b/packages/engine/canvas.cpp @@ -4253,57 +4253,53 @@ void Canvas::strokeRoundedRect(int x, int y, int w, int h, int tl, int tr, int b markDirty(dirtyX0, dirtyY0, dirtyX1, dirtyY1); return; } - fillRect(x + tl, y, w - tl - tr, lw, color); - fillRect(x + bl, y + h - lw, w - bl - br, lw, color); - fillRect(x, y + tl, lw, h - tl - bl, color); - fillRect(x + w - lw, y + tr, lw, h - tr - br, color); - for (int q = 0; q < 4; q++) { - int r, cx, cy; - switch (q) { - case 0: r = tr; cx = x + w - 1 - r; cy = y + r; break; - case 1: r = tl; cx = x + r; cy = y + r; break; - case 2: r = bl; cx = x + r; cy = y + h - 1 - r; break; - case 3: r = br; cx = x + w - 1 - r; cy = y + h - 1 - r; break; - default: continue; - } - if (r <= 0) continue; - if (cx + r < clip->x0 || cx - r > clip->x1 || - cy + r < clip->y0 || cy - r > clip->y1) + // Each row is painted once as the outer shape's span minus the inner + // (padding-edge) shape's span, using fillRoundedRect's scanline so the ring + // is the exact complement of a fill of the inner box. Separate edge and + // corner pieces used to overlap, blending translucent borders twice where + // they met and bulging 1px into the padding area at the curves. + const int ix = x + lw, iy = y + lw, iw = w - 2 * lw, ih = h - 2 * lw; + const int innerMax = std::min(iw / 2, ih / 2); + auto innerRadius = [&](int r) { return std::min(std::max(0, r - lw), std::max(0, innerMax)); }; + const int itl = innerRadius(tl), itr = innerRadius(tr), ibr = innerRadius(br), ibl = innerRadius(bl); + auto rowSpan = [](int bx, int by, int bw, int bh, int rtl, int rtr, int rbr, int rbl, int sy, int &left, int &right) { + if (bw <= 0 || bh <= 0 || sy < by || sy >= by + bh) return false; + left = bx + bw; + right = bx - 1; + auto corner = [&](int r, int cy, bool leftSide) { + const int dy = sy > cy ? sy - cy : cy - sy; + const int dx = CanvasMath::integerSqrt(r * r - dy * dy); + if (leftSide) left = std::min(left, bx + r - dx); + else right = std::max(right, bx + bw - 1 - r + dx); + }; + const bool inTopLeft = rtl > 0 && sy <= by + rtl, inBottomLeft = rbl > 0 && sy >= by + bh - 1 - rbl; + const bool inTopRight = rtr > 0 && sy <= by + rtr, inBottomRight = rbr > 0 && sy >= by + bh - 1 - rbr; + if (inTopLeft) corner(rtl, by + rtl, true); + if (inBottomLeft) corner(rbl, by + bh - 1 - rbl, true); + if (!inTopLeft && !inBottomLeft) left = bx; + if (inTopRight) corner(rtr, by + rtr, false); + if (inBottomRight) corner(rbr, by + bh - 1 - rbr, false); + if (!inTopRight && !inBottomRight) right = bx + bw - 1; + return left <= right; + }; + auto paint = [&](int sy, int sx0, int sx1) { + if (sx0 < clip->x0) sx0 = clip->x0; + if (sx1 > clip->x1) sx1 = clip->x1; + if (sx0 < 0) sx0 = 0; + if (sx1 >= width_) sx1 = width_ - 1; + if (sx0 <= sx1) fillSpanGlobalAlpha(rowToPhysical(sy), sx0, sx1 - sx0 + 1, color); + }; + int row0 = std::max(std::max(y, clip->y0), 0); + int row1 = std::min(std::min(y + h - 1, clip->y1), height_ - 1); + for (int sy = row0; sy <= row1; ++sy) { + int oL, oR, iL, iR; + if (!rowSpan(x, y, w, h, tl, tr, br, bl, sy, oL, oR)) continue; + if (!rowSpan(ix, iy, iw, ih, itl, itr, ibr, ibl, sy, iL, iR)) { + paint(sy, oL, oR); continue; - int ri = r - lw; - if (ri < 0) ri = 0; - int dy0 = 0; - int dy1 = r; - if (q == 0 || q == 1) { - if (cy - dy0 > clip->y1) dy0 = cy - clip->y1; - if (cy - dy1 < clip->y0) dy1 = cy - clip->y0; - } else { - if (cy + dy0 < clip->y0) dy0 = clip->y0 - cy; - if (cy + dy1 > clip->y1) dy1 = clip->y1 - cy; - } - if (dy0 < 0) dy0 = 0; - if (dy1 > r) dy1 = r; - if (dy0 > dy1) continue; - for (int dy = dy0; dy <= dy1; dy++) { - int dx_outer = CanvasMath::integerSqrt(r * r - dy * dy); - int dx_inner = (dy <= ri) ? CanvasMath::integerSqrt(ri * ri - dy * dy) : 0; - int sx0, sx1, sy; - switch (q) { - case 0: sx0 = cx + dx_inner; sx1 = cx + dx_outer; sy = cy - dy; break; - case 1: sx0 = cx - dx_outer; sx1 = cx - dx_inner; sy = cy - dy; break; - case 2: sx0 = cx - dx_outer; sx1 = cx - dx_inner; sy = cy + dy; break; - case 3: sx0 = cx + dx_inner; sx1 = cx + dx_outer; sy = cy + dy; break; - default: continue; - } - if (sy < clip->y0 || sy > clip->y1) continue; - if (sx0 < clip->x0) sx0 = clip->x0; - if (sx1 > clip->x1) sx1 = clip->x1; - if (sx0 < 0) sx0 = 0; - if (sx1 >= width_) sx1 = width_ - 1; - if (sx0 > sx1 || sy < 0 || sy >= height_) continue; - - fillSpanGlobalAlpha(rowToPhysical(sy), sx0, sx1 - sx0 + 1, color); } + paint(sy, oL, std::min(oR, iL - 1)); + paint(sy, std::max(oL, iR + 1), oR); } markDirtyClipped(x, y, x + w - 1, y + h - 1); } From dbace1513f62dc7fd0826dfd05a7078779767c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 22:01:53 +0300 Subject: [PATCH 08/50] Support background-clip: border-area for background colors The keyword was rejected, so the declaration was dropped and the color filled the whole border box. Clip value 4 now paints the color along the border's own geometry: the same stroke for a uniform width (radii included), the side rectangles otherwise. Gradient layers still ignore background-clip, as they already did for padding-box and content-box. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/style.cpp | 2 +- packages/engine/ui/view.cpp | 157 ++++++++++++++++++++--------------- 2 files changed, 92 insertions(+), 67 deletions(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index d705eb1..3e6af63 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -6877,7 +6877,7 @@ int compileBackgroundClip(const std::string &value, bool shorthand = false) for (const auto &layer : splitTopLevel(lower, ',')) { if (!shorthand) { const auto word = trimCssValue(layer); - const int clip = word == "text" ? 3 : backgroundBoxValue(word); + const int clip = word == "text" ? 3 : word == "border-area" ? 4 : backgroundBoxValue(word); if (clip < 0) return -1; clips.push_back(clip); } else { diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 2d71ca9..a6c0ee0 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -677,9 +677,9 @@ void appendFillCircleWithAlpha(int cx, int cy, int r, gea::framework::graphics:: appendAlphaCommand(parentAlpha, x, y, d, d); } -void appendStrokeWithAlpha(const Node &node, uint8_t parentAlpha) +void appendStrokeWithAlpha(const Node &node, uint8_t parentAlpha, uint16_t color, uint8_t alpha) { - const uint8_t effectiveAlpha = combineAlpha(parentAlpha, borderPaintAlpha(node.computedStyle(), 0)); + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, alpha); if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, node.layout.x, node.layout.y, node.layout.width, node.layout.height); @@ -705,13 +705,13 @@ void appendStrokeWithAlpha(const Node &node, uint8_t parentAlpha) if (cmd->strokeRoundedRect.rx8[i] > std::min(node.layout.width, node.layout.height) * 4 || cmd->strokeRoundedRect.ry8[i] > std::min(node.layout.width, node.layout.height) * 4) cmd->strokeRoundedRect.cssRadii = 1; - cmd->strokeRoundedRect.color = borderPaintColor(node.computedStyle(), 0); + cmd->strokeRoundedRect.color = color; } else { cmd->type = DisplayCommandType::StrokeRect; cmd->bx = node.layout.x; cmd->by = node.layout.y; cmd->bw = node.layout.width; cmd->bh = node.layout.height; cmd->stroke.x = node.layout.x; cmd->stroke.y = node.layout.y; cmd->stroke.w = node.layout.width; cmd->stroke.h = node.layout.height; - cmd->stroke.color = borderPaintColor(node.computedStyle(), 0); + cmd->stroke.color = color; } } @@ -719,6 +719,11 @@ void appendStrokeWithAlpha(const Node &node, uint8_t parentAlpha) appendAlphaCommand(parentAlpha, node.layout.x, node.layout.y, node.layout.width, node.layout.height); } +void appendStrokeWithAlpha(const Node &node, uint8_t parentAlpha) +{ + appendStrokeWithAlpha(node, parentAlpha, borderPaintColor(node.computedStyle(), 0), borderPaintAlpha(node.computedStyle(), 0)); +} + void boundsFromCorners(const int16_t *xs, const int16_t *ys, int *x0, int *y0, int *x1, int *y1) { *x0 = *x1 = xs[0]; @@ -1585,10 +1590,74 @@ void appendReliefBordersWithAlpha(const Node &node, uint8_t parentAlpha) } } -void appendSideBordersWithAlpha(const Node &node, uint8_t parentAlpha) +void appendBorderSideWithAlpha(const Node &node, int side, uint16_t color, uint8_t alpha, uint8_t parentAlpha, bool transformed) { const int x = node.layout.x; const int y = node.layout.y; + const int w = node.layout.width; + const int h = node.layout.height; + const int borderWidth = computedBorderWidth(node.computedStyle(), side); + if (borderWidth <= 0) return; + int sx = x; + int sy = y; + int sw = w; + int sh = h; + if (side == 0) { + sh = std::min(borderWidth, h); + } else if (side == 1) { + sw = std::min(borderWidth, w); + sx = x + w - sw; + } else if (side == 2) { + sh = std::min(borderWidth, h); + sy = y + h - sh; + } else { + sw = std::min(borderWidth, w); + } + // Horizontal edges own the corners. Avoid compositing a translucent + // asymmetric border twice where two side rectangles would overlap. + if (side == 1 || side == 3) { + const int top = std::min(computedBorderWidth(node.computedStyle(), 0), h); + const int bottom = std::min(computedBorderWidth(node.computedStyle(), 2), h - top); + sy += top; + sh -= top + bottom; + } + if (sw <= 0 || sh <= 0) return; + if (transformed) { + int16_t xs[4], ys[4]; + ViewGeometry::transformRectCorners(node, false, sx, sy, sw, sh, xs, ys); + int bx0, by0, bx1, by1; + boundsFromCorners(xs, ys, &bx0, &by0, &bx1, &by1); + appendFillQuadWithAlpha(xs, + ys, + color, + alpha, + parentAlpha, + bx0, + by0, + bx1 - bx0 + 1, + by1 - by0 + 1, + sx, + sy, + sw, + sh, + true); + } else { + appendFillRectWithAlpha(sx, + sy, + sw, + sh, + color, + alpha, + parentAlpha, + sx, + sy, + sw, + sh); + } +} + +void appendSideBordersWithAlpha(const Node &node, uint8_t parentAlpha) +{ const int w = node.layout.width; const int h = node.layout.height; if (hasBorderRelief(node.computedStyle())) { appendReliefBordersWithAlpha(node, parentAlpha); return; } @@ -1597,68 +1666,22 @@ void appendSideBordersWithAlpha(const Node &node, uint8_t parentAlpha) if (w <= 0 || h <= 0 || (!hasSideBorder(node.computedStyle()) && !borderColorsDiffer(node.computedStyle()) && !textClippedTransform)) return; const bool transformed = ViewGeometry::hasTransformChain(node, false); - for (int side = 0; side < 4; ++side) { - const int borderWidth = computedBorderWidth(node.computedStyle(), side); - const auto color = borderPaintColor(node.computedStyle(), side); - const auto alpha = borderPaintAlpha(node.computedStyle(), side); - if (borderWidth <= 0) continue; - int sx = x; - int sy = y; - int sw = w; - int sh = h; - if (side == 0) { - sh = std::min(borderWidth, h); - } else if (side == 1) { - sw = std::min(borderWidth, w); - sx = x + w - sw; - } else if (side == 2) { - sh = std::min(borderWidth, h); - sy = y + h - sh; - } else { - sw = std::min(borderWidth, w); - } - // Horizontal edges own the corners. Avoid compositing a translucent - // asymmetric border twice where two side rectangles would overlap. - if (side == 1 || side == 3) { - const int top = std::min(computedBorderWidth(node.computedStyle(), 0), h); - const int bottom = std::min(computedBorderWidth(node.computedStyle(), 2), h - top); - sy += top; - sh -= top + bottom; - } - if (sw <= 0 || sh <= 0) continue; - if (transformed) { - int16_t xs[4], ys[4]; - ViewGeometry::transformRectCorners(node, false, sx, sy, sw, sh, xs, ys); - int bx0, by0, bx1, by1; - boundsFromCorners(xs, ys, &bx0, &by0, &bx1, &by1); - appendFillQuadWithAlpha(xs, - ys, - color, - alpha, - parentAlpha, - bx0, - by0, - bx1 - bx0 + 1, - by1 - by0 + 1, - sx, - sy, - sw, - sh, - true); - } else { - appendFillRectWithAlpha(sx, - sy, - sw, - sh, - color, - alpha, - parentAlpha, - sx, - sy, - sw, - sh); - } + for (int side = 0; side < 4; ++side) + appendBorderSideWithAlpha(node, side, borderPaintColor(node.computedStyle(), side), borderPaintAlpha(node.computedStyle(), side), parentAlpha, transformed); +} + +// background-clip: border-area paints the background only where the border +// strokes, whatever the border color. Trace the geometry the border painters +// use: one stroke for a uniform width, side rectangles otherwise. +void recordBorderAreaFill(const Node &node, uint8_t parentAlpha) +{ + const bool transformed = ViewGeometry::hasTransformChain(node, false); + if (node.computedStyle().border_width > 0 && !hasSideBorder(node.computedStyle()) && !transformed) { + appendStrokeWithAlpha(node, parentAlpha, node.computedStyle().bg_color, node.computedStyle().bg_alpha); + return; } + for (int side = 0; side < 4; ++side) + appendBorderSideWithAlpha(node, side, node.computedStyle().bg_color, node.computedStyle().bg_alpha, parentAlpha, transformed); } void recordLinearGradientBackground(const Node &node, uint8_t parentAlpha) @@ -2564,6 +2587,8 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB TextBackgroundClipScope textClip(*n, clip); if (clip == 1 || clip == 2) { recordTransformedRoundedRectFill(*n, parentAlpha, clip); + } else if (clip == 4) { + recordBorderAreaFill(*n, parentAlpha); } else if (ViewGeometry::hasTransformChain(*n, false)) { int16_t xs[4], ys[4]; int bx0, by0, bx1, by1; From c8da31a1ada7986d9f993ac0a3272ad807c96313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 22:36:30 +0300 Subject: [PATCH 09/50] Support text-align-last and ignore edge spaces when aligning lines text-align-last is a new inherited style field, stored in ComputedStyle padding so the struct keeps its size. A DrawText command now carries it: the drawer applies it to lines a forced break ends and, when LayoutEngine::endsFormattingLine says the run closes its paragraph (a
, a block box or the container end follows), to the run's final line. Aligning a line also stops counting the collapsible spaces that hang at its end and the leading space the inline flow trims from line 0. Both put right- and center-aligned text a space off before. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/input_render.cpp | 1 + packages/engine/ui/internal.h | 9 ++- packages/engine/ui/layout.cpp | 23 ++++++++ packages/engine/ui/node_lifecycle.cpp | 1 + packages/engine/ui/node_model.h | 3 + packages/engine/ui/render.cpp | 3 +- packages/engine/ui/style.cpp | 30 +++++++++- packages/engine/ui/style.h | 4 +- packages/engine/ui/text.cpp | 77 ++++++++++++++++++++----- packages/engine/ui/tree_style.cpp | 1 + packages/geatsc-plugin-gea/src/utils.ts | 14 +++++ 11 files changed, 148 insertions(+), 18 deletions(-) diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index d3cd79e..e0d6e1f 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -169,6 +169,7 @@ void InputRenderer::record(int id) cmd->text.textOverflow = n.computedStyle().text_overflow; cmd->text.maxHeight = 0; cmd->text.firstLineIndent = 0; + cmd->text.alignLast = -1; } } diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 026045b..2db038e 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -203,6 +203,10 @@ void rootScrollImageHoldTick(); // Inline continuation offset for the run's FIRST line only (see // LayoutBox::inline_indent). 0 for every run that starts its own line. int16_t firstLineIndent; + // text-align-last: -1 when every line uses `align`. Otherwise bits 0-1 + // align lines a forced break ends, and bit 2 marks a run whose final + // line also ends its paragraph. + int8_t alignLast; } text; struct { @@ -489,6 +493,9 @@ void rootScrollImageHoldTick(); // Inline-level boxes blockified by float, absolute/fixed positioning, or // flex/grid-item status return false. static bool isCssInlineLevelBox(const Node &n, bool hypothetical = false); + // True when the line box closes right after node `id`: a forced break, a + // block-level box, or the end of its block container follows it. + static bool endsFormattingLine(int id); }; // Shared order for recording, retained transform replay, and hit testing. @@ -570,7 +577,7 @@ void rootScrollImageHoldTick(); int width, uint8_t *outCoverage); static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform = 0, int lineHeight = 0, int whiteSpace = 0, int textOverflow = 0, int maxHeight = 0, - int firstLineIndent = 0); + int firstLineIndent = 0, int alignLast = -1); // Single-line width measure for places that don't go through layout() // — used by InputRenderer to position the caret at the end of the value. static int measureWidth(const char *text, int fontId, int fontSize, int textTransform = 0); diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index f1c51c6..f474280 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -4096,6 +4096,29 @@ bool LayoutEngine::isInlineLevelNode(const Node &n) return isInlineLevelTag(tagFromId(n.tag_id)); } +// Mirror of startsFormattingLine: walk forward through siblings and inline +// ancestors to the next content that shares or closes this line box. +bool LayoutEngine::endsFormattingLine(int id) +{ + const Node *nodes = Tree::instance().nodes(); + while (isCssInlineLevelBox(nodes[id]) && isInlineLevelNode(nodes[id])) { + for (int sibling = nodes[id].next_sibling; sibling >= 0; sibling = nodes[sibling].next_sibling) { + const Node &next = nodes[sibling]; + if (isDisplayNone(next.computedStyle()) || isOutOfFlowPosition(next.computedStyle().position) || next.computedStyle().float_side) + continue; + if (isAnonymousTextNode(next) && + (next.computedStyle().white_space == 0 || next.computedStyle().white_space == 1 || next.computedStyle().white_space == 4) && + next.text.find_first_not_of(" \t\r\n\f") == std::string::npos) continue; + const int edge = formattingEdgeNode(nodes, sibling, false); + if (edge < 0) continue; + return isLineBreak(nodes[edge]) || !isInlineLevelNode(nodes[edge]); + } + id = nodes[id].parent; + if (id < 0) return true; + } + return true; +} + bool LayoutEngine::isCssInlineLevelBox(const Node &n, bool hypothetical) { if (n.computedStyle().display == kDisplayNone || n.computedStyle().display_explicit) return false; diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index 5144938..f984e91 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -205,6 +205,7 @@ constexpr ComputedStyle makeDefaultStyle() #if GEA_CSS_TEXT_ALIGN style.text_align = 0; #endif + style.text_align_last = 0; #if GEA_CSS_TEXT_DECORATION style.text_decoration = 0; #endif diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 16e8da4..92ab5ae 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -798,6 +798,9 @@ struct ComputedStyle { static constexpr int32_t blink_started_ms = 0; static constexpr uint8_t blink_visible = 1; #endif + // CSS `text-align-last`: 0 = auto (follow text-align), otherwise a text_align + // value plus one. Inherited. Sits in padding, so ComputedStyle keeps its size. + int8_t text_align_last; #if GEA_CSS_BORDER_WIDTHS && !GEA_CSS_U8_BORDER int16_t border_width; diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 644f2f3..4354589 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6834,7 +6834,8 @@ int gLastScrollUiFrame = -1000; TextRenderer::drawWrapped(c.text.text, c.text.x, c.text.y, c.text.maxWidth, c.text.color, c.text.scale, c.text.align, c.text.containerWidth, c.text.fontId, c.text.textTransform, c.text.lineHeight, - c.text.whiteSpace, c.text.textOverflow, c.text.maxHeight, c.text.firstLineIndent); + c.text.whiteSpace, c.text.textOverflow, c.text.maxHeight, c.text.firstLineIndent, + c.text.alignLast); break; case DisplayCommandType::BlitImage: #if GEA_PIXEL_STORAGE_PACKED diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 3e6af63..2b30738 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -601,6 +601,7 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) a.font_weight != b.font_weight || a.line_height != b.line_height || a.text_align != b.text_align || + a.text_align_last != b.text_align_last || a.overflow != b.overflow || a.overflow_x != b.overflow_x || a.overflow_y != b.overflow_y || @@ -720,6 +721,7 @@ bool styleEqualExceptLocalDisplayCommands(const ComputedStyle &a, const Computed a.font_weight != b.font_weight || a.line_height != b.line_height || a.text_align != b.text_align || + a.text_align_last != b.text_align_last || a.overflow != b.overflow || a.overflow_x != b.overflow_x || a.overflow_y != b.overflow_y || @@ -1244,6 +1246,7 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "font-weight") == 0) return CssDeclarationId::FontWeight; if (std::strcmp(property, "line-height") == 0) return CssDeclarationId::LineHeight; if (std::strcmp(property, "text-align") == 0) return CssDeclarationId::TextAlign; + if (std::strcmp(property, "text-align-last") == 0) return CssDeclarationId::TextAlignLast; if (std::strcmp(property, "text-decoration") == 0 || std::strcmp(property, "text-decoration-line") == 0) return CssDeclarationId::TextDecoration; if (std::strcmp(property, "text-transform") == 0) return CssDeclarationId::TextTransform; if (std::strcmp(property, "white-space") == 0) return CssDeclarationId::WhiteSpace; @@ -4990,6 +4993,15 @@ int textAlignValue(const std::string &value) return 0; } +// Unlike text-align, `auto` must stay distinct from start: 0 = auto, otherwise +// the text_align value plus one. Justification is unsupported and starts. +int textAlignLastValue(const std::string &value) +{ + if (value == "left" || value == "start" || value == "justify") return 1; + if (value == "center" || value == "right" || value == "end") return textAlignValue(value) + 1; + return 0; +} + // CSS `text-decoration` — only the values relevant to embedded UI are // modeled: `none` (default), `underline`, and `line-through`. The // renderer paints a 1-pixel horizontal line at a y-offset chosen per @@ -7153,6 +7165,9 @@ bool compileKeywordValue(CssDeclarationId declaration, const std::string &value, case CssDeclarationId::TextAlign: compiled.values[0] = textAlignValue(value); return true; + case CssDeclarationId::TextAlignLast: + compiled.values[0] = textAlignLastValue(value); + return true; case CssDeclarationId::TextDecoration: compiled.values[0] = textDecorationValue(value); return true; @@ -8337,6 +8352,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #if GEA_CSS_TEXT_ALIGN case Property::TextAlign: style.text_align = value; return true; #endif + case Property::TextAlignLast: style.text_align_last = value; return true; #if GEA_CSS_TEXT_DECORATION case Property::TextDecoration: style.text_decoration = value; return true; #endif @@ -9359,7 +9375,7 @@ bool propertyAffectsDescendantStyle(Property property) property == Property::FontSize || property == Property::FontWeight || property == Property::LineHeight || property == Property::LineHeightExpression || property == Property::LineHeightMultiplier || - property == Property::TextAlign || + property == Property::TextAlign || property == Property::TextAlignLast || property == Property::TextTransform || property == Property::WhiteSpace || property == Property::Visibility || property == Property::BorderWidth || (property >= Property::BorderTopWidth && property <= Property::BorderLeftWidth); @@ -9393,6 +9409,7 @@ void applyInheritedStyleDefaults(int node) #if GEA_CSS_TEXT_ALIGN style.text_align = parentStyle.text_align; #endif + style.text_align_last = parentStyle.text_align_last; #if GEA_CSS_TEXT_TRANSFORM style.text_transform = parentStyle.text_transform; #endif @@ -10092,6 +10109,9 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::TextAlign: setStyleValue(node, Property::TextAlign, textAlignValue(value), source); return true; + case CssDeclarationId::TextAlignLast: + setStyleValue(node, Property::TextAlignLast, textAlignLastValue(value), source); + return true; case CssDeclarationId::TextDecoration: setStyleValue(node, Property::TextDecoration, textDecorationValue(value), source); return true; @@ -11156,6 +11176,7 @@ bool applyCompiledCssValueWithSource(NodeHandle node, const CssCompiledValue &co case CssDeclarationId::AlignSelf: setStyleValue(node, Property::AlignSelf, compiled.values[0], source); return true; case CssDeclarationId::Position: setStyleValue(node, Property::Position, compiled.values[0], source); return true; case CssDeclarationId::TextAlign: setStyleValue(node, Property::TextAlign, compiled.values[0], source); return true; + case CssDeclarationId::TextAlignLast: setStyleValue(node, Property::TextAlignLast, compiled.values[0], source); return true; case CssDeclarationId::TextDecoration: setStyleValue(node, Property::TextDecoration, compiled.values[0], source); return true; case CssDeclarationId::TextTransform: setStyleValue(node, Property::TextTransform, compiled.values[0], source); return true; case CssDeclarationId::WhiteSpace: setStyleValue(node, Property::WhiteSpace, compiled.values[0], source); return true; @@ -11748,6 +11769,7 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "font-weight") return removeInlineStyleProperties(id, {Property::FontWeight}); if (property == "line-height") return removeInlineStyleProperties(id, {Property::LineHeight, Property::LineHeightExpression, Property::LineHeightMultiplier}); if (property == "text-align") return removeInlineStyleProperties(id, {Property::TextAlign}); + if (property == "text-align-last") return removeInlineStyleProperties(id, {Property::TextAlignLast}); if (property == "text-decoration" || property == "text-decoration-line") return removeInlineStyleProperties(id, {Property::TextDecoration}); if (property == "text-transform") return removeInlineStyleProperties(id, {Property::TextTransform}); @@ -13994,6 +14016,7 @@ bool addCachedKeywordDeclaration(CachedStyleApplyOp &op, CssDeclarationId declar case CssDeclarationId::AlignSelf: return addCachedStyleApplyProperty(op, Property::AlignSelf, value); case CssDeclarationId::Position: return addCachedStyleApplyProperty(op, Property::Position, value); case CssDeclarationId::TextAlign: return addCachedStyleApplyProperty(op, Property::TextAlign, value); + case CssDeclarationId::TextAlignLast: return addCachedStyleApplyProperty(op, Property::TextAlignLast, value); case CssDeclarationId::TextDecoration: return addCachedStyleApplyProperty(op, Property::TextDecoration, value); case CssDeclarationId::TextTransform: return addCachedStyleApplyProperty(op, Property::TextTransform, value); case CssDeclarationId::WhiteSpace: return addCachedStyleApplyProperty(op, Property::WhiteSpace, value); @@ -14905,6 +14928,7 @@ bool addCompiledValueWrites(const CssCompiledValue &compiled, PropertyWriteMask case CssDeclarationId::AlignSelf: addPropertyWrite(mask, Property::AlignSelf); return true; case CssDeclarationId::Position: addPropertyWrite(mask, Property::Position); return true; case CssDeclarationId::TextAlign: addPropertyWrite(mask, Property::TextAlign); return true; + case CssDeclarationId::TextAlignLast: addPropertyWrite(mask, Property::TextAlignLast); return true; case CssDeclarationId::TextDecoration: addPropertyWrite(mask, Property::TextDecoration); return true; case CssDeclarationId::TextTransform: addPropertyWrite(mask, Property::TextTransform); return true; case CssDeclarationId::WhiteSpace: addPropertyWrite(mask, Property::WhiteSpace); return true; @@ -16093,6 +16117,7 @@ struct ParentStyleSnapshot { #else static constexpr std::uint8_t text_align = 0; #endif + std::uint8_t text_align_last; #if GEA_CSS_TEXT_TRANSFORM std::uint8_t text_transform; #else @@ -16136,6 +16161,7 @@ ParentStyleSnapshot snapshotParentStyle(const ComputedStyle &s) #if GEA_CSS_TEXT_ALIGN out.text_align = s.text_align; #endif + out.text_align_last = s.text_align_last; #if GEA_CSS_TEXT_TRANSFORM out.text_transform = s.text_transform; #endif @@ -16158,7 +16184,7 @@ bool parentStylesDiffer(const ParentStyleSnapshot &a, const ParentStyleSnapshot { return a.text_color != b.text_color || a.text_alpha != b.text_alpha || a.font_id != b.font_id || a.font_size != b.font_size || a.font_weight != b.font_weight || a.line_height != b.line_height || a.line_height_multiplier != b.line_height_multiplier || - a.text_align != b.text_align || a.text_transform != b.text_transform || + a.text_align != b.text_align || a.text_align_last != b.text_align_last || a.text_transform != b.text_transform || a.white_space != b.white_space || a.visibility != b.visibility || a.border_widths != b.border_widths; } diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 02f6a56..fa7b4a4 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -212,6 +212,7 @@ enum class Property : int { BorderBottomRelief, BorderLeftRelief, BorderRelief, + TextAlignLast, Count }; @@ -346,7 +347,8 @@ enum class StyleDeclaration : std::uint8_t { BackgroundOrigin, MarginTrim, Transition, - AnimationPlayState + AnimationPlayState, + TextAlignLast }; class Style { diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index f94527b..ea3f0e6 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1091,7 +1091,7 @@ class TextDrawer { command.text.color, command.text.scale, command.text.align, command.text.containerWidth, command.text.fontId, command.text.textTransform, command.text.lineHeight, command.text.whiteSpace, command.text.textOverflow, command.text.maxHeight, - command.text.firstLineIndent, &sink); + command.text.firstLineIndent, command.text.alignLast, &sink); } #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS @@ -1145,7 +1145,7 @@ class TextDrawer { } } - static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, TextCoverageSink *coverageSink = nullptr) + static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, TextCoverageSink *coverageSink = nullptr) { if (!text || !text[0]) return; std::string transformed; @@ -1170,16 +1170,19 @@ class TextDrawer { const bool noWrap = whiteSpace == 1; const bool ellipsis = textOverflow == 1; + // A nowrap run is one line, so only a paragraph end can realign it. + const int singleLineAlign = alignLast >= 0 && (alignLast & 4) ? alignLast & 3 : textAlign; + const bool hangSpaces = whiteSpace == 0 || whiteSpace == 4; #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS if (fontId >= 0) { const int fontSize = static_cast(scale * kBitmapFontHeight + 0.5f); if (gea::framework::graphics::FontRegistry::rasterizedFamily(fontId, fontSize).valid()) { if (noWrap) - drawRasterizedSingleLine(text, x, y, maxWidth, color, textAlign, containerWidth, fontId, fontSize, lineHeight, ellipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); + drawRasterizedSingleLine(text, x, y, maxWidth, color, singleLineAlign, containerWidth, fontId, fontSize, lineHeight, ellipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); else drawRasterizedWrapped(text, x, y, maxWidth, color, textAlign, containerWidth, fontId, fontSize, lineHeight, clipX0, clipY0, clipX1, clipY1, - ellipsis ? maxHeight : 0, firstLineIndent, coverageSink); + ellipsis ? maxHeight : 0, firstLineIndent, alignLast, hangSpaces, coverageSink); return; } } @@ -1187,14 +1190,14 @@ class TextDrawer { (void)fontId; #endif if (noWrap) - drawBitmapSingleLine(text, x, y, maxWidth, color, scale, textAlign, containerWidth, lineHeight, ellipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); + drawBitmapSingleLine(text, x, y, maxWidth, color, scale, singleLineAlign, containerWidth, lineHeight, ellipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); else - drawBitmapWrapped(text, x, y, maxWidth, color, scale, textAlign, containerWidth, lineHeight, clipX0, clipY0, clipX1, clipY1, firstLineIndent, coverageSink); + drawBitmapWrapped(text, x, y, maxWidth, color, scale, textAlign, containerWidth, lineHeight, clipX0, clipY0, clipX1, clipY1, firstLineIndent, alignLast, hangSpaces, coverageSink); } private: #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS - static void drawRasterizedWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, int textAlign, int containerWidth, int fontId, int fontSize, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int ellipsisMaxHeight = 0, int firstLineIndent = 0, const TextCoverageSink *coverageSink = nullptr) + static void drawRasterizedWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, int textAlign, int containerWidth, int fontId, int fontSize, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int ellipsisMaxHeight = 0, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, const TextCoverageSink *coverageSink = nullptr) { // Inline continuation: the run's first line starts `firstLineIndent` px in // from the box's left edge (the pen position it inherited from the box @@ -1206,12 +1209,16 @@ class TextDrawer { return budget < 1 ? 1 : budget; }; const auto lineOriginX_ = [&](int li) { return li == 0 ? x + firstLineIndent : x; }; + // The trimmed leading space still sits in line 0's width; aligning must skip it. + const auto trimmedIndent_ = [&](int li) { return li == 0 && firstLineIndent < 0 ? -firstLineIndent : 0; }; gea::framework::graphics::RasterizedFont font = gea::framework::graphics::FontRegistry::rasterizedFamily(fontId, fontSize); if (!font.valid()) return; const int glyphHeight = font.lineHeight(); const int lineAdvance = lineHeight > 0 ? lineHeight : glyphHeight; const int lineBoxOffset = lineHeight > 0 ? (lineAdvance - glyphHeight) / 2 : 0; + gea::framework::graphics::Glyph spaceGlyph{}; + const int spaceAdvance = font.glyph(' ', &spaceGlyph) ? spaceGlyph.advance : font.sizePx() / 2; // Multi-line clamp (text-overflow: ellipsis on WRAPPED text): the content // box caps how many lines draw; the last budgeted line renders through // the single-line ellipsis routine when more text would follow it. @@ -1277,7 +1284,9 @@ class TextDrawer { const int lineBoxY = penY + lineBoxOffset; if (lineBoxY + glyphHeight - 1 >= clipY0) { const int lineWidth = entry.lineWidths[li]; - const int lineX = lineOriginX_(li) + alignedOffset(textAlign, containerWidth, lineWidth); + const int align = lineAlignment(textAlign, alignLast, lineStart, entry.consumedBytes[li], li == entry.lineCount - 1); + const int hanging = hangSpaces && align ? hangingSpaceWidth(lineStart, chars, spaceAdvance) : 0; + const int lineX = lineOriginX_(li) + alignedOffset(align, containerWidth, lineWidth - hanging - trimmedIndent_(li)); if (lineX <= clipX1 && lineX + lineWidth - 1 >= clipX0) { char lineBuffer[kWrappedLineBufferBytes + 1]; const int copyLen = chars < kWrappedLineBufferBytes ? chars : kWrappedLineBufferBytes; @@ -1314,7 +1323,9 @@ class TextDrawer { const int lineBoxY = penY + lineBoxOffset; if (lineBoxY + glyphHeight - 1 >= clipY0) { - const int lineX = lineOriginX_(lineIndex) + alignedOffset(textAlign, containerWidth, line.width); + const int align = lineAlignment(textAlign, alignLast, lineStart, line.consumedBytes, !lineStart[line.consumedBytes]); + const int hanging = hangSpaces && align ? hangingSpaceWidth(lineStart, line.renderBytes, spaceAdvance) : 0; + const int lineX = lineOriginX_(lineIndex) + alignedOffset(align, containerWidth, line.width - hanging - trimmedIndent_(lineIndex)); if (lineX <= clipX1 && lineX + line.width - 1 >= clipX0) { char lineBuffer[kWrappedLineBufferBytes + 1]; const int copyLen = line.renderBytes < kWrappedLineBufferBytes ? line.renderBytes : kWrappedLineBufferBytes; @@ -1422,7 +1433,7 @@ class TextDrawer { } #endif - static void drawBitmapWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, float scale, int textAlign, int containerWidth, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int firstLineIndent = 0, const TextCoverageSink *coverageSink = nullptr) + static void drawBitmapWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, float scale, int textAlign, int containerWidth, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, const TextCoverageSink *coverageSink = nullptr) { if (scale < 0.1f) scale = 1.0f; int glyphWidth = static_cast(kBitmapFontWidth * scale + 0.5f); @@ -1441,12 +1452,16 @@ class TextDrawer { // the inherited pen and has that much less room. const int budget = lineIndex == 0 ? maxWidth - firstLineIndent : maxWidth; const int lineOriginX = lineIndex == 0 ? x + firstLineIndent : x; + const int trimmedIndent = lineIndex == 0 && firstLineIndent < 0 ? -firstLineIndent : 0; const WrappedLine line = nextWrappedLine(lineStart, budget < 1 ? 1 : budget, [&](int, int) { return glyphWidth; }); lineIndex++; if (penY + glyphHeight - 1 >= clipY0) { - int penX = lineOriginX + alignedOffset(textAlign, containerWidth, line.width); + const int align = lineAlignment(textAlign, alignLast, lineStart, line.consumedBytes, !lineStart[line.consumedBytes]); + const int hanging = hangSpaces && align ? hangingSpaceWidth(lineStart, line.renderBytes, glyphWidth) : 0; + const int lineX = lineOriginX + alignedOffset(align, containerWidth, line.width - hanging - trimmedIndent); + int penX = lineX; if (penX <= clipX1 && penX + line.width - 1 >= clipX0) { const char *glyph = lineStart; const char *renderEnd = lineStart + line.renderBytes; @@ -1538,6 +1553,23 @@ class TextDrawer { else if (textAlign == 2) offset = containerWidth - lineWidth; return offset < 0 ? 0 : offset; } + + // text-align-last aligns every line a forced break ends and, when the run + // closes its paragraph, the run's final line (see DrawText::alignLast). + static int lineAlignment(int textAlign, int alignLast, const char *lineStart, int consumedBytes, bool finalLine) + { + if (alignLast < 0) return textAlign; + const bool forcedBreak = consumedBytes > 0 && lineStart[consumedBytes - 1] == '\n'; + return forcedBreak || (finalLine && (alignLast & 4)) ? alignLast & 3 : textAlign; + } + + // Collapsible spaces that end a line hang, so alignment must not count them. + static int hangingSpaceWidth(const char *line, int bytes, int spaceAdvance) + { + int width = 0; + for (; bytes > 0 && line[bytes - 1] == ' '; --bytes) width += spaceAdvance; + return width; + } }; } // namespace @@ -1934,9 +1966,9 @@ int TextRenderer::reflowToBoxWidth(int id) return measured.width; } -void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent) +void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent, int alignLast) { - TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent); + TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent, alignLast); } void TextRenderer::unionCoverageRow(const DisplayCommand &command, int screenY, int screenX, @@ -1965,6 +1997,15 @@ int TextRenderer::measureHeight(const char *text, int fontId, int fontSize, int return measured.height; } +// DrawText::alignLast for a run: -1 unless text-align-last moves some line. +static int8_t textAlignLastCommand(const Node &node) +{ + const int last = node.computedStyle().text_align_last - 1; + if (last < 0 || last == node.style.text_align) return -1; + const bool endsParagraph = LayoutEngine::endsFormattingLine(static_cast(&node - Tree::instance().nodes())); + return static_cast(last | (endsParagraph ? 4 : 0)); +} + void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlpha) { const Node *n = &node; @@ -2296,6 +2337,15 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph } + const int8_t alignLast = transformed ? -1 : textAlignLastCommand(*n); + // The bounds above assume text-align for every line; realigned lines may + // sit anywhere across the content box. + if (alignLast >= 0) { + const int right = std::max(bx + bw, drawX + containerW + 2); + bx = std::min(bx, drawX - 2); + bw = right - bx; + } + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, n->computedStyle().text_alpha); if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, bx, by, bw, bh); @@ -2319,6 +2369,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.whiteSpace = n->computedStyle().white_space; cmd->text.textOverflow = n->computedStyle().text_overflow; cmd->text.firstLineIndent = static_cast(inlineIndent); + cmd->text.alignLast = alignLast; { const int contentH = h - boxInset(n->computedStyle(), 0) - boxInset(n->computedStyle(), 2); cmd->text.maxHeight = static_cast(contentH > 0 ? (contentH > 32767 ? 32767 : contentH) : 0); diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index c538f89..c7a4b1b 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -978,6 +978,7 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn #if GEA_CSS_TEXT_ALIGN case Property::TextAlign: if (style.text_align != value) { style.text_align = value; changed = 1; } break; #endif + case Property::TextAlignLast: if (style.text_align_last != value) { style.text_align_last = value; changed = 1; } break; #if GEA_CSS_TEXT_DECORATION case Property::TextDecoration: if (style.text_decoration != value) { style.text_decoration = value; changed = 1; } break; #endif diff --git a/packages/geatsc-plugin-gea/src/utils.ts b/packages/geatsc-plugin-gea/src/utils.ts index c0e5def..7ba88d0 100644 --- a/packages/geatsc-plugin-gea/src/utils.ts +++ b/packages/geatsc-plugin-gea/src/utils.ts @@ -65,6 +65,7 @@ export function styleKeywordPropertyValuesForPropertyName(propertyName: string, } if (propertyName === 'position') return keywordValue('Position', POSITION_KEYWORDS, value) if (propertyName === 'text-align') return keywordValue('TextAlign', TEXT_ALIGN_KEYWORDS, value) + if (propertyName === 'text-align-last') return keywordValue('TextAlignLast', TEXT_ALIGN_LAST_KEYWORDS, value) if (propertyName === 'white-space') return keywordValue('WhiteSpace', WHITE_SPACE_KEYWORDS, value) if (propertyName === 'text-overflow') return keywordValue('TextOverflow', TEXT_OVERFLOW_KEYWORDS, value) if (propertyName === 'backface-visibility') return keywordValue('Backface', BACKFACE_KEYWORDS, value) @@ -251,6 +252,7 @@ const STYLE_DECLARATION_ENUM_BY_PROPERTY = new Map([ ['font-weight', 'FontWeight'], ['line-height', 'LineHeight'], ['text-align', 'TextAlign'], + ['text-align-last', 'TextAlignLast'], ['text-decoration', 'TextDecoration'], ['text-decoration-line', 'TextDecoration'], ['text-transform', 'TextTransform'], @@ -369,6 +371,7 @@ const STYLE_KEYWORD_PROPERTIES_BY_PROPERTY = new Map([ ['place-items', ['AlignItems', 'JustifyItems']], ['position', ['Position']], ['text-align', ['TextAlign']], + ['text-align-last', ['TextAlignLast']], ['white-space', ['WhiteSpace']], ['text-overflow', ['TextOverflow']], ['backface-visibility', ['Backface']], @@ -438,6 +441,17 @@ const TEXT_ALIGN_KEYWORDS = new Map([ ['end', 2], ]) +// The engine keeps auto distinct from start: text-align plus one, 0 = auto. +const TEXT_ALIGN_LAST_KEYWORDS = new Map([ + ['auto', 0], + ['left', 1], + ['start', 1], + ['justify', 1], + ['center', 2], + ['right', 3], + ['end', 3], +]) + const WHITE_SPACE_KEYWORDS = new Map([ ['normal', 0], ['nowrap', 1], From 3ddc895553d6b138d89109e53af395bf01cd530b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 22:47:53 +0300 Subject: [PATCH 10/50] Paint outer box-shadows Outer layers were dropped at parse time, so only inset shadows ever painted. A value's first inset layer still wins; otherwise its first outer layer is kept (var() values now parse at runtime instead of compiling outer-only shadows to none). recordOuterBoxShadow grows the border box by the spread with the CSS Backgrounds outset-adjusted radii (a circle stays a circle), offsets it, and approximates the Gaussian blur with constant-alpha bands. Nothing paints inside the border box: over an opaque border-box background each band is one native rounded fill or ring and fully covered parts are skipped; otherwise rows are painted as spans with the box knocked out. A sharp shadow (blur 0) with no radii on the shadow or the box is at most four FillRects for the parts outside the box under one alpha scope, or one rect when it misses the box, instead of a fill under the whole box that the background paints over again. A shadow whose contour, blur included, leaves the int16 range takes the saturating span path. recordBox tests the rare-style shadow alpha byte inline and calls recordOuterBoxShadow only for a box that has an outer shadow. Node bounds grow by the shadow's reach, and the reach at the last snapshot is kept in RenderState as 32 bits, so dirty regions cover both the new and the old shadow. A blurred shadow on the span path (translucent background, transform or elliptical corner) emits commands per band per corner row, about 670 for a radius-12, blur-16 card. Desktop harness, box-shadow-24 (24 cards alternating a sharp and a blurred rounded shadow): the strip path paints the same pixels as the full fill. Not measured on the board yet. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/layout_snapshot.cpp | 1 + packages/engine/ui/node_lifecycle.cpp | 1 + packages/engine/ui/node_model.h | 15 ++ packages/engine/ui/style.cpp | 65 +++--- packages/engine/ui/view.cpp | 273 ++++++++++++++++++++++++- 5 files changed, 315 insertions(+), 40 deletions(-) diff --git a/packages/engine/ui/layout_snapshot.cpp b/packages/engine/ui/layout_snapshot.cpp index ea625f8..5a05541 100644 --- a/packages/engine/ui/layout_snapshot.cpp +++ b/packages/engine/ui/layout_snapshot.cpp @@ -71,6 +71,7 @@ void LayoutSnapshot::capture() #if GEA_CSS_FILTERS state.nodes[i].render.previous_filter_blur_radius = rs.filter_blur_radius; #endif + state.nodes[i].render.previous_box_shadow_extent = boxShadowExtent(state.nodes[i].computedStyle()); state.nodes[i].render.dirty = 0; state.nodes[i].render.canvas_pixels_only_dirty = 0; state.nodes[i].render.layout_dirty = 0; diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index f984e91..e72358a 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -426,6 +426,7 @@ void NodeLifecycle::init(Node *n, NodeType type) #if GEA_CSS_FILTERS n->render.previous_filter_blur_radius = 0; #endif + n->render.previous_box_shadow_extent = 0; n->render.inline_baseline = 0; n->render.dirty = 0; n->render.canvas_pixels_only_dirty = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 92ab5ae..d779099 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -1349,6 +1349,17 @@ inline bool hasSideBorder(const ComputedStyle &style) #endif } +// How far an outer box-shadow can paint outside the border box. +inline int boxShadowExtent(const ComputedStyle &style) +{ + const RareStyle &r = rstyle(style); + if (r.box_shadow_inset || r.box_shadow_alpha == 0) return 0; + const int ox = r.box_shadow_offset_x < 0 ? -r.box_shadow_offset_x : r.box_shadow_offset_x; + const int oy = r.box_shadow_offset_y < 0 ? -r.box_shadow_offset_y : r.box_shadow_offset_y; + const int reach = r.box_shadow_spread + (r.box_shadow_blur_radius > 0 ? r.box_shadow_blur_radius : 0) + (ox > oy ? ox : oy); + return reach > 0 ? reach : 0; +} + inline bool hasAnyBorder(const ComputedStyle &style) { return style.border_width > 0 || hasSideBorder(style); @@ -1543,6 +1554,10 @@ struct RenderState { style_color_t bg_recolor_from; struct { int16_t x0, x1; } text_dirty; }; + // boxShadowExtent at the last snapshot, so a moved or restyled shadow + // invalidates the pixels it used to cover. Spread, blur and offset are each + // int16_t, so their sum needs 32 bits. + int32_t previous_box_shadow_extent; }; inline bool hadIndividualLinearTransform(const RenderState &s) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 2b30738..ea9faa6 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -5143,7 +5143,9 @@ struct ParsedBoxShadow { ParsedCssColor color; }; -ParsedBoxShadow parseInsetBoxShadow(const std::string &value, int nodeId) +// Gea paints one shadow per box: the first inset layer when there is one, +// otherwise the first outer layer. +ParsedBoxShadow parseBoxShadow(const std::string &value, int nodeId) { ParsedBoxShadow out; out.color.r = 0; @@ -5161,6 +5163,7 @@ ParsedBoxShadow parseInsetBoxShadow(const std::string &value, int nodeId) return out; } + ParsedBoxShadow outer; for (const auto &rawLayer : splitTopLevel(text, ',')) { const auto tokens = splitFunctionAwareWords(rawLayer); bool inset = false; @@ -5180,18 +5183,20 @@ ParsedBoxShadow parseInsetBoxShadow(const std::string &value, int nodeId) if (lower == "outset") continue; lengths.push_back(token); } - if (!inset) continue; - out.valid = true; - out.inset = true; - out.color = color; - if (!lengths.empty()) out.offsetX = parseLengthForNode(lengths[0], nodeId, LengthAxis::Horizontal); - if (lengths.size() > 1) out.offsetY = parseLengthForNode(lengths[1], nodeId, LengthAxis::Vertical); - if (lengths.size() > 2) out.blur = parseLengthForNode(lengths[2], nodeId, LengthAxis::None); - if (lengths.size() > 3) out.spread = parseLengthForNode(lengths[3], nodeId, LengthAxis::None); - if (out.blur < 0) out.blur = 0; - if (out.blur > 96) out.blur = 96; - return out; - } + if (!inset && outer.valid) continue; + ParsedBoxShadow &layer = inset ? out : outer; + layer.valid = true; + layer.inset = inset; + layer.color = color; + if (!lengths.empty()) layer.offsetX = parseLengthForNode(lengths[0], nodeId, LengthAxis::Horizontal); + if (lengths.size() > 1) layer.offsetY = parseLengthForNode(lengths[1], nodeId, LengthAxis::Vertical); + if (lengths.size() > 2) layer.blur = parseLengthForNode(lengths[2], nodeId, LengthAxis::None); + if (lengths.size() > 3) layer.spread = parseLengthForNode(lengths[3], nodeId, LengthAxis::None); + if (layer.blur < 0) layer.blur = 0; + if (layer.blur > 96) layer.blur = 96; + if (inset) return out; + } + if (outer.valid) return outer; out.valid = true; out.inset = false; @@ -5201,8 +5206,8 @@ ParsedBoxShadow parseInsetBoxShadow(const std::string &value, int nodeId) void applyBoxShadowValue(NodeHandle node, const std::string &value, StyleApplicationSource source) { - const ParsedBoxShadow shadow = parseInsetBoxShadow(value, node.id()); - if (!shadow.valid || !shadow.inset || shadow.color.a == 0) { + const ParsedBoxShadow shadow = parseBoxShadow(value, node.id()); + if (!shadow.valid || shadow.color.a == 0) { setStyleValue(node, Property::BoxShadowInset, 0, source); setStyleValue(node, Property::BoxShadowOffsetX, 0, source); setStyleValue(node, Property::BoxShadowOffsetY, 0, source); @@ -5218,7 +5223,7 @@ void applyBoxShadowValue(NodeHandle node, const std::string &value, StyleApplica setStyleValue(node, Property::BoxShadowSpread, shadow.spread, source); setStyleValue(node, Property::BoxShadowColor, cssColorStyleValue(shadow.color), source); setStyleValue(node, Property::BoxShadowAlpha, shadow.color.a, source); - setStyleValue(node, Property::BoxShadowInset, 1, source); + setStyleValue(node, Property::BoxShadowInset, shadow.inset ? 1 : 0, source); } int numericLength(double value) @@ -6471,6 +6476,7 @@ bool compileBoxShadowValue(const std::string &value, CssCompiledValue &compiled) compiled.values[1] = 0; if (text.empty() || lowerText == "none") return true; + // aux: 1 = inset layer, 2 = outer layer (see parseBoxShadow for the choice). for (const auto &rawLayer : splitTopLevel(text, ',')) { const auto tokens = splitFunctionAwareWords(rawLayer); bool inset = false; @@ -6497,26 +6503,16 @@ bool compileBoxShadowValue(const std::string &value, CssCompiledValue &compiled) if (!parseCompiledLengthSpec(token, length)) return false; if (lengths.size() < 4) lengths.push_back(length); } - if (!inset) continue; - compiled.aux = 1; - for (std::size_t i = 0; i < lengths.size(); ++i) compiled.lengths[i] = lengths[i]; + if (!inset && compiled.aux == 2) continue; + compiled.aux = inset ? 1 : 2; + for (std::size_t i = 0; i < 4; ++i) compiled.lengths[i] = i < lengths.size() ? lengths[i] : CssLengthSpec{}; compiled.values[0] = static_cast(cssColorStyleValue(color)); compiled.values[1] = color.a; - return true; + if (inset) return true; } return true; } -bool boxShadowValueHasInsetLayer(const std::string &value) -{ - for (const auto &rawLayer : splitTopLevel(value, ',')) { - for (const auto &token : splitFunctionAwareWords(rawLayer)) { - if (toLowerAscii(trimCssValue(token)) == "inset") return true; - } - } - return false; -} - bool compileBorderWidthBox(const std::string &value, CssCompiledValue &compiled) { const auto parts = splitFunctionAwareWords(value); @@ -7704,12 +7700,7 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa return storeCompiledCssValue(compiled); case CssDeclarationId::BoxShadow: if (!compiledCssValueFeatureEnabled(kCssCompiledFeatureEffects)) return kNoCompiledCssValue; - if (hasDynamicCssValue(value)) { - if (boxShadowValueHasInsetLayer(value)) return kNoCompiledCssValue; - compiled.kind = CssCompiledKind::BoxShadow; - compiled.aux = 0; - return storeCompiledCssValue(compiled); - } + if (hasDynamicCssValue(value)) return kNoCompiledCssValue; if (!compileBoxShadowValue(value, compiled)) return kNoCompiledCssValue; compiled.kind = CssCompiledKind::BoxShadow; return storeCompiledCssValue(compiled); @@ -11080,7 +11071,7 @@ bool applyCompiledBoxShadowValue(NodeHandle node, const CssCompiledValue &compil source); setStyleValue(node, Property::BoxShadowColor, compiled.values[0], source); setStyleValue(node, Property::BoxShadowAlpha, compiled.values[1], source); - setStyleValue(node, Property::BoxShadowInset, 1, source); + setStyleValue(node, Property::BoxShadowInset, compiled.aux == 1 ? 1 : 0, source); return true; } diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index a6c0ee0..be69a12 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2262,11 +2262,10 @@ bool shadowContourRow(const ShadowContour &shape, int y, int &left, int &right) return left <= right; } -void appendShadowRect(const Node &node, uint8_t parentAlpha, int x, int y, int w, int h) +void appendShadowRect(const Node &node, uint8_t parentAlpha, uint8_t alpha, int x, int y, int w, int h) { if (w <= 0 || h <= 0) return; const auto color = (GEA_CSS_BOX_SHADOW ? rstyle(node.computedStyle()).box_shadow_color : 0); - const auto alpha = (GEA_CSS_BOX_SHADOW ? rstyle(node.computedStyle()).box_shadow_alpha : 0); if (!ViewGeometry::hasTransformChain(node, false)) { appendFillRectWithAlpha(x, y, w, h, color, alpha, parentAlpha, x, y, w, h); return; @@ -2308,7 +2307,7 @@ void recordSharpInsetShadow(const Node &node, uint8_t parentAlpha, const ShadowC } struct Run { int left = 0, right = -1, y = 0, height = 0; } runs[2]; auto flush = [&](Run &run) { - if (run.height > 0) appendShadowRect(node, parentAlpha, run.left, run.y, run.right - run.left + 1, run.height); + if (run.height > 0) appendShadowRect(node, parentAlpha, rstyle(node.computedStyle()).box_shadow_alpha, run.left, run.y, run.right - run.left + 1, run.height); run.height = 0; }; for (int y = static_cast(clip.y); y < clip.y + clip.h; ++y) { @@ -2333,6 +2332,265 @@ void recordSharpInsetShadow(const Node &node, uint8_t parentAlpha, const ShadowC for (auto &run : runs) flush(run); } +// Outer shadows start from the border box, not the padding box. +ShadowContour borderBoxShadowContour(const Node &node) +{ + ShadowContour box{float(node.layout.x), float(node.layout.y), float(node.layout.width), float(node.layout.height)}; + int16_t rx8[4], ry8[4]; + resolvedBorderRadii8(node, rx8, ry8); + for (int i = 0; i < 4; ++i) { + box.rx[i] = rx8[i] * 0.125f; + box.ry[i] = ry8[i] * 0.125f; + } + constrainShadowRadii(box); + return box; +} + +// CSS Backgrounds' outset-adjusted radius: a small radius grows by less than +// the spread, so a near-square corner stays near square, unless the radius +// already covers the whole side (a pill or a circle keeps its shape). +float outsetShadowRadius(float radius, float outset, float coverage) +{ + if (outset <= 0) return std::max(0.0f, radius + outset); + if (radius > outset || coverage > 1) return radius + outset; + const float remainder = 1 - radius / outset; + return radius + outset * (1 - remainder * remainder * remainder * (1 - coverage * coverage * coverage)); +} + +ShadowContour outerShadowShape(const ShadowContour &box, int spread, int ox, int oy) +{ + ShadowContour shape = box; + shape.x += ox - spread; + shape.y += oy - spread; + shape.w = std::max(0.0f, box.w + 2 * spread); + shape.h = std::max(0.0f, box.h + 2 * spread); + for (int i = 0; i < 4; ++i) { + const float coverage = box.w > 0 && box.h > 0 ? 2 * std::min(box.rx[i] / box.w, box.ry[i] / box.h) : 0; + shape.rx[i] = outsetShadowRadius(box.rx[i], spread, coverage); + shape.ry[i] = outsetShadowRadius(box.ry[i], spread, coverage); + } + constrainShadowRadii(shape); + return shape; +} + +// The contour `d` px outside (negative: inside) a rounded shape. +ShadowContour offsetShadowContour(const ShadowContour &shape, float d) +{ + ShadowContour out = shape; + out.x -= d; + out.y -= d; + out.w = std::max(0.0f, shape.w + 2 * d); + out.h = std::max(0.0f, shape.h + 2 * d); + for (int i = 0; i < 4; ++i) { + out.rx[i] = std::max(0.0f, shape.rx[i] + d); + out.ry[i] = std::max(0.0f, shape.ry[i] + d); + } + constrainShadowRadii(out); + return out; +} + +// Gaussian coverage `d` px outside a straight shadow edge. CSS makes the blur +// radius twice the standard deviation. +uint8_t outerShadowAlphaAt(float d, int blur, uint8_t baseAlpha) +{ + return static_cast(std::lround(baseAlpha * 0.5f * std::erfc(d * 1.41421356f / blur))); +} + +// Paints `outer` minus `hole` minus the border-box knockout, merging rows with +// equal spans into rectangles so every pixel composites once. +void appendShadowSpans(const Node &node, uint8_t parentAlpha, uint8_t alpha, const ShadowContour &outer, + const ShadowContour *hole, const ShadowContour &box) +{ + if (alpha == 0 || outer.w <= 0 || outer.h <= 0) return; + const int top = static_cast(std::floor(outer.y)); + const int bottom = static_cast(std::ceil(outer.y + outer.h)); + const int bx = static_cast(std::floor(outer.x)); + const int bw = static_cast(std::ceil(outer.x + outer.w)) - bx; + // One alpha scope for the whole contour; transformed quads carry their own. + const bool transformed = ViewGeometry::hasTransformChain(node, false); + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, alpha); + const bool scoped = !transformed && effectiveAlpha != parentAlpha; + if (scoped) appendAlphaCommand(effectiveAlpha, bx, top, bw, bottom - top); + struct Run { int left = 0, right = -1, y = 0, height = 0; } runs[4]; + auto flush = [&](Run &run) { + if (run.height <= 0) return; + const int w = run.right - run.left + 1; + if (transformed) appendShadowRect(node, parentAlpha, alpha, run.left, run.y, w, run.height); + else appendFillRectRaw(run.left, run.y, w, run.height, rstyle(node.computedStyle()).box_shadow_color, run.left, run.y, w, run.height); + run.height = 0; + }; + auto cut = [](int (*spans)[2], int &count, int left, int right) { + int kept[4][2], n = 0; + for (int i = 0; i < count; ++i) { + const int s0 = spans[i][0], s1 = spans[i][1]; + if (right < s0 || left > s1) { + kept[n][0] = s0; kept[n][1] = s1; ++n; + continue; + } + if (s0 < left && n < 4) { kept[n][0] = s0; kept[n][1] = left - 1; ++n; } + if (s1 > right && n < 4) { kept[n][0] = right + 1; kept[n][1] = s1; ++n; } + } + for (int i = 0; i < n; ++i) { spans[i][0] = kept[i][0]; spans[i][1] = kept[i][1]; } + count = n; + }; + for (int y = top; y < bottom; ++y) { + int spans[4][2], count = 0, left = 0, right = -1; + if (shadowContourRow(outer, y, left, right)) { spans[0][0] = left; spans[0][1] = right; count = 1; } + if (count && hole && shadowContourRow(*hole, y, left, right)) cut(spans, count, left, right); + if (count && shadowContourRow(box, y, left, right)) cut(spans, count, left, right); + for (int i = 0; i < 4; ++i) { + auto &run = runs[i]; + if (i >= count) { flush(run); continue; } + if (run.height && run.left == spans[i][0] && run.right == spans[i][1]) { ++run.height; continue; } + flush(run); + run = Run{spans[i][0], spans[i][1], y, 1}; + } + } + for (auto &run : runs) flush(run); + if (scoped) appendAlphaCommand(parentAlpha, bx, top, bw, bottom - top); +} + +// One native rounded fill (lineWidth 0) or ring for a shadow contour. +void appendShadowRoundedRect(const Node &node, uint8_t parentAlpha, uint8_t alpha, const ShadowContour &shape, int lineWidth) +{ + const int x = static_cast(shape.x), y = static_cast(shape.y); + const int w = static_cast(shape.w), h = static_cast(shape.h); + if (alpha == 0 || w <= 0 || h <= 0) return; + int16_t radii[4]; + for (int i = 0; i < 4; ++i) radii[i] = static_cast(std::lround(shape.rx[i])); + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, alpha); + if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, x, y, w, h); + DisplayCommand *cmd = DisplayList::instance().append(); + if (cmd) { + cmd->bx = x; cmd->by = y; cmd->bw = w; cmd->bh = h; + if (lineWidth > 0) { + cmd->type = DisplayCommandType::StrokeRoundedRect; + cmd->strokeRoundedRect = {}; + auto &ring = cmd->strokeRoundedRect; + ring.x = x; ring.y = y; ring.w = w; ring.h = h; + ring.tl = radii[0]; ring.tr = radii[1]; ring.br = radii[2]; ring.bl = radii[3]; + for (int i = 0; i < 4; ++i) ring.rx8[i] = ring.ry8[i] = static_cast(radii[i] * 8); + ring.lineWidth = lineWidth; + ring.color = rstyle(node.computedStyle()).box_shadow_color; + } else { + cmd->type = DisplayCommandType::FillRoundedRect; + auto &fill = cmd->fillRoundedRect; + fill.x = x; fill.y = y; fill.w = w; fill.h = h; + fill.tl = radii[0]; fill.tr = radii[1]; fill.br = radii[2]; fill.bl = radii[3]; + fill.color = rstyle(node.computedStyle()).box_shadow_color; + } + } + if (effectiveAlpha != parentAlpha) appendAlphaCommand(parentAlpha, x, y, w, h); +} + +// True when `inner`'s bounding rectangle lies inside the convex `outer`: its +// four corners do, so an opaque box over `outer` hides all of `inner`. +bool shadowContourInside(const ShadowContour &inner, const ShadowContour &outer) +{ + if (inner.w <= 0 || inner.h <= 0) return true; + for (int i = 0; i < 4; ++i) { + const bool right = i == 1 || i == 2, bottom = i >= 2; + const float px = right ? inner.x + inner.w : inner.x; + const float py = bottom ? inner.y + inner.h : inner.y; + if (px < outer.x || px > outer.x + outer.w || py < outer.y || py > outer.y + outer.h) return false; + const float rx = outer.rx[i], ry = outer.ry[i]; + if (rx <= 0 || ry <= 0) continue; + const float dx = (right ? px - (outer.x + outer.w - rx) : outer.x + rx - px) / rx; + const float dy = (bottom ? py - (outer.y + outer.h - ry) : outer.y + ry - py) / ry; + if (dx > 0 && dy > 0 && dx * dx + dy * dy > 1) return false; + } + return true; +} + +// An opaque border-box background paints over whatever the shadow puts under +// the box, so the knockout can be skipped in favour of native rounded fills. +bool outerShadowKnockoutHidden(const Node &node, uint8_t parentAlpha) +{ + if (!node.computedStyle().has_bg || combineAlpha(parentAlpha, node.computedStyle().bg_alpha) != 255) return false; + if (StyleValues::backgroundClip(node.computedStyle(), rstyle(node.computedStyle()).bg_image_layer_count - 1) != 0) return false; + if (ViewGeometry::hasTransformChain(node, false) || isDocumentCanvasRoot(node)) return false; + const int canvasSource = ViewRenderer::canvasBackgroundSource(); + return canvasSource < 0 || &treeState().nodes[canvasSource] != &node; +} + +// A sharp shadow of a square box over an opaque background: at most four +// rectangles, the parts of the shadow outside the box. The native rounded fill +// would paint the whole shadow rectangle and let the background cover it, which +// refills the card's own area on every replay. +void appendSharpShadowStrips(const Node &node, uint8_t parentAlpha, uint8_t alpha, const ShadowContour &shape, const ShadowContour &box) +{ + const int sx = static_cast(shape.x), sy = static_cast(shape.y); + const int sw = static_cast(shape.w), sh = static_cast(shape.h); + if (alpha == 0 || sw <= 0 || sh <= 0) return; + const int bx = static_cast(box.x), by = static_cast(box.y); + const int sx1 = sx + sw, sy1 = sy + sh; + const int bx1 = bx + static_cast(box.w), by1 = by + static_cast(box.h); + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, alpha); + const bool scoped = effectiveAlpha != parentAlpha; + if (scoped) appendAlphaCommand(effectiveAlpha, sx, sy, sw, sh); + const auto color = rstyle(node.computedStyle()).box_shadow_color; + auto strip = [&](int x0, int y0, int x1, int y1) { + if (x1 > x0 && y1 > y0) appendFillRectRaw(x0, y0, x1 - x0, y1 - y0, color, x0, y0, x1 - x0, y1 - y0); + }; + if (bx1 <= sx || bx >= sx1 || by1 <= sy || by >= sy1) { + strip(sx, sy, sx1, sy1); + } else { + strip(sx, sy, sx1, std::min(sy1, by)); + strip(sx, std::max(sy, by1), sx1, sy1); + const int midY0 = std::max(sy, by), midY1 = std::min(sy1, by1); + strip(sx, midY0, std::min(sx1, bx), midY1); + strip(std::max(sx, bx1), midY0, sx1, midY1); + } + if (scoped) appendAlphaCommand(parentAlpha, sx, sy, sw, sh); +} + +// Outer box-shadow: the spread shape, offset, blurred and painted only outside +// the border box. Blur is a Gaussian approximated by constant-alpha bands +// across [-blur, blur] around the shape edge. The caller checks +// box_shadow_alpha first; a box without a shadow pays one byte load. +void recordOuterBoxShadow(const Node &node, uint8_t parentAlpha) +{ + const auto &r = rstyle(node.computedStyle()); + if (r.box_shadow_inset || r.box_shadow_alpha == 0) return; + if (node.layout.width <= 0 || node.layout.height <= 0) return; + const ShadowContour box = borderBoxShadowContour(node); + const ShadowContour shape = outerShadowShape(box, r.box_shadow_spread, r.box_shadow_offset_x, r.box_shadow_offset_y); + bool native = outerShadowKnockoutHidden(node, parentAlpha); + for (int i = 0; i < 4; ++i) native &= std::fabs(shape.rx[i] - shape.ry[i]) < 0.5f; + const int blur = std::max(0, r.box_shadow_blur_radius); + // Native rounded rects carry int16 geometry. A shadow that reaches past that + // range (boxes are 32-bit) takes the span path, which saturates its rects. + native &= shape.x - blur >= -32768.0f && shape.y - blur >= -32768.0f && + shape.x + shape.w + blur <= 32767.0f && shape.y + shape.h + blur <= 32767.0f; + // Natively painted parts the opaque box fully covers are skipped: a full- + // screen card would otherwise refill its whole core on every dirty rect. + if (blur == 0) { + bool sharp = native; + for (int i = 0; i < 4; ++i) + sharp &= shape.rx[i] < 0.5f && shape.ry[i] < 0.5f && box.rx[i] <= 0.0f && box.ry[i] <= 0.0f; + if (!native) appendShadowSpans(node, parentAlpha, r.box_shadow_alpha, shape, nullptr, box); + else if (sharp) appendSharpShadowStrips(node, parentAlpha, r.box_shadow_alpha, shape, box); + else if (!shadowContourInside(shape, box)) appendShadowRoundedRect(node, parentAlpha, r.box_shadow_alpha, shape, 0); + return; + } + // Span bands cost a command per corner row, so they get fewer, wider bands. + const int band = std::max(1, blur / (native ? 8 : 5)); + const ShadowContour core = offsetShadowContour(shape, static_cast(-blur)); + const uint8_t coreAlpha = outerShadowAlphaAt(static_cast(-blur), blur, r.box_shadow_alpha); + if (!native) appendShadowSpans(node, parentAlpha, coreAlpha, core, nullptr, box); + else if (!shadowContourInside(core, box)) appendShadowRoundedRect(node, parentAlpha, coreAlpha, core, 0); + for (int d = -blur; d < blur; d += band) { + const ShadowContour outer = offsetShadowContour(shape, static_cast(d + band)); + const uint8_t alpha = outerShadowAlphaAt(d + band * 0.5f, blur, r.box_shadow_alpha); + if (native) { + if (!shadowContourInside(outer, box)) appendShadowRoundedRect(node, parentAlpha, alpha, outer, band); + } else { + const ShadowContour inner = offsetShadowContour(shape, static_cast(d)); + appendShadowSpans(node, parentAlpha, alpha, outer, &inner, box); + } + } +} + void recordInsetBoxShadow(const Node &node, uint8_t parentAlpha) { if (!(GEA_CSS_BOX_SHADOW ? rstyle(node.computedStyle()).box_shadow_inset : 0) || (GEA_CSS_BOX_SHADOW ? rstyle(node.computedStyle()).box_shadow_alpha : 0) == 0) return; @@ -2403,6 +2661,12 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_transformed_bounds") ViewRenderer: int rotate = use_prev ? n->render.previous_transform_rotate : rstyle(n->computedStyle()).transform_rotate; auto expandForBlur = [&]() { + // An outer box-shadow paints beyond the box as well. + const int shadow = use_prev ? n->render.previous_box_shadow_extent : boxShadowExtent(n->computedStyle()); + *x0 -= shadow; + *y0 -= shadow; + *x1 += shadow; + *y1 += shadow; const int radius = use_prev ? n->render.previous_filter_blur_radius : (GEA_CSS_FILTERS ? rstyle(n->computedStyle()).filter_blur_radius : 0); if (radius <= 0) return; const int extentX = std::max(1, radius) * 5; @@ -2578,6 +2842,9 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB } } if (w <= 0 || h <= 0) return; +#if GEA_CSS_BOX_SHADOW + if (rstyle(n->computedStyle()).box_shadow_alpha) recordOuterBoxShadow(*n, parentAlpha); +#endif if (n->computedStyle().has_bg) { // The color is the bottom layer; image longhands never replace it. From ffd94424f2c2bad3720d3c181c447f659bf261bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 22:54:08 +0300 Subject: [PATCH 11/50] Paint opaque constant linear gradients as solid fills A gradient whose stops share one opaque colour, such as the common linear-gradient(c, c) image layer, went through the dithered gradient rasterizer. On RGB565 that speckled a colour a plain fill paints flat (#008000 alternated between two greens), so the same colour differed between background-color and background-image. Such gradients now fill directly, which is also cheaper. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/render.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 4354589..0c3362f 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -5911,6 +5911,26 @@ int gLastScrollUiFrame = -1000; if (x1 < x0 || y1 < y0) return; + // An opaque constant gradient, e.g. linear-gradient(green, green), is a + // solid fill: dithering it would speckle a colour a plain fill paints flat. + const auto &g = c.gradient; + if (g.fromColor == g.toColor && g.fromAlpha == 255 && g.toAlpha == 255 && + (!g.hasMid || (g.midColor == g.fromColor && g.midAlpha == 255))) + { + if (g.tl == 0 && g.tr == 0 && g.br == 0 && g.bl == 0) + { + gea::platform::display::Display::fillRect(x0, y0, x1 - x0 + 1, y1 - y0 + 1, g.fromColor); + return; + } + for (int y = y0; y <= y1; y++) + { + int rowX0 = x0, rowX1 = x1; + roundedRowSpan(c, y, &rowX0, &rowX1); + if (rowX0 <= rowX1) gea::platform::display::Display::fillRect(rowX0, y, rowX1 - rowX0 + 1, 1, g.fromColor); + } + return; + } + // Static-background gradient cache: a large opaque gradient (the app backdrop) // is re-rasterized over the moving content's dirty region every frame (~23ms // on css-3d-cube). Render it once into a full-screen buffer, then per frame From 8d7dfa325fbcaa6973a2d334192bc7442202bbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 23:12:36 +0300 Subject: [PATCH 12/50] Support line-clamp, max-lines and continue: collapse line-clamp and its longhands were not parsed. A block container with continue: collapse now walks its content after layout: past its max-lines'th line, or without max-lines past the last content that fits its used height, every box is hidden from painting and hit testing, a text run the clamp point cuts paints only its first lines, and an automatic height ends at the clamp point. Lines inside inline wrappers and plain blocks count; independent formatting contexts are units. A container that stops clamping clears the hidden and line-count state its last clamp left on its descendants (line_clamp_owner marks it). As in CSS Overflow 4, inline boxes, flex and grid containers, and multicol containers never clamp, so columns / column-count / column-width are tracked for that alone. -webkit-line-clamp is the legacy form, which only clamps display: -webkit-box, so it never collapses here. block-ellipsis is parsed but not painted yet. GEA_CSS_LINE_CLAMP (default on) gates the family and joins GEA_CSS_RARE_STYLE. Off, the RareStyle fields and the three LayoutBox fields (line_clamp_hidden, line_clamp_owner, line_clamp_lines) are static constexpr 0, so the render, input and text reads fold away, and the clamp walk and the clamp blocks of layoutNode and repositionChildren are not compiled; clampsLines stays outside the gate and folds to false. An app that does not use it pays a byte test per node: no cross-unit call, no allocation, no per-pixel work. The gate off saves 4 bytes of LayoutBox and Node (host). The analyzer and CLI do not report the family yet. applyLineClamp walks the subtree twice and measures each text node, for clamp containers whose layout is dirty only. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/css_features.h | 9 ++ packages/engine/ui/input.cpp | 4 +- packages/engine/ui/input_render.cpp | 1 + packages/engine/ui/internal.h | 4 +- packages/engine/ui/layout.cpp | 129 ++++++++++++++++++++++ packages/engine/ui/node_lifecycle.cpp | 5 + packages/engine/ui/node_model.h | 21 +++- packages/engine/ui/render.cpp | 12 +- packages/engine/ui/style.cpp | 141 ++++++++++++++++++++++++ packages/engine/ui/style.h | 22 +++- packages/engine/ui/text.cpp | 23 ++-- packages/engine/ui/tree_style.cpp | 15 +++ packages/geatsc-plugin-gea/src/utils.ts | 5 + 13 files changed, 369 insertions(+), 22 deletions(-) diff --git a/packages/engine/ui/css_features.h b/packages/engine/ui/css_features.h index d058ed5..dbe87e6 100644 --- a/packages/engine/ui/css_features.h +++ b/packages/engine/ui/css_features.h @@ -247,6 +247,15 @@ #define GEA_CSS_BACKGROUND_LAYERS 1 #endif +// CSS Overflow 4 line clamping (line-clamp, max-lines, continue, block-ellipsis) +// and CSS Multi-column (column-count, column-width, column-fill, column-span). +#ifndef GEA_CSS_LINE_CLAMP +#define GEA_CSS_LINE_CLAMP 1 +#endif +#ifndef GEA_CSS_MULTICOL +#define GEA_CSS_MULTICOL 1 +#endif + #ifndef GEA_CSS_LINE_HEIGHT_EXPRESSIONS #define GEA_CSS_LINE_HEIGHT_EXPRESSIONS 1 #endif diff --git a/packages/engine/ui/input.cpp b/packages/engine/ui/input.cpp index 93b9581..26c6d1e 100644 --- a/packages/engine/ui/input.cpp +++ b/packages/engine/ui/input.cpp @@ -464,7 +464,7 @@ class InputController { int hitTestNodeId(int id, int x, int y, bool clipped = false, bool groupRoot = true, bool includePositioned = true) { Node &node = Tree::instance().nodes()[id]; - if (node.computedStyle().display == 1 || isCollapsedFlexSubtree(node)) return -1; + if (node.style.display == 1 || node.layout.line_clamp_hidden || isCollapsedFlexSubtree(node)) return -1; // CSS `pointer-events: none` — this node and its subtree are never the // target of a pointer event, so the hit-test falls through to whatever is // painted behind it. Without this, a decorative overlay image positioned @@ -503,7 +503,7 @@ class InputController { int findScrollNodeId(int id, int x, int y, bool clipped = false, bool groupRoot = true, bool includePositioned = true) { Node &node = Tree::instance().nodes()[id]; - if (node.computedStyle().display == 1 || isCollapsedFlexSubtree(node)) return -1; + if (node.style.display == 1 || node.layout.line_clamp_hidden || isCollapsedFlexSubtree(node)) return -1; const bool inside = pointInsideNodeHitArea(node, x, y); if (LayoutEngine::isViewportFixed(node)) clipped = false; diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index e0d6e1f..4743535 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -170,6 +170,7 @@ void InputRenderer::record(int id) cmd->text.maxHeight = 0; cmd->text.firstLineIndent = 0; cmd->text.alignLast = -1; + cmd->text.lineLimit = 0; } } diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 2db038e..0aff423 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -207,6 +207,8 @@ void rootScrollImageHoldTick(); // align lines a forced break ends, and bit 2 marks a run whose final // line also ends its paragraph. int8_t alignLast; + // line-clamp: paint at most this many lines (0 = all). + int16_t lineLimit; } text; struct { @@ -577,7 +579,7 @@ void rootScrollImageHoldTick(); int width, uint8_t *outCoverage); static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform = 0, int lineHeight = 0, int whiteSpace = 0, int textOverflow = 0, int maxHeight = 0, - int firstLineIndent = 0, int alignLast = -1); + int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0); // Single-line width measure for places that don't go through layout() // — used by InputRenderer to position the caret at the end of the value. static int measureWidth(const char *text, int fontId, int fontSize, int textTransform = 0); diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index f474280..5155cbd 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -380,6 +380,116 @@ bool hasExplicitHeight(const Node &node) !layoutSizeExpressionUsesPercentage(static_cast(&node - Tree::instance().nodes()), node.computedStyle().height_expression); } +// Line clamping (CSS Overflow 4). A block container with continue: collapse +// hides everything after its clamp point, which follows its max-lines'th line +// or, without max-lines, the last content that fits its used height. Lines of +// inline wrappers and plain block descendants count; independent formatting +// contexts are single units. Child coordinates are still parent-relative. +int writingMode(const Node &node); + +struct LineClampWalk { + Node *nodes; + int maxLines; + int limit; + int lines = 0; + int lastLineTop = INT_MIN; + int keptBottom = 0; + bool clamped = false; +}; + +#if GEA_CSS_LINE_CLAMP +void hideAfterLineClamp(Node *nodes, int id) +{ + nodes[id].layout.line_clamp_hidden = 1; + for (int c = nodes[id].first_child; c >= 0; c = nodes[c].next_sibling) hideAfterLineClamp(nodes, c); +} +#endif + +// line-clamp applies to block containers, never to inline boxes, flex or +// grid containers, or multicol containers. Folds to false without the family. +bool clampsLines(const Node &node) +{ + const int flags = rstyle(node.computedStyle()).line_clamp_flags; + return GEA_CSS_LINE_CLAMP && (flags & 1) && !(flags & 12) && node.type == NodeType::View && node.computedStyle().display == kDisplayBlock && + !(LayoutEngine::isCssInlineLevelBox(node) && LayoutEngine::isInlineLevelNode(node)); +} + +#if GEA_CSS_LINE_CLAMP +// A nested clamp container keeps the state inside it for its own layout. +void clearLineClamp(Node *nodes, int id) +{ + for (int c = nodes[id].first_child; c >= 0; c = nodes[c].next_sibling) { + nodes[c].layout.line_clamp_hidden = 0; + nodes[c].layout.line_clamp_lines = 0; + if (!clampsLines(nodes[c])) clearLineClamp(nodes, c); + } +} + +void walkLineClamp(LineClampWalk &walk, int parent, int originY) +{ + Node *nodes = walk.nodes; + for (int id = nodes[parent].first_child; id >= 0; id = nodes[id].next_sibling) { + Node &child = nodes[id]; + if (walk.clamped) { + hideAfterLineClamp(nodes, id); + continue; + } + if (isDisplayNone(child.computedStyle()) || isOutOfFlowPosition(child.computedStyle().position) || child.computedStyle().float_side || + isLineBreak(child) || suppressAnonymousWhitespace(nodes, parent, id)) continue; + const int top = originY + child.layout.y; + if (child.type == NodeType::Text) { + const int advance = TextRenderer::measureHeight("X", child.computedStyle().font_id, child.computedStyle().font_size, 0, child.computedStyle().line_height); + const int inner = child.layout.height - boxInsets(child.computedStyle(), false); + const int count = advance > 0 ? std::max(1, (inner + advance - 1) / advance) : 1; + for (int line = 0; line < count; ++line) { + const int lineTop = top + boxInset(child.computedStyle(), 0) + line * advance; + const bool newLine = lineTop > walk.lastLineTop; + if (walk.maxLines ? newLine && walk.lines >= walk.maxLines : lineTop + advance > walk.limit) { + walk.clamped = true; + if (line == 0) hideAfterLineClamp(nodes, id); + else child.layout.line_clamp_lines = static_cast(line); + break; + } + if (newLine) { + ++walk.lines; + walk.lastLineTop = lineTop; + } + walk.keptBottom = std::max(walk.keptBottom, lineTop + advance); + } + continue; + } + const bool inlineWrapper = LayoutEngine::isCssInlineLevelBox(child) && LayoutEngine::isInlineLevelNode(child); + const bool plainBlock = child.style.display == kDisplayBlock && !overflowEstablishesContext(child.style) && + writingMode(child) == writingMode(nodes[parent]); + if (child.first_child >= 0 && (inlineWrapper || plainBlock)) { + walkLineClamp(walk, id, top); + if (!walk.clamped && !inlineWrapper) walk.keptBottom = std::max(walk.keptBottom, top + child.layout.height); + continue; + } + const int bottom = top + child.layout.height; + if (walk.maxLines ? walk.lines >= walk.maxLines : bottom > walk.limit) { + walk.clamped = true; + hideAfterLineClamp(nodes, id); + continue; + } + walk.keptBottom = std::max(walk.keptBottom, bottom); + } +} + +void applyLineClamp(int id) +{ + Node *nodes = Tree::instance().nodes(); + Node &node = nodes[id]; + clearLineClamp(nodes, id); + LineClampWalk walk{nodes, rstyle(node.computedStyle()).max_lines, node.layout.height - boxInset(node.computedStyle(), 2)}; + walk.keptBottom = boxInset(node.computedStyle(), 0); + walkLineClamp(walk, id, 0); + if (!walk.clamped || hasExplicitHeight(node)) return; + const int height = clampBorderBoxSize(node.computedStyle(), walk.keptBottom + boxInset(node.computedStyle(), 2), false); + if (height < node.layout.height) node.layout.height = clampInt16(height); +} +#endif + int resolvedStyleWidth(const Node &node, int basis) { if (const auto *scope = flexBasisScope(node, true)) return scope->value == kUnset ? basis : scope->value; @@ -4401,6 +4511,16 @@ void LayoutEngine::layoutNode(int id, int avail_w, int avail_h, bool intrinsicBo applyPreferredRatio(node, avail_w, avail_h); if (node.first_child >= 0 && (node.layout.width != beforeRatioWidth || node.layout.height != beforeRatioHeight)) LayoutNodePass(*this, id, node.layout.width, node.layout.height).repositionChildren(); +#if GEA_CSS_LINE_CLAMP + if (clampsLines(node)) { + applyLineClamp(id); + node.layout.line_clamp_owner = 1; + } else if (node.layout.line_clamp_owner) { + // The container stopped clamping: lift the state its last clamp left. + clearLineClamp(Tree::instance().nodes(), id); + node.layout.line_clamp_owner = 0; + } +#endif if (node.parent < 0) { // The initial containing block places the root; there is no parent // layout pass to position its margin box or apply float alignment. @@ -4431,6 +4551,15 @@ void LayoutEngine::repositionChildren(int id) GEA_REFRESH_PERF(refreshPerfStatsMutable().treeLayoutRepositionCalls++); Node *node = &Tree::instance().nodes()[id]; LayoutNodePass(*this, id, node->layout.width, node->layout.height).repositionChildren(); +#if GEA_CSS_LINE_CLAMP + if (clampsLines(*node)) { + applyLineClamp(id); + node->layout.line_clamp_owner = 1; + } else if (node->layout.line_clamp_owner) { + clearLineClamp(Tree::instance().nodes(), id); + node->layout.line_clamp_owner = 0; + } +#endif } bool LayoutEngine::layoutNodeScoped(int scope, int treeRoot) diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index e72358a..2187421 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -376,6 +376,11 @@ void NodeLifecycle::init(Node *n, NodeType type) n->layout.width = 0; n->layout.height = 0; n->layout.inline_indent = 0; +#if GEA_CSS_LINE_CLAMP + n->layout.line_clamp_hidden = 0; + n->layout.line_clamp_owner = 0; + n->layout.line_clamp_lines = 0; +#endif #if !GEA_EMBEDDED_SHARED_STYLES n->layout.memo_avail_w = n->layout.memo_avail_h = 0; n->layout.memo_pass = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index d779099..2c091a9 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -478,6 +478,12 @@ struct RareStyle { static constexpr uint8_t bg_grid_line_x = 0; static constexpr uint8_t bg_grid_line_y = 0; #endif + // CSS Overflow 4 line clamping: max-lines (0 = none, capped at 255) and + // flags: 1 = continue: collapse, 2 = block-ellipsis other than none, and + // 4 / 8 = column-count / column-width set (a multicol container never + // clamps). Both sit in trailing padding, so RareStyle keeps its size. + uint8_t max_lines = 0; + uint8_t line_clamp_flags = 0; }; // Individual translation is applied outside the transform list. Its translation @@ -493,7 +499,7 @@ inline int composedTranslateZ(const RareStyle &s) { return int(s.transform_trans inline int composedTranslateXPercent(const RareStyle &s) { return int(s.transform_translate_x_percent) + s.translate_x_percent; } inline int composedTranslateYPercent(const RareStyle &s) { return int(s.transform_translate_y_percent) + s.translate_y_percent; } -#define GEA_CSS_RARE_STYLE (GEA_CSS_ASPECT_RATIO || GEA_CSS_BACKGROUND_LAYERS || GEA_CSS_BORDER_RELIEF || GEA_CSS_BOX_EXPRESSIONS || GEA_CSS_BOX_SHADOW || GEA_CSS_CONTAINMENT || GEA_CSS_FILTERS || GEA_CSS_FLEX_BASIS_EXPRESSIONS || GEA_CSS_FLEX_LINE_COUNT || GEA_CSS_GRID || GEA_CSS_JUSTIFY_SELF || GEA_CSS_LINE_HEIGHT_EXPRESSIONS || GEA_CSS_MARGIN_TRIM || GEA_CSS_SIDE_BORDERS || GEA_CSS_TRANSFORMS || GEA_EMBEDDED_RENDERER_LINEAR_GRADIENTS || GEA_EMBEDDED_RENDERER_RADIAL_GRADIENTS) +#define GEA_CSS_RARE_STYLE (GEA_CSS_ASPECT_RATIO || GEA_CSS_BACKGROUND_LAYERS || GEA_CSS_BORDER_RELIEF || GEA_CSS_BOX_EXPRESSIONS || GEA_CSS_BOX_SHADOW || GEA_CSS_CONTAINMENT || GEA_CSS_FILTERS || GEA_CSS_FLEX_BASIS_EXPRESSIONS || GEA_CSS_FLEX_LINE_COUNT || GEA_CSS_GRID || GEA_CSS_JUSTIFY_SELF || GEA_CSS_LINE_CLAMP || GEA_CSS_LINE_HEIGHT_EXPRESSIONS || GEA_CSS_MARGIN_TRIM || GEA_CSS_MULTICOL || GEA_CSS_SIDE_BORDERS || GEA_CSS_TRANSFORMS || GEA_EMBEDDED_RENDERER_LINEAR_GRADIENTS || GEA_EMBEDDED_RENDERER_RADIAL_GRADIENTS) static_assert(GEA_CSS_RARE_STYLE || std::is_empty::value, "An unguarded rare field requires a reachability family"); struct ComputedStyle { @@ -1393,9 +1399,22 @@ struct LayoutBox { #if !GEA_EMBEDDED_SHARED_STYLES int16_t static_block_start = 0; #endif + // line-clamp: 1 when the box lies after an ancestor's clamp point, which + // hides it. Like line_clamp_lines below it sits in padding. + uint8_t line_clamp_hidden = 0; + // 1 on a container whose last layout applied a line clamp, so a layout that + // no longer clamps clears the state left on its descendants. Sits in padding. + uint8_t line_clamp_owner = 0; int16_t previous_x, previous_y; int16_t previous_width, previous_height; +#if GEA_CSS_LINE_CLAMP + // line-clamp: a text run its container's clamp point cuts paints only + // this many lines. 0 paints them all. + int16_t line_clamp_lines = 0; +#else + static constexpr int16_t line_clamp_lines = 0; +#endif // Scroll geometry must be 32-bit: a scrolls over a virtual // content height of itemCount * rowHeight (e.g. 5000 * 259 ≈ 1.29M px), diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 0c3362f..f089bdc 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6855,7 +6855,7 @@ int gLastScrollUiFrame = -1000; c.text.maxWidth, c.text.color, c.text.scale, c.text.align, c.text.containerWidth, c.text.fontId, c.text.textTransform, c.text.lineHeight, c.text.whiteSpace, c.text.textOverflow, c.text.maxHeight, c.text.firstLineIndent, - c.text.alignLast); + c.text.alignLast, c.text.lineLimit); break; case DisplayCommandType::BlitImage: #if GEA_PIXEL_STORAGE_PACKED @@ -7141,7 +7141,7 @@ int gLastScrollUiFrame = -1000; !n->text.empty() && n->computedStyle().text_alpha == 255 && n->layout.height >= std::max(16, static_cast(n->computedStyle().font_size)) && n->layout.height >= n->computedStyle().line_height; - if (n->computedStyle().display == 1 || isCollapsedFlexSubtree(*n) || ViewRenderer::backfaceSubtreeHidden(*n)) + if (n->style.display == 1 || n->layout.line_clamp_hidden || isCollapsedFlexSubtree(*n) || ViewRenderer::backfaceSubtreeHidden(*n)) { state.clearNodeRange(id); return; @@ -12949,10 +12949,10 @@ int gLastScrollUiFrame = -1000; // an empty range, which `old_len == 0 && tmp_len > 0` below (correctly) refuses // to splice — and the refusal cost the whole frame its retained display list. const bool outsideRecordClip = !isDocumentCanvasRoot(*n) && nodeOutsideRecordClip(node, tree.mountedWidth(), tree.mountedHeight()); - if (!outsideRecordClip && n->computedStyle().visibility == 0 && !isCollapsedFlexSubtree(*n) && !ViewRenderer::backfaceSubtreeHidden(*n) && - n->computedStyle().display != 1 && - n->computedStyle().opacity != 0 && - !(n->computedStyle().blink_interval_ms > 0 && !n->computedStyle().blink_visible) && + if (!outsideRecordClip && n->style.visibility == 0 && !n->layout.line_clamp_hidden && !isCollapsedFlexSubtree(*n) && !ViewRenderer::backfaceSubtreeHidden(*n) && + n->style.display != 1 && + n->style.opacity != 0 && + !(n->style.blink_interval_ms > 0 && !n->style.blink_visible) && !isNativeTextInputView(*n)) { ViewRenderer::recordBox(*n); diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index ea9faa6..586af86 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -403,6 +403,8 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) a.float_side != b.float_side || a.clear_side != b.clear_side || ar.margin_trim != br.margin_trim || + ar.max_lines != br.max_lines || + ar.line_clamp_flags != br.line_clamp_flags || a.writing_mode != b.writing_mode || a.direction != b.direction || a.row_gap != b.row_gap || @@ -629,6 +631,8 @@ bool styleEqualExceptLocalDisplayCommands(const ComputedStyle &a, const Computed a.float_side != b.float_side || a.clear_side != b.clear_side || ar.margin_trim != br.margin_trim || + ar.max_lines != br.max_lines || + ar.line_clamp_flags != br.line_clamp_flags || a.writing_mode != b.writing_mode || a.direction != b.direction || a.row_gap != b.row_gap || @@ -1199,6 +1203,14 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "float") == 0) return CssDeclarationId::Float; if (std::strcmp(property, "aspect-ratio") == 0) return CssDeclarationId::AspectRatio; if (std::strcmp(property, "margin-trim") == 0) return CssDeclarationId::MarginTrim; + if (std::strcmp(property, "line-clamp") == 0) return CssDeclarationId::LineClamp; + if (std::strcmp(property, "-webkit-line-clamp") == 0) return CssDeclarationId::WebkitLineClamp; + if (std::strcmp(property, "max-lines") == 0) return CssDeclarationId::MaxLines; + if (std::strcmp(property, "continue") == 0) return CssDeclarationId::Continue; + if (std::strcmp(property, "block-ellipsis") == 0) return CssDeclarationId::BlockEllipsis; + if (std::strcmp(property, "columns") == 0) return CssDeclarationId::Columns; + if (std::strcmp(property, "column-count") == 0) return CssDeclarationId::ColumnCount; + if (std::strcmp(property, "column-width") == 0) return CssDeclarationId::ColumnWidth; if (std::strcmp(property, "clear") == 0) return CssDeclarationId::Clear; if (std::strcmp(property, "direction") == 0) return CssDeclarationId::Direction; if (std::strcmp(property, "writing-mode") == 0) return CssDeclarationId::WritingMode; @@ -6917,6 +6929,89 @@ ParsedCssColor backgroundBaseColor(const std::string &value) const CssCompiledBackground *compiledCssBackgroundForHandle(std::uint16_t handle); bool backgroundImageIsValid(int handle, int nodeId); +// line-clamp (CSS Overflow 4) and its longhands as MaxLines, LineClampContinue +// and BlockEllipsis writes. A custom ellipsis string counts as an ellipsis. +// Returns the write count, 0 for an invalid value, -1 for other declarations. +int lineClampWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[3]) +{ + const bool shorthand = declaration == CssDeclarationId::LineClamp || declaration == CssDeclarationId::WebkitLineClamp; + if (!shorthand && declaration != CssDeclarationId::MaxLines && declaration != CssDeclarationId::Continue && + declaration != CssDeclarationId::BlockEllipsis) return -1; + std::string lower = toLowerAscii(trimCssValue(value)); + bool quoted = false; + const auto quote = lower.find_first_of("\"'"); + if (quote != std::string::npos) { + const auto close = lower.find(lower[quote], quote + 1); + if (close == std::string::npos) return 0; + lower.erase(quote, close - quote + 1); + quoted = true; + } + auto lineCount = [](const std::string &word, int &n) { + if (word.empty() || word.size() > 6 || word.find_first_not_of("0123456789") != std::string::npos) return false; + n = std::min(255, std::atoi(word.c_str())); + return n >= 1; + }; + const auto words = splitWords(lower); + if (declaration == CssDeclarationId::MaxLines) { + int lines = 0; + if (quoted || words.size() != 1 || (words[0] != "none" && !lineCount(words[0], lines))) return 0; + out[0] = {Property::MaxLines, lines}; + return 1; + } + if (declaration == CssDeclarationId::Continue) { + if (quoted || words.size() != 1 || (words[0] != "auto" && words[0] != "collapse" && words[0] != "discard")) return 0; + out[0] = {Property::LineClampContinue, words[0] != "auto"}; + return 1; + } + if (declaration == CssDeclarationId::BlockEllipsis) { + const bool keyword = !quoted && words.size() == 1 && (words[0] == "none" || words[0] == "auto"); + if (!keyword && !(quoted && words.empty())) return 0; + out[0] = {Property::BlockEllipsis, quoted || words[0] == "auto"}; + return 1; + } + int lines = 0; + bool automatic = false, ellipsis = true; + if (!quoted && words.size() == 1 && words[0] == "none") { + out[0] = {Property::MaxLines, 0}; + out[1] = {Property::LineClampContinue, 0}; + out[2] = {Property::BlockEllipsis, 0}; + return 3; + } + for (const auto &word : words) { + if (lineCount(word, lines)) continue; + if (word == "auto" && declaration == CssDeclarationId::LineClamp) automatic = true; + else if (word == "no-ellipsis" && declaration == CssDeclarationId::LineClamp) ellipsis = false; + else if (word != "-webkit-legacy") return 0; + } + if ((lines > 0) == automatic || (!ellipsis && quoted)) return 0; + // -webkit-line-clamp is the legacy form, which clamps only display: + // -webkit-box. Gea has no such display, so it never collapses here. + out[0] = {Property::MaxLines, lines}; + out[1] = {Property::LineClampContinue, declaration == CssDeclarationId::LineClamp}; + out[2] = {Property::BlockEllipsis, ellipsis}; + return 3; +} + +// Only whether columns are set matters: line-clamp skips multicol containers. +int multicolWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[2]) +{ + const bool shorthand = declaration == CssDeclarationId::Columns; + if (!shorthand && declaration != CssDeclarationId::ColumnCount && declaration != CssDeclarationId::ColumnWidth) return -1; + const auto words = splitWords(toLowerAscii(trimCssValue(value))); + if (words.empty() || words.size() > (shorthand ? 2u : 1u)) return 0; + bool count = false, width = false; + for (const auto &word : words) { + if (word == "auto") continue; + if (word.find_first_not_of("0123456789") == std::string::npos && word != "0" && declaration != CssDeclarationId::ColumnWidth) count = true; + else if (CssLengthSpec length; declaration != CssDeclarationId::ColumnCount && parseCompiledLengthSpec(word, length)) width = true; + else return 0; + } + int n = 0; + if (declaration != CssDeclarationId::ColumnWidth) out[n++] = {Property::ColumnCountSet, count}; + if (declaration != CssDeclarationId::ColumnCount) out[n++] = {Property::ColumnWidthSet, width}; + return n; +} + int marginTrimValue(const std::string &raw) { const auto value = toLowerAscii(trimCssValue(raw)); @@ -7408,6 +7503,20 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa return storeCompiledCssValue(compiled); } + StaticStylePropertyValue clampWrites[3]; + int clampCount = hasVar ? -1 : lineClampWrites(declaration, value, clampWrites); + if (clampCount < 0 && !hasVar) clampCount = multicolWrites(declaration, value, clampWrites); + if (const int count = clampCount; count >= 0) { + if (count == 0) return kNoCompiledCssValue; + compiled.kind = CssCompiledKind::DirectPropertyGroup; + compiled.values[0] = count; + for (int i = 0; i < count; ++i) { + compiled.values[1 + i * 2] = static_cast(clampWrites[i].property); + compiled.values[2 + i * 2] = clampWrites[i].value; + } + return storeCompiledCssValue(compiled); + } + if (declaration == CssDeclarationId::MarginTrim && !hasVar) { const int flags = marginTrimValue(value); if (flags < 0) return kNoCompiledCssValue; @@ -7860,6 +7969,16 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #if GEA_CSS_MARGIN_TRIM case Property::MarginTrim: rstyleMut(style).margin_trim = value; return true; #endif + case Property::MaxLines: rstyleMut(style).max_lines = static_cast(value); return true; + case Property::LineClampContinue: + case Property::BlockEllipsis: + case Property::ColumnCountSet: + case Property::ColumnWidthSet: { + const int bit = lineClampFlagBit(property); + auto &flags = rstyleMut(style).line_clamp_flags; + flags = static_cast(value ? flags | bit : flags & ~bit); + return true; + } #if GEA_CSS_FLOATS case Property::Clear: style.clear_side = value; return true; #endif @@ -9704,6 +9823,20 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl if (value == "right") setStyleValue(node, Property::Float, 2, source); return true; #endif + case CssDeclarationId::LineClamp: + case CssDeclarationId::WebkitLineClamp: + case CssDeclarationId::MaxLines: + case CssDeclarationId::Continue: + case CssDeclarationId::BlockEllipsis: + case CssDeclarationId::Columns: + case CssDeclarationId::ColumnCount: + case CssDeclarationId::ColumnWidth: { + StaticStylePropertyValue writes[3]; + int count = lineClampWrites(declaration, value, writes); + if (count < 0) count = multicolWrites(declaration, value, writes); + for (int i = 0; i < count; ++i) setStyleValue(node, writes[i].property, writes[i].value, source); + return true; + } case CssDeclarationId::MarginTrim: { const int parent = Tree::instance().node(nodeId).parent; const int flags = toLowerAscii(trimCssValue(value)) == "inherit" @@ -11699,6 +11832,14 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "box-sizing") return removeInlineStyleProperties(id, {Property::BoxSizing}); if (property == "float") return removeInlineStyleProperties(id, {Property::Float}); if (property == "margin-trim") return removeInlineStyleProperties(id, {Property::MarginTrim}); + if (property == "line-clamp" || property == "-webkit-line-clamp") + return removeInlineStyleProperties(id, {Property::MaxLines, Property::LineClampContinue, Property::BlockEllipsis}); + if (property == "max-lines") return removeInlineStyleProperties(id, {Property::MaxLines}); + if (property == "continue") return removeInlineStyleProperties(id, {Property::LineClampContinue}); + if (property == "block-ellipsis") return removeInlineStyleProperties(id, {Property::BlockEllipsis}); + if (property == "columns") return removeInlineStyleProperties(id, {Property::ColumnCountSet, Property::ColumnWidthSet}); + if (property == "column-count") return removeInlineStyleProperties(id, {Property::ColumnCountSet}); + if (property == "column-width") return removeInlineStyleProperties(id, {Property::ColumnWidthSet}); if (property == "clear") return removeInlineStyleProperties(id, {Property::Clear}); if (property == "direction") return removeInlineStyleProperties(id, {Property::Direction}); if (property == "writing-mode") return removeInlineStyleProperties(id, {Property::WritingMode}); diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index fa7b4a4..b008c11 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -213,9 +213,21 @@ enum class Property : int { BorderLeftRelief, BorderRelief, TextAlignLast, + MaxLines, + LineClampContinue, + BlockEllipsis, + ColumnCountSet, + ColumnWidthSet, Count }; +// Property -> bit in RareStyle::line_clamp_flags. +inline int lineClampFlagBit(Property property) +{ + return property == Property::LineClampContinue ? 1 : property == Property::BlockEllipsis ? 2 : + property == Property::ColumnCountSet ? 4 : 8; +} + enum class StyleDeclaration : std::uint8_t { Unknown, Ignored, @@ -348,7 +360,15 @@ enum class StyleDeclaration : std::uint8_t { MarginTrim, Transition, AnimationPlayState, - TextAlignLast + TextAlignLast, + LineClamp, + WebkitLineClamp, + MaxLines, + Continue, + BlockEllipsis, + Columns, + ColumnCount, + ColumnWidth }; class Style { diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index ea3f0e6..a0b7489 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1091,7 +1091,7 @@ class TextDrawer { command.text.color, command.text.scale, command.text.align, command.text.containerWidth, command.text.fontId, command.text.textTransform, command.text.lineHeight, command.text.whiteSpace, command.text.textOverflow, command.text.maxHeight, - command.text.firstLineIndent, command.text.alignLast, &sink); + command.text.firstLineIndent, command.text.alignLast, command.text.lineLimit, &sink); } #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS @@ -1145,7 +1145,7 @@ class TextDrawer { } } - static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, TextCoverageSink *coverageSink = nullptr) + static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, TextCoverageSink *coverageSink = nullptr) { if (!text || !text[0]) return; std::string transformed; @@ -1182,7 +1182,7 @@ class TextDrawer { drawRasterizedSingleLine(text, x, y, maxWidth, color, singleLineAlign, containerWidth, fontId, fontSize, lineHeight, ellipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); else drawRasterizedWrapped(text, x, y, maxWidth, color, textAlign, containerWidth, fontId, fontSize, lineHeight, clipX0, clipY0, clipX1, clipY1, - ellipsis ? maxHeight : 0, firstLineIndent, alignLast, hangSpaces, coverageSink); + ellipsis ? maxHeight : 0, firstLineIndent, alignLast, hangSpaces, lineLimit, coverageSink); return; } } @@ -1192,12 +1192,12 @@ class TextDrawer { if (noWrap) drawBitmapSingleLine(text, x, y, maxWidth, color, scale, singleLineAlign, containerWidth, lineHeight, ellipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); else - drawBitmapWrapped(text, x, y, maxWidth, color, scale, textAlign, containerWidth, lineHeight, clipX0, clipY0, clipX1, clipY1, firstLineIndent, alignLast, hangSpaces, coverageSink); + drawBitmapWrapped(text, x, y, maxWidth, color, scale, textAlign, containerWidth, lineHeight, clipX0, clipY0, clipX1, clipY1, firstLineIndent, alignLast, hangSpaces, lineLimit, coverageSink); } private: #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS - static void drawRasterizedWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, int textAlign, int containerWidth, int fontId, int fontSize, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int ellipsisMaxHeight = 0, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, const TextCoverageSink *coverageSink = nullptr) + static void drawRasterizedWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, int textAlign, int containerWidth, int fontId, int fontSize, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int ellipsisMaxHeight = 0, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, int lineLimit = 0, const TextCoverageSink *coverageSink = nullptr) { // Inline continuation: the run's first line starts `firstLineIndent` px in // from the box's left edge (the pen position it inherited from the box @@ -1272,7 +1272,7 @@ class TextDrawer { int penY = y; const char *lineStart = text; for (int li = 0; li < entry.lineCount; li++) { - if (penY + lineBoxOffset > clipY1) break; + if (penY + lineBoxOffset > clipY1 || (lineLimit > 0 && li >= lineLimit)) break; if (li == lineBudget - 1 && li < entry.lineCount - 1) { // Last budgeted line with more text behind it: ellipsize the rest. drawRasterizedSingleLine(lineStart, lineOriginX_(li), penY, lineBudget_(li), color, textAlign, containerWidth, @@ -1307,7 +1307,7 @@ class TextDrawer { int penY = y; int li = 0; while (*lineStart) { - if (penY + lineBoxOffset > clipY1) break; + if (penY + lineBoxOffset > clipY1 || (lineLimit > 0 && li >= lineLimit)) break; if (li == lineBudget - 1) { // Budget reached: ellipsize whatever remains onto this final line. drawRasterizedSingleLine(lineStart, lineOriginX_(li), penY, lineBudget_(li), color, textAlign, containerWidth, @@ -1433,7 +1433,7 @@ class TextDrawer { } #endif - static void drawBitmapWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, float scale, int textAlign, int containerWidth, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, const TextCoverageSink *coverageSink = nullptr) + static void drawBitmapWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, float scale, int textAlign, int containerWidth, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, int lineLimit = 0, const TextCoverageSink *coverageSink = nullptr) { if (scale < 0.1f) scale = 1.0f; int glyphWidth = static_cast(kBitmapFontWidth * scale + 0.5f); @@ -1447,7 +1447,7 @@ class TextDrawer { int lineIndex = 0; while (*lineStart) { - if (penY > clipY1) break; + if (penY > clipY1 || (lineLimit > 0 && lineIndex >= lineLimit)) break; // See drawRasterizedWrapped: line 0 of an inline continuation starts at // the inherited pen and has that much less room. const int budget = lineIndex == 0 ? maxWidth - firstLineIndent : maxWidth; @@ -1966,9 +1966,9 @@ int TextRenderer::reflowToBoxWidth(int id) return measured.width; } -void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent, int alignLast) +void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent, int alignLast, int lineLimit) { - TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent, alignLast); + TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent, alignLast, lineLimit); } void TextRenderer::unionCoverageRow(const DisplayCommand &command, int screenY, int screenX, @@ -2370,6 +2370,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.textOverflow = n->computedStyle().text_overflow; cmd->text.firstLineIndent = static_cast(inlineIndent); cmd->text.alignLast = alignLast; + cmd->text.lineLimit = n->layout.line_clamp_lines; { const int contentH = h - boxInset(n->computedStyle(), 0) - boxInset(n->computedStyle(), 2); cmd->text.maxHeight = static_cast(contentH > 0 ? (contentH > 32767 ? 32767 : contentH) : 0); diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index c7a4b1b..b2d0391 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -87,6 +87,11 @@ bool isLayoutProperty(Property prop) case Property::Float: #endif case Property::MarginTrim: + case Property::MaxLines: + case Property::LineClampContinue: + case Property::BlockEllipsis: + case Property::ColumnCountSet: + case Property::ColumnWidthSet: #if GEA_CSS_FLOATS case Property::Clear: #endif @@ -343,6 +348,16 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn #if GEA_CSS_MARGIN_TRIM case Property::MarginTrim: if ((GEA_CSS_MARGIN_TRIM ? rstyle(style).margin_trim : 0) != value) { rstyleMut(style).margin_trim = value; changed = 1; } break; #endif + case Property::MaxLines: if (rstyle(n->style).max_lines != value) { rstyleMut(n->style).max_lines = value; changed = 1; } break; + case Property::LineClampContinue: + case Property::BlockEllipsis: + case Property::ColumnCountSet: + case Property::ColumnWidthSet: { + const int bit = lineClampFlagBit(prop); + const int next = value ? rstyle(style).line_clamp_flags | bit : rstyle(style).line_clamp_flags & ~bit; + if (rstyle(style).line_clamp_flags != next) { rstyleMut(style).line_clamp_flags = next; changed = 1; } + break; + } #if GEA_CSS_FLOATS case Property::Clear: if (style.clear_side != value) { style.clear_side = value; changed = 1; } break; #endif diff --git a/packages/geatsc-plugin-gea/src/utils.ts b/packages/geatsc-plugin-gea/src/utils.ts index 7ba88d0..4f2ac8c 100644 --- a/packages/geatsc-plugin-gea/src/utils.ts +++ b/packages/geatsc-plugin-gea/src/utils.ts @@ -154,6 +154,11 @@ const STYLE_DECLARATION_ENUM_BY_PROPERTY = new Map([ ['float', 'Float'], ['clear', 'Clear'], ['margin-trim', 'MarginTrim'], + ['line-clamp', 'LineClamp'], + ['-webkit-line-clamp', 'WebkitLineClamp'], + ['max-lines', 'MaxLines'], + ['continue', 'Continue'], + ['block-ellipsis', 'BlockEllipsis'], ['writing-mode', 'WritingMode'], ['direction', 'Direction'], ['flex-flow', 'FlexFlow'], From ec78181a8798a8770ecd734407610e2f710a294c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 23:23:13 +0300 Subject: [PATCH 13/50] Support vertical-align keywords in inline formatting vertical-align was not parsed, so every inline item sat on the baseline. One-line inline items now shift for middle, text-top, text-bottom, sub and super, and top/bottom items align to the line box edges. As in CSS, non-replaced inline boxes align by their line-height box, which differs from Gea's layout box when the line-height is below the font size, and a line holding aligned items is sized from the block's strut and every item's reach, negative ones included. The strut is only applied to lines with aligned items for now: applying it everywhere is correct CSS but would lengthen lines of small inline text that current layouts rely on. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/internal.h | 4 + packages/engine/ui/layout.cpp | 99 ++++++++++++++++++++++++- packages/engine/ui/node_lifecycle.cpp | 1 + packages/engine/ui/node_model.h | 3 + packages/engine/ui/style.cpp | 22 ++++++ packages/engine/ui/style.h | 4 +- packages/engine/ui/text.cpp | 19 +++++ packages/engine/ui/tree_style.cpp | 14 ++++ packages/geatsc-plugin-gea/src/utils.ts | 14 ++++ 9 files changed, 175 insertions(+), 5 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 0aff423..1379cfd 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -563,6 +563,10 @@ void rootScrollImageHoldTick(); static const char *prepareText(const char *text, int textTransform, int whiteSpace, std::string &storage); static void layout(int id, int availableWidth); static int baselineOffset(const Node &node, bool last); + // Font metrics vertical-align works with: the content area around the + // baseline, the x-height, and the strut (the line-height box) of `style`. + struct InlineFontMetrics { int ascent, descent, xHeight, fontSize, strutAscent, strutDescent; }; + static InlineFontMetrics inlineFontMetrics(const ComputedStyle &style); // True when a run's line breaking is reproducible from per-glyph advances, // i.e. the same wrapper the draw path uses. A host that measures whole // strings for us (CoreText on Apple targets) does its own breaking, which diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 5155cbd..0a22b26 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -998,6 +998,8 @@ class FlexLayoutPass { int wrapSpans = 0; int wrapAdvance = 0; int wrapLastWidth = 0; + bool lineAligned = false; + int topItemHeight = 0, bottomItemHeight = 0; std::vector lineStaticChildren; for (; j < childCount_; j++) { @@ -1104,12 +1106,46 @@ class FlexLayoutPass { j, m.firstLineTrailingSpace}); } } - const int ascent = cn.computedStyle().margin[0] + childBaseline(cn); - const int descent = cn.layout.height + cn.computedStyle().margin[2] - childBaseline(cn); - if (ascent > lineAscent) lineAscent = ascent; - if (descent > lineDescent) lineDescent = descent; + const int outerHeight = cn.computedStyle().margin[0] + cn.layout.height + cn.computedStyle().margin[2]; + if (cn.computedStyle().vertical_align) lineAligned = true; + if (cn.computedStyle().vertical_align == 1) topItemHeight = std::max(topItemHeight, outerHeight); + else if (cn.computedStyle().vertical_align == 2) bottomItemHeight = std::max(bottomItemHeight, outerHeight); + else { + int ascent = 0, descent = 0; + lineExtent(cn, ascent, descent); + if (ascent > lineAscent) lineAscent = ascent; + if (descent > lineDescent) lineDescent = descent; + } pen += gap + itemMain; } + // A line with vertically aligned items is sized as CSS sizes it: from + // the block's strut and every item's reach, which may be negative when + // the line-height is below the font size. top/bottom items only grow a + // line shorter than themselves. + if (lineAligned) { + const auto strut = TextRenderer::inlineFontMetrics(node_.computedStyle()); + lineAscent = lineIsContinuation ? std::max(strut.strutAscent, inheritedAscent) : strut.strutAscent; + lineDescent = lineIsContinuation ? std::max(strut.strutDescent, inheritedDescent) : strut.strutDescent; + for (int k = i; k < j; ++k) { + const Node &item = nodes[children_[k]]; + if (item.computedStyle().vertical_align == 1 || item.computedStyle().vertical_align == 2) continue; + int ascent = 0, descent = 0; + if (k == wrapIdx) { + const int run = isFragmentableRun(item) ? -1 : transparentInlineRun(item); + ascent = item.computedStyle().margin[0] + childBaseline(item); + descent = runLineDescent(run >= 0 ? nodes[run] : item, wrapAdvance); + } else if (isLineBreak(item)) { + ascent = childBaseline(item); + descent = item.layout.height - childBaseline(item); + } else { + lineExtent(item, ascent, descent); + } + lineAscent = std::max(lineAscent, ascent); + lineDescent = std::max(lineDescent, descent); + } + if (bottomItemHeight > lineAscent + lineDescent) lineAscent = bottomItemHeight - lineDescent; + if (topItemHeight > lineAscent + lineDescent) lineDescent = topItemHeight - lineAscent; + } const bool endedWithBreak = j > i && isLineBreak(nodes[children_[j - 1]]); const bool trailingWrappedStaticBoundary = j >= childCount_ && !endedWithBreak && wrapIdx >= 0; if (j >= childCount_ && !endedWithBreak && !trailingWrappedStaticBoundary) @@ -1368,6 +1404,9 @@ class FlexLayoutPass { } int y = baseline - childBaseline(cn); + if (cn.computedStyle().vertical_align == 1) y = lineTop + cn.computedStyle().margin[0]; + else if (cn.computedStyle().vertical_align == 2) y = lineTop + lineCross - cn.layout.height - cn.computedStyle().margin[2]; + else y += verticalAlignShift(cn); const int align = usedAlignment(crossAlignFor(cn), lineCross - cn.layout.height - cn.computedStyle().margin[0] - cn.computedStyle().margin[2]); if (align != 5) { const int crossBefore = cn.computedStyle().margin[0]; @@ -2171,6 +2210,58 @@ class FlexLayoutPass { // places runs at `top + ascender - bearingY`, so this matches the drawn // pixels exactly. Flex items synthesize missing baselines from border edges; // inline boxes use margin edges. + // CSS aligns a non-replaced inline box by its line-height box, which can + // differ from its layout box when the line-height is below the font size. + bool alignsByLineHeight(const Node &item) const + { + return item.type == NodeType::Text || + (item.type == NodeType::View && item.first_child >= 0 && LayoutEngine::isCssInlineLevelBox(item) && + LayoutEngine::isInlineLevelNode(item)); + } + + // The aligned box of an item, as its reach above and below the item's + // own baseline. + void alignedBox(const Node &item, int &above, int &below) const + { + if (alignsByLineHeight(item)) { + const auto own = TextRenderer::inlineFontMetrics(item.computedStyle()); + above = own.strutAscent; + below = own.strutDescent; + return; + } + above = item.computedStyle().margin[0] + childBaseline(item); + below = item.layout.height + item.computedStyle().margin[2] - childBaseline(item); + } + + // vertical-align of a one-line inline item: how far below its baseline + // position it sits. top and bottom align to the line box instead. + int verticalAlignShift(const Node &item) const + { + const int align = item.computedStyle().vertical_align; + if (align < 3) return 0; + const auto parent = TextRenderer::inlineFontMetrics(node_.computedStyle()); + int above = 0, below = 0; + alignedBox(item, above, below); + if (align == 3) return (above - below) / 2 - parent.xHeight / 2; + if (align == 4) return above - parent.ascent; + if (align == 5) return parent.descent - below; + return align == 6 ? parent.fontSize / 5 : -(parent.fontSize / 3); + } + + // How far an item reaches above and below the line's baseline. A shifted + // item contributes its aligned box; baseline items keep their layout box. + void lineExtent(const Node &item, int &ascent, int &descent) const + { + const int shift = verticalAlignShift(item); + if (item.computedStyle().vertical_align >= 3) alignedBox(item, ascent, descent); + else { + ascent = item.computedStyle().margin[0] + childBaseline(item); + descent = item.layout.height + item.computedStyle().margin[2] - childBaseline(item); + } + ascent -= shift; + descent += shift; + } + int childBaseline(const Node &childNode, bool last = false) const { if ((childNode.type == NodeType::Text || isLineBreak(childNode)) && childNode.computedStyle().font_id >= 0) diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index 2187421..f02c837 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -206,6 +206,7 @@ constexpr ComputedStyle makeDefaultStyle() style.text_align = 0; #endif style.text_align_last = 0; + style.vertical_align = 0; #if GEA_CSS_TEXT_DECORATION style.text_decoration = 0; #endif diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 2c091a9..7804a97 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -789,6 +789,9 @@ struct ComputedStyle { #else static constexpr style_color_t active_bg_color = 0; #endif + // CSS `vertical-align` of an inline-level box: 0 baseline, 1 top, 2 bottom, + // 3 middle, 4 text-top, 5 text-bottom, 6 sub, 7 super. Sits in padding. + int8_t vertical_align; style_color_t text_color; #if GEA_CSS_OPACITY uint8_t opacity; diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 586af86..532b0e5 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -604,6 +604,7 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) a.line_height != b.line_height || a.text_align != b.text_align || a.text_align_last != b.text_align_last || + a.vertical_align != b.vertical_align || a.overflow != b.overflow || a.overflow_x != b.overflow_x || a.overflow_y != b.overflow_y || @@ -726,6 +727,7 @@ bool styleEqualExceptLocalDisplayCommands(const ComputedStyle &a, const Computed a.line_height != b.line_height || a.text_align != b.text_align || a.text_align_last != b.text_align_last || + a.vertical_align != b.vertical_align || a.overflow != b.overflow || a.overflow_x != b.overflow_x || a.overflow_y != b.overflow_y || @@ -1259,6 +1261,7 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "line-height") == 0) return CssDeclarationId::LineHeight; if (std::strcmp(property, "text-align") == 0) return CssDeclarationId::TextAlign; if (std::strcmp(property, "text-align-last") == 0) return CssDeclarationId::TextAlignLast; + if (std::strcmp(property, "vertical-align") == 0) return CssDeclarationId::VerticalAlign; if (std::strcmp(property, "text-decoration") == 0 || std::strcmp(property, "text-decoration-line") == 0) return CssDeclarationId::TextDecoration; if (std::strcmp(property, "text-transform") == 0) return CssDeclarationId::TextTransform; if (std::strcmp(property, "white-space") == 0) return CssDeclarationId::WhiteSpace; @@ -5005,6 +5008,14 @@ int textAlignValue(const std::string &value) return 0; } +// Keywords only; a length or percentage shift keeps the previous value. +int verticalAlignValue(const std::string &value) +{ + static const char *const keywords[] = {"baseline", "top", "bottom", "middle", "text-top", "text-bottom", "sub", "super"}; + for (int i = 0; i < 8; ++i) if (value == keywords[i]) return i; + return value == "initial" || value == "unset" ? 0 : -1; +} + // Unlike text-align, `auto` must stay distinct from start: 0 = auto, otherwise // the text_align value plus one. Justification is unsupported and starts. int textAlignLastValue(const std::string &value) @@ -7259,6 +7270,9 @@ bool compileKeywordValue(CssDeclarationId declaration, const std::string &value, case CssDeclarationId::TextAlignLast: compiled.values[0] = textAlignLastValue(value); return true; + case CssDeclarationId::VerticalAlign: + compiled.values[0] = verticalAlignValue(value); + return compiled.values[0] >= 0; case CssDeclarationId::TextDecoration: compiled.values[0] = textDecorationValue(value); return true; @@ -8463,6 +8477,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::TextAlign: style.text_align = value; return true; #endif case Property::TextAlignLast: style.text_align_last = value; return true; + case Property::VerticalAlign: style.vertical_align = value; return true; #if GEA_CSS_TEXT_DECORATION case Property::TextDecoration: style.text_decoration = value; return true; #endif @@ -10236,6 +10251,9 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::TextAlignLast: setStyleValue(node, Property::TextAlignLast, textAlignLastValue(value), source); return true; + case CssDeclarationId::VerticalAlign: + if (const int align = verticalAlignValue(value); align >= 0) setStyleValue(node, Property::VerticalAlign, align, source); + return true; case CssDeclarationId::TextDecoration: setStyleValue(node, Property::TextDecoration, textDecorationValue(value), source); return true; @@ -11301,6 +11319,7 @@ bool applyCompiledCssValueWithSource(NodeHandle node, const CssCompiledValue &co case CssDeclarationId::Position: setStyleValue(node, Property::Position, compiled.values[0], source); return true; case CssDeclarationId::TextAlign: setStyleValue(node, Property::TextAlign, compiled.values[0], source); return true; case CssDeclarationId::TextAlignLast: setStyleValue(node, Property::TextAlignLast, compiled.values[0], source); return true; + case CssDeclarationId::VerticalAlign: setStyleValue(node, Property::VerticalAlign, compiled.values[0], source); return true; case CssDeclarationId::TextDecoration: setStyleValue(node, Property::TextDecoration, compiled.values[0], source); return true; case CssDeclarationId::TextTransform: setStyleValue(node, Property::TextTransform, compiled.values[0], source); return true; case CssDeclarationId::WhiteSpace: setStyleValue(node, Property::WhiteSpace, compiled.values[0], source); return true; @@ -11902,6 +11921,7 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "line-height") return removeInlineStyleProperties(id, {Property::LineHeight, Property::LineHeightExpression, Property::LineHeightMultiplier}); if (property == "text-align") return removeInlineStyleProperties(id, {Property::TextAlign}); if (property == "text-align-last") return removeInlineStyleProperties(id, {Property::TextAlignLast}); + if (property == "vertical-align") return removeInlineStyleProperties(id, {Property::VerticalAlign}); if (property == "text-decoration" || property == "text-decoration-line") return removeInlineStyleProperties(id, {Property::TextDecoration}); if (property == "text-transform") return removeInlineStyleProperties(id, {Property::TextTransform}); @@ -14149,6 +14169,7 @@ bool addCachedKeywordDeclaration(CachedStyleApplyOp &op, CssDeclarationId declar case CssDeclarationId::Position: return addCachedStyleApplyProperty(op, Property::Position, value); case CssDeclarationId::TextAlign: return addCachedStyleApplyProperty(op, Property::TextAlign, value); case CssDeclarationId::TextAlignLast: return addCachedStyleApplyProperty(op, Property::TextAlignLast, value); + case CssDeclarationId::VerticalAlign: return addCachedStyleApplyProperty(op, Property::VerticalAlign, value); case CssDeclarationId::TextDecoration: return addCachedStyleApplyProperty(op, Property::TextDecoration, value); case CssDeclarationId::TextTransform: return addCachedStyleApplyProperty(op, Property::TextTransform, value); case CssDeclarationId::WhiteSpace: return addCachedStyleApplyProperty(op, Property::WhiteSpace, value); @@ -15061,6 +15082,7 @@ bool addCompiledValueWrites(const CssCompiledValue &compiled, PropertyWriteMask case CssDeclarationId::Position: addPropertyWrite(mask, Property::Position); return true; case CssDeclarationId::TextAlign: addPropertyWrite(mask, Property::TextAlign); return true; case CssDeclarationId::TextAlignLast: addPropertyWrite(mask, Property::TextAlignLast); return true; + case CssDeclarationId::VerticalAlign: addPropertyWrite(mask, Property::VerticalAlign); return true; case CssDeclarationId::TextDecoration: addPropertyWrite(mask, Property::TextDecoration); return true; case CssDeclarationId::TextTransform: addPropertyWrite(mask, Property::TextTransform); return true; case CssDeclarationId::WhiteSpace: addPropertyWrite(mask, Property::WhiteSpace); return true; diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index b008c11..3f5e934 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -218,6 +218,7 @@ enum class Property : int { BlockEllipsis, ColumnCountSet, ColumnWidthSet, + VerticalAlign, Count }; @@ -368,7 +369,8 @@ enum class StyleDeclaration : std::uint8_t { BlockEllipsis, Columns, ColumnCount, - ColumnWidth + ColumnWidth, + VerticalAlign }; class Style { diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index a0b7489..11a6875 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1782,6 +1782,25 @@ bool TextRenderer::canFragmentInlineRuns(const Node &node) return cached != 0; } +TextRenderer::InlineFontMetrics TextRenderer::inlineFontMetrics(const ComputedStyle &style) +{ + const int fontSize = style.font_size > 0 ? style.font_size : 16; + const auto font = gea::framework::graphics::FontRegistry::rasterizedFamily(style.font_id, fontSize); + InlineFontMetrics metrics{fontSize * 4 / 5, fontSize - fontSize * 4 / 5, fontSize / 2, fontSize, 0, 0}; + int glyphHeight = fontSize; + if (font.valid()) { + glyphHeight = font.lineHeight(); + metrics.ascent = font.ascender(); + metrics.descent = glyphHeight - metrics.ascent; + gea::framework::graphics::Glyph x{}; + if (font.glyph('x', &x) && x.bearingY > 0) metrics.xHeight = x.bearingY; + } + const int lineAdvance = style.line_height > 0 ? style.line_height : glyphHeight; + metrics.strutAscent = (lineAdvance - glyphHeight) / 2 + metrics.ascent; + metrics.strutDescent = lineAdvance - metrics.strutAscent; + return metrics; +} + int TextRenderer::baselineOffset(const Node &node, bool last) { const int fontSize = node.computedStyle().font_size > 0 ? node.computedStyle().font_size : 16; diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index b2d0391..55be664 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -92,6 +92,19 @@ bool isLayoutProperty(Property prop) case Property::BlockEllipsis: case Property::ColumnCountSet: case Property::ColumnWidthSet: + case Property::VerticalAlign: +#if GEA_CSS_TEXT_ALIGN + // Line shifts and inline static positions follow physicalTextAlign. + case Property::TextAlign: +#endif +#if GEA_CSS_BORDER_RELIEF + // border-style: none / hidden zeroes the side's used width. + case Property::BorderRelief: + case Property::BorderTopRelief: + case Property::BorderRightRelief: + case Property::BorderBottomRelief: + case Property::BorderLeftRelief: +#endif #if GEA_CSS_FLOATS case Property::Clear: #endif @@ -994,6 +1007,7 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::TextAlign: if (style.text_align != value) { style.text_align = value; changed = 1; } break; #endif case Property::TextAlignLast: if (style.text_align_last != value) { style.text_align_last = value; changed = 1; } break; + case Property::VerticalAlign: if (style.vertical_align != value) { style.vertical_align = value; changed = 1; } break; #if GEA_CSS_TEXT_DECORATION case Property::TextDecoration: if (style.text_decoration != value) { style.text_decoration = value; changed = 1; } break; #endif diff --git a/packages/geatsc-plugin-gea/src/utils.ts b/packages/geatsc-plugin-gea/src/utils.ts index 4f2ac8c..5c3b9ba 100644 --- a/packages/geatsc-plugin-gea/src/utils.ts +++ b/packages/geatsc-plugin-gea/src/utils.ts @@ -66,6 +66,7 @@ export function styleKeywordPropertyValuesForPropertyName(propertyName: string, if (propertyName === 'position') return keywordValue('Position', POSITION_KEYWORDS, value) if (propertyName === 'text-align') return keywordValue('TextAlign', TEXT_ALIGN_KEYWORDS, value) if (propertyName === 'text-align-last') return keywordValue('TextAlignLast', TEXT_ALIGN_LAST_KEYWORDS, value) + if (propertyName === 'vertical-align') return keywordValue('VerticalAlign', VERTICAL_ALIGN_KEYWORDS, value) if (propertyName === 'white-space') return keywordValue('WhiteSpace', WHITE_SPACE_KEYWORDS, value) if (propertyName === 'text-overflow') return keywordValue('TextOverflow', TEXT_OVERFLOW_KEYWORDS, value) if (propertyName === 'backface-visibility') return keywordValue('Backface', BACKFACE_KEYWORDS, value) @@ -258,6 +259,7 @@ const STYLE_DECLARATION_ENUM_BY_PROPERTY = new Map([ ['line-height', 'LineHeight'], ['text-align', 'TextAlign'], ['text-align-last', 'TextAlignLast'], + ['vertical-align', 'VerticalAlign'], ['text-decoration', 'TextDecoration'], ['text-decoration-line', 'TextDecoration'], ['text-transform', 'TextTransform'], @@ -377,6 +379,7 @@ const STYLE_KEYWORD_PROPERTIES_BY_PROPERTY = new Map([ ['position', ['Position']], ['text-align', ['TextAlign']], ['text-align-last', ['TextAlignLast']], + ['vertical-align', ['VerticalAlign']], ['white-space', ['WhiteSpace']], ['text-overflow', ['TextOverflow']], ['backface-visibility', ['Backface']], @@ -446,6 +449,17 @@ const TEXT_ALIGN_KEYWORDS = new Map([ ['end', 2], ]) +const VERTICAL_ALIGN_KEYWORDS = new Map([ + ['baseline', 0], + ['top', 1], + ['bottom', 2], + ['middle', 3], + ['text-top', 4], + ['text-bottom', 5], + ['sub', 6], + ['super', 7], +]) + // The engine keeps auto distinct from start: text-align plus one, 0 = auto. const TEXT_ALIGN_LAST_KEYWORDS = new Map([ ['auto', 0], From 1dcde54f79a344e71c5f5f303ca403122580269e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Fri, 25 Sep 2026 23:29:53 +0300 Subject: [PATCH 14/50] Support position: sticky sticky parsed as static. It is now its own position value: the box stays in flow and, while absolute coordinates are resolved, shifts just enough to stay inset by its top/right/bottom/left from the nearest scrollport (a scroll container's padding box, or the viewport) without leaving its containing block. Sticky boxes contain absolute descendants. The offset depends on scroll positions, so the root scroll-only refresh re-sticks the boxes instead of dragging them along with the content: - The absolute-coordinate pass records each sticky box it places, the offset it added and where it left the box, in a 32-slot table in TreeState; a subtree pass forgets its own boxes before writing them again. The fast path rejects a frame whose box is not where the table says; more than 32 boxes set stickyOverflow until the next root pass. - After the content shifts by the scroll delta, the boxes whose scrollport is the scroll node or above it get their offset taken back and applyStickyOffset applied again (stickyBoxesScrolledBy, restickScrolledBox). A box whose scrollport is below the scroll node moves with the content; a sticky box inside one of them rejects the frame. - The commands of a re-stuck box's subtree move by its extra shift, node by node for up to four inner nodes. - A box that keeps its place and covers the scrollport across the scroll axis from one edge keeps the pixels of that band: DisplayList::regionPaintedOnlyBy proves that after the last node covering the band opaquely nothing that scrolls, and nothing outside the box, paints into it. The blit and the revealed strip leave the band out; the box's paint outside the band, and the old thumb position inside it, are repaired. - A box without a band (pushed or let go) repaints where the blit dragged its last paint and where it sits now. - The esp32 software scroll register rotates the rows of the rect it scrolled last and restarts at zero for a new one, so a frame where bands appear or go away resets it and repaints its rows and the scrollport once (board only). At most four boxes move in a frame; more record the list afresh and repaint the scrollport. A dirty node inside a sticky box, virtual-list slots and nested sticky boxes in one scrollport still reject the frame. GEA_CSS_STICKY (default on) gates the family. Off, sticky parses as static, the sticky helpers, scrollport tracking and the table are not compiled, and TreeState::stickyPresent is static constexpr false. An app without sticky boxes reads one bool per scroll frame; DisplayCommand, Node, ComputedStyle and LayoutBox keep their sizes, TreeState grows by 322 bytes. The analyzer and CLI do not report the family yet. Desktop harness: sticky-scroll-96 (scroll-96 plus a sticky header) about 50 us a frame and sticky-sections-96 (32 sections, headers pushed out) about 59 us, the level of scroll-96 without sticky; a full record and replay of the same frames costs 306 and 390 us. Frames 1-72 of both are bit-identical to that full path. Not measured on the board yet. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/css_features.h | 14 + packages/engine/ui/internal.h | 24 ++ packages/engine/ui/layout.cpp | 200 +++++++++++++- packages/engine/ui/node_model.h | 2 + packages/engine/ui/render.cpp | 29 ++ packages/engine/ui/root_scroll_refresh.cpp | 302 ++++++++++++++++++++- packages/engine/ui/style.cpp | 3 + packages/engine/ui/tree_state.h | 19 ++ packages/geatsc-plugin-gea/src/utils.ts | 2 + 9 files changed, 583 insertions(+), 12 deletions(-) diff --git a/packages/engine/ui/css_features.h b/packages/engine/ui/css_features.h index dbe87e6..992867a 100644 --- a/packages/engine/ui/css_features.h +++ b/packages/engine/ui/css_features.h @@ -247,6 +247,20 @@ #define GEA_CSS_BACKGROUND_LAYERS 1 #endif +// position: sticky (CSS Position 3). Off, sticky parses as static, the +// absolute-coordinate pass tracks no scrollport, and the root scroll-only +// refresh never stands down for sticky boxes. +#ifndef GEA_CSS_STICKY +#define GEA_CSS_STICKY 1 +#endif + +// 32-bit declared and laid-out widths and heights, for boxes past the int16 +// range that a scale transform brings onto the screen. Off, those fields are +// int16 as before and Node is 12 bytes smaller on 32-bit targets. +#ifndef GEA_CSS_LARGE_BOXES +#define GEA_CSS_LARGE_BOXES 1 +#endif + // CSS Overflow 4 line clamping (line-clamp, max-lines, continue, block-ellipsis) // and CSS Multi-column (column-count, column-width, column-fill, column-span). #ifndef GEA_CSS_LINE_CLAMP diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 1379cfd..a64a2cb 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -332,6 +332,14 @@ void rootScrollImageHoldTick(); bool canReplayDirectDirtyRegions(int width, int height) const; void translateNodeCommands(int node, int dx, int dy); void translateSubtreeCommands(int node, int dx, int dy); +#if GEA_CSS_STICKY + // True when a replay of the region paints nothing that moves with + // `moving`'s content except `owner`'s subtree: an opaque cover hides + // everything drawn before it, and no node under `moving` outside `owner` + // draws into the region after it. Clip scopes and scrollbar thumbs, + // which live outside node paint ranges, are not considered. + bool regionPaintedOnlyBy(int x0, int y0, int x1, int y1, int owner, int moving) const; +#endif // After translating a subtree in place, content that was fully outside the // record-time clip (culled: no commands recorded) may now overlap the // viewport. The retained list is then incomplete — replay would paint stale @@ -443,6 +451,19 @@ void rootScrollImageHoldTick(); static int fixedContainingBlock(const Node &node); static bool isViewportFixed(const Node &node); static bool containsViewportFixed(int node); +#if GEA_CSS_STICKY + // A pure scroll of `scrollNode` moves the content under it by the scroll + // delta. The placed sticky boxes whose scrollport is that node or above + // it change their offset with it: their TreeState::stickyBoxes slots go + // to `slots` and the count is returned. -1 when the fast path cannot + // re-stick them in place: more boxes than slots, a box moved since its + // pass placed it, or a sticky box inside one of those. + static int stickyBoxesScrolledBy(int scrollNode, int *slots); + // After that shift: re-apply the offset of the box in `slot` at the new + // scroll position and move its subtree with it. Returns the box's + // shift on top of the content's in *dx, *dy. + static void restickScrolledBox(int slot, int *dx, int *dy); +#endif static bool absoluteGridArea(const Node &parent, const Node &child, int &x, int &y, int &width, int &height); static void absoluteContainingArea(const Node &parent, const Node &child, int &x, int &y, int &width, int &height); @@ -485,6 +506,9 @@ void rootScrollImageHoldTick(); bool layoutNodeScoped(int scope, int treeRoot); void repositionChildren(int id); void resolveAbsoluteCoords(int id, int parentX, int parentY); + // resolveAbsoluteCoords below its entry: `scrollport` is the nearest + // scroll container's padding box (or the viewport) for sticky boxes. + void resolveAbsoluteCoordsIn(int id, int parentX, int parentY, const int *scrollport); // True when `n` is an inline-level box (a span/text/image without an // explicit block-level display) — the inline-formatting input that makes a // plain block flow its children as a row (LayoutNodePass:: diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 0a22b26..916b0f7 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3825,7 +3825,8 @@ bool establishesTransformContainingBlock(const Node &node) bool establishesAbsoluteContainingBlock(const Node &node) { - return isOutOfFlowPosition(node.computedStyle().position) || node.computedStyle().position == 2 || establishesTransformContainingBlock(node); + return isOutOfFlowPosition(node.computedStyle().position) || node.computedStyle().position == 2 || (GEA_CSS_STICKY && node.computedStyle().position == kPositionSticky) || + establishesTransformContainingBlock(node); } int containingBlockForAbsoluteNode(int node, int root, Node *nodes) @@ -4772,7 +4773,186 @@ bool LayoutEngine::layoutNodeScoped(int scope, int treeRoot) return true; } +#if GEA_CSS_STICKY +namespace { + +int stickyInset(const Node &node, int side, int basis) +{ + int inset = node.style.pos_offsets[side] != kUnset ? node.style.pos_offsets[side] : 0; + if (node.style.pos_offset_percent[side] != kUnset) inset += basis * node.style.pos_offset_percent[side] / 1000; + return inset; +} + +bool hasStickyInset(const Node &node, int side) +{ + return node.style.pos_offsets[side] != kUnset || node.style.pos_offset_percent[side] != kUnset; +} + +// position: sticky (CSS Position 3): shift the box, never out of its +// containing block, just enough to keep it inset from its scrollport edges. +// `port` is that scrollport, x0 y0 x1 y1 exclusive, in resolved coordinates. +void applyStickyOffset(Node &node, const Node &block, const int port[4]) +{ + const int portW = port[2] - port[0], portH = port[3] - port[1]; + auto axis = [&](int start, int size, int marginStart, int marginEnd, int portStart, int portEnd, + int blockStart, int blockEnd, int startSide, int endSide, int basis) { + int shift = 0; + if (hasStickyInset(node, startSide)) { + const int limit = portStart + stickyInset(node, startSide, basis); + if (start < limit) shift = std::min(limit - start, std::max(0, blockEnd - marginEnd - (start + size))); + } + if (hasStickyInset(node, endSide)) { + const int limit = portEnd - stickyInset(node, endSide, basis); + if (start + size + shift > limit) + shift = std::max(limit - (start + size), std::min(0, blockStart + marginStart - start)); + } + return shift; + }; + const int blockX0 = block.layout.x + boxInset(block.computedStyle(), 3), blockX1 = block.layout.x + block.layout.width - boxInset(block.computedStyle(), 1); + const int blockY0 = block.layout.y + boxInset(block.computedStyle(), 0), blockY1 = block.layout.y + block.layout.height - boxInset(block.computedStyle(), 2); + node.layout.y += axis(node.layout.y, node.layout.height, node.computedStyle().margin[0], node.computedStyle().margin[2], port[1], port[3], + blockY0, blockY1, 0, 2, portH); + node.layout.x += axis(node.layout.x, node.layout.width, node.computedStyle().margin[3], node.computedStyle().margin[1], port[0], port[2], + blockX0, blockX1, 3, 1, portW); +} + +// A scroll container's padding box, in resolved coordinates. +void scrollportOf(const Node &node, int port[4]) +{ + port[0] = node.layout.x + std::max(node.style.border_width, rstyle(node.style).border_side_width[3]); + port[1] = node.layout.y + std::max(node.style.border_width, rstyle(node.style).border_side_width[0]); + port[2] = node.layout.x + node.layout.width - std::max(node.style.border_width, rstyle(node.style).border_side_width[1]); + port[3] = node.layout.y + node.layout.height - std::max(node.style.border_width, rstyle(node.style).border_side_width[2]); +} + +// The scrollport resolveAbsoluteCoords hands `id`: its nearest ancestor that +// establishes one, or the viewport (-1). +int scrollportOwner(int id, const Node *nodes) +{ + for (int ancestor = nodes[id].parent; ancestor >= 0; ancestor = nodes[ancestor].parent) + if (overflowEstablishesContext(nodes[ancestor].computedStyle())) return ancestor; + return -1; +} + +void noteStickyBox(int id, const Node &node, int dx, int dy) +{ + TreeState &state = treeState(); + const TreeState::StickyBox box{static_cast(id), static_cast(dx), static_cast(dy), + static_cast(node.layout.x), static_cast(node.layout.y)}; + for (int i = 0; i < state.stickyCount; ++i) { + if (state.stickyBoxes[i].node != id) continue; + state.stickyBoxes[i] = box; + return; + } + if (state.stickyCount == TreeState::kMaxStickyBoxes) state.stickyOverflow = true; + else state.stickyBoxes[state.stickyCount++] = box; +} + +// A subtree pass places the sticky boxes inside `id` again. +void forgetStickyBoxesIn(int id, const Node *nodes, int nodeCount) +{ + TreeState &state = treeState(); + for (int i = 0; i < state.stickyCount;) { + int cursor = state.stickyBoxes[i].node; + while (cursor >= 0 && cursor < nodeCount && cursor != id) cursor = nodes[cursor].parent; + if (cursor == id) state.stickyBoxes[i] = state.stickyBoxes[--state.stickyCount]; + else ++i; + } +} + +void translateDescendants(Node *nodes, int id, int dx, int dy) +{ + for (int child = nodes[id].first_child; child >= 0; child = nodes[child].next_sibling) { + nodes[child].layout.x += dx; + nodes[child].layout.y += dy; + translateDescendants(nodes, child, dx, dy); + } +} + +} // namespace + +int LayoutEngine::stickyBoxesScrolledBy(int scrollNode, int *slots) +{ + const TreeState &state = treeState(); + if (state.stickyOverflow) return -1; + const Node *nodes = Tree::instance().nodes(); + const int nodeCount = Tree::instance().nodeCount(); + int count = 0; + for (int i = 0; i < state.stickyCount; ++i) { + const TreeState::StickyBox &box = state.stickyBoxes[i]; + if (box.node >= nodeCount) continue; + // Only a box under the scroll node moves with it, and its offset holds + // when a scrollport below the scroll node moves along with it. + bool under = false, nested = false; + for (int ancestor = nodes[box.node].parent; ancestor >= 0; ancestor = nodes[ancestor].parent) { + if (ancestor == scrollNode) { + under = true; + break; + } + if (overflowEstablishesContext(nodes[ancestor].computedStyle())) break; + if (nodes[ancestor].computedStyle().position == kPositionSticky) nested = true; + } + if (!under) continue; + const Node &node = nodes[box.node]; + if (nested || node.computedStyle().position != kPositionSticky || isDisplayNone(node.computedStyle()) || + node.layout.x != box.x || node.layout.y != box.y) + return -1; + slots[count++] = i; + } + return count; +} + +void LayoutEngine::restickScrolledBox(int slot, int *dx, int *dy) +{ + Node *nodes = Tree::instance().nodes(); + TreeState::StickyBox &box = treeState().stickyBoxes[slot]; + Node &node = nodes[box.node]; + int port[4] = {0, 0, Tree::instance().mountedWidth(), Tree::instance().mountedHeight()}; + const int owner = scrollportOwner(box.node, nodes); + if (owner >= 0) scrollportOf(nodes[owner], port); + // The shift carried the box with the content: take its offset out, then + // stick it again as resolveAbsoluteCoordsIn would. + node.layout.x -= box.dx; + node.layout.y -= box.dy; + const int flowX = node.layout.x, flowY = node.layout.y; + applyStickyOffset(node, nodes[node.parent], port); + *dx = node.layout.x - flowX - box.dx; + *dy = node.layout.y - flowY - box.dy; + box.dx = static_cast(node.layout.x - flowX); + box.dy = static_cast(node.layout.y - flowY); + box.x = node.layout.x; + box.y = node.layout.y; + if (*dx || *dy) translateDescendants(nodes, box.node, *dx, *dy); +} +#endif + void LayoutEngine::resolveAbsoluteCoords(int id, int parent_x, int parent_y) +{ +#if GEA_CSS_STICKY + Node *nodes = Tree::instance().nodes(); + TreeState &state = treeState(); + if (nodes[id].parent < 0) { + state.stickyPresent = false; + state.stickyCount = 0; + state.stickyOverflow = false; + } else if (state.stickyCount) { + forgetStickyBoxesIn(id, nodes, Tree::instance().nodeCount()); + } + // A subtree pass (scoped relayout) inherits its nearest scroll container's + // scrollport; its ancestors are already resolved. + int port[4] = {0, 0, Tree::instance().mountedWidth(), Tree::instance().mountedHeight()}; + for (int ancestor = nodes[id].parent; ancestor >= 0; ancestor = nodes[ancestor].parent) { + if (!overflowEstablishesContext(nodes[ancestor].computedStyle())) continue; + scrollportOf(nodes[ancestor], port); + break; + } + resolveAbsoluteCoordsIn(id, parent_x, parent_y, port); +#else + resolveAbsoluteCoordsIn(id, parent_x, parent_y, nullptr); +#endif +} + +void LayoutEngine::resolveAbsoluteCoordsIn(int id, int parent_x, int parent_y, const int *scrollport) { Node *nodes = Tree::instance().nodes(); Node *node = &nodes[id]; @@ -4781,6 +4961,14 @@ void LayoutEngine::resolveAbsoluteCoords(int id, int parent_x, int parent_y) node->layout.x += parent_x; node->layout.y += parent_y; } +#if GEA_CSS_STICKY + if (node->computedStyle().position == kPositionSticky && node->parent >= 0 && !isDisplayNone(node->computedStyle())) { + const int flowX = node->layout.x, flowY = node->layout.y; + applyStickyOffset(*node, nodes[node->parent], scrollport); + noteStickyBox(id, *node, node->layout.x - flowX, node->layout.y - flowY); + treeState().stickyPresent = true; + } +#endif int childParentX = node->layout.x; int childParentY = node->layout.y; @@ -4788,9 +4976,17 @@ void LayoutEngine::resolveAbsoluteCoords(int id, int parent_x, int parent_y) if (scrollsOverflowX(node->computedStyle())) childParentX -= node->layout.scroll_x; if (scrollsOverflowY(node->computedStyle())) childParentY -= node->layout.scroll_y; } +#if GEA_CSS_STICKY + // A scroll container's padding box is the scrollport of sticky descendants. + int port[4]; + if (overflowEstablishesContext(node->computedStyle())) { + scrollportOf(*node, port); + scrollport = port; + } +#endif for (int child = node->first_child; child >= 0; child = nodes[child].next_sibling) { - resolveAbsoluteCoords(child, childParentX, childParentY); + resolveAbsoluteCoordsIn(child, childParentX, childParentY, scrollport); } } diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 7804a97..695a34b 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -95,6 +95,8 @@ inline constexpr int8_t kDisplayNone = 1; inline constexpr int8_t kDisplayGrid = 2; inline constexpr int8_t kDisplayFlex = 3; inline constexpr int kPositionFixed = 3; +// position: sticky stays in flow; the absolute-coordinate pass shifts it. +inline constexpr int kPositionSticky = 4; inline bool isOutOfFlowPosition(int position) { return position == 1 || position == kPositionFixed; } // Existing alignment values occupy the low nibble. Overflow-position is // independent of the alignment keyword and fits in the existing int8 fields. diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index f089bdc..5e547ff 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -10577,6 +10577,35 @@ int gLastScrollUiFrame = -1000; return true; } +#if GEA_CSS_STICKY + bool DisplayList::regionPaintedOnlyBy(int x0, int y0, int x1, int y1, int owner, int moving) const + { + if (!state.commands || state.drawNodeOrderCount >= state.nodeScratchCapacity) + return false; + Tree &tree = Tree::instance(); + Node *nodes = tree.nodes(); + const int nodeCount = tree.nodeCount(); + const int cull = DisplayCommandReplayer::occlusionCullStartIndex(x0, y0, x1, y1, nodes, nodeCount); + if (cull <= 0) + return false; + auto within = [&](int id, int ancestor) { + for (int cursor = id; cursor >= 0 && cursor < nodeCount; cursor = nodes[cursor].parent) + if (cursor == ancestor) + return true; + return false; + }; + for (int oi = cull; oi < state.drawNodeOrderCount; oi++) + { + const int id = state.drawNodeOrder[oi]; + if (id < 0 || id >= nodeCount || id == moving || !within(id, moving) || within(id, owner)) + continue; + if (DisplayCommandReplayer::commandRangeBBoxOverlaps(state.nodeDrawStart[id], state.nodeDrawEnd[id], x0, y0, x1, y1)) + return false; + } + return true; + } +#endif + void DisplayList::translateNodeCommands(int node, int dx, int dy) { if (!state.commands || node < 0 || node >= Tree::instance().nodeCount()) diff --git a/packages/engine/ui/root_scroll_refresh.cpp b/packages/engine/ui/root_scroll_refresh.cpp index d9491ad..7b20d19 100644 --- a/packages/engine/ui/root_scroll_refresh.cpp +++ b/packages/engine/ui/root_scroll_refresh.cpp @@ -294,6 +294,132 @@ bool dirtyBounds(const Node &n, int width, int height, RootScrollOnlyRefresh::Di return true; } +#if GEA_CSS_STICKY +// Inclusive pixel bounds, empty while x1 < x0. Trivial, so an unused array +// of them costs a scroll frame nothing. +struct PaintBounds { + int x0, y0, x1, y1; + + static PaintBounds none() { return {0, 0, -1, -1}; } + bool empty() const { return x1 < x0 || y1 < y0; } + void unite(const PaintBounds &b) + { + if (b.empty()) return; + if (empty()) { + *this = b; + return; + } + x0 = std::min(x0, b.x0); + y0 = std::min(y0, b.y0); + x1 = std::max(x1, b.x1); + y1 = std::max(y1, b.y1); + } + void shift(int dx, int dy) + { + x0 += dx; + x1 += dx; + y0 += dy; + y1 += dy; + } + bool touches(const PaintBounds &b) const + { + return !empty() && !b.empty() && x0 <= b.x1 + 1 && b.x0 <= x1 + 1 && y0 <= b.y1 + 1 && b.y0 <= y1 + 1; + } +}; + +// A sticky box the scroll re-stuck: its shift on top of the content's, and +// its paint in the previous and the current display list. A frame follows +// this many; more record the list afresh and repaint the scrollport. +constexpr int kMaxStickyMoves = 4; +struct StickyMove { + int node, dx, dy; + PaintBounds old, now; +}; + +// translateSubtreeCommands walks every node's ancestors and every clip +// command's; a sticky box's subtree is a few nodes, so move it node by node +// (each node with children scans the list once for its clip scopes). +int countInnerNodes(const TreeState &state, int id, int limit) +{ + int count = state.nodes[id].first_child >= 0; + for (int child = state.nodes[id].first_child; child >= 0 && count <= limit; child = state.nodes[child].next_sibling) + count += countInnerNodes(state, child, limit - count); + return count; +} + +void translateEachNodeCommands(const TreeState &state, int id, int dx, int dy) +{ + DisplayList::instance().translateNodeCommands(id, dx, dy); + for (int child = state.nodes[id].first_child; child >= 0; child = state.nodes[child].next_sibling) + translateEachNodeCommands(state, child, dx, dy); +} + +void translateStickyCommands(const TreeState &state, int id, int dx, int dy) +{ + constexpr int kMaxInnerNodes = 4; + if (countInnerNodes(state, id, kMaxInnerNodes) <= kMaxInnerNodes) translateEachNodeCommands(state, id, dx, dy); + else DisplayList::instance().translateSubtreeCommands(id, dx, dy); +} + +void uniteSubtreeCommandBounds(const TreeState &state, int id, PaintBounds &bounds) +{ + PaintBounds own; + if (DisplayList::instance().nodeCommandBounds(id, &own.x0, &own.y0, &own.x1, &own.y1)) bounds.unite(own); + for (int child = state.nodes[id].first_child; child >= 0; child = state.nodes[child].next_sibling) + uniteSubtreeCommandBounds(state, child, bounds); +} + +// A re-stuck box that kept its place on screen and whose border box covers +// the scrollport across the scroll axis from its leading or trailing edge, +// with nothing that scrolls painting over it there: that band keeps its +// pixels. Widens *lead or *trail (pixels along the axis) to it. +bool stuckBand(const TreeState &state, const StickyMove &moved, bool horizontal, int move, int scrollNode, int vx, int vy, int vw, + int vh, int *lead, int *trail) +{ + if (horizontal ? (moved.dx != -move || moved.dy != 0) : (moved.dy != -move || moved.dx != 0)) return false; + const LayoutBox &box = state.nodes[moved.node].layout; + if (box.width <= 0 || box.height <= 0) return false; + // Scrollport-relative extents along (a) and across (c) the scroll axis. + const int a0 = horizontal ? box.x - vx : box.y - vy; + const int a1 = horizontal ? box.x + box.width - 1 - vx : box.y + box.height - 1 - vy; + const int c0 = horizontal ? box.y - vy : box.x - vx; + const int c1 = horizontal ? box.y + box.height - 1 - vy : box.x + box.width - 1 - vx; + const int extent = horizontal ? vw : vh; + if (c0 > 0 || c1 < (horizontal ? vh : vw) - 1) return false; + int band0, band1; + if (a0 <= 0 && a1 >= 0) { + band0 = 0; + band1 = std::min(a1, extent - 1); + } else if (a0 <= extent - 1 && a1 >= extent - 1) { + band0 = std::max(a0, 0); + band1 = extent - 1; + } else { + return false; + } + const int x0 = horizontal ? vx + band0 : vx, x1 = horizontal ? vx + band1 : vx + vw - 1; + const int y0 = horizontal ? vy : vy + band0, y1 = horizontal ? vy + vh - 1 : vy + band1; + if (!DisplayList::instance().regionPaintedOnlyBy(x0, y0, x1, y1, moved.node, scrollNode)) return false; + if (band0 == 0) *lead = std::max(*lead, band1 + 1); + else *trail = std::max(*trail, extent - band0); + return true; +} + +// The re-stuck boxes move apart from the content, which the slot and +// extra-dirty damage of refresh() assume they follow. +bool stickyMovesFollowable(const TreeState &state, const int *slots, int count, const RootScrollOnlyRefresh::DirtyNode *extra, + int extraCount, int slotCount) +{ + if (slotCount > 0) return false; + for (int e = 0; e < extraCount; e++) { + for (int s = 0; s < count; s++) { + const int box = state.stickyBoxes[slots[s]].node; + if (extra[e].id == box || isDescendantOf(state, extra[e].id, box)) return false; + } + } + return true; +} +#endif + } // namespace int RootScrollOnlyRefresh::refreshNode(int root, @@ -501,6 +627,19 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) GEA_REFRESH_PERF(perf.rootScrollRejected++); return 0; } +#if GEA_CSS_STICKY + // Shifting content by the scroll delta drags sticky boxes along; the ones + // this scroll re-sticks move back after the shift and repaint their old + // and new places below. + int stickySlots[TreeState::kMaxStickyBoxes]; + int stickyCount = state.stickyPresent ? LayoutEngine::stickyBoxesScrolledBy(scroll_node, stickySlots) : 0; + if (stickyCount > 0 && !stickyMovesFollowable(state, stickySlots, stickyCount, extraDirtyNodes, extraDirtyCount, slotCount)) + stickyCount = -1; + if (stickyCount < 0) { + GEA_REFRESH_PERF(perf.rootScrollRejected++); + return 0; + } +#endif GEA_REFRESH_PERF(perf.rootScrollAccepted++); GEA_REFRESH_PERF(perf.rootScrollExtraDirtyNodes += extraDirtyCount); @@ -517,6 +656,22 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) // frame's delta along the scrolled axis to keep them in sync. if (horizontal) shiftDescendantLayoutX(scroll_node, move); else shiftDescendantLayoutY(scroll_node, move); +#if GEA_CSS_STICKY + StickyMove stickyMoves[kMaxStickyMoves]; + int stickyMoveCount = 0; + bool stickyRepaintAll = false; + for (int s = 0; s < stickyCount; s++) { + const int node = state.stickyBoxes[stickySlots[s]].node; + int dx, dy; + LayoutEngine::restickScrolledBox(stickySlots[s], &dx, &dy); + if (!dx && !dy) continue; + if (stickyMoveCount == kMaxStickyMoves) stickyRepaintAll = true; + else stickyMoves[stickyMoveCount++] = {node, dx, dy, PaintBounds::none(), PaintBounds::none()}; + } + if (stickyRepaintAll) stickyMoveCount = 0; +#else + constexpr bool stickyRepaintAll = false; +#endif // Reconcile repositioned slots natively (mirrors the layout // engine's absolute-child resolution: the positioned list is the containing @@ -567,6 +722,17 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) damage.moved = dx != 0 || dy != 0; } +#if GEA_CSS_STICKY + // Where each re-stuck box painted last frame, in the list that painted it; + // the scroll blit below drags those pixels by `move`. + for (int s = 0; s < stickyMoveCount; s++) { + PaintBounds &old = stickyMoves[s].old; + old = PaintBounds::none(); + uniteSubtreeCommandBounds(state, stickyMoves[s].node, old); + old.shift(horizontal ? move : 0, horizontal ? 0 : move); + } +#endif + int vx = n->layout.x; int vy = n->layout.y; int vw = n->layout.width; @@ -630,6 +796,10 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) // translateSubtreeCommands includes the scroll node's own box // commands (background/border), which are viewport-fixed — undo. list.translateNodeCommands(scroll_node, 0, -move); +#if GEA_CSS_STICKY + for (int s = 0; s < stickyMoveCount; s++) + translateStickyCommands(state, stickyMoves[s].node, stickyMoves[s].dx, stickyMoves[s].dy); +#endif listReady = list.patchScrollbarThumb(scroll_node); } if (!listReady && translateEligible) { @@ -679,6 +849,20 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) } GEA_REFRESH_PERF(perf.rootScrollRebuildUs += refreshPerfNowUs() - rebuildStartUs); } +#if GEA_CSS_STICKY + // A box stuck to an edge of the scrollport stays put on screen. When every + // re-stuck box holds such a band, the blit and the strip leave the bands out + // and nothing repaints there; each box repaints only what it paints outside. + int bandLead = 0, bandTrail = 0; + bool banded = stickyMoveCount > 0; + for (int s = 0; s < stickyMoveCount; s++) { + StickyMove &moved = stickyMoves[s]; + moved.now = PaintBounds::none(); + uniteSubtreeCommandBounds(state, moved.node, moved.now); + banded = banded && stuckBand(state, moved, horizontal, move, scroll_node, vx, vy, vw, vh, &bandLead, &bandTrail); + } + if (!banded) bandLead = bandTrail = 0; +#endif GEA_REFRESH_PERF(perf.rootScrollMovePx += abs_move); GEA_REFRESH_PERF(perf.rootScrollViewportPx += vw * vh); GEA_REFRESH_PERF(perf.rootScrollStripPx += horizontal ? (vh * (abs_move < vw ? abs_move : vw)) @@ -723,21 +907,109 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) const bool contentHasRightFadeMask = hasRightFadeMask(state, scroll_node); const int viewportExtent = horizontal ? vw : vh; - if (!contentHasRightFadeMask && abs_move > 0 && abs_move < viewportExtent) { + // The part of the scrollport the blit moves. + int bx = vx, by = vy, bw = vw, bh = vh; +#if GEA_CSS_STICKY + if (horizontal) { + bx += bandLead; + bw -= bandLead + bandTrail; + } else { + by += bandLead; + bh -= bandLead + bandTrail; + } + const bool bands = bandLead > 0 || bandTrail > 0; + static bool lastBands = false; + bool blit = !stickyRepaintAll && !contentHasRightFadeMask && abs_move > 0 && abs_move < (horizontal ? bw : bh); + // A software scroll register (esp32 display.cpp) rotates the rows of the + // rect it scrolled last and restarts at zero for a new rect, which + // scrambles those rows. Bands change the rect: reset the register and + // repaint its rows and the scrollport once instead of blitting. + if (blit && !horizontal && (bands || lastBands)) { + auto *canvas = gea::platform::display::Display::canvas(); + const int regionY = canvas ? canvas->scrollRegionY() : 0, regionH = canvas ? canvas->scrollRegionH() : 0; + if (regionH > 0 && (regionY != by || regionH != bh)) { + gea::platform::display::Display::resetScrollRegion(); + replayScrollNodeRegion(0, regionY, width, regionH); + blit = false; + } + } + lastBands = bands; +#else + const bool blit = !contentHasRightFadeMask && abs_move > 0 && abs_move < viewportExtent; +#endif + if (blit) { const std::int64_t scrollRectStartUs = refreshPerfNowUs(); - gea::platform::display::Display::scrollRect(vx, vy, vw, vh, horizontal ? move : 0, horizontal ? 0 : move); + gea::platform::display::Display::scrollRect(bx, by, bw, bh, horizontal ? move : 0, horizontal ? 0 : move); GEA_REFRESH_PERF(perf.rootScrollScrollRectUs += refreshPerfNowUs() - scrollRectStartUs); const std::int64_t stripReplayStartUs = refreshPerfNowUs(); + // The newly-revealed edge strip. + int stripX = bx, stripY = by, stripW = bw, stripH = bh; if (horizontal) { - // Repaint the newly-revealed vertical edge strip. - if (move < 0) replayScrollNodeRegion(vx + vw - abs_move, vy, abs_move, vh); - else replayScrollNodeRegion(vx, vy, abs_move, vh); - } else if (move < 0) { - replayScrollNodeRegion(vx, vy + vh - abs_move, vw, abs_move); + stripW = abs_move; + if (move < 0) stripX = bx + bw - abs_move; } else { - replayScrollNodeRegion(vx, vy, vw, abs_move); + stripH = abs_move; + if (move < 0) stripY = by + bh - abs_move; + } +#if GEA_CSS_STICKY + // A re-stuck box left the content: repaint, inside the blit, where the + // blit dragged its pixels and where it sits now. A rect touching the + // strip joins it; then nothing holds images, since a stuck header would + // repaint every frame and never show a held one. + PaintBounds stickyDamage[2 * kMaxStickyMoves]; + int stickyDamageCount = 0; + bool stripHoldsImages = holdImages; + { + const PaintBounds blitRect{bx, by, bx + bw - 1, by + bh - 1}; + auto clipToBlit = [&](PaintBounds b) { + b.x0 = std::max(b.x0, blitRect.x0); + b.y0 = std::max(b.y0, blitRect.y0); + b.x1 = std::min(b.x1, blitRect.x1); + b.y1 = std::min(b.y1, blitRect.y1); + return b; + }; + PaintBounds strip{stripX, stripY, stripX + stripW - 1, stripY + stripH - 1}; + for (int s = 0; s < stickyMoveCount; s++) { + const StickyMove &moved = stickyMoves[s]; + // The stale pixels: last frame's paint dragged by the blit, which is + // also the box's paint now taken back by its own shift. + PaintBounds stale = moved.now; + stale.shift(-moved.dx, -moved.dy); + stale.unite(moved.old); + stale = clipToBlit(stale); + PaintBounds now = clipToBlit(moved.now); + if (stale.touches(now)) { + stale.unite(now); + now = PaintBounds::none(); + } + for (const PaintBounds &b : {stale, now}) { + if (b.empty()) continue; + if (b.touches(strip)) { + strip.unite(b); + stripHoldsImages = false; + } else { + stickyDamage[stickyDamageCount++] = b; + } + } + } + stripX = strip.x0; + stripY = strip.y0; + stripW = strip.x1 - strip.x0 + 1; + stripH = strip.y1 - strip.y0 + 1; + } + noteDamage(stripX, stripY, stripW, stripH); + gScrollStripReplayActive = stripHoldsImages; + ViewportRegion::replayDisplayRegion(stripX, stripY, stripW, stripH); + gScrollStripReplayActive = false; + for (int d = 0; d < stickyDamageCount; d++) { + const PaintBounds &b = stickyDamage[d]; + noteDamage(b.x0, b.y0, b.x1 - b.x0 + 1, b.y1 - b.y0 + 1); + ViewportRegion::replayDisplayRegion(b.x0, b.y0, b.x1 - b.x0 + 1, b.y1 - b.y0 + 1); } +#else + replayScrollNodeRegion(stripX, stripY, stripW, stripH); +#endif GEA_REFRESH_PERF(perf.rootScrollStripReplayUs += refreshPerfNowUs() - stripReplayStartUs); // Scrollbar damage is vertical-only: the horizontal forecast/rail rows use @@ -764,12 +1036,22 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) const int shiftedOldBottom = shiftedOldY + oldThumbH - 1; const int newThumbBottom = newThumbY + newThumbH - 1; if (shiftedOldBottom + 1 >= newThumbY && newThumbBottom + 1 >= shiftedOldY) { - const int damageY = shiftedOldY < newThumbY ? shiftedOldY : newThumbY; - const int damageBottom = shiftedOldBottom > newThumbBottom ? shiftedOldBottom : newThumbBottom; + int damageY = shiftedOldY < newThumbY ? shiftedOldY : newThumbY; + int damageBottom = shiftedOldBottom > newThumbBottom ? shiftedOldBottom : newThumbBottom; +#if GEA_CSS_STICKY + // The thumb rows inside a band were not blitted. + if (bands) { + damageY = std::min(damageY, oldThumbY); + damageBottom = std::max(damageBottom, oldThumbY + oldThumbH - 1); + } +#endif replayScrollbarDamage(damageY, damageBottom - damageY + 1); } else { replayScrollbarDamage(shiftedOldY, oldThumbH); replayScrollbarDamage(newThumbY, newThumbH); +#if GEA_CSS_STICKY + if (bands) replayScrollbarDamage(oldThumbY, oldThumbH); +#endif } } else { replayScrollbarDamage(n->layout.y, n->layout.height); diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 532b0e5..7b5a203 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4998,6 +4998,9 @@ int positionValue(const std::string &value) if (value == "absolute") return 1; if (value == "fixed") return kPositionFixed; if (value == "relative") return 2; +#if GEA_CSS_STICKY + if (value == "sticky" || value == "-webkit-sticky") return kPositionSticky; +#endif return 0; } diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index 36c32d9..30ff981 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -366,6 +366,25 @@ struct TreeState { static constexpr bool transformPresent = false; static constexpr bool transformScanValid = true; #endif + // A position: sticky box was placed by the last absolute-coordinate pass. + // Its offset depends on the scroll position, so scroll-only fast paths that + // shift content without that pass must re-stick it or stand down. +#if GEA_CSS_STICKY + bool stickyPresent = false; + // The sticky boxes those passes placed: the offset each one added and where + // it left the box, so the root scroll fast path can re-stick them without + // the pass (LayoutEngine::stickyBoxesScrolledBy). More boxes than slots + // set stickyOverflow until the next root pass. + struct StickyBox { + std::int16_t node, dx, dy, x, y; + }; + static constexpr int kMaxStickyBoxes = 32; + StickyBox stickyBoxes[kMaxStickyBoxes]; + std::uint8_t stickyCount = 0; + bool stickyOverflow = false; +#else + static constexpr bool stickyPresent = false; +#endif // Focus + caret state for `` elements. -1 means no input is // focused. caretLastFlipMs/caretVisible drive the blink animation — diff --git a/packages/geatsc-plugin-gea/src/utils.ts b/packages/geatsc-plugin-gea/src/utils.ts index 5c3b9ba..5ca88c5 100644 --- a/packages/geatsc-plugin-gea/src/utils.ts +++ b/packages/geatsc-plugin-gea/src/utils.ts @@ -439,6 +439,8 @@ const POSITION_KEYWORDS = new Map([ ['static', 0], ['absolute', 1], ['relative', 2], + ['sticky', 4], + ['-webkit-sticky', 4], ]) const TEXT_ALIGN_KEYWORDS = new Map([ From 820a2f8652d012f16a654f61b8aa635b376b488f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 01:23:37 +0300 Subject: [PATCH 15/50] Keep floats from splitting unbreakable inline content A float inside an inline wrapper now joins the enclosing block's float context, the way blocks inside inlines already do, instead of being confined to the wrapper's box. Floats end inline runs in this layout, so a float anchored where the line cannot break (inside a word or nowrap text) split the line. Move such a float to the next soft wrap opportunity, which places it after the line holding its anchor as CSS does. Line breaking now honours soft wrap opportunities between items: an overflowing item only starts a new line where the line may break, and items glued together start the next line as one unit. Collapsible spaces of atomic text collapse across items, are removed at the start of a line, and hang at its end instead of opening another line. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 151 ++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 8 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 916b0f7..4c96f5b 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -726,6 +726,8 @@ int clampBorderSize(LayoutEngine &engine, const Node &node, int size, bool horiz return clampLayoutSize(node, size, horizontal); } +static bool isInlineLevelTag(const char *tag); + class FlexLayoutPass { public: FlexLayoutPass(LayoutEngine &engine, Node &node, int *children, int childCount, bool isRow, int mainAvail, int padWidth, int padHeight, FlexLine *lines, bool inlineRow = false, bool assignedSize = false) @@ -1043,7 +1045,7 @@ class FlexLayoutPass { if (m.lineCount > 0 && haveLineMetrics) { // Its first WORD does not fit what is left: the run starts the // next line box rather than overflowing it or being cut open. - if (pen > 0 && m.firstLineWidth > firstAvail) break; + if (pen > 0 && m.firstLineWidth > firstAvail && !collapsibleSpaceOnly(subject)) break; const int boxH = m.lineCount * m.lineAdvance + padV; const bool wraps = m.lineCount > 1; const int boxW = wraps ? rest + padH : m.firstLineWidth + padH; @@ -1091,8 +1093,15 @@ class FlexLayoutPass { cn.layout.inline_indent = 0; } - const int itemMain = cn.layout.width + marginL + marginR; - if (pen > 0 && pen + gap + itemMain > contentW) break; + const int lead = collapsedLead(nodes, j, pen == 0 && !lineIsContinuation); + const int itemMain = cn.layout.width + marginL + marginR - lead; + // An item starts the next line only where the line may break, and only + // when it does not fit together with what no break opportunity + // separates from it: nowrap text and words split across items stay + // together. Collapsible spaces hang instead. + if (pen > 0 && !collapsibleSpaceOnly(cn) && + (j == 0 || trailingSoftWrap(nodes[children_[j - 1]]) || leadingSoftWrap(cn)) && + pen + gap + (isFragmentableRun(subject) ? itemMain : gluedWidth(nodes, j, false)) > contentW) break; if (isFragmentableRun(subject) && captureFirstLine && i == 0) { const int padH = boxInset(subject.computedStyle(), 1) + boxInset(subject.computedStyle(), 3); const int rest = contentW - marginL - marginR - padH; @@ -1361,6 +1370,88 @@ class FlexLayoutPass { return run; } + // The text node at an inline item's start or end, found through inline + // boxes, or -1 for an atomic inline box. + static int edgeTextRun(const Node &item, bool last) + { + const Node *nodes = Tree::instance().nodes(); + const Node *n = &item; + while (n->type == NodeType::View) { + if (n->style.display_explicit || isLineBreak(*n) || !isInlineLevelTag(tagFromId(n->tag_id))) return -1; + int c = last ? n->last_child : n->first_child; + while (c >= 0 && (isDisplayNone(nodes[c].computedStyle()) || isOutOfFlowPosition(nodes[c].computedStyle().position) || nodes[c].computedStyle().float_side)) + c = last ? nodes[c].prev_sibling : nodes[c].next_sibling; + if (c < 0) return -1; + n = &nodes[c]; + } + return static_cast(n - nodes); + } + + static bool wrapsLines(const Node &text) { return text.computedStyle().white_space == 0 || text.computedStyle().white_space >= 3; } + static bool collapsesSpaces(const Node &text) { return text.computedStyle().white_space == 0 || text.computedStyle().white_space == 1 || text.computedStyle().white_space == 4; } + static bool collapsibleSpace(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f'; } + + // Collapsible spaces hang at the end of a line; they never start another. + static bool collapsibleSpaceOnly(const Node &item) + { + return item.type == NodeType::Text && (item.computedStyle().white_space == 0 || item.computedStyle().white_space == 1) && + item.text.find_first_not_of(" \t\r\n\f") == std::string::npos; + } + + // Soft wrap opportunities at an item's edges. Text only offers one at a + // space its white-space lets wrap; atomic inline boxes always do. + static bool leadingSoftWrap(const Node &item) + { + const int run = edgeTextRun(item, false); + if (run < 0) return true; + const Node &text = Tree::instance().nodes()[run]; + return !text.text.empty() && collapsibleSpace(text.text.str().front()) && wrapsLines(text); + } + + static bool trailingSoftWrap(const Node &item) + { + const int run = edgeTextRun(item, true); + if (run < 0) return true; + const Node &text = Tree::instance().nodes()[run]; + return !text.text.empty() && collapsibleSpace(text.text.str().back()) && wrapsLines(text); + } + + // A collapsible space that opens an atomic text item is removed at the + // start of a line, and collapses into one that ends the text before it. + // The item keeps its box and draws over that space; this is how far it + // moves back. + int collapsedLead(const Node *nodes, int k, bool lineStart) const + { + const Node &item = nodes[children_[k]]; + if (item.type != NodeType::Text || isFragmentableRun(item) || item.text.empty() || + !collapsibleSpace(item.text.str().front()) || !collapsesSpaces(item)) return 0; + if (!lineStart) { + const int before = k > 0 ? edgeTextRun(nodes[children_[k - 1]], true) : -1; + if (before < 0) return 0; + const Node &previous = nodes[before]; + if (previous.text.empty() || !collapsibleSpace(previous.text.str().back()) || !collapsesSpaces(previous)) return 0; + } + return TextRenderer::measureWidth(" ", item.computedStyle().font_id, item.computedStyle().font_size); + } + + // Width of children_[j] and of the items after it that no soft wrap + // opportunity separates from it; a text run that follows adds its first word. + int gluedWidth(const Node *nodes, int j, bool lineStart) const + { + int width = 0; + for (int k = j; k < childCount_; ++k) { + const Node &item = nodes[children_[k]]; + if (k > j && (isLineBreak(item) || trailingSoftWrap(nodes[children_[k - 1]]) || leadingSoftWrap(item))) break; + const int inner = isFragmentableRun(item) ? -1 : transparentInlineRun(item); + if (k > j && (isFragmentableRun(item) || inner >= 0)) { + width += item.computedStyle().margin[3] + TextRenderer::firstUnbreakableWidth(inner >= 0 ? nodes[inner] : item); + break; + } + width += item.layout.width + item.computedStyle().margin[1] + item.computedStyle().margin[3] - collapsedLead(nodes, k, lineStart && k == j); + } + return width; + } + // Distance from ONE of a run's line baselines to the bottom of that line box, // including the run's own bottom padding/margin (which only the last line // actually carries — the others are flush, so this is the taller, safe value). @@ -1387,19 +1478,23 @@ class FlexLayoutPass { const int marginL = cn.computedStyle().margin[3]; const int marginR = cn.computedStyle().margin[1]; const int gap = pen > 0 ? mainGap_ : 0; + int lead = 0; if (cn.layout.inline_indent > 0) { // A wrapped continuation keeps a full-width box at the block's left // edge; its indent is what carries the pen it started from. cn.layout.x = clampInt16(contentLeft + marginL); } else { - cn.layout.x = clampInt16(contentLeft + pen + gap + marginL); + lead = collapsedLead(nodes, k, pen == 0 && ownsLineStart); + cn.layout.x = clampInt16(contentLeft + pen + gap + marginL - lead); + pen -= lead; } pen += gap + marginL + cn.layout.width + marginR; if (soleRun && cn.type == NodeType::Text && !hasExplicitWidth(cn)) { // text-align needs a box as wide as the line box to align inside. - const int full = contentW - marginL - marginR; + // A removed leading space sits before that box's content edge. + const int full = contentW - marginL - marginR + lead; if (full > cn.layout.width) cn.layout.width = clampInt16(full); } @@ -2354,9 +2449,10 @@ bool splitInlineWrapper(const Node *nodes, int id) for (int c = n.first_child; c >= 0; c = nodes[c].next_sibling) { const Node &child = nodes[c]; if (isDisplayNone(child.computedStyle())) continue; - if (isOutOfFlowPosition(child.computedStyle().position)) return false; - if (child.computedStyle().float_side) continue; - if (child.type == NodeType::View && (child.computedStyle().display_explicit || !isInlineLevelTag(tagFromId(child.tag_id)))) containsBlock = true; + if (isOutOfFlowPosition(child.style.position)) return false; + // A float inside an inline belongs to the enclosing block's float context. + if (child.computedStyle().float_side) { containsBlock = true; continue; } + if (child.type == NodeType::View && (child.style.display_explicit || !isInlineLevelTag(tagFromId(child.tag_id)))) containsBlock = true; if (splitInlineWrapper(nodes, c)) containsBlock = true; } return containsBlock; @@ -2587,9 +2683,48 @@ class LayoutNodePass { } }; collect(collect, id_); + deferUnbreakableFloats(children, count); return count; } + // True when a line may end just before children[k]: at a block or line + // start, or after a soft wrap opportunity. Whitespace-only nowrap text + // collapses into its neighbours, so the search continues through it. + bool softWrapBefore(const int *children, int k) const + { + for (int prev = k - 1; prev >= 0; --prev) { + const Node &item = nodes_[children[prev]]; + if (item.computedStyle().float_side) continue; + if (blockBox(item) || isLineBreak(item) || FlexLayoutPass::trailingSoftWrap(item)) return true; + const int run = FlexLayoutPass::edgeTextRun(item, true); + if (nodes_[run].text.find_first_not_of(" \t\r\n\f") != std::string::npos) return false; + } + return true; + } + + // Floats end inline runs in this layout, so a float anchored where the line + // cannot break (inside a word or nowrap text) would split its line. CSS + // places such a float after the line holding its anchor instead: move it, + // with any floats beside it, to the next soft wrap opportunity. + void deferUnbreakableFloats(int *children, int count) const + { + for (int k = 0; k < count; ++k) { + if (!nodes_[children[k]].computedStyle().float_side) continue; + int group = k + 1; + while (group < count && nodes_[children[group]].computedStyle().float_side) ++group; + int target = group; + if (!softWrapBefore(children, k)) { + while (target < count) { + const Node &item = nodes_[children[target]]; + if (item.computedStyle().float_side || blockBox(item) || isLineBreak(item) || FlexLayoutPass::leadingSoftWrap(item)) break; + if (FlexLayoutPass::trailingSoftWrap(nodes_[children[target++]])) break; + } + } + if (target > group) std::rotate(children + k, children + group, children + target); + k = target - 1; + } + } + struct FloatBox { int left, top, right, bottom, side; }; // One exclusion space for the entire BFC, including ordinary wrappers. // Coordinates exclude relative-position offsets. Clearance is used layout From 207872ab43ba4cabe51789538d1dbcc94a8f6be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 01:40:18 +0300 Subject: [PATCH 16/50] Paint the block ellipsis at a line-clamp clamp point block-ellipsis was parsed but never painted. A custom string from line-clamp or block-ellipsis is now interned as a CSS atom in RareStyle padding; an empty string paints nothing, like none. The clamp walk marks the text run whose line immediately precedes the clamp point; a block in between takes the ellipsis away, as in CSS Overflow 4. Phantom content (collapsible spaces, empty inline boxes) neither counts as a line nor introduces a clamp point. DrawText carries the ellipsis and the room up to the end of the line box. The painter drops trailing characters until the ellipsis fits, removes the spaces before it and appends it. The default ellipsis is U+2026 when the font has it and "..." otherwise. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/input.cpp | 4 +- packages/engine/ui/input_render.cpp | 2 + packages/engine/ui/internal.h | 10 +- packages/engine/ui/layout.cpp | 24 +++- packages/engine/ui/node_model.h | 14 ++- packages/engine/ui/render.cpp | 13 ++- packages/engine/ui/style.cpp | 37 ++++--- packages/engine/ui/style.h | 1 + packages/engine/ui/text.cpp | 163 ++++++++++++++++++++++++++-- packages/engine/ui/tree_style.cpp | 2 + 10 files changed, 233 insertions(+), 37 deletions(-) diff --git a/packages/engine/ui/input.cpp b/packages/engine/ui/input.cpp index 26c6d1e..26fa7e7 100644 --- a/packages/engine/ui/input.cpp +++ b/packages/engine/ui/input.cpp @@ -464,7 +464,7 @@ class InputController { int hitTestNodeId(int id, int x, int y, bool clipped = false, bool groupRoot = true, bool includePositioned = true) { Node &node = Tree::instance().nodes()[id]; - if (node.style.display == 1 || node.layout.line_clamp_hidden || isCollapsedFlexSubtree(node)) return -1; + if (node.computedStyle().display == 1 || (node.layout.line_clamp_hidden & 1) || isCollapsedFlexSubtree(node)) return -1; // CSS `pointer-events: none` — this node and its subtree are never the // target of a pointer event, so the hit-test falls through to whatever is // painted behind it. Without this, a decorative overlay image positioned @@ -503,7 +503,7 @@ class InputController { int findScrollNodeId(int id, int x, int y, bool clipped = false, bool groupRoot = true, bool includePositioned = true) { Node &node = Tree::instance().nodes()[id]; - if (node.style.display == 1 || node.layout.line_clamp_hidden || isCollapsedFlexSubtree(node)) return -1; + if (node.computedStyle().display == 1 || (node.layout.line_clamp_hidden & 1) || isCollapsedFlexSubtree(node)) return -1; const bool inside = pointInsideNodeHitArea(node, x, y); if (LayoutEngine::isViewportFixed(node)) clipped = false; diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index 4743535..e878f2d 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -171,6 +171,8 @@ void InputRenderer::record(int id) cmd->text.firstLineIndent = 0; cmd->text.alignLast = -1; cmd->text.lineLimit = 0; + cmd->text.blockEllipsis = 0; + cmd->text.ellipsisWidth = 0; } } diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index a64a2cb..0093e58 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -76,6 +76,9 @@ void rootScrollImageHoldTick(); FillTransformedRoundedRect }; + // DrawText::blockEllipsis for the default ellipsis ("\u2026", or "..."). + constexpr uint16_t kAutoBlockEllipsis = 0xFFFF; + struct DisplayCommand { DisplayCommandType type; @@ -209,6 +212,11 @@ void rootScrollImageHoldTick(); int8_t alignLast; // line-clamp: paint at most this many lines (0 = all). int16_t lineLimit; + // block-ellipsis on the last painted line: 0 = none, + // kAutoBlockEllipsis = the default, else a custom string's CSS atom. + // That line may use ellipsisWidth px from x, up to its line box end. + uint16_t blockEllipsis; + int16_t ellipsisWidth; } text; struct { @@ -607,7 +615,7 @@ void rootScrollImageHoldTick(); int width, uint8_t *outCoverage); static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform = 0, int lineHeight = 0, int whiteSpace = 0, int textOverflow = 0, int maxHeight = 0, - int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0); + int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, int blockEllipsis = 0, int ellipsisWidth = 0); // Single-line width measure for places that don't go through layout() // — used by InputRenderer to position the caret at the end of the value. static int measureWidth(const char *text, int fontId, int fontSize, int textTransform = 0); diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 4c96f5b..77ddce2 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -386,6 +386,7 @@ bool hasExplicitHeight(const Node &node) // inline wrappers and plain block descendants count; independent formatting // contexts are single units. Child coordinates are still parent-relative. int writingMode(const Node &node); +bool emptyInlineBox(const Node &node); struct LineClampWalk { Node *nodes; @@ -394,6 +395,8 @@ struct LineClampWalk { int lines = 0; int lastLineTop = INT_MIN; int keptBottom = 0; + // The text run whose line is the last one kept; -1 when that is an atomic box. + int lastRun = -1; bool clamped = false; }; @@ -436,6 +439,12 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) } if (isDisplayNone(child.computedStyle()) || isOutOfFlowPosition(child.computedStyle().position) || child.computedStyle().float_side || isLineBreak(child) || suppressAnonymousWhitespace(nodes, parent, id)) continue; + const bool inlineWrapper = LayoutEngine::isCssInlineLevelBox(child) && LayoutEngine::isInlineLevelNode(child); + // Phantom line content (collapsible spaces, empty inline boxes) is + // treated as not existing, so it neither counts as a line nor clamps. + const bool collapsible = child.computedStyle().white_space == 0 || child.computedStyle().white_space == 1 || child.computedStyle().white_space == 4; + if (child.type == NodeType::Text ? collapsible && child.text.find_first_not_of(" \t\r\n\f") == std::string::npos + : inlineWrapper && child.first_child < 0 && emptyInlineBox(child)) continue; const int top = originY + child.layout.y; if (child.type == NodeType::Text) { const int advance = TextRenderer::measureHeight("X", child.computedStyle().font_id, child.computedStyle().font_size, 0, child.computedStyle().line_height); @@ -455,13 +464,16 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) walk.lastLineTop = lineTop; } walk.keptBottom = std::max(walk.keptBottom, lineTop + advance); + walk.lastRun = id; } continue; } - const bool inlineWrapper = LayoutEngine::isCssInlineLevelBox(child) && LayoutEngine::isInlineLevelNode(child); const bool plainBlock = child.style.display == kDisplayBlock && !overflowEstablishesContext(child.style) && writingMode(child) == writingMode(nodes[parent]); if (child.first_child >= 0 && (inlineWrapper || plainBlock)) { + // A block between the last line box and the clamp point takes the + // ellipsis away from that line; its own lines may take it instead. + if (!inlineWrapper) walk.lastRun = -1; walkLineClamp(walk, id, top); if (!walk.clamped && !inlineWrapper) walk.keptBottom = std::max(walk.keptBottom, top + child.layout.height); continue; @@ -473,6 +485,7 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) continue; } walk.keptBottom = std::max(walk.keptBottom, bottom); + walk.lastRun = -1; } } @@ -484,6 +497,9 @@ void applyLineClamp(int id) LineClampWalk walk{nodes, rstyle(node.computedStyle()).max_lines, node.layout.height - boxInset(node.computedStyle(), 2)}; walk.keptBottom = boxInset(node.computedStyle(), 0); walkLineClamp(walk, id, 0); + // The block ellipsis ends the last line before the clamp point. + if (walk.clamped && (rstyle(node.computedStyle()).line_clamp_flags & 2) && walk.lastRun >= 0) + nodes[walk.lastRun].layout.line_clamp_hidden |= 2; if (!walk.clamped || hasExplicitHeight(node)) return; const int height = clampBorderBoxSize(node.computedStyle(), walk.keptBottom + boxInset(node.computedStyle(), 2), false); if (height < node.layout.height) node.layout.height = clampInt16(height); @@ -2435,6 +2451,12 @@ static bool isInlineLevelTag(const char *tag) std::strcmp(tag, "mark") == 0; } +// An inline box with nothing to show: no insets and no visible box. +bool emptyInlineBox(const Node &node) +{ + return !boxInsets(node.computedStyle(), true) && !boxInsets(node.computedStyle(), false) && FlexLayoutPass::paintsNoBox(node); +} + // Box-tree projection for inline ancestors split by in-flow blocks. The DOM // ancestry still owns inheritance, events, relative translation, and painting. // Visible inline decorations require fragments; keep those on their own path. diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 695a34b..07ceb73 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -168,6 +168,9 @@ struct RareStyle { #else static constexpr uint8_t margin_trim = 0; #endif + // CSS atom of a custom block-ellipsis string; 0 = the default ellipsis. + // Only used when line_clamp_flags has block-ellipsis set. Sits in padding. + uint16_t block_ellipsis = 0; // IEEE float bits travel through the existing integer style-value transport. // Negative ratios mean `auto ` (prefer a replaced element's natural ratio). #if GEA_CSS_ASPECT_RATIO @@ -1404,12 +1407,19 @@ struct LayoutBox { #if !GEA_EMBEDDED_SHARED_STYLES int16_t static_block_start = 0; #endif - // line-clamp: 1 when the box lies after an ancestor's clamp point, which - // hides it. Like line_clamp_lines below it sits in padding. + // line-clamp: bit 0 when the box lies after an ancestor's clamp point, which + // hides it; bit 1 on the text run whose last painted line ends before the + // clamp point and carries the block ellipsis. Like line_clamp_lines below + // it sits in padding. +#if GEA_CSS_LINE_CLAMP uint8_t line_clamp_hidden = 0; // 1 on a container whose last layout applied a line clamp, so a layout that // no longer clamps clears the state left on its descendants. Sits in padding. uint8_t line_clamp_owner = 0; +#else + static constexpr uint8_t line_clamp_hidden = 0; + static constexpr uint8_t line_clamp_owner = 0; +#endif int16_t previous_x, previous_y; int16_t previous_width, previous_height; diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 5e547ff..f961030 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6855,7 +6855,8 @@ int gLastScrollUiFrame = -1000; c.text.maxWidth, c.text.color, c.text.scale, c.text.align, c.text.containerWidth, c.text.fontId, c.text.textTransform, c.text.lineHeight, c.text.whiteSpace, c.text.textOverflow, c.text.maxHeight, c.text.firstLineIndent, - c.text.alignLast, c.text.lineLimit); + c.text.alignLast, c.text.lineLimit, + c.text.blockEllipsis, c.text.ellipsisWidth); break; case DisplayCommandType::BlitImage: #if GEA_PIXEL_STORAGE_PACKED @@ -7141,7 +7142,7 @@ int gLastScrollUiFrame = -1000; !n->text.empty() && n->computedStyle().text_alpha == 255 && n->layout.height >= std::max(16, static_cast(n->computedStyle().font_size)) && n->layout.height >= n->computedStyle().line_height; - if (n->style.display == 1 || n->layout.line_clamp_hidden || isCollapsedFlexSubtree(*n) || ViewRenderer::backfaceSubtreeHidden(*n)) + if (n->computedStyle().display == 1 || (n->layout.line_clamp_hidden & 1) || isCollapsedFlexSubtree(*n) || ViewRenderer::backfaceSubtreeHidden(*n)) { state.clearNodeRange(id); return; @@ -12978,10 +12979,10 @@ int gLastScrollUiFrame = -1000; // an empty range, which `old_len == 0 && tmp_len > 0` below (correctly) refuses // to splice — and the refusal cost the whole frame its retained display list. const bool outsideRecordClip = !isDocumentCanvasRoot(*n) && nodeOutsideRecordClip(node, tree.mountedWidth(), tree.mountedHeight()); - if (!outsideRecordClip && n->style.visibility == 0 && !n->layout.line_clamp_hidden && !isCollapsedFlexSubtree(*n) && !ViewRenderer::backfaceSubtreeHidden(*n) && - n->style.display != 1 && - n->style.opacity != 0 && - !(n->style.blink_interval_ms > 0 && !n->style.blink_visible) && + if (!outsideRecordClip && n->computedStyle().visibility == 0 && !(n->layout.line_clamp_hidden & 1) && !isCollapsedFlexSubtree(*n) && !ViewRenderer::backfaceSubtreeHidden(*n) && + n->computedStyle().display != 1 && + n->computedStyle().opacity != 0 && + !(n->computedStyle().blink_interval_ms > 0 && !n->computedStyle().blink_visible) && !isNativeTextInputView(*n)) { ViewRenderer::recordBox(*n); diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 7b5a203..71de38b 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -405,6 +405,7 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) ar.margin_trim != br.margin_trim || ar.max_lines != br.max_lines || ar.line_clamp_flags != br.line_clamp_flags || + ar.block_ellipsis != br.block_ellipsis || a.writing_mode != b.writing_mode || a.direction != b.direction || a.row_gap != b.row_gap || @@ -634,6 +635,7 @@ bool styleEqualExceptLocalDisplayCommands(const ComputedStyle &a, const Computed ar.margin_trim != br.margin_trim || ar.max_lines != br.max_lines || ar.line_clamp_flags != br.line_clamp_flags || + ar.block_ellipsis != br.block_ellipsis || a.writing_mode != b.writing_mode || a.direction != b.direction || a.row_gap != b.row_gap || @@ -6943,23 +6945,28 @@ ParsedCssColor backgroundBaseColor(const std::string &value) const CssCompiledBackground *compiledCssBackgroundForHandle(std::uint16_t handle); bool backgroundImageIsValid(int handle, int nodeId); -// line-clamp (CSS Overflow 4) and its longhands as MaxLines, LineClampContinue -// and BlockEllipsis writes. A custom ellipsis string counts as an ellipsis. +// line-clamp (CSS Overflow 4) and its longhands as MaxLines, LineClampContinue, +// BlockEllipsis and BlockEllipsisString writes. A custom ellipsis string is +// interned as a CSS atom; an empty one paints nothing, like none. // Returns the write count, 0 for an invalid value, -1 for other declarations. -int lineClampWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[3]) +int lineClampWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[5]) { const bool shorthand = declaration == CssDeclarationId::LineClamp || declaration == CssDeclarationId::WebkitLineClamp; if (!shorthand && declaration != CssDeclarationId::MaxLines && declaration != CssDeclarationId::Continue && declaration != CssDeclarationId::BlockEllipsis) return -1; - std::string lower = toLowerAscii(trimCssValue(value)); + const std::string trimmed = trimCssValue(value); + std::string lower = toLowerAscii(trimmed); bool quoted = false; + std::string custom; const auto quote = lower.find_first_of("\"'"); if (quote != std::string::npos) { const auto close = lower.find(lower[quote], quote + 1); if (close == std::string::npos) return 0; + custom = trimmed.substr(quote + 1, close - quote - 1); lower.erase(quote, close - quote + 1); quoted = true; } + const int customAtom = custom.empty() ? 0 : internCssAtom(custom); auto lineCount = [](const std::string &word, int &n) { if (word.empty() || word.size() > 6 || word.find_first_not_of("0123456789") != std::string::npos) return false; n = std::min(255, std::atoi(word.c_str())); @@ -6980,8 +6987,9 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat if (declaration == CssDeclarationId::BlockEllipsis) { const bool keyword = !quoted && words.size() == 1 && (words[0] == "none" || words[0] == "auto"); if (!keyword && !(quoted && words.empty())) return 0; - out[0] = {Property::BlockEllipsis, quoted || words[0] == "auto"}; - return 1; + out[0] = {Property::BlockEllipsis, quoted ? customAtom != 0 : words[0] == "auto"}; + out[1] = {Property::BlockEllipsisString, customAtom}; + return 2; } int lines = 0; bool automatic = false, ellipsis = true; @@ -6989,7 +6997,8 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat out[0] = {Property::MaxLines, 0}; out[1] = {Property::LineClampContinue, 0}; out[2] = {Property::BlockEllipsis, 0}; - return 3; + out[3] = {Property::BlockEllipsisString, 0}; + return 4; } for (const auto &word : words) { if (lineCount(word, lines)) continue; @@ -7002,8 +7011,9 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat // -webkit-box. Gea has no such display, so it never collapses here. out[0] = {Property::MaxLines, lines}; out[1] = {Property::LineClampContinue, declaration == CssDeclarationId::LineClamp}; - out[2] = {Property::BlockEllipsis, ellipsis}; - return 3; + out[2] = {Property::BlockEllipsis, ellipsis && (!quoted || customAtom != 0)}; + out[3] = {Property::BlockEllipsisString, customAtom}; + return 4; } // Only whether columns are set matters: line-clamp skips multicol containers. @@ -7520,7 +7530,7 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa return storeCompiledCssValue(compiled); } - StaticStylePropertyValue clampWrites[3]; + StaticStylePropertyValue clampWrites[4]; int clampCount = hasVar ? -1 : lineClampWrites(declaration, value, clampWrites); if (clampCount < 0 && !hasVar) clampCount = multicolWrites(declaration, value, clampWrites); if (const int count = clampCount; count >= 0) { @@ -7987,6 +7997,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::MarginTrim: rstyleMut(style).margin_trim = value; return true; #endif case Property::MaxLines: rstyleMut(style).max_lines = static_cast(value); return true; + case Property::BlockEllipsisString: rstyleMut(style).block_ellipsis = static_cast(value); return true; case Property::LineClampContinue: case Property::BlockEllipsis: case Property::ColumnCountSet: @@ -9849,7 +9860,7 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::Columns: case CssDeclarationId::ColumnCount: case CssDeclarationId::ColumnWidth: { - StaticStylePropertyValue writes[3]; + StaticStylePropertyValue writes[4]; int count = lineClampWrites(declaration, value, writes); if (count < 0) count = multicolWrites(declaration, value, writes); for (int i = 0; i < count; ++i) setStyleValue(node, writes[i].property, writes[i].value, source); @@ -11855,10 +11866,10 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "float") return removeInlineStyleProperties(id, {Property::Float}); if (property == "margin-trim") return removeInlineStyleProperties(id, {Property::MarginTrim}); if (property == "line-clamp" || property == "-webkit-line-clamp") - return removeInlineStyleProperties(id, {Property::MaxLines, Property::LineClampContinue, Property::BlockEllipsis}); + return removeInlineStyleProperties(id, {Property::MaxLines, Property::LineClampContinue, Property::BlockEllipsis, Property::BlockEllipsisString}); if (property == "max-lines") return removeInlineStyleProperties(id, {Property::MaxLines}); if (property == "continue") return removeInlineStyleProperties(id, {Property::LineClampContinue}); - if (property == "block-ellipsis") return removeInlineStyleProperties(id, {Property::BlockEllipsis}); + if (property == "block-ellipsis") return removeInlineStyleProperties(id, {Property::BlockEllipsis, Property::BlockEllipsisString}); if (property == "columns") return removeInlineStyleProperties(id, {Property::ColumnCountSet, Property::ColumnWidthSet}); if (property == "column-count") return removeInlineStyleProperties(id, {Property::ColumnCountSet}); if (property == "column-width") return removeInlineStyleProperties(id, {Property::ColumnWidthSet}); diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 3f5e934..7680423 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -219,6 +219,7 @@ enum class Property : int { ColumnCountSet, ColumnWidthSet, VerticalAlign, + BlockEllipsisString, Count }; diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index 11a6875..2dbf851 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -4,6 +4,7 @@ #include "graphics/font.h" #include "refresh_perf.h" #include "tree_state.h" +#include "css_atom.h" #include #include @@ -1091,7 +1092,8 @@ class TextDrawer { command.text.color, command.text.scale, command.text.align, command.text.containerWidth, command.text.fontId, command.text.textTransform, command.text.lineHeight, command.text.whiteSpace, command.text.textOverflow, command.text.maxHeight, - command.text.firstLineIndent, command.text.alignLast, command.text.lineLimit, &sink); + command.text.firstLineIndent, command.text.alignLast, command.text.lineLimit, + command.text.blockEllipsis, command.text.ellipsisWidth, &sink); } #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS @@ -1145,13 +1147,17 @@ class TextDrawer { } } - static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, TextCoverageSink *coverageSink = nullptr) + static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, int blockEllipsis = 0, int ellipsisWidth = 0, TextCoverageSink *coverageSink = nullptr) { if (!text || !text[0]) return; std::string transformed; text = textWithTransform(text, textTransform, transformed, whiteSpace); // Pre preserves forced breaks and paints every line without soft wrap. if (whiteSpace == 2) maxWidth = 32767; + const BlockEllipsis ellipsis{blockEllipsis == kAutoBlockEllipsis ? "\xE2\x80\xA6" : blockEllipsis ? cssAtomText(static_cast(blockEllipsis)) : nullptr, + blockEllipsis == kAutoBlockEllipsis, ellipsisWidth}; + // The ellipsis line goes through the wrapped painters; nowrap text is one line. + if (ellipsis.text && whiteSpace == 1) maxWidth = 32767; int clipX0; int clipY0; @@ -1168,8 +1174,8 @@ class TextDrawer { coverageSink = &clippedSink; } - const bool noWrap = whiteSpace == 1; - const bool ellipsis = textOverflow == 1; + const bool noWrap = whiteSpace == 1 && !ellipsis.text; + const bool overflowEllipsis = textOverflow == 1; // A nowrap run is one line, so only a paragraph end can realign it. const int singleLineAlign = alignLast >= 0 && (alignLast & 4) ? alignLast & 3 : textAlign; const bool hangSpaces = whiteSpace == 0 || whiteSpace == 4; @@ -1179,10 +1185,10 @@ class TextDrawer { const int fontSize = static_cast(scale * kBitmapFontHeight + 0.5f); if (gea::framework::graphics::FontRegistry::rasterizedFamily(fontId, fontSize).valid()) { if (noWrap) - drawRasterizedSingleLine(text, x, y, maxWidth, color, singleLineAlign, containerWidth, fontId, fontSize, lineHeight, ellipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); + drawRasterizedSingleLine(text, x, y, maxWidth, color, singleLineAlign, containerWidth, fontId, fontSize, lineHeight, overflowEllipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); else drawRasterizedWrapped(text, x, y, maxWidth, color, textAlign, containerWidth, fontId, fontSize, lineHeight, clipX0, clipY0, clipX1, clipY1, - ellipsis ? maxHeight : 0, firstLineIndent, alignLast, hangSpaces, lineLimit, coverageSink); + overflowEllipsis ? maxHeight : 0, firstLineIndent, alignLast, hangSpaces, lineLimit, ellipsis, coverageSink); return; } } @@ -1190,14 +1196,74 @@ class TextDrawer { (void)fontId; #endif if (noWrap) - drawBitmapSingleLine(text, x, y, maxWidth, color, scale, singleLineAlign, containerWidth, lineHeight, ellipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); + drawBitmapSingleLine(text, x, y, maxWidth, color, scale, singleLineAlign, containerWidth, lineHeight, overflowEllipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); else - drawBitmapWrapped(text, x, y, maxWidth, color, scale, textAlign, containerWidth, lineHeight, clipX0, clipY0, clipX1, clipY1, firstLineIndent, alignLast, hangSpaces, lineLimit, coverageSink); + drawBitmapWrapped(text, x, y, maxWidth, color, scale, textAlign, containerWidth, lineHeight, clipX0, clipY0, clipX1, clipY1, firstLineIndent, alignLast, hangSpaces, lineLimit, ellipsis, coverageSink); } private: + // CSS block-ellipsis for the last painted line of a clamped block: the + // string, whether it is the default one, and the room from the run's x to + // the end of its line box. + struct BlockEllipsis { + const char *text; + bool automatic; + int width; + }; + + // Keeps the leading glyphs of a line that leave room for the ellipsis, + // drops the spaces that would end them, then appends it. Returns the width + // of what it wrote to `out` (NUL-terminated). + template + static int ellipsizedLine(const char *line, int bytes, int budget, const char *ellipsis, const Advance &advanceOf, + char *out, int capacity) + { + int ellipsisWidth = 0; + for (const char *p = ellipsis; *p;) ellipsisWidth += advanceOf(nextUtf8Codepoint(p)); + const int ellipsisBytes = static_cast(std::strlen(ellipsis)); + int len = 0, width = 0; + int keptLen = 0, keptWidth = 0; + for (const char *p = line, *end = line + bytes; p < end;) { + const char *glyphStart = p; + const int cp = nextUtf8Codepoint(p); + const int advance = advanceOf(cp); + const int glyphBytes = static_cast(p - glyphStart); + if (width + advance + ellipsisWidth > budget || len + glyphBytes + ellipsisBytes >= capacity) break; + std::memcpy(out + len, glyphStart, glyphBytes); + len += glyphBytes; + width += advance; + if (cp != ' ') { keptLen = len; keptWidth = width; } + } + if (keptLen + ellipsisBytes < capacity) { + std::memcpy(out + keptLen, ellipsis, ellipsisBytes); + keptLen += ellipsisBytes; + keptWidth += ellipsisWidth; + } + out[keptLen] = '\0'; + return keptWidth; + } + #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS - static void drawRasterizedWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, int textAlign, int containerWidth, int fontId, int fontSize, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int ellipsisMaxHeight = 0, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, int lineLimit = 0, const TextCoverageSink *coverageSink = nullptr) + static void drawRasterizedEllipsis(const char *line, int bytes, int x, int lineBoxY, int budget, int align, const BlockEllipsis &ellipsis, + std::uint16_t color, int fontId, int fontSize, const gea::framework::graphics::RasterizedFont &font, + const TextCoverageSink *coverageSink) + { + const auto advanceOf = [&font](int cp) { + gea::framework::graphics::Glyph glyph{}; + return font.glyph(cp, &glyph) ? glyph.advance : (font.sizePx() / 2); + }; + // A font without U+2026 substitutes '?'; the default is then "...". + gea::framework::graphics::Glyph glyph{}; + const bool horizontalEllipsis = font.glyph(0x2026, &glyph) && glyph.codepoint == 0x2026; + const char *text = ellipsis.automatic && !horizontalEllipsis ? "..." : ellipsis.text; + char buf[kWrappedLineBufferBytes + 1]; + const int width = ellipsizedLine(line, bytes, budget, text, advanceOf, buf, static_cast(sizeof(buf))); + const int lineX = x + alignedOffset(align, budget, width); + if (coverageSink) rasterizedGlyphRun(buf, lineX, lineBoxY, fontId, fontSize, *coverageSink); + else gea::platform::display::Display::drawTextFontFamily(buf, lineX, lineBoxY, color, fontId, fontSize); + } + + static void drawRasterizedWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, int textAlign, int containerWidth, int fontId, int fontSize, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int ellipsisMaxHeight = 0, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, int lineLimit = 0, const BlockEllipsis &ellipsis = {}, const TextCoverageSink *coverageSink = nullptr) { // Inline continuation: the run's first line starts `firstLineIndent` px in // from the box's left edge (the pen position it inherited from the box @@ -1271,8 +1337,16 @@ class TextDrawer { if (entry.textPtr == text) { int penY = y; const char *lineStart = text; + const int lastLine = (lineLimit > 0 && lineLimit < entry.lineCount ? lineLimit : entry.lineCount) - 1; for (int li = 0; li < entry.lineCount; li++) { if (penY + lineBoxOffset > clipY1 || (lineLimit > 0 && li >= lineLimit)) break; + if (ellipsis.text && li == lastLine) { + if (penY + lineBoxOffset + glyphHeight - 1 >= clipY0) + drawRasterizedEllipsis(lineStart, entry.renderBytes[li], lineOriginX_(li), penY + lineBoxOffset, + ellipsis.width - (li == 0 ? firstLineIndent : 0), textAlign, ellipsis, + color, fontId, fontSize, font, coverageSink); + return; + } if (li == lineBudget - 1 && li < entry.lineCount - 1) { // Last budgeted line with more text behind it: ellipsize the rest. drawRasterizedSingleLine(lineStart, lineOriginX_(li), penY, lineBudget_(li), color, textAlign, containerWidth, @@ -1322,6 +1396,13 @@ class TextDrawer { [&](int cp, int nextCp) { return font.advance(cp, nextCp); }); const int lineBoxY = penY + lineBoxOffset; + if (ellipsis.text && (li == lineLimit || !lineStart[line.consumedBytes])) { + if (lineBoxY + glyphHeight - 1 >= clipY0) + drawRasterizedEllipsis(lineStart, line.renderBytes, lineOriginX_(lineIndex), lineBoxY, + ellipsis.width - (lineIndex == 0 ? firstLineIndent : 0), textAlign, ellipsis, + color, fontId, fontSize, font, coverageSink); + return; + } if (lineBoxY + glyphHeight - 1 >= clipY0) { const int align = lineAlignment(textAlign, alignLast, lineStart, line.consumedBytes, !lineStart[line.consumedBytes]); const int hanging = hangSpaces && align ? hangingSpaceWidth(lineStart, line.renderBytes, spaceAdvance) : 0; @@ -1433,7 +1514,26 @@ class TextDrawer { } #endif - static void drawBitmapWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, float scale, int textAlign, int containerWidth, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, int lineLimit = 0, const TextCoverageSink *coverageSink = nullptr) + static void drawBitmapEllipsis(const char *line, int bytes, int x, int y, int budget, int align, const BlockEllipsis &ellipsis, + std::uint16_t color, float scale, int glyphWidth, const TextCoverageSink *coverageSink) + { + char buf[kWrappedLineBufferBytes + 1]; + // The bitmap font has no U+2026. + const int width = ellipsizedLine(line, bytes, budget, ellipsis.automatic ? "..." : ellipsis.text, + [glyphWidth](int) { return glyphWidth; }, buf, static_cast(sizeof(buf))); + int penX = x + alignedOffset(align, budget, width); + for (const char *glyph = buf; *glyph;) { + const char *glyphStart = glyph; + nextUtf8Codepoint(glyph); + char one[5] = {}; + std::memcpy(one, glyphStart, static_cast(glyph - glyphStart)); + if (coverageSink) bitmapGlyphRun(one, penX, y, scale, *coverageSink); + else gea::platform::display::Display::drawText(one, penX, y, color, scale); + penX += glyphWidth; + } + } + + static void drawBitmapWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, float scale, int textAlign, int containerWidth, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, int lineLimit = 0, const BlockEllipsis &ellipsis = {}, const TextCoverageSink *coverageSink = nullptr) { if (scale < 0.1f) scale = 1.0f; int glyphWidth = static_cast(kBitmapFontWidth * scale + 0.5f); @@ -1456,6 +1556,12 @@ class TextDrawer { const WrappedLine line = nextWrappedLine(lineStart, budget < 1 ? 1 : budget, [&](int, int) { return glyphWidth; }); lineIndex++; + if (ellipsis.text && (lineIndex == lineLimit || !lineStart[line.consumedBytes])) { + if (penY + glyphHeight - 1 >= clipY0) + drawBitmapEllipsis(lineStart, line.renderBytes, lineOriginX, penY, ellipsis.width - (lineIndex == 1 ? firstLineIndent : 0), + textAlign, ellipsis, color, scale, glyphWidth, coverageSink); + return; + } if (penY + glyphHeight - 1 >= clipY0) { const int align = lineAlignment(textAlign, alignLast, lineStart, line.consumedBytes, !lineStart[line.consumedBytes]); @@ -1985,9 +2091,9 @@ int TextRenderer::reflowToBoxWidth(int id) return measured.width; } -void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent, int alignLast, int lineLimit) +void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent, int alignLast, int lineLimit, int blockEllipsis, int ellipsisWidth) { - TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent, alignLast, lineLimit); + TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent, alignLast, lineLimit, blockEllipsis, ellipsisWidth); } void TextRenderer::unionCoverageRow(const DisplayCommand &command, int screenY, int screenX, @@ -2016,6 +2122,23 @@ int TextRenderer::measureHeight(const char *text, int fontId, int fontSize, int return measured.height; } +#if GEA_CSS_LINE_CLAMP +// DrawText::blockEllipsis for a run whose last painted line ends a clamped +// block, and the room from its text origin `textX` to that line box's end. +static uint16_t blockEllipsisCommand(const Node &run, int textX, int &width) +{ + const Tree &tree = Tree::instance(); + for (int ancestor = run.parent; ancestor >= 0; ancestor = tree.node(ancestor).parent) { + const Node &box = tree.node(ancestor); + if (!(rstyle(box.computedStyle()).line_clamp_flags & 1)) continue; + const int right = box.layout.x + box.layout.width - boxInset(box.computedStyle(), 1); + width = std::max(0, std::min(32767, right - textX)); + return rstyle(box.computedStyle()).block_ellipsis ? rstyle(box.computedStyle()).block_ellipsis : kAutoBlockEllipsis; + } + return 0; +} +#endif + // DrawText::alignLast for a run: -1 unless text-align-last moves some line. static int8_t textAlignLastCommand(const Node &node) { @@ -2365,6 +2488,20 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph bw = right - bx; } + int ellipsisWidth = 0; +#if GEA_CSS_LINE_CLAMP + const uint16_t blockEllipsis = !transformed && (n->layout.line_clamp_hidden & 2) + ? blockEllipsisCommand(*n, n->layout.x + boxInset(n->computedStyle(), 3), ellipsisWidth) : 0; +#else + const uint16_t blockEllipsis = 0; +#endif + // The ellipsis may reach past the run's own box to the end of its line. + if (blockEllipsis) { + const int right = std::max(bx + bw, drawX + ellipsisWidth + 2); + bx = std::min(bx, drawX - 2); + bw = right - bx; + } + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, n->computedStyle().text_alpha); if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, bx, by, bw, bh); @@ -2390,6 +2527,8 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.firstLineIndent = static_cast(inlineIndent); cmd->text.alignLast = alignLast; cmd->text.lineLimit = n->layout.line_clamp_lines; + cmd->text.blockEllipsis = blockEllipsis; + cmd->text.ellipsisWidth = static_cast(ellipsisWidth); { const int contentH = h - boxInset(n->computedStyle(), 0) - boxInset(n->computedStyle(), 2); cmd->text.maxHeight = static_cast(contentH > 0 ? (contentH > 32767 ? 32767 : contentH) : 0); diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 55be664..5c30448 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -92,6 +92,7 @@ bool isLayoutProperty(Property prop) case Property::BlockEllipsis: case Property::ColumnCountSet: case Property::ColumnWidthSet: + case Property::BlockEllipsisString: case Property::VerticalAlign: #if GEA_CSS_TEXT_ALIGN // Line shifts and inline static positions follow physicalTextAlign. @@ -362,6 +363,7 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::MarginTrim: if ((GEA_CSS_MARGIN_TRIM ? rstyle(style).margin_trim : 0) != value) { rstyleMut(style).margin_trim = value; changed = 1; } break; #endif case Property::MaxLines: if (rstyle(n->style).max_lines != value) { rstyleMut(n->style).max_lines = value; changed = 1; } break; + case Property::BlockEllipsisString: if (rstyle(n->style).block_ellipsis != value) { rstyleMut(n->style).block_ellipsis = value; changed = 1; } break; case Property::LineClampContinue: case Property::BlockEllipsis: case Property::ColumnCountSet: From 19a37c8de8a8a648c285117b27da0ffca14ae674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 01:59:53 +0300 Subject: [PATCH 17/50] Paint transformed borders and clip overflow to rounded shapes A uniform border on a rotated or scaled box was not painted at all unless the box was a full ellipse. FillTransformedRoundedRect gains a ring mode: the rasterizer paints inside the border box and outside the padding box, whose CSS inner radii it derives from the border widths. A ring never joins the uniform rounded-rect span batch, whose rasterizers do not know about the hole. Overflow clips now carry their shape: the padding box with its inner radii, and for a transformed box the screen parallelogram, which is also clipped now (perspective projections stay unclipped as before). Display clips are rectangles, so replay saves the pixels the shape's bounds may expose (only the four corner boxes of an untransformed box), lets the clipped content paint, and restores every pixel outside the shape, blending partly covered edges. The stream replay, the node-range dirty-region replays and the simple dirty-region replay all do this, and record-time culling uses the same clip bounds. The cost is bounded: - A shaped clip stays open across consecutive nodes under it inside a dirty-region pass (ShapedClips::PassScope / reconcile), so the corners are saved and restored once per clip per region, not once per node. - The stream replay saves only inside the union of the draw bounds between a PushClip and its PopClip (paint order wraps each row of a scroll container in its own scope of the container's clip; most of them paint nothing near a corner), opens no entry for a scope that paints nothing, and computes the inverse matrix only when there is something to save. - Saved boxes are cut to the dirty region; an untransformed corner is restored through a cached coverage mask that samples exactly as the per-pixel path does (bit-identical on 349 million corner pixels), by per-row spans: a memcpy for the uncovered run, a blend for the antialiased band, nothing for the covered run. - A clip whose exposed area exceeds kMaxSavedPixels stays a plain rectangle instead of saving a screenful; overflowClipShape answers an untransformed box without radii before computing borders and radii. - In the simple dirty-region replay the list's distinct shaped clips (up to four) are cached against the list serial; the corner boxes in each region are first filled with the mount-time clear colour, and a clip stays open across the nodes under it. - Each render core has its own clip stacks for the parallel band replay. GEA_CSS_SHAPED_CLIPS (default on) compiles the shape fields and the save/restore machinery out; off, every overflow clip is a rectangle and a transformed box does not clip, as before. The border-ring painting for transformed boxes is unaffected by the gate. No struct changes size; CornerMask holds two bytes of spans per row, at most four masks of 64 rows, in apps with shaped clips only. Desktop harness: rounded-scroll (a full-screen scroll container with border-radius: 24px over 96 rows) about 260 us a frame where saving every scope's corners took 8.6 ms (on this base the scene repaints at its mount position, as on main before e8c197e); rounded-update-96 (rows changing class under a full-screen rounded clip, the simple dirty-region path) 44 us. Not measured on the board yet. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/css_features.h | 8 + packages/engine/ui/internal.h | 29 + packages/engine/ui/render.cpp | 939 ++++++++++++++++++++++++++++-- packages/engine/ui/view.cpp | 139 ++++- 4 files changed, 1070 insertions(+), 45 deletions(-) diff --git a/packages/engine/ui/css_features.h b/packages/engine/ui/css_features.h index 992867a..2d6b3e7 100644 --- a/packages/engine/ui/css_features.h +++ b/packages/engine/ui/css_features.h @@ -235,6 +235,14 @@ #define GEA_CSS_RADIUS_INDEX(index) 0 #endif +// Shaped overflow clips: a rounded or transformed `overflow: hidden` box clips +// its content to the padding box's inner radii, or to its screen parallelogram. +// Off, every overflow clip is a rectangle and a transformed box does not clip, +// and replay carries none of the save/restore machinery. +#ifndef GEA_CSS_SHAPED_CLIPS +#define GEA_CSS_SHAPED_CLIPS 1 +#endif + #ifndef GEA_CSS_FIRST_LINE #define GEA_CSS_FIRST_LINE 1 #endif diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 0093e58..f4f6523 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -96,6 +96,16 @@ void rootScrollImageHoldTick(); // range. Keep the owner so retained subtree translations can move the // scope with the node (and undo the scroll viewport's own movement). int16_t nodeId; + // A rounded or transformed overflow clip: the padding box lx/ly/lw/lh, + // with its CSS inner radii, mapped to the screen quad qx/qy. The rect + // above is its bounds; replay restores what the clipped content paints + // outside the shape. 0 for a plain rect clip. + uint8_t shaped; +#if GEA_CSS_SHAPED_CLIPS + int16_t qx[4], qy[4]; + int16_t lx, ly, lw, lh; + int16_t rx8[4], ry8[4]; +#endif } clip; struct { @@ -272,6 +282,10 @@ void rootScrollImageHoldTick(); int16_t blRx8, blRy8; gea::framework::graphics::pixel::native_t color; uint8_t backfaceHidden; + // Border widths (top, right, bottom, left) when this paints a border + // ring: the padding box, with its CSS inner radii, stays unpainted. + // All zero for a fill. + uint8_t ring[4]; } transformedRoundedRect; struct { @@ -543,9 +557,24 @@ void rootScrollImageHoldTick(); static std::vector collectChildren(int node, bool groupRoot = true, bool includePositioned = true); }; + // The overflow clip of a node: screen bounds, and for a rounded or + // transformed clip the shape PushClip carries (see DisplayCommand::clip). + struct OverflowClipShape + { + int x, y, w, h; + bool shaped; +#if GEA_CSS_SHAPED_CLIPS + int16_t qx[4], qy[4]; + int16_t lx, ly, lw, lh; + int16_t rx8[4], ry8[4]; +#endif + }; + class ViewRenderer { public: + // False when the clip cannot be recorded: a perspective-projected box. + static bool overflowClipShape(const Node &node, OverflowClipShape &out); static bool recordClipBegin(const Node &node); static void recordClipEnd(const Node &node); static void recordBox(const Node &node, uint8_t parentAlpha = 255); diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index f961030..3fb2b29 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -1530,6 +1530,33 @@ int gLastScrollUiFrame = -1000; using TransformedRoundedRectCommand = decltype(DisplayCommand{}.transformedRoundedRect); + bool transformedRoundedRectIsRing(const TransformedRoundedRectCommand &r) + { + return (r.ring[0] | r.ring[1] | r.ring[2] | r.ring[3]) != 0; + } + + // The unpainted inside of a border ring: the padding box, whose radii + // are the outer radii less the adjoining border widths (CSS Backgrounds 5.2). + TransformedRoundedRectCommand transformedRoundedRectHole(const TransformedRoundedRectCommand &r) + { + TransformedRoundedRectCommand hole = r; + hole.ring[0] = hole.ring[1] = hole.ring[2] = hole.ring[3] = 0; + hole.lx = static_cast(r.lx + r.ring[3]); + hole.ly = static_cast(r.ly + r.ring[0]); + hole.lw = static_cast(std::max(0, r.lw - r.ring[1] - r.ring[3])); + hole.lh = static_cast(std::max(0, r.lh - r.ring[0] - r.ring[2])); + hole.tlRx8 = static_cast(std::max(0, r.tlRx8 - 8 * r.ring[3])); + hole.tlRy8 = static_cast(std::max(0, r.tlRy8 - 8 * r.ring[0])); + hole.trRx8 = static_cast(std::max(0, r.trRx8 - 8 * r.ring[1])); + hole.trRy8 = static_cast(std::max(0, r.trRy8 - 8 * r.ring[0])); + hole.brRx8 = static_cast(std::max(0, r.brRx8 - 8 * r.ring[1])); + hole.brRy8 = static_cast(std::max(0, r.brRy8 - 8 * r.ring[2])); + hole.blRx8 = static_cast(std::max(0, r.blRx8 - 8 * r.ring[3])); + hole.blRy8 = static_cast(std::max(0, r.blRy8 - 8 * r.ring[2])); + return hole; + } + + struct RoundedRectScreenSpan { int x; @@ -1691,7 +1718,7 @@ int gLastScrollUiFrame = -1000; { const auto &r = command.transformedRoundedRect; RoundedRectScreenSpan span{}; - if (!transformedRoundedRectToScreenSpan(r, &span)) + if (transformedRoundedRectIsRing(r) || !transformedRoundedRectToScreenSpan(r, &span)) return false; if (!roundedRectScreenSpanFitsCanvas(span)) return false; @@ -2006,7 +2033,7 @@ int gLastScrollUiFrame = -1000; pixel = gea::framework::graphics::pixel::blendNative(color, pixel, effectiveAlpha); } - bool roundedRectContainsFast(const TransformedRoundedRectCommand &r, float sx, float sy) + bool roundedRectOuterContainsFast(const TransformedRoundedRectCommand &r, float sx, float sy) { const float left = static_cast(r.lx); const float top = static_cast(r.ly); @@ -2037,6 +2064,13 @@ int gLastScrollUiFrame = -1000; return true; } + bool roundedRectContainsFast(const TransformedRoundedRectCommand &r, float sx, float sy) + { + if (!roundedRectOuterContainsFast(r, sx, sy)) + return false; + return !transformedRoundedRectIsRing(r) || !roundedRectOuterContainsFast(transformedRoundedRectHole(r), sx, sy); + } + int roundedRectCoverageFast(const TransformedRoundedRectCommand &r, float localX, float localY, @@ -2071,6 +2105,8 @@ int gLastScrollUiFrame = -1000; float marginX, float marginY) { + if (transformedRoundedRectIsRing(r)) + return false; const float left = static_cast(r.lx); const float top = static_cast(r.ly); const float right = left + static_cast(r.lw); @@ -2113,6 +2149,8 @@ int gLastScrollUiFrame = -1000; int roundedRectCoverageHomography(const TransformedRoundedRectCommand &r, const double *homography, int x, int y, int samples) { const float kernelWidth = transformedRoundedRectAntialiasKernelWidth(samples, r); + const bool ring = transformedRoundedRectIsRing(r); + const TransformedRoundedRectCommand hole = ring ? transformedRoundedRectHole(r) : r; int coverage = 0; for (int iy = 0; iy < samples; ++iy) { @@ -2137,13 +2175,574 @@ int gLastScrollUiFrame = -1000; r.blRx8, r.blRy8, sx, - sy)) + sy) && + (!ring || !roundedRectOuterContainsFast(hole, static_cast(sx), static_cast(sy)))) ++coverage; } } return coverage; } + + // Rounded and transformed overflow clips. Display clips are rectangles, so + // replay saves the canvas pixels a shaped clip's bounds may expose, lets + // the clipped content paint, then puts back every pixel outside the shape + // (in part where antialiasing covers it in part). + // + // Three bounds keep this cheap. The saved area is cut to the dirty region, + // and in a stream replay to the bounds of what the clip's scope paints, so + // a strip or a row that misses a rounded corner saves nothing. Node-range replays + // run inside a pass that keeps a clip open across consecutive nodes under + // it, so N rows under one rounded clip save and restore its corners once, + // not N times. An untransformed corner restores through a cached coverage + // mask, sampled exactly as the per-pixel path samples, instead of sampling + // floats per pixel. A clip whose exposed area exceeds kMaxSavedPixels + // stays a plain rectangle. + class ShapedClips + { + public: + // Pixels one shaped clip may save; past this the clip stays rectangular + // and its content overpaints the corners, as every clip did before. + static constexpr int kMaxSavedPixels = 16384; + + static std::size_t depth() { return entries().size(); } + + // One dirty-region node walk. Entries opened inside it stay open for the + // next node under the same clip; whatever is still open ends with the + // pass, or earlier through finish(). + struct PassScope + { + std::size_t previousBase; + bool previousActive; + PassScope() : previousBase(passBase()), previousActive(passActive()) + { + passBase() = depth(); + passActive() = true; + } + ~PassScope() { finish(); } + PassScope(const PassScope &) = delete; + PassScope &operator=(const PassScope &) = delete; + void finish() + { + if (!passActive()) + return; + endTo(passBase()); + passBase() = previousBase; + passActive() = previousActive; + } + }; + static bool inPass() { return passActive(); } + // Depth below which entries belong to an enclosing scope. + static std::size_t base() { return passActive() ? passBase() : depth(); } + + // Stream replay: an entry for the PushClip at `open`, ended by its PopClip. + // A scope that paints nothing exposes nothing and opens none (false). + static bool begin(int open) + { + int ink[4]; + const bool bounded = scopeInk(open, ink); + if (bounded && (ink[0] > ink[2] || ink[1] > ink[3])) return false; + begin(shapeOf(state.commands[open]), -1, bounded ? ink : nullptr); + return true; + } + + // Node-range replay: the index-th shaped clip ancestor (outermost first) + // of the node about to paint, owned by node `owner`. An entry already + // open at that depth for the same owner stays, so siblings under one + // clip share its save and restore; anything else at or above that depth + // ends first, innermost first, before the node paints over it. + static void reconcile(std::size_t base, int index, int owner, const OverflowClipShape &shape, const int *region) + { + if (isOpen(base, index, owner)) return; + endTo(base + static_cast(index)); + begin(shapeOf(shape), owner, region); + } + + // Whether the index-th entry above base is the one `owner` opened. + static bool isOpen(std::size_t base, int index, int owner) + { + const std::size_t at = base + static_cast(index); + return at < entries().size() && entries()[at].owner == owner; + } + + // Paints `colour` over what the shaped clip of the PushClip `clip` may + // expose inside `region` (x0 y0 x1 y1), one fill per box: a fill per + // masked row costs more in dirty-rect bookkeeping than the fully covered + // columns it would skip. + static void fillExposable(const DisplayCommand &clip, const int *region, gea::framework::graphics::pixel::native_t colour) + { + Box boxes[4]; + int8_t corners[4]; + const int count = exposableBoxes(shapeOf(clip), boxes, corners); + for (int i = 0; i < count; ++i) + { + const int x0 = std::max(boxes[i].x0, region[0]), y0 = std::max(boxes[i].y0, region[1]); + const int x1 = std::min(boxes[i].x1, region[2]), y1 = std::min(boxes[i].y1, region[3]); + if (x0 <= x1 && y0 <= y1) gea::platform::display::Display::fillRect(x0, y0, x1 - x0 + 1, y1 - y0 + 1, colour); + } + } + + // Ends the entries above a node's shaped ancestors. + static void settle(std::size_t base, int shapedCount) { endTo(base + static_cast(shapedCount)); } + + static void endTo(std::size_t mark) + { + while (entries().size() > mark) + end(); + } + + static void end() + { + const Entry entry = entries().back(); + entries().pop_back(); + restore(entry); + saved().resize(entry.offset); + } + + private: + struct Box + { + int x0, y0, x1, y1; + }; + // Coverage of one corner, sampled for the top-left orientation; the other + // corners mirror their pixel offsets into it. + struct CornerMask + { + int16_t rx8 = -1, ry8 = -1; + int8_t samples = 0; + int8_t kernelHalf = 0; + int w = 0, h = 0; + std::vector coverage; + // Per row, columns [0, zeroEnd) have no coverage and [fullStart, w) full + // coverage; only the columns between need their coverage read. + std::vector zeroEnd, fullStart; + }; + struct Entry + { + TransformedRoundedRectCommand shape; + float inv00, inv01, inv10, inv11; + Box boxes[4]; + // Corner of each box (0 tl, 1 tr, 2 br, 3 bl), or -1 for a bounds box. + int8_t corner[4]; + bool maskable; + int boxCount; + std::size_t offset; + int owner; + }; + static constexpr int kMaxCornerMaskExtent = 64; + static constexpr int kCornerMaskSlots = 4; + + // Per-core banks: the 2-core band split replays both halves concurrently, + // and each half pushes and pops its own shaped clips. + static std::vector &entries() + { + static std::vector value[2]; + return value[gea_current_render_core() & 1]; + } + + static std::vector &saved() + { + static std::vector value[2]; + return value[gea_current_render_core() & 1]; + } + + static std::size_t &passBase() + { + static std::size_t value[2]; + return value[gea_current_render_core() & 1]; + } + + static bool &passActive() + { + static bool value[2]; + return value[gea_current_render_core() & 1]; + } + + static TransformedRoundedRectCommand shapeOf(const DisplayCommand &clip) + { + const auto &c = clip.clip; + TransformedRoundedRectCommand shape{}; + shape.x0 = c.qx[0]; shape.y0 = c.qy[0]; + shape.x1 = c.qx[1]; shape.y1 = c.qy[1]; + shape.x2 = c.qx[2]; shape.y2 = c.qy[2]; + shape.x3 = c.qx[3]; shape.y3 = c.qy[3]; + shape.lx = c.lx; shape.ly = c.ly; shape.lw = c.lw; shape.lh = c.lh; + shape.tlRx8 = c.rx8[0]; shape.tlRy8 = c.ry8[0]; + shape.trRx8 = c.rx8[1]; shape.trRy8 = c.ry8[1]; + shape.brRx8 = c.rx8[2]; shape.brRy8 = c.ry8[2]; + shape.blRx8 = c.rx8[3]; shape.blRy8 = c.ry8[3]; + return shape; + } + + static TransformedRoundedRectCommand shapeOf(const OverflowClipShape &clip) + { + TransformedRoundedRectCommand shape{}; + shape.x0 = clip.qx[0]; shape.y0 = clip.qy[0]; + shape.x1 = clip.qx[1]; shape.y1 = clip.qy[1]; + shape.x2 = clip.qx[2]; shape.y2 = clip.qy[2]; + shape.x3 = clip.qx[3]; shape.y3 = clip.qy[3]; + shape.lx = clip.lx; shape.ly = clip.ly; shape.lw = clip.lw; shape.lh = clip.lh; + shape.tlRx8 = clip.rx8[0]; shape.tlRy8 = clip.ry8[0]; + shape.trRx8 = clip.rx8[1]; shape.trRy8 = clip.ry8[1]; + shape.brRx8 = clip.rx8[2]; shape.brRy8 = clip.ry8[2]; + shape.blRx8 = clip.rx8[3]; shape.blRy8 = clip.ry8[3]; + return shape; + } + + // Union of the draw bounds between the PushClip at `open` and its PopClip. + // A pixel none of them paints keeps its colour, and restoring a pixel that + // kept its colour writes that colour back (a colour blended with itself is + // exact), so nothing outside the union needs saving. Paint order lifts a + // scroll container's rows into its stacking context, each row in its own + // scope of the container's clip, and most rows reach no corner. False when + // a draw carries no bounds: the clip then saves all it may expose. + static bool scopeInk(int open, int *out) + { + int x0 = 32767, y0 = 32767, x1 = -32768, y1 = -32768; + int depth = 0; + for (int i = open + 1; i < state.commandCount; ++i) + { + const DisplayCommand &c = state.commands[i]; + if (c.type == DisplayCommandType::PushClip) ++depth; + else if (c.type == DisplayCommandType::PopClip) { if (depth-- == 0) break; } + else if (c.type != DisplayCommandType::SetAlpha) + { + if (c.bw <= 0 || c.bh <= 0) return false; + x0 = std::min(x0, static_cast(c.bx)); + y0 = std::min(y0, static_cast(c.by)); + x1 = std::max(x1, c.bx + c.bw - 1); + y1 = std::max(y1, c.by + c.bh - 1); + } + } + out[0] = x0; + out[1] = y0; + out[2] = x1; + out[3] = y1; + return true; + } + + static int extent(int radius8) { return (std::max(0, radius8) + 7) / 8; } + + // The screen boxes a shaped clip can expose, and the corner of each (0 tl, + // 1 tr, 2 br, 3 bl, or -1 for a bounds box). + static int exposableBoxes(const TransformedRoundedRectCommand &shape, Box *boxes, int8_t *corners) + { + int count = 0; + if (transformedRoundedRectLooksAxisAligned(shape) && shape.x1 - shape.x0 == shape.lw && shape.y3 - shape.y0 == shape.lh) + { + // Only the corner boxes can lie outside an untransformed rounded rect. + const int left = shape.lx, top = shape.ly, right = shape.lx + shape.lw - 1, bottom = shape.ly + shape.lh - 1; + const int tlW = extent(shape.tlRx8), tlH = extent(shape.tlRy8), trW = extent(shape.trRx8), trH = extent(shape.trRy8); + const int brW = extent(shape.brRx8), brH = extent(shape.brRy8), blW = extent(shape.blRx8), blH = extent(shape.blRy8); + if (tlW && tlH) { corners[count] = 0; boxes[count++] = {left, top, left + tlW - 1, top + tlH - 1}; } + if (trW && trH) { corners[count] = 1; boxes[count++] = {right - trW + 1, top, right, top + trH - 1}; } + if (brW && brH) { corners[count] = 2; boxes[count++] = {right - brW + 1, bottom - brH + 1, right, bottom}; } + if (blW && blH) { corners[count] = 3; boxes[count++] = {left, bottom - blH + 1, left + blW - 1, bottom}; } + return count; + } + const int16_t xs[4] = {shape.x0, shape.x1, shape.x2, shape.x3}; + const int16_t ys[4] = {shape.y0, shape.y1, shape.y2, shape.y3}; + corners[0] = -1; + boxes[0] = {std::min({xs[0], xs[1], xs[2], xs[3]}), std::min({ys[0], ys[1], ys[2], ys[3]}), + std::max({xs[0], xs[1], xs[2], xs[3]}), std::max({ys[0], ys[1], ys[2], ys[3]})}; + return 1; + } + + // Row of a corner's mask: from the top edge for the top corners, from the + // bottom edge for the bottom ones. + static int maskRow(const TransformedRoundedRectCommand &r, int corner, int y) + { + return corner < 2 ? y - r.ly : r.ly + r.lh - 1 - y; + } + + // Columns of a corner box row that a restore can change: all but those the + // mask covers fully (mask columns [fullStart, w), which run inward from the + // corner's outer edge). Empty when x0 > x1. + static void touchedColumns(const TransformedRoundedRectCommand &r, const Box &box, int corner, int fullStart, int &x0, int &x1) + { + const bool left = corner == 0 || corner == 3; + x0 = left ? box.x0 : std::max(box.x0, r.lx + r.lw - fullStart); + x1 = left ? std::min(box.x1, r.lx + fullStart - 1) : box.x1; + } + + static int cornerRx8(const TransformedRoundedRectCommand &r, int corner) + { + return corner == 0 ? r.tlRx8 : corner == 1 ? r.trRx8 : corner == 2 ? r.brRx8 : r.blRx8; + } + static int cornerRy8(const TransformedRoundedRectCommand &r, int corner) + { + return corner == 0 ? r.tlRy8 : corner == 1 ? r.trRy8 : corner == 2 ? r.brRy8 : r.blRy8; + } + + // The screen quad is the untransformed box itself, so a pixel's local + // coordinates are its screen coordinates and every sample offset is a + // multiple of a quarter pixel: the mask below then reproduces the + // per-pixel sampling bit for bit. + static bool exactlyAxisAligned(const TransformedRoundedRectCommand &r) + { + return r.x0 == r.x3 && r.x1 == r.x2 && r.y0 == r.y1 && r.y2 == r.y3 && + r.x1 - r.x0 == r.lw && r.y3 - r.y0 == r.lh; + } + + // The corners restore through cached masks (see cornerMask). + static bool isMaskable(const TransformedRoundedRectCommand &r) + { + return exactlyAxisAligned(r) && !transformedRoundedRectIsRing(r) && !transformedRoundedRectIsPillLike(r) && cornersDisjoint(r); + } + + // No corner box reaches another corner's region, so a corner's pixels + // see only their own arc, as the mask's canonical box does. + static bool cornersDisjoint(const TransformedRoundedRectCommand &r) + { + return extent(r.tlRx8) + extent(r.trRx8) <= r.lw && extent(r.blRx8) + extent(r.brRx8) <= r.lw && + extent(r.tlRy8) + extent(r.blRy8) <= r.lh && extent(r.trRy8) + extent(r.brRy8) <= r.lh; + } + + // Coverage of the top-left corner of an untransformed rounded rect for the + // pixel at (mx, my) from the corner, sampled with the same offsets and the + // same containment test restore() uses, on a box large enough that no + // other corner reaches these pixels. Null when the corner is too large to + // cache; restore() then samples per pixel. + static const CornerMask *cornerMask(int rx8, int ry8, int samples, float kernelWidth) + { + static CornerMask banks[2][kCornerMaskSlots]; + static int next[2]; + const int core = gea_current_render_core() & 1; + const int kernelHalf = static_cast(kernelWidth * 2.0f + 0.5f); + for (auto &m : banks[core]) + if (m.rx8 == rx8 && m.ry8 == ry8 && m.samples == samples && m.kernelHalf == kernelHalf) + return &m; + const int w = extent(rx8), h = extent(ry8); + if (w <= 0 || h <= 0 || w > kMaxCornerMaskExtent || h > kMaxCornerMaskExtent) + return nullptr; + CornerMask &m = banks[core][next[core]]; + next[core] = (next[core] + 1) % kCornerMaskSlots; + m.rx8 = static_cast(rx8); + m.ry8 = static_cast(ry8); + m.samples = static_cast(samples); + m.kernelHalf = static_cast(kernelHalf); + m.w = w; + m.h = h; + m.coverage.assign(static_cast(w) * static_cast(h), 0); + TransformedRoundedRectCommand canon{}; + const int16_t side = static_cast(4 * kMaxCornerMaskExtent); + canon.x1 = canon.x2 = side; + canon.y2 = canon.y3 = side; + canon.lw = canon.lh = side; + canon.tlRx8 = static_cast(rx8); + canon.tlRy8 = static_cast(ry8); + for (int my = 0; my < h; ++my) + { + for (int mx = 0; mx < w; ++mx) + { + const float localX = static_cast(mx) + 0.5f; + const float localY = static_cast(my) + 0.5f; + int coverage = 0; + if (samples >= 2) + { + for (int iy = 0; iy < samples; ++iy) + { + const float oy = antialiasOffsetWithKernel(iy, samples, kernelWidth); + for (int ix = 0; ix < samples; ++ix) + { + const float ox = antialiasOffsetWithKernel(ix, samples, kernelWidth); + const float dx = ox - 0.5f; + const float dy = oy - 0.5f; + if (roundedRectContainsFast(canon, localX + 1.0f * dx + 0.0f * dy, localY + 0.0f * dx + 1.0f * dy)) + ++coverage; + } + } + } + else + coverage = roundedRectContainsFast(canon, localX, localY) ? 1 : 0; + m.coverage[static_cast(my) * static_cast(w) + static_cast(mx)] = static_cast(coverage); + } + } + const int full = samples >= 2 ? samples * samples : 1; + m.zeroEnd.assign(static_cast(h), 0); + m.fullStart.assign(static_cast(h), 0); + for (int my = 0; my < h; ++my) + { + const uint8_t *row = m.coverage.data() + static_cast(my) * static_cast(w); + int zeroEnd = 0, fullStart = w; + while (zeroEnd < w && row[zeroEnd] == 0) ++zeroEnd; + while (fullStart > zeroEnd && row[fullStart - 1] >= full) --fullStart; + m.zeroEnd[static_cast(my)] = static_cast(zeroEnd); + m.fullStart[static_cast(my)] = static_cast(fullStart); + } + return &m; + } + + static void begin(const TransformedRoundedRectCommand &shape, int owner, const int *region) + { + Entry entry{}; + entry.shape = shape; + entry.owner = owner; + auto &store = saved(); + entry.offset = store.size(); + auto *canvas = gea::platform::display::Display::canvas(); + const auto *pixels = canvas ? canvas->pixels() : nullptr; + if (pixels) + { + Box candidates[4]; + int8_t corners[4]; + const int count = exposableBoxes(shape, candidates, corners); + int clipX0 = 0, clipY0 = 0, clipX1 = -1, clipY1 = -1; + gea::platform::display::Display::clip(&clipX0, &clipY0, &clipX1, &clipY1); + clipX0 = std::max(clipX0, 0); + clipY0 = std::max(clipY0, 0); + clipX1 = std::min(clipX1, canvas->width() - 1); + clipY1 = std::min(clipY1, canvas->height() - 1); + if (region) + { + // Only pixels inside the region can change while the clip is open + // (the dirty rect a pass repaints, or the ink of a stream scope), + // so only they can be exposed. + clipX0 = std::max(clipX0, region[0]); + clipY0 = std::max(clipY0, region[1]); + clipX1 = std::min(clipX1, region[2]); + clipY1 = std::min(clipY1, region[3]); + } + Box boxes[4]; + int8_t boxCorners[4] = {-1, -1, -1, -1}; + int boxCount = 0; + std::size_t total = 0; + for (int i = 0; i < count; ++i) + { + Box box{std::max(candidates[i].x0, clipX0), std::max(candidates[i].y0, clipY0), + std::min(candidates[i].x1, clipX1), std::min(candidates[i].y1, clipY1)}; + if (box.x0 > box.x1 || box.y0 > box.y1) + continue; + total += static_cast(box.x1 - box.x0 + 1) * static_cast(box.y1 - box.y0 + 1); + boxCorners[boxCount] = corners[i]; + boxes[boxCount++] = box; + } + // Past the budget the clip stays a rectangle rather than saving a + // screenful: a transformed clip's bounds box can be most of the panel. + // The inverse only serves restore(), so a clip that exposes nothing + // here (most row scopes under a scroll container) skips its divisions. + float a = 0.0f, b = 0.0f, c = 0.0f, d = 0.0f; + if (boxCount > 0 && total <= static_cast(kMaxSavedPixels)) + { + a = shape.lw ? static_cast(shape.x1 - shape.x0) / shape.lw : 0.0f; + b = shape.lh ? static_cast(shape.x3 - shape.x0) / shape.lh : 0.0f; + c = shape.lw ? static_cast(shape.y1 - shape.y0) / shape.lw : 0.0f; + d = shape.lh ? static_cast(shape.y3 - shape.y0) / shape.lh : 0.0f; + } + const float det = a * d - b * c; + if (std::fabs(det) >= 1e-6f) + { + entry.inv00 = d / det; + entry.inv01 = -b / det; + entry.inv10 = -c / det; + entry.inv11 = a / det; + entry.maskable = isMaskable(shape); + const int stride = canvas->strideBytes() / static_cast(sizeof(gea::framework::graphics::pixel::native_t)); + const int samples = gea::framework::graphics::Canvas::antialiasSamples(); + const float kernelWidth = entry.maskable ? transformedRoundedRectAntialiasKernelWidth(samples, shape) : 0.0f; + for (int i = 0; i < boxCount; ++i) + { + const Box &box = boxes[i]; + const int corner = boxCorners[i]; + // Only what restore() can change: a mask leaves its fully covered + // columns alone, so they need no copy. + const CornerMask *mask = entry.maskable && corner >= 0 + ? cornerMask(cornerRx8(shape, corner), cornerRy8(shape, corner), samples, kernelWidth) + : nullptr; + for (int y = box.y0; y <= box.y1; ++y) + { + int x0 = box.x0, x1 = box.x1; + if (mask) touchedColumns(shape, box, corner, mask->fullStart[static_cast(maskRow(shape, corner, y))], x0, x1); + if (x0 > x1) continue; + const auto *row = pixels + canvas->rowToPhysical(y) * stride; + store.insert(store.end(), row + x0, row + x1 + 1); + } + entry.corner[entry.boxCount] = boxCorners[i]; + entry.boxes[entry.boxCount++] = box; + } + } + } + entries().push_back(entry); + } + + // Puts back the part of a pixel outside the shape: all of it where no sample + // covers it, a blend where antialiasing covers some of it. + static void putOutside(gea::framework::graphics::pixel::native_t &pixel, gea::framework::graphics::pixel::native_t outside, + int coverage, int sampleCount) + { + if (coverage >= sampleCount) return; + pixel = coverage <= 0 ? outside + : gea::framework::graphics::pixel::blendNative(pixel, outside, combinedCoverageAlpha(255, coverage, sampleCount)); + } + + static void restore(const Entry &entry) + { + auto *canvas = gea::platform::display::Display::canvas(); + auto *pixels = canvas ? canvas->pixels() : nullptr; + if (pixels && entry.boxCount > 0) + { + const auto &r = entry.shape; + const int stride = canvas->strideBytes() / static_cast(sizeof(gea::framework::graphics::pixel::native_t)); + const int samples = gea::framework::graphics::Canvas::antialiasSamples(); + const int sampleCount = samples >= 2 ? samples * samples : 1; + const float kernelWidth = transformedRoundedRectAntialiasKernelWidth(samples, r); + const int right = r.lx + r.lw - 1; + // saved() and pixels() are calls; once, not per pixel or row. + const auto *outsidePixels = saved().data(); + std::size_t index = entry.offset; + for (int i = 0; i < entry.boxCount; ++i) + { + const Box &box = entry.boxes[i]; + const int corner = entry.corner[i]; + const CornerMask *mask = entry.maskable && corner >= 0 + ? cornerMask(cornerRx8(r, corner), cornerRy8(r, corner), samples, kernelWidth) + : nullptr; + const int boxW = box.x1 - box.x0 + 1; + const bool leftCorner = corner == 0 || corner == 3; + for (int y = box.y0; y <= box.y1; ++y) + { + auto *row = pixels + canvas->rowToPhysical(y) * stride; + const auto *outside = outsidePixels + index; + if (mask) + { + // begin() saved this row's touched columns only. They run inward + // from the corner's outer edge: the uncovered run copies back + // whole and the partly covered run blends. + const int my = maskRow(r, corner, y); + const uint8_t *coverage = mask->coverage.data() + static_cast(my) * static_cast(mask->w); + const int zeroEnd = mask->zeroEnd[static_cast(my)], fullStart = mask->fullStart[static_cast(my)]; + int x0, x1; + touchedColumns(r, box, corner, fullStart, x0, x1); + if (x0 > x1) continue; + index += static_cast(x1 - x0 + 1); + const int clearX0 = leftCorner ? x0 : std::max(x0, right - zeroEnd + 1); + const int clearX1 = leftCorner ? std::min(x1, r.lx + zeroEnd - 1) : x1; + if (clearX0 <= clearX1) + std::memcpy(row + clearX0, outside + (clearX0 - x0), static_cast(clearX1 - clearX0 + 1) * sizeof(*row)); + const int edgeX0 = std::max(x0, leftCorner ? r.lx + zeroEnd : right - fullStart + 1); + const int edgeX1 = std::min(x1, leftCorner ? r.lx + fullStart - 1 : right - zeroEnd); + for (int x = edgeX0; x <= edgeX1; ++x) putOutside(row[x], outside[x - x0], coverage[leftCorner ? x - r.lx : right - x], sampleCount); + continue; + } + index += static_cast(boxW); + const float py = static_cast(y) + 0.5f - static_cast(r.y0); + for (int x = box.x0; x <= box.x1; ++x) + { + const float px = static_cast(x) + 0.5f - static_cast(r.x0); + const float localX = static_cast(r.lx) + entry.inv00 * px + entry.inv01 * py; + const float localY = static_cast(r.ly) + entry.inv10 * px + entry.inv11 * py; + const int coverage = samples >= 2 + ? roundedRectCoverageFast(r, localX, localY, entry.inv00, entry.inv10, entry.inv01, entry.inv11, samples) + : (roundedRectContainsFast(r, localX, localY) ? 1 : 0); + putOutside(row[x], outside[x - box.x0], coverage, sampleCount); + } + } + canvas->markDirty(box.x0, box.y0, box.x1, box.y1); + } + } + } + }; + bool nodeFilterShapeContains(const Node &node, double sx, double sy) { const double x = static_cast(node.layout.x); @@ -4657,6 +5256,8 @@ int gLastScrollUiFrame = -1000; }; static const int quadEdges[4][2] = {{0, 1}, {1, 2}, {2, 3}, {3, 0}}; const int aaSpanPad = useCoverageAntialias ? 1 : 0; + const bool ring = transformedRoundedRectIsRing(r); + const TransformedRoundedRectCommand hole = ring ? transformedRoundedRectHole(r) : r; for (int y = drawY0; y <= drawY1; ++y) { @@ -4708,7 +5309,9 @@ int gLastScrollUiFrame = -1000; float localY = static_cast(r.ly) + inv10 * (static_cast(rowDrawX0) + 0.5f - static_cast(r.x0)) + inv11 * py; for (int x = rowDrawX0; x <= rowDrawX1; ++x) { - const int coverage = useCoverageAntialias + const int coverage = ring && roundedRectSolidCoreContainsFast(hole, localX, localY, sampleMarginX, sampleMarginY) + ? 0 + : useCoverageAntialias ? (roundedRectSolidCoreContainsFast(r, localX, localY, sampleMarginX, sampleMarginY) ? sampleCount : roundedRectCoverageFast(r, localX, localY, inv00, inv10, inv01, inv11, aaSamples)) @@ -5105,7 +5708,9 @@ int gLastScrollUiFrame = -1000; r.blRx8, r.blRy8, sx, - sy) + sy) && + (!transformedRoundedRectIsRing(r) || + !roundedRectOuterContainsFast(transformedRoundedRectHole(r), static_cast(sx), static_cast(sy))) ? 1 : 0; } @@ -5172,9 +5777,11 @@ int gLastScrollUiFrame = -1000; static void clampToNode(const Node &node, int *cx0, int *cy0, int *cx1, int *cy1) { - int nx0, ny0, w, h; - overflowClipBounds(node, nx0, ny0, w, h); - int nx1 = nx0 + w - 1, ny1 = ny0 + h - 1; + OverflowClipShape shape; + if (!ViewRenderer::overflowClipShape(node, shape)) + return; + const int nx0 = shape.x, ny0 = shape.y; + int nx1 = nx0 + shape.w - 1, ny1 = ny0 + shape.h - 1; if (*cx0 < nx0) *cx0 = nx0; if (*cy0 < ny0) @@ -5355,10 +5962,22 @@ int gLastScrollUiFrame = -1000; case DisplayCommandType::PushClip: { const int id = c->clip.nodeId; const Node *owner = id >= 0 && id < Tree::instance().nodeCount() ? &Tree::instance().nodes()[id] : nullptr; - if (!owner || overflowX(owner->computedStyle())) c->clip.x += dx; - else c->bx -= dx; - if (!owner || overflowY(owner->computedStyle())) c->clip.y += dy; - else c->by -= dy; + const int clipDx = !owner || overflowX(owner->computedStyle()) ? dx : 0; + const int clipDy = !owner || overflowY(owner->computedStyle()) ? dy : 0; + c->clip.x += clipDx; + c->bx -= dx - clipDx; + c->clip.y += clipDy; + c->by -= dy - clipDy; +#if GEA_CSS_SHAPED_CLIPS + if (c->clip.shaped) { + for (int i = 0; i < 4; ++i) { + c->clip.qx[i] += clipDx; + c->clip.qy[i] += clipDy; + } + c->clip.lx += clipDx; + c->clip.ly += clipDy; + } +#endif break; } case DisplayCommandType::FillRect: @@ -7475,7 +8094,10 @@ int gLastScrollUiFrame = -1000; } RoundedRectScreenSpan span{}; - if (!transformedRoundedRectToScreenSpan(r, &span) || + // A border ring has a hole the span rasterizers do not know about; + // it stays on the ring-aware replay path. + if (transformedRoundedRectIsRing(r) || + !transformedRoundedRectToScreenSpan(r, &span) || !roundedRectScreenSpanFitsCanvas(span)) return false; if (roundedRectScreenSpanIsCircle(span)) @@ -7916,7 +8538,8 @@ int gLastScrollUiFrame = -1000; return false; } - static bool nodeRecordsOverflowClip(int node_id, Node *nodes, int nodeCount) + // Mirrors ViewRenderer::recordClipBegin; `shape` receives the clip. + static bool nodeRecordsOverflowClip(int node_id, Node *nodes, int nodeCount, OverflowClipShape *shape = nullptr) { if (node_id < 0 || node_id >= nodeCount) return false; @@ -7925,7 +8548,8 @@ int gLastScrollUiFrame = -1000; return false; if (node.first_child < 0) return false; - return !nodeHasTransformChain(node_id, nodes, nodeCount); + OverflowClipShape local; + return ViewRenderer::overflowClipShape(node, shape ? *shape : local); } static std::uint8_t GEA_RENDER_HOT_SRAM effectiveAlphaForNode(int node_id, Node *nodes, int nodeCount) @@ -7950,7 +8574,12 @@ int gLastScrollUiFrame = -1000; return static_cast(alpha); } - static int GEA_RENDER_HOT_SRAM pushNodeReplayClips(int node_id, Node *nodes, int nodeCount) + // Pushes the Display clips of the node's clipping ancestors, outermost + // first, and lines the shaped ones up with ShapedClips (see reconcile: + // inside a pass, a clip the previous node already opened stays open). + // `region` is the dirty rect being replayed, x0 y0 x1 y1; a shaped clip + // saves only what lies inside it. + static int GEA_RENDER_HOT_SRAM pushNodeReplayClips(int node_id, Node *nodes, int nodeCount, const int *region) { int chain[kScratchDepth]{}; int count = 0; @@ -7962,17 +8591,20 @@ int gLastScrollUiFrame = -1000; if (LayoutEngine::isViewportFixed(nodes[cursor])) break; } int pushed = 0; + int shapedCount = 0; + const std::size_t shapedBase = ShapedClips::base(); for (int i = count - 1; i > 0; i--) { const int id = chain[i]; - if (!nodeRecordsOverflowClip(id, nodes, nodeCount)) + OverflowClipShape shape; + if (!nodeRecordsOverflowClip(id, nodes, nodeCount, &shape)) continue; - const Node &clip = nodes[id]; - int x, y, w, h; - overflowClipBounds(clip, x, y, w, h); - gea::platform::display::Display::pushClip(x, y, w, h); + gea::platform::display::Display::pushClip(shape.x, shape.y, shape.w, shape.h); + if (shape.shaped) + ShapedClips::reconcile(shapedBase, shapedCount++, id, shape, region); pushed++; } + ShapedClips::settle(shapedBase, shapedCount); return pushed; } @@ -8079,6 +8711,172 @@ int gLastScrollUiFrame = -1000; command.clip.y + command.clip.h >= height; } + // What Tree::mount() leaves under every pixel the list does not paint (see + // restoreClearBaseInRegion). + static gea::framework::graphics::pixel::native_t clearBaseColour() + { + Tree &tree = Tree::instance(); + const int root = tree.mountedRoot(); + return root >= 0 && root < tree.nodeCount() && isDocumentCanvasRoot(tree.nodes()[root]) + ? gea::framework::graphics::pixel::nativeColor(255, 255, 255) : 0; + } + +#if GEA_CSS_SHAPED_CLIPS + // The list's shaped PushClips, one per distinct shape (each row of a scroll + // container repeats its container's). Clip commands change only with a full + // record, which bumps displayListSerial; a splice changes the command count. + // Both key the scan, so a frame pays it only after the list changed. + struct ListShapedClips + { + static constexpr int kMax = 4; + std::uint32_t serial = 0; + int commandCount = -1; + // Distinct clips found, or kMax + 1 when there are more than kMax. + int count = 0; + int first[kMax]; + }; + + static bool sameClipShape(const DisplayCommand &a, const DisplayCommand &b) + { + for (int i = 0; i < 4; ++i) + if (a.clip.qx[i] != b.clip.qx[i] || a.clip.qy[i] != b.clip.qy[i] || a.clip.rx8[i] != b.clip.rx8[i] || a.clip.ry8[i] != b.clip.ry8[i]) + return false; + return a.clip.lx == b.clip.lx && a.clip.ly == b.clip.ly && a.clip.lw == b.clip.lw && a.clip.lh == b.clip.lh; + } + + static const ListShapedClips &listShapedClips() + { + static ListShapedClips value{}; + if (value.serial != state.displayListSerial || value.commandCount != state.commandCount) scanShapedClips(value); + return value; + } + + __attribute__((noinline)) static void scanShapedClips(ListShapedClips &value) + { + value.serial = state.displayListSerial; + value.commandCount = state.commandCount; + value.count = 0; + for (int ci = 0; ci < state.commandCount && value.count <= ListShapedClips::kMax; ++ci) + { + const DisplayCommand &c = state.commands[ci]; + if (c.type != DisplayCommandType::PushClip || !c.clip.shaped) continue; + bool seen = false; + for (int i = 0; i < value.count && !seen; ++i) seen = sameClipShape(c, state.commands[value.first[i]]); + if (seen) continue; + if (value.count < ListShapedClips::kMax) value.first[value.count] = ci; + ++value.count; + } + } + + // The simple replays skip clip commands: every clip they accept covers the + // viewport. A shaped one still cuts its corners. When the list has one, the + // replay first puts the mount-time clear back where the clip may expose + // (the clip restores what lies under its corners, which must not carry the + // previous frame's blend), then lines each node's shaped clip ancestors up + // with ShapedClips before the node paints, as pushNodeReplayClips does: a + // clip stays open across the nodes under it and saves only inside the + // regions' bounds. The batch flushes before a clip opens or ends, so no + // rounded rect lands on the wrong side of a save or restore. A list without + // shaped clips pays one cached check per replay. + class SimpleShapedClips + { + public: + SimpleShapedClips(const DisplayReplayRegion *regions, int count, UniformRoundedRectBatch &batch) : batch(batch) + { + const ListShapedClips &clips = listShapedClips(); + if (clips.count > 0 && count > 0) open(regions, count, clips); + } + ~SimpleShapedClips() { finish(); } + SimpleShapedClips(const SimpleShapedClips &) = delete; + SimpleShapedClips &operator=(const SimpleShapedClips &) = delete; + + // Inline, so a list without shaped clips pays one flag test per node. + void enter(int node_id, Node *nodes, int nodeCount) + { + if (active) line(node_id, nodes, nodeCount); + } + + // Paints what is still batched under the open clips, then restores them. + void finish() + { + if (!active) return; + active = false; + batch.flush(); + ShapedClips::endTo(mark); + } + + private: + __attribute__((noinline)) void open(const DisplayReplayRegion *regions, int count, const ListShapedClips &clips) + { + active = true; + mark = ShapedClips::depth(); + region[0] = region[1] = 32767; + region[2] = region[3] = -32768; + const auto base = clearBaseColour(); + for (int ri = 0; ri < count; ++ri) + { + const DisplayReplayRegion &r = regions[ri]; + if (r.x0 > r.x1 || r.y0 > r.y1) continue; + region[0] = std::min(region[0], r.x0); + region[1] = std::min(region[1], r.y0); + region[2] = std::max(region[2], r.x1); + region[3] = std::max(region[3], r.y1); + const int bounds[4] = {r.x0, r.y0, r.x1, r.y1}; + if (clips.count > ListShapedClips::kMax) + gea::platform::display::Display::fillRect(r.x0, r.y0, r.x1 - r.x0 + 1, r.y1 - r.y0 + 1, base); + else + for (int i = 0; i < clips.count; ++i) ShapedClips::fillExposable(state.commands[clips.first[i]], bounds, base); + } + } + + __attribute__((noinline)) void line(int node_id, Node *nodes, int nodeCount) + { + int owners[kScratchDepth]; + int count = 0; + for (int cursor = node_id; cursor >= 0 && cursor < nodeCount; cursor = nodes[cursor].parent) + { + OverflowClipShape shape; + if (cursor != node_id && nodes[cursor].computedStyle().overflow != 0 && + nodeRecordsOverflowClip(cursor, nodes, nodeCount, &shape) && shape.shaped) + { + // Deeper than any page: the node paints unclipped, as every node did before. + if (count == kScratchDepth) { count = 0; break; } + owners[count++] = cursor; + } + if (LayoutEngine::isViewportFixed(nodes[cursor])) break; + } + // Outermost first, as ShapedClips indexes them. + for (int i = 0; i < count; ++i) + { + const int owner = owners[count - 1 - i]; + if (ShapedClips::isOpen(mark, i, owner)) continue; + batch.flush(); + OverflowClipShape shape; + nodeRecordsOverflowClip(owner, nodes, nodeCount, &shape); + ShapedClips::reconcile(mark, i, owner, shape, region); + } + if (ShapedClips::depth() > mark + static_cast(count)) + { + batch.flush(); + ShapedClips::settle(mark, count); + } + } + + UniformRoundedRectBatch &batch; + int region[4] = {0, 0, -1, -1}; + bool active = false; + std::size_t mark = 0; + }; +#else + class SimpleShapedClips + { + public: + SimpleShapedClips(const DisplayReplayRegion *, int, UniformRoundedRectBatch &) {} + void enter(int, Node *, int) {} + void finish() {} + }; +#endif + static bool simpleTextAlphaNode(int id, Node *nodes, int count) { if (id < 0 || id >= count || !state.hasNodeScratchFor(id)) return false; @@ -8163,6 +8961,9 @@ int gLastScrollUiFrame = -1000; command.type == DisplayCommandType::BeginFilterBlur || command.type == DisplayCommandType::ApplyFilterBlur) { SRDBG("alpha/blur cmd type", (int)command.type); return false; } + // This path skips clip commands, so only a clip around the whole + // viewport may pass; a shaped one cuts its corners through + // SimpleShapedClips. if (command.type == DisplayCommandType::PushClip && !clipCoversViewport(command, width, height)) { SRDBG("non-viewport clip cmd", ci); return false; } @@ -8176,6 +8977,7 @@ int gLastScrollUiFrame = -1000; UniformRoundedRectBatch roundedRects; Tree &tree = Tree::instance(); Node *nodes = tree.nodes(); + SimpleShapedClips shapedClips(regions, count, roundedRects); restoreRetainedBackgroundsInRegions(regions, count); // Occlusion cull (see occlusionCullStartIndex): skip nodes fully covered // by a later opaque full-bleed fill in EVERY region. @@ -8207,10 +9009,12 @@ int gLastScrollUiFrame = -1000; const int start = state.nodeDrawStart[node_id]; const int end = state.nodeDrawEnd[node_id]; if (node.computedStyle().opacity == 0) continue; + shapedClips.enter(node_id, nodes, tree.nodeCount()); SimpleTextAlphaScope alpha(node, roundedRects); replaySimpleCommandRangeInRegions(start, end, regions, count, roundedRects); } roundedRects.flush(); + shapedClips.finish(); } static bool canReplaySimpleDirtyRegions(int width, int height) @@ -8234,6 +9038,7 @@ int gLastScrollUiFrame = -1000; int x1, int y1, UniformRoundedRectBatch &roundedRects, + SimpleShapedClips &shapedClips, bool requireLayoutOverlap) { Tree &tree = Tree::instance(); @@ -8262,6 +9067,7 @@ int gLastScrollUiFrame = -1000; if (start < 0 || end <= start || end > state.commandCount) return; if (node.computedStyle().opacity == 0) return; + shapedClips.enter(node_id, nodes, nodeCount); SimpleTextAlphaScope alpha(node, roundedRects); for (int ci = start; ci < end; ci++) { @@ -8294,6 +9100,8 @@ int gLastScrollUiFrame = -1000; const int nodeCount = tree.nodeCount(); const int16_t *bboxes = state.drawNodeBBox; refreshSimpleReplayNodeBBoxes(tree, nodes, nodeCount); + const DisplayReplayRegion region{x0, y0, x1, y1, -1}; + SimpleShapedClips shapedClips(®ion, 1, roundedRects); // Occlusion cull (see occlusionCullStartIndex): start at the last opaque // full-bleed fill covering this region — everything under it is invisible. const int cullStart = occlusionCullStartIndex(x0, y0, x1, y1, nodes, nodeCount); @@ -8320,6 +9128,7 @@ int gLastScrollUiFrame = -1000; if (start < 0 || end <= start || end > state.commandCount) continue; if (node.computedStyle().opacity == 0) continue; + shapedClips.enter(node_id, nodes, nodeCount); SimpleTextAlphaScope alpha(node, roundedRects); for (int ci = start; ci < end; ci++) { @@ -8345,6 +9154,7 @@ int gLastScrollUiFrame = -1000; } const std::int64_t __r1 = refreshPerfNowUs(); roundedRects.flush(); + shapedClips.finish(); __rasterUs += refreshPerfNowUs() - __r1; auto &perf = refreshPerfStatsMutable(); GEA_REFRESH_PERF(perf.treeReplaySplitRasterUs += __rasterUs); @@ -8398,6 +9208,10 @@ int gLastScrollUiFrame = -1000; static void replaySimpleClippedDirtyRegion(int x0, int y0, int x1, int y1, int origin = -1, bool allowSplit = true) { GEA_REFRESH_PERF(refreshPerfStatsMutable().treeReplaySimpleRegionCalls++); +#if GEA_CSS_SHAPED_CLIPS + // Fills the shaped-clip scan on this core before the bands read it. + listShapedClips(); +#endif // Coarse 2-core band split, mirroring replayDirectDirtyRegion's: the // per-origin simple replay is the path a single full-screen dirty rect // takes every frame (e.g. the bouncing-balls field on a small panel), @@ -8466,19 +9280,22 @@ int gLastScrollUiFrame = -1000; replaySimpleClippedDirtyRegionFast(x0, y0, x1, y1); return; } + const DisplayReplayRegion region{x0, y0, x1, y1, origin}; + SimpleShapedClips shapedClips(®ion, 1, roundedRects); const std::int64_t __bgStart = refreshPerfNowUs(); for (int i = chainLen - 1; i >= 0; --i) - replaySimpleNodeCommandsInRect(chain[i], x0, y0, x1, y1, roundedRects, false); + replaySimpleNodeCommandsInRect(chain[i], x0, y0, x1, y1, roundedRects, shapedClips, false); GEA_REFRESH_PERF(refreshPerfStatsMutable().treeReplaySplitBgUs += refreshPerfNowUs() - __bgStart); const std::int64_t __dynStart = refreshPerfNowUs(); if (originOrder >= 0) { for (int oi = originOrder + 1; oi < state.drawNodeOrderCount; ++oi) - replaySimpleNodeCommandsInRect(state.drawNodeOrder[oi], x0, y0, x1, y1, roundedRects, true); + replaySimpleNodeCommandsInRect(state.drawNodeOrder[oi], x0, y0, x1, y1, roundedRects, shapedClips, true); } GEA_REFRESH_PERF(refreshPerfStatsMutable().treeReplaySplitDynUs += refreshPerfNowUs() - __dynStart); const std::int64_t __rasterStart = refreshPerfNowUs(); roundedRects.flush(); + shapedClips.finish(); GEA_REFRESH_PERF(refreshPerfStatsMutable().treeReplaySplitRasterUs += refreshPerfNowUs() - __rasterStart); return; } @@ -8491,6 +9308,8 @@ int gLastScrollUiFrame = -1000; Tree &tree = Tree::instance(); Node *nodes = tree.nodes(); const int nodeCount = tree.nodeCount(); + const DisplayReplayRegion region{x0, y0, x1, y1, origin}; + SimpleShapedClips shapedClips(®ion, 1, roundedRects); restoreRetainedBackgroundInRegion(x0, y0, x1, y1); for (int oi = 0; oi < state.drawNodeOrderCount; oi++) { @@ -8516,6 +9335,7 @@ int gLastScrollUiFrame = -1000; const int end = state.nodeDrawEnd[node_id]; if (start < 0 || end <= start || end > state.commandCount) continue; + shapedClips.enter(node_id, nodes, nodeCount); for (int ci = start; ci < end; ci++) { DisplayCommand *c = &state.commands[ci]; @@ -8538,6 +9358,7 @@ int gLastScrollUiFrame = -1000; } } roundedRects.flush(); + shapedClips.finish(); } static void GEA_RENDER_HOT_SRAM replayNodeCommandRange(int node_id, int x0, int y0, int x1, int y1, UniformRoundedRectBatch &roundedRects) @@ -8556,7 +9377,9 @@ int gLastScrollUiFrame = -1000; if (start < 0 || end <= start || end > state.commandCount) return; - const int pushedClips = pushNodeReplayClips(node_id, nodes, nodeCount); + const std::size_t shapedMark = ShapedClips::depth(); + const int region[4] = {x0, y0, x1, y1}; + const int pushedClips = pushNodeReplayClips(node_id, nodes, nodeCount, region); if (pushedClips < 0) return; const std::uint8_t alpha = effectiveAlphaForNode(node_id, nodes, nodeCount); @@ -8569,6 +9392,9 @@ int gLastScrollUiFrame = -1000; replayCommandRange(start, end, x0, y0, x1, y1, roundedRects); if (stateChanged) roundedRects.flush(); + // Inside a pass the shaped clips stay open for the next node under them. + if (!ShapedClips::inPass()) + ShapedClips::endTo(shapedMark); for (int i = 0; i < pushedClips; i++) gea::platform::display::Display::popClip(); if (stateChanged) @@ -8613,6 +9439,7 @@ int gLastScrollUiFrame = -1000; return false; Node *nodes = tree.nodes(); UniformRoundedRectBatch roundedRects; + ShapedClips::PassScope shapedClipPass; gea::platform::display::Display::pushClip(x0, y0, x1 - x0 + 1, y1 - y0 + 1); replayNodeCommandRange(origin, x0, y0, x1, y1, roundedRects); for (int oi = originOrder + 1; oi < state.drawNodeOrderCount; ++oi) @@ -8631,6 +9458,7 @@ int gLastScrollUiFrame = -1000; continue; replayNodeCommandRange(node_id, x0, y0, x1, y1, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); gea::platform::display::Display::popClip(); return true; @@ -8663,6 +9491,7 @@ int gLastScrollUiFrame = -1000; // doesn't overlap this single dirty region. Mirrors the per-command clip in // the multi-region path (replayCommandInRegion). UniformRoundedRectBatch roundedRects; + ShapedClips::PassScope shapedClipPass; gea::platform::display::Display::pushClip(x0, y0, x1 - x0 + 1, y1 - y0 + 1); // Static-backdrop cache: blit the baked backdrop (bg gradient + stage + the // dithered floor) over the region, then replay only the dynamic subtree (the @@ -8697,6 +9526,7 @@ int gLastScrollUiFrame = -1000; continue; replayNodeCommandRange(node_id, x0, y0, x1, y1, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); gea::platform::display::Display::popClip(); return true; @@ -8732,6 +9562,7 @@ int gLastScrollUiFrame = -1000; gea::platform::display::Display::resetClip(); gea::platform::display::Display::setAlpha(255); UniformRoundedRectBatch roundedRects; + ShapedClips::PassScope shapedClipPass; gea::platform::display::Display::pushClip(0, 0, width, height); auto &treeSt = treeState(); for (int i = 0; i < tree.nodeCount() && i < kMaxNodes; i++) @@ -8757,6 +9588,7 @@ int gLastScrollUiFrame = -1000; if (node_id < kMaxNodes) treeSt.nodeInBackdrop[node_id] = 1; } + shapedClipPass.finish(); roundedRects.flush(); gea::platform::display::Display::popClip(); @@ -8894,10 +9726,11 @@ int gLastScrollUiFrame = -1000; bool clipped = false; for (int cursor = node_id; cursor >= 0 && cursor < nodeCount; cursor = nodes[cursor].parent) { - if (!nodeRecordsOverflowClip(cursor, nodes, nodeCount)) + OverflowClipShape shape; + if (!nodeRecordsOverflowClip(cursor, nodes, nodeCount, &shape)) continue; const Node &clip = nodes[cursor]; - if (clip.layout.x > x0 || clip.layout.y > y0 || + if (shape.shaped || clip.layout.x > x0 || clip.layout.y > y0 || clip.layout.x + clip.layout.width - 1 < x1 || clip.layout.y + clip.layout.height - 1 < y1) { @@ -8925,10 +9758,7 @@ int gLastScrollUiFrame = -1000; Tree &tree = Tree::instance(); if (regionHasOpaqueBase(x0, y0, x1, y1, tree.nodes(), tree.nodeCount())) return; - const int root = tree.mountedRoot(); - const auto base = root >= 0 && root < tree.nodeCount() && isDocumentCanvasRoot(tree.nodes()[root]) - ? gea::framework::graphics::pixel::nativeColor(255, 255, 255) : 0; - gea::platform::display::Display::fillRect(x0, y0, x1 - x0 + 1, y1 - y0 + 1, base); + gea::platform::display::Display::fillRect(x0, y0, x1 - x0 + 1, y1 - y0 + 1, clearBaseColour()); } // Painter's-algorithm occlusion cull: the LAST node (in draw order) from @@ -9006,10 +9836,12 @@ int gLastScrollUiFrame = -1000; bool clipped = false; for (int cursor = node_id; cursor >= 0 && cursor < nodeCount; cursor = nodes[cursor].parent) { - if (!nodeRecordsOverflowClip(cursor, nodes, nodeCount)) + OverflowClipShape shape; + if (!nodeRecordsOverflowClip(cursor, nodes, nodeCount, &shape)) continue; const Node &clip = nodes[cursor]; - if (clip.layout.y > y0 || clip.layout.y + clip.layout.height - 1 < y1) + // A rounded (shaped) clip never proves a rectangular cover. + if (shape.shaped || clip.layout.y > y0 || clip.layout.y + clip.layout.height - 1 < y1) { clipped = true; break; @@ -9064,6 +9896,7 @@ int gLastScrollUiFrame = -1000; // command bboxes (e.g. a root background fill) from painting outside the // region and erasing non-overlapping siblings. UniformRoundedRectBatch roundedRects; + ShapedClips::PassScope shapedClipPass; gea::platform::display::Display::pushClip(x0, y0, x1 - x0 + 1, y1 - y0 + 1); // Static-backdrop cache: this is the origin<0 path (e.g. a coalesced // full-viewport sync rect, which loses its single ancestor chain). Blit the @@ -9096,6 +9929,7 @@ int gLastScrollUiFrame = -1000; replayNodeCommandRange(node_id, x0, y0, x1, y1, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); gea::platform::display::Display::popClip(); } @@ -9252,7 +10086,19 @@ int gLastScrollUiFrame = -1000; if (!commandRangeOverlapsAnyRegion(start, end, regions, count)) return; - const int pushedClips = pushNodeReplayClips(node_id, nodes, nodeCount); + const std::size_t shapedMark = ShapedClips::depth(); + // A shaped clip saves only inside the regions' bounds. + int region[4] = {regions[0].x0, regions[0].y0, regions[0].x1, regions[0].y1}; + for (int ri = 1; ri < count; ri++) + { + if (regions[ri].x0 > regions[ri].x1 || regions[ri].y0 > regions[ri].y1) + continue; + region[0] = std::min(region[0], regions[ri].x0); + region[1] = std::min(region[1], regions[ri].y0); + region[2] = std::max(region[2], regions[ri].x1); + region[3] = std::max(region[3], regions[ri].y1); + } + const int pushedClips = pushNodeReplayClips(node_id, nodes, nodeCount, region); if (pushedClips < 0) return; const std::uint8_t alpha = effectiveAlphaForNode(node_id, nodes, nodeCount); @@ -9293,6 +10139,9 @@ int gLastScrollUiFrame = -1000; } if (stateChanged || alphaTouched) roundedRects.flush(); + // Inside a pass the shaped clips stay open for the next node under them. + if (!ShapedClips::inPass()) + ShapedClips::endTo(shapedMark); for (int i = 0; i < pushedClips; i++) gea::platform::display::Display::popClip(); if (stateChanged || alphaTouched) @@ -9412,6 +10261,7 @@ int gLastScrollUiFrame = -1000; Tree &tree = Tree::instance(); Node *nodes = tree.nodes(); UniformRoundedRectBatch roundedRects; + ShapedClips::PassScope shapedClipPass; // Static-backdrop cache (multi-region path — the transformed cube produces // several regions): blit the baked backdrop into each region, then replay only // the dynamic subtree on top, same as the single-region path. Without this a @@ -9461,11 +10311,18 @@ int gLastScrollUiFrame = -1000; replayNodeCommandRangeInRegions(node_id, regions, count, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); } static void GEA_RENDER_HOT_SRAM replay() { + // A stream without its PopClip still restores what its shaped clips saved. + struct ShapedClipGuard + { + std::size_t mark = ShapedClips::depth(); + ~ShapedClipGuard() { ShapedClips::endTo(mark); } + } shapedClipGuard; int cx0 = 0, cy0 = 0, cx1 = 0, cy1 = 0; int clip_dirty = 1; int clip_stack_depth = 0; @@ -9586,8 +10443,6 @@ int gLastScrollUiFrame = -1000; ny1 = cy1; int changed = nx0 != cx0 || ny0 != cy0 || nx1 != cx1 || ny1 != cy1; - if (state.ensureReplayClipCapacity(clip_stack_depth + 1)) - state.replayClipPushed[clip_stack_depth++] = (uint8_t)changed; if (changed) { gea::platform::display::Display::pushClip(c->clip.x, c->clip.y, c->clip.w, c->clip.h); @@ -9596,16 +10451,26 @@ int gLastScrollUiFrame = -1000; cx1 = nx1; cy1 = ny1; } + // Bit 0: a Display clip was pushed; bit 1: a shaped clip began (after + // the push, whose clip bounds what it saves). + const bool shaped = c->clip.shaped && state.ensureReplayClipCapacity(clip_stack_depth + 1) && ShapedClips::begin(i); + if (state.ensureReplayClipCapacity(clip_stack_depth + 1)) + state.replayClipPushed[clip_stack_depth++] = static_cast(changed | (shaped ? 2 : 0)); continue; } case DisplayCommandType::PopClip: + { roundedRects.flush(); - if (clip_stack_depth <= 0 || state.replayClipPushed[--clip_stack_depth]) + const uint8_t pushed = clip_stack_depth > 0 ? state.replayClipPushed[--clip_stack_depth] : 1; + if (pushed & 2) + ShapedClips::end(); + if (pushed & 1) { gea::platform::display::Display::popClip(); clip_dirty = 1; } continue; + } case DisplayCommandType::SetAlpha: roundedRects.flush(); gea::platform::display::Display::setAlpha(c->alpha.alpha); @@ -11035,6 +11900,7 @@ int gLastScrollUiFrame = -1000; return false; UniformRoundedRectBatch roundedRects; + ShapedClips::PassScope shapedClipPass; for (int oi = orderIndex + 1; oi < state.drawNodeOrderCount; oi++) { const int paintNode = state.drawNodeOrder[oi]; @@ -11047,6 +11913,7 @@ int gLastScrollUiFrame = -1000; continue; DisplayCommandReplayer::replayNodeCommandRangeInRegions(paintNode, ®ion, 1, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); return true; } diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index be69a12..93f6c4d 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -1684,6 +1684,14 @@ void recordBorderAreaFill(const Node &node, uint8_t parentAlpha) appendBorderSideWithAlpha(node, side, node.computedStyle().bg_color, node.computedStyle().bg_alpha, parentAlpha, transformed); } +// A transformed linear-gradient face paints a uniform border as its edge frame. +bool gradientEdgeCarriesBorder(const Node &node) +{ + return !StyleValues::hasTextBackgroundClip(node.computedStyle()) && node.computedStyle().border_width > 0 && !hasBorderRelief(node.computedStyle()) && + !hasSideBorder(node.computedStyle()) && !borderColorsDiffer(node.computedStyle()) && borderPaintAlpha(node.computedStyle(), 0) > 0 && + !isFullyRoundedShape(node); +} + void recordLinearGradientBackground(const Node &node, uint8_t parentAlpha) { const int x = node.layout.x; @@ -1741,8 +1749,7 @@ void recordLinearGradientBackground(const Node &node, uint8_t parentAlpha) // span rasterizer paints the first/last edgeWidth px of every span, tracing // the quad outline). Emitting projected stroke quads instead would disarm // the transform-reproject fast path every frame. - if (!StyleValues::hasTextBackgroundClip(node.computedStyle()) && node.computedStyle().border_width > 0 && !hasBorderRelief(node.computedStyle()) && !hasSideBorder(node.computedStyle()) && !borderColorsDiffer(node.computedStyle()) && borderPaintAlpha(node.computedStyle(), 0) > 0 && - !isFullyRoundedShape(node)) { + if (gradientEdgeCarriesBorder(node)) { g.edgeColor = borderPaintColor(node.computedStyle(), 0); g.edgeAlpha = borderPaintAlpha(node.computedStyle(), 0); const int ew = node.computedStyle().border_width < 1 ? 1 : node.computedStyle().border_width; @@ -1826,6 +1833,7 @@ bool recordPlacedBackgrounds(const Node &geometry, const Node &source, bool canv begin->type = DisplayCommandType::PushClip; begin->bx = begin->clip.x = x; begin->by = begin->clip.y = y; begin->bw = begin->clip.w = w; begin->bh = begin->clip.h = h; begin->clip.nodeId = -1; + begin->clip.shaped = 0; struct TileAxis { double start, step; int count; }; auto axis = [](int position, int size, int mode, int area, int areaSize, int clipStart, int clipSize) -> TileAxis { if (mode == 1) return {static_cast(position), static_cast(size), 1}; @@ -1954,6 +1962,55 @@ void recordTransformedRoundedRectFill(const Node &node, uint8_t parentAlpha, int r.blRx8 = rx8[3]; r.blRy8 = ry8[3]; r.color = node.computedStyle().bg_color; r.backfaceHidden = node.computedStyle().backface_hidden ? 1 : 0; + r.ring[0] = r.ring[1] = r.ring[2] = r.ring[3] = 0; + } + + if (effectiveAlpha != parentAlpha) appendAlphaCommand(parentAlpha, bx0, by0, bx1 - bx0 + 1, by1 - by0 + 1); +} + +// A uniform border on a transformed box: one shaded ring between the border +// box and the padding box, each rounded as CSS rounds it. +void recordTransformedBorderRing(const Node &node, uint8_t parentAlpha) +{ + const int x = node.layout.x; + const int y = node.layout.y; + const int w = node.layout.width; + const int h = node.layout.height; + if (w <= 0 || h <= 0) return; + + int16_t xs[4], ys[4]; + ViewGeometry::transformRectCorners(node, false, x, y, w, h, xs, ys); + int bx0, by0, bx1, by1; + boundsFromCorners(xs, ys, &bx0, &by0, &bx1, &by1); + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, borderPaintAlpha(node.computedStyle(), 0)); + if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, bx0, by0, bx1 - bx0 + 1, by1 - by0 + 1); + int16_t rx8[4]{}; + int16_t ry8[4]{}; + resolvedBorderRadii8(node, rx8, ry8); + DisplayCommand *cmd = DisplayList::instance().append(); + if (cmd) { + cmd->type = DisplayCommandType::FillTransformedRoundedRect; + cmd->bx = bx0; + cmd->by = by0; + cmd->bw = bx1 - bx0 + 1; + cmd->bh = by1 - by0 + 1; + auto &r = cmd->transformedRoundedRect; + r.x0 = xs[0]; r.y0 = ys[0]; + r.x1 = xs[1]; r.y1 = ys[1]; + r.x2 = xs[2]; r.y2 = ys[2]; + r.x3 = xs[3]; r.y3 = ys[3]; + r.lx = static_cast(x); + r.ly = static_cast(y); + r.lw = static_cast(w); + r.lh = static_cast(h); + r.tlRx8 = rx8[0]; r.tlRy8 = ry8[0]; + r.trRx8 = rx8[1]; r.trRy8 = ry8[1]; + r.brRx8 = rx8[2]; r.brRy8 = ry8[2]; + r.blRx8 = rx8[3]; r.blRy8 = ry8[3]; + r.color = borderPaintColor(node.computedStyle(), 0); + r.backfaceHidden = node.computedStyle().backface_hidden ? 1 : 0; + for (int i = 0; i < 4; ++i) + r.ring[i] = static_cast(std::clamp(computedBorderWidth(node.computedStyle(), i), 1, 255)); } if (effectiveAlpha != parentAlpha) appendAlphaCommand(parentAlpha, bx0, by0, bx1 - bx0 + 1, by1 - by0 + 1); @@ -2742,23 +2799,84 @@ bool GEA_VIEW_HOT_SRAM_SECTION("view_renderer_any_transform_active") ViewRendere return ViewGeometry::anyTransformPresent(); } +bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) +{ + int x, y, w, h; + overflowClipBounds(node, x, y, w, h); + out = OverflowClipShape{x, y, w, h, false, {}, {}, 0, 0, 0, 0, {}, {}}; + const bool transformed = ViewGeometry::hasTransformChain(node, false); + // The padding edge is rounded with the outer radii less the border widths. + const auto &s = node.computedStyle(); + // A clip open on one axis stays a rectangle; a transformed one clips nothing. + if (!overflowX(s) || !overflowY(s)) return !transformed; + // The padding edge is rounded with the outer radii less the border widths. + const int border[4] = {boxInset(s, 0) - s.padding[0], boxInset(s, 1) - s.padding[1], + boxInset(s, 2) - s.padding[2], boxInset(s, 3) - s.padding[3]}; + int16_t rx8[4]{}; + int16_t ry8[4]{}; + if (hasAnyRadius(node)) resolvedBorderRadii8(node, rx8, ry8); + bool rounded = false; + for (int i = 0; i < 4; ++i) { + out.rx8[i] = static_cast(std::max(0, rx8[i] - 8 * border[i == 0 || i == 3 ? 3 : 1])); + out.ry8[i] = static_cast(std::max(0, ry8[i] - 8 * border[i < 2 ? 0 : 2])); + rounded |= out.rx8[i] > 0 && out.ry8[i] > 0; + } + if (!transformed && !rounded) return true; + out.lx = static_cast(x); + out.ly = static_cast(y); + out.lw = static_cast(w); + out.lh = static_cast(h); + if (transformed) { + ViewGeometry::transformRectCorners(node, false, x, y, w, h, out.qx, out.qy); + // A perspective projection is not a parallelogram; keep it unclipped. + if (std::abs(out.qx[0] + out.qx[2] - out.qx[1] - out.qx[3]) > 1 || + std::abs(out.qy[0] + out.qy[2] - out.qy[1] - out.qy[3]) > 1) return false; + int bx0, by0, bx1, by1; + boundsFromCorners(out.qx, out.qy, &bx0, &by0, &bx1, &by1); + out.x = bx0; + out.y = by0; + out.w = bx1 - bx0 + 1; + out.h = by1 - by0 + 1; + } else { + const int16_t right = static_cast(x + w), bottom = static_cast(y + h); + out.qx[0] = out.qx[3] = static_cast(x); + out.qx[1] = out.qx[2] = right; + out.qy[0] = out.qy[1] = static_cast(y); + out.qy[2] = out.qy[3] = bottom; + } + out.shaped = true; + return true; +} + bool ViewRenderer::recordClipBegin(const Node &node) { const Node *n = &node; - if (ViewGeometry::hasTransformChain(*n, false)) return 0; if (!isViewLikeNodeType(n->type) || n->computedStyle().overflow == 0) return 0; if (n->first_child < 0) return 0; - int x, y, w, h; - overflowClipBounds(node, x, y, w, h); + OverflowClipShape shape; + if (!overflowClipShape(node, shape)) return 0; DisplayCommand *cmd = DisplayList::instance().append(); if (cmd) { cmd->type = DisplayCommandType::PushClip; - cmd->bx = cmd->clip.x = x; - cmd->by = cmd->clip.y = y; - cmd->bw = cmd->clip.w = w; - cmd->bh = cmd->clip.h = h; + cmd->bx = cmd->clip.x = static_cast(shape.x); + cmd->by = cmd->clip.y = static_cast(shape.y); + cmd->bw = cmd->clip.w = static_cast(shape.w); + cmd->bh = cmd->clip.h = static_cast(shape.h); cmd->clip.nodeId = static_cast(&node - Tree::instance().nodes()); + cmd->clip.shaped = shape.shaped ? 1 : 0; +#if GEA_CSS_SHAPED_CLIPS + for (int i = 0; i < 4; ++i) { + cmd->clip.qx[i] = shape.qx[i]; + cmd->clip.qy[i] = shape.qy[i]; + cmd->clip.rx8[i] = shape.rx8[i]; + cmd->clip.ry8[i] = shape.ry8[i]; + } + cmd->clip.lx = shape.lx; + cmd->clip.ly = shape.ly; + cmd->clip.lw = shape.lw; + cmd->clip.lh = shape.lh; +#endif } return 1; } @@ -2845,6 +2963,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB #if GEA_CSS_BOX_SHADOW if (rstyle(n->computedStyle()).box_shadow_alpha) recordOuterBoxShadow(*n, parentAlpha); #endif + bool borderOnGradientEdge = false; if (n->computedStyle().has_bg) { // The color is the bottom layer; image longhands never replace it. @@ -2882,6 +3001,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB if (n->computedStyle().bg_fill == 1) { TextBackgroundClipScope textClip(*n, StyleValues::backgroundClip(n->computedStyle(), rstyle(n->computedStyle()).bg_gradient_layer)); recordLinearGradientBackground(*n, parentAlpha); + borderOnGradientEdge = ViewGeometry::hasTransformChain(*n, false) && gradientEdgeCarriesBorder(*n); } { TextBackgroundClipScope textClip(*n, StyleValues::backgroundClip(n->computedStyle(), rstyle(n->computedStyle()).bg_radial_gradient_layer)); @@ -2903,6 +3023,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB if (n->computedStyle().border_width > 0 && !hasBorderRelief(n->computedStyle()) && !hasSideBorder(n->computedStyle()) && !borderColorsDiffer(n->computedStyle()) && !borderIsSameOpaqueSolidBackground(*n, parentAlpha)) { if (ViewGeometry::hasTransformChain(*n, false)) { if (isFullyRoundedShape(*n)) recordTransformedEllipseStroke(*n, parentAlpha); + else if (!borderOnGradientEdge) recordTransformedBorderRing(*n, parentAlpha); } else { appendStrokeWithAlpha(*n, parentAlpha); } From 0b734682d4ae44e533bf921836ebd2c58333e3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 02:04:20 +0300 Subject: [PATCH 18/50] Keep opaque glyphs out of the background-clip: text mask The text-clip mask painted the background with glyph coverage and the text then painted over it with the same coverage, so the background bled through every partly covered edge pixel. Glyphs with no opacity between them and the clip owner paint over that background completely; leaving them out of the mask removes the fringe and matches CSS, where no part of such a background shows. Transparent and translucent text keep showing the background as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/render.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 3fb2b29..147ac89 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -7298,17 +7298,24 @@ int gLastScrollUiFrame = -1000; int x0, y0, x1, y1; }; + // `opaque`: no opacity between the owner and this node. Opaque glyphs + // paint over the background they clip with the very same coverage, so + // that background never shows; leaving them out of the mask also keeps + // it out of their antialiased edges, as the text alone would paint them. static void collectTextInk(int id, int owner, int x0, int y0, int x1, int y1, - std::vector &ink) + std::vector &ink, bool opaque = true) { auto &tree = Tree::instance(); if (id < 0 || id >= tree.nodeCount()) return; const auto &node = tree.nodes()[id]; if (node.computedStyle().display == 1 || (id != owner && isOutOfFlowPosition(node.computedStyle().position))) return; + opaque = opaque && node.computedStyle().opacity == 255; if (state.hasNodeScratchFor(id) && node.computedStyle().visibility == 0) { + const bool hidesBackground = opaque && node.type == NodeType::Text && node.computedStyle().text_alpha == 255; const int begin = state.nodeDrawStart[id], end = state.nodeDrawEnd[id]; for (int ci = std::max(0, begin); ci < end && ci < state.commandCount; ++ci) { const auto &c = state.commands[ci]; + if (hidesBackground && c.type == DisplayCommandType::DrawText) continue; if (c.type == DisplayCommandType::DrawText || c.type == DisplayCommandType::DrawProjectedText || c.textDecorationInk) ink.push_back({&c, x0, y0, x1, y1}); } @@ -7326,7 +7333,7 @@ int gLastScrollUiFrame = -1000; } if (x0 > x1 || y0 > y1) return; for (int child = node.first_child; child >= 0; child = tree.nodes()[child].next_sibling) - collectTextInk(child, owner, x0, y0, x1, y1, ink); + collectTextInk(child, owner, x0, y0, x1, y1, ink, opaque); } static void replayTextClipped(const DisplayCommand &command) From e91e8e3e67de503433ecd694721f5a686cb17963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 02:29:57 +0300 Subject: [PATCH 19/50] Lay out multi-column containers columns, column-count and column-width now keep their values instead of only whether they are set, and column-fill, column-span and continue: discard are parsed; the fields sit in RareStyle padding. The line-clamp shorthand, its none value and its inline removal reset continue: discard as well. A multicol container lays its content out as one column (the flow thread) of the used column width (CSS Multi-column 3.4). Its column height is the definite height under column-fill: auto, else the shortest height that balances the flow over the columns without a column boundary cutting a line box or an image. column-gap separates column boxes only; it no longer spaces the content's lines. Painting records the flow thread once and copies it into every further column box, translated along the inline direction (from the right edge in rtl) and up by the column heights, each copy clipped to its column's band. The first band stays open above and the last below, so ink crossing fragmentation edges shows, which is box-decoration-break: slice. continue: discard drops overflow columns. A multicol container paints its whole subtree so the copies cover it, and text-clipped backgrounds in a copy take their glyph mask from the same copy. Node range dirty-region replays, in-place translation and the root scroll fast path cannot follow the copies, so a list holding them is recorded in full. A container with a column-span: all child keeps a single column, since column sets are not implemented. An app without columns pays one inline test: styleHasColumns() in node_model.h checks column-count and column-width before the cross-unit multicolContainer call or the rare-data lookup in isContext, canReplayDirectDirtyRegions and recordNode. GEA_CSS_MULTICOL (default on) gates the family and joins GEA_CSS_RARE_STYLE: off, the style fields are static constexpr, and MulticolLayout, the column copies and the column layout are not compiled; NodeRareData is 16 bytes smaller (host). The analyzer and CLI do not report the family yet. Column balancing allocates in layout, for multicol containers only. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/internal.h | 8 + packages/engine/ui/layout.cpp | 172 +++++++++++++++++++- packages/engine/ui/node_model.h | 27 ++- packages/engine/ui/render.cpp | 181 ++++++++++++++++++++- packages/engine/ui/root_scroll_refresh.cpp | 4 +- packages/engine/ui/style.cpp | 85 ++++++++-- packages/engine/ui/style.h | 10 +- packages/engine/ui/tree_render.cpp | 5 + packages/engine/ui/tree_state.h | 21 +++ packages/engine/ui/tree_style.cpp | 22 ++- 10 files changed, 496 insertions(+), 39 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index f4f6523..0c1a13a 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -458,6 +458,10 @@ void rootScrollImageHoldTick(); void filterBlurCacheStats(int *hits, int *misses) const; int commandCount() const; bool hasTextClippedBackgrounds() const; + // True when the list holds multicol column copies (replicateColumns). In-place + // translation and re-recording do not update them, so callers fall back to a + // full record. + bool hasColumnCopies() const; int nodeCommandCount(int node) const; const DisplayCommand *nodeCommandAt(int node, int index) const; bool nodeCommandBounds(int node, int *x0, int *y0, int *x1, int *y1) const; @@ -544,6 +548,10 @@ void rootScrollImageHoldTick(); // True when the line box closes right after node `id`: a forced break, a // block-level box, or the end of its block container follows it. static bool endsFormattingLine(int id); + // A block container with a column count or width (CSS Multi-column). + static bool multicolContainer(const Node &node); + // Resolved direction: true for rtl. + static bool rightToLeftDirection(const Node &node); }; // Shared order for recording, retained transform replay, and hit testing. diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 77ddce2..622c9ec 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -723,8 +723,12 @@ bool flexRowDirection(const ComputedStyle &style) return !style.flex_direction_explicit || (style.flex_direction & 1); } +bool isMulticolContainer(const Node &node); + int resolvedGap(const Node &node, bool rowGap, int width, int height) { + // A multicol container's column-gap separates its column boxes, not its content. + if (isMulticolContainer(node)) return 0; const int length = rowGap ? node.computedStyle().row_gap : node.computedStyle().column_gap; const int percent = rowGap ? node.computedStyle().row_gap_percent : node.computedStyle().column_gap_percent; const bool vertical = writingMode(node) != 0; @@ -2451,6 +2455,31 @@ static bool isInlineLevelTag(const char *tag) std::strcmp(tag, "mark") == 0; } +// A block container with a column count or width (CSS Multi-column 2). +// Spanning elements (column-span: all) split the columns into sets, which +// this layout does not do: such a container keeps a single column. +bool isMulticolContainer(const Node &node) +{ + const RareStyle &rs = rstyle(node.computedStyle()); + if ((rs.column_count <= 0 && rs.column_width <= 0) || node.type != NodeType::View || node.computedStyle().display != kDisplayBlock || + (LayoutEngine::isCssInlineLevelBox(node) && LayoutEngine::isInlineLevelNode(node))) return false; + const Node *nodes = Tree::instance().nodes(); + for (int c = node.first_child; c >= 0; c = nodes[c].next_sibling) + if (rstyle(nodes[c].computedStyle()).line_clamp_flags & 64) return false; + return true; +} + +#if GEA_CSS_MULTICOL +// column-gap: normal is 1em in a multicol container. +int multicolGap(const Node &node, int available) +{ + const auto &s = node.computedStyle(); + if (s.column_gap_percent != kUnset) return std::max(0, resolvePercentSize(available, s.column_gap_percent)); + if (s.column_gap != kUnset) return std::max(0, static_cast(s.column_gap)); + return s.gap > 0 ? s.gap : s.font_size; +} +#endif + // An inline box with nothing to show: no insets and no visible box. bool emptyInlineBox(const Node &node) { @@ -2544,6 +2573,14 @@ class LayoutNodePass { return; } + if (NodeRareData *rare = rareDataFor(id_)) rare->multicol.valid = false; + const ColumnGeometry columns = columnGeometry(); + if (columns.count > 0) layoutColumns(columns); + else layoutContent(); + } + + void layoutContent() + { const int padWidth = paddedWidth(); const int padHeight = paddedHeight(); const bool inlineRow = resolveInlineFormattingRow(); @@ -2610,6 +2647,11 @@ class LayoutNodePass { #if GEA_CSS_FIRST_LINE if (NodeRareData *rare = rareDataFor(id_)) rare->firstLineBackground.lineValid = false; #endif +#if GEA_CSS_MULTICOL + // A multicol container's content keeps the column width it was laid out in. + const ColumnGeometry columns = columnGeometry(); + ColumnWidthScope columnWidth(node_, columns.count > 0 ? columns.width + boxInsets(node_.computedStyle(), true) : -1); +#endif const int padWidth = paddedWidth(); const int padHeight = paddedHeight(); @@ -2656,6 +2698,112 @@ class LayoutNodePass { applyRelativeOffsets(); } + struct ColumnGeometry { + int count = 0, width = 0, gap = 0; + }; + +#if GEA_CSS_MULTICOL + // CSS Multi-column 3.4: the used column count and width of a multicol + // container. count 0: not a multicol container. + ColumnGeometry columnGeometry() const + { + ColumnGeometry g; + if (!isMulticolContainer(node_)) return g; + const RareStyle &rs = rstyle(node_.computedStyle()); + const int available = paddedWidth(); + g.gap = multicolGap(node_, available); + int count = rs.column_count; + if (rs.column_width > 0) { + const int fit = std::max(1, (available + g.gap) / (rs.column_width + g.gap)); + count = count > 0 ? std::min(count, fit) : fit; + } + g.count = std::max(1, count); + g.width = std::max(1, (available - (g.count - 1) * g.gap) / g.count); + return g; + } + + // Lays content out in a narrower border box, restoring the width after. + struct ColumnWidthScope { + Node &node; + int16_t width; + ColumnWidthScope(Node &n, int columnWidth) : node(n), width(n.layout.width) + { + if (columnWidth >= 0) n.layout.width = clampInt16(columnWidth); + } + ~ColumnWidthScope() { node.layout.width = width; } + }; + + // A multicol container lays its content out as one column (the flow + // thread) of the column width; painting slices it into column boxes (see + // RenderRecorder). Column height follows a definite height under + // column-fill: auto, else balances the flow without cutting a line box. + void layoutColumns(const ColumnGeometry &g) + { + const int insetsX = boxInsets(node_.computedStyle(), true); + const int insetsY = boxInsets(node_.computedStyle(), false); + const bool definite = hasExplicitHeight(node_); + const int given = paddedHeight(); + int flow = 0; + { + ColumnWidthScope columnWidth(node_, g.width + insetsX); + layoutContent(); + if (node_.first_child >= 0) updateScrollContentSize(); + flow = node_.first_child >= 0 ? std::max(0, node_.layout.scroll_content_height - insetsY) : 0; + } + const int flags = rstyle(node_.computedStyle()).line_clamp_flags; + int height = definite && (flags & 16) ? given : balancedColumnHeight(flow, g.count); + if (definite) height = std::min(height, given); + height = std::max(1, height); + if (!definite) node_.layout.height = clampInt16(clampLayoutSize(node_, height + insetsY, false)); + // Overflow columns follow the last column box; continue: discard drops them. + int used = std::max(1, (flow + height - 1) / height); + if (flags & 32) used = std::min(used, g.count); + ensureRareData(id_).multicol = MulticolLayout{clampInt16(g.width), clampInt16(g.gap), clampInt16(height), clampInt16(used), true, (flags & 32) != 0}; + node_.layout.scroll_content_width = clampInt16(std::max(node_.layout.width, insetsX + used * (g.width + g.gap) - g.gap)); + node_.layout.scroll_content_height = node_.layout.height; + } + + // The shortest column height that fits the flow into `count` columns with + // no column boundary cutting a line box or a replaced element. + int balancedColumnHeight(int flow, int count) const + { + if (count <= 1 || flow <= 0) return flow; + std::vector> units; + collectMonolithicUnits(id_, -boxInset(node_.computedStyle(), 0), units); + for (int height = (flow + count - 1) / count; height < flow; ++height) { + bool fits = true; + for (const auto &unit : units) { + const int column = unit.first / height; + if (column < count - 1 && unit.second > (column + 1) * height) { fits = false; break; } + } + if (fits) return height; + } + return flow; + } + + void collectMonolithicUnits(int parent, int originY, std::vector> &units) const + { + for (int id = nodes_[parent].first_child; id >= 0; id = nodes_[id].next_sibling) { + const Node &child = nodes_[id]; + if (isDisplayNone(child.computedStyle()) || isOutOfFlowPosition(child.computedStyle().position)) continue; + const int top = originY + child.layout.y; + if (child.type == NodeType::Text) { + const int advance = TextRenderer::measureHeight("X", child.computedStyle().font_id, child.computedStyle().font_size, 0, child.computedStyle().line_height); + const int inner = child.layout.height - boxInsets(child.computedStyle(), false); + const int lines = advance > 0 ? std::max(1, (inner + advance - 1) / advance) : 1; + for (int line = 0; line < lines; ++line) { + const int lineTop = top + boxInset(child.computedStyle(), 0) + line * advance; + units.emplace_back(lineTop, std::min(lineTop + advance, top + child.layout.height)); + } + } else if (child.type == NodeType::Image) { + units.emplace_back(top, top + child.layout.height); + } else { + collectMonolithicUnits(id, top, units); + } + } + } +#endif + bool containsChildMargins() const { // A stable border box is not sufficient for isolated relayout: child @@ -2781,9 +2929,9 @@ class LayoutNodePass { bool establishesBlockContext(const Node &n) const { - return n.parent < 0 || isOutOfFlowPosition(n.computedStyle().position) || n.computedStyle().float_side || - n.computedStyle().display != kDisplayBlock || overflowEstablishesContext(n.computedStyle()) || - (n.parent >= 0 && (nodes_[n.parent].computedStyle().display == kDisplayFlex || isDisplayGrid(nodes_[n.parent].computedStyle()) || + return n.parent < 0 || isOutOfFlowPosition(n.style.position) || n.style.float_side || + n.style.display != kDisplayBlock || overflowEstablishesContext(n.style) || isMulticolContainer(n) || + (n.parent >= 0 && (nodes_[n.parent].style.display == kDisplayFlex || isDisplayGrid(nodes_[n.parent].style) || writingMode(n) != writingMode(nodes_[n.parent]))); } @@ -4457,6 +4605,16 @@ bool LayoutEngine::isInlineLevelNode(const Node &n) // Mirror of startsFormattingLine: walk forward through siblings and inline // ancestors to the next content that shares or closes this line box. +bool LayoutEngine::multicolContainer(const Node &node) +{ + return isMulticolContainer(node); +} + +bool LayoutEngine::rightToLeftDirection(const Node &node) +{ + return rightToLeft(node); +} + bool LayoutEngine::endsFormattingLine(int id) { const Node *nodes = Tree::instance().nodes(); @@ -4976,10 +5134,10 @@ void applyStickyOffset(Node &node, const Node &block, const int port[4]) // A scroll container's padding box, in resolved coordinates. void scrollportOf(const Node &node, int port[4]) { - port[0] = node.layout.x + std::max(node.style.border_width, rstyle(node.style).border_side_width[3]); - port[1] = node.layout.y + std::max(node.style.border_width, rstyle(node.style).border_side_width[0]); - port[2] = node.layout.x + node.layout.width - std::max(node.style.border_width, rstyle(node.style).border_side_width[1]); - port[3] = node.layout.y + node.layout.height - std::max(node.style.border_width, rstyle(node.style).border_side_width[2]); + port[0] = node.layout.x + computedBorderWidth(node.computedStyle(), 3); + port[1] = node.layout.y + computedBorderWidth(node.computedStyle(), 0); + port[2] = node.layout.x + node.layout.width - computedBorderWidth(node.computedStyle(), 1); + port[3] = node.layout.y + node.layout.height - computedBorderWidth(node.computedStyle(), 2); } // The scrollport resolveAbsoluteCoords hands `id`: its nearest ancestor that diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 07ceb73..bf1abf8 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -168,6 +168,8 @@ struct RareStyle { #else static constexpr uint8_t margin_trim = 0; #endif + // Multi-column: column-count (0 = auto). Sits in padding. + uint8_t column_count = 0; // CSS atom of a custom block-ellipsis string; 0 = the default ellipsis. // Only used when line_clamp_flags has block-ellipsis set. Sits in padding. uint16_t block_ellipsis = 0; @@ -395,6 +397,8 @@ struct RareStyle { // center/extent 500/1000) --- #if GEA_CSS_BACKGROUND_LAYERS uint16_t bg_image_layer_count = 1; // Includes none layers. + // Multi-column: column-width in px, -1 = auto. Sits in padding. + int16_t column_width = -1; int32_t bg_size_list = -1, bg_position_list = -1, bg_repeat_list = -1; int32_t bg_attachment_list = -1, bg_origin_list = -1; #else @@ -484,11 +488,20 @@ struct RareStyle { static constexpr uint8_t bg_grid_line_y = 0; #endif // CSS Overflow 4 line clamping: max-lines (0 = none, capped at 255) and - // flags: 1 = continue: collapse, 2 = block-ellipsis other than none, and - // 4 / 8 = column-count / column-width set (a multicol container never - // clamps). Both sit in trailing padding, so RareStyle keeps its size. + // flags: 1 = continue: collapse or discard, 2 = block-ellipsis other than + // none, 4 / 8 = column-count / column-width set (a multicol container never + // clamps), 16 = column-fill: auto, 32 = continue: discard, 64 = column-span: + // all. Both sit in trailing padding, so RareStyle keeps its size. +#if GEA_CSS_LINE_CLAMP uint8_t max_lines = 0; +#else + static constexpr uint8_t max_lines = 0; +#endif +#if GEA_CSS_LINE_CLAMP || GEA_CSS_MULTICOL uint8_t line_clamp_flags = 0; +#else + static constexpr uint8_t line_clamp_flags = 0; +#endif }; // Individual translation is applied outside the transform list. Its translation @@ -1076,6 +1089,14 @@ RareStyle &rstyleMut(ComputedStyle &style); void releaseRareStyle(int16_t &handle); // frees the entry (or no-op when embedded) void resetRareStylePool(); // drops all entries (or no-op when embedded) +// A column-count or column-width is set (CSS Multi-column): the cheap test +// the per-node paint and replay paths make before calling across units into +// LayoutEngine::multicolContainer. Folds to false without the family. +inline bool styleHasColumns(const ComputedStyle &style) +{ + return GEA_CSS_MULTICOL && (rstyle(style).column_count > 0 || rstyle(style).column_width > 0); +} + inline bool styleHasBackgroundImage(const ComputedStyle &style) { const auto &rare = rstyle(style); diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 147ac89..28f40ce 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -2184,6 +2184,34 @@ int gLastScrollUiFrame = -1000; } +#if GEA_CSS_MULTICOL + // Multicol column copies: commands [begin, end) repeat those `offset` + // indices earlier, translated into a later column box. Text-clipped + // backgrounds in a copy take their glyph mask from the same copy. + struct ColumnCopy + { + int begin, end, offset; + }; + + std::vector &columnCopies() + { + static std::vector value; + return value; + } + + int columnCopyOffset(const DisplayCommand &command) + { + if (!state.commands || &command < state.commands || &command >= state.commands + state.commandCount) + return 0; + const int index = static_cast(&command - state.commands); + for (const auto © : columnCopies()) + if (index >= copy.begin && index < copy.end) + return copy.offset; + return 0; + } +#endif + +#if GEA_CSS_SHAPED_CLIPS // Rounded and transformed overflow clips. Display clips are rectangles, so // replay saves the canvas pixels a shaped clip's bounds may expose, lets // the clipped content paint, then puts back every pixel outside the shape @@ -2742,6 +2770,31 @@ int gLastScrollUiFrame = -1000; } } }; +#else + // Shaped clips are compiled out: every overflow clip is a rectangle, and + // replay carries no save/restore state. + class ShapedClips + { + public: + static constexpr int kMaxSavedPixels = 0; + static std::size_t depth() { return 0; } + struct PassScope + { + PassScope() {} + ~PassScope() {} + PassScope(const PassScope &) = delete; + PassScope &operator=(const PassScope &) = delete; + void finish() {} + }; + static bool inPass() { return false; } + static std::size_t base() { return 0; } + static bool begin(int) { return false; } + static void reconcile(std::size_t, int, int, const OverflowClipShape &, const int *) {} + static void settle(std::size_t, int) {} + static void endTo(std::size_t) {} + static void end() {} + }; +#endif bool nodeFilterShapeContains(const Node &node, double sx, double sy) { @@ -7303,7 +7356,7 @@ int gLastScrollUiFrame = -1000; // that background never shows; leaving them out of the mask also keeps // it out of their antialiased edges, as the text alone would paint them. static void collectTextInk(int id, int owner, int x0, int y0, int x1, int y1, - std::vector &ink, bool opaque = true) + std::vector &ink, bool opaque = true, int copyOffset = 0) { auto &tree = Tree::instance(); if (id < 0 || id >= tree.nodeCount()) return; @@ -7312,7 +7365,7 @@ int gLastScrollUiFrame = -1000; opaque = opaque && node.computedStyle().opacity == 255; if (state.hasNodeScratchFor(id) && node.computedStyle().visibility == 0) { const bool hidesBackground = opaque && node.type == NodeType::Text && node.computedStyle().text_alpha == 255; - const int begin = state.nodeDrawStart[id], end = state.nodeDrawEnd[id]; + const int begin = state.nodeDrawStart[id] + copyOffset, end = state.nodeDrawEnd[id] + copyOffset; for (int ci = std::max(0, begin); ci < end && ci < state.commandCount; ++ci) { const auto &c = state.commands[ci]; if (hidesBackground && c.type == DisplayCommandType::DrawText) continue; @@ -7323,7 +7376,7 @@ int gLastScrollUiFrame = -1000; // Match the overflow clip recorded for descendants of this node. // Positioned in-flow and stacking-context descendants still contribute; // their opacity does not change the shape of the mask. - const int clipIndex = state.hasNodeScratchFor(id) ? state.nodeDrawEnd[id] : -1; + const int clipIndex = state.hasNodeScratchFor(id) ? state.nodeDrawEnd[id] + copyOffset : -1; if (clipIndex >= 0 && clipIndex < state.commandCount) { const auto &clip = state.commands[clipIndex]; if (clip.type == DisplayCommandType::PushClip && clip.clip.nodeId == id) { @@ -7333,7 +7386,7 @@ int gLastScrollUiFrame = -1000; } if (x0 > x1 || y0 > y1) return; for (int child = node.first_child; child >= 0; child = tree.nodes()[child].next_sibling) - collectTextInk(child, owner, x0, y0, x1, y1, ink, opaque); + collectTextInk(child, owner, x0, y0, x1, y1, ink, opaque, copyOffset); } static void replayTextClipped(const DisplayCommand &command) @@ -7350,7 +7403,11 @@ int gLastScrollUiFrame = -1000; y1 = std::min({y1, canvas->height()-1, command.by+command.bh-1}); if (x0 > x1 || y0 > y1) return; std::vector ink; - collectTextInk(command.textClipOwner, command.textClipOwner, x0, y0, x1, y1, ink); + int copyOffset = 0; +#if GEA_CSS_MULTICOL + copyOffset = columnCopyOffset(command); +#endif + collectTextInk(command.textClipOwner, command.textClipOwner, x0, y0, x1, y1, ink, true, copyOffset); if (ink.empty()) return; DisplayCommand paint = command; paint.textClipOwner = -1; @@ -7745,6 +7802,70 @@ int gLastScrollUiFrame = -1000; ~SuspendedRecordClips() { restore(clips); } }; +#if GEA_CSS_MULTICOL + // Column k of a multicol container shows its flow thread's band + // [k * height, (k + 1) * height). The first column stays open above and + // the last below, so ink overflowing the fragmentation edges still shows. + static bool appendColumnClip(DisplayList &list, const Node &n, const MulticolLayout &columns, int k) + { + DisplayCommand *cmd = list.append(); + if (!cmd) + return false; + const int top = n.layout.y + boxInset(n.computedStyle(), 0); + const int y0 = k == 0 ? -16384 : top; + const int y1 = k == columns.used - 1 && !columns.discard ? 16383 : top + columns.height; + cmd->type = DisplayCommandType::PushClip; + cmd->bx = cmd->clip.x = -16384; + cmd->bw = cmd->clip.w = 32767; + cmd->by = cmd->clip.y = static_cast(y0); + cmd->bh = cmd->clip.h = static_cast(y1 - y0); + cmd->clip.nodeId = -1; + cmd->clip.shaped = 0; + return true; + } + + static void appendColumnPop(DisplayList &list) + { + DisplayCommand *cmd = list.append(); + if (!cmd) + return; + cmd->type = DisplayCommandType::PopClip; + cmd->bx = cmd->by = 0; + cmd->bw = cmd->bh = 0; + cmd->clip.nodeId = -1; + } + + // Paints the flow thread recorded at [begin, end) into every further + // column box: a copy translated k columns along the inline direction and + // k column heights up. In rtl the columns progress from the right edge. + static void replicateColumns(DisplayList &list, const Node &n, const MulticolLayout &columns, int begin, int end) + { + const bool rtl = LayoutEngine::rightToLeftDirection(n); + const int contentWidth = n.layout.width - boxInset(n.computedStyle(), 1) - boxInset(n.computedStyle(), 3); + const int step = rtl ? -(columns.width + columns.gap) : columns.width + columns.gap; + const int start = rtl ? contentWidth - columns.width : 0; + if (start != 0) + for (int ci = begin; ci < end; ++ci) + DisplayCommandTranslator::translate(&state.commands[ci], start, 0); + appendColumnPop(list); + for (int k = 1; k < columns.used; ++k) { + if (!appendColumnClip(list, n, columns, k)) + return; + const int copyBegin = state.commandCount; + for (int ci = begin; ci < end; ++ci) { + const DisplayCommand copy = state.commands[ci]; + DisplayCommand *cmd = list.append(); + if (!cmd) + break; + *cmd = copy; + DisplayCommandTranslator::translate(cmd, k * step, -k * columns.height); + } + columnCopies().push_back({copyBegin, state.commandCount, copyBegin - begin}); + appendColumnPop(list); + } + } +#endif + static void recordNode(int id, uint8_t parent_alpha, int cx0, int cy0, int cx1, int cy1, const Node *mask_node, bool insideDirty = false, const RecordClipFrame *clips = nullptr, bool groupRoot = true, bool includePositioned = true) { if (!state.hasNodeScratchFor(id)) @@ -7927,6 +8048,26 @@ int gLastScrollUiFrame = -1000; else if (pushed_clip) ClipMath::clampToNode(*n, &child_cx0, &child_cy0, &child_cx1, &child_cy1); + // A multicol flow thread is recorded once, clipped to the first column, + // then copied into the others. Its content reaches past the container's + // box before it is translated, so it is never culled there. +#if GEA_CSS_MULTICOL + // The style bytes are checked before the rare-data lookup: most nodes + // have no columns and pay nothing further here. + MulticolLayout columns{}; + if (styleHasColumns(n->computedStyle()) && !state.appendOverride) + if (const NodeRareData *rare = rareDataFor(id); rare && rare->multicol.valid && + (rare->multicol.used > 1 || LayoutEngine::rightToLeftDirection(*n))) + columns = rare->multicol; + int columnFlowBegin = -1; + if (columns.valid) { + child_cy0 = -32768; + child_cy1 = 32767; + child_cx1 = 32767; + if (appendColumnClip(list, *n, columns, 0)) columnFlowBegin = state.commandCount; + } +#endif + const RecordClipFrame ownClip{*n, clips}; for (int child : children) { // A positioned descendant can paint in this context while its @@ -7955,6 +8096,10 @@ int gLastScrollUiFrame = -1000; recordNode(child, cur_alpha, x0, y0, x1, y1, active_mask, dirty, childClips, PaintOrder::isGroup(child), PaintOrder::isContext(child)); for (auto frame = frames.rbegin(); frame != frames.rend(); ++frame) ViewRenderer::recordClipEnd(frame->node); } +#if GEA_CSS_MULTICOL + if (columnFlowBegin >= 0) + replicateColumns(list, *n, columns, columnFlowBegin, state.commandCount); +#endif state.recordDepth--; @@ -10517,8 +10662,11 @@ int gLastScrollUiFrame = -1000; const auto &n = nodes[id]; const bool item = n.parent >= 0 && !isOutOfFlowPosition(n.computedStyle().position) && (nodes[n.parent].computedStyle().display == kDisplayFlex || isDisplayGrid(nodes[n.parent].computedStyle())); + // A multicol container paints its whole flow thread, positioned content + // included, so every column box can repeat it. return n.parent < 0 || n.computedStyle().position == kPositionFixed || ChildZSorter::effectGroup(n) || - (!n.computedStyle().z_index_auto && (n.computedStyle().position != 0 || item)); + (!n.computedStyle().z_index_auto && (n.computedStyle().position != 0 || item)) || + (styleHasColumns(n.computedStyle()) && LayoutEngine::multicolContainer(n)); } bool PaintOrder::isGroup(int id) @@ -10723,6 +10871,9 @@ int gLastScrollUiFrame = -1000; state.textClippedBackgrounds = false; state.commandOverflow = false; state.drawNodeOrderCount = 0; +#if GEA_CSS_MULTICOL + columnCopies().clear(); +#endif if (!state.ensureNodeScratchCapacity(tree.nodeCount())) return; for (int i = 0; i < tree.nodeCount(); i++) @@ -11350,6 +11501,9 @@ int gLastScrollUiFrame = -1000; return false; if (nodes[i].computedStyle().overflow == 2) return false; + // Multicol content paints as copies no node range describes. + if (styleHasColumns(nodes[i].computedStyle()) && LayoutEngine::multicolContainer(nodes[i])) + return false; } (void)width; (void)height; @@ -11363,6 +11517,15 @@ int gLastScrollUiFrame = -1000; return state.textClippedBackgrounds; } + bool DisplayList::hasColumnCopies() const + { +#if GEA_CSS_MULTICOL + return !columnCopies().empty(); +#else + return false; +#endif + } + void DisplayList::clearRetainedBackgroundRecolors() { state.retainedBackgroundRecolorCount = 0; @@ -13835,6 +13998,12 @@ int gLastScrollUiFrame = -1000; int end = state.nodeDrawEnd[node]; if (start < 0 || end < start || end > state.commandCount) return false; +#if GEA_CSS_MULTICOL + // Splicing moves commands but not the multicol column copies, which would + // then point at stale indices; only a full record rebuilds them. + if (!columnCopies().empty()) + return false; +#endif DisplayCommand tmp[96]; int tmp_len = 0; diff --git a/packages/engine/ui/root_scroll_refresh.cpp b/packages/engine/ui/root_scroll_refresh.cpp index 7b20d19..1983890 100644 --- a/packages/engine/ui/root_scroll_refresh.cpp +++ b/packages/engine/ui/root_scroll_refresh.cpp @@ -780,7 +780,9 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) !horizontal && n->type != NodeType::VirtualList && slotCount == 0 && - extraDirtyCount == 0; + extraDirtyCount == 0 && + !stickyRepaintAll && + !list.hasColumnCopies(); const int coverageDelta = scrollYNow - coverageScrollY; const bool coverageValid = translateEligible && coverageRoot == root && diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 71de38b..df377b3 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -406,6 +406,8 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) ar.max_lines != br.max_lines || ar.line_clamp_flags != br.line_clamp_flags || ar.block_ellipsis != br.block_ellipsis || + ar.column_count != br.column_count || + ar.column_width != br.column_width || a.writing_mode != b.writing_mode || a.direction != b.direction || a.row_gap != b.row_gap || @@ -636,6 +638,8 @@ bool styleEqualExceptLocalDisplayCommands(const ComputedStyle &a, const Computed ar.max_lines != br.max_lines || ar.line_clamp_flags != br.line_clamp_flags || ar.block_ellipsis != br.block_ellipsis || + ar.column_count != br.column_count || + ar.column_width != br.column_width || a.writing_mode != b.writing_mode || a.direction != b.direction || a.row_gap != b.row_gap || @@ -1215,6 +1219,8 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "columns") == 0) return CssDeclarationId::Columns; if (std::strcmp(property, "column-count") == 0) return CssDeclarationId::ColumnCount; if (std::strcmp(property, "column-width") == 0) return CssDeclarationId::ColumnWidth; + if (std::strcmp(property, "column-fill") == 0) return CssDeclarationId::ColumnFill; + if (std::strcmp(property, "column-span") == 0) return CssDeclarationId::ColumnSpan; if (std::strcmp(property, "clear") == 0) return CssDeclarationId::Clear; if (std::strcmp(property, "direction") == 0) return CssDeclarationId::Direction; if (std::strcmp(property, "writing-mode") == 0) return CssDeclarationId::WritingMode; @@ -6946,7 +6952,7 @@ const CssCompiledBackground *compiledCssBackgroundForHandle(std::uint16_t handle bool backgroundImageIsValid(int handle, int nodeId); // line-clamp (CSS Overflow 4) and its longhands as MaxLines, LineClampContinue, -// BlockEllipsis and BlockEllipsisString writes. A custom ellipsis string is +// LineClampDiscard, BlockEllipsis and BlockEllipsisString writes. A custom ellipsis string is // interned as a CSS atom; an empty one paints nothing, like none. // Returns the write count, 0 for an invalid value, -1 for other declarations. int lineClampWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[5]) @@ -6982,7 +6988,8 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat if (declaration == CssDeclarationId::Continue) { if (quoted || words.size() != 1 || (words[0] != "auto" && words[0] != "collapse" && words[0] != "discard")) return 0; out[0] = {Property::LineClampContinue, words[0] != "auto"}; - return 1; + out[1] = {Property::LineClampDiscard, words[0] == "discard"}; + return 2; } if (declaration == CssDeclarationId::BlockEllipsis) { const bool keyword = !quoted && words.size() == 1 && (words[0] == "none" || words[0] == "auto"); @@ -6998,7 +7005,8 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat out[1] = {Property::LineClampContinue, 0}; out[2] = {Property::BlockEllipsis, 0}; out[3] = {Property::BlockEllipsisString, 0}; - return 4; + out[4] = {Property::LineClampDiscard, 0}; + return 5; } for (const auto &word : words) { if (lineCount(word, lines)) continue; @@ -7013,26 +7021,52 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat out[1] = {Property::LineClampContinue, declaration == CssDeclarationId::LineClamp}; out[2] = {Property::BlockEllipsis, ellipsis && (!quoted || customAtom != 0)}; out[3] = {Property::BlockEllipsisString, customAtom}; - return 4; + out[4] = {Property::LineClampDiscard, 0}; + return 5; } // Only whether columns are set matters: line-clamp skips multicol containers. -int multicolWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[2]) +// columns / column-count / column-width / column-fill as ColumnCount(Set) and +// ColumnWidth(Set) writes plus the column-fill flag. A column width other than +// px is not resolved here and leaves the width auto. +int multicolWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[4]) { + const auto words = splitWords(toLowerAscii(trimCssValue(value))); + if (declaration == CssDeclarationId::ColumnSpan) { + if (words.size() != 1 || (words[0] != "none" && words[0] != "all")) return 0; + out[0] = {Property::ColumnSpanAll, words[0] == "all"}; + return 1; + } + if (declaration == CssDeclarationId::ColumnFill) { + if (words.size() != 1 || (words[0] != "auto" && words[0] != "balance" && words[0] != "balance-all")) return 0; + out[0] = {Property::ColumnFillAuto, words[0] == "auto"}; + return 1; + } const bool shorthand = declaration == CssDeclarationId::Columns; if (!shorthand && declaration != CssDeclarationId::ColumnCount && declaration != CssDeclarationId::ColumnWidth) return -1; - const auto words = splitWords(toLowerAscii(trimCssValue(value))); if (words.empty() || words.size() > (shorthand ? 2u : 1u)) return 0; bool count = false, width = false; + int countValue = 0, widthValue = -1; for (const auto &word : words) { if (word == "auto") continue; - if (word.find_first_not_of("0123456789") == std::string::npos && word != "0" && declaration != CssDeclarationId::ColumnWidth) count = true; - else if (CssLengthSpec length; declaration != CssDeclarationId::ColumnCount && parseCompiledLengthSpec(word, length)) width = true; - else return 0; + if (word.find_first_not_of("0123456789") == std::string::npos && word != "0" && declaration != CssDeclarationId::ColumnWidth) { + count = true; + countValue = std::min(255, std::atoi(word.c_str())); + } else if (CssLengthSpec length; declaration != CssDeclarationId::ColumnCount && parseCompiledLengthSpec(word, length)) { + width = true; + if (length.unit == CssLengthUnit::Px || length.unit == CssLengthUnit::Raw) + widthValue = std::max(1, std::min(32767, length.unit == CssLengthUnit::Px ? cssPixelLength(length.value) : rawNumber(length.value))); + } else return 0; } int n = 0; - if (declaration != CssDeclarationId::ColumnWidth) out[n++] = {Property::ColumnCountSet, count}; - if (declaration != CssDeclarationId::ColumnCount) out[n++] = {Property::ColumnWidthSet, width}; + if (declaration != CssDeclarationId::ColumnWidth) { + out[n++] = {Property::ColumnCountSet, count}; + out[n++] = {Property::ColumnCount, countValue}; + } + if (declaration != CssDeclarationId::ColumnCount) { + out[n++] = {Property::ColumnWidthSet, width}; + out[n++] = {Property::ColumnWidth, widthValue}; + } return n; } @@ -7996,17 +8030,28 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #if GEA_CSS_MARGIN_TRIM case Property::MarginTrim: rstyleMut(style).margin_trim = value; return true; #endif +#if GEA_CSS_LINE_CLAMP case Property::MaxLines: rstyleMut(style).max_lines = static_cast(value); return true; case Property::BlockEllipsisString: rstyleMut(style).block_ellipsis = static_cast(value); return true; +#endif +#if GEA_CSS_MULTICOL + case Property::ColumnCount: rstyleMut(style).column_count = static_cast(value); return true; + case Property::ColumnWidth: rstyleMut(style).column_width = static_cast(value); return true; +#endif +#if GEA_CSS_LINE_CLAMP || GEA_CSS_MULTICOL case Property::LineClampContinue: case Property::BlockEllipsis: case Property::ColumnCountSet: - case Property::ColumnWidthSet: { + case Property::ColumnWidthSet: + case Property::ColumnFillAuto: + case Property::LineClampDiscard: + case Property::ColumnSpanAll: { const int bit = lineClampFlagBit(property); auto &flags = rstyleMut(style).line_clamp_flags; flags = static_cast(value ? flags | bit : flags & ~bit); return true; } +#endif #if GEA_CSS_FLOATS case Property::Clear: style.clear_side = value; return true; #endif @@ -9859,7 +9904,9 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::BlockEllipsis: case CssDeclarationId::Columns: case CssDeclarationId::ColumnCount: - case CssDeclarationId::ColumnWidth: { + case CssDeclarationId::ColumnWidth: + case CssDeclarationId::ColumnFill: + case CssDeclarationId::ColumnSpan: { StaticStylePropertyValue writes[4]; int count = lineClampWrites(declaration, value, writes); if (count < 0) count = multicolWrites(declaration, value, writes); @@ -11866,13 +11913,15 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "float") return removeInlineStyleProperties(id, {Property::Float}); if (property == "margin-trim") return removeInlineStyleProperties(id, {Property::MarginTrim}); if (property == "line-clamp" || property == "-webkit-line-clamp") - return removeInlineStyleProperties(id, {Property::MaxLines, Property::LineClampContinue, Property::BlockEllipsis, Property::BlockEllipsisString}); + return removeInlineStyleProperties(id, {Property::MaxLines, Property::LineClampContinue, Property::LineClampDiscard, Property::BlockEllipsis, Property::BlockEllipsisString}); if (property == "max-lines") return removeInlineStyleProperties(id, {Property::MaxLines}); - if (property == "continue") return removeInlineStyleProperties(id, {Property::LineClampContinue}); + if (property == "continue") return removeInlineStyleProperties(id, {Property::LineClampContinue, Property::LineClampDiscard}); if (property == "block-ellipsis") return removeInlineStyleProperties(id, {Property::BlockEllipsis, Property::BlockEllipsisString}); - if (property == "columns") return removeInlineStyleProperties(id, {Property::ColumnCountSet, Property::ColumnWidthSet}); - if (property == "column-count") return removeInlineStyleProperties(id, {Property::ColumnCountSet}); - if (property == "column-width") return removeInlineStyleProperties(id, {Property::ColumnWidthSet}); + if (property == "columns") return removeInlineStyleProperties(id, {Property::ColumnCountSet, Property::ColumnWidthSet, Property::ColumnCount, Property::ColumnWidth}); + if (property == "column-count") return removeInlineStyleProperties(id, {Property::ColumnCountSet, Property::ColumnCount}); + if (property == "column-width") return removeInlineStyleProperties(id, {Property::ColumnWidthSet, Property::ColumnWidth}); + if (property == "column-fill") return removeInlineStyleProperties(id, {Property::ColumnFillAuto}); + if (property == "column-span") return removeInlineStyleProperties(id, {Property::ColumnSpanAll}); if (property == "clear") return removeInlineStyleProperties(id, {Property::Clear}); if (property == "direction") return removeInlineStyleProperties(id, {Property::Direction}); if (property == "writing-mode") return removeInlineStyleProperties(id, {Property::WritingMode}); diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 7680423..ef080fc 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -220,6 +220,11 @@ enum class Property : int { ColumnWidthSet, VerticalAlign, BlockEllipsisString, + ColumnCount, + ColumnWidth, + ColumnFillAuto, + LineClampDiscard, + ColumnSpanAll, Count }; @@ -227,7 +232,8 @@ enum class Property : int { inline int lineClampFlagBit(Property property) { return property == Property::LineClampContinue ? 1 : property == Property::BlockEllipsis ? 2 : - property == Property::ColumnCountSet ? 4 : 8; + property == Property::ColumnCountSet ? 4 : property == Property::ColumnWidthSet ? 8 : + property == Property::ColumnFillAuto ? 16 : property == Property::LineClampDiscard ? 32 : 64; } enum class StyleDeclaration : std::uint8_t { @@ -371,6 +377,8 @@ enum class StyleDeclaration : std::uint8_t { Columns, ColumnCount, ColumnWidth, + ColumnFill, + ColumnSpan, VerticalAlign }; diff --git a/packages/engine/ui/tree_render.cpp b/packages/engine/ui/tree_render.cpp index 28440e4..1421953 100644 --- a/packages/engine/ui/tree_render.cpp +++ b/packages/engine/ui/tree_render.cpp @@ -2459,6 +2459,11 @@ namespace gea::embedded::ui int dy = n->layout.y - n->layout.previous_y - inherited_dy; if (dx || dy) { + if (DisplayList::instance().hasColumnCopies()) + { + can_keep_display_list = 0; + break; + } if (n->first_child >= 0) DisplayList::instance().translateSubtreeCommands(i, dx, dy); else diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index 30ff981..c668a9c 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -120,6 +120,19 @@ struct InlineStaticPosition { bool valid = false; }; +// Column boxes of a multicol container (CSS Multi-column). Layout places the +// content as one column `width` wide; painting slices it into column boxes +// `height` tall and `gap` apart, `used` of them, first to last. `discard` +// (continue: discard) drops what overflows the last column box. +struct MulticolLayout { + std::int16_t width = 0; + std::int16_t gap = 0; + std::int16_t height = 0; + std::int16_t used = 0; + bool valid = false; + bool discard = false; +}; + struct NodeCustomProperty { CssAtomId nameId = kInvalidCssAtom; CssAtomId valueAtom = kInvalidCssAtom; @@ -245,6 +258,11 @@ struct NodeRareData { inline static constexpr FirstLineBackground firstLineBackground{}; inline static constexpr FirstLineFragment firstLineFragment{}; #endif +#if GEA_CSS_MULTICOL + MulticolLayout multicol; +#else + inline static constexpr MulticolLayout multicol{}; +#endif void clear() { @@ -267,6 +285,9 @@ struct NodeRareData { firstLineFragment = FirstLineFragment{}; #endif inlineStaticPosition = InlineStaticPosition{}; +#if GEA_CSS_MULTICOL + multicol = MulticolLayout{}; +#endif } }; diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 5c30448..96a4cf9 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -93,6 +93,11 @@ bool isLayoutProperty(Property prop) case Property::ColumnCountSet: case Property::ColumnWidthSet: case Property::BlockEllipsisString: + case Property::ColumnCount: + case Property::ColumnWidth: + case Property::ColumnFillAuto: + case Property::LineClampDiscard: + case Property::ColumnSpanAll: case Property::VerticalAlign: #if GEA_CSS_TEXT_ALIGN // Line shifts and inline static positions follow physicalTextAlign. @@ -362,17 +367,28 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn #if GEA_CSS_MARGIN_TRIM case Property::MarginTrim: if ((GEA_CSS_MARGIN_TRIM ? rstyle(style).margin_trim : 0) != value) { rstyleMut(style).margin_trim = value; changed = 1; } break; #endif - case Property::MaxLines: if (rstyle(n->style).max_lines != value) { rstyleMut(n->style).max_lines = value; changed = 1; } break; - case Property::BlockEllipsisString: if (rstyle(n->style).block_ellipsis != value) { rstyleMut(n->style).block_ellipsis = value; changed = 1; } break; +#if GEA_CSS_LINE_CLAMP + case Property::MaxLines: if (rstyle(style).max_lines != value) { rstyleMut(style).max_lines = value; changed = 1; } break; + case Property::BlockEllipsisString: if (rstyle(style).block_ellipsis != value) { rstyleMut(style).block_ellipsis = value; changed = 1; } break; +#endif +#if GEA_CSS_MULTICOL + case Property::ColumnCount: if (rstyle(style).column_count != value) { rstyleMut(style).column_count = value; changed = 1; } break; + case Property::ColumnWidth: if (rstyle(style).column_width != value) { rstyleMut(style).column_width = value; changed = 1; } break; +#endif +#if GEA_CSS_LINE_CLAMP || GEA_CSS_MULTICOL case Property::LineClampContinue: case Property::BlockEllipsis: case Property::ColumnCountSet: - case Property::ColumnWidthSet: { + case Property::ColumnWidthSet: + case Property::ColumnFillAuto: + case Property::LineClampDiscard: + case Property::ColumnSpanAll: { const int bit = lineClampFlagBit(prop); const int next = value ? rstyle(style).line_clamp_flags | bit : rstyle(style).line_clamp_flags & ~bit; if (rstyle(style).line_clamp_flags != next) { rstyleMut(style).line_clamp_flags = next; changed = 1; } break; } +#endif #if GEA_CSS_FLOATS case Property::Clear: if (style.clear_side != value) { style.clear_side = value; changed = 1; } break; #endif From 8eb91b8a28d90a92e00f9adebf05b57e6118028b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 02:38:25 +0300 Subject: [PATCH 20/50] Support border-style and its longhands border-style and border-top/right/bottom/left-style were not parsed at all, so border: 15px aqua; border-style: none solid painted all four sides. They now write each side's relief (groove, ridge, inset, outset now work outside the border shorthand too); dashed, dotted and double paint solid. none and hidden set a bit in the side's relief byte: the side paints with width 0 and keeps its declared width in border_none_width, which comes back when the style changes back, so the order of border-style and border-width declarations does not matter. The border shorthand records none and hidden as that bit, so a later border-width alone cannot bring the border back, and resets the styles before it writes the widths. The class-rule fast setter applies the same rule for the all-sides property, and relief properties mark layout dirty. Painters mask the bit out of the relief. border_none_width adds 8 bytes to RareStyle under GEA_CSS_BORDER_RELIEF. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Fable 5.1 --- packages/engine/ui/node_model.h | 34 ++++++++++- packages/engine/ui/style.cpp | 93 +++++++++++++++++++++++++++---- packages/engine/ui/style.h | 5 ++ packages/engine/ui/tree_style.cpp | 6 ++ packages/engine/ui/view.cpp | 2 +- 5 files changed, 124 insertions(+), 16 deletions(-) diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index bf1abf8..7053012 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -168,11 +168,22 @@ struct RareStyle { #else static constexpr uint8_t margin_trim = 0; #endif +#if GEA_CSS_MULTICOL // Multi-column: column-count (0 = auto). Sits in padding. uint8_t column_count = 0; + // Multi-column: column-width in px, -1 = auto. Sits in padding. + int16_t column_width = -1; +#else + static constexpr uint8_t column_count = 0; + static constexpr int16_t column_width = -1; +#endif +#if GEA_CSS_LINE_CLAMP // CSS atom of a custom block-ellipsis string; 0 = the default ellipsis. // Only used when line_clamp_flags has block-ellipsis set. Sits in padding. uint16_t block_ellipsis = 0; +#else + static constexpr uint16_t block_ellipsis = 0; +#endif // IEEE float bits travel through the existing integer style-value transport. // Negative ratios mean `auto ` (prefer a replaced element's natural ratio). #if GEA_CSS_ASPECT_RATIO @@ -367,10 +378,16 @@ struct RareStyle { static constexpr int16_t border_side_width[4] = {}; #endif // 0: flat; 1: groove; 2: ridge; 3: inset; 4: outset. + // Per side: 1-4 groove / ridge / inset / outset, and kBorderStyleNone when + // border-style is none or hidden (the side then keeps a zero width). #if GEA_CSS_BORDER_RELIEF uint8_t border_relief[4] = {}; + // The width a side declared while its border-style is none or hidden. The + // side paints with width 0 until the style changes back, then takes this. + int16_t border_none_width[4] = {}; #else static constexpr uint8_t border_relief[4] = {}; + static constexpr int16_t border_none_width[4] = {}; #endif #if GEA_CSS_SIDE_BORDERS style_color_t border_side_color[4] = { @@ -397,8 +414,6 @@ struct RareStyle { // center/extent 500/1000) --- #if GEA_CSS_BACKGROUND_LAYERS uint16_t bg_image_layer_count = 1; // Includes none layers. - // Multi-column: column-width in px, -1 = auto. Sits in padding. - int16_t column_width = -1; int32_t bg_size_list = -1, bg_position_list = -1, bg_repeat_list = -1; int32_t bg_attachment_list = -1, bg_origin_list = -1; #else @@ -1117,6 +1132,9 @@ inline int computedBorderWidth(const ComputedStyle &style, int side) #endif } +// RareStyle::border_relief bit for border-style: none / hidden on that side. +inline constexpr uint8_t kBorderStyleNone = 0x80; + inline bool setComputedBorderWidth(ComputedStyle &style, int side, int value, const ComputedStyle *parent) { #if GEA_CSS_BORDER_WIDTHS @@ -1126,6 +1144,15 @@ inline bool setComputedBorderWidth(ComputedStyle &style, int side, int value, co widths[i] = computedBorderWidth(style, i); if (side < 0 || side == i) widths[i] = value == kInheritedBorderWidth ? (parent ? computedBorderWidth(*parent, i) : 0) : value; +#if GEA_CSS_BORDER_RELIEF + // A side whose style is none has no border, whatever width it declares. + // The declared width is kept so a later border-style brings it back. + if (rstyle(style).border_relief[i] & kBorderStyleNone) { + if ((side < 0 || side == i) && rstyle(style).border_none_width[i] != widths[i]) + rstyleMut(style).border_none_width[i] = static_cast(widths[i]); + widths[i] = 0; + } +#endif } const bool uniform = side < 0 && widths[0] == widths[1] && widths[0] == widths[2] && widths[0] == widths[3]; // A side can override a wider common border with a narrower or zero edge. @@ -1403,7 +1430,8 @@ inline bool hasAnyBorder(const ComputedStyle &style) inline bool hasBorderRelief(const ComputedStyle &style) { const auto &r = rstyle(style); - return r.border_relief[0] || r.border_relief[1] || r.border_relief[2] || r.border_relief[3]; + constexpr uint8_t relief = static_cast(~kBorderStyleNone); + return (r.border_relief[0] & relief) || (r.border_relief[1] & relief) || (r.border_relief[2] & relief) || (r.border_relief[3] & relief); } #ifndef GEA_EMBEDDED_SHARED_STYLES diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index df377b3..bdbaadc 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -1254,6 +1254,11 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "border-right-width") == 0) return CssDeclarationId::BorderRightWidth; if (std::strcmp(property, "border-bottom-width") == 0) return CssDeclarationId::BorderBottomWidth; if (std::strcmp(property, "border-left-width") == 0) return CssDeclarationId::BorderLeftWidth; + if (std::strcmp(property, "border-style") == 0) return CssDeclarationId::BorderStyle; + if (std::strcmp(property, "border-top-style") == 0) return CssDeclarationId::BorderTopStyle; + if (std::strcmp(property, "border-right-style") == 0) return CssDeclarationId::BorderRightStyle; + if (std::strcmp(property, "border-bottom-style") == 0) return CssDeclarationId::BorderBottomStyle; + if (std::strcmp(property, "border-left-style") == 0) return CssDeclarationId::BorderLeftStyle; if (std::strcmp(property, "border-top-color") == 0) return CssDeclarationId::BorderTopColor; if (std::strcmp(property, "border-right-color") == 0) return CssDeclarationId::BorderRightColor; if (std::strcmp(property, "border-bottom-color") == 0) return CssDeclarationId::BorderBottomColor; @@ -6584,7 +6589,12 @@ bool compileBorderShorthandValue(const std::string &value, CssCompiledValue &com else if (!parseCompiledLengthSpec(part, compiled.lengths[0])) return false; foundWidth = true; } - if (noStroke) compiled.lengths[0] = {0.0f, CssLengthUnit::Px}; + // none and hidden record kBorderStyleNone, so a later border-width alone + // cannot bring the border back (CSS Backgrounds 3.3); the width stays 0. + if (noStroke) { + compiled.lengths[0] = {0.0f, CssLengthUnit::Px}; + compiled.values[2] = kBorderStyleNone; + } compiled.aux = 0; for (const auto &part : parts) { if (part.empty()) continue; @@ -7025,6 +7035,43 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat return 5; } +// border-style and its longhands as per-side relief writes (CSS Backgrounds +// 3.3); none and hidden set kBorderStyleNone, which keeps the side's width 0. +// dashed, dotted and double paint as solid. +int borderStyleWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[4]) +{ + int first = -1; + switch (declaration) { + case CssDeclarationId::BorderStyle: first = 0; break; + case CssDeclarationId::BorderTopStyle: first = 0; break; + case CssDeclarationId::BorderRightStyle: first = 1; break; + case CssDeclarationId::BorderBottomStyle: first = 2; break; + case CssDeclarationId::BorderLeftStyle: first = 3; break; + default: return -1; + } + const auto words = splitWords(toLowerAscii(trimCssValue(value))); + const bool shorthand = declaration == CssDeclarationId::BorderStyle; + if (words.empty() || words.size() > (shorthand ? 4u : 1u)) return 0; + int reliefs[4]; + for (std::size_t i = 0; i < words.size(); ++i) { + const auto &w = words[i]; + if (w == "none" || w == "hidden") reliefs[i] = kBorderStyleNone; + else if (w == "solid" || w == "dashed" || w == "dotted" || w == "double") reliefs[i] = 0; + else if (w == "groove" || w == "ridge" || w == "inset" || w == "outset") + reliefs[i] = w == "groove" ? 1 : w == "ridge" ? 2 : w == "inset" ? 3 : 4; + else return 0; + } + if (!shorthand) { + out[0] = {static_cast(static_cast(Property::BorderTopRelief) + first), reliefs[0]}; + return 1; + } + // top, right, bottom, left from one to four values. + const int count = static_cast(words.size()); + const int side[4] = {reliefs[0], reliefs[count > 1 ? 1 : 0], reliefs[count > 2 ? 2 : 0], reliefs[count > 3 ? 3 : count > 1 ? 1 : 0]}; + for (int i = 0; i < 4; ++i) out[i] = {static_cast(static_cast(Property::BorderTopRelief) + i), side[i]}; + return 4; +} + // Only whether columns are set matters: line-clamp skips multicol containers. // columns / column-count / column-width / column-fill as ColumnCount(Set) and // ColumnWidth(Set) writes plus the column-fill flag. A column width other than @@ -7567,6 +7614,7 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa StaticStylePropertyValue clampWrites[4]; int clampCount = hasVar ? -1 : lineClampWrites(declaration, value, clampWrites); if (clampCount < 0 && !hasVar) clampCount = multicolWrites(declaration, value, clampWrites); + if (clampCount < 0 && !hasVar) clampCount = borderStyleWrites(declaration, value, clampWrites); if (const int count = clampCount; count >= 0) { if (count == 0) return kNoCompiledCssValue; compiled.kind = CssCompiledKind::DirectPropertyGroup; @@ -8420,18 +8468,27 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) return true; #endif #if GEA_CSS_BORDER_RELIEF + case Property::BorderRelief: case Property::BorderTopRelief: case Property::BorderRightRelief: case Property::BorderBottomRelief: case Property::BorderLeftRelief: { - const int side = static_cast(property) - static_cast(Property::BorderTopRelief); - if (rstyle(style).border_relief[side] != value) rstyleMut(style).border_relief[side] = static_cast(value); + if (property == Property::BorderRelief && value == 0) { + const auto &relief = rstyle(style).border_relief; + if (!(relief[0] | relief[1] | relief[2] | relief[3])) return true; + } + for (int side = 0; side < 4; ++side) { + if (property != Property::BorderRelief && side != static_cast(property) - static_cast(Property::BorderTopRelief)) continue; + const bool wasNone = rstyle(style).border_relief[side] & kBorderStyleNone; + if (rstyle(style).border_relief[side] != value) rstyleMut(style).border_relief[side] = static_cast(value); + // border-style: none leaves the side without a border but keeps its + // declared width, which comes back when the style does. + const bool isNone = value & kBorderStyleNone; + if (isNone && !wasNone) setComputedBorderWidth(style, side, computedBorderWidth(style, side), nullptr); + else if (wasNone && !isNone) setComputedBorderWidth(style, side, rstyle(style).border_none_width[side], nullptr); + } return true; } - case Property::BorderRelief: - if (value == 0 && !hasBorderRelief(style)) return true; - for (int side = 0; side < 4; ++side) rstyleMut(style).border_relief[side] = static_cast(value); - return true; #endif #if GEA_CSS_SIDE_BORDERS case Property::BorderTopWidth: setComputedBorderWidth(style, 0, value, target.parent >= 0 ? &treeState().nodes[target.parent].computedStyle() : nullptr); return true; @@ -9177,9 +9234,10 @@ void applyBorderShorthand(NodeHandle node, const std::string &value, StyleApplic const auto parts = splitFunctionAwareWords(value); const int width = resolveBorderWidth(compiled.lengths[0], node.id()); if (width < 0) return; - setLengthStyleValue(node, compiled.lengths[0], Property::BorderWidth, width, source); + // The shorthand resets border-style first, so a side that was none takes the width. for (int side = 0; side < 4; ++side) setStyleValue(node, static_cast(static_cast(Property::BorderTopRelief) + side), compiled.values[2], source); + setLengthStyleValue(node, compiled.lengths[0], Property::BorderWidth, width, source); for (const auto &part : parts) { if (part.empty()) continue; if (parseCssColor(part).valid) { @@ -9906,10 +9964,16 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::ColumnCount: case CssDeclarationId::ColumnWidth: case CssDeclarationId::ColumnFill: - case CssDeclarationId::ColumnSpan: { + case CssDeclarationId::ColumnSpan: + case CssDeclarationId::BorderStyle: + case CssDeclarationId::BorderTopStyle: + case CssDeclarationId::BorderRightStyle: + case CssDeclarationId::BorderBottomStyle: + case CssDeclarationId::BorderLeftStyle: { StaticStylePropertyValue writes[4]; int count = lineClampWrites(declaration, value, writes); if (count < 0) count = multicolWrites(declaration, value, writes); + if (count < 0) count = borderStyleWrites(declaration, value, writes); for (int i = 0; i < count; ++i) setStyleValue(node, writes[i].property, writes[i].value, source); return true; } @@ -11135,11 +11199,11 @@ bool applyRuntimeBorderShorthandValue(NodeHandle node, Node &target = state.nodes[nodeId]; const int snappedWidth = resolveBorderWidth(width, nodeId); if (snappedWidth < 0) return true; + for (int side = 0; side < 4; ++side) + setStyleValue(node, static_cast(static_cast(Property::BorderTopRelief) + side), relief, source); setUniformBorderWidth(node, snappedWidth, source); if (source == StyleApplicationSource::Inline && width.unit == CssLengthUnit::Px) ensureRareData(node.id()).inlineStyles.setCssPixels(Property::BorderWidth, width.value); - for (int side = 0; side < 4; ++side) - setStyleValue(node, static_cast(static_cast(Property::BorderTopRelief) + side), relief, source); if (alpha >= 0) { setStyleValue(node, Property::BorderColor, color, source); #if GEA_CSS_BORDER_ALPHA @@ -11956,6 +12020,11 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "border-right-width") return removeInlineStyleProperties(id, {Property::BorderRightWidth}); if (property == "border-bottom-width") return removeInlineStyleProperties(id, {Property::BorderBottomWidth}); if (property == "border-left-width") return removeInlineStyleProperties(id, {Property::BorderLeftWidth}); + if (property == "border-style") return removeInlineStyleProperties(id, {Property::BorderTopRelief, Property::BorderRightRelief, Property::BorderBottomRelief, Property::BorderLeftRelief}); + if (property == "border-top-style") return removeInlineStyleProperties(id, {Property::BorderTopRelief}); + if (property == "border-right-style") return removeInlineStyleProperties(id, {Property::BorderRightRelief}); + if (property == "border-bottom-style") return removeInlineStyleProperties(id, {Property::BorderBottomRelief}); + if (property == "border-left-style") return removeInlineStyleProperties(id, {Property::BorderLeftRelief}); if (property == "border-top-color") return removeInlineStyleProperties(id, {Property::BorderTopColor, Property::BorderTopAlpha, Property::BorderTopColorCurrent}); if (property == "border-right-color") return removeInlineStyleProperties(id, {Property::BorderRightColor, Property::BorderRightAlpha, Property::BorderRightColorCurrent}); if (property == "border-bottom-color") return removeInlineStyleProperties(id, {Property::BorderBottomColor, Property::BorderBottomAlpha, Property::BorderBottomColorCurrent}); @@ -14619,8 +14688,8 @@ bool buildCachedStyleApplyOp(const CssCompiledValue *compiled, CachedStyleApplyO op.values[3] = compiled->aux != 0 ? compiled->values[1] : -1; return true; } - if (!addCachedUniformBorderWidth(op, width)) return false; if (!addCachedStyleApplyProperty(op, Property::BorderRelief, compiled->values[2])) return false; + if (!addCachedUniformBorderWidth(op, width)) return false; return compiled->aux == 0 ? addCachedStyleApplyProperty(op, Property::BorderColorCurrent, 1) : addCachedStyleApplyProperty(op, Property::BorderColor, compiled->values[0]); } case CssCompiledKind::BorderSideShorthand: { diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index ef080fc..91c3d7a 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -379,6 +379,11 @@ enum class StyleDeclaration : std::uint8_t { ColumnWidth, ColumnFill, ColumnSpan, + BorderStyle, + BorderTopStyle, + BorderRightStyle, + BorderBottomStyle, + BorderLeftStyle, VerticalAlign }; diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 96a4cf9..e39f921 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -878,10 +878,16 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::BorderLeftRelief: for (int side = 0; side < 4; ++side) { if (prop != Property::BorderRelief && side != static_cast(prop) - static_cast(Property::BorderTopRelief)) continue; + const bool wasNone = rstyle(style).border_relief[side] & kBorderStyleNone; if (rstyle(style).border_relief[side] != value) { rstyleMut(style).border_relief[side] = static_cast(value); changed = 1; } + // border-style: none leaves the side without a border but keeps its + // declared width, which comes back when the style does. + const bool isNone = value & kBorderStyleNone; + if (isNone && !wasNone) changed |= setComputedBorderWidth(style, side, computedBorderWidth(style, side), nullptr); + else if (wasNone && !isNone) changed |= setComputedBorderWidth(style, side, rstyle(style).border_none_width[side], nullptr); } break; #endif diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 93f6c4d..8eb2961 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -1556,7 +1556,7 @@ void appendReliefBordersWithAlpha(const Node &node, uint8_t parentAlpha) const bool transformed = ViewGeometry::hasTransformChain(node, false); for (int side = 0; side < 4; ++side) { if (widths[side] <= 0) continue; - const int relief = rstyle(node.computedStyle()).border_relief[side]; + const int relief = rstyle(node.computedStyle()).border_relief[side] & ~kBorderStyleNone; const int bands = relief == 1 || relief == 2 ? 2 : 1; const auto color = borderPaintColor(node.computedStyle(), side); int r, g, b; pixel::unpackRgb565(color, &r, &g, &b); From d1facdd44a002ca5d0ff4c9e97b47630bb314d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 02:44:44 +0300 Subject: [PATCH 21/50] Support logical properties for horizontal-tb ltr Logical properties were ignored. Single-side longhands (inline-size, block-size and their min-/max- forms, margin-, padding-, inset- and border- inline/block start/end including -width/-color/-style, and the logical corner radii) now classify as their physical counterparts, so the rest of the style system applies them unchanged. The two-sided shorthands (margin-/padding-/inset-inline and -block with one or two values, border-inline/-block and their -width/-color/-style forms) are applied as their two physical sides. The mapping assumes horizontal-tb and ltr; rtl and vertical writing modes still map to the same physical sides. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/style.cpp | 133 +++++++++++++++++++++++++++++++++++ packages/engine/ui/style.h | 15 ++++ 2 files changed, 148 insertions(+) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index bdbaadc..b5a0da6 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -1135,6 +1135,88 @@ enum class CssRuleProperty : std::uint8_t { using CssDeclarationId = StyleDeclaration; +// CSS Logical Properties 4, resolved for horizontal-tb ltr: the inline axis is +// horizontal and starts on the left. Other writing modes and rtl still map this +// way. A logical longhand classifies as its physical property. +const char *physicalLogicalLonghand(const char *property) +{ + static const std::pair longhands[] = { + {"inline-size", "width"}, + {"block-size", "height"}, + {"min-inline-size", "min-width"}, + {"min-block-size", "min-height"}, + {"max-inline-size", "max-width"}, + {"max-block-size", "max-height"}, + {"margin-inline-start", "margin-left"}, + {"margin-inline-end", "margin-right"}, + {"margin-block-start", "margin-top"}, + {"margin-block-end", "margin-bottom"}, + {"padding-inline-start", "padding-left"}, + {"padding-inline-end", "padding-right"}, + {"padding-block-start", "padding-top"}, + {"padding-block-end", "padding-bottom"}, + {"inset-inline-start", "left"}, + {"inset-inline-end", "right"}, + {"inset-block-start", "top"}, + {"inset-block-end", "bottom"}, + {"border-inline-start", "border-left"}, + {"border-inline-end", "border-right"}, + {"border-block-start", "border-top"}, + {"border-block-end", "border-bottom"}, + {"border-inline-start-width", "border-left-width"}, + {"border-inline-start-color", "border-left-color"}, + {"border-inline-start-style", "border-left-style"}, + {"border-inline-end-width", "border-right-width"}, + {"border-inline-end-color", "border-right-color"}, + {"border-inline-end-style", "border-right-style"}, + {"border-block-start-width", "border-top-width"}, + {"border-block-start-color", "border-top-color"}, + {"border-block-start-style", "border-top-style"}, + {"border-block-end-width", "border-bottom-width"}, + {"border-block-end-color", "border-bottom-color"}, + {"border-block-end-style", "border-bottom-style"}, + {"border-start-start-radius", "border-top-left-radius"}, + {"border-start-end-radius", "border-top-right-radius"}, + {"border-end-start-radius", "border-bottom-left-radius"}, + {"border-end-end-radius", "border-bottom-right-radius"} + }; + for (const auto &entry : longhands) + if (std::strcmp(property, entry.first) == 0) return entry.second; + return nullptr; +} + +// A logical shorthand for two physical sides: `split` values give the second +// side its own value (one value sets both); otherwise both take the value. +struct LogicalPair { + const char *name; + StyleDeclaration declaration; + StyleDeclaration first, second; + bool split; +}; + +const LogicalPair *logicalPairFor(const char *property, StyleDeclaration declaration = StyleDeclaration::Unknown) +{ + static const LogicalPair pairs[] = { + {"margin-inline", StyleDeclaration::MarginInline, StyleDeclaration::MarginLeft, StyleDeclaration::MarginRight, true}, + {"margin-block", StyleDeclaration::MarginBlock, StyleDeclaration::MarginTop, StyleDeclaration::MarginBottom, true}, + {"padding-inline", StyleDeclaration::PaddingInline, StyleDeclaration::PaddingLeft, StyleDeclaration::PaddingRight, true}, + {"padding-block", StyleDeclaration::PaddingBlock, StyleDeclaration::PaddingTop, StyleDeclaration::PaddingBottom, true}, + {"inset-inline", StyleDeclaration::InsetInline, StyleDeclaration::Left, StyleDeclaration::Right, true}, + {"inset-block", StyleDeclaration::InsetBlock, StyleDeclaration::Top, StyleDeclaration::Bottom, true}, + {"border-inline", StyleDeclaration::BorderInline, StyleDeclaration::BorderLeft, StyleDeclaration::BorderRight, false}, + {"border-block", StyleDeclaration::BorderBlock, StyleDeclaration::BorderTop, StyleDeclaration::BorderBottom, false}, + {"border-inline-width", StyleDeclaration::BorderInlineWidth, StyleDeclaration::BorderLeftWidth, StyleDeclaration::BorderRightWidth, true}, + {"border-block-width", StyleDeclaration::BorderBlockWidth, StyleDeclaration::BorderTopWidth, StyleDeclaration::BorderBottomWidth, true}, + {"border-inline-color", StyleDeclaration::BorderInlineColor, StyleDeclaration::BorderLeftColor, StyleDeclaration::BorderRightColor, true}, + {"border-block-color", StyleDeclaration::BorderBlockColor, StyleDeclaration::BorderTopColor, StyleDeclaration::BorderBottomColor, true}, + {"border-inline-style", StyleDeclaration::BorderInlineStyle, StyleDeclaration::BorderLeftStyle, StyleDeclaration::BorderRightStyle, true}, + {"border-block-style", StyleDeclaration::BorderBlockStyle, StyleDeclaration::BorderTopStyle, StyleDeclaration::BorderBottomStyle, true} + }; + for (const auto &pair : pairs) + if (property ? std::strcmp(property, pair.name) == 0 : pair.declaration == declaration) return &pair; + return nullptr; +} + CssDeclarationId classifyDeclaration(const char *property) { if (!property || !*property) return CssDeclarationId::Unknown; @@ -1144,6 +1226,10 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "right") == 0) return CssDeclarationId::Right; if (std::strcmp(property, "bottom") == 0) return CssDeclarationId::Bottom; if (property[0] == '-' && property[1] == '-') return CssDeclarationId::Custom; + if (std::strstr(property, "inline") || std::strstr(property, "block") || std::strstr(property, "start") || std::strstr(property, "end")) { + if (const char *physical = physicalLogicalLonghand(property)) return classifyDeclaration(physical); + if (const LogicalPair *pair = logicalPairFor(property)) return pair->declaration; + } if (std::strcmp(property, "color-scheme") == 0 || std::strcmp(property, "isolation") == 0 || std::strcmp(property, "letter-spacing") == 0 || @@ -10197,6 +10283,29 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl if (compileFlexBasisValue(value, compiled)) setFlexBasisValue(node, compiled.aux != 0, compiled.lengths[0], source); return true; } + case CssDeclarationId::MarginInline: + case CssDeclarationId::MarginBlock: + case CssDeclarationId::PaddingInline: + case CssDeclarationId::PaddingBlock: + case CssDeclarationId::InsetInline: + case CssDeclarationId::InsetBlock: + case CssDeclarationId::BorderInline: + case CssDeclarationId::BorderBlock: + case CssDeclarationId::BorderInlineWidth: + case CssDeclarationId::BorderBlockWidth: + case CssDeclarationId::BorderInlineColor: + case CssDeclarationId::BorderBlockColor: + case CssDeclarationId::BorderInlineStyle: + case CssDeclarationId::BorderBlockStyle: + { + const LogicalPair *pair = logicalPairFor(nullptr, declaration); + if (!pair) return true; + const auto parts = pair->split ? splitFunctionAwareWords(value) : std::vector{value}; + if (parts.empty() || parts.size() > 2) return true; + applyKnownResolvedPropertyWithSource(node, pair->first, parts[0], source); + applyKnownResolvedPropertyWithSource(node, pair->second, parts.size() > 1 ? parts[1] : parts[0], source); + return true; + } case CssDeclarationId::Padding: case CssDeclarationId::PaddingTop: case CssDeclarationId::PaddingRight: @@ -11875,6 +11984,30 @@ bool removeInlineStyleProperties(int node, std::initializer_list prope bool removeInlineStyleProperty(NodeHandle node, const std::string &property) { if (!node) return false; + if (const char *physical = physicalLogicalLonghand(property.c_str())) return removeInlineStyleProperty(node, physical); + if (const LogicalPair *pair = logicalPairFor(property.c_str())) { + const auto &names = [](StyleDeclaration side) { + static const std::pair table[] = { + {StyleDeclaration::MarginLeft, "margin-left"}, {StyleDeclaration::MarginRight, "margin-right"}, + {StyleDeclaration::MarginTop, "margin-top"}, {StyleDeclaration::MarginBottom, "margin-bottom"}, + {StyleDeclaration::PaddingLeft, "padding-left"}, {StyleDeclaration::PaddingRight, "padding-right"}, + {StyleDeclaration::PaddingTop, "padding-top"}, {StyleDeclaration::PaddingBottom, "padding-bottom"}, + {StyleDeclaration::Left, "left"}, {StyleDeclaration::Right, "right"}, {StyleDeclaration::Top, "top"}, {StyleDeclaration::Bottom, "bottom"}, + {StyleDeclaration::BorderLeft, "border-left"}, {StyleDeclaration::BorderRight, "border-right"}, + {StyleDeclaration::BorderTop, "border-top"}, {StyleDeclaration::BorderBottom, "border-bottom"}, + {StyleDeclaration::BorderLeftWidth, "border-left-width"}, {StyleDeclaration::BorderRightWidth, "border-right-width"}, + {StyleDeclaration::BorderTopWidth, "border-top-width"}, {StyleDeclaration::BorderBottomWidth, "border-bottom-width"}, + {StyleDeclaration::BorderLeftColor, "border-left-color"}, {StyleDeclaration::BorderRightColor, "border-right-color"}, + {StyleDeclaration::BorderTopColor, "border-top-color"}, {StyleDeclaration::BorderBottomColor, "border-bottom-color"}, + {StyleDeclaration::BorderLeftStyle, "border-left-style"}, {StyleDeclaration::BorderRightStyle, "border-right-style"}, + {StyleDeclaration::BorderTopStyle, "border-top-style"}, {StyleDeclaration::BorderBottomStyle, "border-bottom-style"}, + }; + for (const auto &entry : table) if (entry.first == side) return entry.second; + return ""; + }; + const bool first = removeInlineStyleProperty(node, names(pair->first)); + return removeInlineStyleProperty(node, names(pair->second)) || first; + } if (property.rfind("--", 0) == 0) { #if GEA_CSS_CUSTOM_PROPERTIES NodeRareData *rare = rareDataFor(node.id()); diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 91c3d7a..dd76287 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -384,6 +384,21 @@ enum class StyleDeclaration : std::uint8_t { BorderRightStyle, BorderBottomStyle, BorderLeftStyle, + // Logical two-sided shorthands (see logicalPairFor in style.cpp). + MarginInline, + MarginBlock, + PaddingInline, + PaddingBlock, + InsetInline, + InsetBlock, + BorderInline, + BorderBlock, + BorderInlineWidth, + BorderBlockWidth, + BorderInlineColor, + BorderBlockColor, + BorderInlineStyle, + BorderBlockStyle, VerticalAlign }; From 3da89c1e55e664c06bebc4802c3d78572abdba79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 02:58:21 +0300 Subject: [PATCH 22/50] Align lines of several inline items and resolve start by direction text-align was applied only by the text drawer, inside each run's own box, so a line such as "Hello world" never moved: it stayed start-aligned, every run aligned inside its own box instead (which lost the space before the span and could overlap runs under right alignment), and direction: rtl did not move start to the right. A line made of single-line items now shifts as a whole, its trailing collapsible space hanging, together with its inline static positions and ::first-line fragments; runs on it draw start-aligned in their boxes (RenderState::inline_baseline bit 1). A text run that owns its line is still aligned by the drawer. text-align now keeps start, left, end and right apart (0 start, 1 center, 2 right, 3 left, 4 end, also in the static values the build script compiles), and LayoutEngine::physicalTextAlign resolves start and end by direction everywhere text alignment is read. Lines where a wrapping run shares its first or last line with other items keep the old alignment. direction inherits lazily, so resolving start would walk to the root on every text record. TreeState::rtlSeen turns true when either style setter writes direction: rtl to any node and is never cleared; rightToLeft() returns ltr at once while it is false. Wrapped-text replay looks up the space advance only when hanging spaces can align (text-align not start, or text-align-last moving a line). TreeState gains one bool; the per-node structs do not change. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Opus 5.5 --- .../core/scripts/build-gea-vite-geatsc.mjs | 7 ++- packages/engine/ui/input_render.cpp | 2 +- packages/engine/ui/internal.h | 2 + packages/engine/ui/layout.cpp | 48 ++++++++++++++++--- packages/engine/ui/node_model.h | 4 +- packages/engine/ui/style.cpp | 18 +++++-- packages/engine/ui/text.cpp | 31 +++++++----- packages/engine/ui/tree_state.h | 6 +++ packages/engine/ui/tree_style.cpp | 5 +- packages/geatsc-plugin-gea/src/utils.ts | 5 +- 10 files changed, 99 insertions(+), 29 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index af336c3..1392f1f 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -2227,9 +2227,12 @@ function cssStaticPropertyValue(property, rawValue) { if (lower === 'scale-down') return direct('ImageFit', 4) return null case 'text-align': - if (value === 'left' || value === 'start') return direct('TextAlign', 0) + // 0 start, 1 center, 2 right, 3 left, 4 end (see textAlignValue in style.cpp). + if (value === 'start') return direct('TextAlign', 0) if (value === 'center') return direct('TextAlign', 1) - if (value === 'right' || value === 'end') return direct('TextAlign', 2) + if (value === 'right') return direct('TextAlign', 2) + if (value === 'left') return direct('TextAlign', 3) + if (value === 'end') return direct('TextAlign', 4) return null case 'text-decoration': case 'text-decoration-line': diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index e878f2d..1ce9753 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -158,7 +158,7 @@ void InputRenderer::record(int id) cmd->text.maxWidth = contentW; cmd->text.color = color; cmd->text.scale = textScale; - cmd->text.align = n.computedStyle().text_align; + cmd->text.align = static_cast(LayoutEngine::physicalTextAlign(n)); cmd->text.textTransform = n.computedStyle().text_transform; cmd->text.lineHeight = n.computedStyle().line_height; cmd->text.containerWidth = contentW; diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 0c1a13a..147c12c 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -552,6 +552,8 @@ void rootScrollImageHoldTick(); static bool multicolContainer(const Node &node); // Resolved direction: true for rtl. static bool rightToLeftDirection(const Node &node); + // text-align as 0 left, 1 center, 2 right: start and end follow direction. + static int physicalTextAlign(const Node &node); }; // Shared order for recording, retained transform replay, and hit testing. diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 622c9ec..a5f4eb7 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -541,6 +541,9 @@ int writingMode(const Node &node) bool rightToLeft(const Node &node) { + // Every text record and aligned line asks this; without an rtl node in the + // tree the ancestor walk below always ends at ltr. + if (!treeState().rtlSeen) return false; const Node *current = &node; while (current->computedStyle().direction < 0 && current->parent >= 0) current = &Tree::instance().nodes()[current->parent]; @@ -1201,8 +1204,19 @@ class FlexLayoutPass { padHeight_ > lineCross) lineCross = padHeight_; + // text-align moves a line of single-line items as a whole. A text run + // that owns its line, or wraps across lines, is aligned by the drawer. + const int contentEnd = j > i && isLineBreak(nodes[children_[j - 1]]) ? j - 1 : j; + const bool soleRun = !lineIsContinuation && contentEnd - i == 1 && nodes[children_[i]].type == NodeType::Text; + const int align = !lineIsContinuation && wrapIdx < 0 && !soleRun && contentEnd > i ? LayoutEngine::physicalTextAlign(node_) : 0; + int lineShift = 0; + if (align) { + const int used = pen - hangingSpace(nodes[children_[contentEnd - 1]]); + lineShift = std::max(0, align == 1 ? (contentW - used) / 2 : contentW - used); + for (int id : lineStaticChildren) ensureRareData(id).inlineStaticPosition.x += lineShift; + } placeLineItems(nodes, i, j, contentLeft, contentW, lineTop, lineCross, baseline, penStart, - !lineIsContinuation); + !lineIsContinuation, lineShift, align != 0); #if GEA_CSS_FIRST_LINE if (i == 0 && captureFirstLine) { int firstFormattedLineHeight = actualLineCross; @@ -1232,7 +1246,7 @@ class FlexLayoutPass { for (const auto &candidate : firstLineCandidates) { if (candidate.node < 0 || candidate.node >= Tree::instance().nodeCount()) continue; auto &fragment = ensureRareData(candidate.node).firstLineFragment; - fragment.x = clampInt16(candidate.x); + fragment.x = clampInt16(candidate.x + lineShift); fragment.y = clampInt16(lineTop); // Spaces between inline runs remain part of the background; // collapsible spaces at the end of the line do not. @@ -1482,8 +1496,18 @@ class FlexLayoutPass { return descent < 0 ? 0 : descent; } + // The collapsible space that ends a line item hangs past the line's end. + static int hangingSpace(const Node &item) + { + const int run = edgeTextRun(item, true); + if (run < 0) return 0; + const Node &text = Tree::instance().nodes()[run]; + if (text.text.empty() || !collapsibleSpace(text.text.str().back()) || !collapsesSpaces(text)) return 0; + return TextRenderer::measureWidth(" ", text.computedStyle().font_id, text.computedStyle().font_size); + } + void placeLineItems(Node *nodes, int from, int to, int contentLeft, int contentW, int lineTop, - int lineCross, int baseline, int penStart, bool ownsLineStart) + int lineCross, int baseline, int penStart, bool ownsLineStart, int lineShift = 0, bool lineAligned = false) { const int contentEnd = to > from && isLineBreak(nodes[children_[to - 1]]) ? to - 1 : to; const bool soleRun = @@ -1506,7 +1530,7 @@ class FlexLayoutPass { cn.layout.x = clampInt16(contentLeft + marginL); } else { lead = collapsedLead(nodes, k, pen == 0 && ownsLineStart); - cn.layout.x = clampInt16(contentLeft + pen + gap + marginL - lead); + cn.layout.x = clampInt16(contentLeft + lineShift + pen + gap + marginL - lead); pen -= lead; } pen += gap + marginL + cn.layout.width + marginR; @@ -1536,12 +1560,13 @@ class FlexLayoutPass { } } cn.layout.y = clampInt16(y); - cn.render.inline_baseline = 1; + const uint8_t placed = lineAligned ? 3 : 1; + cn.render.inline_baseline = placed; // An inline wrapper (text) contributes its CHILD's glyphs to // this line box, so the flag has to reach the node that actually draws. if (cn.type == NodeType::View) { for (int c = cn.first_child; c >= 0; c = nodes[c].next_sibling) - if (nodes[c].type == NodeType::Text) nodes[c].render.inline_baseline = 1; + if (nodes[c].type == NodeType::Text) nodes[c].render.inline_baseline = placed; } } } @@ -4615,6 +4640,17 @@ bool LayoutEngine::rightToLeftDirection(const Node &node) return rightToLeft(node); } +int LayoutEngine::physicalTextAlign(const Node &node) +{ + switch (node.computedStyle().text_align) { + case 1: return 1; + case 2: return 2; + case 3: return 0; + case 4: return rightToLeft(node) ? 0 : 2; + default: return rightToLeft(node) ? 2 : 0; + } +} + bool LayoutEngine::endsFormattingLine(int id) { const Node *nodes = Tree::instance().nodes(); diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 7053012..5c48389 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -1630,7 +1630,9 @@ struct RenderState { // the line advance: ink centring shifts by however far that particular string's // glyphs reach, so runs of different words — or different fonts — on one line // end up at different heights. A run that owns its line keeps the centring. - uint8_t inline_baseline : 1; + // Bit 1: the line layout already applied text-align to this box's line, so + // the run draws start-aligned in its own box. + uint8_t inline_baseline : 2; // Only one paint shortcut may own this scratch space. Mixed changes use // the ordinary dirty-region replay. The recolor destination is bg_color. union { diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index b5a0da6..4a8f95c 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -5103,10 +5103,14 @@ int positionValue(const std::string &value) return 0; } +// 0 start, 1 center, 2 right, 3 left, 4 end; justify is unsupported and starts. +// LayoutEngine::physicalTextAlign resolves start and end by direction. int textAlignValue(const std::string &value) { if (value == "center") return 1; - if (value == "right" || value == "end") return 2; + if (value == "right") return 2; + if (value == "left") return 3; + if (value == "end") return 4; return 0; } @@ -5118,12 +5122,13 @@ int verticalAlignValue(const std::string &value) return value == "initial" || value == "unset" ? 0 : -1; } -// Unlike text-align, `auto` must stay distinct from start: 0 = auto, otherwise -// the text_align value plus one. Justification is unsupported and starts. +// 0 = auto, else the physical alignment plus one: 1 left, 2 center, 3 right. +// start and end map as in ltr. Justification is unsupported and starts. int textAlignLastValue(const std::string &value) { if (value == "left" || value == "start" || value == "justify") return 1; - if (value == "center" || value == "right" || value == "end") return textAlignValue(value) + 1; + if (value == "center") return 2; + if (value == "right" || value == "end") return 3; return 0; } @@ -8191,7 +8196,10 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #endif #if GEA_CSS_WRITING_MODE case Property::WritingMode: style.writing_mode = value; return true; - case Property::Direction: style.direction = value; return true; + case Property::Direction: + if (value == 1) treeState().rtlSeen = true; + style.direction = value; + return true; #endif #if GEA_CSS_AXIS_GAP case Property::RowGap: style.row_gap = value; return true; diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index 2dbf851..becb41b 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -750,7 +750,7 @@ bool recordProjectedRasterText(const Node &node, const char *text, uint8_t /*par const int fontLineHeight = font.lineHeight(); const int lineAdvance = node.computedStyle().line_height > 0 ? node.computedStyle().line_height : fontLineHeight; const int lineBoxOffset = node.computedStyle().line_height > 0 ? (lineAdvance - fontLineHeight) / 2 : 0; - const int srcX = tx + alignedOffset(node.computedStyle().text_align, tw, lineWidth); + const int srcX = tx + alignedOffset(LayoutEngine::physicalTextAlign(node), tw, lineWidth); const int srcY = ty + lineBoxOffset; const int srcW = lineWidth; const int srcH = fontLineHeight; @@ -1283,8 +1283,13 @@ class TextDrawer { const int glyphHeight = font.lineHeight(); const int lineAdvance = lineHeight > 0 ? lineHeight : glyphHeight; const int lineBoxOffset = lineHeight > 0 ? (lineAdvance - glyphHeight) / 2 : 0; + // The space advance only serves hanging-space alignment, which a + // start-aligned run without text-align-last never does: skip the glyph + // lookup on that common path. lineAlignment() returns a non-zero + // alignment only from textAlign or the alignLast bits. + const bool alignsHangingSpaces = hangSpaces && (textAlign != 0 || (alignLast >= 0 && (alignLast & 3) != 0)); gea::framework::graphics::Glyph spaceGlyph{}; - const int spaceAdvance = font.glyph(' ', &spaceGlyph) ? spaceGlyph.advance : font.sizePx() / 2; + const int spaceAdvance = !alignsHangingSpaces ? 0 : font.glyph(' ', &spaceGlyph) ? spaceGlyph.advance : font.sizePx() / 2; // Multi-line clamp (text-overflow: ellipsis on WRAPPED text): the content // box caps how many lines draw; the last budgeted line renders through // the single-line ellipsis routine when more text would follow it. @@ -1766,7 +1771,7 @@ void Tree::setText(int node, const char *text) const int gid_previousX1 = gid_hadPartial ? target.render.text_dirty.x1 : 0; const bool gid_canPartial = (!target.render.dirty || gid_hadPartial) && !target.render.bg_recolor_pending && gid_singleLine && target.computedStyle().white_space == 1 && - target.computedStyle().text_align == 0 && gid_prefix < gid_newLen; + LayoutEngine::physicalTextAlign(target) == 0 && gid_prefix < gid_newLen; // Text and recolor shortcuts share their payload. Other pending paint // changes require a full node repaint, while consecutive text runs union // their extents so none of the earlier changes is forgotten. @@ -2143,7 +2148,7 @@ static uint16_t blockEllipsisCommand(const Node &run, int textX, int &width) static int8_t textAlignLastCommand(const Node &node) { const int last = node.computedStyle().text_align_last - 1; - if (last < 0 || last == node.style.text_align) return -1; + if (last < 0 || last == LayoutEngine::physicalTextAlign(node)) return -1; const bool endsParagraph = LayoutEngine::endsFormattingLine(static_cast(&node - Tree::instance().nodes())); return static_cast(last | (endsParagraph ? 4 : 0)); } @@ -2225,6 +2230,10 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph #endif + // Physical alignment. A line the inline layout already aligned as a whole + // draws start-aligned inside this run's box. + const bool lineAligned = (n->render.inline_baseline & 2) != 0; + const int textAlign = lineAligned ? 0 : LayoutEngine::physicalTextAlign(*n); int x = n->layout.x; int y = n->layout.y; int w = n->layout.width; @@ -2320,7 +2329,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph int iy1 = -1; if (rasterizedTextInkBounds(measureText, drawW, - n->computedStyle().text_align, + textAlign, containerW, n->computedStyle().font_id, commandFontSize, @@ -2397,7 +2406,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph // RenderState::inline_baseline. if (singleLine && !n->render.inline_baseline) { drawY += rasterizedSingleLineInkCenterOffsetY(measureText, - n->computedStyle().text_align, + textAlign, containerW, n->computedStyle().font_id, commandFontSize, @@ -2405,7 +2414,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph } } #endif - const int xOffset = alignedOffset(n->computedStyle().text_align, containerW, paintWidth); + const int xOffset = alignedOffset(textAlign, containerW, paintWidth); int paintX0 = drawX + xOffset; int paintY0 = drawY; int paintX1 = paintX0 + paintWidth - 1; @@ -2418,7 +2427,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph int iy1 = -1; if (rasterizedTextInkBounds(measureText, noSoftWrap ? 32767 : drawW, - n->computedStyle().text_align, + textAlign, containerW, n->computedStyle().font_id, commandFontSize, @@ -2460,7 +2469,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph int inkOff = 0; #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS const int genFonts = 1; - inkOff = rasterizedSingleLineInkCenterOffsetY(measureText, n->computedStyle().text_align, containerW, + inkOff = rasterizedSingleLineInkCenterOffsetY(measureText, textAlign, containerW, n->computedStyle().font_id, dbgFontSize, commandLineHeight); #else const int genFonts = 0; @@ -2479,7 +2488,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph } - const int8_t alignLast = transformed ? -1 : textAlignLastCommand(*n); + const int8_t alignLast = transformed || lineAligned ? -1 : textAlignLastCommand(*n); // The bounds above assume text-align for every line; realigned lines may // sit anywhere across the content box. if (alignLast >= 0) { @@ -2517,7 +2526,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.maxWidth = drawW; cmd->text.color = n->computedStyle().text_color; cmd->text.scale = textScale; - cmd->text.align = n->computedStyle().text_align; + cmd->text.align = static_cast(textAlign); cmd->text.textTransform = n->computedStyle().text_transform; cmd->text.lineHeight = commandLineHeight; cmd->text.containerWidth = containerW; diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index c668a9c..a1138c8 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -406,6 +406,12 @@ struct TreeState { #else static constexpr bool stickyPresent = false; #endif + // direction: rtl was set on some node at some point. direction inherits + // lazily (-1 walks to the nearest ancestor that sets it), so every + // start/end alignment question walks to the root; while this stays false + // the answer is ltr without the walk. Never cleared: it only guards a + // fast path. + bool rtlSeen = false; // Focus + caret state for `` elements. -1 means no input is // focused. caretLastFlipMs/caretVisible drive the blink animation — diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index e39f921..be9db1b 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -393,7 +393,10 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::Clear: if (style.clear_side != value) { style.clear_side = value; changed = 1; } break; #endif #if GEA_CSS_WRITING_MODE - case Property::Direction: if (style.direction != value) { style.direction = value; changed = 1; } break; + case Property::Direction: + if (value == 1) treeState().rtlSeen = true; + if (style.direction != value) { style.direction = value; changed = 1; } + break; case Property::WritingMode: if (style.writing_mode != value) { style.writing_mode = value; changed = 1; } break; #endif #if GEA_CSS_AXIS_GAP diff --git a/packages/geatsc-plugin-gea/src/utils.ts b/packages/geatsc-plugin-gea/src/utils.ts index 5ca88c5..53133b9 100644 --- a/packages/geatsc-plugin-gea/src/utils.ts +++ b/packages/geatsc-plugin-gea/src/utils.ts @@ -443,12 +443,13 @@ const POSITION_KEYWORDS = new Map([ ['-webkit-sticky', 4], ]) +// 0 start, 1 center, 2 right, 3 left, 4 end (see textAlignValue in style.cpp). const TEXT_ALIGN_KEYWORDS = new Map([ - ['left', 0], ['start', 0], ['center', 1], ['right', 2], - ['end', 2], + ['left', 3], + ['end', 4], ]) const VERTICAL_ALIGN_KEYWORDS = new Map([ From 6c5529b5babd214775d14fb8af4c980486cc5b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 03:10:08 +0300 Subject: [PATCH 23/50] Contain abspos children of fragmented inlines in their fragments An inline with a forced break and no visible box was laid out as one atomic box, so its later lines started at its own left edge instead of the block's. Such an inline now opens into its block's lines like one holding a block or a float, and out-of-flow children no longer prevent that. A positioned inline split across lines is the containing block of its out-of-flow children: the rect from its first fragment's inline-start and block-start edges to its last fragment's inline-end and block-end edges (CSS Position 3), the start edge on the right in rtl. After its block's layout the inline takes that rect as its box, its in-flow content keeps its place, and its out-of-flow children are positioned against it. Text fragments are measured again, since a run that owns its line gets a line-wide box for text-align. A wrapping run's leading collapsible space now also collapses into one that ends the item before it, as nowrap runs already did. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 127 +++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index a5f4eb7..ea7a619 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -727,6 +727,7 @@ bool flexRowDirection(const ComputedStyle &style) } bool isMulticolContainer(const Node &node); +bool establishesAbsoluteContainingBlock(const Node &node); int resolvedGap(const Node &node, bool rowGap, int width, int height) { @@ -1056,7 +1057,8 @@ class FlexLayoutPass { // not of the line it can fill. const int rest = contentW - marginL - marginR - padH; const int firstAvail = rest - pen - gap; - const bool atLineStart = pen == 0 && (j > 0 || blockOpensItsFirstLine); + // A leading space also collapses into one that ends the item before it. + const bool atLineStart = pen == 0 ? j > 0 || blockOpensItsFirstLine : j > 0 && endsWithCollapsibleSpace(nodes[children_[j - 1]]); const InlineFlowMeasure m = TextRenderer::measureInlineFlow(subject, firstAvail, rest, atLineStart); // Without real line metrics childFirstBaseline reports the box's @@ -1129,7 +1131,8 @@ class FlexLayoutPass { const int padH = boxInset(subject.computedStyle(), 1) + boxInset(subject.computedStyle(), 3); const int rest = contentW - marginL - marginR - padH; const InlineFlowMeasure m = TextRenderer::measureInlineFlow( - subject, rest - pen - gap, rest, pen == 0 && (j > 0 || blockOpensItsFirstLine)); + subject, rest - pen - gap, rest, + pen == 0 ? j > 0 || blockOpensItsFirstLine : j > 0 && endsWithCollapsibleSpace(nodes[children_[j - 1]])); if (m.lineCount > 0 && m.firstLineWidth > 0) { firstLineCandidates.push_back({innerRun >= 0 ? innerRun : child, contentLeft + pen + gap + marginL, @@ -1496,6 +1499,14 @@ class FlexLayoutPass { return descent < 0 ? 0 : descent; } + static bool endsWithCollapsibleSpace(const Node &item) + { + const int run = edgeTextRun(item, true); + if (run < 0) return false; + const Node &text = Tree::instance().nodes()[run]; + return !text.text.empty() && collapsibleSpace(text.text.str().back()) && collapsesSpaces(text); + } + // The collapsible space that ends a line item hangs past the line's end. static int hangingSpace(const Node &item) { @@ -2525,9 +2536,13 @@ bool splitInlineWrapper(const Node *nodes, int id) for (int c = n.first_child; c >= 0; c = nodes[c].next_sibling) { const Node &child = nodes[c]; if (isDisplayNone(child.computedStyle())) continue; - if (isOutOfFlowPosition(child.style.position)) return false; + // Out-of-flow children stay with the wrapper, whose fragments contain them + // (LayoutNodePass::positionFragmentedInlineChildren). + if (isOutOfFlowPosition(child.computedStyle().position)) continue; // A float inside an inline belongs to the enclosing block's float context. if (child.computedStyle().float_side) { containsBlock = true; continue; } + // A forced break splits the inline across line boxes. + if (isLineBreak(child)) { containsBlock = true; continue; } if (child.type == NodeType::View && (child.style.display_explicit || !isInlineLevelTag(tagFromId(child.tag_id)))) containsBlock = true; if (splitInlineWrapper(nodes, c)) containsBlock = true; } @@ -2637,6 +2652,7 @@ class LayoutNodePass { encloseFloatDescendants(); updateScrollContentSize(); positionAbsoluteChildren(); + positionFragmentedInlineChildren(); applyRelativeOffsets(); return; } @@ -2703,6 +2719,7 @@ class LayoutNodePass { if (layoutFloatChildren(scratch.children(), childCount, padWidth, padHeight, false)) { updateScrollContentSize(); positionAbsoluteChildren(); + positionFragmentedInlineChildren(); applyRelativeOffsets(); return; } @@ -4031,6 +4048,110 @@ class LayoutNodePass { } } + // A positioned inline split into fragments across this block's lines is + // the containing block of its out-of-flow children: the rect from its first + // fragment's inline-start and block-start edges to its last fragment's + // inline-end and block-end edges (CSS Position 3, 3.2). Its box, otherwise + // empty at its parent's origin, takes that rect, its in-flow content keeps + // its place, and its out-of-flow children are positioned against it. + void positionFragmentedInlineChildren() + { + if (!splitInlineChildren_) return; + auto visit = [&](auto &&self, int parent, int originX, int originY) -> void { + for (int c = nodes_[parent].first_child; c >= 0; c = nodes_[c].next_sibling) { + if (!splitInlineWrapper(nodes_, c)) continue; + Node &wrapper = nodes_[c]; + int x = originX + wrapper.layout.x, y = originY + wrapper.layout.y; + bool contains = false; + for (int k = wrapper.first_child; k >= 0 && !contains; k = nodes_[k].next_sibling) + contains = !isDisplayNone(nodes_[k].computedStyle()) && isOutOfFlowPosition(nodes_[k].computedStyle().position); + int rect[4]; + if (contains && establishesAbsoluteContainingBlock(wrapper) && fragmentRect(c, x, y, rect)) { + const int dx = rect[0] - x, dy = rect[1] - y; + wrapper.layout.x = clampInt16(wrapper.layout.x + dx); + wrapper.layout.y = clampInt16(wrapper.layout.y + dy); + wrapper.layout.width = clampInt16(rect[2]); + wrapper.layout.height = clampInt16(rect[3]); + for (int k = wrapper.first_child; k >= 0; k = nodes_[k].next_sibling) { + if (isOutOfFlowPosition(nodes_[k].computedStyle().position)) continue; + nodes_[k].layout.x = clampInt16(nodes_[k].layout.x - dx); + nodes_[k].layout.y = clampInt16(nodes_[k].layout.y - dy); + } + x = rect[0]; + y = rect[1]; + LayoutNodePass wrapperPass(engine_, c, rect[2], rect[3]); + wrapperPass.positionAbsoluteChildren(); + } + self(self, c, x, y); + } + }; + visit(visit, id_, 0, 0); + } + + // The fragments of a split inline at (originX, originY): x, y, width and + // height from its first in-flow inline content to its last. + bool fragmentRect(int wrapper, int originX, int originY, int rect[4]) const + { + struct Leaf { int id, x, y; }; + Leaf first{-1, 0, 0}, last{-1, 0, 0}; + auto walk = [&](auto &&self, int parent, int ox, int oy) -> void { + for (int c = nodes_[parent].first_child; c >= 0; c = nodes_[c].next_sibling) { + const Node &child = nodes_[c]; + if (isDisplayNone(child.computedStyle()) || isOutOfFlowPosition(child.computedStyle().position) || child.computedStyle().float_side || + isLineBreak(child) || blockBox(child) || suppressAnonymousWhitespace(nodes_, parent, c)) continue; + if (child.type == NodeType::Text && child.text.find_first_not_of(" \t\r\n\f") == std::string::npos) continue; + if (splitInlineWrapper(nodes_, c)) { + self(self, c, ox + child.layout.x, oy + child.layout.y); + continue; + } + if (first.id < 0) first = {c, ox + child.layout.x, oy + child.layout.y}; + last = {c, ox + child.layout.x, oy + child.layout.y}; + } + }; + walk(walk, wrapper, originX, originY); + if (first.id < 0) return false; + const Node &a = nodes_[first.id], &b = nodes_[last.id]; + int firstLeft, firstRight, firstLastLeft, firstLastRight, lastFirstLeft, lastFirstRight, lastLeft, lastRight; + inlineExtent(a, first.x, firstLeft, firstRight, firstLastLeft, firstLastRight); + inlineExtent(b, last.x, lastFirstLeft, lastFirstRight, lastLeft, lastRight); + int left = firstLeft, right = lastRight; + if (rightToLeft(nodes_[wrapper])) { + left = lastLeft; + right = firstRight; + } + rect[0] = left; + rect[1] = first.y; + rect[2] = std::max(0, right - left); + rect[3] = std::max(0, last.y + b.layout.height - first.y); + return true; + } + + // The x ranges an inline item's first and last line actually cover. A text + // run's box can be wider than its glyphs (a lone run fills its line for + // text-align), so text is measured again from where its first line starts. + void inlineExtent(const Node &item, int x, int &firstStart, int &firstEnd, int &lastStart, int &lastEnd) const + { + firstStart = lastStart = x; + firstEnd = lastEnd = x + item.layout.width; + if (item.type != NodeType::Text) return; + const int contentLeft = boxInset(node_.computedStyle(), 3); + const int lineStart = x + std::max(0, item.layout.inline_indent); + const int contentWidth = paddedWidth(); + const InlineFlowMeasure m = TextRenderer::measureInlineFlow(item, contentWidth - (lineStart - contentLeft), contentWidth, true); + if (m.lineCount <= 0) return; + const int hang = FlexLayoutPass::hangingSpace(item); + const int firstWidth = m.firstLineWidth - (m.lineCount == 1 ? hang : m.firstLineTrailingSpace); + const int lastWidth = m.lastLineWidth - hang; + // Unless the line layout placed it, the drawer aligns each line in the box. + const int align = (item.render.inline_baseline & 2) ? 0 : LayoutEngine::physicalTextAlign(item); + const int box = item.layout.width - boxInsets(item.computedStyle(), true); + const auto offset = [&](int width) { return std::max(0, align == 1 ? (box - width) / 2 : align == 2 ? box - width : 0); }; + firstStart = lineStart + offset(firstWidth); + firstEnd = firstStart + firstWidth; + lastStart = m.lineCount == 1 ? firstStart : x + offset(lastWidth); + lastEnd = lastStart + lastWidth; + } + void stretchAbsoluteChild(int child, Node &childNode, int childAvailWidth, int childAvailHeight) { bool resized = false; From bfe894ec7949d54416df00239c1cdcd8eb3c5664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 03:21:46 +0300 Subject: [PATCH 24/50] Support text-emphasis text-emphasis and its longhands were not parsed. text-emphasis-style, -color and -position now inherit through ComputedStyle padding (a style byte and a colour), and the text drawer paints one mark per character that is neither a space nor punctuation, over or under the glyph box: filled or open dot, circle, double circle, triangle or sesame, scaled with the font, at the same line origin as the glyphs in both the rasterized and the bitmap drawer. The same geometry feeds the background-clip: text mask, and the row-clipped mask pass now reaches marks outside a line's glyph box. Text commands grow their bounds to include the marks. Marks are vector shapes rather than glyphs, and lines do not grow to make room for them; string marks are unsupported. A marked character paints five to seven rows with one fillRect each. GEA_CSS_TEXT_EMPHASIS (default on) gates the family. Off, the two fields are static constexpr 0; defaults, inheritance, the parent snapshot and the style comparisons skip them; the properties are unknown; record grows no bounds and writes 0 into the command fields; and the mark painters are not compiled. Unmarked text pays a byte test per record and an early return per line. ComputedStyle keeps its size on the host either way (the fields sit in padding). The analyzer and CLI do not report the family yet. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/css_features.h | 7 ++ packages/engine/ui/input_render.cpp | 2 + packages/engine/ui/internal.h | 7 +- packages/engine/ui/node_lifecycle.cpp | 4 + packages/engine/ui/node_model.h | 15 +++ packages/engine/ui/render.cpp | 2 +- packages/engine/ui/style.cpp | 110 ++++++++++++++++++++- packages/engine/ui/style.h | 8 ++ packages/engine/ui/text.cpp | 136 +++++++++++++++++++++++--- packages/engine/ui/tree_style.cpp | 15 +++ 10 files changed, 291 insertions(+), 15 deletions(-) diff --git a/packages/engine/ui/css_features.h b/packages/engine/ui/css_features.h index 2d6b3e7..b299513 100644 --- a/packages/engine/ui/css_features.h +++ b/packages/engine/ui/css_features.h @@ -255,6 +255,13 @@ #define GEA_CSS_BACKGROUND_LAYERS 1 #endif +// text-emphasis marks (CSS Text Decoration 3, section 3). Off, the properties +// are unknown, ComputedStyle carries no emphasis fields and the text drawer +// paints no marks. +#ifndef GEA_CSS_TEXT_EMPHASIS +#define GEA_CSS_TEXT_EMPHASIS 1 +#endif + // position: sticky (CSS Position 3). Off, sticky parses as static, the // absolute-coordinate pass tracks no scrollport, and the root scroll-only // refresh never stands down for sticky boxes. diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index 1ce9753..75d5cde 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -173,6 +173,8 @@ void InputRenderer::record(int id) cmd->text.lineLimit = 0; cmd->text.blockEllipsis = 0; cmd->text.ellipsisWidth = 0; + cmd->text.emphasis = 0; + cmd->text.emphasisColor = 0; } } diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 147c12c..f990d6a 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -227,6 +227,10 @@ void rootScrollImageHoldTick(); // That line may use ellipsisWidth px from x, up to its line box end. uint16_t blockEllipsis; int16_t ellipsisWidth; + // text-emphasis marks (ComputedStyle::text_emphasis bits, 0 = none), + // painted in emphasisColor unless the bits say transparent. + uint8_t emphasis; + gea::framework::graphics::pixel::native_t emphasisColor; } text; struct { @@ -654,7 +658,8 @@ void rootScrollImageHoldTick(); int width, uint8_t *outCoverage); static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform = 0, int lineHeight = 0, int whiteSpace = 0, int textOverflow = 0, int maxHeight = 0, - int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, int blockEllipsis = 0, int ellipsisWidth = 0); + int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, int blockEllipsis = 0, int ellipsisWidth = 0, + int emphasis = 0, gea::framework::graphics::pixel::native_t emphasisColor = 0); // Single-line width measure for places that don't go through layout() // — used by InputRenderer to position the caret at the end of the value. static int measureWidth(const char *text, int fontId, int fontSize, int textTransform = 0); diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index f02c837..4a297da 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -206,6 +206,10 @@ constexpr ComputedStyle makeDefaultStyle() style.text_align = 0; #endif style.text_align_last = 0; +#if GEA_CSS_TEXT_EMPHASIS + style.text_emphasis = 0; + style.text_emphasis_color = 0; +#endif style.vertical_align = 0; #if GEA_CSS_TEXT_DECORATION style.text_decoration = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 5c48389..0a323d3 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -609,6 +609,13 @@ struct ComputedStyle { #endif #if GEA_CSS_GAP && !GEA_CSS_U8_GAP int16_t gap; +#endif + // text-emphasis-color when text_emphasis says it is explicit. Inherited; + // sits in padding on 16-bit colour builds. +#if GEA_CSS_TEXT_EMPHASIS + style_color_t text_emphasis_color; +#else + static constexpr style_color_t text_emphasis_color = 0; #endif #if GEA_CSS_BOX_SIZING int8_t box_sizing; // 0: content-box (CSS initial), 1: border-box @@ -816,6 +823,14 @@ struct ComputedStyle { uint8_t line_height; #endif style_color_t bg_color; + // text-emphasis (inherited): bits 0-2 mark 0 none, 1 dot, 2 circle, + // 3 double-circle, 4 triangle, 5 sesame; bit 3 open; bit 4 under; bits 5-6 + // colour 0 currentColor, 1 text_emphasis_color, 2 transparent. In padding. +#if GEA_CSS_TEXT_EMPHASIS + uint8_t text_emphasis; +#else + static constexpr uint8_t text_emphasis = 0; +#endif // linear/overlay/radial gradients + background-grid moved to RareStyle (rare). #if GEA_CSS_ACTIVE_BACKGROUND style_color_t active_bg_color; diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 28f40ce..cf5e43c 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -7539,7 +7539,7 @@ int gLastScrollUiFrame = -1000; c.text.align, c.text.containerWidth, c.text.fontId, c.text.textTransform, c.text.lineHeight, c.text.whiteSpace, c.text.textOverflow, c.text.maxHeight, c.text.firstLineIndent, c.text.alignLast, c.text.lineLimit, - c.text.blockEllipsis, c.text.ellipsisWidth); + c.text.blockEllipsis, c.text.ellipsisWidth, c.text.emphasis, c.text.emphasisColor); break; case DisplayCommandType::BlitImage: #if GEA_PIXEL_STORAGE_PACKED diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 4a8f95c..2b1491f 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -607,6 +607,10 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) a.line_height != b.line_height || a.text_align != b.text_align || a.text_align_last != b.text_align_last || +#if GEA_CSS_TEXT_EMPHASIS + a.text_emphasis != b.text_emphasis || + a.text_emphasis_color != b.text_emphasis_color || +#endif a.vertical_align != b.vertical_align || a.overflow != b.overflow || a.overflow_x != b.overflow_x || @@ -733,6 +737,10 @@ bool styleEqualExceptLocalDisplayCommands(const ComputedStyle &a, const Computed a.line_height != b.line_height || a.text_align != b.text_align || a.text_align_last != b.text_align_last || +#if GEA_CSS_TEXT_EMPHASIS + a.text_emphasis != b.text_emphasis || + a.text_emphasis_color != b.text_emphasis_color || +#endif a.vertical_align != b.vertical_align || a.overflow != b.overflow || a.overflow_x != b.overflow_x || @@ -1360,6 +1368,12 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "line-height") == 0) return CssDeclarationId::LineHeight; if (std::strcmp(property, "text-align") == 0) return CssDeclarationId::TextAlign; if (std::strcmp(property, "text-align-last") == 0) return CssDeclarationId::TextAlignLast; +#if GEA_CSS_TEXT_EMPHASIS + if (std::strcmp(property, "text-emphasis") == 0) return CssDeclarationId::TextEmphasis; + if (std::strcmp(property, "text-emphasis-style") == 0) return CssDeclarationId::TextEmphasisStyleDeclaration; + if (std::strcmp(property, "text-emphasis-color") == 0) return CssDeclarationId::TextEmphasisColorDeclaration; + if (std::strcmp(property, "text-emphasis-position") == 0) return CssDeclarationId::TextEmphasisPositionDeclaration; +#endif if (std::strcmp(property, "vertical-align") == 0) return CssDeclarationId::VerticalAlign; if (std::strcmp(property, "text-decoration") == 0 || std::strcmp(property, "text-decoration-line") == 0) return CssDeclarationId::TextDecoration; if (std::strcmp(property, "text-transform") == 0) return CssDeclarationId::TextTransform; @@ -7163,6 +7177,59 @@ int borderStyleWrites(CssDeclarationId declaration, const std::string &value, St return 4; } +// text-emphasis (CSS Text Decoration 3.3) and its longhands as writes to the +// ComputedStyle::text_emphasis bits and colour. A string mark is unsupported. +int textEmphasisWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[4]) +{ + const bool shorthand = declaration == CssDeclarationId::TextEmphasis; + if (!shorthand && declaration != CssDeclarationId::TextEmphasisStyleDeclaration && + declaration != CssDeclarationId::TextEmphasisColorDeclaration && declaration != CssDeclarationId::TextEmphasisPositionDeclaration) return -1; + const auto words = splitFunctionAwareWords(trimCssValue(value)); + if (words.empty()) return 0; + if (declaration == CssDeclarationId::TextEmphasisPositionDeclaration) { + int under = -1; + for (const auto &word : words) { + const auto lower = toLowerAscii(word); + if (lower == "over") under = 0; + else if (lower == "under") under = 0x10; + else if (lower != "right" && lower != "left") return 0; + } + if (under < 0) return 0; + out[0] = {Property::TextEmphasisPosition, under}; + return 1; + } + int mark = -1, open = -1, colorMode = -1, color = 0; + for (const auto &word : words) { + const auto lower = toLowerAscii(word); + if (declaration != CssDeclarationId::TextEmphasisColorDeclaration) { + if (lower == "none") { mark = 0; continue; } + if (lower == "filled" || lower == "open") { open = lower == "open" ? 8 : 0; continue; } + if (lower == "dot" || lower == "circle" || lower == "double-circle" || lower == "triangle" || lower == "sesame") { + mark = lower == "dot" ? 1 : lower == "circle" ? 2 : lower == "double-circle" ? 3 : lower == "triangle" ? 4 : 5; + continue; + } + } + if (declaration == CssDeclarationId::TextEmphasisStyleDeclaration || colorMode >= 0) return 0; + if (lower == "currentcolor") { colorMode = 0; continue; } + const ParsedCssColor parsed = parseCssColor(word); + if (!parsed.valid) return 0; + colorMode = parsed.a == 0 ? 0x40 : 0x20; + color = static_cast(cssColorStyleValue(parsed)); + } + int count = 0; + if (declaration != CssDeclarationId::TextEmphasisColorDeclaration) { + if (mark < 0 && open < 0) return 0; + // filled / open alone mean a dot in horizontal text; a shape alone is filled. + out[count++] = {Property::TextEmphasisStyle, mark == 0 ? 0 : (mark < 0 ? 1 : mark) | (open < 0 ? 0 : open)}; + } + if (declaration != CssDeclarationId::TextEmphasisStyleDeclaration) { + if (colorMode < 0) colorMode = 0; + out[count++] = {Property::TextEmphasisColorMode, colorMode}; + if (colorMode == 0x20) out[count++] = {Property::TextEmphasisColor, color}; + } + return count; +} + // Only whether columns are set matters: line-clamp skips multicol containers. // columns / column-count / column-width / column-fill as ColumnCount(Set) and // ColumnWidth(Set) writes plus the column-fill flag. A column width other than @@ -7706,6 +7773,9 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa int clampCount = hasVar ? -1 : lineClampWrites(declaration, value, clampWrites); if (clampCount < 0 && !hasVar) clampCount = multicolWrites(declaration, value, clampWrites); if (clampCount < 0 && !hasVar) clampCount = borderStyleWrites(declaration, value, clampWrites); +#if GEA_CSS_TEXT_EMPHASIS + if (clampCount < 0 && !hasVar) clampCount = textEmphasisWrites(declaration, value, clampWrites); +#endif if (const int count = clampCount; count >= 0) { if (count == 0) return kNoCompiledCssValue; compiled.kind = CssCompiledKind::DirectPropertyGroup; @@ -8687,6 +8757,12 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::TextAlign: style.text_align = value; return true; #endif case Property::TextAlignLast: style.text_align_last = value; return true; +#if GEA_CSS_TEXT_EMPHASIS + case Property::TextEmphasisStyle: style.text_emphasis = static_cast((style.text_emphasis & ~0x0f) | (value & 0x0f)); return true; + case Property::TextEmphasisPosition: style.text_emphasis = static_cast((style.text_emphasis & ~0x10) | (value & 0x10)); return true; + case Property::TextEmphasisColorMode: style.text_emphasis = static_cast((style.text_emphasis & ~0x60) | (value & 0x60)); return true; + case Property::TextEmphasisColor: style.text_emphasis_color = StyleValues::pixelFromStyleValue(value); return true; +#endif case Property::VerticalAlign: style.vertical_align = value; return true; #if GEA_CSS_TEXT_DECORATION case Property::TextDecoration: style.text_decoration = value; return true; @@ -9712,6 +9788,10 @@ bool propertyAffectsDescendantStyle(Property property) property == Property::FontWeight || property == Property::LineHeight || property == Property::LineHeightExpression || property == Property::LineHeightMultiplier || property == Property::TextAlign || property == Property::TextAlignLast || +#if GEA_CSS_TEXT_EMPHASIS + property == Property::TextEmphasisStyle || property == Property::TextEmphasisPosition || + property == Property::TextEmphasisColorMode || property == Property::TextEmphasisColor || +#endif property == Property::TextTransform || property == Property::WhiteSpace || property == Property::Visibility || property == Property::BorderWidth || (property >= Property::BorderTopWidth && property <= Property::BorderLeftWidth); @@ -9746,6 +9826,10 @@ void applyInheritedStyleDefaults(int node) style.text_align = parentStyle.text_align; #endif style.text_align_last = parentStyle.text_align_last; +#if GEA_CSS_TEXT_EMPHASIS + style.text_emphasis = parentStyle.text_emphasis; + style.text_emphasis_color = parentStyle.text_emphasis_color; +#endif #if GEA_CSS_TEXT_TRANSFORM style.text_transform = parentStyle.text_transform; #endif @@ -10063,11 +10147,18 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::BorderTopStyle: case CssDeclarationId::BorderRightStyle: case CssDeclarationId::BorderBottomStyle: - case CssDeclarationId::BorderLeftStyle: { + case CssDeclarationId::BorderLeftStyle: + case CssDeclarationId::TextEmphasis: + case CssDeclarationId::TextEmphasisStyleDeclaration: + case CssDeclarationId::TextEmphasisColorDeclaration: + case CssDeclarationId::TextEmphasisPositionDeclaration: { StaticStylePropertyValue writes[4]; int count = lineClampWrites(declaration, value, writes); if (count < 0) count = multicolWrites(declaration, value, writes); if (count < 0) count = borderStyleWrites(declaration, value, writes); +#if GEA_CSS_TEXT_EMPHASIS + if (count < 0) count = textEmphasisWrites(declaration, value, writes); +#endif for (int i = 0; i < count; ++i) setStyleValue(node, writes[i].property, writes[i].value, source); return true; } @@ -12194,6 +12285,12 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "line-height") return removeInlineStyleProperties(id, {Property::LineHeight, Property::LineHeightExpression, Property::LineHeightMultiplier}); if (property == "text-align") return removeInlineStyleProperties(id, {Property::TextAlign}); if (property == "text-align-last") return removeInlineStyleProperties(id, {Property::TextAlignLast}); +#if GEA_CSS_TEXT_EMPHASIS + if (property == "text-emphasis") return removeInlineStyleProperties(id, {Property::TextEmphasisStyle, Property::TextEmphasisColorMode, Property::TextEmphasisColor}); + if (property == "text-emphasis-style") return removeInlineStyleProperties(id, {Property::TextEmphasisStyle}); + if (property == "text-emphasis-color") return removeInlineStyleProperties(id, {Property::TextEmphasisColorMode, Property::TextEmphasisColor}); + if (property == "text-emphasis-position") return removeInlineStyleProperties(id, {Property::TextEmphasisPosition}); +#endif if (property == "vertical-align") return removeInlineStyleProperties(id, {Property::VerticalAlign}); if (property == "text-decoration" || property == "text-decoration-line") return removeInlineStyleProperties(id, {Property::TextDecoration}); @@ -16545,6 +16642,10 @@ struct ParentStyleSnapshot { static constexpr std::uint8_t text_align = 0; #endif std::uint8_t text_align_last; +#if GEA_CSS_TEXT_EMPHASIS + std::uint8_t text_emphasis; + style_color_t text_emphasis_color; +#endif #if GEA_CSS_TEXT_TRANSFORM std::uint8_t text_transform; #else @@ -16589,6 +16690,10 @@ ParentStyleSnapshot snapshotParentStyle(const ComputedStyle &s) out.text_align = s.text_align; #endif out.text_align_last = s.text_align_last; +#if GEA_CSS_TEXT_EMPHASIS + out.text_emphasis = s.text_emphasis; + out.text_emphasis_color = s.text_emphasis_color; +#endif #if GEA_CSS_TEXT_TRANSFORM out.text_transform = s.text_transform; #endif @@ -16612,6 +16717,9 @@ bool parentStylesDiffer(const ParentStyleSnapshot &a, const ParentStyleSnapshot return a.text_color != b.text_color || a.text_alpha != b.text_alpha || a.font_id != b.font_id || a.font_size != b.font_size || a.font_weight != b.font_weight || a.line_height != b.line_height || a.line_height_multiplier != b.line_height_multiplier || a.text_align != b.text_align || a.text_align_last != b.text_align_last || a.text_transform != b.text_transform || +#if GEA_CSS_TEXT_EMPHASIS + a.text_emphasis != b.text_emphasis || a.text_emphasis_color != b.text_emphasis_color || +#endif a.white_space != b.white_space || a.visibility != b.visibility || a.border_widths != b.border_widths; } diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index dd76287..96d4af7 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -225,6 +225,10 @@ enum class Property : int { ColumnFillAuto, LineClampDiscard, ColumnSpanAll, + TextEmphasisStyle, + TextEmphasisPosition, + TextEmphasisColorMode, + TextEmphasisColor, Count }; @@ -384,6 +388,10 @@ enum class StyleDeclaration : std::uint8_t { BorderRightStyle, BorderBottomStyle, BorderLeftStyle, + TextEmphasis, + TextEmphasisStyleDeclaration, + TextEmphasisColorDeclaration, + TextEmphasisPositionDeclaration, // Logical two-sided shorthands (see logicalPairFor in style.cpp). MarginInline, MarginBlock, diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index becb41b..16d0ecd 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1093,7 +1093,7 @@ class TextDrawer { command.text.fontId, command.text.textTransform, command.text.lineHeight, command.text.whiteSpace, command.text.textOverflow, command.text.maxHeight, command.text.firstLineIndent, command.text.alignLast, command.text.lineLimit, - command.text.blockEllipsis, command.text.ellipsisWidth, &sink); + command.text.blockEllipsis, command.text.ellipsisWidth, command.text.emphasis, command.text.emphasisColor, &sink); } #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS @@ -1147,7 +1147,7 @@ class TextDrawer { } } - static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, int blockEllipsis = 0, int ellipsisWidth = 0, TextCoverageSink *coverageSink = nullptr) + static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, int blockEllipsis = 0, int ellipsisWidth = 0, int emphasis = 0, gea::framework::graphics::pixel::native_t emphasisColor = 0, TextCoverageSink *coverageSink = nullptr) { if (!text || !text[0]) return; std::string transformed; @@ -1155,9 +1155,11 @@ class TextDrawer { // Pre preserves forced breaks and paints every line without soft wrap. if (whiteSpace == 2) maxWidth = 32767; const BlockEllipsis ellipsis{blockEllipsis == kAutoBlockEllipsis ? "\xE2\x80\xA6" : blockEllipsis ? cssAtomText(static_cast(blockEllipsis)) : nullptr, - blockEllipsis == kAutoBlockEllipsis, ellipsisWidth}; - // The ellipsis line goes through the wrapped painters; nowrap text is one line. - if (ellipsis.text && whiteSpace == 1) maxWidth = 32767; + blockEllipsis == kAutoBlockEllipsis, ellipsisWidth, GEA_CSS_TEXT_EMPHASIS && (emphasis & 7) ? emphasis : 0, emphasisColor}; + // The ellipsis line and emphasis marks go through the wrapped painters; + // nowrap text is one line there. + const bool lineExtras = ellipsis.text || ellipsis.emphasis; + if (lineExtras && whiteSpace == 1) maxWidth = 32767; int clipX0; int clipY0; @@ -1174,7 +1176,7 @@ class TextDrawer { coverageSink = &clippedSink; } - const bool noWrap = whiteSpace == 1 && !ellipsis.text; + const bool noWrap = whiteSpace == 1 && !lineExtras; const bool overflowEllipsis = textOverflow == 1; // A nowrap run is one line, so only a paragraph end can realign it. const int singleLineAlign = alignLast >= 0 && (alignLast & 4) ? alignLast & 3 : textAlign; @@ -1209,8 +1211,83 @@ class TextDrawer { const char *text; bool automatic; int width; + // Also carries the text-emphasis marks of every painted line. + int emphasis; + gea::framework::graphics::pixel::native_t emphasisColor; }; +#if GEA_CSS_TEXT_EMPHASIS + // One text-emphasis mark centred at (cx, cy): rows of spans, painted or + // added to the coverage sink of a text-clipped background. + static void fillEmphasisMark(int shape, bool open, int cx, int cy, int r, gea::framework::graphics::pixel::native_t color, + const TextCoverageSink *sink) + { + const auto span = [&](int y, int x0, int x1) { + if (x1 < x0) return; + if (sink) { + for (int x = x0; x <= x1; ++x) sink->add(x, y, 255); + return; + } + gea::platform::display::Display::fillRect(x0, y, x1 - x0 + 1, 1, color); + }; + const int top = cy - r, bottom = shape == 4 ? cy + r / 2 : cy + r; + for (int y = top; y <= bottom; ++y) { + const float dy = static_cast(y) + 0.5f - static_cast(cy); + if (shape == 4) { + // A triangle pointing up, as wide at its base as it is tall. + const int half = static_cast(static_cast(y - top + 1) * static_cast(r) / static_cast(bottom - top + 1)); + span(y, cx - half, cx + half - 1); + continue; + } + const float rx = shape == 5 ? static_cast(r) * 0.5f : static_cast(r); + const float ry = static_cast(r); + const float inside = 1.0f - (dy * dy) / (ry * ry); + if (inside < 0.0f) continue; + const int half = static_cast(std::lround(rx * std::sqrt(inside))); + const int ring = shape == 3 || open ? std::max(1, r / 3) : 0; + const float innerR = static_cast(r - ring); + const float innerInside = innerR > 0.0f ? 1.0f - (dy * dy) / (innerR * innerR) : -1.0f; + if (ring && innerInside >= 0.0f) { + const int innerHalf = static_cast(std::lround(innerR * (shape == 5 ? 0.5f : 1.0f) * std::sqrt(innerInside))); + span(y, cx - half, cx - innerHalf - 1); + span(y, cx + innerHalf, cx + half - 1); + // A double circle also has a filled dot inside its ring. + const float dot = static_cast(r) * 0.45f; + const float dotInside = shape == 3 ? 1.0f - (dy * dy) / (dot * dot) : -1.0f; + if (dotInside >= 0.0f) { + const int dotHalf = static_cast(std::lround(dot * std::sqrt(dotInside))); + span(y, cx - dotHalf, cx + dotHalf - 1); + } + } else { + span(y, cx - half, cx + half - 1); + } + } + } + + // CSS text-emphasis: one mark per character that is not a space or + // punctuation, centred on its advance, over or under the glyph box. + template + static void drawEmphasisMarks(const char *line, int bytes, int x, int lineBoxY, int glyphHeight, int fontSize, + const BlockEllipsis &extras, const Advance &advanceOf, const TextCoverageSink *sink) + { + const int shape = extras.emphasis & 7; + const bool transparent = (extras.emphasis & 0x60) == 0x40; + if (!shape || (transparent && !sink)) return; + const int r = std::max(1, (shape == 2 || shape == 3 ? 17 : 9) * fontSize / 100); + const int gap = std::max(1, fontSize / 10); + const int cy = (extras.emphasis & 0x10) ? lineBoxY + glyphHeight + gap + r : lineBoxY - gap - r; + int penX = x; + for (const char *p = line, *end = line + bytes; p < end && *p;) { + const int cp = nextUtf8Codepoint(p); + const int advance = advanceOf(cp); + const bool punctuation = cp < 128 && cp > ' ' && std::strchr("!\"#%&'()*,-./:;?@[\\]_{}", cp); + if (cp > ' ' && cp != 0xA0 && !punctuation) + fillEmphasisMark(shape, (extras.emphasis & 8) != 0, penX + advance / 2, cy, r, extras.emphasisColor, sink); + penX += advance; + } + } +#endif + // Keeps the leading glyphs of a line that leave room for the ellipsis, // drops the spaces that would end them, then appends it. Returns the width // of what it wrote to `out` (NUL-terminated). @@ -1290,6 +1367,17 @@ class TextDrawer { const bool alignsHangingSpaces = hangSpaces && (textAlign != 0 || (alignLast >= 0 && (alignLast & 3) != 0)); gea::framework::graphics::Glyph spaceGlyph{}; const int spaceAdvance = !alignsHangingSpaces ? 0 : font.glyph(' ', &spaceGlyph) ? spaceGlyph.advance : font.sizePx() / 2; + // Emphasis marks reach past a line's glyph box, which a row clip must include. +#if GEA_CSS_TEXT_EMPHASIS + const auto glyphAdvance = [&font](int cp) { + gea::framework::graphics::Glyph glyph{}; + return font.glyph(cp, &glyph) ? glyph.advance : (font.sizePx() / 2); + }; + const int markAbove = (ellipsis.emphasis & 7) && !(ellipsis.emphasis & 0x10) ? fontSize : 0; + const int markBelow = (ellipsis.emphasis & 7) && (ellipsis.emphasis & 0x10) ? fontSize : 0; +#else + const int markAbove = 0, markBelow = 0; +#endif // Multi-line clamp (text-overflow: ellipsis on WRAPPED text): the content // box caps how many lines draw; the last budgeted line renders through // the single-line ellipsis routine when more text would follow it. @@ -1344,7 +1432,7 @@ class TextDrawer { const char *lineStart = text; const int lastLine = (lineLimit > 0 && lineLimit < entry.lineCount ? lineLimit : entry.lineCount) - 1; for (int li = 0; li < entry.lineCount; li++) { - if (penY + lineBoxOffset > clipY1 || (lineLimit > 0 && li >= lineLimit)) break; + if (penY + lineBoxOffset - markAbove > clipY1 || (lineLimit > 0 && li >= lineLimit)) break; if (ellipsis.text && li == lastLine) { if (penY + lineBoxOffset + glyphHeight - 1 >= clipY0) drawRasterizedEllipsis(lineStart, entry.renderBytes[li], lineOriginX_(li), penY + lineBoxOffset, @@ -1361,7 +1449,7 @@ class TextDrawer { } const int chars = entry.renderBytes[li]; const int lineBoxY = penY + lineBoxOffset; - if (lineBoxY + glyphHeight - 1 >= clipY0) { + if (lineBoxY + glyphHeight - 1 + markBelow >= clipY0) { const int lineWidth = entry.lineWidths[li]; const int align = lineAlignment(textAlign, alignLast, lineStart, entry.consumedBytes[li], li == entry.lineCount - 1); const int hanging = hangSpaces && align ? hangingSpaceWidth(lineStart, chars, spaceAdvance) : 0; @@ -1374,6 +1462,9 @@ class TextDrawer { if (coverageSink) rasterizedGlyphRun(lineBuffer, lineX, lineBoxY, fontId, fontSize, *coverageSink); else gea::platform::display::Display::drawTextFontFamily(lineBuffer, lineX, lineBoxY, color, fontId, fontSize); } +#if GEA_CSS_TEXT_EMPHASIS + drawEmphasisMarks(lineStart, chars, lineX, lineBoxY, glyphHeight, fontSize, ellipsis, glyphAdvance, coverageSink); +#endif } lineStart += entry.consumedBytes[li]; penY += lineAdvance; @@ -1386,7 +1477,7 @@ class TextDrawer { int penY = y; int li = 0; while (*lineStart) { - if (penY + lineBoxOffset > clipY1 || (lineLimit > 0 && li >= lineLimit)) break; + if (penY + lineBoxOffset - markAbove > clipY1 || (lineLimit > 0 && li >= lineLimit)) break; if (li == lineBudget - 1) { // Budget reached: ellipsize whatever remains onto this final line. drawRasterizedSingleLine(lineStart, lineOriginX_(li), penY, lineBudget_(li), color, textAlign, containerWidth, @@ -1408,7 +1499,7 @@ class TextDrawer { color, fontId, fontSize, font, coverageSink); return; } - if (lineBoxY + glyphHeight - 1 >= clipY0) { + if (lineBoxY + glyphHeight - 1 + markBelow >= clipY0) { const int align = lineAlignment(textAlign, alignLast, lineStart, line.consumedBytes, !lineStart[line.consumedBytes]); const int hanging = hangSpaces && align ? hangingSpaceWidth(lineStart, line.renderBytes, spaceAdvance) : 0; const int lineX = lineOriginX_(lineIndex) + alignedOffset(align, containerWidth, line.width - hanging - trimmedIndent_(lineIndex)); @@ -1420,6 +1511,9 @@ class TextDrawer { if (coverageSink) rasterizedGlyphRun(lineBuffer, lineX, lineBoxY, fontId, fontSize, *coverageSink); else gea::platform::display::Display::drawTextFontFamily(lineBuffer, lineX, lineBoxY, color, fontId, fontSize); } +#if GEA_CSS_TEXT_EMPHASIS + drawEmphasisMarks(lineStart, line.renderBytes, lineX, lineBoxY, glyphHeight, fontSize, ellipsis, glyphAdvance, coverageSink); +#endif } lineStart += line.consumedBytes; @@ -1587,6 +1681,9 @@ class TextDrawer { penX += glyphWidth; } } +#if GEA_CSS_TEXT_EMPHASIS + drawEmphasisMarks(lineStart, line.renderBytes, lineX, penY, glyphHeight, glyphHeight, ellipsis, [glyphWidth](int) { return glyphWidth; }, coverageSink); +#endif } lineStart += line.consumedBytes; @@ -2096,9 +2193,9 @@ int TextRenderer::reflowToBoxWidth(int id) return measured.width; } -void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent, int alignLast, int lineLimit, int blockEllipsis, int ellipsisWidth) +void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent, int alignLast, int lineLimit, int blockEllipsis, int ellipsisWidth, int emphasis, gea::framework::graphics::pixel::native_t emphasisColor) { - TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent, alignLast, lineLimit, blockEllipsis, ellipsisWidth); + TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent, alignLast, lineLimit, blockEllipsis, ellipsisWidth, emphasis, emphasisColor); } void TextRenderer::unionCoverageRow(const DisplayCommand &command, int screenY, int screenX, @@ -2503,6 +2600,14 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph ? blockEllipsisCommand(*n, n->layout.x + boxInset(n->computedStyle(), 3), ellipsisWidth) : 0; #else const uint16_t blockEllipsis = 0; +#endif +#if GEA_CSS_TEXT_EMPHASIS + // Emphasis marks sit above (or below) the glyph box, up to half an em. + if (n->computedStyle().text_emphasis & 7) { + const int reach = n->computedStyle().font_size; + if (n->computedStyle().text_emphasis & 0x10) bh += reach; + else { by -= reach; bh += reach; } + } #endif // The ellipsis may reach past the run's own box to the end of its line. if (blockEllipsis) { @@ -2538,6 +2643,13 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.lineLimit = n->layout.line_clamp_lines; cmd->text.blockEllipsis = blockEllipsis; cmd->text.ellipsisWidth = static_cast(ellipsisWidth); +#if GEA_CSS_TEXT_EMPHASIS + cmd->text.emphasis = (n->computedStyle().text_emphasis & 7) ? n->computedStyle().text_emphasis : 0; + cmd->text.emphasisColor = (n->computedStyle().text_emphasis & 0x60) == 0x20 ? n->computedStyle().text_emphasis_color : n->computedStyle().text_color; +#else + cmd->text.emphasis = 0; + cmd->text.emphasisColor = 0; +#endif { const int contentH = h - boxInset(n->computedStyle(), 0) - boxInset(n->computedStyle(), 2); cmd->text.maxHeight = static_cast(contentH > 0 ? (contentH > 32767 ? 32767 : contentH) : 0); diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index be9db1b..2c0f423 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -1034,6 +1034,21 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::TextAlign: if (style.text_align != value) { style.text_align = value; changed = 1; } break; #endif case Property::TextAlignLast: if (style.text_align_last != value) { style.text_align_last = value; changed = 1; } break; +#if GEA_CSS_TEXT_EMPHASIS + case Property::TextEmphasisStyle: + case Property::TextEmphasisPosition: + case Property::TextEmphasisColorMode: { + const int mask = prop == Property::TextEmphasisStyle ? 0x0f : prop == Property::TextEmphasisPosition ? 0x10 : 0x60; + const int next = (style.text_emphasis & ~mask) | (value & mask); + if (style.text_emphasis != next) { style.text_emphasis = static_cast(next); changed = 1; } + break; + } + case Property::TextEmphasisColor: { + const auto color = StyleValues::pixelFromStyleValue(value); + if (style.text_emphasis_color != color) { style.text_emphasis_color = color; changed = 1; } + break; + } +#endif case Property::VerticalAlign: if (style.vertical_align != value) { style.vertical_align = value; changed = 1; } break; #if GEA_CSS_TEXT_DECORATION case Property::TextDecoration: if (style.text_decoration != value) { style.text_decoration = value; changed = 1; } break; From f54c98262597e6f8ad86505eff06c5f73ae4b6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 03:42:49 +0300 Subject: [PATCH 25/50] Support background-blend-mode and clip text backgrounds once Parse background-blend-mode as an interned per-layer list and carry each gradient layer's mode on its display command. A non-normal linear or radial gradient blends pixel by pixel with the canvas using the Compositing 1 separable and non-separable formulas (an achromatic colour is detected by value, so hue and saturation never divide by zero). Layers blend as an isolated group, so a layer with nothing of the element painted under it keeps normal. background-clip: text used to mask every layer separately, so partly covered edge pixels picked up the lower layers twice. Those pixels now take each layer's change over the unmasked composite of the layers below it, clipping the whole background to the text once. A blended layer works on rows, output bit-identical to blending pixel by pixel: - A canvas row is read with one memcpy, written back with one and marked dirty once, inside the canvas and Display::clip; a rotated or packed framebuffer keeps the per-pixel accessors. - kBlendUnit, a compile-time table of i / 255.0f, replaces the divisions by 255. - A gradient with no vertical component works its source colour out once per column per command. - Pixels blend in chunks of 32: one mode switch per chunk, a constant-mode loop for the separable modes, blend() for the others. - The row, column and chunk buffers are per core and allocated on the first blended draw: about 7.2 KB of heap in an app that blends, none in one that does not. backgroundLayerBlend tests the rare-style bg_blend byte first, so a gradient without a blend mode pays one byte test. GEA_CSS_BACKGROUND_BLEND (default on) gates the family: off, bg_blend is static constexpr 0, the property is unknown, and the blend drawer, replayBlended and the radial blend branch are not compiled. RareStyle keeps its size (the byte sits in padding); DisplayCommand, Node and ComputedStyle do not change. The analyzer and CLI do not report the family yet. Desktop harness: blend-mode-24 (24 rows of two gradient layers with multiply) about 2.45 ms a frame and blend-modes-15 (all 15 modes over a translucent stop) about 3.0 ms, where blending pixel by pixel took 7.2 and 7.7 ms; main, which does not blend, draws blend-mode-24 in 1.3 ms. A full-screen blend layer is expected to exceed the 16 ms budget on the board, which has not been measured yet. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/css_features.h | 7 + packages/engine/ui/display_invalidation.cpp | 1 + packages/engine/ui/internal.h | 3 + packages/engine/ui/node_model.h | 5 + packages/engine/ui/render.cpp | 417 ++++++++++++++++++-- packages/engine/ui/style.cpp | 76 ++++ packages/engine/ui/style.h | 2 + packages/engine/ui/style_values.h | 7 + packages/engine/ui/tree_style.cpp | 13 +- packages/engine/ui/view.cpp | 37 +- 10 files changed, 529 insertions(+), 39 deletions(-) diff --git a/packages/engine/ui/css_features.h b/packages/engine/ui/css_features.h index b299513..bd63386 100644 --- a/packages/engine/ui/css_features.h +++ b/packages/engine/ui/css_features.h @@ -255,6 +255,13 @@ #define GEA_CSS_BACKGROUND_LAYERS 1 #endif +// background-blend-mode on gradient layers (Compositing 1, section 10). Off, +// the property is unknown, RareStyle carries no handle, and replay carries no +// per-pixel blend path. +#ifndef GEA_CSS_BACKGROUND_BLEND +#define GEA_CSS_BACKGROUND_BLEND 1 +#endif + // text-emphasis marks (CSS Text Decoration 3, section 3). Off, the properties // are unknown, ComputedStyle carries no emphasis fields and the text drawer // paints no marks. diff --git a/packages/engine/ui/display_invalidation.cpp b/packages/engine/ui/display_invalidation.cpp index 6247a68..7207f0c 100644 --- a/packages/engine/ui/display_invalidation.cpp +++ b/packages/engine/ui/display_invalidation.cpp @@ -43,6 +43,7 @@ bool DisplayInvalidation::rebuildsNodeDisplayCommands(Property prop) case Property::BackgroundColor: case Property::BackgroundAlpha: case Property::BackgroundClip: + case Property::BackgroundBlendMode: case Property::BackgroundImage: case Property::Containment: case Property::ActiveBackgroundColor: diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index f990d6a..e0065db 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -159,6 +159,8 @@ void rootScrollImageHoldTick(); uint8_t toAlpha; uint16_t toStop; uint8_t hasMid; + // background-blend-mode of the layer; 0 is normal (see BlendMode). + uint8_t blend; } gradient; struct { @@ -171,6 +173,7 @@ void rootScrollImageHoldTick(); uint16_t stopPermille; uint8_t fromAlpha; uint8_t toAlpha; + uint8_t blend; } radialGradient; struct { diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 0a323d3..5e32439 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -414,6 +414,11 @@ struct RareStyle { // center/extent 500/1000) --- #if GEA_CSS_BACKGROUND_LAYERS uint16_t bg_image_layer_count = 1; // Includes none layers. + // bg_blend and the five border-image handles sit between the layer count + // and the int32 lists; without them RareStyle is four bytes smaller. +#if GEA_CSS_BACKGROUND_BLEND + uint8_t bg_blend = 0; // Interned background-blend-mode list; zero is all normal. +#endif int32_t bg_size_list = -1, bg_position_list = -1, bg_repeat_list = -1; int32_t bg_attachment_list = -1, bg_origin_list = -1; #else diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index cf5e43c..dbf870b 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6288,6 +6288,228 @@ int gLastScrollUiFrame = -1000; } }; +#if GEA_CSS_BACKGROUND_BLEND + // background-blend-mode (Compositing 1, section 10): a gradient layer + // blends with what is painted under it. The backdrop should be only the + // element's lower layers; the canvas holds them over whatever is behind the + // element, which differs only where those layers are not opaque. + // Per pixel, in floating point: a cold path for a rare property. + // + // v / 255.0f for every channel value: the same floats the division gives, + // without a division per channel per pixel. + struct BlendUnitTable + { + float v[256]; + constexpr BlendUnitTable() : v{} + { + for (int i = 0; i < 256; ++i) v[i] = static_cast(i) / 255.0f; + } + }; + constexpr BlendUnitTable kBlendUnit{}; + + struct BlendDrawer + { + static float softLightD(float cb) + { + return cb <= 0.25f ? ((16.0f * cb - 12.0f) * cb + 4.0f) * cb : std::sqrt(cb); + } + + static float channel(int mode, float cb, float cs) + { + switch (mode) + { + case 1: return cb * cs; + case 2: return cb + cs - cb * cs; + case 3: return channel(8, cs, cb); + case 4: return std::min(cb, cs); + case 5: return std::max(cb, cs); + case 6: + if (cb <= 0.0f) return 0.0f; + return cs >= 1.0f ? 1.0f : std::min(1.0f, cb / (1.0f - cs)); + case 7: + if (cb >= 1.0f) return 1.0f; + return cs <= 0.0f ? 0.0f : 1.0f - std::min(1.0f, (1.0f - cb) / cs); + case 8: return cs <= 0.5f ? cb * 2.0f * cs : channel(2, cb, 2.0f * cs - 1.0f); + case 9: + if (cs <= 0.5f) return cb - (1.0f - 2.0f * cs) * cb * (1.0f - cb); + return cb + (2.0f * cs - 1.0f) * (softLightD(cb) - cb); + case 10: return std::fabs(cb - cs); + case 11: return cb + cs - 2.0f * cb * cs; + default: return cs; + } + } + + static float lum(const float c[3]) { return 0.3f * c[0] + 0.59f * c[1] + 0.11f * c[2]; } + + static void setLum(float c[3], float l) + { + const float d = l - lum(c); + for (int i = 0; i < 3; ++i) c[i] += d; + l = lum(c); + const float n = std::min({c[0], c[1], c[2]}), x = std::max({c[0], c[1], c[2]}); + for (int i = 0; i < 3; ++i) { + if (n < 0.0f) c[i] = l + (c[i] - l) * l / (l - n); + if (x > 1.0f) c[i] = l + (c[i] - l) * (1.0f - l) / (x - l); + } + } + + static void setSat(float c[3], float s) + { + int lo = 0, hi = 0; + for (int i = 1; i < 3; ++i) { + if (c[i] < c[lo]) lo = i; + if (c[i] >= c[hi]) hi = i; + } + // An achromatic colour (all channels equal) has no saturation to + // scale; the index test alone misses it and divides by zero below. + if (lo == hi || c[hi] - c[lo] <= 0.0f) { c[0] = c[1] = c[2] = 0.0f; return; } + const int mid = 3 - lo - hi; + c[mid] = (c[mid] - c[lo]) * s / (c[hi] - c[lo]); + c[hi] = s; + c[lo] = 0.0f; + } + + static float sat(const float c[3]) { return std::max({c[0], c[1], c[2]}) - std::min({c[0], c[1], c[2]}); } + + // `out` = B(cb, cs), channels in 0..1. + static void blend(int mode, const float cb[3], const float cs[3], float out[3]) + { + if (mode < 12) { + for (int i = 0; i < 3; ++i) out[i] = channel(mode, cb[i], cs[i]); + return; + } + const float *hueSource = mode == 13 ? cb : cs; + for (int i = 0; i < 3; ++i) out[i] = mode == 15 ? cb[i] : hueSource[i]; + if (mode == 12) setSat(out, sat(cb)); + if (mode == 13) setSat(out, sat(cs)); + setLum(out, mode == 15 ? lum(cs) : lum(cb)); + } + + // mixed = B(cb, cs) for n pixels of interleaved channels: one switch per + // chunk, not a call and a switch per pixel. Each separable loop is + // channel() with a constant mode, so the expressions, hence the floats, + // are the ones the per-pixel blend() computes. + static void blendChunk(int mode, const float *cb, const float *cs, float *out, int n) + { + const int count = 3 * n; + switch (mode) + { + case 1: for (int i = 0; i < count; ++i) out[i] = channel(1, cb[i], cs[i]); return; + case 2: for (int i = 0; i < count; ++i) out[i] = channel(2, cb[i], cs[i]); return; + case 3: for (int i = 0; i < count; ++i) out[i] = channel(3, cb[i], cs[i]); return; + case 4: for (int i = 0; i < count; ++i) out[i] = channel(4, cb[i], cs[i]); return; + case 5: for (int i = 0; i < count; ++i) out[i] = channel(5, cb[i], cs[i]); return; + case 6: for (int i = 0; i < count; ++i) out[i] = channel(6, cb[i], cs[i]); return; + case 7: for (int i = 0; i < count; ++i) out[i] = channel(7, cb[i], cs[i]); return; + case 8: for (int i = 0; i < count; ++i) out[i] = channel(8, cb[i], cs[i]); return; + case 9: for (int i = 0; i < count; ++i) out[i] = channel(9, cb[i], cs[i]); return; + case 10: for (int i = 0; i < count; ++i) out[i] = channel(10, cb[i], cs[i]); return; + case 11: for (int i = 0; i < count; ++i) out[i] = channel(11, cb[i], cs[i]); return; + default: for (int i = 0; i < count; i += 3) blend(mode, cb + i, cs + i, out + i); return; + } + } + + static constexpr int kChunk = 32; + + // Per-core scratch, allocated by the first blended draw so an app that + // never blends reserves no RAM: a canvas row, a row of source colours and + // one chunk of unit channels. + struct Scratch + { + gea::framework::graphics::pixel::native_t row[2][gea::platform::display::kWidth]; + std::uint32_t source[2][gea::platform::display::kWidth]; + float cb[2][3 * kChunk], cs[2][3 * kChunk], mixed[2][3 * kChunk]; + std::uint8_t alpha[2][kChunk]; + }; + static Scratch &scratch() + { + static auto storage = std::make_unique(); + return *storage; + } + + // Framebuffer row y when it is plain linear memory; null on a rotated or + // packed framebuffer, where only the per-pixel accessors are exact. + static gea::framework::graphics::pixel::native_t *canvasRow(gea::framework::graphics::Canvas &canvas, int y) + { +#if GEA_EMBEDDED_DISPLAY_ROTATE_LANDSCAPE + if (canvas.isLandscapeRotated()) return nullptr; +#endif +#if GEA_PIXEL_STORAGE_PACKED + (void)canvas; + (void)y; + return nullptr; +#else + auto *pixels = canvas.pixels(); + if (!pixels) return nullptr; + const int stride = canvas.strideBytes() / static_cast(sizeof(*pixels)); + return pixels + static_cast(canvas.rowToPhysical(y)) * stride; +#endif + } + + // Blends the source colour the callback gives for each pixel of the + // clipped rows into the canvas. `color(x, y, rgb, &alpha)` returns false + // where the layer paints nothing. A row is read, blended and written back + // whole: no canvas call, clip test or dirty mark per pixel. + template + static void replay(int mode, int x0, int y0, int x1, int y1, Span span, Color color) + { + auto *canvas = gea::platform::display::Display::canvas(); + if (!canvas) return; + // Inside the canvas and the clip, so whole rows can be copied back. + int cx0, cy0, cx1, cy1; + gea::platform::display::Display::clip(&cx0, &cy0, &cx1, &cy1); + x0 = std::max({x0, 0, cx0}); y0 = std::max({y0, 0, cy0}); + x1 = std::min({x1, canvas->width() - 1, cx1}); y1 = std::min({y1, canvas->height() - 1, cy1}); + const int baseAlpha = gea::platform::display::Display::alpha(); + Scratch &s = scratch(); + const int core = gea_current_render_core() & 1; + gea::framework::graphics::pixel::native_t *row = s.row[core]; + float *cb = s.cb[core], *cs = s.cs[core], *mixed = s.mixed[core]; + std::uint8_t *alphas = s.alpha[core]; + for (int y = y0; y <= y1; ++y) { + int rowX0 = x0, rowX1 = x1; + span(y, &rowX0, &rowX1); + for (int chunkX = rowX0; chunkX <= rowX1; chunkX += gea::platform::display::kWidth) { + const int w = std::min(gea::platform::display::kWidth, rowX1 - chunkX + 1); + gea::framework::graphics::pixel::native_t *line = canvasRow(*canvas, y); + if (line) std::memcpy(row, line + chunkX, static_cast(w) * sizeof(*row)); + else for (int i = 0; i < w; ++i) row[i] = canvas->readPixelNative(chunkX + i, y); + for (int base = 0; base < w; base += kChunk) { + const int n = std::min(kChunk, w - base); + // Unit channels of the backdrop and the source; alpha 0 marks + // a pixel the layer leaves alone. + for (int i = 0; i < n; ++i) { + int rgb[3], alpha = 0; + alphas[i] = 0; + if (!color(chunkX + base + i, y, rgb, &alpha)) continue; + alpha = alpha * baseAlpha / 255; + if (alpha <= 0) continue; + alphas[i] = static_cast(alpha); + int br, bg, bb, ba; + gea::framework::graphics::pixel::unpackNative8(row[base + i], &br, &bg, &bb, &ba); + cb[3 * i] = kBlendUnit.v[br]; cb[3 * i + 1] = kBlendUnit.v[bg]; cb[3 * i + 2] = kBlendUnit.v[bb]; + cs[3 * i] = kBlendUnit.v[rgb[0]]; cs[3 * i + 1] = kBlendUnit.v[rgb[1]]; cs[3 * i + 2] = kBlendUnit.v[rgb[2]]; + } + blendChunk(mode, cb, cs, mixed, n); + for (int i = 0; i < n; ++i) { + if (!alphas[i]) continue; + const float a = kBlendUnit.v[alphas[i]]; + int out[3]; + for (int k = 0; k < 3; ++k) + out[k] = std::clamp(static_cast((cb[3 * i + k] + (mixed[3 * i + k] - cb[3 * i + k]) * a) * 255.0f + 0.5f), 0, 255); + row[base + i] = gea::framework::graphics::pixel::packNative8(out[0], out[1], out[2]); + } + } + if (line) { + std::memcpy(line + chunkX, row, static_cast(w) * sizeof(*row)); + canvas->markDirty(chunkX, y, chunkX + w - 1, y); + } else for (int i = 0; i < w; ++i) canvas->writePixelNativeExact(chunkX + i, y, row[i]); + } + } + } + }; +#endif + struct LinearGradientDrawer { static constexpr double kPi = 3.14159265358979323846; @@ -6560,6 +6782,81 @@ int gLastScrollUiFrame = -1000; *x1 = std::min(*x1, rowRight); } + // The gradient line: the position of pixel centre (px, py) along it is + // px*dx + py*dy, running from minProjection over span. + static void axis(const DisplayCommand &c, double *dxOut, double *dyOut, double *minProjectionOut, double *spanOut) + { + const double angleRadians = (static_cast(c.gradient.angle) * kPi) / 1800.0; + double dx = std::sin(angleRadians); + double dy = -std::cos(angleRadians); + // Snap axis-aligned angles to exact unit vectors: sin(pi) is 1.2e-16, + // not 0, so a plain `to bottom` gradient would miss the constant-row + // fast path below (and drag ~1e-16 noise through the projection). + switch (c.gradient.angle % 3600) + { + case 0: dx = 0.0; dy = -1.0; break; + case 900: dx = 1.0; dy = 0.0; break; + case 1800: dx = 0.0; dy = 1.0; break; + case 2700: dx = -1.0; dy = 0.0; break; + default: break; + } + const double gx0 = static_cast(c.gradient.x); + const double gy0 = static_cast(c.gradient.y); + const double gx1 = static_cast(c.gradient.x + c.gradient.w); + const double gy1 = static_cast(c.gradient.y + c.gradient.h); + const double p0 = gx0 * dx + gy0 * dy; + const double p1 = gx1 * dx + gy0 * dy; + const double p2 = gx1 * dx + gy1 * dy; + const double p3 = gx0 * dx + gy1 * dy; + const double minProjection = std::min(std::min(p0, p1), std::min(p2, p3)); + const double maxProjection = std::max(std::max(p0, p1), std::max(p2, p3)); + *dxOut = dx; + *dyOut = dy; + *minProjectionOut = minProjection; + *spanOut = maxProjection - minProjection; + } + +#if GEA_CSS_BACKGROUND_BLEND + static void replayBlended(const DisplayCommand &c, int x0, int y0, int x1, int y1) + { + double dx, dy, minProjection, projectionSpan; + axis(c, &dx, &dy, &minProjection, &projectionSpan); + const double invSpanPermille = projectionSpan > 0.001 ? 1000.0 / projectionSpan : 0.0; + int lastPermille = -1; + std::uint32_t last = 0; + // The source as r | g << 8 | b << 16 | alpha << 24. + auto sourceAt = [&](int x, int y) { + const double projection = (x + 0.5) * dx + (y + 0.5) * dy; + const int permille = clampPermille(static_cast((projection - minProjection) * invSpanPermille + 0.5)); + if (permille != lastPermille) { + int r, g, b; + rgb888At(c, permille, &r, &g, &b); + last = static_cast(r) | static_cast(g) << 8 | static_cast(b) << 16 | + static_cast(alphaAt(c, permille)) << 24; + lastPermille = permille; + } + return last; + }; + // Without a vertical component the source depends on x alone (y * 0 + // adds nothing): work it out once per column, not once per pixel. + std::uint32_t *columns = nullptr; + if (dy == 0.0 && x1 - x0 < gea::platform::display::kWidth) { + columns = BlendDrawer::scratch().source[gea_current_render_core() & 1]; + for (int x = x0; x <= x1; ++x) columns[x - x0] = sourceAt(x, y0); + } + BlendDrawer::replay(c.gradient.blend, x0, y0, x1, y1, + [&](int y, int *a, int *b) { roundedRowSpan(c, y, a, b); }, + [&](int x, int y, int rgb[3], int *alpha) { + const std::uint32_t s = columns ? columns[x - x0] : sourceAt(x, y); + rgb[0] = static_cast(s & 255u); + rgb[1] = static_cast(s >> 8 & 255u); + rgb[2] = static_cast(s >> 16 & 255u); + *alpha = static_cast(s >> 24); + return true; + }); + } +#endif + static void fillRun(int x, int y, int w, uint16_t color, uint8_t alpha, uint8_t baseAlpha) { const uint8_t effectiveAlpha = combineAlpha(baseAlpha, alpha); @@ -6582,6 +6879,13 @@ int gLastScrollUiFrame = -1000; const int y1 = std::min(c.gradient.y + c.gradient.h - 1, cy1); if (x1 < x0 || y1 < y0) return; +#if GEA_CSS_BACKGROUND_BLEND + if (c.gradient.blend) + { + replayBlended(c, x0, y0, x1, y1); + return; + } +#endif // An opaque constant gradient, e.g. linear-gradient(green, green), is a // solid fill: dithering it would speckle a colour a plain fill paints flat. @@ -6681,31 +6985,8 @@ int gLastScrollUiFrame = -1000; #endif - const double angleRadians = (static_cast(c.gradient.angle) * kPi) / 1800.0; - double dx = std::sin(angleRadians); - double dy = -std::cos(angleRadians); - // Snap axis-aligned angles to exact unit vectors: sin(pi) is 1.2e-16, - // not 0, so a plain `to bottom` gradient would miss the constant-row - // fast path below (and drag ~1e-16 noise through the projection). - switch (c.gradient.angle % 3600) - { - case 0: dx = 0.0; dy = -1.0; break; - case 900: dx = 1.0; dy = 0.0; break; - case 1800: dx = 0.0; dy = 1.0; break; - case 2700: dx = -1.0; dy = 0.0; break; - default: break; - } - const double gx0 = static_cast(c.gradient.x); - const double gy0 = static_cast(c.gradient.y); - const double gx1 = static_cast(c.gradient.x + c.gradient.w); - const double gy1 = static_cast(c.gradient.y + c.gradient.h); - const double p0 = gx0 * dx + gy0 * dy; - const double p1 = gx1 * dx + gy0 * dy; - const double p2 = gx1 * dx + gy1 * dy; - const double p3 = gx0 * dx + gy1 * dy; - const double minProjection = std::min(std::min(p0, p1), std::min(p2, p3)); - const double maxProjection = std::max(std::max(p0, p1), std::max(p2, p3)); - const double projectionSpan = maxProjection - minProjection; + double dx, dy, minProjection, projectionSpan; + axis(c, &dx, &dy, &minProjection, &projectionSpan); if (projectionSpan <= 0.001) { const uint8_t baseAlpha = gea::platform::display::Display::alpha(); @@ -7172,6 +7453,23 @@ int gLastScrollUiFrame = -1000; static_cast(c.radialGradient.h) * static_cast(c.radialGradient.ryPermille) / 1000.0f); const float invRadiusX = 1.0f / radiusX; const float invRadiusY = 1.0f / radiusY; +#if GEA_CSS_BACKGROUND_BLEND + if (c.radialGradient.blend) + { + BlendDrawer::replay(c.radialGradient.blend, x0, y0, x1, y1, + [&](int y, int *a, int *b) { roundedRowSpan(c, y, a, b); }, + [&](int x, int y, int rgb[3], int *alpha) { + const float dxv = (static_cast(x) + 0.5f - centerX) * invRadiusX; + const float dyv = (static_cast(y) + 0.5f - centerY) * invRadiusY; + const int permille = std::min(1000, static_cast(std::sqrt(dxv * dxv + dyv * dyv) * 1000.0f + 0.5f)); + LinearGradientDrawer::interpolatePremultipliedRgb888(c.radialGradient.fromColor, c.radialGradient.fromAlpha, + c.radialGradient.toColor, c.radialGradient.toAlpha, localPermille(c, permille), &rgb[0], &rgb[1], &rgb[2]); + *alpha = alphaAt(c, permille); + return true; + }); + return; + } +#endif // Static-gradient bitmap cache (shared StaticGradientCache). The shell's // warm-glow radial is static — fixed colors + box, identical across themes @@ -7389,6 +7687,39 @@ int gLastScrollUiFrame = -1000; collectTextInk(child, owner, x0, y0, x1, y1, ink, opaque, copyOffset); } + // The first command of the text-clipped background `command` belongs to: + // the element's layers are recorded together under one owner. + static int textClipGroupStart(const DisplayCommand &command, int *index) + { + *index = -1; + if (!state.commands || &command < state.commands || &command >= state.commands + state.commandCount) return -1; + *index = static_cast(&command - state.commands); + int start = *index; + while (start > 0 && state.commands[start - 1].textClipOwner == command.textClipOwner) --start; + for (int i = start; i < *index; ++i) { + const auto type = state.commands[i].type; + if (type != DisplayCommandType::SetAlpha && type != DisplayCommandType::PushClip && type != DisplayCommandType::PopClip) return start; + } + return -1; + } + + // Paints the group's layers in [start, end) unclipped by the text. + static void replayTextClipBackdrop(int start, int end) + { + using gea::platform::display::Display; + const uint8_t alpha = Display::alpha(); + int pushed = 0; + for (int i = start; i < end; ++i) { + DisplayCommand c = state.commands[i]; + c.textClipOwner = -1; + if (c.type == DisplayCommandType::PushClip) { Display::pushClip(c.clip.x, c.clip.y, c.clip.w, c.clip.h); ++pushed; } + else if (c.type != DisplayCommandType::PopClip) replay(c); + else if (pushed > 0) { Display::popClip(); --pushed; } + } + while (pushed-- > 0) Display::popClip(); + Display::setAlpha(alpha); + } + static void replayTextClipped(const DisplayCommand &command) { using gea::platform::display::Display; @@ -7411,10 +7742,12 @@ int gLastScrollUiFrame = -1000; if (ink.empty()) return; DisplayCommand paint = command; paint.textClipOwner = -1; + int index = -1; + const int groupStart = textClipGroupStart(command, &index); // Stack-local, bounded scratch remains safe if independent dirty rows // are replayed on separate workers. No viewport-sized backing store. constexpr int chunkSize = 128; - pixel::native_t before[chunkSize]; + pixel::native_t before[chunkSize], painted[chunkSize], under[chunkSize]; uint8_t coverage[chunkSize]; for (int y = y0; y <= y1; ++y) for (int x = x0; x <= x1; x += chunkSize) { const int width = std::min(chunkSize, x1-x+1); @@ -7436,9 +7769,35 @@ int gLastScrollUiFrame = -1000; if (std::any_of(coverage, coverage+width, [](uint8_t a) { return a != 0; })) { for (int i = 0; i < width; ++i) before[i] = canvas->readPixelNative(x+i, y); replay(paint); - for (int i = 0; i < width; ++i) if (coverage[i] != 255) - canvas->writePixelNativeExact(x+i, y, coverage[i] == 0 ? before[i] : - pixel::blendNative(canvas->readPixelNative(x+i, y), before[i], coverage[i])); + if (groupStart >= 0 && std::any_of(coverage, coverage+width, [](uint8_t a) { return a != 0 && a != 255; })) { + // CSS clips all of an element's layers to the text once. At a + // partly covered pixel the canvas already mixes the lower layers + // with what is behind them, so add this layer's change to their + // unclipped composite instead of blending over that mix. + for (int i = 0; i < width; ++i) { + painted[i] = canvas->readPixelNative(x+i, y); + canvas->writePixelNativeExact(x+i, y, before[i]); + } + replayTextClipBackdrop(groupStart, index); + for (int i = 0; i < width; ++i) under[i] = canvas->readPixelNative(x+i, y); + replay(paint); + for (int i = 0; i < width; ++i) { + if (coverage[i] == 255 || coverage[i] == 0) { + canvas->writePixelNativeExact(x+i, y, coverage[i] ? painted[i] : before[i]); + continue; + } + int b[4], u[4], p[4]; + pixel::unpackNative8(before[i], &b[0], &b[1], &b[2], &b[3]); + pixel::unpackNative8(under[i], &u[0], &u[1], &u[2], &u[3]); + pixel::unpackNative8(canvas->readPixelNative(x+i, y), &p[0], &p[1], &p[2], &p[3]); + for (int k = 0; k < 3; ++k) b[k] = std::clamp(b[k] + ((p[k] - u[k]) * coverage[i] + (p[k] >= u[k] ? 127 : -127)) / 255, 0, 255); + canvas->writePixelNativeExact(x+i, y, pixel::packNative8(b[0], b[1], b[2])); + } + } else { + for (int i = 0; i < width; ++i) if (coverage[i] != 255) + canvas->writePixelNativeExact(x+i, y, coverage[i] == 0 ? before[i] : + pixel::blendNative(canvas->readPixelNative(x+i, y), before[i], coverage[i])); + } } Display::popClip(); } diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 2b1491f..48da9ba 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -505,6 +505,9 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) a.bg_fill != b.bg_fill || ar.containment != br.containment || ar.bg_clip != br.bg_clip || +#if GEA_CSS_BACKGROUND_BLEND + ar.bg_blend != br.bg_blend || +#endif ar.bg_size_list != br.bg_size_list || ar.bg_position_list != br.bg_position_list || ar.bg_repeat_list != br.bg_repeat_list || @@ -1327,6 +1330,9 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "contain") == 0) return CssDeclarationId::Contain; if (std::strcmp(property, "background-color") == 0) return CssDeclarationId::BackgroundColor; if (std::strcmp(property, "background-clip") == 0) return CssDeclarationId::BackgroundClip; +#if GEA_CSS_BACKGROUND_BLEND + if (std::strcmp(property, "background-blend-mode") == 0) return CssDeclarationId::BackgroundBlendMode; +#endif if (std::strcmp(property, "background-image") == 0) return CssDeclarationId::BackgroundImage; if (std::strcmp(property, "background") == 0) return CssDeclarationId::Background; if (std::strcmp(property, "background-size") == 0) return CssDeclarationId::BackgroundSize; @@ -2437,10 +2443,21 @@ std::vector> &backgroundClipLists() return lists; } +#if GEA_CSS_BACKGROUND_BLEND +std::vector> &backgroundBlendLists() +{ + static std::vector> lists; + return lists; +} +#endif + void clearCompiledCssBackgrounds() { compiledCssBackgrounds().clear(); backgroundClipLists().clear(); +#if GEA_CSS_BACKGROUND_BLEND + backgroundBlendLists().clear(); +#endif backgroundPlacementLists().clear(); } @@ -7052,6 +7069,33 @@ int compileBackgroundClip(const std::string &value, bool shorthand = false) return storeBackgroundClipList(clips); } +#if GEA_CSS_BACKGROUND_BLEND +// background-blend-mode as an interned per-layer list; the handle is one +// byte, so an app gets 255 distinct non-normal lists. +int compileBackgroundBlendMode(const std::string &value) +{ + static constexpr const char *kModes[] = {"normal", "multiply", "screen", "overlay", "darken", "lighten", + "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", + "color", "luminosity"}; + const auto lower = toLowerAscii(trimCssValue(value)); + if (lower == "initial" || lower == "unset") return 0; + std::vector modes; + for (const auto &layer : splitTopLevel(lower, ',')) { + const auto word = trimCssValue(layer); + const auto *mode = std::find_if(std::begin(kModes), std::end(kModes), [&](const char *name) { return word == name; }); + if (mode == std::end(kModes)) return -1; + modes.push_back(static_cast(mode - std::begin(kModes))); + } + if (modes.empty()) return -1; + if (std::all_of(modes.begin(), modes.end(), [](auto mode) { return mode == 0; })) return 0; + auto &lists = backgroundBlendLists(); + for (std::size_t i = 0; i < lists.size(); ++i) if (lists[i] == modes) return static_cast(i + 1); + if (lists.size() >= 255) return -1; + lists.push_back(modes); + return static_cast(lists.size()); +} +#endif + ParsedCssColor backgroundBaseColor(const std::string &value) { ParsedCssColor color; @@ -7768,6 +7812,16 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa compiled.values[1] = clip; return storeCompiledCssValue(compiled); } +#if GEA_CSS_BACKGROUND_BLEND + if (declaration == CssDeclarationId::BackgroundBlendMode && !hasVar) { + const int blend = compileBackgroundBlendMode(value); + if (blend < 0) return kNoCompiledCssValue; + compiled.kind = CssCompiledKind::DirectProperty; + compiled.values[0] = static_cast(Property::BackgroundBlendMode); + compiled.values[1] = blend; + return storeCompiledCssValue(compiled); + } +#endif StaticStylePropertyValue clampWrites[4]; int clampCount = hasVar ? -1 : lineClampWrites(declaration, value, clampWrites); @@ -10068,6 +10122,16 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl if (clip >= 0) setStyleValue(node, Property::BackgroundClip, clip, source); return true; } + case CssDeclarationId::BackgroundBlendMode: { +#if GEA_CSS_BACKGROUND_BLEND + const int parent = Tree::instance().node(nodeId).parent; + const int blend = toLowerAscii(trimCssValue(value)) == "inherit" + ? (parent >= 0 ? rstyle(Tree::instance().node(parent).computedStyle()).bg_blend : 0) + : compileBackgroundBlendMode(value); + if (blend >= 0) setStyleValue(node, Property::BackgroundBlendMode, blend, source); +#endif + return true; + } case CssDeclarationId::Contain: { const int parent = Tree::instance().node(nodeId).parent; const int flags = toLowerAscii(trimCssValue(value)) == "inherit" @@ -12229,6 +12293,9 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "background") return removeInlineStyleProperties(id, {Property::BackgroundColor, Property::BackgroundAlpha, Property::BackgroundImage, Property::BackgroundClip, Property::HasBackground, Property::BackgroundSizeList, Property::BackgroundPositionList, Property::BackgroundRepeatList, Property::BackgroundAttachmentList, Property::BackgroundOriginList}); if (property == "background-clip") return removeInlineStyleProperties(id, {Property::BackgroundClip}); +#if GEA_CSS_BACKGROUND_BLEND + if (property == "background-blend-mode") return removeInlineStyleProperties(id, {Property::BackgroundBlendMode}); +#endif if (property == "background-size") return removeInlineStyleProperties(id, {Property::BackgroundSizeList}); if (property == "background-position") return removeInlineStyleProperties(id, {Property::BackgroundPositionList}); if (property == "background-repeat") return removeInlineStyleProperties(id, {Property::BackgroundRepeatList}); @@ -18155,6 +18222,15 @@ bool StyleValues::hasTextBackgroundClip(const ComputedStyle &style) return std::find(list.begin(), list.end(), 3) != list.end(); } +int StyleValues::backgroundBlendMode(const ComputedStyle &style, int layer) +{ + const auto handle = rstyle(style).bg_blend; + const auto &lists = backgroundBlendLists(); + if (!handle || handle > lists.size()) return 0; + const auto &list = lists[handle - 1]; + return list[std::max(0, layer) % list.size()]; +} + int StyleValues::backgroundClip(const ComputedStyle &style, int layer) { const auto handle = rstyle(style).bg_clip; diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 96d4af7..5b224dc 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -229,6 +229,7 @@ enum class Property : int { TextEmphasisPosition, TextEmphasisColorMode, TextEmphasisColor, + BackgroundBlendMode, Count }; @@ -392,6 +393,7 @@ enum class StyleDeclaration : std::uint8_t { TextEmphasisStyleDeclaration, TextEmphasisColorDeclaration, TextEmphasisPositionDeclaration, + BackgroundBlendMode, // Logical two-sided shorthands (see logicalPairFor in style.cpp). MarginInline, MarginBlock, diff --git a/packages/engine/ui/style_values.h b/packages/engine/ui/style_values.h index ceb2316..0132181 100644 --- a/packages/engine/ui/style_values.h +++ b/packages/engine/ui/style_values.h @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include "css_features.h" #include "pixel.h" #include @@ -22,6 +23,12 @@ class StyleValues { static bool applyBackgroundImage(ComputedStyle &style, int handle, int nodeId); // 0: border-box, 1: padding-box, 2: content-box. Lists repeat by image layer. static int backgroundClip(const ComputedStyle &style, int layer); +#if GEA_CSS_BACKGROUND_BLEND + // 0 normal, then multiply, screen, overlay, darken, lighten, color-dodge, + // color-burn, hard-light, soft-light, difference, exclusion, hue, + // saturation, color, luminosity. Callers check rstyle(style).bg_blend first. + static int backgroundBlendMode(const ComputedStyle &style, int layer); +#endif static bool hasTextBackgroundClip(const ComputedStyle &style); static BackgroundPlacement backgroundPlacement(const ComputedStyle &style, int nodeId, int layer, int x, int y, int width, int height); diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 2c0f423..69342d6 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -775,12 +775,13 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn break; } #if GEA_CSS_BACKGROUND_LAYERS - case Property::BackgroundClip: if (rstyle(style).bg_clip != value) { rstyleMut(style).bg_clip = value; changed = 1; } break; - case Property::BackgroundSizeList: if (rstyle(style).bg_size_list != value) { rstyleMut(style).bg_size_list = value; changed = 1; } break; - case Property::BackgroundPositionList: if (rstyle(style).bg_position_list != value) { rstyleMut(style).bg_position_list = value; changed = 1; } break; - case Property::BackgroundRepeatList: if (rstyle(style).bg_repeat_list != value) { rstyleMut(style).bg_repeat_list = value; changed = 1; } break; - case Property::BackgroundAttachmentList: if (rstyle(style).bg_attachment_list != value) { rstyleMut(style).bg_attachment_list = value; changed = 1; } break; - case Property::BackgroundOriginList: if (rstyle(style).bg_origin_list != value) { rstyleMut(style).bg_origin_list = value; changed = 1; } break; + case Property::BackgroundClip: if (rstyle(n->style).bg_clip != value) { rstyleMut(n->style).bg_clip = value; changed = 1; } break; + case Property::BackgroundBlendMode: if (rstyle(n->style).bg_blend != value) { rstyleMut(n->style).bg_blend = value; changed = 1; } break; + case Property::BackgroundSizeList: if (rstyle(n->style).bg_size_list != value) { rstyleMut(n->style).bg_size_list = value; changed = 1; } break; + case Property::BackgroundPositionList: if (rstyle(n->style).bg_position_list != value) { rstyleMut(n->style).bg_position_list = value; changed = 1; } break; + case Property::BackgroundRepeatList: if (rstyle(n->style).bg_repeat_list != value) { rstyleMut(n->style).bg_repeat_list = value; changed = 1; } break; + case Property::BackgroundAttachmentList: if (rstyle(n->style).bg_attachment_list != value) { rstyleMut(n->style).bg_attachment_list = value; changed = 1; } break; + case Property::BackgroundOriginList: if (rstyle(n->style).bg_origin_list != value) { rstyleMut(n->style).bg_origin_list = value; changed = 1; } break; #endif case Property::BackgroundImage: diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 8eb2961..96fb7ad 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -284,10 +284,34 @@ void appendFillQuadStrokeSegmentRaw(const int16_t *xs, const int16_t *ys, uint16 void boundsFromCorners(const int16_t *xs, const int16_t *ys, int *x0, int *y0, int *x1, int *y1); +// background-blend-mode of `layer`. An element's layers blend as an isolated +// group, so a layer with none of the element's paint under it (its color or a +// lower layer) has a transparent backdrop and paints normally. +uint8_t backgroundLayerBlend(const Node &node, int layer) +{ +#if GEA_CSS_BACKGROUND_BLEND + // Every gradient record passes here, so the handle byte is checked before + // the call into style.cpp; a node without the property pays one load. + const auto &r = rstyle(node.computedStyle()); + if (!r.bg_blend) return 0; + const int mode = StyleValues::backgroundBlendMode(node.computedStyle(), layer); + if (!mode) return 0; + const bool backdrop = (node.computedStyle().has_bg && node.computedStyle().bg_alpha > 0) || + (node.computedStyle().bg_fill == 1 && r.bg_gradient_layer > layer) || + (r.bg_overlay_gradient && r.bg_overlay_gradient_layer > layer) || + (r.bg_radial_gradient && r.bg_radial_gradient_layer > layer); + return backdrop ? static_cast(mode) : 0; +#else + (void)node; + (void)layer; + return 0; +#endif +} + DisplayCommand *appendLinearGradientRectRaw(const Node &node, int x, int y, int w, int h, uint16_t fromColor, uint16_t midColor, uint16_t toColor, uint16_t midStop, uint16_t toStop, int16_t angle, uint8_t fromAlpha, - uint8_t midAlpha, uint8_t toAlpha, uint8_t hasMid) + uint8_t midAlpha, uint8_t toAlpha, uint8_t hasMid, uint8_t blend = 0) { DisplayCommand *cmd = DisplayList::instance().append(); if (!cmd) return nullptr; @@ -314,6 +338,7 @@ DisplayCommand *appendLinearGradientRectRaw(const Node &node, int x, int y, int cmd->gradient.midAlpha = midAlpha; cmd->gradient.toAlpha = toAlpha; cmd->gradient.hasMid = hasMid; + cmd->gradient.blend = blend; return cmd; } @@ -333,7 +358,8 @@ DisplayCommand *appendLinearGradientRectRaw(const Node &node, int x, int y, int rstyle(node.computedStyle()).bg_gradient_from_alpha, rstyle(node.computedStyle()).bg_gradient_mid_alpha, rstyle(node.computedStyle()).bg_gradient_to_alpha, - rstyle(node.computedStyle()).bg_gradient_has_mid); + rstyle(node.computedStyle()).bg_gradient_has_mid, + backgroundLayerBlend(node, rstyle(node.computedStyle()).bg_gradient_layer)); } DisplayCommand *appendRadialGradientRectRaw(const Node &node, int x, int y, int w, int h) @@ -362,6 +388,7 @@ DisplayCommand *appendRadialGradientRectRaw(const Node &node, int x, int y, int cmd->radialGradient.stopPermille = rstyle(node.computedStyle()).bg_radial_gradient_stop; cmd->radialGradient.fromAlpha = rstyle(node.computedStyle()).bg_radial_gradient_from_alpha; cmd->radialGradient.toAlpha = rstyle(node.computedStyle()).bg_radial_gradient_to_alpha; + cmd->radialGradient.blend = backgroundLayerBlend(node, rstyle(node.computedStyle()).bg_radial_gradient_layer); return cmd; } @@ -1796,7 +1823,8 @@ void recordOverlayLinearGradientBackground(const Node &node) rstyle(node.computedStyle()).bg_overlay_gradient_from_alpha, rstyle(node.computedStyle()).bg_overlay_gradient_mid_alpha, rstyle(node.computedStyle()).bg_overlay_gradient_to_alpha, - rstyle(node.computedStyle()).bg_overlay_gradient_has_mid); + rstyle(node.computedStyle()).bg_overlay_gradient_has_mid, + backgroundLayerBlend(node, rstyle(node.computedStyle()).bg_overlay_gradient_layer)); } // Background positioning and painting areas are distinct. In particular, the @@ -1855,7 +1883,8 @@ bool recordPlacedBackgrounds(const Node &geometry, const Node &source, bool canv else paint = appendLinearGradientRectRaw(source,tx,ty,p.width,p.height, r.bg_overlay_gradient_from_color,r.bg_overlay_gradient_mid_color,r.bg_overlay_gradient_to_color, r.bg_overlay_gradient_mid_stop,r.bg_overlay_gradient_to_stop,r.bg_overlay_gradient_angle, - r.bg_overlay_gradient_from_alpha,r.bg_overlay_gradient_mid_alpha,r.bg_overlay_gradient_to_alpha,r.bg_overlay_gradient_has_mid); + r.bg_overlay_gradient_from_alpha,r.bg_overlay_gradient_mid_alpha,r.bg_overlay_gradient_to_alpha,r.bg_overlay_gradient_has_mid, + backgroundLayerBlend(source, layer)); // Radius belongs to the painting box, never to each repeated image. if (paint) { auto &cmd = *paint; From babf6f6e4ec16a48b883f5e05f644d658304bc17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 04:01:54 +0300 Subject: [PATCH 26/50] Support border-image with gradient sources Parse border-image and its five longhands into one-byte interned handles in RareStyle padding. A compiled rule can now write five properties, the shorthand's reset of all longhands; such groups skip the style apply cache. A linear-gradient source is sized to the border image area (border box plus outset) and cut by the slices into nine parts drawn over the border image widths, with stretch, repeat, round and space along the edges and the middle part only with fill. Each part is one gradient command scaled so its slice lands on the tile and clipped to the command bounds (a boundsClip flag inside the command union; flagged tiles never enter the gradient caches). An edge that needs more than 16 tiles is stretched, which bounds a box at 4 x 16 + 5 commands. A painted border image replaces the border styles, and the node's dirty rect grows by the outset; the raster guard also covers the current or previous outer box-shadow extent. The geometry is single precision, with its divisions per box. recordBox tests the source byte inline and calls nothing when it is zero; the dirty-rect guards share one rare-style lookup and ask for the outset extent only when there is a source. Gradient drawing serves the tiles too: axis() calls sin/cos only for angles that are not axis-aligned; a gradient with square corners, opaque stops and w x h within the row buffer is rasterized as one block and sent with a single blitImage, copying the 4-row Bayer period when it has no vertical component; and the linear and radial drawers are out of line, which keeps them out of the command dispatcher's frame (29 KB down to 18 KB). GEA_CSS_BORDER_IMAGE (default on) gates the family. Off, the five handles are static constexpr 0, the properties are unknown, and record, the guards and the gradient replay carry no border-image checks; RareStyle is 4 bytes smaller. DisplayCommand stays 80 bytes and CssCompiledValue 80 in every configuration. The analyzer and CLI do not report the family yet. Desktop harness: border-image-24 (24 boxes with gradient border images) about 690 us a frame, about 64 us of it border-image's own cost over plain borders; the rest is dithered gradient pixels, about 40k a frame. Not measured on the board yet. Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/css_features.h | 7 + packages/engine/ui/display_invalidation.cpp | 5 + packages/engine/ui/internal.h | 6 + packages/engine/ui/layout.cpp | 27 +- packages/engine/ui/node_model.h | 31 +- packages/engine/ui/render.cpp | 177 ++++++---- packages/engine/ui/style.cpp | 343 +++++++++++++++++++- packages/engine/ui/style.h | 11 + packages/engine/ui/style_values.h | 23 ++ packages/engine/ui/tree_render.cpp | 19 +- packages/engine/ui/tree_style.cpp | 23 +- packages/engine/ui/view.cpp | 101 ++++++ 12 files changed, 686 insertions(+), 87 deletions(-) diff --git a/packages/engine/ui/css_features.h b/packages/engine/ui/css_features.h index bd63386..1de6ab1 100644 --- a/packages/engine/ui/css_features.h +++ b/packages/engine/ui/css_features.h @@ -255,6 +255,13 @@ #define GEA_CSS_BACKGROUND_LAYERS 1 #endif +// border-image with a linear-gradient() source (CSS Backgrounds 3, section 6). +// Off, the properties are unknown, RareStyle carries no handles, and the +// record and dirty-rect paths carry no border-image checks. +#ifndef GEA_CSS_BORDER_IMAGE +#define GEA_CSS_BORDER_IMAGE 1 +#endif + // background-blend-mode on gradient layers (Compositing 1, section 10). Off, // the property is unknown, RareStyle carries no handle, and replay carries no // per-pixel blend path. diff --git a/packages/engine/ui/display_invalidation.cpp b/packages/engine/ui/display_invalidation.cpp index 7207f0c..7315641 100644 --- a/packages/engine/ui/display_invalidation.cpp +++ b/packages/engine/ui/display_invalidation.cpp @@ -44,6 +44,11 @@ bool DisplayInvalidation::rebuildsNodeDisplayCommands(Property prop) case Property::BackgroundAlpha: case Property::BackgroundClip: case Property::BackgroundBlendMode: + case Property::BorderImageSource: + case Property::BorderImageSlice: + case Property::BorderImageWidth: + case Property::BorderImageOutset: + case Property::BorderImageRepeat: case Property::BackgroundImage: case Property::Containment: case Property::ActiveBackgroundColor: diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index e0065db..19dffc2 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -161,6 +161,12 @@ void rootScrollImageHoldTick(); uint8_t hasMid; // background-blend-mode of the layer; 0 is normal (see BlendMode). uint8_t blend; +#if GEA_CSS_BORDER_IMAGE + // 1: the replay also clips the fill to the command bounds. A + // border-image tile's gradient box is the whole scaled image; its + // bounds are the tile, so it needs no PushClip/PopClip pair. + uint8_t boundsClip; +#endif } gradient; struct { diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index ea7a619..884df01 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2801,8 +2801,10 @@ class LayoutNodePass { int used = std::max(1, (flow + height - 1) / height); if (flags & 32) used = std::min(used, g.count); ensureRareData(id_).multicol = MulticolLayout{clampInt16(g.width), clampInt16(g.gap), clampInt16(height), clampInt16(used), true, (flags & 32) != 0}; +#if GEA_CSS_SCROLLING node_.layout.scroll_content_width = clampInt16(std::max(node_.layout.width, insetsX + used * (g.width + g.gap) - g.gap)); node_.layout.scroll_content_height = node_.layout.height; +#endif } // The shortest column height that fits the flow into `count` columns with @@ -5248,16 +5250,33 @@ bool LayoutEngine::layoutNodeScoped(int scope, int treeRoot) #if GEA_CSS_STICKY namespace { -int stickyInset(const Node &node, int side, int basis) +// The position offset macros select a side at compile time. +template +int stickyInsetFor(const Node &node, int basis) { - int inset = node.style.pos_offsets[side] != kUnset ? node.style.pos_offsets[side] : 0; - if (node.style.pos_offset_percent[side] != kUnset) inset += basis * node.style.pos_offset_percent[side] / 1000; + int inset = GEA_CSS_POSITION_PX(node.computedStyle(), side) != kUnset ? GEA_CSS_POSITION_PX(node.computedStyle(), side) : 0; + if (GEA_CSS_POSITION_PERCENT(node.computedStyle(), side) != kUnset) inset += basis * GEA_CSS_POSITION_PERCENT(node.computedStyle(), side) / 1000; return inset; } +int stickyInset(const Node &node, int side, int basis) +{ + switch (side) { + case 0: return stickyInsetFor<0>(node, basis); + case 1: return stickyInsetFor<1>(node, basis); + case 2: return stickyInsetFor<2>(node, basis); + default: return stickyInsetFor<3>(node, basis); + } +} + bool hasStickyInset(const Node &node, int side) { - return node.style.pos_offsets[side] != kUnset || node.style.pos_offset_percent[side] != kUnset; + switch (side) { + case 0: return hasPositionOffsetValue<0>(node); + case 1: return hasPositionOffsetValue<1>(node); + case 2: return hasPositionOffsetValue<2>(node); + default: return hasPositionOffsetValue<3>(node); + } } // position: sticky (CSS Position 3): shift the box, never out of its diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 5e32439..1b5b86e 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -418,6 +418,15 @@ struct RareStyle { // and the int32 lists; without them RareStyle is four bytes smaller. #if GEA_CSS_BACKGROUND_BLEND uint8_t bg_blend = 0; // Interned background-blend-mode list; zero is all normal. +#endif +#if GEA_CSS_BORDER_IMAGE + // border-image-* (see StyleValues::borderImageSource/Sides/Repeat): interned + // handles, zero is the initial value. + uint8_t border_image_source = 0; + uint8_t border_image_slice = 0; + uint8_t border_image_width = 0; + uint8_t border_image_outset = 0; + uint8_t border_image_repeat = 0; // bits 0-1 horizontal, 2-3 vertical: stretch, repeat, round, space #endif int32_t bg_size_list = -1, bg_position_list = -1, bg_repeat_list = -1; int32_t bg_attachment_list = -1, bg_origin_list = -1; @@ -425,6 +434,26 @@ struct RareStyle { static constexpr uint16_t bg_image_layer_count = 1; // Includes none layers. static constexpr int32_t bg_size_list = -1, bg_position_list = -1, bg_repeat_list = -1; static constexpr int32_t bg_attachment_list = -1, bg_origin_list = -1; +#if GEA_CSS_BACKGROUND_BLEND + uint8_t bg_blend = 0; +#endif +#if GEA_CSS_BORDER_IMAGE + uint8_t border_image_source = 0; + uint8_t border_image_slice = 0; + uint8_t border_image_width = 0; + uint8_t border_image_outset = 0; + uint8_t border_image_repeat = 0; +#endif +#endif +#if !GEA_CSS_BACKGROUND_BLEND + static constexpr uint8_t bg_blend = 0; +#endif +#if !GEA_CSS_BORDER_IMAGE + static constexpr uint8_t border_image_source = 0; + static constexpr uint8_t border_image_slice = 0; + static constexpr uint8_t border_image_width = 0; + static constexpr uint8_t border_image_outset = 0; + static constexpr uint8_t border_image_repeat = 0; #endif #if GEA_EMBEDDED_RENDERER_LINEAR_GRADIENTS || GEA_EMBEDDED_RENDERER_RADIAL_GRADIENTS uint16_t bg_gradient_layer = 0, bg_overlay_gradient_layer = 0, bg_radial_gradient_layer = 0; @@ -537,7 +566,7 @@ inline int composedTranslateZ(const RareStyle &s) { return int(s.transform_trans inline int composedTranslateXPercent(const RareStyle &s) { return int(s.transform_translate_x_percent) + s.translate_x_percent; } inline int composedTranslateYPercent(const RareStyle &s) { return int(s.transform_translate_y_percent) + s.translate_y_percent; } -#define GEA_CSS_RARE_STYLE (GEA_CSS_ASPECT_RATIO || GEA_CSS_BACKGROUND_LAYERS || GEA_CSS_BORDER_RELIEF || GEA_CSS_BOX_EXPRESSIONS || GEA_CSS_BOX_SHADOW || GEA_CSS_CONTAINMENT || GEA_CSS_FILTERS || GEA_CSS_FLEX_BASIS_EXPRESSIONS || GEA_CSS_FLEX_LINE_COUNT || GEA_CSS_GRID || GEA_CSS_JUSTIFY_SELF || GEA_CSS_LINE_CLAMP || GEA_CSS_LINE_HEIGHT_EXPRESSIONS || GEA_CSS_MARGIN_TRIM || GEA_CSS_MULTICOL || GEA_CSS_SIDE_BORDERS || GEA_CSS_TRANSFORMS || GEA_EMBEDDED_RENDERER_LINEAR_GRADIENTS || GEA_EMBEDDED_RENDERER_RADIAL_GRADIENTS) +#define GEA_CSS_RARE_STYLE (GEA_CSS_ASPECT_RATIO || GEA_CSS_BACKGROUND_BLEND || GEA_CSS_BACKGROUND_LAYERS || GEA_CSS_BORDER_IMAGE || GEA_CSS_BORDER_RELIEF || GEA_CSS_BOX_EXPRESSIONS || GEA_CSS_BOX_SHADOW || GEA_CSS_CONTAINMENT || GEA_CSS_FILTERS || GEA_CSS_FLEX_BASIS_EXPRESSIONS || GEA_CSS_FLEX_LINE_COUNT || GEA_CSS_GRID || GEA_CSS_JUSTIFY_SELF || GEA_CSS_LINE_CLAMP || GEA_CSS_LINE_HEIGHT_EXPRESSIONS || GEA_CSS_MARGIN_TRIM || GEA_CSS_MULTICOL || GEA_CSS_SIDE_BORDERS || GEA_CSS_TRANSFORMS || GEA_EMBEDDED_RENDERER_LINEAR_GRADIENTS || GEA_EMBEDDED_RENDERER_RADIAL_GRADIENTS) static_assert(GEA_CSS_RARE_STYLE || std::is_empty::value, "An unguarded rare field requires a reachability family"); struct ComputedStyle { diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index dbf870b..1c328ff 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6786,19 +6786,25 @@ int gLastScrollUiFrame = -1000; // px*dx + py*dy, running from minProjection over span. static void axis(const DisplayCommand &c, double *dxOut, double *dyOut, double *minProjectionOut, double *spanOut) { - const double angleRadians = (static_cast(c.gradient.angle) * kPi) / 1800.0; - double dx = std::sin(angleRadians); - double dy = -std::cos(angleRadians); - // Snap axis-aligned angles to exact unit vectors: sin(pi) is 1.2e-16, - // not 0, so a plain `to bottom` gradient would miss the constant-row - // fast path below (and drag ~1e-16 noise through the projection). + // Axis-aligned angles are exact unit vectors: sin(pi) is 1.2e-16, not 0, + // so a plain `to bottom` gradient would miss the constant-row fast path + // below (and drag ~1e-16 noise through the projection). They also skip + // the double trigonometry, which the board's FPU emulates in software, + // once per command (a border-image box replays ~24 tile commands). + double dx, dy; switch (c.gradient.angle % 3600) { case 0: dx = 0.0; dy = -1.0; break; case 900: dx = 1.0; dy = 0.0; break; case 1800: dx = 0.0; dy = 1.0; break; case 2700: dx = -1.0; dy = 0.0; break; - default: break; + default: + { + const double angleRadians = (static_cast(c.gradient.angle) * kPi) / 1800.0; + dx = std::sin(angleRadians); + dy = -std::cos(angleRadians); + break; + } } const double gx0 = static_cast(c.gradient.x); const double gy0 = static_cast(c.gradient.y); @@ -6869,10 +6875,26 @@ int gLastScrollUiFrame = -1000; gea::platform::display::Display::setAlpha(baseAlpha); } - static void replay(const DisplayCommand &c) + // Out of line: inlined, this rasterizer's frame and register pressure + // land in DisplayCommandDrawer::replay and every command pays for them. + __attribute__((noinline)) static void replay(const DisplayCommand &c) { int cx0, cy0, cx1, cy1; gea::platform::display::Display::clip(&cx0, &cy0, &cx1, &cy1); +#if GEA_CSS_BORDER_IMAGE + if (c.gradient.boundsClip) + { + // A border-image tile: the gradient box is the whole scaled image and + // the bounds are the tile. + cx0 = std::max(cx0, c.bx); + cy0 = std::max(cy0, c.by); + cx1 = std::min(cx1, c.bx + c.bw - 1); + cy1 = std::min(cy1, c.by + c.bh - 1); + } + [[maybe_unused]] const bool tile = c.gradient.boundsClip; +#else + [[maybe_unused]] constexpr bool tile = false; +#endif const int x0 = std::max(c.gradient.x, cx0); const int y0 = std::max(c.gradient.y, cy0); const int x1 = std::min(c.gradient.x + c.gradient.w - 1, cx1); @@ -6931,8 +6953,10 @@ int gLastScrollUiFrame = -1000; c.gradient.tl == 0 && c.gradient.tr == 0 && c.gradient.br == 0 && c.gradient.bl == 0; const bool bgLarge = static_cast(c.gradient.w) * c.gradient.h * 2 >= static_cast(scrW) * scrH; + // A border-image tile never takes a static cache slot: its scaled image can + // be screen-sized while the tile shows a sliver of it. const bool bgCacheable = bgBuf && bgCanvas && bgCanvas->pixels() && bgOpaque && bgLarge && - scrW > 0 && scrH > 0 && bgCap >= scrW * scrH; + scrW > 0 && scrH > 0 && bgCap >= scrW * scrH && !tile; const std::uint16_t bgKeyNow[14] = { static_cast(c.gradient.fromColor), static_cast(c.gradient.midColor), static_cast(c.gradient.toColor), c.gradient.midStop, c.gradient.toStop, static_cast(c.gradient.angle), c.gradient.fromAlpha, @@ -6971,7 +6995,8 @@ int gLastScrollUiFrame = -1000; const bool hasTransparency = c.gradient.fromAlpha < 255 || c.gradient.toAlpha < 255 || (c.gradient.hasMid && c.gradient.midAlpha < 255); const bool transCacheable = bgCanvas && bgCanvas->pixels() && scrW > 0 && scrH > 0 && hasTransparency && - static_cast(c.gradient.w) * c.gradient.h * 16 >= static_cast(scrW) * scrH; + static_cast(c.gradient.w) * c.gradient.h * 16 >= static_cast(scrW) * scrH && + !tile; const std::uint16_t transKey[14] = { static_cast(c.gradient.fromColor), static_cast(c.gradient.midColor), static_cast(c.gradient.toColor), c.gradient.midStop, c.gradient.toStop, static_cast(c.gradient.angle), c.gradient.fromAlpha, c.gradient.midAlpha, c.gradient.toAlpha, @@ -7135,6 +7160,85 @@ int gLastScrollUiFrame = -1000; static #endif uint8_t rowAlpha[gea::platform::display::kWidth]; + // One scanline: spanW pixels from chunkX into color/alpha. Returns + // whether every pixel is opaque. + auto rasterRow = [&](gea::framework::graphics::pixel::native_t *color, uint8_t *alpha, int chunkX, int spanW, int y) + { + // Work on copies: the uint8_t alpha stores may alias anything, so the + // captured references would be reloaded on every pixel. + const float step = stepX, start = fMinProjection, scale = invSpanPermille; + const auto table = lut; + const uint8_t *bayerRow = bayer4[y & 3]; + float projection = (static_cast(chunkX) + 0.5f) * step + (static_cast(y) + 0.5f) * stepY; + bool rowOpaque = true; + if (step == 0.0f) + { + // Vertical gradient (the common `to bottom` case): the projection — + // hence the permille bucket, LUT entry, and alpha — is constant + // across the row; only the 4-periodic bayer COLUMN pattern varies. + // Compute the 4-pixel pattern once with exactly the per-pixel math + // (bit-identical) and fill the row at store speed instead of paying + // the DDA + clamp + LUT + dither chain per pixel. + const int permille = clampPermille(static_cast((projection - start) * scale + 0.5f)); + const DitherStop &e = table[permille]; + gea::framework::graphics::pixel::native_t pattern[4]; + for (int k = 0; k < 4; k++) + { +#if GEA_EMBEDDED_PIXEL_FORMAT == GEA_PIXEL_RGB565 + const int threshold = bayerRow[k] * 16 + 8; + const int r5 = e.qR + ((e.remR > threshold) ? 1 : 0); + const int g6 = e.qG + ((e.remG > threshold) ? 1 : 0); + const int b5 = e.qB + ((e.remB > threshold) ? 1 : 0); + pattern[k] = gea::framework::graphics::pixel::packRgb565Components(r5, g6, b5); +#else + pattern[k] = e.color; +#endif + } + for (int i = 0; i < spanW; i++) + color[i] = pattern[(chunkX + i) & 3]; + std::memset(alpha, e.alpha, static_cast(spanW)); + return e.alpha == 255; + } + for (int i = 0; i < spanW; i++, projection += step) + { + const int permille = clampPermille(static_cast((projection - start) * scale + 0.5f)); + const DitherStop &e = table[permille]; +#if GEA_EMBEDDED_PIXEL_FORMAT == GEA_PIXEL_RGB565 + const int threshold = bayerRow[(chunkX + i) & 3] * 16 + 8; + const int r5 = e.qR + ((e.remR > threshold) ? 1 : 0); + const int g6 = e.qG + ((e.remG > threshold) ? 1 : 0); + const int b5 = e.qB + ((e.remB > threshold) ? 1 : 0); + color[i] = gea::framework::graphics::pixel::packRgb565Components(r5, g6, b5); +#else + color[i] = e.color; // full colour: no dither (8888 doesn't band) +#endif + alpha[i] = e.alpha; + if (e.alpha != 255) + rowOpaque = false; + } + return rowOpaque; + }; + // A small square-cornered opaque gradient, e.g. a border-image tile, is + // built as one block in the row buffer and blitted once, not once per + // row: every blit clips and scans the dirty-rect list, and a tile is a + // handful of pixels per row. A gradient with no vertical component + // repeats with the 4-row dither period, so rows past the fourth are + // copies. Same pixels as the row path below. + const int blockW = x1 - x0 + 1, blockH = y1 - y0 + 1; + const bool opaqueStops = c.gradient.fromAlpha == 255 && c.gradient.toAlpha == 255 && + (!c.gradient.hasMid || c.gradient.midAlpha == 255); + if (opaqueStops && (c.gradient.tl | c.gradient.tr | c.gradient.br | c.gradient.bl) == 0 && blockH > 1 && + blockW * blockH <= gea::platform::display::kWidth) + { + for (int row = 0; row < blockH; row++) + { + gea::framework::graphics::pixel::native_t *color = rowColor + row * blockW; + if (stepY == 0.0f && row >= 4) std::memcpy(color, color - 4 * blockW, static_cast(blockW) * sizeof *color); + else rasterRow(color, rowAlpha + row * blockW, x0, blockW, y0 + row); + } + gea::platform::display::Display::blitImage(rowColor, nullptr, blockW, blockH, x0, y0); + } + else for (int y = y0; y <= y1; y++) { int rowX0 = x0; @@ -7142,59 +7246,11 @@ int gLastScrollUiFrame = -1000; roundedRowSpan(c, y, &rowX0, &rowX1); if (rowX0 > rowX1) continue; - const uint8_t *bayerRow = bayer4[y & 3]; // Keep the device-sized buffer, but cover wider simulator viewports in chunks. for (int chunkX = rowX0; chunkX <= rowX1; chunkX += gea::platform::display::kWidth) { const int spanW = std::min(gea::platform::display::kWidth, rowX1 - chunkX + 1); - float projection = (static_cast(chunkX) + 0.5f) * stepX + (static_cast(y) + 0.5f) * stepY; - bool rowOpaque = true; - if (stepX == 0.0f) - { - // Vertical gradient (the common `to bottom` case): the projection — - // hence the permille bucket, LUT entry, and alpha — is constant - // across the row; only the 4-periodic bayer COLUMN pattern varies. - // Compute the 4-pixel pattern once with exactly the per-pixel math - // (bit-identical) and fill the row at store speed instead of paying - // the DDA + clamp + LUT + dither chain per pixel. - const int permille = clampPermille(static_cast((projection - fMinProjection) * invSpanPermille + 0.5f)); - const DitherStop &e = lut[permille]; - gea::framework::graphics::pixel::native_t pattern[4]; - for (int k = 0; k < 4; k++) - { -#if GEA_EMBEDDED_PIXEL_FORMAT == GEA_PIXEL_RGB565 - const int threshold = bayerRow[k] * 16 + 8; - const int r5 = e.qR + ((e.remR > threshold) ? 1 : 0); - const int g6 = e.qG + ((e.remG > threshold) ? 1 : 0); - const int b5 = e.qB + ((e.remB > threshold) ? 1 : 0); - pattern[k] = gea::framework::graphics::pixel::packRgb565Components(r5, g6, b5); -#else - pattern[k] = e.color; -#endif - } - for (int i = 0; i < spanW; i++) - rowColor[i] = pattern[(chunkX + i) & 3]; - std::memset(rowAlpha, e.alpha, static_cast(spanW)); - rowOpaque = e.alpha == 255; - } - else - for (int i = 0; i < spanW; i++, projection += stepX) - { - const int permille = clampPermille(static_cast((projection - fMinProjection) * invSpanPermille + 0.5f)); - const DitherStop &e = lut[permille]; -#if GEA_EMBEDDED_PIXEL_FORMAT == GEA_PIXEL_RGB565 - const int threshold = bayerRow[(chunkX + i) & 3] * 16 + 8; - const int r5 = e.qR + ((e.remR > threshold) ? 1 : 0); - const int g6 = e.qG + ((e.remG > threshold) ? 1 : 0); - const int b5 = e.qB + ((e.remB > threshold) ? 1 : 0); - rowColor[i] = gea::framework::graphics::pixel::packRgb565Components(r5, g6, b5); -#else - rowColor[i] = e.color; // full colour: no dither (8888 doesn't band) -#endif - rowAlpha[i] = e.alpha; - if (e.alpha != 255) - rowOpaque = false; - } + const bool rowOpaque = rasterRow(rowColor, rowAlpha, chunkX, spanW, y); // alpha=nullptr -> blitImage's opaque memcpy (baseAlpha==255) or whole-row // global blend (baseAlpha<255); the rowAlpha mask -> per-pixel combine // with baseAlpha. Equivalent to the old fillRun(combineAlpha(baseAlpha, @@ -7432,7 +7488,8 @@ int gLastScrollUiFrame = -1000; LinearGradientDrawer::interpolateAlpha(c.radialGradient.fromAlpha, c.radialGradient.toAlpha, lp)); } - static void replay(const DisplayCommand &c) + // Out of line for the same reason as LinearGradientDrawer::replay. + __attribute__((noinline)) static void replay(const DisplayCommand &c) { int cx0, cy0, cx1, cy1; gea::platform::display::Display::clip(&cx0, &cy0, &cx1, &cy1); diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 48da9ba..e62bd10 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -507,6 +507,13 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) ar.bg_clip != br.bg_clip || #if GEA_CSS_BACKGROUND_BLEND ar.bg_blend != br.bg_blend || +#endif +#if GEA_CSS_BORDER_IMAGE + ar.border_image_source != br.border_image_source || + ar.border_image_slice != br.border_image_slice || + ar.border_image_width != br.border_image_width || + ar.border_image_outset != br.border_image_outset || + ar.border_image_repeat != br.border_image_repeat || #endif ar.bg_size_list != br.bg_size_list || ar.bg_position_list != br.bg_position_list || @@ -1332,6 +1339,14 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "background-clip") == 0) return CssDeclarationId::BackgroundClip; #if GEA_CSS_BACKGROUND_BLEND if (std::strcmp(property, "background-blend-mode") == 0) return CssDeclarationId::BackgroundBlendMode; +#endif +#if GEA_CSS_BORDER_IMAGE + if (std::strcmp(property, "border-image") == 0) return CssDeclarationId::BorderImage; + if (std::strcmp(property, "border-image-source") == 0) return CssDeclarationId::BorderImageSourceDeclaration; + if (std::strcmp(property, "border-image-slice") == 0) return CssDeclarationId::BorderImageSliceDeclaration; + if (std::strcmp(property, "border-image-width") == 0) return CssDeclarationId::BorderImageWidthDeclaration; + if (std::strcmp(property, "border-image-outset") == 0) return CssDeclarationId::BorderImageOutsetDeclaration; + if (std::strcmp(property, "border-image-repeat") == 0) return CssDeclarationId::BorderImageRepeatDeclaration; #endif if (std::strcmp(property, "background-image") == 0) return CssDeclarationId::BackgroundImage; if (std::strcmp(property, "background") == 0) return CssDeclarationId::Background; @@ -1590,9 +1605,10 @@ struct CssCompiledValue { std::uint16_t flags = 0; std::uint8_t aux = 0; CssLengthSpec lengths[4]; - // Four direct-property pairs are the largest scalar payload. Transform - // rotation and scale occupy slots 0..5; translations use lengths[]. - std::int32_t values[8]{}; + // Five direct-property pairs (the line-clamp and border-image shorthands) + // are the largest scalar payload. Transform rotation and scale occupy slots + // 0..5; translations use lengths[]. + std::int32_t values[10]{}; }; CssLengthSpec cssLengthSpecForStatic(StaticStyleLengthSpec spec); @@ -2026,6 +2042,12 @@ std::uint16_t storeDirectPropertyCompiledValue(Property property, int value) return static_cast(list.size() - 1); } +// CssCompiledValue::values holds up to five property/value pairs; aux holds the +// count. lineClampWrites and borderImageWrites fill all five, and the line-clamp +// writer runs whatever the feature gates say. +constexpr int kDirectPropertyGroupCapacity = 5; +static_assert(2 * kDirectPropertyGroupCapacity <= static_cast(sizeof(CssCompiledValue::values) / sizeof(CssCompiledValue::values[0]))); + std::uint16_t storeDirectPropertyGroupCompiledValue(std::initializer_list properties) { auto &list = compiledCssValues(); @@ -2034,7 +2056,7 @@ std::uint16_t storeDirectPropertyGroupCompiledValue(std::initializer_list= 4) break; + if (count >= kDirectPropertyGroupCapacity) break; compiled.values[count * 2] = static_cast(entry.property); compiled.values[1 + count * 2] = entry.value; count++; @@ -2451,12 +2473,30 @@ std::vector> &backgroundBlendLists() } #endif +#if GEA_CSS_BORDER_IMAGE +std::vector &borderImageSources() +{ + static std::vector list; + return list; +} + +std::vector &borderImageSideLists() +{ + static std::vector list; + return list; +} +#endif + void clearCompiledCssBackgrounds() { compiledCssBackgrounds().clear(); backgroundClipLists().clear(); #if GEA_CSS_BACKGROUND_BLEND backgroundBlendLists().clear(); +#endif +#if GEA_CSS_BORDER_IMAGE + borderImageSources().clear(); + borderImageSideLists().clear(); #endif backgroundPlacementLists().clear(); } @@ -7221,6 +7261,221 @@ int borderStyleWrites(CssDeclarationId declaration, const std::string &value, St return 4; } +#if GEA_CSS_BORDER_IMAGE +BorderImageSides initialBorderImageSides(int which) +{ + BorderImageSides sides; + for (int i = 0; i < 4; ++i) { + sides.kind[i] = which == 0 ? 2 : 0; + sides.value[i] = which == 0 ? 100.0f : which == 1 ? 1.0f : 0.0f; + } + return sides; +} + +bool sameBorderImageSides(const BorderImageSides &a, const BorderImageSides &b) +{ + return a.fill == b.fill && std::equal(a.kind, a.kind + 4, b.kind) && std::equal(a.value, a.value + 4, b.value); +} + +// One-byte handle for a slice (0), width (1) or outset (2) list; 0 is initial. +int internBorderImageSides(int which, const BorderImageSides &sides) +{ + if (sameBorderImageSides(sides, initialBorderImageSides(which))) return 0; + auto &list = borderImageSideLists(); + for (std::size_t i = 0; i < list.size(); ++i) if (sameBorderImageSides(list[i], sides)) return static_cast(i + 1); + if (list.size() >= 255) return -1; + list.push_back(sides); + return static_cast(list.size()); +} + +// Tokens of a border-image value: function-aware words, with '/' on its own. +std::vector borderImageTokens(const std::string &value) +{ + std::vector tokens; + std::string current; + int depth = 0; + for (const char c : value) { + if (c == '(') ++depth; + else if (c == ')' && depth > 0) --depth; + if (depth == 0 && (c == '/' || std::isspace(static_cast(c)))) { + if (!current.empty()) tokens.push_back(current); + current.clear(); + if (c == '/') tokens.emplace_back("/"); + continue; + } + current += c; + } + if (!current.empty()) tokens.push_back(current); + return tokens; +} + +// A border-image-source handle: 0 for none and for images Gea cannot paint +// (only linear-gradient() paints), -1 when invalid. +int borderImageSourceHandle(const std::string &token) +{ + const auto lower = toLowerAscii(trimCssValue(token)); + if (lower == "none") return 0; + if (lower.rfind("linear-gradient(", 0) != 0) return lower.find('(') != std::string::npos ? 0 : -1; + const ParsedLinearGradient parsed = parseLinearGradient(token); + if (!parsed.valid) return -1; + const CssCompiledLinearGradient g = compileLinearGradientLayer(parsed); + const BorderImageSource source{g.fromNativeColor, g.midNativeColor, g.toNativeColor, g.fromAlpha, g.midAlpha, g.toAlpha, + g.hasMid, g.midStopPermille, g.toStopPermille, g.angleTenths}; + auto same = [&](const BorderImageSource &a) { + return a.from == source.from && a.mid == source.mid && a.to == source.to && a.fromAlpha == source.fromAlpha && + a.midAlpha == source.midAlpha && a.toAlpha == source.toAlpha && a.hasMid == source.hasMid && + a.midStop == source.midStop && a.toStop == source.toStop && a.angle == source.angle; + }; + auto &list = borderImageSources(); + for (std::size_t i = 0; i < list.size(); ++i) + if (same(list[i])) return static_cast(i + 1); + if (list.size() >= 255) return -1; + list.push_back(source); + return static_cast(list.size()); +} + +// Kind of one slice (0), width (1) or outset (2) value, -1 when invalid. +// Font-relative lengths are not resolved here and are rejected. +int borderImageSideValue(const std::string &token, int which, float *value) +{ + const auto word = toLowerAscii(token); + if (which == 1 && word == "auto") { *value = 0.0f; return 3; } + CssLengthSpec length; + if (!parseCompiledLengthSpec(word, length) || length.value < 0.0f) return -1; + *value = length.value; + if (length.unit == CssLengthUnit::Raw) return 0; + if (length.unit == CssLengthUnit::Percent && which != 2) return 2; + if (length.unit != CssLengthUnit::Px || which == 0) return -1; + *value = static_cast(cssPixelLength(length.value)); + return 1; +} + +bool borderImageSideToken(const std::string &token, int which) +{ + float value = 0.0f; + return (which == 0 && toLowerAscii(token) == "fill") || borderImageSideValue(token, which, &value) >= 0; +} + +// One to four values in [begin, end) as box sides, plus fill for a slice. +bool parseBorderImageSides(const std::vector &tokens, std::size_t begin, std::size_t end, int which, BorderImageSides &out) +{ + int count = 0; + for (std::size_t i = begin; i < end; ++i) { + if (which == 0 && toLowerAscii(tokens[i]) == "fill") { + if (out.fill) return false; + out.fill = 1; + continue; + } + if (count == 4) return false; + const int kind = borderImageSideValue(tokens[i], which, &out.value[count]); + if (kind < 0) return false; + out.kind[count++] = static_cast(kind); + } + if (!count) return false; + for (int i = count; i < 4; ++i) { + const int from = count == 1 ? 0 : i - 2; + out.kind[i] = out.kind[from]; + out.value[i] = out.value[from]; + } + return true; +} + +int borderImageRepeatKeyword(const std::string &token) +{ + const auto word = toLowerAscii(token); + return word == "stretch" ? 0 : word == "repeat" ? 1 : word == "round" ? 2 : word == "space" ? 3 : -1; +} + +int borderImageRepeatValue(const std::vector &tokens, std::size_t begin, std::size_t end) +{ + if (end <= begin || end - begin > 2) return -1; + const int horizontal = borderImageRepeatKeyword(tokens[begin]); + const int vertical = end - begin == 2 ? borderImageRepeatKeyword(tokens[begin + 1]) : horizontal; + return horizontal < 0 || vertical < 0 ? -1 : horizontal | vertical << 2; +} + +// border-image (CSS Backgrounds 3, section 6) and its longhands as writes to +// the RareStyle handles. The shorthand resets all five longhands. +int borderImageWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[5]) +{ + using D = CssDeclarationId; + static constexpr Property kSideProperties[] = {Property::BorderImageSlice, Property::BorderImageWidth, Property::BorderImageOutset}; + const int side = declaration == D::BorderImageSliceDeclaration ? 0 : declaration == D::BorderImageWidthDeclaration ? 1 : + declaration == D::BorderImageOutsetDeclaration ? 2 : -1; + if (side < 0 && declaration != D::BorderImage && declaration != D::BorderImageSourceDeclaration && + declaration != D::BorderImageRepeatDeclaration) return -1; + const auto tokens = borderImageTokens(trimCssValue(value)); + if (tokens.empty()) return 0; + const auto keyword = toLowerAscii(trimCssValue(value)); + const bool initial = keyword == "initial" || keyword == "unset"; + if (declaration == D::BorderImageSourceDeclaration) { + const int source = initial ? 0 : tokens.size() == 1 ? borderImageSourceHandle(tokens[0]) : -1; + if (source < 0) return 0; + out[0] = {Property::BorderImageSource, source}; + return 1; + } + if (declaration == D::BorderImageRepeatDeclaration) { + const int repeat = initial ? 0 : borderImageRepeatValue(tokens, 0, tokens.size()); + if (repeat < 0) return 0; + out[0] = {Property::BorderImageRepeat, repeat}; + return 1; + } + if (side >= 0) { + BorderImageSides sides; + if (!initial && !parseBorderImageSides(tokens, 0, tokens.size(), side, sides)) return 0; + const int handle = internBorderImageSides(side, initial ? initialBorderImageSides(side) : sides); + if (handle < 0) return 0; + out[0] = {kSideProperties[side], handle}; + return 1; + } + // || [ / | / ? / ]? || + int source = -1, repeat = -1; + bool present[3] = {}; + BorderImageSides sides[3]; + for (std::size_t i = 0; !initial && i < tokens.size();) { + const auto word = toLowerAscii(tokens[i]); + if (source < 0 && (word == "none" || word.find('(') != std::string::npos)) { + source = borderImageSourceHandle(tokens[i++]); + if (source < 0) return 0; + continue; + } + if (repeat < 0 && borderImageRepeatKeyword(word) >= 0) { + std::size_t end = i + 1; + if (end < tokens.size() && borderImageRepeatKeyword(tokens[end]) >= 0) ++end; + repeat = borderImageRepeatValue(tokens, i, end); + i = end; + continue; + } + if (present[0]) return 0; + std::size_t end = i; + while (end < tokens.size() && borderImageSideToken(tokens[end], 0)) ++end; + if (!parseBorderImageSides(tokens, i, end, 0, sides[0])) return 0; + present[0] = true; + i = end; + for (int part = 1; part <= 2 && i < tokens.size() && tokens[i] == "/"; ++part) { + end = ++i; + while (end < tokens.size() && borderImageSideToken(tokens[end], part)) ++end; + if (end > i) { + if (!parseBorderImageSides(tokens, i, end, part, sides[part])) return 0; + present[part] = true; + } else if (part == 2 || i >= tokens.size() || tokens[i] != "/") { + return 0; + } + i = end; + } + } + out[0] = {Property::BorderImageSource, std::max(0, source)}; + for (int part = 0; part < 3; ++part) { + const int handle = internBorderImageSides(part, present[part] ? sides[part] : initialBorderImageSides(part)); + if (handle < 0) return 0; + out[1 + part] = {kSideProperties[part], handle}; + } + out[4] = {Property::BorderImageRepeat, std::max(0, repeat)}; + return 5; +} +#endif + +#if GEA_CSS_TEXT_EMPHASIS // text-emphasis (CSS Text Decoration 3.3) and its longhands as writes to the // ComputedStyle::text_emphasis bits and colour. A string mark is unsupported. int textEmphasisWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[4]) @@ -7273,6 +7528,7 @@ int textEmphasisWrites(CssDeclarationId declaration, const std::string &value, S } return count; } +#endif // Only whether columns are set matters: line-clamp skips multicol containers. // columns / column-count / column-width / column-fill as ColumnCount(Set) and @@ -7823,20 +8079,23 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa } #endif - StaticStylePropertyValue clampWrites[4]; + StaticStylePropertyValue clampWrites[kDirectPropertyGroupCapacity]; int clampCount = hasVar ? -1 : lineClampWrites(declaration, value, clampWrites); if (clampCount < 0 && !hasVar) clampCount = multicolWrites(declaration, value, clampWrites); if (clampCount < 0 && !hasVar) clampCount = borderStyleWrites(declaration, value, clampWrites); #if GEA_CSS_TEXT_EMPHASIS if (clampCount < 0 && !hasVar) clampCount = textEmphasisWrites(declaration, value, clampWrites); +#endif +#if GEA_CSS_BORDER_IMAGE + if (clampCount < 0 && !hasVar) clampCount = borderImageWrites(declaration, value, clampWrites); #endif if (const int count = clampCount; count >= 0) { if (count == 0) return kNoCompiledCssValue; compiled.kind = CssCompiledKind::DirectPropertyGroup; - compiled.values[0] = count; + compiled.aux = static_cast(count); for (int i = 0; i < count; ++i) { - compiled.values[1 + i * 2] = static_cast(clampWrites[i].property); - compiled.values[2 + i * 2] = clampWrites[i].value; + compiled.values[i * 2] = static_cast(clampWrites[i].property); + compiled.values[1 + i * 2] = clampWrites[i].value; } return storeCompiledCssValue(compiled); } @@ -8253,6 +8512,16 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::BackgroundAttachmentList: rstyleMut(style).bg_attachment_list = value; return true; case Property::BackgroundOriginList: rstyleMut(style).bg_origin_list = value; return true; #endif +#if GEA_CSS_BACKGROUND_BLEND + case Property::BackgroundBlendMode: rstyleMut(style).bg_blend = value; return true; +#endif +#if GEA_CSS_BORDER_IMAGE + case Property::BorderImageSource: rstyleMut(style).border_image_source = value; return true; + case Property::BorderImageSlice: rstyleMut(style).border_image_slice = value; return true; + case Property::BorderImageWidth: rstyleMut(style).border_image_width = value; return true; + case Property::BorderImageOutset: rstyleMut(style).border_image_outset = value; return true; + case Property::BorderImageRepeat: rstyleMut(style).border_image_repeat = value; return true; +#endif #if GEA_CSS_CONTAINMENT case Property::Containment: rstyleMut(style).containment = value; return true; @@ -10215,13 +10484,24 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::TextEmphasis: case CssDeclarationId::TextEmphasisStyleDeclaration: case CssDeclarationId::TextEmphasisColorDeclaration: - case CssDeclarationId::TextEmphasisPositionDeclaration: { - StaticStylePropertyValue writes[4]; + case CssDeclarationId::TextEmphasisPositionDeclaration: + // The border-image ids are never classified without the family; the labels + // stay so the switch covers the enum. + case CssDeclarationId::BorderImage: + case CssDeclarationId::BorderImageSourceDeclaration: + case CssDeclarationId::BorderImageSliceDeclaration: + case CssDeclarationId::BorderImageWidthDeclaration: + case CssDeclarationId::BorderImageOutsetDeclaration: + case CssDeclarationId::BorderImageRepeatDeclaration: { + StaticStylePropertyValue writes[kDirectPropertyGroupCapacity]; int count = lineClampWrites(declaration, value, writes); if (count < 0) count = multicolWrites(declaration, value, writes); if (count < 0) count = borderStyleWrites(declaration, value, writes); #if GEA_CSS_TEXT_EMPHASIS if (count < 0) count = textEmphasisWrites(declaration, value, writes); +#endif +#if GEA_CSS_BORDER_IMAGE + if (count < 0) count = borderImageWrites(declaration, value, writes); #endif for (int i = 0; i < count; ++i) setStyleValue(node, writes[i].property, writes[i].value, source); return true; @@ -11687,7 +11967,7 @@ bool applyCompiledCssValueWithSource(NodeHandle node, const CssCompiledValue &co case CssCompiledKind::DirectPropertyGroup: { int count = compiled.aux; if (count < 0) count = 0; - if (count > 4) count = 4; + if (count > kDirectPropertyGroupCapacity) count = kDirectPropertyGroupCapacity; for (int i = 0; i < count; ++i) { const int propertyIndex = compiled.values[i * 2]; if (propertyIndex < 0 || propertyIndex >= static_cast(Property::Count)) return false; @@ -12295,6 +12575,16 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "background-clip") return removeInlineStyleProperties(id, {Property::BackgroundClip}); #if GEA_CSS_BACKGROUND_BLEND if (property == "background-blend-mode") return removeInlineStyleProperties(id, {Property::BackgroundBlendMode}); +#endif +#if GEA_CSS_BORDER_IMAGE + if (property == "border-image") + return removeInlineStyleProperties(id, {Property::BorderImageSource, Property::BorderImageSlice, Property::BorderImageWidth, + Property::BorderImageOutset, Property::BorderImageRepeat}); + if (property == "border-image-source") return removeInlineStyleProperties(id, {Property::BorderImageSource}); + if (property == "border-image-slice") return removeInlineStyleProperties(id, {Property::BorderImageSlice}); + if (property == "border-image-width") return removeInlineStyleProperties(id, {Property::BorderImageWidth}); + if (property == "border-image-outset") return removeInlineStyleProperties(id, {Property::BorderImageOutset}); + if (property == "border-image-repeat") return removeInlineStyleProperties(id, {Property::BorderImageRepeat}); #endif if (property == "background-size") return removeInlineStyleProperties(id, {Property::BackgroundSizeList}); if (property == "background-position") return removeInlineStyleProperties(id, {Property::BackgroundPositionList}); @@ -15490,7 +15780,7 @@ bool addCompiledValueWrites(const CssCompiledValue &compiled, PropertyWriteMask case CssCompiledKind::DirectPropertyGroup: { int count = compiled.aux; if (count < 0) count = 0; - if (count > 4) count = 4; + if (count > kDirectPropertyGroupCapacity) count = kDirectPropertyGroupCapacity; for (int i = 0; i < count; ++i) { const int propertyIndex = compiled.values[i * 2]; if (propertyIndex < 0 || propertyIndex >= static_cast(Property::Count)) return false; @@ -18222,6 +18512,34 @@ bool StyleValues::hasTextBackgroundClip(const ComputedStyle &style) return std::find(list.begin(), list.end(), 3) != list.end(); } +#if GEA_CSS_BORDER_IMAGE +const BorderImageSource *StyleValues::borderImageSource(const ComputedStyle &style) +{ + const auto handle = rstyle(style).border_image_source; + const auto &list = borderImageSources(); + return handle && handle <= list.size() ? &list[handle - 1] : nullptr; +} + +BorderImageSides StyleValues::borderImageSides(const ComputedStyle &style, int which) +{ + const auto &r = rstyle(style); + const auto handle = which == 0 ? r.border_image_slice : which == 1 ? r.border_image_width : r.border_image_outset; + const auto &list = borderImageSideLists(); + return handle && handle <= list.size() ? list[handle - 1] : initialBorderImageSides(which); +} + +int StyleValues::borderImageOutsetExtent(const ComputedStyle &style) +{ + if (!rstyle(style).border_image_source) return 0; + const auto outset = borderImageSides(style, 2); + float extent = 0.0f; + for (int i = 0; i < 4; ++i) + extent = std::max(extent, outset.kind[i] == 1 ? outset.value[i] : outset.value[i] * computedBorderWidth(style, i)); + return static_cast(std::ceil(extent)); +} +#endif + +#if GEA_CSS_BACKGROUND_BLEND int StyleValues::backgroundBlendMode(const ComputedStyle &style, int layer) { const auto handle = rstyle(style).bg_blend; @@ -18230,6 +18548,7 @@ int StyleValues::backgroundBlendMode(const ComputedStyle &style, int layer) const auto &list = lists[handle - 1]; return list[std::max(0, layer) % list.size()]; } +#endif int StyleValues::backgroundClip(const ComputedStyle &style, int layer) { diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 5b224dc..84948de 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -230,6 +230,11 @@ enum class Property : int { TextEmphasisColorMode, TextEmphasisColor, BackgroundBlendMode, + BorderImageSource, + BorderImageSlice, + BorderImageWidth, + BorderImageOutset, + BorderImageRepeat, Count }; @@ -394,6 +399,12 @@ enum class StyleDeclaration : std::uint8_t { TextEmphasisColorDeclaration, TextEmphasisPositionDeclaration, BackgroundBlendMode, + BorderImage, + BorderImageSourceDeclaration, + BorderImageSliceDeclaration, + BorderImageWidthDeclaration, + BorderImageOutsetDeclaration, + BorderImageRepeatDeclaration, // Logical two-sided shorthands (see logicalPairFor in style.cpp). MarginInline, MarginBlock, diff --git a/packages/engine/ui/style_values.h b/packages/engine/ui/style_values.h index 0132181..5863c48 100644 --- a/packages/engine/ui/style_values.h +++ b/packages/engine/ui/style_values.h @@ -9,6 +9,20 @@ namespace gea::embedded::ui { struct ComputedStyle; +// A border-image-source that paints: a linear-gradient(). +struct BorderImageSource { + gea::framework::graphics::pixel::native_t from, mid, to; + std::uint8_t fromAlpha, midAlpha, toAlpha, hasMid; + std::uint16_t midStop, toStop; + std::int16_t angle; +}; +// border-image-slice, -width or -outset for top, right, bottom, left. kind 0: +// number, 1: device px, 2: percentage, 3: auto. +struct BorderImageSides { + float value[4] = {}; + std::uint8_t kind[4] = {}; + std::uint8_t fill = 0; +}; struct BackgroundPlacement { int x, y, width, height; int repeatX = 0, repeatY = 0; // repeat, no-repeat, round, space @@ -30,6 +44,15 @@ class StyleValues { static int backgroundBlendMode(const ComputedStyle &style, int layer); #endif static bool hasTextBackgroundClip(const ComputedStyle &style); +#if GEA_CSS_BORDER_IMAGE + // Null when border-image-source is none or an image Gea cannot paint. + static const BorderImageSource *borderImageSource(const ComputedStyle &style); + // 0: slice, 1: width, 2: outset. + static BorderImageSides borderImageSides(const ComputedStyle &style, int which); + // How far the border image reaches outside the border box, in px. Callers + // check rstyle(style).border_image_source first; this is a call across units. + static int borderImageOutsetExtent(const ComputedStyle &style); +#endif static BackgroundPlacement backgroundPlacement(const ComputedStyle &style, int nodeId, int layer, int x, int y, int width, int height); // Convert a raw style-value colour int into this board's native pixel, applied diff --git a/packages/engine/ui/tree_render.cpp b/packages/engine/ui/tree_render.cpp index 1421953..baa308e 100644 --- a/packages/engine/ui/tree_render.cpp +++ b/packages/engine/ui/tree_render.cpp @@ -10,8 +10,10 @@ #include "layout_snapshot.h" #include "refresh_perf.h" #include "root_scroll_refresh.h" +#include "style_values.h" #include "tree_state.h" +#include #include #include #include @@ -382,11 +384,14 @@ namespace gea::embedded::ui { if (hasAnyBorder(node.computedStyle())) return true; - if ((GEA_CSS_FILTERS ? rstyle(node.computedStyle()).filter_blur_radius : 0) > 0 || node.render.previous_filter_blur_radius > 0) + // One rare-style lookup serves the filter, shadow and border-image checks. + const auto &rare = rstyle(node.computedStyle()); + if ((GEA_CSS_FILTERS ? rare.filter_blur_radius : 0) > 0 || node.render.previous_filter_blur_radius > 0) return true; if (node.computedStyle().mask_right_fade_width > 0) return true; - if ((GEA_CSS_BOX_SHADOW ? rstyle(node.computedStyle()).box_shadow_alpha : 0) > 0) + if ((GEA_CSS_BOX_SHADOW ? rare.box_shadow_alpha : 0) > 0 || node.render.previous_box_shadow_extent > 0 || + (GEA_CSS_BORDER_IMAGE ? rare.border_image_source : 0)) return true; return node.computedStyle().has_bg && (styleHasRoundedRasterEdge(node.computedStyle()) || node.computedStyle().bg_alpha < 255); } @@ -408,7 +413,15 @@ namespace gea::embedded::ui { if (!dirtyRectNeedsRasterGuard(node)) return rect; - return expandDirtyRect(rect, 1); + // border-image-outset and an outer box-shadow paint outside the border box; + // the previous shadow extent covers pixels a moved or shrunk shadow left. + const int shadow = std::max(boxShadowExtent(node.computedStyle()), static_cast(node.render.previous_box_shadow_extent)); +#if GEA_CSS_BORDER_IMAGE + const int outset = rstyle(node.computedStyle()).border_image_source ? StyleValues::borderImageOutsetExtent(node.computedStyle()) : 0; +#else + const int outset = 0; +#endif + return expandDirtyRect(rect, 1 + std::max(outset, shadow)); } DirtyRegions::Rect dirtyRectWithRetainedMoveGuard(DirtyRegions::Rect rect) diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 69342d6..52e41ee 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -775,14 +775,23 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn break; } #if GEA_CSS_BACKGROUND_LAYERS - case Property::BackgroundClip: if (rstyle(n->style).bg_clip != value) { rstyleMut(n->style).bg_clip = value; changed = 1; } break; - case Property::BackgroundBlendMode: if (rstyle(n->style).bg_blend != value) { rstyleMut(n->style).bg_blend = value; changed = 1; } break; - case Property::BackgroundSizeList: if (rstyle(n->style).bg_size_list != value) { rstyleMut(n->style).bg_size_list = value; changed = 1; } break; - case Property::BackgroundPositionList: if (rstyle(n->style).bg_position_list != value) { rstyleMut(n->style).bg_position_list = value; changed = 1; } break; - case Property::BackgroundRepeatList: if (rstyle(n->style).bg_repeat_list != value) { rstyleMut(n->style).bg_repeat_list = value; changed = 1; } break; - case Property::BackgroundAttachmentList: if (rstyle(n->style).bg_attachment_list != value) { rstyleMut(n->style).bg_attachment_list = value; changed = 1; } break; - case Property::BackgroundOriginList: if (rstyle(n->style).bg_origin_list != value) { rstyleMut(n->style).bg_origin_list = value; changed = 1; } break; + case Property::BackgroundClip: if (rstyle(style).bg_clip != value) { rstyleMut(style).bg_clip = value; changed = 1; } break; + case Property::BackgroundSizeList: if (rstyle(style).bg_size_list != value) { rstyleMut(style).bg_size_list = value; changed = 1; } break; + case Property::BackgroundPositionList: if (rstyle(style).bg_position_list != value) { rstyleMut(style).bg_position_list = value; changed = 1; } break; + case Property::BackgroundRepeatList: if (rstyle(style).bg_repeat_list != value) { rstyleMut(style).bg_repeat_list = value; changed = 1; } break; + case Property::BackgroundAttachmentList: if (rstyle(style).bg_attachment_list != value) { rstyleMut(style).bg_attachment_list = value; changed = 1; } break; + case Property::BackgroundOriginList: if (rstyle(style).bg_origin_list != value) { rstyleMut(style).bg_origin_list = value; changed = 1; } break; +#endif +#if GEA_CSS_BACKGROUND_BLEND + case Property::BackgroundBlendMode: if (rstyle(style).bg_blend != value) { rstyleMut(style).bg_blend = value; changed = 1; } break; +#endif +#if GEA_CSS_BORDER_IMAGE + case Property::BorderImageSource: if (rstyle(style).border_image_source != value) { rstyleMut(style).border_image_source = value; changed = 1; } break; + case Property::BorderImageSlice: if (rstyle(style).border_image_slice != value) { rstyleMut(style).border_image_slice = value; changed = 1; } break; + case Property::BorderImageWidth: if (rstyle(style).border_image_width != value) { rstyleMut(style).border_image_width = value; changed = 1; } break; + case Property::BorderImageOutset: if (rstyle(style).border_image_outset != value) { rstyleMut(style).border_image_outset = value; changed = 1; } break; + case Property::BorderImageRepeat: if (rstyle(style).border_image_repeat != value) { rstyleMut(style).border_image_repeat = value; changed = 1; } break; #endif case Property::BackgroundImage: changed = StyleValues::applyBackgroundImage(style, value, node); diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 96fb7ad..2867d33 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -339,6 +339,9 @@ DisplayCommand *appendLinearGradientRectRaw(const Node &node, int x, int y, int cmd->gradient.toAlpha = toAlpha; cmd->gradient.hasMid = hasMid; cmd->gradient.blend = blend; +#if GEA_CSS_BORDER_IMAGE + cmd->gradient.boundsClip = 0; +#endif return cmd; } @@ -2964,6 +2967,99 @@ int ViewRenderer::canvasBackgroundSource() return root; } +#if GEA_CSS_BORDER_IMAGE +// An edge that would need more tiles than this stretches its part instead, +// which bounds a box at 4 * 16 + 5 commands. +constexpr int kBorderImageMaxTiles = 16; + +// border-image (CSS Backgrounds 3, section 6): the source, sized to the border +// image area, is cut by the slices into nine parts drawn over the border image +// widths. A gradient part is its gradient box scaled so the slice lands on the +// tile; the command's bounds are the tile and the replay clips to them, so a +// tile is one command. Single precision throughout: the board has no double +// FPU. The caller checks border_image_source before calling. +bool recordBorderImage(const Node &node) +{ + const auto *source = StyleValues::borderImageSource(node.computedStyle()); + if (!source || ViewGeometry::hasTransformChain(node, false)) return false; + const auto slice = StyleValues::borderImageSides(node.computedStyle(), 0); + const auto widths = StyleValues::borderImageSides(node.computedStyle(), 1); + const auto outset = StyleValues::borderImageSides(node.computedStyle(), 2); + const int repeat = rstyle(node.computedStyle()).border_image_repeat; + float border[4], out[4]; + for (int i = 0; i < 4; ++i) { + border[i] = static_cast(computedBorderWidth(node.computedStyle(), i)); + out[i] = outset.kind[i] == 1 ? outset.value[i] : outset.value[i] * border[i]; + } + const float ax = static_cast(node.layout.x) - out[3], ay = static_cast(node.layout.y) - out[0]; + const float aw = static_cast(node.layout.width) + out[1] + out[3]; + const float ah = static_cast(node.layout.height) + out[0] + out[2]; + if (aw <= 0.0f || ah <= 0.0f) return true; + // A gradient has no natural size: the image is the border image area, and + // auto widths fall back to the border widths. + float s[4], w[4]; + for (int i = 0; i < 4; ++i) { + const float size = i % 2 ? aw : ah; + s[i] = std::min(size, slice.kind[i] == 2 ? slice.value[i] * size / 100.0f : slice.value[i]); + w[i] = widths.kind[i] == 0 ? widths.value[i] * border[i] : widths.kind[i] == 1 ? widths.value[i] : + widths.kind[i] == 2 ? widths.value[i] * size / 100.0f : border[i]; + } + const float fit = std::min({1.0f, w[1] + w[3] > aw ? aw / (w[1] + w[3]) : 1.0f, w[0] + w[2] > ah ? ah / (w[0] + w[2]) : 1.0f}); + for (float &v : w) v *= fit; + const float dx[4] = {ax, ax + w[3], ax + aw - w[1], ax + aw}, dy[4] = {ay, ay + w[0], ay + ah - w[2], ay + ah}; + const float sx[4] = {0.0f, s[3], aw - s[1], aw}, sy[4] = {0.0f, s[0], ah - s[2], ah}; + // A part's scale along the tiled axis, from the edge it follows. + auto factor = [](float first, float firstSlice, float second, float secondSlice) { + if (firstSlice > 0.0f && first > 0.0f) return first / firstSlice; + return secondSlice > 0.0f && second > 0.0f ? second / secondSlice : 1.0f; + }; + // Tile origins and size along one axis of a part. + struct Tiles { float start, size, step; int count; }; + auto tile = [](int mode, float d0, float d1, float size) -> Tiles { + const float span = d1 - d0; + if (mode == 0 || size <= 0.0f || span > size * static_cast(kBorderImageMaxTiles)) return {d0, span, span, 1}; + if (mode == 2) { + const int n = std::max(1, static_cast(std::lround(span / size))); + return {d0, span / static_cast(n), span / static_cast(n), n}; + } + if (mode == 3) { + const int n = static_cast(span / size); + const float gap = n ? (span - static_cast(n) * size) / static_cast(n + 1) : 0.0f; + return {d0 + gap, size, size + gap, n}; + } + const float first = d0 + (span - size) * 0.5f - std::ceil((span - size) * 0.5f / size) * size; + return {first, size, size, static_cast(std::ceil((d1 - first) / size))}; + }; + for (int row = 0; row < 3; ++row) for (int col = 0; col < 3; ++col) { + if (row == 1 && col == 1 && !slice.fill) continue; + const float dw = dx[col + 1] - dx[col], dh = dy[row + 1] - dy[row]; + const float sw = sx[col + 1] - sx[col], sh = sy[row + 1] - sy[row]; + if (dw <= 0.0f || dh <= 0.0f || sw <= 0.0f || sh <= 0.0f) continue; + const float kx = col != 1 ? dw / sw : row == 1 ? factor(w[0], s[0], w[2], s[2]) : dh / sh; + const float ky = row != 1 ? dh / sh : col == 1 ? factor(w[3], s[3], w[1], s[1]) : dw / sw; + const Tiles tx = col == 1 ? tile(repeat & 3, dx[1], dx[2], sw * kx) : Tiles{dx[col], dw, dw, 1}; + const Tiles ty = row == 1 ? tile(repeat >> 2, dy[1], dy[2], sh * ky) : Tiles{dy[row], dh, dh, 1}; + const float gx = tx.size / sw, gy = ty.size / sh; + const long vw = std::lround(aw * gx), vh = std::lround(ah * gy); + for (int j = 0; j < ty.count; ++j) for (int i = 0; i < tx.count; ++i) { + const float t0 = tx.start + static_cast(i) * tx.step, u0 = ty.start + static_cast(j) * ty.step; + const int cx0 = std::lround(std::max(t0, dx[col])), cx1 = std::lround(std::min(t0 + tx.size, dx[col + 1])); + const int cy0 = std::lround(std::max(u0, dy[row])), cy1 = std::lround(std::min(u0 + ty.size, dy[row + 1])); + if (cx1 <= cx0 || cy1 <= cy0) continue; + const long vx = std::lround(t0 - sx[col] * gx), vy = std::lround(u0 - sy[row] * gy); + if (std::max({std::labs(vx), std::labs(vy), vx + vw, vy + vh}) > 32000) continue; + auto *paint = appendLinearGradientRectRaw(node, vx, vy, vw, vh, source->from, source->mid, source->to, + source->midStop, source->toStop, source->angle, source->fromAlpha, source->midAlpha, source->toAlpha, source->hasMid); + if (!paint) return true; + paint->gradient.tl = paint->gradient.tr = paint->gradient.br = paint->gradient.bl = 0; + paint->gradient.boundsClip = 1; + paint->bx = cx0; paint->by = cy0; paint->bw = cx1 - cx0; paint->bh = cy1 - cy0; + } + } + return true; +} +#endif + void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordBox(const Node &node, uint8_t parentAlpha) { const Node *n = &node; @@ -3048,6 +3144,11 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB #if GEA_CSS_BOX_SHADOW recordInsetBoxShadow(*n, parentAlpha); #endif +#if GEA_CSS_BORDER_IMAGE + // A painted border image replaces the border styles. The handle check is + // one rare-style byte; a box without border-image pays nothing more. + if (rstyle(n->computedStyle()).border_image_source && recordBorderImage(*n)) return; +#endif if (n->computedStyle().border_width > 0 && !hasBorderRelief(n->computedStyle()) && !hasSideBorder(n->computedStyle()) && !borderColorsDiffer(n->computedStyle()) && !borderIsSameOpaqueSolidBackground(*n, parentAlpha)) { if (ViewGeometry::hasTransformChain(*n, false)) { From 868054bec51b6813df05a4d6d4d8389729e1e48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 04:17:44 +0300 Subject: [PATCH 27/50] Split decorated inline boxes around their blocks A span with a border, padding or background that contains an in-flow block now lays the inline runs between its blocks out as fragments, each shrink-wrapped like the unsplit span and open on its split sides. The first fragment carries the inline-start edge, the last the inline-end edge, and every fragment its block edges; the blocks sit on the parent's content box. The fragments are kept in NodeRareData and painted instead of the single box. Only spans Gea places on their own line are split. In the same mixed inline and block path, a block narrower than the line now hugs the right edge in right-to-left flow. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 115 +++++++++++++++++++++++++++----- packages/engine/ui/tree_state.h | 9 +++ packages/engine/ui/view.cpp | 29 ++++++++ 3 files changed, 136 insertions(+), 17 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 884df01..1563cbb 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2549,6 +2549,24 @@ bool splitInlineWrapper(const Node *nodes, int id) return containsBlock; } +// An inline box with visible decorations split by an in-flow block (CSS 2.2 +// 9.2.1.1) keeps its box but lays its inline runs out as fragments: shrink- +// wrapped like the unsplit span, open on the split sides. The blocks sit on +// the parent's content edges. Only a span on its own line (Gea reads vertical +// margins so) starts at those edges. +bool splitDecoratedInline(const Node *nodes, int id) +{ + const Node &n = nodes[id]; + if (!LayoutEngine::isCssInlineLevelBox(n) || LayoutEngine::isInlineLevelNode(n) || n.type != NodeType::View || + n.computedStyle().display != kDisplayBlock || emptyInlineBox(n) || overflowEstablishesContext(n.computedStyle())) return false; + for (int c = n.first_child; c >= 0; c = nodes[c].next_sibling) { + const Node &child = nodes[c]; + if (!isDisplayNone(child.computedStyle()) && !isOutOfFlowPosition(child.computedStyle().position) && !child.computedStyle().float_side && + child.type == NodeType::View && (child.style.display_explicit || !isInlineLevelTag(tagFromId(child.tag_id)))) return true; + } + return false; +} + int formattingEdgeNode(const Node *nodes, int id, bool last) { if (!splitInlineWrapper(nodes, id)) return id; @@ -2613,10 +2631,19 @@ class LayoutNodePass { return; } - if (NodeRareData *rare = rareDataFor(id_)) rare->multicol.valid = false; + if (NodeRareData *rare = rareDataFor(id_)) { +#if GEA_CSS_MULTICOL + rare->multicol.valid = false; +#endif + rare->inlineParts.clear(); + } +#if GEA_CSS_MULTICOL const ColumnGeometry columns = columnGeometry(); if (columns.count > 0) layoutColumns(columns); else layoutContent(); +#else + layoutContent(); +#endif } void layoutContent() @@ -3255,6 +3282,34 @@ class LayoutNodePass { int intrinsicWidth = 0; CollapsedMargin pending; bool first = true, clearedThrough = false; + // A split decorated inline (splitDecoratedInline): the runs between its + // blocks become fragments, which reach past its content box on their + // open sides. outer[] is margin, border and padding per side. + const bool split = splitDecoratedInline(nodes_, id_); + const bool rtl = rightToLeft(node_); + int outer[4]; + for (int s = 0; s < 4; ++s) outer[s] = split ? node_.computedStyle().margin[s] + boxInset(node_.computedStyle(), s) : 0; + std::vector parts; + int partTop = -1, partWidth = 0, trimLeft = 0, trimRight = 0; + auto blockFollows = [&](int from) { + for (int k = from; k < count; ++k) + if (blockBox(nodes_[children[k]]) && !nodes_[children[k]].computedStyle().float_side) return true; + return false; + }; + auto closePart = [&]() { + const int left = rtl ? contentWidth + trimRight - partWidth - (trimLeft ? 0 : boxInset(node_.computedStyle(), 3)) : -trimLeft - (trimLeft ? 0 : boxInset(node_.computedStyle(), 3)); + const int right = rtl ? contentWidth + trimRight + (trimRight ? 0 : boxInset(node_.computedStyle(), 1)) : -trimLeft + partWidth + (trimRight ? 0 : boxInset(node_.computedStyle(), 1)); + const int top = partTop - boxInset(node_.computedStyle(), 0), bottom = flowY + boxInset(node_.computedStyle(), 2); + InlinePart part; + part.x = clampInt16(boxInset(node_.computedStyle(), 3) + left); + part.y = clampInt16(boxInset(node_.computedStyle(), 0) + top); + part.width = clampInt16(right - left); + part.height = clampInt16(bottom - top); + part.sides = static_cast(5 | (trimRight ? 0 : 2) | (trimLeft ? 0 : 8)); + parts.push_back(part); + partTop = -1; + partWidth = 0; + }; for (int i = 0; i < count; ++i) { Node &child = nodes_[children[i]]; if (!blockBox(child) && !child.computedStyle().float_side) { @@ -3265,6 +3320,15 @@ class LayoutNodePass { end < count && !blockBox(nodes_[children[end]]) && !nodes_[children[end]].computedStyle().float_side) ++end; flowY += pending.value(); pending = {}; + if (split && partTop < 0) { + const bool firstPart = parts.empty(), lastPart = !blockFollows(end); + if (!firstPart) flowY += outer[0]; + partTop = flowY; + trimLeft = (rtl ? !lastPart : !firstPart) ? outer[3] : 0; + trimRight = (rtl ? !firstPart : !lastPart) ? outer[1] : 0; + } + const int runWidth = contentWidth + (split ? trimLeft + trimRight : 0); + const int runShift = split ? trimLeft : 0; ScratchFrame inlineScratch; if (inlineScratch.valid()) { auto layoutRun = [&](int width) { @@ -3276,7 +3340,7 @@ class LayoutNodePass { NodeRareData *ownerRare = rareDataFor(id_); const bool ownerLineWasValid = ownerRare && ownerRare->firstLineBackground.lineValid; #endif - auto extent = layoutRun(contentWidth); + auto extent = layoutRun(runWidth); // If even the first unbreakable unit cannot fit beside a float, // move this line down to the next float boundary and retry. // Once the whole containing width is available, the word may overflow. @@ -3291,20 +3355,20 @@ class LayoutNodePass { minimum += item.computedStyle().margin[1] + item.computedStyle().margin[3]; if (item.type == NodeType::Text) minimum += boxInsets(item.computedStyle(), true); } - int left = 0, right = contentWidth; + int left = 0, right = runWidth; for (;;) { - left = 0; right = contentWidth; + left = 0; right = runWidth; int nextBottom = INT_MAX; for (const auto &f : floats) { if (originY + flowY >= f.bottom || originY + flowY + extent.firstLineHeight <= f.top) continue; - if (f.side == 1) left = std::max(left, f.right - originX); - else right = std::min(right, f.left - originX); + if (f.side == 1) left = std::max(left, f.right - originX + runShift); + else right = std::min(right, f.left - originX + runShift); nextBottom = std::min(nextBottom, f.bottom - originY); } - if (minimum <= right-left || (left == 0 && right == contentWidth) || nextBottom == INT_MAX) break; + if (minimum <= right-left || (left == 0 && right == runWidth) || nextBottom == INT_MAX) break; flowY = nextBottom; } - if (right - left != contentWidth) extent = layoutRun(std::max(0, right - left)); + if (right - left != runWidth) extent = layoutRun(std::max(0, right - left)); #if GEA_CSS_FIRST_LINE if (!ownerLineWasValid && ownerRare && ownerRare->firstLineBackground.lineValid) ownerRare->firstLineBackground.lineY = clampInt16(ownerRare->firstLineBackground.lineY + flowY); @@ -3316,7 +3380,7 @@ class LayoutNodePass { if (NodeRareData *rare = rareDataFor(fragmentId)) { auto &fragment = rare->firstLineFragment; if (fragment.valid && fragment.contextNode == id_) { - fragment.x = clampInt16(fragment.x + left); + fragment.x = clampInt16(fragment.x + left - runShift); fragment.y = clampInt16(fragment.y + flowY); } } @@ -3324,11 +3388,17 @@ class LayoutNodePass { } #endif for (int j = i; j < end; ++j) { - nodes_[children[j]].layout.x += left; + nodes_[children[j]].layout.x += left - runShift; nodes_[children[j]].layout.y += flowY; } flowY += extent.height; intrinsicWidth = std::max(intrinsicWidth, left + extent.width); + if (split) { + // A one-line run's collapsible trailing space hangs past the fragment. + const int lastItem = children[isLineBreak(nodes_[children[end - 1]]) && end - 1 > i ? end - 2 : end - 1]; + const int hang = extent.height == extent.firstLineHeight ? FlexLayoutPass::hangingSpace(nodes_[lastItem]) : 0; + partWidth = std::max(partWidth, left + extent.width - hang); + } const Node &last = nodes_[children[end - 1]]; if (isLineBreak(last)) for (const auto &f : floats) if (last.computedStyle().clear_side & f.side) flowY = std::max(flowY, f.bottom - originY); @@ -3337,7 +3407,14 @@ class LayoutNodePass { i = end - 1; continue; } - engine_.layoutNode(children[i], contentWidth, contentHeight, measureWidth); + const bool splitBlock = split && !child.computedStyle().float_side && blockBox(child); + if (splitBlock && partTop >= 0) { + closePart(); + flowY += outer[2]; + } + // A block splitting an inline fills the parent's content box. + const int area = splitBlock ? contentWidth + outer[1] + outer[3] : contentWidth; + engine_.layoutNode(children[i], area, contentHeight, measureWidth); const bool block = !child.computedStyle().float_side && blockBox(child); const int measuredOuterW = child.layout.width + child.computedStyle().margin[3] + child.computedStyle().margin[1]; const bool automaticWidth = block && !hasExplicitWidth(child) && !isIntrinsicSizeExpression(child.computedStyle().width_expression) && @@ -3345,7 +3422,7 @@ class LayoutNodePass { // Ordinary blocks still fill their containing block beside floats; // only their line boxes are shortened by float exclusions. if (automaticWidth) { - const int width = clampLayoutSize(child, std::max(0, contentWidth - child.computedStyle().margin[1] - child.computedStyle().margin[3]), true); + const int width = clampLayoutSize(child, std::max(0, area - child.computedStyle().margin[1] - child.computedStyle().margin[3]), true); if (child.layout.width != width) { child.layout.width = clampInt16(width); engine_.repositionChildren(children[i]); @@ -3376,11 +3453,11 @@ class LayoutNodePass { // when that requires negative clearance above the top margin. y = clearance ? clearBottom : std::max(y, clearBottom); if (clearance && !context.hasClearance(children[i])) context.cleared.push_back(children[i]); - int left = 0, right = contentWidth, contextX = 0; + int left = 0, right = area, contextX = 0; const bool independentBlock = block && establishesBlockContext(child); if (child.computedStyle().float_side || child.computedStyle().display != kDisplayBlock || establishesBlockContext(child)) { for (;;) { - left = 0; right = contentWidth; + left = 0; right = area; int nextY = 32767; bool hasLeft = false, hasRight = false; for (const auto &global : floats) { @@ -3396,7 +3473,7 @@ class LayoutNodePass { // Automatic width uses the remaining interval. Even a // zero-width float prevents a negative margin crossing it. const int start = hasLeft ? std::max(child.computedStyle().margin[3], left) : child.computedStyle().margin[3]; - const int end = hasRight ? std::min(contentWidth - child.computedStyle().margin[1], right) : contentWidth - child.computedStyle().margin[1]; + const int end = hasRight ? std::min(area - child.computedStyle().margin[1], right) : area - child.computedStyle().margin[1]; contextX = start - child.computedStyle().margin[3]; if (automaticWidth) { const int width = clampLayoutSize(child, std::max(0, end - start), true); @@ -3416,13 +3493,15 @@ class LayoutNodePass { y = nextY; } } - const int x = child.computedStyle().float_side == 2 ? right - outerW : independentBlock ? contextX : left; + // In right-to-left flow a block that does not fill the line hugs the right. + const int x = (child.style.float_side == 2 || (block && rtl && !independentBlock) ? right - outerW : independentBlock ? contextX : left) - + (splitBlock ? outer[3] : 0); intrinsicWidth = std::max(intrinsicWidth, child.computedStyle().float_side ? left + outerW + contentWidth - right : measuredOuterW); child.layout.x = boxInset(node_.computedStyle(), 3) + x + child.computedStyle().margin[3]; child.layout.y = boxInset(node_.computedStyle(), 0) + y + (block ? 0 : child.computedStyle().margin[0]); if (block && !establishesBlockContext(child) && child.first_child >= 0) { - LayoutNodePass nested(engine_, children[i], contentWidth, contentHeight); + LayoutNodePass nested(engine_, children[i], area, contentHeight); if (!nested.resolveInlineFormattingRow()) { ScratchFrame nestedScratch; if (nestedScratch.valid()) { @@ -3479,6 +3558,8 @@ class LayoutNodePass { clearedThrough = false; } else flowY = y + outerH; } + if (partTop >= 0) closePart(); + if (split) ensureRareData(id_).inlineParts = std::move(parts); if (clearedThrough || (!collapsesWithChildren(id_, true) && !(first && collapsesWithChildren(id_, false)))) flowY += pending.value(); if (measureWidth) diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index a1138c8..c7e3c79 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -124,6 +124,13 @@ struct InlineStaticPosition { // content as one column `width` wide; painting slices it into column boxes // `height` tall and `gap` apart, `used` of them, first to last. `discard` // (continue: discard) drops what overflows the last column box. +// One fragment of an inline box split by blocks: its border box relative to +// the element's, and the sides it paints (bits 0-3: top, right, bottom, left). +struct InlinePart { + std::int16_t x = 0, y = 0, width = 0, height = 0; + std::uint8_t sides = 0; +}; + struct MulticolLayout { std::int16_t width = 0; std::int16_t gap = 0; @@ -263,6 +270,7 @@ struct NodeRareData { #else inline static constexpr MulticolLayout multicol{}; #endif + std::vector inlineParts; void clear() { @@ -288,6 +296,7 @@ struct NodeRareData { #if GEA_CSS_MULTICOL multicol = MulticolLayout{}; #endif + inlineParts.clear(); } }; diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 2867d33..9c29778 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2967,6 +2967,34 @@ int ViewRenderer::canvasBackgroundSource() return root; } +// A split inline (splitDecoratedInline in layout.cpp) paints its fragments +// instead of one box: each fragment's background colour and solid border, +// open on its split sides. +bool recordInlineParts(const Node &node, uint8_t parentAlpha) +{ + const NodeRareData *rare = rareDataFor(ViewGeometry::nodeIndex(node)); + if (!rare || rare->inlineParts.empty() || ViewGeometry::hasTransformChain(node, false)) return false; + for (const auto &part : rare->inlineParts) { + const int x = node.layout.x + part.x, y = node.layout.y + part.y, w = part.width, h = part.height; + if (w <= 0 || h <= 0) continue; + if (node.computedStyle().has_bg && node.computedStyle().bg_alpha > 0) + appendFillRectWithAlpha(x, y, w, h, node.computedStyle().bg_color, node.computedStyle().bg_alpha, parentAlpha, x, y, w, h); + // Horizontal edges own the corners, as for an unsplit box. + const int top = part.sides & 1 ? std::min(computedBorderWidth(node.computedStyle(), 0), h) : 0; + const int bottom = part.sides & 4 ? std::min(computedBorderWidth(node.computedStyle(), 2), h - top) : 0; + for (int side = 0; side < 4; ++side) { + const int width = computedBorderWidth(node.computedStyle(), side); + if (!(part.sides & (1 << side)) || width <= 0 || borderPaintAlpha(node.computedStyle(), side) == 0) continue; + int sx = x, sy = y + top, sw = std::min(width, w), sh = h - top - bottom; + if (side == 0 || side == 2) { sx = x; sw = w; sy = side ? y + h - bottom : y; sh = side ? bottom : top; } + else if (side == 1) sx = x + w - sw; + if (sw > 0 && sh > 0) + appendFillRectWithAlpha(sx, sy, sw, sh, borderPaintColor(node.computedStyle(), side), borderPaintAlpha(node.computedStyle(), side), parentAlpha, sx, sy, sw, sh); + } + } + return true; +} + #if GEA_CSS_BORDER_IMAGE // An edge that would need more tiles than this stretches its part instead, // which bounds a box at 4 * 16 + 5 commands. @@ -3085,6 +3113,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB } } if (w <= 0 || h <= 0) return; + if (recordInlineParts(*n, parentAlpha)) return; #if GEA_CSS_BOX_SHADOW if (rstyle(n->computedStyle()).box_shadow_alpha) recordOuterBoxShadow(*n, parentAlpha); #endif From 5fa5f2ff3af295adcafe541f68a8f8085670457e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 11:14:08 +0300 Subject: [PATCH 28/50] Snap fractional flex and percentage edges like browsers flex-grow floored each item's share and gave the leftover pixels to the last item, so 3:1 of 70px was 52/18. Rounding the running share instead puts every item edge on the pixel nearest its exact position: 53/17, and 33/34/33 for three equal items in 100px. Block flow now also carries the fractional part of plain percentage heights, so each such box ends on the pixel nearest its exact bottom edge: 75% and 25% of 70px are 53 and 17 instead of 53 and 18. A resized box's scroll content height follows, so its container no longer reports a phantom 1px overflow. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 36 ++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 1563cbb..8aea605 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2017,14 +2017,21 @@ class FlexLayoutPass { for (int i = 0; i < line.count; i++) if (nodes[children_[line.start + i]].computedStyle().flex > 0) lastGrow = i; + // Round the running share, not each item's: every item edge lands on the + // pixel nearest its exact position, as browsers snap fractional edges + // (3:1 of 70px is 53/17, not 52/18). + long long weight = 0; for (int i = 0; i <= lastGrow; i++) { const int child = children_[line.start + i]; Node &childNode = nodes[child]; if (childNode.computedStyle().flex <= 0) continue; + const long long before = (2LL * delta * weight + totalGrow) / (2LL * totalGrow); + weight += childNode.computedStyle().flex; + const long long after = (2LL * delta * weight + totalGrow) / (2LL * totalGrow); // The last growing item also takes the integer remainder (and whatever // earlier items could not absorb) in the same resize, so its subtree — // or its text re-wrap — is laid out once, not twice. - const int share = (delta * childNode.computedStyle().flex) / totalGrow; + const int share = static_cast(after - before); const int change = i == lastGrow ? std::max(share, delta - applied) : share; applied += applyFlexMainSizeDelta(child, childNode, change, line.start + i); } @@ -3123,6 +3130,19 @@ class LayoutNodePass { return margin; } + // The exact border-box height of a box sized by a plain percentage of + // `basis`, or -1 when its integer height is already exact (lengths, auto, + // calc(), or min/max clamping). + double exactPercentHeight(const Node &child, int basis) const + { + const auto &s = child.computedStyle(); + if (s.height_percent == kUnset || s.height != kUnset || s.height_expression >= 0 || + percentageHeightDependsOnIntrinsicSize(child) || flexBasisScope(child, false) || intrinsicSizeScope(child, false)) return -1; + const int insets = s.box_sizing == 0 ? boxInsets(s, false) : 0; + if (resolvePercentSize(basis, s.height_percent) + insets != child.layout.height) return -1; + return static_cast(basis) * s.height_percent / 1000.0 + insets; + } + bool layoutBlockChildren(int *children, int count, int contentWidth, int contentHeight, bool autosize) { if (node_.computedStyle().display != kDisplayBlock || node_.computedStyle().flex_direction_explicit) return false; @@ -3197,6 +3217,10 @@ class LayoutNodePass { const bool mergeTop = collapsesWithChildren(id_, false); const bool mergeBottom = collapsesWithChildren(id_, true); int cursor = 0; + // The exact flow position minus cursor. Percentage heights are + // fractional; each such box ends on the pixel nearest its exact bottom + // edge, as browsers snap, so 75% and 25% of 70px are 53 and 17. + double drift = 0; bool first = true; CollapsedMargin pending; for (int i = 0; i < count; ++i) { @@ -3216,6 +3240,16 @@ class LayoutNodePass { pending.add(childEdgeMargin(id_, id, false)); cursor += first && mergeTop ? 0 : pending.value(); (vertical ? child.layout.x : child.layout.y) = boxInset(node_.computedStyle(), blockMarginSide(false)) + cursor; + if (const double exact = vertical ? -1 : exactPercentHeight(child, contentHeight); exact >= 0) { + const double bottom = cursor + drift + exact; + const int height = static_cast(std::floor(bottom + 0.5)) - cursor; + if (height >= 0 && height != child.layout.height) { + if (child.layout.scroll_content_height == child.layout.height) child.layout.scroll_content_height = clampInt16(height); + child.layout.height = clampInt16(height); + engine_.repositionChildren(id); + } + drift = bottom - cursor - child.layout.height; + } cursor += vertical ? child.layout.width : child.layout.height; pending = childEdgeMargin(id_, id, true); first = false; From 7e4faca76bb78711bd95781caf075a13d6b6cd55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 13:15:27 +0300 Subject: [PATCH 29/50] Establish block formatting contexts for flow-root and align-content display: flow-root now keeps a flag next to the block display kind, and a block container with a non-normal align-content becomes a BFC root as CSS Align 3 requires. Both contain floats and child margins and avoid outer floats. An automatic-width BFC beside floats now tries the widest layout opportunity at each position first and only narrows as its height meets more floats. Before, a box whose height follows its width (aspect-ratio) could flip between two widths forever. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 21 +++++++++++++++------ packages/engine/ui/node_model.h | 19 +++++++++++++++---- packages/engine/ui/style.cpp | 5 +++-- packages/engine/ui/tree_style.cpp | 8 ++++++-- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 8aea605..ccf4d62 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3005,10 +3005,13 @@ class LayoutNodePass { !nodes_[child].computedStyle().float_side && !suppressAnonymousWhitespace(nodes_, parent, child); } + // A non-normal align-content makes a block container a BFC root (CSS Align 3 + // 5.1), just like display: flow-root. bool establishesBlockContext(const Node &n) const { return n.parent < 0 || isOutOfFlowPosition(n.style.position) || n.style.float_side || - n.style.display != kDisplayBlock || overflowEstablishesContext(n.style) || isMulticolContainer(n) || + n.style.display != kDisplayBlock || isFlowRoot(n.style) || (blockBox(n) && n.style.align_content != 0) || + overflowEstablishesContext(n.style) || isMulticolContainer(n) || (n.parent >= 0 && (nodes_[n.parent].style.display == kDisplayFlex || isDisplayGrid(nodes_[n.parent].style) || writingMode(n) != writingMode(nodes_[n.parent]))); } @@ -3490,13 +3493,19 @@ class LayoutNodePass { int left = 0, right = area, contextX = 0; const bool independentBlock = block && establishesBlockContext(child); if (child.computedStyle().float_side || child.computedStyle().display != kDisplayBlock || establishesBlockContext(child)) { + // An automatic-width BFC tries the widest opportunity at each y + // first and narrows only while its height meets more floats. Its + // width never grows back, so a height that depends on the width + // (aspect-ratio, wrapping) cannot make this search oscillate. + const bool searchWidth = independentBlock && automaticWidth; + int extent = searchWidth ? 1 : independentBlock ? int(child.layout.height) : outerH; for (;;) { left = 0; right = area; int nextY = 32767; bool hasLeft = false, hasRight = false; for (const auto &global : floats) { const FloatBox f{global.left - originX, global.top - originY, global.right - originX, global.bottom - originY, global.side}; - if (y >= f.bottom || y + std::max(1, independentBlock ? int(child.layout.height) : outerH) <= f.top) continue; + if (y >= f.bottom || y + std::max(1, extent) <= f.top) continue; if (f.side == 1) { left = std::max(left, f.right); hasLeft = true; } else { right = std::min(right, f.left); hasRight = true; } nextY = std::min(nextY, f.bottom); @@ -3509,22 +3518,22 @@ class LayoutNodePass { const int start = hasLeft ? std::max(child.computedStyle().margin[3], left) : child.computedStyle().margin[3]; const int end = hasRight ? std::min(area - child.computedStyle().margin[1], right) : area - child.computedStyle().margin[1]; contextX = start - child.computedStyle().margin[3]; - if (automaticWidth) { - const int width = clampLayoutSize(child, std::max(0, end - start), true); + if (searchWidth && end >= start) { + const int width = clampLayoutSize(child, end - start, true); if (child.layout.width != width) { - const int previousHeight = child.layout.height; engine_.layoutNode(children[i], width + child.computedStyle().margin[1] + child.computedStyle().margin[3], contentHeight); child.layout.width = clampInt16(width); engine_.repositionChildren(children[i]); outerW = width + child.computedStyle().margin[1] + child.computedStyle().margin[3]; outerH = child.layout.height + child.computedStyle().margin[0] + child.computedStyle().margin[2]; - if (previousHeight != child.layout.height) continue; } + if (child.layout.height > extent) { extent = child.layout.height; continue; } } fits = child.layout.width <= end - start; } if (fits || nextY == 32767) break; y = nextY; + if (searchWidth) extent = 1; } } // In right-to-left flow a block that does not fill the line hugs the right. diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 1b5b86e..6ea1c39 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -94,6 +94,12 @@ inline constexpr int8_t kDisplayBlock = 0; inline constexpr int8_t kDisplayNone = 1; inline constexpr int8_t kDisplayGrid = 2; inline constexpr int8_t kDisplayFlex = 3; +// A Display property value carries the box kind in its low bits and extra +// keyword bits above kDisplayFlagShift; the extra bits land in display_explicit. +inline constexpr int kDisplayKindMask = 15; +inline constexpr int kDisplayFlagShift = 4; +inline constexpr int8_t kDisplayExplicit = 1; +inline constexpr int8_t kDisplayFlowRoot = 2; // display: flow-root inline constexpr int kPositionFixed = 3; // position: sticky stays in flow; the absolute-coordinate pass shifts it. inline constexpr int kPositionSticky = 4; @@ -577,19 +583,19 @@ struct ComputedStyle { // inline-formatting heuristic distinguish an explicit `display:block` on an // inline-level tag (e.g. , which is block-level // and stacks) from a span's default inline behaviour. Mirrors - // flex_direction_explicit. + // flex_direction_explicit. Other bits: kDisplayFlowRoot. #if GEA_CSS_DISPLAY_EXPLICIT - uint8_t display_explicit : 1; + uint8_t display_explicit : 3; #else static constexpr uint8_t display_explicit = 0; #endif #if GEA_CSS_TEXT_ALIGN - int8_t text_align; + int8_t text_align; // 0 start, 1 center, 2 right, 3 left, 4 end #else static constexpr int8_t text_align = 0; #endif int8_t overflow : 3; - uint8_t position : 2; + uint8_t position : 3; // kPositionSticky needs the third bit uint8_t has_bg : 1; #if GEA_CSS_Z_INDEX uint8_t z_index_auto : 1; @@ -1365,6 +1371,11 @@ inline bool isDisplayGrid(const ComputedStyle &style) return GEA_CSS_GRID && style.display == kDisplayGrid; } +inline bool isFlowRoot(const ComputedStyle &style) +{ + return style.display == kDisplayBlock && (style.display_explicit & kDisplayFlowRoot); +} + inline bool usesRowLayout(const ComputedStyle &style) { // `flex-direction` applies to FLEX CONTAINERS, and to nothing else -- diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index e62bd10..329f174 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -5048,6 +5048,7 @@ int displayValue(const std::string &value) if (value == "none") return kDisplayNone; if (value == "grid" || value == "inline-grid") return kDisplayGrid; if (value == "flex" || value == "inline-flex") return kDisplayFlex; + if (value == "flow-root") return kDisplayBlock | (kDisplayFlowRoot << kDisplayFlagShift); return kDisplayBlock; } @@ -8488,9 +8489,9 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) switch (property) { case Property::Display: #if GEA_CSS_DISPLAY_EXPLICIT - style.display_explicit = 1; + style.display_explicit = kDisplayExplicit | (value >> kDisplayFlagShift); #endif - style.display = value; + style.display = value & kDisplayKindMask; return true; #if GEA_CSS_FLEX_DIRECTION case Property::FlexDirection: diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 52e41ee..562596f 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -333,9 +333,13 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn // it so the inline-formatting heuristic treats e.g. `display:block` on a // as block-level (stacks) rather than its default inline behaviour. #if GEA_CSS_DISPLAY_EXPLICIT - if (!style.display_explicit) { style.display_explicit = 1; changed = 1; } + // Keyword bits above the box kind (flow-root) are recorded with it. + if (style.display_explicit != (kDisplayExplicit | (value >> kDisplayFlagShift))) { + style.display_explicit = kDisplayExplicit | (value >> kDisplayFlagShift); + changed = 1; + } #endif - if (style.display != value) { style.display = value; changed = 1; } + if (style.display != (value & kDisplayKindMask)) { style.display = value & kDisplayKindMask; changed = 1; } break; #if GEA_CSS_FLEX_DIRECTION case Property::FlexDirection: From 085107775687033d481e6408ec437e6591a61e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 13:18:18 +0300 Subject: [PATCH 30/50] Follow CSS operator precedence in calc() Both calc() parsers split at the first operator in the order / * + -, so calc(4lh + 2 * 5px) became (4lh + 2) * 5px and calc(128px + 2 * 5px) was rejected. They now split sums at their last top-level + or -, then products at their last * or /, accept the number on either side of *, and unwrap parenthesised groups. A + or - after an operator, an opening parenthesis or an exponent stays part of the number. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/style.cpp | 107 ++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 40 deletions(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 329f174..ab55474 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -3948,26 +3948,61 @@ int containmentValue(const std::string &value) std::vector splitFunctionAwareWords(const std::string &value); int parseOriginPart(const std::string &part, int fallback); -int parseCalcExpression(const std::string &expr, int nodeId, LengthAxis axis) +// The operator a calc() sum or product splits at, following CSS precedence: the +// last top-level + or - (sums are left-associative), else the last * or /. +// A + or - is binary only after an operand, not as a number's sign or exponent. +std::size_t calcOperatorSplit(const std::string &text) { - const std::string text = trimCssValue(expr); - for (char op : {'/', '*', '+', '-'}) { + for (const char *ops : {"+-", "*/"}) { int depth = 0; + std::size_t split = std::string::npos; for (std::size_t i = 0; i < text.size(); ++i) { const char c = text[i]; if (c == '(') depth++; else if (c == ')' && depth > 0) depth--; - else if (c == op && depth == 0 && i > 0) { - const int left = parseLengthForNode(text.substr(0, i), nodeId, axis); - const double right = std::strtod(text.substr(i + 1).c_str(), nullptr); - if (op == '/') return right == 0.0 ? 0 : roundToInt(static_cast(left) / right); - if (op == '*') return roundToInt(static_cast(left) * right); - const int rightLength = parseLengthForNode(text.substr(i + 1), nodeId, axis); - return op == '+' ? left + rightLength : left - rightLength; + else if (depth == 0 && i > 0 && std::strchr(ops, c)) { + std::size_t p = i; + while (p > 0 && text[p - 1] == ' ') --p; + if (p == 0) continue; + const char previous = text[p - 1]; + if (ops[0] == '+' && (std::strchr("+-*/(", previous) || + (p == i && (previous == 'e' || previous == 'E') && p >= 2 && std::isdigit(static_cast(text[p - 2]))))) continue; + split = i; } } + if (split != std::string::npos) return split; + } + return std::string::npos; +} + +bool calcNumber(const std::string &raw, double &out) +{ + const std::string text = trimCssValue(raw); + char *end = nullptr; + out = std::strtod(text.c_str(), &end); + return !text.empty() && end && *end == '\0'; +} + +int parseCalcExpression(const std::string &expr, int nodeId, LengthAxis axis) +{ + const std::string text = trimCssValue(expr); + const std::size_t split = calcOperatorSplit(text); + if (split == std::string::npos) { + if (text.size() > 1 && text.front() == '(' && text.back() == ')') return parseCalcExpression(text.substr(1, text.size() - 2), nodeId, axis); + return parseLengthForNode(text, nodeId, axis); + } + const char op = text[split]; + const std::string left = text.substr(0, split), right = text.substr(split + 1); + if (op == '+' || op == '-') { + const int a = parseCalcExpression(left, nodeId, axis), b = parseCalcExpression(right, nodeId, axis); + return op == '+' ? a + b : a - b; } - return parseLengthForNode(text, nodeId, axis); + double scalar = 0.0; + if (op == '*' && calcNumber(left, scalar)) return roundToInt(static_cast(parseCalcExpression(right, nodeId, axis)) * scalar); + scalar = std::strtod(right.c_str(), nullptr); + const int length = parseCalcExpression(left, nodeId, axis); + if (op == '/') return scalar == 0.0 ? 0 : roundToInt(static_cast(length) / scalar); + return roundToInt(static_cast(length) * scalar); } int parseLengthForNode(const std::string &rawValue, int nodeId, LengthAxis axis) @@ -5942,37 +5977,29 @@ bool parseCompiledLengthSpec(const std::string &raw, CssLengthSpec &out, bool al bool parseCompiledCalcExpression(const std::string &expr, CssLengthSpec &out) { const std::string text = trimCssValue(expr); - for (char op : {'/', '*', '+', '-'}) { - int depth = 0; - for (std::size_t i = 0; i < text.size(); ++i) { - const char c = text[i]; - if (c == '(') depth++; - else if (c == ')' && depth > 0) depth--; - else if (c == op && depth == 0 && i > 0) { - CssLengthExpression expression; - if (!parseCompiledLengthSpec(text.substr(0, i), expression.a)) return false; - if (op == '/' || op == '*') { - const std::string right = trimCssValue(text.substr(i + 1)); - char *end = nullptr; - const double scalar = std::strtod(right.c_str(), &end); - if (end == right.c_str()) return false; - while (*end == ' ') ++end; - if (*end != '\0') return false; - expression.scalar = static_cast(scalar); - expression.kind = op == '/' - ? CssLengthExpressionKind::Divide - : CssLengthExpressionKind::Multiply; - } else { - if (!parseCompiledLengthSpec(text.substr(i + 1), expression.b)) return false; - expression.kind = op == '+' - ? CssLengthExpressionKind::Add - : CssLengthExpressionKind::Subtract; - } - return storeCompiledCssLengthExpressionSpec(expression, out); - } + const std::size_t split = calcOperatorSplit(text); + if (split == std::string::npos) { + if (text.size() > 1 && text.front() == '(' && text.back() == ')') return parseCompiledCalcExpression(text.substr(1, text.size() - 2), out); + return parseCompiledLengthSpec(text, out); + } + const char op = text[split]; + const std::string left = text.substr(0, split), right = text.substr(split + 1); + CssLengthExpression expression; + if (op == '+' || op == '-') { + if (!parseCompiledCalcExpression(left, expression.a) || !parseCompiledCalcExpression(right, expression.b)) return false; + expression.kind = op == '+' ? CssLengthExpressionKind::Add : CssLengthExpressionKind::Subtract; + } else { + // A product takes its number on either side; a quotient divides by one. + double scalar = 0.0; + if (op == '*' && calcNumber(left, scalar)) { + if (!parseCompiledCalcExpression(right, expression.a)) return false; + } else if (!calcNumber(right, scalar) || !parseCompiledCalcExpression(left, expression.a)) { + return false; } + expression.scalar = static_cast(scalar); + expression.kind = op == '/' ? CssLengthExpressionKind::Divide : CssLengthExpressionKind::Multiply; } - return parseCompiledLengthSpec(text, out); + return storeCompiledCssLengthExpressionSpec(expression, out); } bool compileLengthExpressionSpec(const std::string &raw, CssLengthSpec &out) From b940614e18285d2048e976de5053733e31dbb25e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 13:21:09 +0300 Subject: [PATCH 31/50] Clamp lines by height with box ends, margins and BFC boundaries A line-clamp container now establishes a BFC, as the CSS Overflow 4 references expect. Its clamp walk treats flow-root and other BFC roots as single units, hides a block whose first content already follows the clamp point together with its background and borders, and counts kept blocks, including empty ones, so their ancestors stay visible. A block hidden that way no longer takes the ellipsis from the line before it. With line-clamp: auto a break point fits only if the boxes around it still fit once clamped: their bottom border and padding, min-height and collapsing bottom margins now count, and the clamped height includes them. An empty cleared block's collapsed margin sits above its border edge, as CSS 2.2 8.3.1 places it. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 71 +++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index ccf4d62..a3cd211 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -397,7 +397,27 @@ struct LineClampWalk { int keptBottom = 0; // The text run whose line is the last one kept; -1 when that is an atomic box. int lastRun = -1; + // Lines and boxes kept so far. + int kept = 0; bool clamped = false; + // The blocks the walk is inside, innermost last. Clamping removes the rest + // of their content, so each still ends with its bottom border and padding, + // its min-height and its bottom margin. + struct OpenBlock { int top, inset, minHeight, margin; }; + std::vector open; + // Where the container's content ends when clamped after content ending at + // `bottom` with bottom margin `margin`. Margins collapse outwards through + // blocks without bottom border, padding or min-height (CSS 2.2 8.3.1); the + // container is a BFC root, so the outermost margin stays inside it. + int endAfter(int bottom, int margin) const + { + for (auto it = open.rbegin(); it != open.rend(); ++it) { + if (it->inset > 0 || it->minHeight > 0) { bottom += margin; margin = 0; } + bottom = std::max(bottom + it->inset, it->top + it->minHeight); + margin = std::max(margin, it->margin); + } + return bottom + margin; + } }; #if GEA_CSS_LINE_CLAMP @@ -446,6 +466,12 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) if (child.type == NodeType::Text ? collapsible && child.text.find_first_not_of(" \t\r\n\f") == std::string::npos : inlineWrapper && child.first_child < 0 && emptyInlineBox(child)) continue; const int top = originY + child.layout.y; + // An empty block's margins collapse through it; with clearance the + // collapsed margin sits above its border edge (CSS 2.2 8.3.1). + auto bottomMargin = [&](bool empty) { + if (inlineWrapper) return 0; + return empty && child.layout.height == 0 ? std::max(0, child.computedStyle().margin[2] - child.computedStyle().margin[0]) : int(child.computedStyle().margin[2]); + }; if (child.type == NodeType::Text) { const int advance = TextRenderer::measureHeight("X", child.computedStyle().font_id, child.computedStyle().font_size, 0, child.computedStyle().line_height); const int inner = child.layout.height - boxInsets(child.computedStyle(), false); @@ -453,7 +479,8 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) for (int line = 0; line < count; ++line) { const int lineTop = top + boxInset(child.computedStyle(), 0) + line * advance; const bool newLine = lineTop > walk.lastLineTop; - if (walk.maxLines ? newLine && walk.lines >= walk.maxLines : lineTop + advance > walk.limit) { + const int end = walk.endAfter(lineTop + advance, 0); + if (walk.maxLines ? newLine && walk.lines >= walk.maxLines : end > walk.limit) { walk.clamped = true; if (line == 0) hideAfterLineClamp(nodes, id); else child.layout.line_clamp_lines = static_cast(line); @@ -463,29 +490,49 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) ++walk.lines; walk.lastLineTop = lineTop; } - walk.keptBottom = std::max(walk.keptBottom, lineTop + advance); + walk.keptBottom = std::max(walk.keptBottom, end); walk.lastRun = id; + ++walk.kept; } continue; } - const bool plainBlock = child.style.display == kDisplayBlock && !overflowEstablishesContext(child.style) && + const bool plainBlock = child.computedStyle().display == kDisplayBlock && !overflowEstablishesContext(child.computedStyle()) && + !isFlowRoot(child.computedStyle()) && child.computedStyle().align_content == 0 && !clampsLines(child) && writingMode(child) == writingMode(nodes[parent]); if (child.first_child >= 0 && (inlineWrapper || plainBlock)) { // A block between the last line box and the clamp point takes the // ellipsis away from that line; its own lines may take it instead. + const int lastRun = walk.lastRun; if (!inlineWrapper) walk.lastRun = -1; + const int kept = walk.kept; + if (!inlineWrapper) { + const int minHeight = child.computedStyle().min_height > 0 ? contentSizeToBorderSize(child.computedStyle(), child.computedStyle().min_height, false) : 0; + walk.open.push_back({top, boxInset(child.computedStyle(), 2), minHeight, child.computedStyle().margin[2]}); + } walkLineClamp(walk, id, top); - if (!walk.clamped && !inlineWrapper) walk.keptBottom = std::max(walk.keptBottom, top + child.layout.height); + if (!inlineWrapper) walk.open.pop_back(); + // A box whose first content already follows the clamp point is + // hidden whole, backgrounds and borders included. It is not between + // the last line and the clamp point, so that line keeps the ellipsis. + if (walk.clamped && walk.kept == kept) { + hideAfterLineClamp(nodes, id); + walk.lastRun = lastRun; + } + if (!walk.clamped) { + if (!inlineWrapper) walk.keptBottom = std::max(walk.keptBottom, walk.endAfter(top + child.layout.height, bottomMargin(walk.kept == kept))); + ++walk.kept; + } continue; } - const int bottom = top + child.layout.height; - if (walk.maxLines ? walk.lines >= walk.maxLines : bottom > walk.limit) { + const int end = walk.endAfter(top + child.layout.height, bottomMargin(child.first_child < 0)); + if (walk.maxLines ? walk.lines >= walk.maxLines : end > walk.limit) { walk.clamped = true; hideAfterLineClamp(nodes, id); continue; } - walk.keptBottom = std::max(walk.keptBottom, bottom); + walk.keptBottom = std::max(walk.keptBottom, end); walk.lastRun = -1; + ++walk.kept; } } @@ -3006,13 +3053,13 @@ class LayoutNodePass { } // A non-normal align-content makes a block container a BFC root (CSS Align 3 - // 5.1), just like display: flow-root. + // 5.1), just like display: flow-root and a line-clamp container. bool establishesBlockContext(const Node &n) const { - return n.parent < 0 || isOutOfFlowPosition(n.style.position) || n.style.float_side || - n.style.display != kDisplayBlock || isFlowRoot(n.style) || (blockBox(n) && n.style.align_content != 0) || - overflowEstablishesContext(n.style) || isMulticolContainer(n) || - (n.parent >= 0 && (nodes_[n.parent].style.display == kDisplayFlex || isDisplayGrid(nodes_[n.parent].style) || + return n.parent < 0 || isOutOfFlowPosition(n.computedStyle().position) || n.computedStyle().float_side || + n.computedStyle().display != kDisplayBlock || isFlowRoot(n.computedStyle()) || (blockBox(n) && n.computedStyle().align_content != 0) || clampsLines(n) || + overflowEstablishesContext(n.computedStyle()) || isMulticolContainer(n) || + (n.parent >= 0 && (nodes_[n.parent].computedStyle().display == kDisplayFlex || isDisplayGrid(nodes_[n.parent].computedStyle()) || writingMode(n) != writingMode(nodes_[n.parent]))); } From 48490686c19df86fe48a96d63f3acbfceccc1d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 13:53:09 +0300 Subject: [PATCH 32/50] Lay out inline-flex and inline-grid as atomic inline-level boxes inline-flex and inline-grid were plain flex and grid containers, so they filled the line and stood on their own. They now carry an inline flag next to the box kind and sit on the line like an image: their automatic width is fit-content, measured the way floats are, and their own aspect-ratio applies. Floated and absolutely positioned ones are blockified. blockLevelView() now answers whether a view is block-level in one place. The static style compilers emit the same display values as the engine (inline-flex 67, inline-grid 66, flow-root 32), so a declaration means the same whether it is resolved at build time or at runtime. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/scripts/build-gea-vite-geatsc.mjs | 3 +- packages/engine/ui/layout.cpp | 34 ++++++++++++------- packages/engine/ui/node_model.h | 10 +++++- packages/engine/ui/style.cpp | 6 ++-- packages/geatsc-plugin-gea/src/utils.ts | 7 ++-- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index 1392f1f..89bd49d 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -2132,7 +2132,8 @@ function cssStaticPropertyValue(property, rawValue) { const length = simpleStaticLength(value) switch (property) { case 'display': { - const values = new Map([['block', 0], ['none', 1], ['grid', 2], ['inline-grid', 2], ['flex', 3], ['inline-flex', 3]]) + // Keyword flags above the box kind, as in the engine's displayValue(). + const values = new Map([['block', 0], ['none', 1], ['grid', 2], ['inline-grid', 66], ['flex', 3], ['inline-flex', 67], ['flow-root', 32]]) return values.has(value) ? direct('Display', values.get(value)) : null } case 'box-sizing': diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index a3cd211..bb49902 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -681,7 +681,7 @@ bool definiteStretchedSize(const Node &node, bool horizontal) void applyPreferredRatio(Node &node, int availableWidth, int availableHeight) { if (!preferredRatio(node) || (node.type != NodeType::View && node.type != NodeType::Image)) return; - if (node.type != NodeType::Image && LayoutEngine::isInlineLevelNode(node)) return; + if (node.type != NodeType::Image && !isAtomicInline(node.computedStyle()) && LayoutEngine::isInlineLevelNode(node)) return; bool width = hasExplicitWidth(node), height = hasExplicitHeight(node); if (width && height) return; const Node *parent = node.parent >= 0 ? &Tree::instance().nodes()[node.parent] : nullptr; @@ -1622,7 +1622,8 @@ class FlexLayoutPass { cn.render.inline_baseline = placed; // An inline wrapper (text) contributes its CHILD's glyphs to // this line box, so the flag has to reach the node that actually draws. - if (cn.type == NodeType::View) { + // An atomic inline box (inline-flex) lays out its own children. + if (cn.type == NodeType::View && !isAtomicInline(cn.computedStyle())) { for (int c = cn.first_child; c >= 0; c = nodes[c].next_sibling) if (nodes[c].type == NodeType::Text) nodes[c].render.inline_baseline = placed; } @@ -2576,6 +2577,14 @@ bool emptyInlineBox(const Node &node) return !boxInsets(node.computedStyle(), true) && !boxInsets(node.computedStyle(), false) && FlexLayoutPass::paintsNoBox(node); } +// A view that is block-level in its parent: an explicit display or a +// block-level tag, unless it is an atomic inline-level box (inline-flex). +bool blockLevelView(const Node &node) +{ + return node.type == NodeType::View && !isAtomicInline(node.style) && + (node.style.display_explicit || !isInlineLevelTag(tagFromId(node.tag_id))); +} + // Box-tree projection for inline ancestors split by in-flow blocks. The DOM // ancestry still owns inheritance, events, relative translation, and painting. // Visible inline decorations require fragments; keep those on their own path. @@ -2597,7 +2606,7 @@ bool splitInlineWrapper(const Node *nodes, int id) if (child.computedStyle().float_side) { containsBlock = true; continue; } // A forced break splits the inline across line boxes. if (isLineBreak(child)) { containsBlock = true; continue; } - if (child.type == NodeType::View && (child.style.display_explicit || !isInlineLevelTag(tagFromId(child.tag_id)))) containsBlock = true; + if (blockLevelView(child)) containsBlock = true; if (splitInlineWrapper(nodes, c)) containsBlock = true; } return containsBlock; @@ -2616,7 +2625,7 @@ bool splitDecoratedInline(const Node *nodes, int id) for (int c = n.first_child; c >= 0; c = nodes[c].next_sibling) { const Node &child = nodes[c]; if (!isDisplayNone(child.computedStyle()) && !isOutOfFlowPosition(child.computedStyle().position) && !child.computedStyle().float_side && - child.type == NodeType::View && (child.style.display_explicit || !isInlineLevelTag(tagFromId(child.tag_id)))) return true; + blockLevelView(child)) return true; } return false; } @@ -3042,8 +3051,7 @@ class LayoutNodePass { bool blockBox(const Node &n) const { - return n.type == NodeType::View && !isDisplayNone(n.computedStyle()) && - (n.computedStyle().display_explicit || !isInlineLevelTag(tagFromId(n.tag_id))); + return !isDisplayNone(n.computedStyle()) && blockLevelView(n); } bool flowChild(int parent, int child) const @@ -4904,8 +4912,9 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod bool LayoutEngine::isInlineLevelNode(const Node &n) { - if (n.computedStyle().display == kDisplayFlex || n.computedStyle().display == kDisplayGrid || n.computedStyle().display == kDisplayNone) - return false; + if (n.computedStyle().display == kDisplayNone) return false; + if (isAtomicInline(n.computedStyle())) return true; + if (n.computedStyle().display == kDisplayFlex || n.computedStyle().display == kDisplayGrid) return false; // An explicit `display:block` makes even an inline-level tag (a ) — or a // Text/Image node — a BLOCK-LEVEL box, so it stacks vertically among siblings // instead of flowing into an inline row. Browsers do this; gea must too (e.g. @@ -5153,10 +5162,11 @@ void LayoutEngine::layoutNode(int id, int avail_w, int avail_h, bool intrinsicBo Node &node = Tree::instance().nodes()[id]; auto &persistent = layoutState(id); const Node *parent = node.parent >= 0 ? &Tree::instance().nodes()[node.parent] : nullptr; - // Horizontal non-replaced floats use the same min/max-content measurement - // and final used-width phase as fit-content. Floats on flex/grid items are - // ignored; orthogonal automatic inline sizing is handled separately. - const bool shrinkFloat = node.type == NodeType::View && node.computedStyle().float_side && + // Horizontal non-replaced floats and atomic inline boxes (inline-flex) use + // the same min/max-content measurement and final used-width phase as + // fit-content. Flex and grid items are blockified, so they are left alone; + // orthogonal automatic inline sizing is handled separately. + const bool shrinkFloat = node.type == NodeType::View && (node.computedStyle().float_side || isAtomicInline(node.computedStyle())) && !isOutOfFlowPosition(node.computedStyle().position) && (!parent || (parent->computedStyle().display != kDisplayFlex && !isDisplayGrid(parent->computedStyle()))) && node.computedStyle().width == kUnset && node.computedStyle().width_percent == kUnset && node.computedStyle().width_expression == -1 && diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 6ea1c39..dc2cf27 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -100,6 +100,7 @@ inline constexpr int kDisplayKindMask = 15; inline constexpr int kDisplayFlagShift = 4; inline constexpr int8_t kDisplayExplicit = 1; inline constexpr int8_t kDisplayFlowRoot = 2; // display: flow-root +inline constexpr int8_t kDisplayInline = 4; // inline-flex, inline-grid: an atomic inline-level box inline constexpr int kPositionFixed = 3; // position: sticky stays in flow; the absolute-coordinate pass shifts it. inline constexpr int kPositionSticky = 4; @@ -583,7 +584,7 @@ struct ComputedStyle { // inline-formatting heuristic distinguish an explicit `display:block` on an // inline-level tag (e.g. , which is block-level // and stacks) from a span's default inline behaviour. Mirrors - // flex_direction_explicit. Other bits: kDisplayFlowRoot. + // flex_direction_explicit. Other bits: kDisplayFlowRoot, kDisplayInline. #if GEA_CSS_DISPLAY_EXPLICIT uint8_t display_explicit : 3; #else @@ -1376,6 +1377,13 @@ inline bool isFlowRoot(const ComputedStyle &style) return style.display == kDisplayBlock && (style.display_explicit & kDisplayFlowRoot); } +// inline-flex and inline-grid sit on a line like an image, sized to their +// content. Floats and absolutely positioned boxes are blockified. +inline bool isAtomicInline(const ComputedStyle &style) +{ + return (style.display_explicit & kDisplayInline) && !style.float_side && !isOutOfFlowPosition(style.position); +} + inline bool usesRowLayout(const ComputedStyle &style) { // `flex-direction` applies to FLEX CONTAINERS, and to nothing else -- diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index ab55474..0586c18 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -5081,8 +5081,10 @@ int flexAlignValue(const std::string &raw) int displayValue(const std::string &value) { if (value == "none") return kDisplayNone; - if (value == "grid" || value == "inline-grid") return kDisplayGrid; - if (value == "flex" || value == "inline-flex") return kDisplayFlex; + if (value == "grid") return kDisplayGrid; + if (value == "inline-grid") return kDisplayGrid | (kDisplayInline << kDisplayFlagShift); + if (value == "flex") return kDisplayFlex; + if (value == "inline-flex") return kDisplayFlex | (kDisplayInline << kDisplayFlagShift); if (value == "flow-root") return kDisplayBlock | (kDisplayFlowRoot << kDisplayFlagShift); return kDisplayBlock; } diff --git a/packages/geatsc-plugin-gea/src/utils.ts b/packages/geatsc-plugin-gea/src/utils.ts index 53133b9..c7a921c 100644 --- a/packages/geatsc-plugin-gea/src/utils.ts +++ b/packages/geatsc-plugin-gea/src/utils.ts @@ -392,13 +392,16 @@ const STYLE_KEYWORD_PROPERTIES_BY_PROPERTY = new Map([ ['text-transform', ['TextTransform']], ]) +// As in the engine's displayValue(): keyword flags sit above the box kind, +// 32 for flow-root and 64 for an atomic inline-level box. const DISPLAY_KEYWORDS = new Map([ ['block', 0], ['none', 1], ['grid', 2], - ['inline-grid', 2], + ['inline-grid', 66], ['flex', 3], - ['inline-flex', 3], + ['inline-flex', 67], + ['flow-root', 32], ]) const FLEX_DIRECTION_KEYWORDS = new Map([ From 4e081d6256e697b986b37c047827f05ca7a40e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 14:56:55 +0300 Subject: [PATCH 33/50] Align absolute boxes on their static position in block containers An explicit align-self now centers or ends an absolutely positioned box on its block-level static position, a rectangle with no block size, and an explicit justify-self aligns it in the parent's content box, as CSS Align 3 describes. auto stays normal for such a box, so the parent's justify-items still does not move it. anchor-center behaves as center, since Gea has no anchor boxes. An inline-level absolute box that no line box placed, such as a span after a block and a float, now starts where its hypothetical line would, below the preceding block, instead of at the parent's top edge. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 34 ++++++++++++++++++++++++++++------ packages/engine/ui/style.cpp | 4 ++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index bb49902..2c52e99 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -4193,7 +4193,9 @@ class LayoutNodePass { Node &child = nodes_[id]; if (isDisplayNone(child.computedStyle())) return; if (isOutOfFlowPosition(child.computedStyle().position)) { - if (child.parent != id_ || !blockBox(child)) return; + // An inline-level box that no line box placed (inlineStaticPosition) + // starts a hypothetical line here, where a block-level one would. + if (child.parent != id_) return; const int edge = cursor + (first && collapsesWithChildren(id_, false) ? 0 : pending.value()); auto &persistent = layoutState(id); persistent.static_block_start = clampInt16(reversed ? node_.layout.width - edge : edge); @@ -4805,16 +4807,36 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod if (parent.computedStyle().display == kDisplayBlock && !parent.computedStyle().flex_direction_explicit && persistent.static_block_axis == (horizontal ? 2 : 1)) { const bool reversed = horizontal && gridAxisReversed(parent, true); - return persistent.static_block_start + (reversed ? -childNode.layout.width - childNode.computedStyle().margin[1] - : childNode.computedStyle().margin[horizontal ? 3 : 0]); + int offset = childNode.layout.static_block_start + (reversed ? -childNode.layout.width - childNode.style.margin[1] + : childNode.style.margin[horizontal ? 3 : 0]); + // A block-level static-position rectangle has no block size, so an + // explicit align-self centers or ends the margin box on it (CSS Align 3). + const int align = childNode.style.align_self; + if (!horizontal && align >= 0 && writingMode(parent) == 0) { + const int free = -(childNode.layout.height + childNode.style.margin[0] + childNode.style.margin[2]); + const int used = usedGridAlignment(parent, childNode, align, free, false, true); + if (used == 1) offset += free / 2; + else if (used == 2) offset += free; + } + return offset; } // In block layout the hypothetical box starts at the parent's inline // content edge. RTL anchors its inline-end margin edge, even when the absolute // box is wider than its static-position parent. Its actual containing // block can be an entirely different ancestor. - if (horizontal == (writingMode(parent) == 0) && parent.computedStyle().display == kDisplayBlock && - (!isInlineLevelNode(parent) || isOutOfFlowPosition(parent.computedStyle().position)) && - !parent.computedStyle().flex_direction_explicit) { + if (horizontal == (writingMode(parent) == 0) && parent.style.display == kDisplayBlock && + (!isInlineLevelNode(parent) || isOutOfFlowPosition(parent.style.position)) && + !parent.style.flex_direction_explicit) { + // An explicit justify-self aligns the margin box in the parent's content + // box, the static-position rectangle's inline extent. auto stays normal + // for an absolutely positioned box (CSS Align 3, 6.1). + const int justify = rstyle(childNode.computedStyle()).justify_self; + if (horizontal && justify >= 0 && writingMode(parent) == 0) { + const int free = parent.layout.width - boxInsets(parent.style, true) - childNode.layout.width - + childNode.style.margin[1] - childNode.style.margin[3]; + const int used = usedGridAlignment(parent, childNode, justify, free, true, true); + return boxInset(parent.style, 3) + childNode.style.margin[3] + (used == 1 ? free / 2 : used == 2 ? free : 0); + } return rightToLeft(parent) ? (horizontal ? parent.layout.width : parent.layout.height) - boxInset(parent.computedStyle(), horizontal ? 1 : 2) - (horizontal ? childNode.layout.width : childNode.layout.height) - childNode.computedStyle().margin[horizontal ? 1 : 2] diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 0586c18..54726ef 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -5124,6 +5124,9 @@ int selfAlignValue(const std::string &raw, bool physical = false) } if (position == "self-start") return kAlignSelfStart | overflow; if (position == "self-end") return kAlignSelfEnd | overflow; + // Without a default anchor box, anchor-center behaves as center (CSS Anchor + // Positioning); Gea has no anchors. + if (value == "anchor-center") return 1; if (physical && position == "left") return kAlignLeft | overflow; if (physical && position == "right") return kAlignRight | overflow; const int alignment = flexAlignValue(value); @@ -5138,6 +5141,7 @@ int justifySelfValue(const std::string &raw) const std::string value = toLowerAscii(trimCssValue(raw)); if (value == "auto") return -1; if (value == "normal" || value == "stretch") return 0; + if (value == "anchor-center") return 1; const int baseline = flexAlignValue(value); if (baseline == 5 || baseline == kAlignLastBaseline) return baseline; const auto words = splitFunctionAwareWords(value); From a310dbceb3a3c7fcc8b0a44c08c3107d29782358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 15:01:33 +0300 Subject: [PATCH 34/50] Lay out display: inline and inline-block display: inline made any element a block, even a span. It now makes it an inline box like a span, whatever the tag, and inline-block becomes an atomic inline-level BFC (inline plus flow-root): fit-content wide, on the line like inline-flex, painted as one group like a float (CSS 2.2 Appendix E) so its block children stay above its background. An inline-block takes its last line box's baseline. Blocks and inline boxes are searched for it in turn, collapsible white space and blocks without line boxes add none, and a clipping block stands for its bottom margin edge; without a line box, or when it clips its own overflow, it uses its bottom margin edge (CSS 2.2 10.8.1). Absolute children of an inline-block get block static positions, as in any block container. The static style compilers emit the same values (inline 64, inline-block 96). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/scripts/build-gea-vite-geatsc.mjs | 2 +- packages/engine/ui/layout.cpp | 70 ++++++++++++++----- packages/engine/ui/node_model.h | 18 +++-- packages/engine/ui/render.cpp | 4 +- packages/engine/ui/style.cpp | 2 + packages/geatsc-plugin-gea/src/utils.ts | 4 +- 6 files changed, 77 insertions(+), 23 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index 89bd49d..c77a925 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -2133,7 +2133,7 @@ function cssStaticPropertyValue(property, rawValue) { switch (property) { case 'display': { // Keyword flags above the box kind, as in the engine's displayValue(). - const values = new Map([['block', 0], ['none', 1], ['grid', 2], ['inline-grid', 66], ['flex', 3], ['inline-flex', 67], ['flow-root', 32]]) + const values = new Map([['block', 0], ['none', 1], ['grid', 2], ['inline-grid', 66], ['flex', 3], ['inline-flex', 67], ['flow-root', 32], ['inline', 64], ['inline-block', 96]]) return values.has(value) ? direct('Display', values.get(value)) : null } case 'box-sizing': diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 2c52e99..ed45ac0 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -798,6 +798,8 @@ int clampBorderSize(LayoutEngine &engine, const Node &node, int size, bool horiz } static bool isInlineLevelTag(const char *tag); +static bool inlineBoxView(const Node &n); +bool blockLevelView(const Node &node); class FlexLayoutPass { public: @@ -1461,7 +1463,7 @@ class FlexLayoutPass { const Node *nodes = Tree::instance().nodes(); const Node *n = &item; while (n->type == NodeType::View) { - if (n->style.display_explicit || isLineBreak(*n) || !isInlineLevelTag(tagFromId(n->tag_id))) return -1; + if (!inlineBoxView(*n) || isLineBreak(*n)) return -1; int c = last ? n->last_child : n->first_child; while (c >= 0 && (isDisplayNone(nodes[c].computedStyle()) || isOutOfFlowPosition(nodes[c].computedStyle().position) || nodes[c].computedStyle().float_side)) c = last ? nodes[c].prev_sibling : nodes[c].next_sibling; @@ -2468,10 +2470,40 @@ class FlexLayoutPass { descent += shift; } + // The baseline of a block container's last in-flow line box, or -1 without + // one. A block child without line boxes adds none; one that clips its + // overflow stands for its bottom margin edge. + int lastLineBaseline(const Node &block) const + { + const Node *nodes = Tree::instance().nodes(); + for (int c = block.last_child; c >= 0; c = nodes[c].prev_sibling) { + const Node &inner = nodes[c]; + if (isDisplayNone(inner.computedStyle()) || isOutOfFlowPosition(inner.computedStyle().position) || inner.computedStyle().float_side || + collapsibleSpaceOnly(inner)) continue; + // Blocks and inline boxes are searched in turn; atomic boxes and text + // sit on a line of this container. + if (inner.type == NodeType::View && inner.computedStyle().display == kDisplayBlock && !isAtomicInline(inner.computedStyle())) { + if (blockLevelView(inner) && overflowEstablishesContext(inner.computedStyle())) + return inner.layout.y + inner.layout.height + inner.computedStyle().margin[2]; + const int line = lastLineBaseline(inner); + if (line >= 0) return inner.layout.y + line; + continue; + } + return inner.layout.y + childBaseline(inner, true); + } + return -1; + } + int childBaseline(const Node &childNode, bool last = false) const { if ((childNode.type == NodeType::Text || isLineBreak(childNode)) && childNode.computedStyle().font_id >= 0) return TextRenderer::baselineOffset(childNode, last); + // An inline-block takes its last line box's baseline, or its bottom + // margin edge when it has none or clips its overflow (CSS 2.2 10.8.1). + if (childNode.type == NodeType::View && childNode.computedStyle().display == kDisplayBlock && isAtomicInline(childNode.computedStyle())) { + const int line = overflowEstablishesContext(childNode.computedStyle()) ? -1 : lastLineBaseline(childNode); + return line >= 0 ? line : childNode.layout.height + childNode.computedStyle().margin[2]; + } // An inline wrapper (a around text) carries no text of its own, so // CSS takes the baseline of its FIRST in-flow line box. Its children were // already positioned by measureChildren, and layout.y is still parent- @@ -2483,7 +2515,8 @@ class FlexLayoutPass { const Node *nodes = Tree::instance().nodes(); for (int c = last ? childNode.last_child : childNode.first_child; c >= 0; c = last ? nodes[c].prev_sibling : nodes[c].next_sibling) { const Node &inner = nodes[c]; - if (isDisplayNone(inner.computedStyle()) || isOutOfFlowPosition(inner.computedStyle().position)) continue; + // Collapsible white space alone makes no line box, so no baseline. + if (isDisplayNone(inner.computedStyle()) || isOutOfFlowPosition(inner.computedStyle().position) || collapsibleSpaceOnly(inner)) continue; return inner.layout.y + childBaseline(inner, last); } } @@ -2546,6 +2579,13 @@ static bool isInlineLevelTag(const char *tag) std::strcmp(tag, "mark") == 0; } +// A view that is an inline box: an authored display: inline, or an inline-level +// tag with no authored display. +static bool inlineBoxView(const Node &n) +{ + return n.type == NodeType::View && (n.computedStyle().display_explicit ? isInlineBoxDisplay(n.computedStyle()) : isInlineLevelTag(tagFromId(n.tag_id))); +} + // A block container with a column count or width (CSS Multi-column 2). // Spanning elements (column-span: all) split the columns into sets, which // this layout does not do: such a container keeps a single column. @@ -2577,12 +2617,11 @@ bool emptyInlineBox(const Node &node) return !boxInsets(node.computedStyle(), true) && !boxInsets(node.computedStyle(), false) && FlexLayoutPass::paintsNoBox(node); } -// A view that is block-level in its parent: an explicit display or a -// block-level tag, unless it is an atomic inline-level box (inline-flex). +// A view that is block-level in its parent: neither an inline box nor an +// atomic inline-level box (inline-block, inline-flex). bool blockLevelView(const Node &node) { - return node.type == NodeType::View && !isAtomicInline(node.style) && - (node.style.display_explicit || !isInlineLevelTag(tagFromId(node.tag_id))); + return node.type == NodeType::View && !isAtomicInline(node.computedStyle()) && !inlineBoxView(node); } // Box-tree projection for inline ancestors split by in-flow blocks. The DOM @@ -2591,8 +2630,7 @@ bool blockLevelView(const Node &node) bool splitInlineWrapper(const Node *nodes, int id) { const Node &n = nodes[id]; - if (n.type != NodeType::View || n.computedStyle().display_explicit || n.computedStyle().display != kDisplayBlock || - !isInlineLevelTag(tagFromId(n.tag_id)) || n.computedStyle().float_side || isOutOfFlowPosition(n.computedStyle().position) || + if (!inlineBoxView(n) || n.computedStyle().float_side || isOutOfFlowPosition(n.computedStyle().position) || !FlexLayoutPass::paintsNoBox(n) || boxInsets(n.computedStyle(), true) || boxInsets(n.computedStyle(), false) || n.computedStyle().margin[1] || n.computedStyle().margin[3] || n.computedStyle().opacity != 255 || overflowEstablishesContext(n.computedStyle())) return false; bool containsBlock = false; @@ -4183,7 +4221,7 @@ class LayoutNodePass { for (int c = node_.first_child; c >= 0; c = nodes_[c].next_sibling) if (isOutOfFlowPosition(nodes_[c].computedStyle().position)) layoutState(c).static_block_axis = 0; if (node_.computedStyle().display != kDisplayBlock || node_.computedStyle().flex_direction_explicit || - (LayoutEngine::isInlineLevelNode(node_) && !isOutOfFlowPosition(node_.computedStyle().position))) return; + (LayoutEngine::isInlineLevelNode(node_) && !isAtomicInline(node_.computedStyle()) && !isOutOfFlowPosition(node_.computedStyle().position))) return; const bool vertical = writingMode(node_) != 0; const bool reversed = vertical && gridAxisReversed(node_, true); int cursor = boxInset(node_.computedStyle(), blockMarginSide(false)); @@ -4824,9 +4862,9 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod // content edge. RTL anchors its inline-end margin edge, even when the absolute // box is wider than its static-position parent. Its actual containing // block can be an entirely different ancestor. - if (horizontal == (writingMode(parent) == 0) && parent.style.display == kDisplayBlock && - (!isInlineLevelNode(parent) || isOutOfFlowPosition(parent.style.position)) && - !parent.style.flex_direction_explicit) { + if (horizontal == (writingMode(parent) == 0) && parent.computedStyle().display == kDisplayBlock && + (!isInlineLevelNode(parent) || isAtomicInline(parent.computedStyle()) || isOutOfFlowPosition(parent.computedStyle().position)) && + !parent.computedStyle().flex_direction_explicit) { // An explicit justify-self aligns the margin box in the parent's content // box, the static-position rectangle's inline extent. auto stays normal // for an absolutely positioned box (CSS Align 3, 6.1). @@ -4943,14 +4981,14 @@ bool LayoutEngine::isInlineLevelNode(const Node &n) // the weather city list's .city-name/.city-detail spans set display:block to // stack the name over the region). Without an explicit display, fall back to // the tag's intrinsic inline-ness. - if (n.computedStyle().display == kDisplayBlock && n.computedStyle().display_explicit) return false; + if (n.computedStyle().display == kDisplayBlock && n.computedStyle().display_explicit && !isInlineBoxDisplay(n.computedStyle())) return false; // In Gea's native layout model, vertical margins on inline-level wrappers are // used as an authoring signal that the wrapper occupies its own line. Keep // default spans inline, but do not merge title/score spans with margin-bottom // into one row. if (n.computedStyle().margin[0] != 0 || n.computedStyle().margin[2] != 0) return false; if (n.type == NodeType::Text || n.type == NodeType::Image) return true; - return isInlineLevelTag(tagFromId(n.tag_id)); + return inlineBoxView(n); } // Mirror of startsFormattingLine: walk forward through siblings and inline @@ -4999,14 +5037,14 @@ bool LayoutEngine::endsFormattingLine(int id) bool LayoutEngine::isCssInlineLevelBox(const Node &n, bool hypothetical) { - if (n.computedStyle().display == kDisplayNone || n.computedStyle().display_explicit) return false; + if (n.computedStyle().display == kDisplayNone || (n.computedStyle().display_explicit && !isInlineBoxDisplay(n.computedStyle()))) return false; if (!hypothetical && (n.computedStyle().float_side || isOutOfFlowPosition(n.computedStyle().position))) return false; if (!hypothetical && n.parent >= 0) { const Node &parent = Tree::instance().nodes()[n.parent]; if (parent.computedStyle().display == kDisplayFlex || isDisplayGrid(parent.computedStyle())) return false; } if (n.type == NodeType::Text || n.type == NodeType::Image) return true; - return n.type == NodeType::View && isInlineLevelTag(tagFromId(n.tag_id)); + return inlineBoxView(n); } int LayoutEngine::clampSize(int size, int minSize, int maxSize) const diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index dc2cf27..22de47d 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -100,7 +100,9 @@ inline constexpr int kDisplayKindMask = 15; inline constexpr int kDisplayFlagShift = 4; inline constexpr int8_t kDisplayExplicit = 1; inline constexpr int8_t kDisplayFlowRoot = 2; // display: flow-root -inline constexpr int8_t kDisplayInline = 4; // inline-flex, inline-grid: an atomic inline-level box +// display: inline on a block kind; with kDisplayFlowRoot it is inline-block, and +// on a flex or grid kind inline-flex or inline-grid. Those three are atomic. +inline constexpr int8_t kDisplayInline = 4; inline constexpr int kPositionFixed = 3; // position: sticky stays in flow; the absolute-coordinate pass shifts it. inline constexpr int kPositionSticky = 4; @@ -1377,11 +1379,19 @@ inline bool isFlowRoot(const ComputedStyle &style) return style.display == kDisplayBlock && (style.display_explicit & kDisplayFlowRoot); } -// inline-flex and inline-grid sit on a line like an image, sized to their -// content. Floats and absolutely positioned boxes are blockified. +// inline-block, inline-flex and inline-grid sit on a line like an image, sized +// to their content. Floats and absolutely positioned boxes are blockified. inline bool isAtomicInline(const ComputedStyle &style) { - return (style.display_explicit & kDisplayInline) && !style.float_side && !isOutOfFlowPosition(style.position); + return (style.display_explicit & kDisplayInline) && (style.display != kDisplayBlock || (style.display_explicit & kDisplayFlowRoot)) && + !style.float_side && !isOutOfFlowPosition(style.position); +} + +// An authored display: inline, which makes any element an inline box like a +// . +inline bool isInlineBoxDisplay(const ComputedStyle &style) +{ + return style.display == kDisplayBlock && (style.display_explicit & (kDisplayInline | kDisplayFlowRoot)) == kDisplayInline; } inline bool usesRowLayout(const ComputedStyle &style) diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 1c328ff..1c26e48 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -11091,7 +11091,9 @@ int gLastScrollUiFrame = -1000; const auto &n = nodes[id]; const bool item = n.parent >= 0 && (nodes[n.parent].computedStyle().display == kDisplayFlex || isDisplayGrid(nodes[n.parent].computedStyle())); - return isContext(id) || n.computedStyle().position != 0 || n.computedStyle().float_side || item; + // An inline-block, inline-flex or inline-grid paints atomically, like a + // float (CSS 2.2 Appendix E). + return isContext(id) || n.computedStyle().position != 0 || n.computedStyle().float_side || item || isAtomicInline(n.computedStyle()); } std::vector PaintOrder::collectChildren(int root, bool groupRoot, bool includePositioned) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 54726ef..cbce225 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -5086,6 +5086,8 @@ int displayValue(const std::string &value) if (value == "flex") return kDisplayFlex; if (value == "inline-flex") return kDisplayFlex | (kDisplayInline << kDisplayFlagShift); if (value == "flow-root") return kDisplayBlock | (kDisplayFlowRoot << kDisplayFlagShift); + if (value == "inline") return kDisplayBlock | (kDisplayInline << kDisplayFlagShift); + if (value == "inline-block") return kDisplayBlock | ((kDisplayInline | kDisplayFlowRoot) << kDisplayFlagShift); return kDisplayBlock; } diff --git a/packages/geatsc-plugin-gea/src/utils.ts b/packages/geatsc-plugin-gea/src/utils.ts index c7a921c..26e3640 100644 --- a/packages/geatsc-plugin-gea/src/utils.ts +++ b/packages/geatsc-plugin-gea/src/utils.ts @@ -393,7 +393,7 @@ const STYLE_KEYWORD_PROPERTIES_BY_PROPERTY = new Map([ ]) // As in the engine's displayValue(): keyword flags sit above the box kind, -// 32 for flow-root and 64 for an atomic inline-level box. +// 32 for flow-root and 64 for inline (both together: inline-block). const DISPLAY_KEYWORDS = new Map([ ['block', 0], ['none', 1], @@ -402,6 +402,8 @@ const DISPLAY_KEYWORDS = new Map([ ['flex', 3], ['inline-flex', 67], ['flow-root', 32], + ['inline', 64], + ['inline-block', 96], ]) const FLEX_DIRECTION_KEYWORDS = new Map([ From bb2d2148a66fa1e54ef007a9366bb22c90addc1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 16:55:54 +0300 Subject: [PATCH 35/50] Align absolute boxes between insets and on their static rectangle CSS Align 3 / Position 3: an absolutely positioned box with both insets of an axis set and an explicit align-self/justify-self is aligned within the inset-modified containing block instead of taking the start inset. Static-position alignment now covers vertical writing modes (align-self in the block axis, justify-self in the inline axis, reversed block axis starting at the far edge), and justify-self aligns around the zero-width inline static position. A block-level absolute box after content inside an inline box starts on the next line, as the block it would be. Absolute children of inline boxes split across lines now get line-based static positions, and the node that recorded the position is kept so a relatively positioned span subtracts its own offset. Centering rounds half pixels up. WPT: align-self-static-position-001 and -002 pass (537 pass / 54 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 174 ++++++++++++++++++++++---------- packages/engine/ui/tree_state.h | 4 + 2 files changed, 127 insertions(+), 51 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index ed45ac0..fda3d7f 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -760,6 +760,27 @@ int usedGridAlignment(const Node &parent, const Node &child, int alignment, int return usedAlignment(alignment, free, gridAxisReversed(parent, horizontal)); } +// Half of a free space, rounded half up like a fractional edge snapped to the +// nearest pixel (12.5 -> 13, -12.5 -> -12). +int roundedHalf(int free) { return free >= -1 ? (free + 1) / 2 : -(-free / 2); } + +// An absolute box with both insets of an axis set and an explicit, non-normal +// self-alignment places its margin box inside the inset-modified containing +// block (CSS Position 3, 4.4; CSS Align 3). offset is measured from the +// containing area's start. Auto margins, normal and stretch keep the CSS 2 +// placement from the start inset. +bool insetAlignedOffset(const Node &containing, const Node &absolute, bool horizontal, int areaSize, int start, int end, int &offset) +{ + // justify-self works in the containing block's inline axis, align-self in its block axis. + const int align = horizontal == (writingMode(containing) == 0) ? rstyle(absolute.computedStyle()).justify_self : absolute.computedStyle().align_self; + if (align <= 0 || (absolute.computedStyle().margin_auto & (horizontal ? 10 : 5))) return false; + const int before = absolute.computedStyle().margin[horizontal ? 3 : 0], after = absolute.computedStyle().margin[horizontal ? 1 : 2]; + const int free = areaSize - start - end - (horizontal ? absolute.layout.width : absolute.layout.height) - before - after; + const int used = usedGridAlignment(containing, absolute, align, free, horizontal); + offset = start + before + (used == 1 ? roundedHalf(free) : used == 2 ? free : 0); + return true; +} + void gridPhysicalRange(const Node &parent, bool columns, int start, int end, int &offset, int &size) { const bool horizontal = columns == (writingMode(parent) == 0); @@ -800,6 +821,7 @@ int clampBorderSize(LayoutEngine &engine, const Node &node, int size, bool horiz static bool isInlineLevelTag(const char *tag); static bool inlineBoxView(const Node &n); bool blockLevelView(const Node &node); +bool splitInlineWrapper(const Node *nodes, int id); class FlexLayoutPass { public: @@ -1002,17 +1024,29 @@ class FlexLayoutPass { #endif std::vector> staticBoundaries; int flowIndex = 0; - for (int child = node_.first_child; child >= 0; child = nodes[child].next_sibling) { - if (isDisplayNone(nodes[child].computedStyle()) || suppressAnonymousWhitespace(nodes, parentId, child)) continue; - if (isOutOfFlowPosition(nodes[child].computedStyle().position)) { - if (auto *rare = rareDataFor(child)) rare->inlineStaticPosition.valid = false; - if (LayoutEngine::isCssInlineLevelBox(nodes[child], true)) - staticBoundaries.emplace_back(flowIndex, child); - continue; + // Split inline wrappers lend their children to this flow, out-of-flow + // ones included. Inside an inline box block-level boxes take static + // positions from the lines too, as it has no block layout of its own. + const bool inlineBox = LayoutEngine::isCssInlineLevelBox(node_) && LayoutEngine::isInlineLevelNode(node_); + auto collectStaticBoundaries = [&](auto &&self, int parent) -> void { + for (int child = nodes[parent].first_child; child >= 0; child = nodes[child].next_sibling) { + if (isDisplayNone(nodes[child].computedStyle()) || suppressAnonymousWhitespace(nodes, parent, child)) continue; + if (isOutOfFlowPosition(nodes[child].computedStyle().position)) { + if (auto *rare = rareDataFor(child)) rare->inlineStaticPosition.valid = false; + if (parent != parentId || inlineBox || LayoutEngine::isCssInlineLevelBox(nodes[child], true)) + staticBoundaries.emplace_back(flowIndex, child); + continue; + } + if (splitInlineWrapper(nodes, child) && + std::find(children_, children_ + childCount_, child) == children_ + childCount_) { + self(self, child); + continue; + } + while (flowIndex < childCount_ && children_[flowIndex] != child) ++flowIndex; + if (flowIndex < childCount_) ++flowIndex; } - while (flowIndex < childCount_ && children_[flowIndex] != child) ++flowIndex; - if (flowIndex < childCount_) ++flowIndex; - } + }; + collectStaticBoundaries(collectStaticBoundaries, parentId); int contentW = mainAvail_ < 0 ? 0 : mainAvail_; if (intrinsicWidthConstraint(node_) == 1) { int pendingWord = 0; @@ -1058,6 +1092,8 @@ class FlexLayoutPass { continuationLine && LayoutEngine::isCssInlineLevelBox(node_); rare.inlineStaticPosition.x = clampInt16( (rare.inlineStaticPosition.continuationLine ? 0 : contentLeft) + pen); + rare.inlineStaticPosition.afterContent = pen > 0 || continuationLine; + rare.inlineStaticPosition.flowNode = static_cast(parentId); } }; @@ -4409,10 +4445,19 @@ class LayoutNodePass { { int x, y, width, height; LayoutEngine::absoluteContainingArea(node_, childNode, x, y, width, height); - if (hasPositionOffset<3>(childNode)) childNode.layout.x = x + resolvedPositionOffsetWithBasis<3>(childNode, width) + childNode.computedStyle().margin[3]; + int offset = 0; + if (hasPositionOffset<3>(childNode) && hasPositionOffset<1>(childNode) && + insetAlignedOffset(node_, childNode, true, width, resolvedPositionOffsetWithBasis<3>(childNode, width), + resolvedPositionOffsetWithBasis<1>(childNode, width), offset)) + childNode.layout.x = x + offset; + else if (hasPositionOffset<3>(childNode)) childNode.layout.x = x + resolvedPositionOffsetWithBasis<3>(childNode, width) + childNode.computedStyle().margin[3]; else if (hasPositionOffset<1>(childNode)) childNode.layout.x = x + width - childNode.layout.width - resolvedPositionOffsetWithBasis<1>(childNode, width) - childNode.computedStyle().margin[1]; else childNode.layout.x = alignedAbsoluteChildPosition(childNode, true); - if (hasPositionOffset<0>(childNode)) childNode.layout.y = y + resolvedPositionOffsetWithBasis<0>(childNode, height) + childNode.computedStyle().margin[0]; + if (hasPositionOffset<0>(childNode) && hasPositionOffset<2>(childNode) && + insetAlignedOffset(node_, childNode, false, height, resolvedPositionOffsetWithBasis<0>(childNode, height), + resolvedPositionOffsetWithBasis<2>(childNode, height), offset)) + childNode.layout.y = y + offset; + else if (hasPositionOffset<0>(childNode)) childNode.layout.y = y + resolvedPositionOffsetWithBasis<0>(childNode, height) + childNode.computedStyle().margin[0]; else if (hasPositionOffset<2>(childNode)) childNode.layout.y = y + height - childNode.layout.height - resolvedPositionOffsetWithBasis<2>(childNode, height) - childNode.computedStyle().margin[2]; else childNode.layout.y = alignedAbsoluteChildPosition(childNode, false); } @@ -4665,15 +4710,23 @@ void positionAbsoluteNodeInContainingBlock(int node, int containing, Node *nodes if (!hasPositionOffsetValue<0>(absolute) && !hasPositionOffsetValue<2>(absolute)) absolute.layout.y = LayoutEngine::alignedAbsoluteOffset(nodes[parent], absolute, false, &containingNode, areaY - parentOffsetY, areaHeight); - int containingX = absolute.layout.x + parentOffsetX; - if (hasPositionOffsetValue<3>(absolute)) + int containingX = absolute.layout.x + parentOffsetX, offset = 0; + if (hasPositionOffsetValue<3>(absolute) && hasPositionOffsetValue<1>(absolute) && + insetAlignedOffset(containingNode, absolute, true, areaWidth, resolvedPositionOffsetForBasis<3>(absolute, areaWidth), + resolvedPositionOffsetForBasis<1>(absolute, areaWidth), offset)) + containingX = areaX + offset; + else if (hasPositionOffsetValue<3>(absolute)) containingX = areaX + resolvedPositionOffsetForBasis<3>(absolute, areaWidth) + absolute.computedStyle().margin[3]; else if (hasPositionOffsetValue<1>(absolute)) containingX = areaX + areaWidth - absolute.layout.width - resolvedPositionOffsetForBasis<1>(absolute, areaWidth) - absolute.computedStyle().margin[1]; int containingY = absolute.layout.y + parentOffsetY; - if (hasPositionOffsetValue<0>(absolute)) + if (hasPositionOffsetValue<0>(absolute) && hasPositionOffsetValue<2>(absolute) && + insetAlignedOffset(containingNode, absolute, false, areaHeight, resolvedPositionOffsetForBasis<0>(absolute, areaHeight), + resolvedPositionOffsetForBasis<2>(absolute, areaHeight), offset)) + containingY = areaY + offset; + else if (hasPositionOffsetValue<0>(absolute)) containingY = areaY + resolvedPositionOffsetForBasis<0>(absolute, areaHeight) + absolute.computedStyle().margin[0]; else if (hasPositionOffsetValue<2>(absolute)) containingY = areaY + areaHeight - absolute.layout.height - @@ -4812,31 +4865,51 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod const Node *treeNodes = Tree::instance().nodes(); const int childId = static_cast(&childNode - treeNodes); if (const auto *rare = rareDataFor(childId); rare && rare->inlineStaticPosition.valid) { - int continuationOrigin = 0; - if (horizontal && rare->inlineStaticPosition.continuationLine) { - // Resolve fragmented inline origins here, after ancestor layout has - // finalized their first-fragment x positions. The continuation line - // starts at the nearest formatting block's content edge, not at the - // inline parent's first-fragment padding edge. - int inlineOffset = 0; - int cursor = static_cast(&parent - treeNodes); - while (cursor >= 0 && isCssInlineLevelBox(treeNodes[cursor])) { - inlineOffset += treeNodes[cursor].layout.x; - cursor = treeNodes[cursor].parent; + const auto &position = rare->inlineStaticPosition; + // A block-level box that a line placed stands where the block it would be + // starts: at the next line box when content precedes it on its line + // (CSS 2.2 10.3.7). + const bool nextLine = blockLevelView(childNode) && position.afterContent && writingMode(parent) == 0; + // A box inside a split inline wrapper was placed by an ancestor's lines; + // the wrappers in between may have moved to their fragment rectangles. + int dx = 0, dy = 0, cursor = static_cast(&parent - treeNodes); + for (; cursor >= 0 && cursor != position.flowNode; cursor = treeNodes[cursor].parent) { + dx += treeNodes[cursor].layout.x; + dy += treeNodes[cursor].layout.y; + } + if (cursor < 0) dx = dy = 0; + if (horizontal) { + int x = position.x - dx; + if (position.continuationLine || nextLine) { + // Resolve fragmented inline origins here, after ancestor layout has + // finalized their first-fragment x positions. A new line starts at + // the nearest formatting block's content edge, not at the inline + // parent's first-fragment padding edge. + int inlineOffset = 0; + int cursor = static_cast(&parent - treeNodes); + while (cursor >= 0 && isCssInlineLevelBox(treeNodes[cursor])) { + inlineOffset += treeNodes[cursor].layout.x; + cursor = treeNodes[cursor].parent; + } + const int origin = cursor >= 0 ? boxInset(treeNodes[cursor].computedStyle(), 3) - inlineOffset : 0; + x = (nextLine ? 0 : position.x) + origin; } - if (cursor >= 0) continuationOrigin = boxInset(treeNodes[cursor].computedStyle(), 3) - inlineOffset; + // The static-position rectangle has no inline size, so an explicit + // justify-self centers or ends the margin box on it. + const int justify = rstyle(childNode.computedStyle()).justify_self; + const int free = -(childNode.layout.width + childNode.computedStyle().margin[1] + childNode.computedStyle().margin[3]); + const int used = justify > 0 && writingMode(parent) == 0 ? usedGridAlignment(parent, childNode, justify, free, true, true) : 6; + return x + childNode.computedStyle().margin[3] + (used == 1 ? roundedHalf(free) : used == 2 ? free : 0); } - if (horizontal) return rare->inlineStaticPosition.x + continuationOrigin + childNode.style.margin[3]; // The static-position rectangle spans the line box's block extent, so an // explicit align-self aligns within it (CSS Position 3, 4.1). - int y = rare->inlineStaticPosition.y + childNode.style.margin[0]; - const int align = childNode.style.align_self; + int y = position.y - dy + (nextLine ? position.lineHeight : 0) + childNode.computedStyle().margin[0]; + const int align = childNode.computedStyle().align_self; if (align >= 0 && writingMode(parent) == 0) { - const int free = rare->inlineStaticPosition.lineHeight - childNode.layout.height - + const int free = position.lineHeight - childNode.layout.height - childNode.computedStyle().margin[0] - childNode.computedStyle().margin[2]; - const int self = physicalSelfAlignment(parent, childNode, align, free, false); - const int used = self >= 0 ? self : usedAlignment(align, free); - if (used == 1) y += free / 2; + const int used = usedGridAlignment(parent, childNode, align, free, false, true); + if (used == 1) y += roundedHalf(free); else if (used == 2) y += free; } return y; @@ -4844,19 +4917,16 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod const auto &persistent = layoutState(childId); if (parent.computedStyle().display == kDisplayBlock && !parent.computedStyle().flex_direction_explicit && persistent.static_block_axis == (horizontal ? 2 : 1)) { + // A block-level static-position rectangle has no block size: the margin + // box starts at it, or with an explicit align-self centers or ends on it + // (CSS Align 3). A reversed block axis (vertical-rl) starts on the right. const bool reversed = horizontal && gridAxisReversed(parent, true); - int offset = childNode.layout.static_block_start + (reversed ? -childNode.layout.width - childNode.style.margin[1] - : childNode.style.margin[horizontal ? 3 : 0]); - // A block-level static-position rectangle has no block size, so an - // explicit align-self centers or ends the margin box on it (CSS Align 3). - const int align = childNode.style.align_self; - if (!horizontal && align >= 0 && writingMode(parent) == 0) { - const int free = -(childNode.layout.height + childNode.style.margin[0] + childNode.style.margin[2]); - const int used = usedGridAlignment(parent, childNode, align, free, false, true); - if (used == 1) offset += free / 2; - else if (used == 2) offset += free; - } - return offset; + const int free = -((horizontal ? childNode.layout.width : childNode.layout.height) + + childNode.computedStyle().margin[horizontal ? 3 : 0] + childNode.computedStyle().margin[horizontal ? 1 : 2]); + const int align = childNode.computedStyle().align_self; + const int used = align >= 0 ? usedGridAlignment(parent, childNode, align, free, horizontal, true) : reversed ? 2 : 6; + return persistent.static_block_start + childNode.computedStyle().margin[horizontal ? 3 : 0] + + (used == 1 ? roundedHalf(free) : used == 2 ? free : 0); } // In block layout the hypothetical box starts at the parent's inline // content edge. RTL anchors its inline-end margin edge, even when the absolute @@ -4869,11 +4939,13 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod // box, the static-position rectangle's inline extent. auto stays normal // for an absolutely positioned box (CSS Align 3, 6.1). const int justify = rstyle(childNode.computedStyle()).justify_self; - if (horizontal && justify >= 0 && writingMode(parent) == 0) { - const int free = parent.layout.width - boxInsets(parent.style, true) - childNode.layout.width - - childNode.style.margin[1] - childNode.style.margin[3]; - const int used = usedGridAlignment(parent, childNode, justify, free, true, true); - return boxInset(parent.style, 3) + childNode.style.margin[3] + (used == 1 ? free / 2 : used == 2 ? free : 0); + if (justify >= 0) { + const int free = (horizontal ? parent.layout.width : parent.layout.height) - boxInsets(parent.computedStyle(), horizontal) - + (horizontal ? childNode.layout.width : childNode.layout.height) - + childNode.computedStyle().margin[horizontal ? 3 : 0] - childNode.computedStyle().margin[horizontal ? 1 : 2]; + const int used = usedGridAlignment(parent, childNode, justify, free, horizontal, true); + return boxInset(parent.computedStyle(), horizontal ? 3 : 0) + childNode.computedStyle().margin[horizontal ? 3 : 0] + + (used == 1 ? roundedHalf(free) : used == 2 ? free : 0); } return rightToLeft(parent) ? (horizontal ? parent.layout.width : parent.layout.height) - boxInset(parent.computedStyle(), horizontal ? 1 : 2) - diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index c7e3c79..801fbb9 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -116,7 +116,11 @@ struct InlineStaticPosition { // Block extent of the line box that holds the static position; 0 when the // box is anchored to a line that has not been laid out yet. std::int16_t lineHeight = 0; + // The node whose line layout recorded x and y; they are relative to it. + std::int16_t flowNode = -1; bool continuationLine = false; + // Line content precedes the static position on its line. + bool afterContent = false; bool valid = false; }; From 4150dbe982c1f3cbe2e39c292d81f59ab797c077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 16:56:03 +0300 Subject: [PATCH 36/50] Apply center/end align-content to fixed-height block containers CSS Align 3: align-content center or end on a block container whose content box is taller than its content moves the in-flow content down; with no in-flow content, the static positions of its absolute children move instead. Applied to horizontal-tb display:block containers with a definite height or min-height that are not scroll containers, inline boxes or multicol containers. The shift is recomputed from the content's current top, so repeated layout passes are idempotent. WPT: align-out-of-flow-only-content passes (538 pass / 53 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 38 ++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index fda3d7f..6e5f5ca 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -4260,7 +4260,7 @@ class LayoutNodePass { (LayoutEngine::isInlineLevelNode(node_) && !isAtomicInline(node_.computedStyle()) && !isOutOfFlowPosition(node_.computedStyle().position))) return; const bool vertical = writingMode(node_) != 0; const bool reversed = vertical && gridAxisReversed(node_, true); - int cursor = boxInset(node_.computedStyle(), blockMarginSide(false)); + int cursor = boxInset(node_.computedStyle(), blockMarginSide(false)) + contentShift_; bool first = true; CollapsedMargin pending; visitFormattingChildren(nodes_, id_, [&](int id) { @@ -4289,8 +4289,44 @@ class LayoutNodePass { }); } + // CSS Align 3: center or end align-content on a block container moves its + // in-flow content down when its content box is taller; with no content, the + // static positions of its absolute children move. Recomputed from the + // content's current top, so a second call leaves it where it is. + int contentShift_ = 0; + void alignBlockContent() + { + contentShift_ = 0; + const int keyword = node_.computedStyle().align_content & 15; + const bool center = keyword == 1 || keyword == 4 || keyword == kAlignSpaceEvenly, end = keyword == 2 || keyword == kAlignEnd; + // An automatic height already fits the content, floats included. + if ((!center && !end) || (!hasExplicitHeight(node_) && node_.computedStyle().min_height <= 0) || + node_.computedStyle().display != kDisplayBlock || node_.computedStyle().flex_direction_explicit || + inlineBoxView(node_) || writingMode(node_) != 0 || overflowEstablishesContext(node_.computedStyle()) || isMulticolContainer(node_)) return; + const int top = boxInset(node_.computedStyle(), 0); + int first = INT_MAX, last = top; + for (int c = node_.first_child; c >= 0; c = nodes_[c].next_sibling) { + const Node &child = nodes_[c]; + if (isDisplayNone(child.computedStyle()) || isOutOfFlowPosition(child.computedStyle().position) || suppressAnonymousWhitespace(nodes_, id_, c)) continue; + first = std::min(first, child.layout.y - child.computedStyle().margin[0]); + last = std::max(last, child.layout.y + child.layout.height + child.computedStyle().margin[2]); + } + if (first == INT_MAX) first = top; + const int free = node_.layout.height - boxInsets(node_.computedStyle(), false) - (last - first); + if (free <= 0) return; + contentShift_ = end ? free : roundedHalf(free); + const int delta = top + contentShift_ - first; + if (!delta) return; + for (int c = node_.first_child; c >= 0; c = nodes_[c].next_sibling) { + Node &child = nodes_[c]; + if (isDisplayNone(child.computedStyle()) || isOutOfFlowPosition(child.computedStyle().position)) continue; + child.layout.y = clampInt16(child.layout.y + delta); + } + } + void positionAbsoluteChildren() { + alignBlockContent(); captureBlockStaticPositions(); for (int child = node_.first_child; child >= 0; child = nodes_[child].next_sibling) { Node &childNode = nodes_[child]; From 35f9cbeab49ebe90049b9d348471881fe3280555 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 17:25:02 +0300 Subject: [PATCH 37/50] Place inline-level absolute boxes on a hypothetical line in block flow An inline-level absolutely positioned box whose parent lays out only block-level children has no line box to take its static position from. It now opens a hypothetical empty line at its block position: preceding floats shorten that line and text-align places the static position on it. In a right-to-left block the box's right margin edge meets the position (CSS 2.2 10.3.7). Positions recorded by laid-out lines keep anchoring the box's left edge: those lines keep their items in left-to-right order, so the content that follows the box is on its right. WPT: inline-level-absolute-in-block-level-context-001..012 pass (548 pass / 43 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 31 ++++++++++++++++++++++++++++++- packages/engine/ui/tree_state.h | 4 ++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 6e5f5ca..7b491e6 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1093,6 +1093,7 @@ class FlexLayoutPass { rare.inlineStaticPosition.x = clampInt16( (rare.inlineStaticPosition.continuationLine ? 0 : contentLeft) + pen); rare.inlineStaticPosition.afterContent = pen > 0 || continuationLine; + rare.inlineStaticPosition.anchorRight = false; rare.inlineStaticPosition.flowNode = static_cast(parentId); } }; @@ -4274,6 +4275,7 @@ class LayoutNodePass { auto &persistent = layoutState(id); persistent.static_block_start = clampInt16(reversed ? node_.layout.width - edge : edge); persistent.static_block_axis = vertical ? 2 : 1; + if (!vertical && LayoutEngine::isCssInlineLevelBox(child, true)) captureHypotheticalLine(id, edge); return; } if (child.computedStyle().float_side || suppressAnonymousWhitespace(nodes_, child.parent, id)) return; @@ -4289,6 +4291,32 @@ class LayoutNodePass { }); } + // An inline-level box that no line placed would open an empty line at edge: + // the floats before it shorten that line and text-align places the static + // position on it. In a right-to-left block the box's right edge meets it + // (CSS 2.2 10.3.7). + void captureHypotheticalLine(int id, int edge) + { + if (const NodeRareData *rare = rareDataFor(id); rare && rare->inlineStaticPosition.valid) return; + int left = boxInset(node_.computedStyle(), 3), right = node_.layout.width - boxInset(node_.computedStyle(), 1); + for (int f = node_.first_child; f >= 0 && f != id; f = nodes_[f].next_sibling) { + const Node &box = nodes_[f]; + if (!box.computedStyle().float_side || isDisplayNone(box.computedStyle()) || isOutOfFlowPosition(box.computedStyle().position)) continue; + if (edge < box.layout.y - box.computedStyle().margin[0] || edge >= box.layout.y + box.layout.height + box.computedStyle().margin[2]) continue; + if (box.computedStyle().float_side == 1) left = std::max(left, box.layout.x + box.layout.width + box.computedStyle().margin[1]); + else right = std::min(right, box.layout.x - box.computedStyle().margin[3]); + } + right = std::max(left, right); + const int align = LayoutEngine::physicalTextAlign(node_); + auto &position = ensureRareData(id).inlineStaticPosition; + position = InlineStaticPosition{}; + position.x = clampInt16(align == 1 ? left + (right - left) / 2 : align == 2 ? right : left); + position.y = clampInt16(edge); + position.flowNode = static_cast(id_); + position.anchorRight = rightToLeft(node_); + position.valid = true; + } + // CSS Align 3: center or end align-content on a block container moves its // in-flow content down when its content box is taller; with no content, the // static positions of its absolute children move. Recomputed from the @@ -4934,7 +4962,8 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod // justify-self centers or ends the margin box on it. const int justify = rstyle(childNode.computedStyle()).justify_self; const int free = -(childNode.layout.width + childNode.computedStyle().margin[1] + childNode.computedStyle().margin[3]); - const int used = justify > 0 && writingMode(parent) == 0 ? usedGridAlignment(parent, childNode, justify, free, true, true) : 6; + const int used = justify > 0 && writingMode(parent) == 0 ? usedGridAlignment(parent, childNode, justify, free, true, true) + : position.anchorRight ? 2 : 6; return x + childNode.computedStyle().margin[3] + (used == 1 ? roundedHalf(free) : used == 2 ? free : 0); } // The static-position rectangle spans the line box's block extent, so an diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index 801fbb9..06e9b9e 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -121,6 +121,10 @@ struct InlineStaticPosition { bool continuationLine = false; // Line content precedes the static position on its line. bool afterContent = false; + // The box's right margin edge meets x: an empty line in a right-to-left + // block. Laid-out lines keep their items in left-to-right order, so a box + // they placed starts at x. + bool anchorRight = false; bool valid = false; }; From cc3a55df104973e9f154d4e86a2b3981ac646943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 17:56:34 +0300 Subject: [PATCH 38/50] Size block flex items to their content in a flex row A sole text run is widened to the line it was offered so text-align has room to work in. When the block holding it then autosized to its content, the run kept that width, and the flex automatic minimum size, measured from the children's extents, grew the item back to the full line: every block item carrying one line of text filled its flex row. Widened runs now span the block's final content width. A document's root element fills the initial containing block whatever its display (CSS 2.2 10.3.3); a flex html root no longer shrinks to its items. Native roots keep sizing to their content. WPT: 548 pass / 43 fail, unchanged; the rows of clip-text-stacking-context-child-002 that depend on it now match. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 7b491e6..e6e2aa1 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -979,6 +979,7 @@ class FlexLayoutPass { { Node *nodes = Tree::instance().nodes(); const int parentId = static_cast(&node_ - nodes); + widenedRuns_.clear(); int firstLineStyleOwner = -1; #if GEA_CSS_FIRST_LINE for (int ancestor = parentId; ancestor >= 0; ancestor = nodes[ancestor].parent) { @@ -1422,6 +1423,11 @@ class FlexLayoutPass { if (!hasExplicitWidth(node_) && !keepScrollWidth) { const int width = maxLineWidth + boxInset(node_.computedStyle(), 1) + boxInset(node_.computedStyle(), 3); node_.layout.width = clampInt16(clampLayoutSize(node_, width, true)); + // A widened sole run spans the box's final content width instead, so + // its extent does not hold the box at the width it was offered. + const int content = node_.layout.width - boxInsets(node_.computedStyle(), true); + for (const WidenedRun &run : widenedRuns_) + nodes[run.id].layout.width = clampInt16(std::max(run.natural, content + run.extra)); } return extent; } @@ -1636,7 +1642,10 @@ class FlexLayoutPass { // text-align needs a box as wide as the line box to align inside. // A removed leading space sits before that box's content edge. const int full = contentW - marginL - marginR + lead; - if (full > cn.layout.width) cn.layout.width = clampInt16(full); + if (full > cn.layout.width) { + widenedRuns_.push_back({child, cn.layout.width, full - contentW}); + cn.layout.width = clampInt16(full); + } } int y = baseline - childBaseline(cn); @@ -1858,7 +1867,11 @@ class FlexLayoutPass { node_.layout.height = clampLayoutSize(node_, height, false); } - if (!hasExplicitWidth(node_) && !keepScrollWidth) { + // A document's root element is block-level in the initial containing + // block, so its automatic width fills it whatever its display (CSS 2.2 + // 10.3.3). A native root sizes to its content. + const bool fillsRoot = node_.parent < 0 && std::strcmp(tagFromId(node_.tag_id), "html") == 0; + if (!hasExplicitWidth(node_) && !keepScrollWidth && !fillsRoot) { const int width = (isRow_ ? maxMainSize(lineCount) : totalCrossSize(lineCount)) + boxInset(node_.computedStyle(), 1) + boxInset(node_.computedStyle(), 3); node_.layout.width = clampLayoutSize(node_, width, true); } @@ -2590,6 +2603,10 @@ class FlexLayoutPass { int padHeight_ = 0; FlexLine *lines_ = nullptr; std::vector extraLines_; + // Sole runs placeLineItems widened to the offered line: the run, its own + // width, and its widened width less the line's. + struct WidenedRun { int id, natural, extra; }; + std::vector widenedRuns_; int mainGap_ = 0; int crossGap_ = 0; static constexpr int kUnresolvedBasis = INT_MIN; From aea403993d323464d877868b15b22ccc38c8cfea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 18:10:36 +0300 Subject: [PATCH 39/50] Place a float after a one-line run at the top of that line A float that followed inline content started below the content's line. CSS 2.2 9.5.1 puts a float that fits beside the content of the current line at the top of that line, and the line's content flows beside it. When a run ends at a float without a line break, fits on one line and leaves room for the float, the float now moves ahead of the run: it is placed at the run's top and the run is laid out again beside it, taking in any content that follows the float. WPT: clip-text-stacking-context-child-002 passes (549 pass / 42 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index e6e2aa1..8277562 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3394,7 +3394,9 @@ class LayoutNodePass { const double bottom = cursor + drift + exact; const int height = static_cast(std::floor(bottom + 0.5)) - cursor; if (height >= 0 && height != child.layout.height) { +#if GEA_CSS_SCROLLING if (child.layout.scroll_content_height == child.layout.height) child.layout.scroll_content_height = clampInt16(height); +#endif child.layout.height = clampInt16(height); engine_.repositionChildren(id); } @@ -3553,6 +3555,22 @@ class LayoutNodePass { flowY = nextBottom; } if (right - left != runWidth) extent = layoutRun(std::max(0, right - left)); + // A float after a one-line run goes to the top of that line when it + // fits beside the run's content (CSS 2.2 9.5.1): place the float + // first, then flow the run again beside it. + if (end < count && nodes_[children[end]].computedStyle().float_side && !nodes_[children[end]].computedStyle().clear_side && + !isLineBreak(nodes_[children[end - 1]]) && extent.height == extent.firstLineHeight) { + const Node &next = nodes_[children[end]]; + engine_.layoutNode(children[end], contentWidth, contentHeight, measureWidth); + if (extent.width + next.layout.width + next.computedStyle().margin[1] + next.computedStyle().margin[3] <= right - left) { +#if GEA_CSS_FIRST_LINE + if (ownerRare) ownerRare->firstLineBackground.lineValid = ownerLineWasValid; +#endif + std::rotate(children + i, children + end, children + end + 1); + --i; + continue; + } + } #if GEA_CSS_FIRST_LINE if (!ownerLineWasValid && ownerRare && ownerRare->firstLineBackground.lineValid) ownerRare->firstLineBackground.lineY = clampInt16(ownerRare->firstLineBackground.lineY + flowY); From 3615ae10f1e7a65d66b6448daf3493a2d5689d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 18:38:24 +0300 Subject: [PATCH 40/50] Trim item margins against flex and grid container edges margin-trim was only applied to block containers. A flex container now zeroes, after building its lines, the main-axis margins of each line's first and last item against a trimmed edge, and the cross-axis margins of the items in the first and last line; line sizes are recomputed before flexing, alignment and autosizing. A grid container zeroes the margins of items in its first and last rows and columns against trimmed edges before sizing tracks. Flags map to physical sides through the container's writing mode and direction, so reversed directions and wrap-reverse find the right items. Trimmed margins are restored when the pass ends. positionLineChildren's start-side computation moves into flexStartSides so both use it. WPT: 18 margin-trim flex and grid tests pass (595 pass / 59 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 114 ++++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 8277562..1186020 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -823,6 +823,49 @@ static bool inlineBoxView(const Node &n); bool blockLevelView(const Node &node); bool splitInlineWrapper(const Node *nodes, int id); +// The physical side (0 top, 1 right, 2 bottom, 3 left) of a margin-trim flag +// bit of a container: block-start, block-end, inline-start, inline-end. +int marginTrimSide(const Node &container, int flag) +{ + const int mode = writingMode(container); + const bool inlineAxis = flag >= 2, end = flag & 1; + const bool reversed = inlineAxis ? rightToLeft(container) != (mode == 4) : mode == 2 || mode == 3; + const int start = inlineAxis == (mode == 0) ? 3 : 0; + return reversed != end ? (start + 2) % 4 : start; +} + +// Margins zeroed by margin-trim for one layout pass, restored when it ends. +struct TrimmedMargins { + struct Entry { int child, side; int16_t value; bool automatic; }; + std::vector entries; + void cut(int child, int side) + { + Node &item = Tree::instance().nodes()[child]; + if (!item.computedStyle().margin[side] && !(item.computedStyle().margin_auto & (1 << side))) return; + entries.push_back({child, side, item.computedStyle().margin[side], static_cast(item.computedStyle().margin_auto & (1 << side))}); +#if GEA_CSS_MARGINS + item.mutableStyle().margin[side] = 0; +#endif +#if GEA_CSS_MARGIN_AUTO + item.mutableStyle().margin_auto &= static_cast(~(1 << side)); +#endif + } + void restore() + { + Node *nodes = Tree::instance().nodes(); + for (auto it = entries.rbegin(); it != entries.rend(); ++it) { +#if GEA_CSS_MARGINS + nodes[it->child].mutableStyle().margin[it->side] = it->value; +#endif +#if GEA_CSS_MARGIN_AUTO + if (it->automatic) nodes[it->child].mutableStyle().margin_auto |= static_cast(1 << it->side); +#endif + } + entries.clear(); + } + ~TrimmedMargins() { restore(); } +}; + class FlexLayoutPass { public: FlexLayoutPass(LayoutEngine &engine, Node &node, int *children, int childCount, bool isRow, int mainAvail, int padWidth, int padHeight, FlexLine *lines, bool inlineRow = false, bool assignedSize = false) @@ -895,6 +938,9 @@ class FlexLayoutPass { { const int originalWidth = node_.layout.width, originalHeight = node_.layout.height; int lineCount = buildLines(); + // Trimmed margins stay zero until this pass ends. + TrimmedMargins trimmed; + trimEdgeMargins(lineCount, trimmed); expandCrossSizes(lineCount, autosize); if (autosize) autosizeParent(lineCount); positionLines(lineCount); @@ -2298,7 +2344,9 @@ class FlexLayoutPass { } } - void positionLineChildren(const FlexLine &line, Node *nodes, int mainOffset, int crossOffset, int autoSpace = 0, int autoCount = 0, int distributedFree = 0) + // Physical sides (0 top, 1 right, 2 bottom, 3 left) the main axis starts at + // and the first line sits against. + void flexStartSides(bool &reverseMain, bool &reverseCross, int &mainBefore, int &crossBefore) const { #if GEA_CSS_FLEX_WRAP const bool wrapReversed = (node_.computedStyle().flex_wrap & 3) == 2; @@ -2310,11 +2358,56 @@ class FlexLayoutPass { const int mode = writingMode(node_); const bool blockReversed = mode == 2 || mode == 3; const bool inlineReversed = rightToLeft(node_) != (mode == 4); - const bool reverseMain = flex && ((node_.computedStyle().flex_direction_explicit && node_.computedStyle().flex_direction >= 2) != (logicalRow ? inlineReversed : blockReversed)); - const bool reverseCross = flex && ((wrapReversed) != (logicalRow ? blockReversed : inlineReversed)); - const int mainBefore = isRow_ ? (reverseMain ? 1 : 3) : (reverseMain ? 2 : 0); + reverseMain = flex && ((node_.computedStyle().flex_direction_explicit && node_.computedStyle().flex_direction >= 2) != (logicalRow ? inlineReversed : blockReversed)); + reverseCross = flex && ((wrapReversed) != (logicalRow ? blockReversed : inlineReversed)); + mainBefore = isRow_ ? (reverseMain ? 1 : 3) : (reverseMain ? 2 : 0); + crossBefore = isRow_ ? (reverseCross ? 2 : 0) : (reverseCross ? 1 : 3); + } + + // CSS Box 4 margin-trim on a flex container: the margins of items against a + // trimmed edge of the container are zero. Along the main axis that is each + // line's first or last item, across it every item of the first or last line. + void trimEdgeMargins(int lineCount, TrimmedMargins &trimmed) + { + const int trim = rstyle(node_.computedStyle()).margin_trim; + if (!trim || node_.computedStyle().display != kDisplayFlex || lineCount <= 0) return; + Node *nodes = Tree::instance().nodes(); + bool reverseMain, reverseCross; + int mainBefore, crossBefore; + flexStartSides(reverseMain, reverseCross, mainBefore, crossBefore); + for (int flag = 0; flag < 4; ++flag) { + if (!(trim & (1 << flag))) continue; + const int side = marginTrimSide(node_, flag); + if (side == mainBefore || side == (mainBefore + 2) % 4) { + for (int l = 0; l < lineCount; ++l) + if (lines_[l].count) trimmed.cut(children_[side == mainBefore ? lines_[l].start : lines_[l].start + lines_[l].count - 1], side); + } else { + const FlexLine &line = lines_[side == crossBefore ? 0 : lineCount - 1]; + for (int i = 0; i < line.count; ++i) trimmed.cut(children_[line.start + i], side); + } + } + if (trimmed.entries.empty()) return; + for (int l = 0; l < lineCount; ++l) { + FlexLine &line = lines_[l]; + int mainSize = std::max(0, line.count - 1) * mainGap_, crossSize = 0; + for (int i = 0; i < line.count; ++i) { + const Node &item = nodes[children_[line.start + i]]; + mainSize += isRow_ ? item.layout.width + item.computedStyle().margin[1] + item.computedStyle().margin[3] + : item.layout.height + item.computedStyle().margin[0] + item.computedStyle().margin[2]; + crossSize = std::max(crossSize, isRow_ ? item.layout.height + item.computedStyle().margin[0] + item.computedStyle().margin[2] + : item.layout.width + item.computedStyle().margin[1] + item.computedStyle().margin[3]); + } + line.mainSize = mainSize; + line.crossSize = std::max(line.strutSize, baselineCrossSize(line.start, line.count, crossSize)); + } + } + + void positionLineChildren(const FlexLine &line, Node *nodes, int mainOffset, int crossOffset, int autoSpace = 0, int autoCount = 0, int distributedFree = 0) + { + bool reverseMain, reverseCross; + int mainBefore, crossBefore; + flexStartSides(reverseMain, reverseCross, mainBefore, crossBefore); const int mainAfter = (mainBefore + 2) % 4; - const int crossBefore = isRow_ ? (reverseCross ? 2 : 0) : (reverseCross ? 1 : 3); const int crossAfter = (crossBefore + 2) % 4; int autoUsed = 0; auto autoMargin = [&](const Node &child, int side) { @@ -3997,6 +4090,17 @@ class LayoutNodePass { occupy(p); cursorColumn = p.column.end; } + // CSS Box 4 margin-trim: an item in the first or last row or column has + // no margin against that edge of the grid, until this pass ends. + TrimmedMargins trimmed; + if (const int trim = rstyle(node_.computedStyle()).margin_trim) { + for (int i = 0; i < childCount; ++i) { + const auto &p = placements[i]; + const bool atEdge[4] = {p.row.start == 0, p.row.end == rowCount, p.column.start == 0, p.column.end == columnCount}; + for (int flag = 0; flag < 4; ++flag) + if ((trim & (1 << flag)) && atEdge[flag]) trimmed.cut(children[i], marginTrimSide(node_, flag)); + } + } const int columnGap = resolvedGap(node_, false, padWidth, padHeight); const int rowGap = resolvedGap(node_, true, padWidth, padHeight); From 13c894517664b46377de31c77c32e247486a19aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 19:19:37 +0300 Subject: [PATCH 41/50] Resolve transform: inherit to the parent's transform transform: inherit was parsed as a transform list, found nothing and cleared the transform. It now takes the parent's computed rotation, translation, scale and outer-axis translation. WPT: css-transform-inherit-scale passes (596 pass / 58 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/style.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index cbce225..59a777e 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -9572,6 +9572,22 @@ void setIndividualTranslation(NodeHandle node, const TransformComponents &t, Sty setStyleValue(node, Property::TranslateYPercent, t.translateYPercent, source); } +// transform: inherit takes the parent's transform list as it computed. +TransformComponents inheritedTransformComponents(int nodeId) +{ + TransformComponents t; + const int parent = Tree::instance().node(nodeId).parent; + if (parent < 0) return t; + const RareStyle &r = rstyle(Tree::instance().node(parent).computedStyle()); + t.translateOuterAxes = r.transform_translate_outer_axes; + t.rotateX = r.transform_rotate_x; t.rotateY = r.transform_rotate_y; t.rotateZ = r.transform_rotate; + t.translateX = r.transform_translate_x; t.translateY = r.transform_translate_y; t.translateZ = r.transform_translate_z; + t.translateXPercent = r.transform_translate_x_percent; t.translateYPercent = r.transform_translate_y_percent; + t.scaleX = r.transform_scale_x; t.scaleY = r.transform_scale_y; t.scaleZ = r.transform_scale_z; + t.hasRotateZ = r.transform_present; + return t; +} + void setTransformComponents(NodeHandle node, const TransformComponents &t, StyleApplicationSource source) { setStyleValue(node, Property::TransformTranslateOuterAxes, t.translateOuterAxes, source); @@ -11024,7 +11040,8 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl return true; } case CssDeclarationId::Transform: - setTransformComponents(node, parseTransformComponents(value, nodeId), source); + setTransformComponents(node, toLowerAscii(trimCssValue(value)) == "inherit" ? inheritedTransformComponents(nodeId) + : parseTransformComponents(value, nodeId), source); return true; case CssDeclarationId::Rotate: { IndividualRotation rotation; From 299ef190de89ed0536b983ef4785656109f114eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 19:19:45 +0300 Subject: [PATCH 42/50] Support the + and ~ sibling combinators in selectors The selector engine knew only the descendant and child combinators; a + or ~ was read as a tag name and the rule never matched. Selector plans now record a sibling relation per part: + requires the previous element sibling to match the part on its left, ~ any earlier element sibling. Text nodes and generated content are not element siblings. Restyling follows: a class mutation restyles the siblings after the node when it adds or removes a class used in a + or ~ selector, and inserting, moving or removing a node restyles the siblings after its old and new positions. Generated pseudo nodes are skipped, and so are the children of a subtree being removed, whose siblings go next. All of it is skipped unless some rule uses a sibling combinator. The static selector plans generated by the geatsc build carry the same relation, as they share the selector plan cache with runtime rules. WPT: background-clip-padding-box-with-border-radius passes; its reference relied on div + div (597 pass / 57 fail). Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Fable 5.1 --- .../core/scripts/build-gea-vite-geatsc.mjs | 15 ++- packages/engine/ui/style.cpp | 105 ++++++++++++++++-- packages/engine/ui/style.h | 4 + packages/engine/ui/tree_internal.h | 3 + packages/engine/ui/tree_nodes.cpp | 18 ++- 5 files changed, 132 insertions(+), 13 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index c77a925..9882c29 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -1623,22 +1623,27 @@ function parseStaticSelectorPlanSpec(selector) { if (!text) return null const parts = [] let nextDirect = false + // Relation to the previous part: 1 next sibling (+), 2 any earlier sibling (~). + let nextSibling = 0 + const combinator = (char) => char === '>' || char === '+' || char === '~' let i = 0 while (i < text.length) { while (i < text.length && text.charCodeAt(i) <= 32) i++ if (i >= text.length) break - if (text[i] === '>') { - nextDirect = true + if (combinator(text[i])) { + nextDirect = text[i] === '>' + nextSibling = text[i] === '+' ? 1 : text[i] === '~' ? 2 : 0 i++ continue } const start = i - while (i < text.length && text.charCodeAt(i) > 32 && text[i] !== '>') i++ + while (i < text.length && text.charCodeAt(i) > 32 && !combinator(text[i])) i++ if (i <= start) continue const simple = parseStaticSimpleSelectorSpec(text.slice(start, i)) if (!simple) return null - parts.push({ simple, directParent: nextDirect }) + parts.push({ simple, directParent: nextDirect, sibling: nextSibling }) nextDirect = false + nextSibling = 0 } return parts.length > 0 ? { text, parts } : null } @@ -1651,7 +1656,7 @@ function staticSimpleSelectorSpecCode(simple) { } function staticSelectorPartSpecCode(part) { - return `{${staticSimpleSelectorSpecCode(part.simple)}, ${part.directParent ? 'true' : 'false'}}` + return `{${staticSimpleSelectorSpecCode(part.simple)}, ${part.directParent ? 'true' : 'false'}, ${part.sibling}}` } function staticSelectorPlanRegistration(selector) { diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 59a777e..20b3907 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -12851,6 +12851,16 @@ bool isRootNode(int node) return state.nodes[node].parent < 0; } +// The element sibling before node; text and generated content are not elements. +int previousElementSibling(int node) +{ + const auto &state = treeState(); + if (node < 0 || node >= state.nodeCount) return -1; + for (int sibling = state.nodes[node].prev_sibling; sibling >= 0; sibling = state.nodes[sibling].prev_sibling) + if (!isGeneratedPseudoNode(state.nodes[sibling]) && !isAnonymousTextNode(state.nodes[sibling])) return sibling; + return -1; +} + bool isFirstElementChild(int node) { const auto &state = treeState(); @@ -13016,6 +13026,8 @@ ParsedSimpleSelector parseSimpleSelector(const char *rawSimple, std::size_t rawL struct SelectorPart { [[no_unique_address]] ParsedSimpleSelector simple; bool directParent = false; + // Relation to the part on its left: 1 next sibling (+), 2 any earlier sibling (~). + std::uint8_t sibling = 0; }; struct SelectorPlan { @@ -13025,6 +13037,8 @@ struct SelectorPlan { bool rightmostRoot = false; bool valid = true; [[no_unique_address]] SmallSelectorList ancestorClasses; + [[no_unique_address]] SmallSelectorList siblingClasses; + [[no_unique_address]] SmallSelectorList siblingTags; CssAtomId rightmostId = kInvalidCssAtom; [[no_unique_address]] SmallSelectorList ancestorTags; CssAtomId rightmostClass = kInvalidCssAtom; @@ -13071,13 +13085,16 @@ void finishSelectorPlan(SelectorPlan &plan) } for (std::size_t p = 0; p + 1 < plan.parts.size(); ++p) { + // A part joined to the next by + or ~ matches an earlier sibling, not an + // ancestor: a change to it restyles the siblings that follow. + const bool sibling = plan.parts.at(p + 1).sibling != 0; const ParsedSimpleSelector &ancestor = plan.parts.at(p).simple; for (std::size_t c = 0, n = ancestor.classIds.size(); c < n; ++c) { const CssAtomId cls = ancestor.classIds.at(c); - if (cls != kInvalidCssAtom) appendUnique(plan.ancestorClasses, cls); + if (cls != kInvalidCssAtom) appendUnique(sibling ? plan.siblingClasses : plan.ancestorClasses, cls); } if (ancestor.hasTag && !ancestor.rootTag && ancestor.tagId >= 0) - appendUnique(plan.ancestorTags, ancestor.tagId); + appendUnique(sibling ? plan.siblingTags : plan.ancestorTags, ancestor.tagId); } } @@ -13086,22 +13103,27 @@ SelectorPlan parseNormalizedSelectorPlan(const char *selectorText, std::size_t s SelectorPlan plan; const char *selector = selectorText ? selectorText : ""; bool nextDirect = false; + std::uint8_t nextSibling = 0; + auto combinator = [](char c) { return c == '>' || c == '+' || c == '~'; }; std::size_t i = 0; while (i < selectorLength) { while (i < selectorLength && static_cast(selector[i]) <= ' ') ++i; if (i >= selectorLength) break; - if (selector[i] == '>') { - nextDirect = true; + if (combinator(selector[i])) { + nextDirect = selector[i] == '>'; + nextSibling = selector[i] == '+' ? 1 : selector[i] == '~' ? 2 : 0; ++i; continue; } const std::size_t start = i; - while (i < selectorLength && static_cast(selector[i]) > ' ' && selector[i] != '>') ++i; + while (i < selectorLength && static_cast(selector[i]) > ' ' && !combinator(selector[i])) ++i; if (i <= start) continue; SelectorPart part; part.simple = parseSimpleSelector(selector + start, i - start); part.directParent = nextDirect; + part.sibling = nextSibling; nextDirect = false; + nextSibling = 0; if (!part.simple.valid) plan.valid = false; plan.specificity += simpleSelectorSpecificity(part.simple); plan.parts.push_back(std::move(part)); @@ -13189,6 +13211,7 @@ std::uint16_t storeStaticSelectorPlan(const char *selector, SelectorPart part; part.simple = staticSimpleSelectorForSpec(spec.simple); part.directParent = spec.directParent; + part.sibling = spec.sibling; if (!part.simple.valid) plan.valid = false; plan.specificity += simpleSelectorSpecificity(part.simple); plan.parts.push_back(std::move(part)); @@ -13258,6 +13281,19 @@ bool selectorPlanMatchesNode(const SelectorPlan &plan, int node) for (int target = last - 1; target >= 0; --target) { const bool direct = parts.at(static_cast(target + 1)).directParent; + const std::uint8_t sibling = parts.at(static_cast(target + 1)).sibling; + if (sibling) { + bool found = false; + for (int earlier = previousElementSibling(current); earlier >= 0; earlier = sibling == 1 ? -1 : previousElementSibling(earlier)) { + if (matchSimpleSelector(earlier, parts.at(static_cast(target)).simple)) { + current = earlier; + found = true; + break; + } + } + if (!found) return false; + continue; + } const int parent = current >= 0 && current < state.nodeCount ? state.nodes[current].parent : -1; if (direct) { current = parent; @@ -14023,6 +14059,11 @@ struct RuleIndex { // recomputed in full — a custom-prop/inheritance diff alone wouldn't catch it. DenseIdSet ancestorClasses; DenseIdSet ancestorTags; + // The same for parts joined by + or ~, which match an earlier sibling: a + // change to one of these keys restyles the siblings after the node. + DenseIdSet siblingClasses; + DenseIdSet siblingTags; + bool hasSiblingRules = false; // CSS specificity per rule (indexed by rule index), packed as // ids*10000 + (classes+pseudo-classes)*100 + (elements+pseudo-elements). Used to // order the cascade: a more-specific rule wins over a less-specific one @@ -14115,6 +14156,9 @@ void rebuildRuleIndexIfNeeded() clearCachedStyleApplyOps(); g_ruleIndex.ancestorClasses.clear(); g_ruleIndex.ancestorTags.clear(); + g_ruleIndex.siblingClasses.clear(); + g_ruleIndex.siblingTags.clear(); + g_ruleIndex.hasSiblingRules = false; const auto &list = rules(); g_ruleIndex.specificity.assign(list.size(), 0); g_ruleIndex.writeMasks.assign(list.size(), {}); @@ -14170,6 +14214,12 @@ void rebuildRuleIndexIfNeeded() const int16_t tag = plan->ancestorTags.at(ancestor); if (tag >= 0) g_ruleIndex.ancestorTags.insert(tag); } + for (std::size_t p = 0; p < plan->parts.size(); ++p) + if (plan->parts.at(p).sibling) g_ruleIndex.hasSiblingRules = true; + for (std::size_t sibling = 0, count = plan->siblingClasses.size(); sibling < count; ++sibling) + g_ruleIndex.siblingClasses.insert(plan->siblingClasses.at(sibling)); + for (std::size_t sibling = 0, count = plan->siblingTags.size(); sibling < count; ++sibling) + g_ruleIndex.siblingTags.insert(plan->siblingTags.at(sibling)); } break; } @@ -17194,6 +17244,30 @@ struct NodeClassSnapshot { } }; +// A class mutation changes a + or ~ match only through a class it added or +// removed: the tag and the classes present both before and after stay as +// they were, so they cannot flip a sibling selector. +bool classTokensTouchSiblingSelectors(const NodeClassSnapshot &oldTokens, const NodeClassList ¤t) +{ + rebuildRuleIndexIfNeeded(); + if (!g_ruleIndex.hasSiblingRules) return false; + auto inOld = [&](CssAtomId token) { + for (std::size_t i = 0; i < oldTokens.count; ++i) + if (oldTokens.at(i) == token) return true; + return false; + }; + auto inCurrent = [&](CssAtomId token) { + for (std::size_t i = 0, n = current.size(); i < n; ++i) + if (current.at(i) == token) return true; + return false; + }; + for (std::size_t i = 0; i < oldTokens.count; ++i) + if (!inCurrent(oldTokens.at(i)) && g_ruleIndex.siblingClasses.contains(oldTokens.at(i))) return true; + for (std::size_t i = 0, n = current.size(); i < n; ++i) + if (!inOld(current.at(i)) && g_ruleIndex.siblingClasses.contains(current.at(i))) return true; + return false; +} + bool classTokensTouchAncestorSelectors(const NodeClassSnapshot &oldTokens, const NodeClassList ¤t) { if (g_ruleIndex.ancestorClasses.empty()) return false; @@ -17336,10 +17410,14 @@ void snapshotCustomProperties(const NodeRareData *rareData, CustomPropertySnapsh void noteClassMutationForIncremental(int node, const NodeClassSnapshot &oldTokens) { - if (!g_styleMountBatchActive) return; - rebuildRuleIndexIfNeeded(); const auto &state = treeState(); if (node < 0 || node >= state.nodeCount) return; + // A + or ~ selector keyed on a class this mutation added or removed + // restyles the siblings after this node. + if (classTokensTouchSiblingSelectors(oldTokens, state.classLists[node])) + StyleSheet::instance().recomputeSiblingsFrom(state.nodes[node].next_sibling); + if (!g_styleMountBatchActive) return; + rebuildRuleIndexIfNeeded(); if (classTokensTouchAncestorSelectors(oldTokens, state.classLists[node])) g_forceFullSubtreeMark.insert(node); } @@ -19540,6 +19618,19 @@ void StyleSheet::recomputeSubtree(int nodeId) const recomputeSubtreeClassStyles(nodeId); } +void StyleSheet::recomputeSiblingsFrom(int nodeId) const +{ + rebuildRuleIndexIfNeeded(); + if (!g_ruleIndex.hasSiblingRules) return; + auto &state = treeState(); + // ::before/::after nodes take their style from the owner's rule plan, not + // their own tag, so they are skipped like in recomputeSubtreeClassStyles. + for (int sibling = nodeId; sibling >= 0 && sibling < state.nodeCount; sibling = state.nodes[sibling].next_sibling) { + if (isGeneratedPseudoNode(state.nodes[sibling])) continue; + recomputeSubtreeClassStyles(sibling); + } +} + void StyleSheet::startCssAnimations(std::uint32_t nowMs) const { #if GEA_CSS_ANIMATIONS diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 84948de..2075488 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -484,6 +484,8 @@ struct StaticStyleSimpleSelectorSpec { struct StaticStyleSelectorPartSpec { StaticStyleSimpleSelectorSpec simple; bool directParent = false; + // Relation to the part on its left: 1 next sibling (+), 2 any earlier sibling (~). + std::uint8_t sibling = 0; }; enum class StaticStyleMediaFeatureKind : std::uint8_t { @@ -937,6 +939,8 @@ class StyleSheet { bool applyNumberProperty(NodeHandle node, const char *property, double value) const; bool removeProperty(NodeHandle node, const std::string &property) const; void recomputeSubtree(int nodeId) const; + // Restyles nodeId and the siblings after it when a + or ~ selector exists. + void recomputeSiblingsFrom(int nodeId) const; void hoverChanged() const; void startCssAnimations(std::uint32_t nowMs) const; diff --git a/packages/engine/ui/tree_internal.h b/packages/engine/ui/tree_internal.h index b1568e5..b158f82 100644 --- a/packages/engine/ui/tree_internal.h +++ b/packages/engine/ui/tree_internal.h @@ -162,6 +162,9 @@ class Tree { private: Tree() = default; + // restyleSiblings is false for the children of a node being removed: their + // siblings are removed next, so restyling them is wasted work. + void removeNode(int node, bool restyleSiblings); }; // Document-level (off-tree) events: `rotary` always, and `keydown` bound on a diff --git a/packages/engine/ui/tree_nodes.cpp b/packages/engine/ui/tree_nodes.cpp index 96c4086..fae4001 100644 --- a/packages/engine/ui/tree_nodes.cpp +++ b/packages/engine/ui/tree_nodes.cpp @@ -542,6 +542,7 @@ void Tree::setParent(int child, int parent) if (reparentWouldCycle(state, child, parent)) return; Node *c = &state.nodes[child]; const bool localMutation = localAbsoluteLeafTreeMutation(state, child); + const int oldNext = c->next_sibling; if (c->parent >= 0) { Node *old_parent = &state.nodes[c->parent]; if (old_parent->first_child == child) old_parent->first_child = c->next_sibling; @@ -582,6 +583,8 @@ void Tree::setParent(int child, int parent) else markDisplayListDirty(); StyleSheet::instance().recomputeSubtree(child); + // Sibling selectors (+, ~) of the siblings after the old position. + StyleSheet::instance().recomputeSiblingsFrom(oldNext); } void Tree::insertBefore(int child, int parent, int reference) @@ -599,6 +602,7 @@ void Tree::insertBefore(int child, int parent, int reference) Node *c = &state.nodes[child]; const bool localMutation = localAbsoluteLeafTreeMutation(state, child); + const int oldNext = c->next_sibling; // Detach `child` from its current position first; this may mutate the // reference node's prev_sibling if they were adjacent, so re-read it after. if (c->parent >= 0) { @@ -643,15 +647,23 @@ void Tree::insertBefore(int child, int parent, int reference) else markDisplayListDirty(); StyleSheet::instance().recomputeSubtree(child); + // Sibling selectors (+, ~) of the siblings after the old and new positions. + if (oldNext != reference) StyleSheet::instance().recomputeSiblingsFrom(oldNext); + StyleSheet::instance().recomputeSiblingsFrom(reference); } void Tree::removeNode(int id) +{ + removeNode(id, true); +} + +void Tree::removeNode(int id, bool restyleSiblings) { auto &state = treeState(); if (id < 0 || id >= state.nodeCount || !state.nodeActive[id]) return; if (containsNode(id, state.hoveredNodeId)) pointerHover(-1, -1); while (state.nodes[id].first_child >= 0) { - removeNode(state.nodes[id].first_child); + removeNode(state.nodes[id].first_child, false); if (id >= state.nodeCount || !state.nodeActive[id]) return; } Node *n = &state.nodes[id]; @@ -671,8 +683,12 @@ void Tree::removeNode(int id) if (p->first_child == id) p->first_child = n->next_sibling; if (p->last_child == id) p->last_child = n->prev_sibling; } + const int next = n->next_sibling; if (n->prev_sibling >= 0) state.nodes[n->prev_sibling].next_sibling = n->next_sibling; if (n->next_sibling >= 0) state.nodes[n->next_sibling].prev_sibling = n->prev_sibling; + // Sibling selectors (+, ~) of the siblings that followed it. + if (restyleSiblings) StyleSheet::instance().recomputeSiblingsFrom(next); + n = &state.nodes[id]; n->mutableStyle().display = 1; n->parent = -1; n->first_child = -1; From c2faa78de93a34ff6765679b06e6e283e213c5e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 19:37:11 +0300 Subject: [PATCH 43/50] Size auto aspect ratios by content box and clamp flex minimums by them aspect-ratio: auto applies the ratio to the content box whatever box-sizing says (CSS Sizing 4); the transfer used the border box when box-sizing was border-box. The stored ratio's sign already marks the auto form. A flex item's content size suggestion is now clamped by its definite minimum and maximum cross sizes converted through its preferred aspect ratio (CSS Flexbox 4.5): a min-height widens a row item, a max-width caps a column item whose content is taller. WPT: flex-aspect-ratio-025 and -026 pass (599 pass / 55 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 1186020..77da5b8 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -642,8 +642,14 @@ int ratioTransferredSize(const Node &node, int crossBorderSize, bool horizontal) { const double ratio = preferredRatio(node); if (ratio <= 0) return 0; - const int crossEdges = node.computedStyle().box_sizing ? 0 : boxInsets(node.computedStyle(), !horizontal); - const int mainEdges = node.computedStyle().box_sizing ? 0 : boxInsets(node.computedStyle(), horizontal); + // `auto ` (stored negative) sizes the content box whatever box-sizing + // says (CSS Sizing 4, 5.1). + float stored = 0; + const int bits = rstyle(node.computedStyle()).aspect_ratio; + std::memcpy(&stored, &bits, sizeof(stored)); + const bool contentBox = !node.computedStyle().box_sizing || stored < 0; + const int crossEdges = contentBox ? boxInsets(node.computedStyle(), !horizontal) : 0; + const int mainEdges = contentBox ? boxInsets(node.computedStyle(), horizontal) : 0; const double value = std::max(0, crossBorderSize - crossEdges) * (horizontal ? ratio : 1 / ratio) + mainEdges; return static_cast(std::min(32767.0, std::round(value))); } @@ -2242,6 +2248,17 @@ class FlexLayoutPass { const int transferred = ratioTransferredSize(childNode, isRow_ ? childNode.layout.height : childNode.layout.width, isRow_); extent = childNode.type == NodeType::Image ? std::min(extent, transferred) : std::max(extent, transferred); } + // With a preferred aspect ratio the content size suggestion is clamped by + // the definite minimum and maximum cross sizes converted through it + // (CSS Flexbox 4.5). + if (preferredRatio(childNode) > 0) { + const int maxCross = isRow_ ? childNode.computedStyle().max_height : childNode.computedStyle().max_width; + const int minCross = isRow_ ? childNode.computedStyle().min_height : childNode.computedStyle().min_width; + if (maxCross != kUnset) + extent = std::min(extent, ratioTransferredSize(childNode, contentSizeToBorderSize(childNode.computedStyle(), maxCross, !isRow_), isRow_)); + if (minCross != kUnset && minCross > 0) + extent = std::max(extent, ratioTransferredSize(childNode, contentSizeToBorderSize(childNode.computedStyle(), minCross, !isRow_), isRow_)); + } if (extent <= 0) return 0; const int max = isRow_ ? childNode.computedStyle().max_width : childNode.computedStyle().max_height; if (max != kUnset) extent = std::min(extent, contentSizeToBorderSize(childNode.computedStyle(), max, isRow_)); From 8e8fd3517cf22987eee34e6e730141c3a4dd785f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 19:50:56 +0300 Subject: [PATCH 44/50] Honor auto margins for blocks laid out in a float context When a block formatting context contains a float anywhere, its normal blocks, including those inside non-BFC wrappers, are placed by the float path, which ignored auto margins: a single float left every margin: 0 auto block in that context at the start edge. Block placement now shares blockInlineOffset between the block and float paths, and a block formatting context beside floats centers with auto margins in the space between them. WPT: unchanged (599 pass / 55 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 77da5b8..e11439d 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3403,6 +3403,16 @@ class LayoutNodePass { return static_cast(basis) * s.height_percent / 1000.0 + insets; } + // Where a block-level box sits in the free inline space of its horizontal + // container: auto margins take it (CSS 2.2 10.3.3); otherwise it starts at + // the container's start edge. + int blockInlineOffset(const Node &child, int slack) const + { + const bool autoLeft = child.computedStyle().margin_auto & (1 << 3), autoRight = child.computedStyle().margin_auto & (1 << 1); + if (autoLeft || autoRight) return slack > 0 && autoLeft ? (autoRight ? slack / 2 : slack) : 0; + return rightToLeft(node_) ? slack : 0; + } + bool layoutBlockChildren(int *children, int count, int contentWidth, int contentHeight, bool autosize) { if (node_.computedStyle().display != kDisplayBlock || node_.computedStyle().flex_direction_explicit) return false; @@ -3467,11 +3477,7 @@ class LayoutNodePass { } } const int slack = contentWidth - child.layout.width - child.computedStyle().margin[1] - child.computedStyle().margin[3]; - const bool autoLeft = child.computedStyle().margin_auto & (1 << 3); - const bool autoRight = child.computedStyle().margin_auto & (1 << 1); - int offset = slack > 0 && autoLeft ? (autoRight ? slack / 2 : slack) : 0; - if (!autoLeft && !autoRight && rightToLeft(node_)) offset = slack; - child.layout.x = boxInset(node_.computedStyle(), 3) + child.computedStyle().margin[3] + offset; + child.layout.x = boxInset(node_.computedStyle(), 3) + child.computedStyle().margin[3] + blockInlineOffset(child, slack); } } const bool mergeTop = collapsesWithChildren(id_, false); @@ -3765,7 +3771,7 @@ class LayoutNodePass { // when that requires negative clearance above the top margin. y = clearance ? clearBottom : std::max(y, clearBottom); if (clearance && !context.hasClearance(children[i])) context.cleared.push_back(children[i]); - int left = 0, right = area, contextX = 0; + int left = 0, right = area, contextX = 0, contextFree = 0; const bool independentBlock = block && establishesBlockContext(child); if (child.computedStyle().float_side || child.computedStyle().display != kDisplayBlock || establishesBlockContext(child)) { // An automatic-width BFC tries the widest opportunity at each y @@ -3805,14 +3811,21 @@ class LayoutNodePass { if (child.layout.height > extent) { extent = child.layout.height; continue; } } fits = child.layout.width <= end - start; + contextFree = end - start - child.layout.width; } if (fits || nextY == 32767) break; y = nextY; if (searchWidth) extent = 1; } } - // In right-to-left flow a block that does not fill the line hugs the right. - const int x = (child.style.float_side == 2 || (block && rtl && !independentBlock) ? right - outerW : independentBlock ? contextX : left) - + // A normal block ignores floats; auto margins center it, and in + // right-to-left flow one that does not fill the line hugs the right. + // A block formatting context beside floats centers with auto margins in + // the space between them. + const bool autoMargins = child.computedStyle().margin_auto & 10; + const int x = (block && !independentBlock ? left + blockInlineOffset(child, right - left - outerW) : + child.computedStyle().float_side == 2 ? right - outerW : + independentBlock ? contextX + (autoMargins ? blockInlineOffset(child, contextFree) : 0) : left) - (splitBlock ? outer[3] : 0); intrinsicWidth = std::max(intrinsicWidth, child.computedStyle().float_side ? left + outerW + contentWidth - right : measuredOuterW); From 9504ab337415d5152a78ff9e1da21488500530b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 20:39:46 +0300 Subject: [PATCH 45/50] Draw bare flex text on its baseline unless the container centres it A text node that is a flex item was always ink-centred in its line advance, a deliberate optical-centring choice for UI boxes. CSS wraps bare flex text in an anonymous block, so it sits on its baseline exactly like the same text in block flow; the engine drew it 1-2px lower. Flex text items now get the baseline flag unless the container centres them vertically (align-items/align-self: center in a row, justify-content: center in a column), which keeps optical centring where the author asked for centring: buttons and labels. Evidence (simulator WPT rig): - FAIL -> PASS at 0 differing pixels: css/css-flexbox/anonymous-flex-item-002.html css/css-flexbox/anonymous-flex-item-004.html - Full 1,100-case run: 601 PASS, 53 FAIL, 0 BLOCKED, 446 SKIP, 0 ERROR (previous 599 PASS, 55 FAIL); no other test changed pixel counts - 12 native tests pass; app pipelines unchanged (same 5 known fails) Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index e11439d..5d1d3e5 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2495,8 +2495,15 @@ class FlexLayoutPass { const int mainSize = isRow_ ? childNode.layout.width : childNode.layout.height; mainOffset += mainSize + mainMarginAfter + mainGap_; - // Flex items own their boxes; nothing here shares a line box. - childNode.render.inline_baseline = 0; + // Flex items own their boxes; nothing here shares a line box. Ink + // centring stays for a text item the container centres vertically, + // which is where optical centring is what the author asked for. Any + // other text item sits on its baseline, as the anonymous block around + // bare flex text puts it (CSS Flexbox 4). + const int verticalAlignment = isRow_ ? crossAlignFor(childNode) : node_.computedStyle().justify_content; + const bool centredText = verticalAlignment >= 0 && (verticalAlignment & 15) == 1; + const bool baselineText = childNode.type == NodeType::Text && node_.computedStyle().display == kDisplayFlex && !centredText; + childNode.render.inline_baseline = baselineText ? 1 : 0; } } From 62668c744e2bda583449d110f572ec9b923a27a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Sat, 26 Sep 2026 20:57:12 +0300 Subject: [PATCH 46/50] Store box widths and heights as 32-bit so huge boxes lay out and paint Declared and laid-out width/height were int16: `width: 40000px` wrapped to a negative length, so a huge box scaled down by a transform lost its content and painted as its borders alone. ComputedStyle::width/height, LayoutBox::width/height and previous_width/height are now layout_extent_t, bounded by kMaxLayoutExtent (2^24, exact in the float projection math); the layout writes that clamped to int16, the flex automatic minimum included, use clampLayoutExtent instead. Display commands still carry int16 rects. Rather than grow every command, recording saturates what overflows: overflow clip shapes, untransformed overflow clip rects (both bounds) and FillRect/SetAlpha rects clamp to the int16 range, which no screen exceeds, and a transformed border ring whose local box passes int16 or whose ring passes 255px paints as four transformed side quads. GEA_CSS_LARGE_BOXES (default on) selects layout_extent_t: int32 and kMaxLayoutExtent 2^24 when on, int16 and 32767 when off, where boxes are int16 as before. On, Node grows by 12 bytes (+6 KB at 512 nodes); off (host), ComputedStyle is 172 bytes instead of 176, LayoutBox 56 instead of 64 and Node 356 instead of 368. The analyzer and CLI do not report the family yet. Evidence (simulator WPT rig): - FAIL -> PASS at 0 differing pixels: css/css-transforms/huge-length-tiny-scale.html - Full 1,100-case run: 602 PASS, 52 FAIL, 0 BLOCKED, 446 SKIP, 0 ERROR (previous 601 PASS, 53 FAIL); no other test changed pixel counts - All 55 native/pipeline scripts in core/packages/core/test: 53 pass, fixed-text-local-refresh and style-viewport-metrics fail with their pre-existing messages; app pipelines unchanged (same 5 known fails) Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/canvas_element.cpp | 4 +- packages/engine/ui/layout.cpp | 64 +++++++++++++-------------- packages/engine/ui/node_model.h | 21 +++++++-- packages/engine/ui/view.cpp | 46 ++++++++++++++++++- 4 files changed, 96 insertions(+), 39 deletions(-) diff --git a/packages/engine/ui/canvas_element.cpp b/packages/engine/ui/canvas_element.cpp index 7f3b365..77d81cc 100644 --- a/packages/engine/ui/canvas_element.cpp +++ b/packages/engine/ui/canvas_element.cpp @@ -2657,8 +2657,8 @@ void CanvasRenderer::record(const Node &node) cmd->canvasBatch = {static_cast(id), node.layout.x, node.layout.y, - node.layout.width, - node.layout.height, + static_cast(node.layout.width), + static_cast(node.layout.height), CanvasRenderingContext2D::stateFor(id)->identity_}; return; } diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 5d1d3e5..4d2fc98 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -549,7 +549,7 @@ void applyLineClamp(int id) nodes[walk.lastRun].layout.line_clamp_hidden |= 2; if (!walk.clamped || hasExplicitHeight(node)) return; const int height = clampBorderBoxSize(node.computedStyle(), walk.keptBottom + boxInset(node.computedStyle(), 2), false); - if (height < node.layout.height) node.layout.height = clampInt16(height); + if (height < node.layout.height) node.layout.height = clampLayoutExtent(height); } #endif @@ -712,13 +712,13 @@ void applyPreferredRatio(Node &node, int availableWidth, int availableHeight) width = true; } const bool horizontal = !width && height; - int16_t &target = horizontal ? node.layout.width : node.layout.height; + layout_extent_t &target = horizontal ? node.layout.width : node.layout.height; const int fromRatio = ratioTransferredSize(node, horizontal ? node.layout.height : node.layout.width, horizontal); // Non-scrollable boxes retain a content-based minimum on the automatic axis. const bool contentMinimum = node.type != NodeType::Image && (horizontal ? node.computedStyle().min_width : node.computedStyle().min_height) == kUnset && !isScrollableOverflow(horizontal ? overflowX(node.computedStyle()) : overflowY(node.computedStyle())); const int result = contentMinimum ? std::max(target, fromRatio) : fromRatio; - target = clampInt16(clampLayoutSize(node, result, horizontal)); + target = clampLayoutExtent(clampLayoutSize(node, result, horizontal)); } int baselineFallbackAlignment(const Node &parent, const Node &child, int alignment, int free, bool horizontal, bool staticPosition) @@ -933,8 +933,8 @@ class FlexLayoutPass { // wider than its empty neighbours). if (!zeroBasisGrowItem(item)) { const int floor = automaticMinimumMainSize(item); - int16_t &main = isRow_ ? item.layout.width : item.layout.height; - if (main < floor) { main = clampInt16(floor); engine_.repositionChildren(child); } + layout_extent_t &main = isRow_ ? item.layout.width : item.layout.height; + if (main < floor) { main = clampLayoutExtent(floor); engine_.repositionChildren(child); } } } } @@ -1221,8 +1221,8 @@ class FlexLayoutPass { // whitespace; that discarded space is not part of the fragment. const int firstLineFragmentX = fragmentItemX + (wraps ? pen + gap : 0); const int firstLineFragmentWidth = m.firstLineWidth + padH; - subject.layout.height = clampInt16(boxH); - subject.layout.width = clampInt16(boxW); + subject.layout.height = clampLayoutExtent(boxH); + subject.layout.width = clampLayoutExtent(boxW); subject.layout.inline_indent = clampInt16(indent); if (innerRun >= 0) { // The wrapper is transparent: it takes the run's box exactly, and @@ -1230,8 +1230,8 @@ class FlexLayoutPass { // through the wrapper still lands on the run's own baseline. subject.layout.x = 0; subject.layout.y = 0; - cn.layout.height = clampInt16(boxH); - cn.layout.width = clampInt16(boxW); + cn.layout.height = clampLayoutExtent(boxH); + cn.layout.width = clampLayoutExtent(boxW); cn.layout.inline_indent = clampInt16(indent); } if (wraps) { @@ -1470,16 +1470,16 @@ class FlexLayoutPass { const bool keepScrollWidth = !intrinsicWidthConstraint(node_) && scrollsOverflowX(node_.computedStyle()) && node_.layout.width > 0; if (!hasExplicitHeight(node_) && !keepScrollHeight) { const int height = (flowHeight < 0 ? 0 : flowHeight) + boxInset(node_.computedStyle(), 0) + boxInset(node_.computedStyle(), 2); - node_.layout.height = clampInt16(clampLayoutSize(node_, height, false)); + node_.layout.height = clampLayoutExtent(clampLayoutSize(node_, height, false)); } if (!hasExplicitWidth(node_) && !keepScrollWidth) { const int width = maxLineWidth + boxInset(node_.computedStyle(), 1) + boxInset(node_.computedStyle(), 3); - node_.layout.width = clampInt16(clampLayoutSize(node_, width, true)); + node_.layout.width = clampLayoutExtent(clampLayoutSize(node_, width, true)); // A widened sole run spans the box's final content width instead, so // its extent does not hold the box at the width it was offered. const int content = node_.layout.width - boxInsets(node_.computedStyle(), true); for (const WidenedRun &run : widenedRuns_) - nodes[run.id].layout.width = clampInt16(std::max(run.natural, content + run.extra)); + nodes[run.id].layout.width = clampLayoutExtent(std::max(run.natural, content + run.extra)); } return extent; } @@ -1696,7 +1696,7 @@ class FlexLayoutPass { const int full = contentW - marginL - marginR + lead; if (full > cn.layout.width) { widenedRuns_.push_back({child, cn.layout.width, full - contentW}); - cn.layout.width = clampInt16(full); + cn.layout.width = clampLayoutExtent(full); } } @@ -1713,7 +1713,7 @@ class FlexLayoutPass { if (align == 1) y += (lineCross - crossTotal) / 2; else if (align == 2) y += lineCross - crossTotal; else if (align == 0 && !hasExplicitHeight(cn) && !isIntrinsicSizeExpression(cn.computedStyle().height_expression)) { - cn.layout.height = clampInt16(lineCross - crossBefore - crossAfter); + cn.layout.height = clampLayoutExtent(lineCross - crossBefore - crossAfter); engine_.repositionChildren(child); } } @@ -1824,7 +1824,7 @@ class FlexLayoutPass { continue; } const int floor = automaticMinimumMainSize(childNode); - if (floor > 0) (isRow_ ? childNode.layout.width : childNode.layout.height) = clampInt16(floor); + if (floor > 0) (isRow_ ? childNode.layout.width : childNode.layout.height) = clampLayoutExtent(floor); engine_.repositionChildren(child); } } @@ -1849,8 +1849,8 @@ class FlexLayoutPass { // height affects following lines and reversed line order as well. if (ratioCrossChanged) { expandCrossSizes(lineCount, true); - int16_t &cross = isRow_ ? node_.layout.height : node_.layout.width; - cross = clampInt16(clampLayoutSize(node_, totalCrossSize(lineCount) + boxInsets(node_.computedStyle(), !isRow_), !isRow_)); + layout_extent_t &cross = isRow_ ? node_.layout.height : node_.layout.width; + cross = clampLayoutExtent(clampLayoutSize(node_, totalCrossSize(lineCount) + boxInsets(node_.computedStyle(), !isRow_), !isRow_)); } int crossOffset = boxInset(node_.computedStyle(), isRow_ ? 0 : 3); const int crossBox = (isRow_ ? node_.layout.height : node_.layout.width) - boxInsets(node_.computedStyle(), !isRow_); @@ -2335,8 +2335,8 @@ class FlexLayoutPass { if (rowText && zeroBasisGrow) after = reflowFlexedText(child, childNode, true, index >= 0 && measuredWidths_ ? measuredWidths_[index] : -1); if (preferredRatio(childNode) > 0 && !(isRow_ ? hasExplicitHeight(childNode) : hasExplicitWidth(childNode)) && !definiteFlexCrossSize(node_, childNode, isRow_)) { - int16_t &cross = isRow_ ? childNode.layout.height : childNode.layout.width; - cross = clampInt16(clampLayoutSize(childNode, ratioTransferredSize(childNode, after, !isRow_), !isRow_)); + layout_extent_t &cross = isRow_ ? childNode.layout.height : childNode.layout.width; + cross = clampLayoutExtent(clampLayoutSize(childNode, ratioTransferredSize(childNode, after, !isRow_), !isRow_)); } engine_.repositionChildren(child); return delta > 0 ? after - before : before - after; @@ -2881,7 +2881,7 @@ class LayoutNodePass { if (isLineBreak(node_) && LayoutEngine::isInlineLevelNode(node_) && (node_.parent < 0 || (nodes_[node_.parent].computedStyle().display != kDisplayFlex && !isDisplayGrid(nodes_[node_.parent].computedStyle())))) { node_.layout.width = 0; - node_.layout.height = clampInt16(TextRenderer::measureHeight(" ", node_.computedStyle().font_id, + node_.layout.height = clampLayoutExtent(TextRenderer::measureHeight(" ", node_.computedStyle().font_id, node_.computedStyle().font_size, 0, node_.computedStyle().line_height)); return; } @@ -3069,7 +3069,7 @@ class LayoutNodePass { int16_t width; ColumnWidthScope(Node &n, int columnWidth) : node(n), width(n.layout.width) { - if (columnWidth >= 0) n.layout.width = clampInt16(columnWidth); + if (columnWidth >= 0) n.layout.width = clampLayoutExtent(columnWidth); } ~ColumnWidthScope() { node.layout.width = width; } }; @@ -3095,7 +3095,7 @@ class LayoutNodePass { int height = definite && (flags & 16) ? given : balancedColumnHeight(flow, g.count); if (definite) height = std::min(height, given); height = std::max(1, height); - if (!definite) node_.layout.height = clampInt16(clampLayoutSize(node_, height + insetsY, false)); + if (!definite) node_.layout.height = clampLayoutExtent(clampLayoutSize(node_, height + insetsY, false)); // Overflow columns follow the last column box; continue: discard drops them. int used = std::max(1, (flow + height - 1) / height); if (flags & 32) used = std::min(used, g.count); @@ -3445,7 +3445,7 @@ class LayoutNodePass { !(preferredRatio(child) > 0 && hasExplicitWidth(child))) { const int height = clampLayoutSize(child, std::max(0, contentHeight - child.computedStyle().margin[0] - child.computedStyle().margin[2]), false); if (child.layout.height != height) { - child.layout.height = clampInt16(height); engine_.repositionChildren(id); + child.layout.height = clampLayoutExtent(height); engine_.repositionChildren(id); } } const int slack = contentHeight - child.layout.height - child.computedStyle().margin[0] - child.computedStyle().margin[2]; @@ -3479,7 +3479,7 @@ class LayoutNodePass { if (!hasExplicitWidth(child) && !isIntrinsicSizeExpression(child.computedStyle().width_expression) && writingMode(child) == 0 && !(preferredRatio(child) > 0 && hasExplicitHeight(child))) { const int width = clampLayoutSize(child, std::max(0, contentWidth - child.computedStyle().margin[1] - child.computedStyle().margin[3]), true); if (child.layout.width != width) { - child.layout.width = clampInt16(width); + child.layout.width = clampLayoutExtent(width); engine_.repositionChildren(id); } } @@ -3520,7 +3520,7 @@ class LayoutNodePass { #if GEA_CSS_SCROLLING if (child.layout.scroll_content_height == child.layout.height) child.layout.scroll_content_height = clampInt16(height); #endif - child.layout.height = clampInt16(height); + child.layout.height = clampLayoutExtent(height); engine_.repositionChildren(id); } drift = bottom - cursor - child.layout.height; @@ -3749,7 +3749,7 @@ class LayoutNodePass { if (automaticWidth) { const int width = clampLayoutSize(child, std::max(0, area - child.computedStyle().margin[1] - child.computedStyle().margin[3]), true); if (child.layout.width != width) { - child.layout.width = clampInt16(width); + child.layout.width = clampLayoutExtent(width); engine_.repositionChildren(children[i]); } } @@ -3810,7 +3810,7 @@ class LayoutNodePass { const int width = clampLayoutSize(child, end - start, true); if (child.layout.width != width) { engine_.layoutNode(children[i], width + child.computedStyle().margin[1] + child.computedStyle().margin[3], contentHeight); - child.layout.width = clampInt16(width); + child.layout.width = clampLayoutExtent(width); engine_.repositionChildren(children[i]); outerW = width + child.computedStyle().margin[1] + child.computedStyle().margin[3]; outerH = child.layout.height + child.computedStyle().margin[0] + child.computedStyle().margin[2]; @@ -3901,7 +3901,7 @@ class LayoutNodePass { if (clearedThrough || (!collapsesWithChildren(id_, true) && !(first && collapsesWithChildren(id_, false)))) flowY += pending.value(); if (measureWidth) - node_.layout.width = clampInt16(clampLayoutSize(node_, intrinsicWidth + boxInsets(node_.computedStyle(), true), true)); + node_.layout.width = clampLayoutExtent(clampLayoutSize(node_, intrinsicWidth + boxInsets(node_.computedStyle(), true), true)); if (autosize && !hasExplicitHeight(node_)) { // Float enclosure is finalized with all same-context descendants. node_.layout.height = clampBorderSize(engine_, node_, flowY + boxInsets(node_.computedStyle(), false), false); @@ -4567,8 +4567,8 @@ class LayoutNodePass { const int dx = rect[0] - x, dy = rect[1] - y; wrapper.layout.x = clampInt16(wrapper.layout.x + dx); wrapper.layout.y = clampInt16(wrapper.layout.y + dy); - wrapper.layout.width = clampInt16(rect[2]); - wrapper.layout.height = clampInt16(rect[3]); + wrapper.layout.width = clampLayoutExtent(rect[2]); + wrapper.layout.height = clampLayoutExtent(rect[3]); for (int k = wrapper.first_child; k >= 0; k = nodes_[k].next_sibling) { if (isOutOfFlowPosition(nodes_[k].computedStyle().position)) continue; nodes_[k].layout.x = clampInt16(nodes_[k].layout.x - dx); @@ -5772,8 +5772,8 @@ bool LayoutEngine::layoutNodeScoped(int scope, int treeRoot) // A parent-owned box cannot be reproduced by laying only the child's // subtree. Put those dimensions back and position its descendants inside // the same box exactly as the omitted parent pass would. - a.layout.width = static_cast(prevW); - a.layout.height = static_cast(prevH); + a.layout.width = prevW; + a.layout.height = prevH; repositionChildren(scope); } a.layout.x = prevX; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 22de47d..c645975 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -40,6 +40,21 @@ inline int16_t storedPositionOffset(int value) { return static_cast(value == kUnset ? kUnset : std::clamp(value, -32767, 32767)); } +// Width and height (declared and laid out) are 32-bit with GEA_CSS_LARGE_BOXES: +// a box can be larger than int16 allows and still fit the screen under a scale +// transform. The bound keeps every size exact in the float math of transform +// projection. Without the family they stay int16, as every screen fits. +#if GEA_CSS_LARGE_BOXES +using layout_extent_t = int32_t; +inline constexpr int kMaxLayoutExtent = 1 << 24; +#else +using layout_extent_t = int16_t; +inline constexpr int kMaxLayoutExtent = 32767; +#endif +inline layout_extent_t clampLayoutExtent(int value) +{ + return static_cast(std::clamp(value, -kMaxLayoutExtent, kMaxLayoutExtent)); +} // Internal value carrier for CSS z-index:auto; numeric stack levels stay int16. inline constexpr int kZIndexAuto = INT32_MIN; // Origins keep percentage permille values unchanged; absolute pixel lengths @@ -730,7 +745,7 @@ struct ComputedStyle { #else static constexpr int32_t height_expression = -1; #endif - int16_t width, height; + layout_extent_t width, height; // kUnset: auto; see kMaxLayoutExtent #if GEA_CSS_WIDTH_PERCENT int16_t width_percent; #else @@ -1518,7 +1533,7 @@ inline bool hasBorderRelief(const ComputedStyle &style) struct LayoutBox { int16_t x, y; - int16_t width, height; + layout_extent_t width, height; // see kMaxLayoutExtent // Inline formatting: x offset, inside this box's content area, at which the // run's FIRST line starts. A text run that begins part-way along a line box @@ -1549,7 +1564,6 @@ struct LayoutBox { #endif int16_t previous_x, previous_y; - int16_t previous_width, previous_height; #if GEA_CSS_LINE_CLAMP // line-clamp: a text run its container's clamp point cuts paints only // this many lines. 0 paints them all. @@ -1557,6 +1571,7 @@ struct LayoutBox { #else static constexpr int16_t line_clamp_lines = 0; #endif + layout_extent_t previous_width, previous_height; // Scroll geometry must be 32-bit: a scrolls over a virtual // content height of itemCount * rowHeight (e.g. 5000 * 259 ≈ 1.29M px), diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 9c29778..40d6e7f 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -68,8 +68,20 @@ bool borderIsSameOpaqueSolidBackground(const Node &node, uint8_t parentAlpha) combineAlpha(parentAlpha, node.computedStyle().bg_alpha) == combineAlpha(parentAlpha, borderPaintAlpha(node.computedStyle(), 0)); } +// Display commands carry int16 rects. A box past that range (see +// kMaxLayoutExtent) records the part inside it; no screen reaches beyond. +void saturateRect16(int &x, int &y, int &w, int &h) +{ + const int right = std::min(x + w, 32767), bottom = std::min(y + h, 32767); + x = std::max(x, -32768); + y = std::max(y, -32768); + w = std::clamp(right - x, 0, 32767); + h = std::clamp(bottom - y, 0, 32767); +} + void appendAlphaCommand(uint8_t alpha, int bx, int by, int bw, int bh) { + saturateRect16(bx, by, bw, bh); DisplayCommand *cmd = DisplayList::instance().append(); if (!cmd) return; cmd->type = DisplayCommandType::SetAlpha; @@ -187,6 +199,8 @@ uint8_t gradientAlphaAt(const Node &node, int permille) void appendFillRectWithAlpha(int x, int y, int w, int h, uint16_t color, uint8_t alpha, uint8_t parentAlpha, int bx, int by, int bw, int bh) { + saturateRect16(x, y, w, h); + saturateRect16(bx, by, bw, bh); const uint8_t effectiveAlpha = combineAlpha(parentAlpha, alpha); if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, bx, by, bw, bh); DisplayCommand *cmd = DisplayList::instance().append(); @@ -202,6 +216,8 @@ void appendFillRectWithAlpha(int x, int y, int w, int h, uint16_t color, uint8_t void appendFillRectRaw(int x, int y, int w, int h, uint16_t color, int bx, int by, int bw, int bh) { + saturateRect16(x, y, w, h); + saturateRect16(bx, by, bw, bh); DisplayCommand *cmd = DisplayList::instance().append(); if (!cmd) return; cmd->type = DisplayCommandType::FillRect; @@ -2009,6 +2025,17 @@ void recordTransformedBorderRing(const Node &node, uint8_t parentAlpha) const int w = node.layout.width; const int h = node.layout.height; if (w <= 0 || h <= 0) return; + // The ring command carries its local box as int16 and each ring width as + // a byte. A box or border past that (a huge box under a tiny scale) paints + // its sides as transformed quads instead, with square corners. + int ring = 0; + for (int side = 0; side < 4; ++side) + ring = std::max({ring, node.computedStyle().border_width, rstyle(node.computedStyle()).border_side_width[side]}); + if (w > 32767 || h > 32767 || ring > 255) { + for (int side = 0; side < 4; ++side) + appendBorderSideWithAlpha(node, side, borderPaintColor(node.computedStyle(), side), borderPaintAlpha(node.computedStyle(), side), parentAlpha, true); + return; + } int16_t xs[4], ys[4]; ViewGeometry::transformRectCorners(node, false, x, y, w, h, xs, ys); @@ -2835,9 +2862,23 @@ bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) { int x, y, w, h; overflowClipBounds(node, x, y, w, h); - out = OverflowClipShape{x, y, w, h, false, {}, {}, 0, 0, 0, 0, {}, {}}; const bool transformed = ViewGeometry::hasTransformChain(node, false); - // The padding edge is rounded with the outer radii less the border widths. + // Clip commands carry int16 rects. Past that range the saturated rect + // clips exactly as much: no screen reaches beyond it. A transformed clip + // takes its rect from the projected corners instead. + if (!transformed) saturateRect16(x, y, w, h); + out = OverflowClipShape{}; + out.x = x; + out.y = y; + out.w = w; + out.h = h; +#if !GEA_CSS_SHAPED_CLIPS + // Every clip is a rectangle; a transformed box clips nothing. + return !transformed; +#else + // The common clip, an untransformed box without rounded corners, is a + // rectangle and leaves here: replay asks for this shape in its inner loops. + if (!transformed && !hasAnyRadius(node)) return true; const auto &s = node.computedStyle(); // A clip open on one axis stays a rectangle; a transformed one clips nothing. if (!overflowX(s) || !overflowY(s)) return !transformed; @@ -2878,6 +2919,7 @@ bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) } out.shaped = true; return true; +#endif } bool ViewRenderer::recordClipBegin(const Node &node) From 0c750b47b488dfd65e7e4102499e43fa1c17abb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 12 Oct 2026 22:16:45 +0300 Subject: [PATCH 47/50] Restyle a subtree without walking every selector to the root wrapped-layout-96 changes the root's class every frame, and the root class is an ancestor in selectors, so its ~290 nodes restyle. Alone the scene takes 437 us on desktop; in the suite 2050 us, 83% of it selector matching. The suite registers every scenario's CSS, so a row has about 180 candidate declarations (".X .row", ".X .label" of the other scenes; each declaration of a block is its own rule), and each one walked the ancestor chain to the root: about 52k selectorMatchesNode calls a frame. Apps are in the same place (weather registers ~686 rules). - Plan memo: building a node's plan matches each selector once, and the declarations of one block share the result. - Ancestor key filter: a subtree walk keeps the classes and tags of the ancestors in a 512-slot counting Bloom filter, as WebKit's SelectorFilter does. A plan whose ancestor parts are not all in it is rejected without the walk. - Block skip: a selector that rejects a declaration skips the rest of its block in one step. The filter slots of a plan and the rule flags are computed once when the index is built. - Signature: an id that no selector names leaves the signature (each row-N id made its own), and a full candidate cache evicts the oldest entry in turn (earlier screens' signatures held their slots forever). - Shared plans: in a walk, nodes whose signatures and their ancestors' signatures are equal pair by pair (a chain class, compared for equality, not hashed) use one plan, so the 96 rows, labels and icons each build theirs once. Only the outermost style recompute shares, so a nested one cannot overwrite an entry the outer one is using. Why the result is exact: the filter only gives false positives (a saturated counter says maybe); the ancestor parts of a plan, except a part matched against a sibling, must be ancestors of the node; the filter answers only for a node whose parent is the top of the chain, and a class or tag change (noteClassMutationForIncremental, setTagName) turns it off until the walk ends; a candidate that looks at position, hover or siblings is never stored for sharing; media queries do not change during a walk. A diagnostic build ran the full suite for 240 frames, built a reference plan with the old algorithm at every plan build and compared it rule by rule, and checked every filter answer against direct matching: 524,924 plans (334,701 shared) and 32.6 million filter answers, 0 mismatches. Restyles outside a subtree walk run without the filter and without sharing; the memo and the block skip apply. Static memory is ~5.8 KB of .bss (filter ~2.1 KB, four shared plans of ~612 B, 64 chain classes of 20 B), in internal RAM on the board; the capacities can shrink. Index build (mutation tier): one flag byte per rule, a 3-byte memo and the ancestor slots per plan. No allocation in the frame path; one 512-byte memset per walk. No gate: this is the style engine's general path, not a CSS family. No struct changes size. Desktop harness against the previous commit, 7 rounds x 240 frames, load ~3.5: wrapped-layout-96 2051.92 -> 392.63 us (-80.9%), flex-layout-96 -82.0%, grid-layout-64 -72.5%, flex-layout-16 -57.9%, dashboard-mixed-96 -57.5%, class-update, matching-sibling-update and selector-cascade-256 -47 to -48%, box-shadow-24 -61.8%, text-emphasis-24 -52.7%, border-image-24 -49.8%, line-clamp-24 -39.2%, rounded-update-96 -37.2%; 29/29 same. Scenes that do not restyle stay within +-4%, +-2.5% on a second run; horizontal-swipe +3.4% (0.08 us) and vertical-drag +2.4% are inside their 11-12% spread and 0% on the second run. The same path is in main and could land there on its own. Not measured on the board yet, where matching reads nodes and rules from PSRAM. Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/style.cpp | 474 +++++++++++++++++++++++++++++++---- 1 file changed, 431 insertions(+), 43 deletions(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 20b3907..831c77e 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -14019,6 +14019,129 @@ constexpr std::uint16_t kNoCachedStyleApplyOp = 0xFFFFu; bool ruleWriteMask(const CssRule &rule, PropertyWriteMask &mask); +// Ancestor key filter for a top-down subtree recompute (WebKit's +// SelectorFilter): a counting Bloom filter over the classes and tags of the +// ancestors of the node being restyled. A selector whose ancestor parts name a +// class or tag the filter lacks cannot match, so its ancestor walk is skipped. +// It answers only for a child of the last node on its chain, and any class or +// tag change switches it off until the walk that opened it ends. +class AncestorKeyFilter { + static constexpr int kSlotBits = 9; + +public: + bool active() const { return active_; } + bool covers(int node) const + { + return depth_ > 0 && !off_ && treeState().nodes[node].parent == chain_[depth_ - 1]; + } + bool mayHave(unsigned slot) const { return counts_[slot] != 0; } + // The chain class (SharedRulePlans) of the node on top, -1 for none. + int topClass() const { return depth_ > 0 ? chainClass_[depth_ - 1] : -1; } + // The class the plan build gave `node`, for when it goes on top. + void noteClass(int node, int chainClass) + { + pendingNode_ = node; + pendingClass_ = chainClass; + } + static unsigned slotForClass(CssAtomId cls) { return (static_cast(cls) * 2654435761u) >> (32 - kSlotBits); } + static unsigned slotForTag(int16_t tag) + { + return ((static_cast(static_cast(tag)) | 0x10000u) * 2654435761u) >> (32 - kSlotBits); + } + void invalidate() + { + if (active_) off_ = true; + } + + // Opens the filter for a walk from `root`, holding root's ancestors. + void begin(int root) + { + const auto &state = treeState(); + active_ = true; + int ancestors[kMaxDepth]; + int count = 0; + for (int a = state.nodes[root].parent; a >= 0 && a < state.nodeCount; a = state.nodes[a].parent) { + if (count == kMaxDepth) { + off_ = true; + return; + } + ancestors[count++] = a; + } + while (count > 0) { + const int ancestor = ancestors[--count]; + noteClass(ancestor, ancestorChainClass(ancestor)); + push(ancestor); + } + } + void end() + { + std::memset(counts_, 0, sizeof counts_); + depth_ = keyCount_ = 0; + pendingNode_ = pendingClass_ = -1; + active_ = off_ = false; + } + // Adds `node` on top of the chain. False when it did not enter (no pop). + bool push(int node) + { + if (off_ || depth_ == kMaxDepth) { + off_ = true; + return false; + } + const auto &state = treeState(); + keyStart_[depth_] = static_cast(keyCount_); + chainClass_[depth_] = static_cast(pendingNode_ == node ? pendingClass_ : -1); + chain_[depth_++] = node; + const NodeClassList &classes = state.classLists[node]; + for (std::size_t i = 0, n = classes.size(); i < n; ++i) add(slotForClass(classes.at(i))); + add(slotForTag(state.nodes[node].tag_id)); + return true; + } + void pop() + { + --depth_; + while (keyCount_ > keyStart_[depth_]) { + const unsigned slot = keys_[--keyCount_]; + if (counts_[slot] != 255) --counts_[slot]; + } + } + +private: + static constexpr int kMaxDepth = 64; + static constexpr int kMaxKeys = 512; + // A saturated counter never comes down: it can only answer "maybe". + void add(unsigned slot) + { + if (keyCount_ == kMaxKeys) { + off_ = true; + return; + } + keys_[keyCount_++] = static_cast(slot); + if (counts_[slot] != 255) ++counts_[slot]; + } + + std::uint8_t counts_[1 << kSlotBits] = {}; + std::uint16_t keys_[kMaxKeys]; + std::uint16_t keyStart_[kMaxDepth]; + std::int16_t chainClass_[kMaxDepth]; + int chain_[kMaxDepth]; + int pendingNode_ = -1; + int pendingClass_ = -1; + int ancestorChainClass(int node) const; + int depth_ = 0; + int keyCount_ = 0; + bool active_ = false; + bool off_ = false; +}; + +AncestorKeyFilter g_ancestorFilter; + +constexpr std::uint8_t kRuleSamePlanNext = 1; +constexpr std::uint8_t kRuleSignatureLocal = 2; +// The match depends only on the node's and its ancestors' classes, tag, id +// and root-ness: no :first-child, :last-child, :hover or sibling combinator. +constexpr std::uint8_t kRuleChainLocal = 4; +bool selectorRuleIsSignatureLocal(const CssRule &rule); + // Rule-matching index. A full class-style recompute is O(nodes x rules), and // with a large stylesheet (the weather app registers ~686 rules) scanning every // rule for every node dominates mount/recompute time. The index buckets rules by @@ -14063,6 +14186,9 @@ struct RuleIndex { // change to one of these keys restyles the siblings after the node. DenseIdSet siblingClasses; DenseIdSet siblingTags; + // Ids some selector names. Any other id cannot affect a match, so the + // candidate signature leaves it out and same-shaped nodes share entries. + DenseIdSet referencedIds; bool hasSiblingRules = false; // CSS specificity per rule (indexed by rule index), packed as // ids*10000 + (classes+pseudo-classes)*100 + (elements+pseudo-elements). Used to @@ -14076,6 +14202,28 @@ struct RuleIndex { // than one selector key. Serial 0 means "not seen". std::vector candidateSeen; std::uint16_t candidateSerial = 0; + // Selector matches of the node whose rule plan is being built, by selector + // plan: every declaration of a rule block is its own rule sharing one plan, + // and each used to walk the ancestor chain again. A result holds while its + // serial equals planSerial, which every plan build advances. + struct PlanMatch { + std::uint16_t serial; + std::uint8_t result; + }; + std::vector planMatch; + std::uint16_t planSerial = 0; + // The ancestor key filter answers for that node (AncestorKeyFilter), and + // the filter slots each plan's ancestor parts need: planKeySlots from + // planKeyBegin[plan] to planKeyBegin[plan + 1]. + bool planFiltered = false; + // Every candidate of the plan being built was chain-local (kRuleChainLocal). + bool planChainLocal = true; + std::vector planKeyBegin; + std::vector planKeySlots; + // Per rule: kRuleSamePlanNext (the next rule is the next declaration of + // the same selector block, so a selector that rejects one rejects both), + // kRuleSignatureLocal (selectorRuleIsSignatureLocal) and kRuleChainLocal. + std::vector ruleFlags; // Media-query results are viewport-global. Cache one active bit per rule so a // recompute over many nodes does not re-evaluate the same @media expressions. std::vector mediaMatches; @@ -14158,6 +14306,7 @@ void rebuildRuleIndexIfNeeded() g_ruleIndex.ancestorTags.clear(); g_ruleIndex.siblingClasses.clear(); g_ruleIndex.siblingTags.clear(); + g_ruleIndex.referencedIds.clear(); g_ruleIndex.hasSiblingRules = false; const auto &list = rules(); g_ruleIndex.specificity.assign(list.size(), 0); @@ -14165,6 +14314,8 @@ void rebuildRuleIndexIfNeeded() g_ruleIndex.styleOps.assign(list.size(), kNoCachedStyleApplyOp); g_ruleIndex.candidateSeen.assign(list.size(), 0); g_ruleIndex.candidateSerial = 0; + g_ruleIndex.planMatch.assign(selectorPlans().size(), {0, 0}); + g_ruleIndex.planSerial = 0; g_ruleIndex.mediaMatches.assign(list.size(), 0); g_ruleIndex.hasMediaConditions = false; g_ruleIndex.hasPseudoElementRules = false; @@ -14214,8 +14365,10 @@ void rebuildRuleIndexIfNeeded() const int16_t tag = plan->ancestorTags.at(ancestor); if (tag >= 0) g_ruleIndex.ancestorTags.insert(tag); } - for (std::size_t p = 0; p < plan->parts.size(); ++p) + for (std::size_t p = 0; p < plan->parts.size(); ++p) { if (plan->parts.at(p).sibling) g_ruleIndex.hasSiblingRules = true; + if (plan->parts.at(p).simple.idAtom != kInvalidCssAtom) g_ruleIndex.referencedIds.insert(plan->parts.at(p).simple.idAtom); + } for (std::size_t sibling = 0, count = plan->siblingClasses.size(); sibling < count; ++sibling) g_ruleIndex.siblingClasses.insert(plan->siblingClasses.at(sibling)); for (std::size_t sibling = 0, count = plan->siblingTags.size(); sibling < count; ++sibling) @@ -14234,6 +14387,36 @@ void rebuildRuleIndexIfNeeded() if (list[i].propertyKind == CssRuleProperty::Animation) g_ruleIndex.animationRules.push_back(i); #endif } + g_ruleIndex.ruleFlags.assign(list.size(), 0); + for (std::size_t i = 0; i < list.size(); ++i) { + std::uint8_t flags = selectorRuleIsSignatureLocal(list[i]) ? kRuleSignatureLocal : 0; + bool chainLocal = true; + if (list[i].selectorType == CssRule::SelectorType::Selector) { + if (const SelectorPlan *plan = selectorPlanForHandle(list[i].selectorPlan)) { + for (std::size_t p = 0; chainLocal && p < plan->parts.size(); ++p) { + const SelectorPart &part = plan->parts.at(p); + chainLocal = !part.sibling && !part.simple.wantsFirstChild && !part.simple.wantsLastChild && !part.simple.wantsHover; + } + } + } + if (chainLocal) flags |= kRuleChainLocal; + if (i + 1 < list.size() && list[i].selectorType == CssRule::SelectorType::Selector && + list[i + 1].selectorType == CssRule::SelectorType::Selector && list[i].selectorPlan != kNoSelectorPlan && + list[i].selectorPlan == list[i + 1].selectorPlan) + flags |= kRuleSamePlanNext; + g_ruleIndex.ruleFlags[i] = flags; + } + const auto &plans = selectorPlans(); + g_ruleIndex.planKeyBegin.assign(plans.size() + 1, 0); + g_ruleIndex.planKeySlots.clear(); + for (std::size_t p = 0; p < plans.size(); ++p) { + g_ruleIndex.planKeyBegin[p] = static_cast(g_ruleIndex.planKeySlots.size()); + for (std::size_t i = 0, n = plans[p].ancestorClasses.size(); i < n; ++i) + g_ruleIndex.planKeySlots.push_back(AncestorKeyFilter::slotForClass(plans[p].ancestorClasses.at(i))); + for (std::size_t i = 0, n = plans[p].ancestorTags.size(); i < n; ++i) + g_ruleIndex.planKeySlots.push_back(AncestorKeyFilter::slotForTag(plans[p].ancestorTags.at(i))); + } + g_ruleIndex.planKeyBegin[plans.size()] = static_cast(g_ruleIndex.planKeySlots.size()); g_ruleIndex.byClass.finalize(); g_ruleIndex.byTag.finalize(); g_ruleIndex.selById.finalize(); @@ -14280,6 +14463,34 @@ bool ruleMediaMatchesIndex(int ruleIndex) return g_ruleIndex.mediaMatches[static_cast(ruleIndex)] != 0; } +void nextPlanMatchSerial() +{ + std::uint16_t next = static_cast(g_ruleIndex.planSerial + 1u); + if (next == 0) { + for (RuleIndex::PlanMatch &memo : g_ruleIndex.planMatch) memo.serial = 0; + next = 1; + } + g_ruleIndex.planSerial = next; +} + +// selectorMatchesNode for the node of the current plan build, once per plan. +bool selectorMatchesPlanNode(const CssRule &rule, int node) +{ + const std::size_t handle = rule.selectorPlan; + if (handle + 1 >= g_ruleIndex.planKeyBegin.size()) return selectorMatchesNode(rule, node); + RuleIndex::PlanMatch &memo = g_ruleIndex.planMatch[handle]; + if (memo.serial != g_ruleIndex.planSerial) { + memo.serial = g_ruleIndex.planSerial; + bool matches = true; + if (g_ruleIndex.planFiltered) { + for (std::uint32_t k = g_ruleIndex.planKeyBegin[handle], end = g_ruleIndex.planKeyBegin[handle + 1]; matches && k < end; ++k) + matches = g_ancestorFilter.mayHave(g_ruleIndex.planKeySlots[k]); + } + memo.result = matches && selectorMatchesNode(rule, node) ? 1u : 0u; + } + return memo.result != 0; +} + std::uint16_t nextCandidateCollectSerial() { std::uint16_t next = static_cast(g_ruleIndex.candidateSerial + 1u); @@ -15528,16 +15739,10 @@ std::uint16_t cachedStyleApplyOpForCompiledValue(const CssCompiledValue *compile return static_cast(ops.size() - 1); } -void storeActiveRulePlanForSignature(const RuleCandidateSignature &signature, const ActiveRulePlan &plan) +// The plan's buckets as a cache entry; false when they do not fit. +bool flattenActiveRulePlan(const ActiveRulePlan &plan, ActiveRulePlanCacheEntry &entry) { - static constexpr std::size_t kActiveRulePlanCacheLimit = 64; - if (!activeRulePlanFitsCache(plan)) return; - auto &cache = activeRulePlanCache(); - if (cache.size() >= kActiveRulePlanCacheLimit) return; - cache.emplace_back(); - ActiveRulePlanCacheEntry &entry = cache.back(); - activeRulePlanCacheLastHit() = cache.size() - 1; - copyActiveRulePlanSignature(entry, signature); + if (!activeRulePlanFitsCache(plan)) return false; const auto &ruleList = rules(); std::size_t write = 0; for (int bucket = 0; bucket < kActiveRuleBucketCount; ++bucket) { @@ -15546,10 +15751,7 @@ void storeActiveRulePlanForSignature(const RuleCandidateSignature &signature, co entry.bucketCounts[bucket] = static_cast(count); entry.bucketOffsets[bucket] = static_cast(write); for (std::size_t i = 0; i < count; ++i) { - if (write >= kActiveRulePlanCacheRuleCapacity) { - cache.pop_back(); - return; - } + if (write >= kActiveRulePlanCacheRuleCapacity) return false; const int ri = src.at(i); if (ri < 0 || static_cast(ri) >= ruleList.size()) { entry.rules[write++] = {}; @@ -15569,6 +15771,115 @@ void storeActiveRulePlanForSignature(const RuleCandidateSignature &signature, co : cachedStyleApplyOpForCompiledValue(compiledCssValueForHandle(rule.compiledValue)); } } + return true; +} + +void storeActiveRulePlanForSignature(const RuleCandidateSignature &signature, const ActiveRulePlan &plan) +{ + static constexpr std::size_t kActiveRulePlanCacheLimit = 64; + if (!activeRulePlanFitsCache(plan)) return; + auto &cache = activeRulePlanCache(); + if (cache.size() >= kActiveRulePlanCacheLimit) return; + cache.emplace_back(); + ActiveRulePlanCacheEntry &entry = cache.back(); + activeRulePlanCacheLastHit() = cache.size() - 1; + copyActiveRulePlanSignature(entry, signature); + if (!flattenActiveRulePlan(plan, entry)) cache.pop_back(); +} + +bool captureRuleCandidateSignature(int selectorNode, RuleCandidateSignature &signature); + +// Plans shared along one top-down subtree recompute. Nodes whose signature +// and whose ancestors' signatures, pair by pair, are the same (one chain +// class) get the same rules from chain-local selectors (kRuleChainLocal), so +// a list of identical rows builds its row, label and icon plans once. +class SharedRulePlans { +public: + void clear() + { + classCount_ = 0; + for (std::int16_t &key : planKey_) key = 0; + nextPlan_ = 0; + inUse_ = -1; + } + // The class of a node with `signature` whose parent has `parentClass` + // (kAboveRoot for the tree root); -1 when the parent has none or the + // table is full. + static constexpr int kAboveRoot = -2; + int chainClass(int parentClass, const RuleCandidateSignature &signature) + { + if (parentClass == -1) return -1; + for (int i = 0; i < classCount_; ++i) + if (classes_[i].parent == parentClass && classes_[i].same(signature)) return i; + if (classCount_ == kMaxClasses) return -1; + ChainClass &added = classes_[classCount_]; + added.parent = static_cast(parentClass); + added.idAtom = signature.idAtom; + added.tagId = signature.tagId; + added.classCount = signature.classCount; + added.root = signature.root; + for (std::size_t i = 0; i < signature.classCount; ++i) added.classes[i] = signature.classes[i]; + return classCount_++; + } + const ActiveRulePlanCacheEntry *find(int chainClass) + { + for (int i = 0; i < kMaxPlans; ++i) { + if (planKey_[i] != chainClass + 1) continue; + inUse_ = i; + return &plans_[i]; + } + return nullptr; + } + // Never overwrites the entry find() last handed out. + void store(int chainClass, const ActiveRulePlan &plan) + { + int slot = nextPlan_; + if (slot == inUse_) slot = (slot + 1) % kMaxPlans; + nextPlan_ = (slot + 1) % kMaxPlans; + planKey_[slot] = 0; + if (flattenActiveRulePlan(plan, plans_[slot])) planKey_[slot] = static_cast(chainClass + 1); + } + +private: + static constexpr int kMaxClasses = 64; + static constexpr int kMaxPlans = 4; + // A parent class and a signature; plain fields, so the table is zero-initialized. + struct ChainClass { + std::int16_t parent; + CssAtomId classes[kRuleCandidateCacheClassCapacity]; + CssAtomId idAtom; + int16_t tagId; + std::uint8_t classCount; + bool root; + + bool same(const RuleCandidateSignature &signature) const + { + if (idAtom != signature.idAtom || tagId != signature.tagId || classCount != signature.classCount || root != signature.root) + return false; + for (std::size_t i = 0; i < classCount; ++i) + if (classes[i] != signature.classes[i]) return false; + return true; + } + }; + ChainClass classes_[kMaxClasses]; + ActiveRulePlanCacheEntry plans_[kMaxPlans]; + std::int16_t planKey_[kMaxPlans] = {}; // chain class + 1, 0 empty + int classCount_ = 0; + int nextPlan_ = 0; + int inUse_ = -1; +}; + +SharedRulePlans g_sharedPlans; +// recomputeNodeClassStyles calls in progress. Only the outermost shares +// plans: a nested one could overwrite the entry the outer one is applying. +int g_classRecomputeDepth = 0; + +int AncestorKeyFilter::ancestorChainClass(int node) const +{ + RuleCandidateSignature signature; + if (!captureRuleCandidateSignature(node, signature)) return -1; + const int parent = treeState().nodes[node].parent; + return g_sharedPlans.chainClass(parent < 0 ? SharedRulePlans::kAboveRoot : topClass(), signature); } bool selectorRuleIsSignatureLocal(const CssRule &rule) @@ -16238,7 +16549,8 @@ bool captureRuleCandidateSignature(int selectorNode, RuleCandidateSignature &sig for (std::size_t i = 0; i < classes.size(); ++i) signature.classes[i] = classes.at(i); sortRuleCandidateSignatureClasses(signature); - signature.idAtom = nodeIdAttributeAtom(selectorNode); + const CssAtomId idAtom = nodeIdAttributeAtom(selectorNode); + if (idAtom != kInvalidCssAtom && g_ruleIndex.referencedIds.contains(idAtom)) signature.idAtom = idAtom; signature.tagId = state.nodes[selectorNode].tag_id; signature.root = isRootNode(selectorNode); return true; @@ -16343,34 +16655,48 @@ const RuleCandidateCacheEntry *cachedRuleCandidateEntryForSignature(const RuleCa lastHit = i; return &g_ruleIndex.candidateCache[i]; } - if (g_ruleIndex.candidateCache.size() >= kRuleCandidateCacheLimit) return nullptr; - g_ruleIndex.candidateCache.emplace_back(); - RuleCandidateCacheEntry &entry = g_ruleIndex.candidateCache.back(); + // Full: take over the slots in turn, so the shapes of the current screen + // win over those an earlier screen left behind. + static std::size_t nextVictim = 0; + std::size_t slot; + if (g_ruleIndex.candidateCache.size() < kRuleCandidateCacheLimit) { + g_ruleIndex.candidateCache.emplace_back(); + slot = g_ruleIndex.candidateCache.size() - 1; + } else { + slot = nextVictim; + nextVictim = (nextVictim + 1) % kRuleCandidateCacheLimit; + } + RuleCandidateCacheEntry &entry = g_ruleIndex.candidateCache[slot]; copyCandidateSignature(entry, signature); buildRuleCandidatesForSignature(signature, entry.rules); entry.signatureLocal = candidateRulesAreSignatureLocal(entry.rules, rules()); - lastHit = g_ruleIndex.candidateCache.size() - 1; + lastHit = slot; return &entry; } -void addCachedActiveCandidateRule(ActiveRulePlan &plan, +// False when the rule's selector rejected the node. +bool addCachedActiveCandidateRule(ActiveRulePlan &plan, int selectorNode, int ruleIndex, const std::vector &ruleList, const std::vector *mediaMatches, const RuleCandidateSignature *signature = nullptr) { - if (ruleIndex < 0 || static_cast(ruleIndex) >= ruleList.size()) return; + if (ruleIndex < 0 || static_cast(ruleIndex) >= ruleList.size()) return true; if (mediaMatches && (static_cast(ruleIndex) >= mediaMatches->size() || (*mediaMatches)[static_cast(ruleIndex)] == 0)) - return; + return true; const CssRule &rule = ruleList[static_cast(ruleIndex)]; if (rule.selectorType == CssRule::SelectorType::Selector) { - if (signature && selectorRuleIsSignatureLocal(rule)) { - if (!selectorRuleMatchesSignature(rule, *signature)) return; - } else if (!selectorMatchesNode(rule, selectorNode)) { - return; + const bool known = static_cast(ruleIndex) < g_ruleIndex.ruleFlags.size(); + const std::uint8_t flags = known ? g_ruleIndex.ruleFlags[static_cast(ruleIndex)] : 0; + if (!(flags & kRuleChainLocal)) g_ruleIndex.planChainLocal = false; + const bool signatureLocal = known ? (flags & kRuleSignatureLocal) != 0 : selectorRuleIsSignatureLocal(rule); + if (signature && signatureLocal) { + if (!selectorRuleMatchesSignature(rule, *signature)) return false; + } else if (!selectorMatchesPlanNode(rule, selectorNode)) { + return false; } } const int bucket = activeRuleBucketFor(rule); @@ -16382,26 +16708,28 @@ void addCachedActiveCandidateRule(ActiveRulePlan &plan, if ((rule.declaration == CssDeclarationId::Transition || rule.declaration == CssDeclarationId::AnimationPlayState) && rule.pseudoElement == CssRule::PseudoElement::None) plan.push(kActiveMotion, ruleIndex); #endif + return true; } -void addActiveCandidateRule(ActiveRulePlan &plan, +// False when the rule's selector rejected the node. +bool addActiveCandidateRule(ActiveRulePlan &plan, int selectorNode, int ruleIndex, std::uint16_t serial, const std::vector &ruleList, const std::vector *mediaMatches) { - if (ruleIndex < 0 || static_cast(ruleIndex) >= g_ruleIndex.candidateSeen.size()) return; + if (ruleIndex < 0 || static_cast(ruleIndex) >= g_ruleIndex.candidateSeen.size()) return true; std::uint16_t &seen = g_ruleIndex.candidateSeen[static_cast(ruleIndex)]; - if (seen == serial) return; + if (seen == serial) return true; seen = serial; - if (static_cast(ruleIndex) >= ruleList.size()) return; + if (static_cast(ruleIndex) >= ruleList.size()) return true; if (mediaMatches && (static_cast(ruleIndex) >= mediaMatches->size() || (*mediaMatches)[static_cast(ruleIndex)] == 0)) - return; + return true; const CssRule &rule = ruleList[static_cast(ruleIndex)]; - if (rule.selectorType == CssRule::SelectorType::Selector && !selectorMatchesNode(rule, selectorNode)) return; + if (rule.selectorType == CssRule::SelectorType::Selector && !selectorMatchesPlanNode(rule, selectorNode)) return false; const int bucket = activeRuleBucketFor(rule); if (bucket >= 0) plan.push(bucket, ruleIndex); #if GEA_CSS_ANIMATIONS @@ -16411,6 +16739,23 @@ void addActiveCandidateRule(ActiveRulePlan &plan, if ((rule.declaration == CssDeclarationId::Transition || rule.declaration == CssDeclarationId::AnimationPlayState) && rule.pseudoElement == CssRule::PseudoElement::None) plan.push(kActiveMotion, ruleIndex); #endif + return true; +} + +// After a selector rejected rules[i], the number of following entries that +// are the next declarations of the same block (and so rejected as well). +template +std::size_t rejectedBlockTail(const Rules &rules, std::size_t i, std::size_t count) +{ + std::size_t skip = 0; + while (i + skip + 1 < count) { + const int ri = rules[i + skip]; + if (static_cast(ri) >= g_ruleIndex.ruleFlags.size() || + !(g_ruleIndex.ruleFlags[static_cast(ri)] & kRuleSamePlanNext) || rules[i + skip + 1] != ri + 1) + break; + ++skip; + } + return skip; } void addActiveCandidateRuleBucket(ActiveRulePlan &plan, @@ -16422,7 +16767,8 @@ void addActiveCandidateRuleBucket(ActiveRulePlan &plan, { if (bucket.empty()) return; for (std::size_t i = 0; i < bucket.count; ++i) - addActiveCandidateRule(plan, selectorNode, bucket.data[i], serial, ruleList, mediaMatches); + if (!addActiveCandidateRule(plan, selectorNode, bucket.data[i], serial, ruleList, mediaMatches)) + i += rejectedBlockTail(bucket.data, i, bucket.count); } void addActiveCandidateRuleBucket(ActiveRulePlan &plan, @@ -16433,8 +16779,9 @@ void addActiveCandidateRuleBucket(ActiveRulePlan &plan, const std::vector *mediaMatches) { if (bucket.empty()) return; - for (const int ri : bucket) - addActiveCandidateRule(plan, selectorNode, ri, serial, ruleList, mediaMatches); + for (std::size_t i = 0; i < bucket.size(); ++i) + if (!addActiveCandidateRule(plan, selectorNode, bucket[i], serial, ruleList, mediaMatches)) + i += rejectedBlockTail(bucket, i, bucket.size()); } void buildActiveRulePlanForNode(int selectorNode, ActiveRulePlan &plan) @@ -16445,23 +16792,40 @@ void buildActiveRulePlanForNode(int selectorNode, ActiveRulePlan &plan) const auto &state = treeState(); if (selectorNode < 0 || selectorNode >= state.nodeCount) return; if (isAnonymousTextNode(state.nodes[selectorNode])) return; + nextPlanMatchSerial(); + g_ruleIndex.planFiltered = g_ancestorFilter.covers(selectorNode); const auto &ruleList = rules(); const std::vector *mediaMatches = g_ruleIndex.hasMediaConditions ? &g_ruleIndex.mediaMatches : nullptr; RuleCandidateSignature signature; if (captureRuleCandidateSignature(selectorNode, signature)) { + int chainClass = -1; + if (g_ruleIndex.planFiltered) { + chainClass = g_sharedPlans.chainClass(g_ancestorFilter.topClass(), signature); + g_ancestorFilter.noteClass(selectorNode, chainClass); + } if (const ActiveRulePlanCacheEntry *cachedPlan = cachedActiveRulePlanForSignature(signature)) { setActiveRulePlanFromCache(plan, *cachedPlan); return; } - if (const RuleCandidateCacheEntry *candidateEntry = cachedRuleCandidateEntryForSignature(signature)) { - for (std::size_t i = 0, n = candidateEntry->rules.size(); i < n; ++i) { - const int ri = candidateEntry->rules[i]; - addCachedActiveCandidateRule(plan, selectorNode, ri, ruleList, mediaMatches, &signature); + if (g_classRecomputeDepth > 1) chainClass = -1; + if (chainClass >= 0) { + if (const ActiveRulePlanCacheEntry *shared = g_sharedPlans.find(chainClass)) { + setActiveRulePlanFromCache(plan, *shared); + return; } + } + if (const RuleCandidateCacheEntry *candidateEntry = cachedRuleCandidateEntryForSignature(signature)) { + g_ruleIndex.planChainLocal = true; + const RuleCandidateList &candidates = candidateEntry->rules; + for (std::size_t i = 0, n = candidates.size(); i < n; ++i) + if (!addCachedActiveCandidateRule(plan, selectorNode, candidates[i], ruleList, mediaMatches, &signature)) + i += rejectedBlockTail(candidates, i, n); collapseShadowedActiveRules(plan); if (candidateEntry->signatureLocal) storeActiveRulePlanForSignature(signature, plan); + else if (chainClass >= 0 && g_ruleIndex.planChainLocal) + g_sharedPlans.store(chainClass, plan); return; } } @@ -16926,6 +17290,7 @@ void recomputeNodeClassStyles(int node) { auto &state = treeState(); if (node < 0 || node >= state.nodeCount) return; + ++g_classRecomputeDepth; const ComputedStyle beforeStyle = state.nodes[node].computedStyle(); #if GEA_CSS_FIRST_LINE @@ -17027,6 +17392,7 @@ void recomputeNodeClassStyles(int node) #if GEA_CSS_CUSTOM_PROPERTIES g_recordingNode = prevRecordingNode; #endif + --g_classRecomputeDepth; } // When set (during the initial app mount), per-op class-style recomputes are @@ -17044,6 +17410,20 @@ bool g_styleMountBatchActive = false; // descendant classes — from N full/overlapping recomputes into one pass. std::vector g_pendingRecomputeRoots; +// `filtered`: the ancestor key filter holds node's ancestors, so node goes on +// top of it for its children. +void recomputeSubtreeWalk(int node, bool filtered) +{ + auto &state = treeState(); + recomputeNodeClassStyles(node); + const bool entered = filtered && g_ancestorFilter.push(node); + for (int child = state.nodes[node].first_child; child >= 0; child = state.nodes[child].next_sibling) { + if (isGeneratedPseudoNode(state.nodes[child])) continue; + recomputeSubtreeWalk(child, entered); + } + if (entered) g_ancestorFilter.pop(); +} + void recomputeSubtreeClassStyles(int node) { if (g_styleMountBatchActive) { @@ -17052,11 +17432,17 @@ void recomputeSubtreeClassStyles(int node) } auto &state = treeState(); if (node < 0 || node >= state.nodeCount) return; - recomputeNodeClassStyles(node); - for (int child = state.nodes[node].first_child; child >= 0; child = state.nodes[child].next_sibling) { - if (isGeneratedPseudoNode(state.nodes[child])) continue; - recomputeSubtreeClassStyles(child); + // A walk started from inside another leaves that walk's filter alone. + if (g_ancestorFilter.active()) { + recomputeSubtreeWalk(node, false); + return; } + rebuildRuleIndexIfNeeded(); + g_sharedPlans.clear(); + g_ancestorFilter.begin(node); + recomputeSubtreeWalk(node, true); + g_ancestorFilter.end(); + g_sharedPlans.clear(); } void recomputeDescendantClassStyles(int node) @@ -17410,6 +17796,7 @@ void snapshotCustomProperties(const NodeRareData *rareData, CustomPropertySnapsh void noteClassMutationForIncremental(int node, const NodeClassSnapshot &oldTokens) { + g_ancestorFilter.invalidate(); const auto &state = treeState(); if (node < 0 || node >= state.nodeCount) return; // A + or ~ selector keyed on a class this mutation added or removed @@ -19781,6 +20168,7 @@ void Tree::setTagName(int node, const char *tagName) const int16_t id = internTag(tagName ? tagName : ""); if (state.nodes[node].tag_id == id) return; state.nodes[node].tag_id = id; + g_ancestorFilter.invalidate(); recomputeSubtreeClassStyles(node); } From fe17141d0a35b3552baffa637be992d97b1d73bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 12 Oct 2026 22:16:45 +0300 Subject: [PATCH 48/50] Write one span per row in small opaque fills A small opaque fillRect read every pixel, wrote the ones that differed and marked each differing run dirty, one markDirty (a scan of the dirty rect list) per run. A row background whose colour toggles differs everywhere, so its 13 rows x 410 px compared one by one were half of a 200 us flex-layout-16 frame, and text over old text paid one markDirty per glyph stroke. Each row now finds its first and last differing pixel and writes the span between them with fillSpanColor, at store speed. Rows with the same span share one dirty rect; a row without a change is still read and skipped. The pixels are the same. The dirty rect may also cover pixels inside the span that already had the colour; that does not happen in the harness, where flush_pixels is identical in all 29 scenarios. Packed (e-paper) and large fills keep their paths. Pixel tier, every app's row and card backgrounds. The worst case is an unchanged row, read whole as before; a changed row costs two scans from the ends and a memset-speed write. No cull changes. No gate: a general raster path. Canvas::fillRect grows from 1728 to 2304 bytes on desktop (64-byte alignment included; the scan loops get unrolled), to be checked in the board build. Static checks in the default, RGBA8888, GRAY4 (packed), rotated and rotated GRAY4 configurations: warning counts unchanged. Desktop harness against the previous commit, under load from Spotlight but with the base within 1% of earlier clean runs and the candidate within 0.5% of a second run: suite total 10497 -> 8990 us (-14.4%), 29/29 same. flex-layout-16 201.6 -> 94.3 us (-53.2%), class-update, matching-sibling-update and selector-cascade-256 -51%, minimal-text-12 -39.3%, rounded-scroll -39.1%, text-update -36.6%, momentum-flick -33.7% (the scroll strips fill through here), line-clamp-24 -26.9%; vertical-drag +0.6% (0.04 us), nothing else above zero. In the flex-layout-16 profile fillRect falls from 110 us to ~15 us (9.5 us loop, 5.6 us memset), close to desktop store bandwidth. Not measured on the board yet; with the framebuffer in PSRAM the old path read every pixel from it. Co-Authored-By: Claude Opus 5.5 --- packages/engine/canvas.cpp | 54 ++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/packages/engine/canvas.cpp b/packages/engine/canvas.cpp index e0e107d..95da443 100644 --- a/packages/engine/canvas.cpp +++ b/packages/engine/canvas.cpp @@ -1768,10 +1768,11 @@ void Canvas::fillRect(int x, int y, int w, int h, pixel::native_t color) return; } // Large opaque fills (e.g. a full-screen page/paper background) skip the - // per-pixel occlusion scan below. That scan READS every pixel just to - // minimize the dirty rect — worth it for a small changed region, but pure - // overhead for a page-sized fill that gets repainted wholesale anyway. Here - // we memset each row (fillSpanColor) and mark the whole rect dirty once. + // per-pixel occlusion scan below. That scan READS pixels just to minimize + // the dirty rect (a whole row when nothing in it changes) — worth it for a + // small changed region, but pure overhead for a page-sized fill that gets + // repainted wholesale anyway. Here we memset each row (fillSpanColor) and + // mark the whole rect dirty once. // (Packed targets take the word-scan below for EVERY size: on e-paper the // erase fill repaints a mostly-unchanged page, and skipping the matching // words costs one sequential read instead of a full write-allocate pass — @@ -1782,8 +1783,8 @@ void Canvas::fillRect(int x, int y, int w, int h, pixel::native_t color) return; } #endif - for (int row = y0; row <= y1; row++) { #if GEA_PIXEL_STORAGE_PACKED + for (int row = y0; row <= y1; row++) { // Byte/word-wise occlusion scan: a packed byte holds kPxPerByte pixels, // so comparing whole bytes against the replicated value (and aligned // 32-bit words against it ×4) skips already-correct spans without @@ -1845,23 +1846,38 @@ void Canvas::fillRect(int x, int y, int w, int h, pixel::native_t color) markDirty(runStart, row, xe, row); runStart = -1; // keep the shared tail below a no-op } + if (runStart >= 0) markDirty(x0 + runStart, row, x1, row); + } #else - pixel::native_t *dst = &pixels_[rowToPhysical(row) * stride_ + x0]; - int runStart = -1; - for (int i = 0; i < count; i++) { - if (dst[i] == c) { - if (runStart >= 0) { - markDirty(x0 + runStart, row, x0 + i - 1, row); - runStart = -1; - } - continue; - } - if (runStart < 0) runStart = i; - dst[i] = c; + // One span per row, from the first to the last pixel the fill changes, + // written at store speed. Reading and comparing every pixel and marking + // each differing run dirty cost more than the fill itself: a row + // background whose colour toggles differs everywhere, and its 13 rows x + // 410 px compared one by one were half of a 200 us desktop frame. The + // pixels are the same; the dirty rect also covers pixels inside the span + // that already had the colour. Rows with the same span share one rect. + int spanX0 = 0, spanX1 = -1, spanY0 = 0; + for (int row = y0; row <= y1; row++) { + const int phys = rowToPhysical(row); + const pixel::native_t *dst = &pixels_[phys * stride_ + x0]; + int first = 0; + while (first < count && dst[first] == c) first++; + if (first == count) { + if (spanX1 >= 0) markDirty(spanX0, spanY0, spanX1, row - 1); + spanX1 = -1; + continue; } -#endif - if (runStart >= 0) markDirty(x0 + runStart, row, x1, row); + int last = count - 1; + while (dst[last] == c) last--; + fillSpanColor(phys, x0 + first, last - first + 1, c); + if (spanX1 >= 0 && x0 + first == spanX0 && x0 + last == spanX1) continue; + if (spanX1 >= 0) markDirty(spanX0, spanY0, spanX1, row - 1); + spanX0 = x0 + first; + spanX1 = x0 + last; + spanY0 = row; } + if (spanX1 >= 0) markDirty(spanX0, spanY0, spanX1, y1); +#endif } else if (globalAlpha_ != 0) { const int a = globalAlpha_; #if GEA_EMBEDDED_DISPLAY_ROTATE_LANDSCAPE From adc3c8728d39e738a3b150d231f68385d3319599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 12 Oct 2026 22:16:45 +0300 Subject: [PATCH 49/50] Read the tree state pointer inline treeState() is called per rule and per node in style, layout and paint order (257 call sites). Out of line it took about 10% of a desktop layout frame's samples. The pointer is now declared in tree_state.h and treeState() is inline: one load and a branch, and only the first call leaves the caller for createTreeState(), which keeps the lazy PSRAM allocation unchanged. Initialising the pointer statically would drop the branch but needs the state to exist before the first access, which static initialisation order does not promise. Static checks of tree_state.cpp, style.cpp, layout.cpp, render.cpp, tree_render.cpp and tree_nodes.cpp in the default, new-gates-off and refresh-perf configurations: warning counts unchanged. No other translation unit declares treeState(). Desktop harness against the previous commit: 29/29 same, flush_pixels unchanged, suite total 8999 -> 8870 us (-1.4%). flex-layout-96 261.7 -> 244.8 us (-6.4%), wrapped-layout-96 -6.2%, vertical-drag -6.0%, nested-scroll -5.5%, grid-layout-64 -5.3%, almost every other scenario -1 to -5%. line-clamp-24 is +1.2% (372.6 -> 377.2 us; 372.8 -> 377.4 us over 7 interleaved rounds, the ranges do not overlap). Counting builds show the same work on both sides: 388,986 textWithTransform and 213,655 measureTextLayoutForNode calls. The time goes into libc's strpbrk, the same calls on the same text about 40% slower each, while treeState itself drops 9.4 us in the same profile. An elimination build that keeps text.cpp on the out-of-line call does not remove it (375.8 us), so it is code placement elsewhere acting on the branchy strpbrk loop. Not scanning the whole text with strpbrk/strstr on every measure and draw (about 389k scans a suite run) would remove both this and the scan itself. Desktop __text grows by 4.5 KB (+0.14%, 3,291,852 -> 3,296,332 B). Not measured on the board yet; its code size needs checking against the flash limit. Co-Authored-By: Claude Opus 5.5 --- packages/engine/ui/tree_state.cpp | 7 ++++--- packages/engine/ui/tree_state.h | 13 ++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/engine/ui/tree_state.cpp b/packages/engine/ui/tree_state.cpp index 38b4ac1..16aaf54 100644 --- a/packages/engine/ui/tree_state.cpp +++ b/packages/engine/ui/tree_state.cpp @@ -762,8 +762,9 @@ const NodeCustomProperty *NodeCustomPropertyStore::getEntry(CssAtomId nameId) co // __cxa_guard_acquire CALL on every access — and treeState() sits on the hottest // style-recompute path (called per rule, per node). UI state is single-threaded // (the mount task and the frame task never run concurrently), so the guard is -// unnecessary; a zero-initialized pointer + null check is one load and a branch. -static TreeState *g_treeState = nullptr; +// unnecessary; a zero-initialized pointer + null check is one load and a branch, +// inlined into the caller (tree_state.h). +TreeState *g_treeState = nullptr; StorageUsage treeStorageUsage() { StorageUsage usage; @@ -773,7 +774,7 @@ StorageUsage treeStorageUsage() } -TreeState &treeState() +TreeState &createTreeState() { if (!g_treeState) { #if defined(ESP_PLATFORM) diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index 06e9b9e..bf310e0 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -446,6 +446,17 @@ struct TreeState { int hoveredNodeId = -1; }; -TreeState &treeState(); +// Created by the first treeState() call (tree_state.cpp). treeState() is read +// per rule and per node in style, layout and paint order; out of line, the call +// took ~10% of a desktop layout frame's samples (inline: flex-layout-96 -6%), so +// the pointer is read inline and only the first call leaves the caller. +extern TreeState *g_treeState; +TreeState &createTreeState(); + +inline TreeState &treeState() +{ + if (g_treeState) return *g_treeState; + return createTreeState(); +} } // namespace gea::embedded::ui From 8d721ae1fe5d78ce83eb1bc110085ebb4d4604e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 13 Oct 2026 00:41:37 +0300 Subject: [PATCH 50/50] Keep whether a text collapses with the text, not scanned per draw textWithTransform scanned the text with strpbrk and strstr on every measure and every draw to decide whether white-space collapsing would change it: about 389k scans in one run of the desktop CSS suite, and in line-clamp-24, 24 paragraphs long enough for the scan to show, 40 us of a 377 us frame. The answer depends on the text alone. NodeText::assign now computes it once (hasCollapsibleWhitespace) and keeps it in the top bit of the pool handle, so a node stays four bytes and reading it is one load. The paths that measure or paint a node's text read the bit (textForNode); the draw path, which only sees the command, gets it from the recorder in DisplayCommand::text.collapsible along with the text's byte count. Text without a node keeps the scan: projected labels through TextRenderer::prepareText, and the long drawWrapped the tests call. The DrawText fields are ordered by size so the command keeps its size in every pixel format: DisplayCommand 80/88/80 bytes and Node 368/384/364 in the default, RGBA8888 and GRAY4 builds, as before; NodeText 4. The line-break cache keyed a text by its pointer and a hash of its first 16 bytes. A text assigned into the same buffer with the same prefix and a different rest hit the entry and painted the previous text's line breaks, cutting its last line. The key is now the byte count and the first and last 16 bytes (the whole text up to 32); the count comes from the command, so no strlen. The desktop suite registers no font asset and paints every text through the 8x16 bitmap path, where the cache does not exist, so the harness cannot show the fix: a wrapped-text-update-24 case built for it (24 wrapped paragraphs whose text alternates between a short and a long version sharing a prefix) compares same against a build whose cache never hits. Board apps with TrueType fonts run that path. Static checks of text.cpp, render.cpp, tree_state.cpp, input_render.cpp, layout.cpp and tree_nodes.cpp in the default, new-gates-off, refresh-perf, RGBA8888, GRAY4 and no-generated-fonts builds: warning counts unchanged. Desktop harness against the previous commit, 7 rounds x 240 frames: 29/29 same, flush_pixels unchanged, suite total 8871 -> 8783 us (-1.0%). line-clamp-24 377.0 -> 333.1 us (-11.7%), minimal-text-12 -6.3%, multicol-3 -4.1%, text-update -2.6%, rounded-scroll -2.0%, wrapped-layout-96 -1.8%; the new wrapped-text-update-24 308.3 -> 272.1 us (-11.7%) in a 30-case run (9211 -> 9059 us, 30/30 same). vertical-drag +3.2% (0.21 us, base spread 37%) and horizontal-swipe +1.8% (0.04 us) in the first run are +0.0% in the second; sticky-scroll-96 +1.9% in the second run is -0.3% in the first with equal p95. Desktop __text grows by 512 bytes. Not measured on the board yet. Co-Authored-By: Claude Fable 5.1 --- packages/engine/ui/input_render.cpp | 3 + packages/engine/ui/internal.h | 44 ++++--- packages/engine/ui/node_text.h | 22 ++++ packages/engine/ui/render.cpp | 7 +- packages/engine/ui/text.cpp | 171 ++++++++++++++++++++-------- packages/engine/ui/tree_state.cpp | 9 +- 6 files changed, 180 insertions(+), 76 deletions(-) diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index 75d5cde..934008a 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -175,6 +175,9 @@ void InputRenderer::record(int id) cmd->text.ellipsisWidth = 0; cmd->text.emphasis = 0; cmd->text.emphasisColor = 0; + const std::size_t contentBytes = std::strlen(content); + cmd->text.textBytes = static_cast(contentBytes > 65535 ? 65535 : contentBytes); + cmd->text.collapsible = hasCollapsibleWhitespace(content) ? 1 : 0; } } diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 19dffc2..135d6ea 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -202,22 +202,20 @@ void rootScrollImageHoldTick(); int16_t rx8[4], ry8[4]; uint8_t cssRadii; } strokeRoundedRect; + // Fields in size order, so the struct keeps its size in every pixel + // format (native_t is two or four bytes). struct { const char *text; - int16_t x, y, maxWidth; - gea::framework::graphics::pixel::native_t color; float scale; - int8_t align; - int8_t textTransform; + gea::framework::graphics::pixel::native_t color; + // text-emphasis marks (ComputedStyle::text_emphasis bits, 0 = none), + // painted in emphasisColor unless the bits say transparent. + gea::framework::graphics::pixel::native_t emphasisColor; + int16_t x, y, maxWidth; int16_t lineHeight; int16_t containerWidth; int16_t fontId; - // CSS white-space / text-overflow, carried so the deferred draw - // can reproduce single-line truncation. whiteSpace==1 => nowrap - // (no width-based wrapping); textOverflow==1 => ellipsis ("..."). - int8_t whiteSpace; - int8_t textOverflow; // Content-box height. With ellipsis on WRAPPED text this caps the // line budget (height / line advance) and the last line ellipsizes // — the multi-line clamp. 0 = unlimited (draw every line). @@ -225,10 +223,6 @@ void rootScrollImageHoldTick(); // Inline continuation offset for the run's FIRST line only (see // LayoutBox::inline_indent). 0 for every run that starts its own line. int16_t firstLineIndent; - // text-align-last: -1 when every line uses `align`. Otherwise bits 0-1 - // align lines a forced break ends, and bit 2 marks a run whose final - // line also ends its paragraph. - int8_t alignLast; // line-clamp: paint at most this many lines (0 = all). int16_t lineLimit; // block-ellipsis on the last painted line: 0 = none, @@ -236,10 +230,23 @@ void rootScrollImageHoldTick(); // That line may use ellipsisWidth px from x, up to its line box end. uint16_t blockEllipsis; int16_t ellipsisWidth; - // text-emphasis marks (ComputedStyle::text_emphasis bits, 0 = none), - // painted in emphasisColor unless the bits say transparent. + // strlen(text), saturated: part of the line-break cache key. + uint16_t textBytes; + int8_t align; + int8_t textTransform; + // CSS white-space / text-overflow, carried so the deferred draw + // can reproduce single-line truncation. whiteSpace==1 => nowrap + // (no width-based wrapping); textOverflow==1 => ellipsis ("..."). + int8_t whiteSpace; + int8_t textOverflow; + // text-align-last: -1 when every line uses `align`. Otherwise bits 0-1 + // align lines a forced break ends, and bit 2 marks a run whose final + // line also ends its paragraph. + int8_t alignLast; uint8_t emphasis; - gea::framework::graphics::pixel::native_t emphasisColor; + // NodeText::collapsible of the text: white-space collapsing may + // change it, so the draw normalizes a copy. 0 skips that outright. + uint8_t collapsible; } text; struct { @@ -644,6 +651,8 @@ void rootScrollImageHoldTick(); public: // Shared preparation for measuring and replaying authored text. Storage // owns the result only when collapsing or case transformation is needed. + // For text that has no node (projected labels): scans it for collapsible + // whitespace, which a node's text carries (NodeText::collapsible). static const char *prepareText(const char *text, int textTransform, int whiteSpace, std::string &storage); static void layout(int id, int availableWidth); static int baselineOffset(const Node &node, bool last); @@ -665,6 +674,9 @@ void rootScrollImageHoldTick(); static void record(const Node &node, uint8_t parentAlpha = 255); static void unionCoverageRow(const DisplayCommand &command, int screenY, int screenX, int width, uint8_t *outCoverage); + // Paints a DrawText command. + static void drawWrapped(const DisplayCommand &command); + // For callers that hold no command (tests): scans the text as record() would. static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform = 0, int lineHeight = 0, int whiteSpace = 0, int textOverflow = 0, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, int blockEllipsis = 0, int ellipsisWidth = 0, diff --git a/packages/engine/ui/node_text.h b/packages/engine/ui/node_text.h index d4a983f..3fec3f1 100644 --- a/packages/engine/ui/node_text.h +++ b/packages/engine/ui/node_text.h @@ -6,6 +6,22 @@ namespace gea::embedded::ui { +// True when CSS white-space collapsing would change `text`: a tab, newline, +// carriage return or form feed, or two spaces in a row. The answer is kept +// with the text when it is assigned, so measuring and painting read a bit +// instead of scanning the text twice per call (about 389k scans in one run +// of the desktop CSS suite, 40 us a frame of a 24-paragraph scene). +inline bool hasCollapsibleWhitespace(const char *text) +{ + if (!text) return false; + for (const char *p = text; *p; ++p) { + const char c = *p; + if (c == '\t' || c == '\n' || c == '\r' || c == '\f') return true; + if (c == ' ' && p[1] == ' ') return true; + } + return false; +} + // Empty nodes own no string storage. Nonempty text lives in stable pooled // records, preserving c_str() addresses while other nodes are created/removed. // Copies own independent records: retained display commands can scrub one @@ -36,6 +52,8 @@ class NodeText void clear(); void assign(const char *text); bool empty() const { return handle_ == kEmpty; } + // hasCollapsibleWhitespace() of the text, computed by assign(). + bool collapsible() const { return handle_ != kEmpty && (handle_ & kCollapsible) != 0; } const std::string &str() const; operator const std::string &() const { return str(); } const char *c_str() const { return str().c_str(); } @@ -79,6 +97,10 @@ class NodeText private: static constexpr std::uint32_t kEmpty = UINT32_MAX; + // The pool index in the low bits; the top bit is collapsible(), so the + // node stays four bytes. kEmpty has every bit set and is tested whole. + static constexpr std::uint32_t kCollapsible = 0x80000000u; + std::uint32_t index() const { return handle_ & ~kCollapsible; } std::uint32_t handle_ = kEmpty; }; } // namespace gea::embedded::ui diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 1c26e48..afb7d49 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -7950,12 +7950,7 @@ int gLastScrollUiFrame = -1000; drawProjectedText(c); break; case DisplayCommandType::DrawText: - TextRenderer::drawWrapped(c.text.text, c.text.x, c.text.y, - c.text.maxWidth, c.text.color, c.text.scale, - c.text.align, c.text.containerWidth, c.text.fontId, c.text.textTransform, c.text.lineHeight, - c.text.whiteSpace, c.text.textOverflow, c.text.maxHeight, c.text.firstLineIndent, - c.text.alignLast, c.text.lineLimit, - c.text.blockEllipsis, c.text.ellipsisWidth, c.text.emphasis, c.text.emphasisColor); + TextRenderer::drawWrapped(c); break; case DisplayCommandType::BlitImage: #if GEA_PIXEL_STORAGE_PACKED diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index 16d0ecd..41f41df 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -116,7 +116,7 @@ static_assert(kMaxCachedLines > 0); struct LineBreakCacheEntry { const char *textPtr = nullptr; - std::uint32_t textHashPrefix = 0; + std::uint32_t textHash = 0; int maxWidth = -1; // The first line of an inline continuation gets a different budget and origin // than the rest, so two runs of the same string at the same maxWidth can break @@ -133,17 +133,18 @@ struct LineBreakCacheEntry { LineBreakCacheEntry gLineBreakCache[kLineBreakCacheSlots]; -inline std::uint32_t hashTextPrefix(const char *text) +inline std::uint32_t hashTextEnds(const char *text, int bytes) { - // 32-bit FNV-1a over first 16 bytes (or up to NUL). Cheap and good - // enough to disambiguate text-mutations on the same buffer. + // 32-bit FNV-1a over the byte count and the first and last 16 bytes (the + // whole text up to 32). A hash of the first 16 bytes alone took a text + // that reused another's buffer and shared its prefix for that text, and + // painted its line breaks, cutting the last line. std::uint32_t h = 2166136261u; - for (int i = 0; i < 16; i++) { - const unsigned char c = static_cast(text[i]); - if (!c) break; - h ^= c; - h *= 16777619u; - } + h = (h ^ static_cast(bytes & 0xff)) * 16777619u; + h = (h ^ static_cast((bytes >> 8) & 0xff)) * 16777619u; + const int head = bytes < 16 ? bytes : 16; + for (int i = 0; i < head; i++) h = (h ^ static_cast(text[i])) * 16777619u; + for (int i = bytes > 32 ? bytes - 16 : head; i < bytes; i++) h = (h ^ static_cast(text[i])) * 16777619u; return h; } @@ -498,13 +499,27 @@ std::string transformedTextCopy(const char *text, int textTransform) return out; } -const char *textWithTransform(const char *text, int textTransform, std::string &storage, int whiteSpace = -1) +inline bool collapsesWhiteSpace(int whiteSpace) +{ + // normal, nowrap and pre-line collapse spaces; pre and pre-wrap keep them. + return whiteSpace == 0 || whiteSpace == 1 || whiteSpace == 4; +} + +inline int saturatedTextBytes(std::size_t bytes) +{ + return bytes > 65535 ? 65535 : static_cast(bytes); +} + +// `collapsible` is hasCollapsibleWhitespace() of `text`: NodeText::collapsible +// for a node's own text, computed by the caller for other text, false for text +// already normalized. It stands in for the strpbrk and strstr scan this +// function made on every measure and draw. +const char *textWithTransform(const char *text, int textTransform, std::string &storage, int whiteSpace, bool collapsible) { // Perform CSS segment-break/space collapsing identically in measurement // and painting, while retaining the authored Node::text for later mutation. // Edge trimming belongs to line layout, not to individual inline runs. - if (text && (whiteSpace == 0 || whiteSpace == 1 || whiteSpace == 4) && - (std::strpbrk(text, "\t\r\n\f") || std::strstr(text, " "))) { + if (text && collapsible && collapsesWhiteSpace(whiteSpace)) { std::string normalized; normalized.reserve(std::strlen(text)); bool space = false; @@ -531,6 +546,12 @@ const char *textWithTransform(const char *text, int textTransform, std::string & return storage.c_str(); } +// A node's own text, normalized and transformed as its style says. +const char *textForNode(const Node &node, std::string &storage) +{ + return textWithTransform(node.text.c_str(), node.computedStyle().text_transform, storage, node.computedStyle().white_space, node.text.collapsible()); +} + struct TextMeasure { int width = 0; int height = 0; @@ -1006,11 +1027,11 @@ int leadingCollapsibleBytes(const char *text) return static_cast(p - text); } -TextLayoutMeasure measureTextLayoutForNode(const Node &node, const char *text, int contentWidth) +TextLayoutMeasure measureTextLayoutForNode(const Node &node, int contentWidth) { if (contentWidth < 0) contentWidth = 0; std::string transformed; - const char *measureText = textWithTransform(text, node.computedStyle().text_transform, transformed, node.computedStyle().white_space); + const char *measureText = textForNode(node, transformed); // white-space: nowrap lays the run out as a single line — measure against an // unbounded width so the content box sizes to the full text (then max-width / // the parent box clamps it). Drawing truncates/ellipsizes the overflow. @@ -1033,11 +1054,13 @@ TextLayoutMeasure measureTextLayoutForNode(const Node &node, const char *text, i return out; } -int measureSingleLineTextWidthForNode(const Node &node, const char *text) +// `text` is measured in the node's style and need not be the node's own; +// `collapsible` as for textWithTransform. +int measureSingleLineTextWidthForNode(const Node &node, const char *text, bool collapsible) { if (!text || !text[0]) return 0; std::string transformed; - const char *measureText = textWithTransform(text, node.computedStyle().text_transform, transformed, node.computedStyle().white_space); + const char *measureText = textWithTransform(text, node.computedStyle().text_transform, transformed, node.computedStyle().white_space, collapsible); return TextMetrics::measure(measureText, 32767, node.computedStyle().font_id, @@ -1088,12 +1111,7 @@ class TextDrawer { sink.screenX = screenX; sink.width = width; sink.coverage = outCoverage; - drawWrapped(command.text.text, command.text.x, command.text.y, command.text.maxWidth, - command.text.color, command.text.scale, command.text.align, command.text.containerWidth, - command.text.fontId, command.text.textTransform, command.text.lineHeight, - command.text.whiteSpace, command.text.textOverflow, command.text.maxHeight, - command.text.firstLineIndent, command.text.alignLast, command.text.lineLimit, - command.text.blockEllipsis, command.text.ellipsisWidth, command.text.emphasis, command.text.emphasisColor, &sink); + drawWrapped(command, &sink); } #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS @@ -1147,11 +1165,27 @@ class TextDrawer { } } - static void drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int textAlign, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight = 0, int firstLineIndent = 0, int alignLast = -1, int lineLimit = 0, int blockEllipsis = 0, int ellipsisWidth = 0, int emphasis = 0, gea::framework::graphics::pixel::native_t emphasisColor = 0, TextCoverageSink *coverageSink = nullptr) + // Paints a DrawText command, normalizing its authored text as layout did + // when the command says white-space collapsing may change it. + static void drawWrapped(const DisplayCommand &command, TextCoverageSink *coverageSink = nullptr) { + const char *text = command.text.text; if (!text || !text[0]) return; + const int x = command.text.x, y = command.text.y; + int maxWidth = command.text.maxWidth; + const gea::framework::graphics::pixel::native_t color = command.text.color; + const float scale = command.text.scale; + const int textAlign = command.text.align, containerWidth = command.text.containerWidth, fontId = command.text.fontId; + const int textTransform = command.text.textTransform, lineHeight = command.text.lineHeight, whiteSpace = command.text.whiteSpace; + const int textOverflow = command.text.textOverflow, maxHeight = command.text.maxHeight, firstLineIndent = command.text.firstLineIndent; + const int alignLast = command.text.alignLast, lineLimit = command.text.lineLimit, blockEllipsis = command.text.blockEllipsis; + const int ellipsisWidth = command.text.ellipsisWidth, emphasis = command.text.emphasis; + const gea::framework::graphics::pixel::native_t emphasisColor = command.text.emphasisColor; std::string transformed; - text = textWithTransform(text, textTransform, transformed, whiteSpace); + text = textWithTransform(text, textTransform, transformed, whiteSpace, command.text.collapsible != 0); + // Keys the line-break cache: the command's count for the authored text, + // the copy's once it was normalized or transformed. + const int textBytes = text == command.text.text ? command.text.textBytes : saturatedTextBytes(transformed.size()); // Pre preserves forced breaks and paints every line without soft wrap. if (whiteSpace == 2) maxWidth = 32767; const BlockEllipsis ellipsis{blockEllipsis == kAutoBlockEllipsis ? "\xE2\x80\xA6" : blockEllipsis ? cssAtomText(static_cast(blockEllipsis)) : nullptr, @@ -1189,13 +1223,15 @@ class TextDrawer { if (noWrap) drawRasterizedSingleLine(text, x, y, maxWidth, color, singleLineAlign, containerWidth, fontId, fontSize, lineHeight, overflowEllipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); else - drawRasterizedWrapped(text, x, y, maxWidth, color, textAlign, containerWidth, fontId, fontSize, lineHeight, clipX0, clipY0, clipX1, clipY1, + drawRasterizedWrapped(text, textBytes, x, y, maxWidth, color, textAlign, containerWidth, fontId, fontSize, lineHeight, clipX0, clipY0, clipX1, clipY1, overflowEllipsis ? maxHeight : 0, firstLineIndent, alignLast, hangSpaces, lineLimit, ellipsis, coverageSink); return; } } #else (void)fontId; + (void)textBytes; + (void)maxHeight; #endif if (noWrap) drawBitmapSingleLine(text, x, y, maxWidth, color, scale, singleLineAlign, containerWidth, lineHeight, overflowEllipsis, clipX0, clipY0, clipX1, clipY1, coverageSink); @@ -1340,7 +1376,7 @@ class TextDrawer { else gea::platform::display::Display::drawTextFontFamily(buf, lineX, lineBoxY, color, fontId, fontSize); } - static void drawRasterizedWrapped(const char *text, int x, int y, int maxWidth, std::uint16_t color, int textAlign, int containerWidth, int fontId, int fontSize, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int ellipsisMaxHeight = 0, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, int lineLimit = 0, const BlockEllipsis &ellipsis = {}, const TextCoverageSink *coverageSink = nullptr) + static void drawRasterizedWrapped(const char *text, int textBytes, int x, int y, int maxWidth, std::uint16_t color, int textAlign, int containerWidth, int fontId, int fontSize, int lineHeight, int clipX0, int clipY0, int clipX1, int clipY1, int ellipsisMaxHeight = 0, int firstLineIndent = 0, int alignLast = -1, bool hangSpaces = false, int lineLimit = 0, const BlockEllipsis &ellipsis = {}, const TextCoverageSink *coverageSink = nullptr) { // Inline continuation: the run's first line starts `firstLineIndent` px in // from the box's left edge (the pen position it inherited from the box @@ -1386,12 +1422,12 @@ class TextDrawer { // Cache lookup: if the same (text content, layout params) was laid out // previously, skip the per-glyph advance loop and iterate the cached // chars[] array directly. See LineBreakCacheEntry comment. - const std::uint32_t hash = hashTextPrefix(text); + const std::uint32_t hash = hashTextEnds(text, textBytes); const int slot = lineBreakCacheSlot(text, maxWidth); LineBreakCacheEntry &entry = gLineBreakCache[slot]; const bool cacheHit = entry.textPtr == text && - entry.textHashPrefix == hash && + entry.textHash == hash && entry.maxWidth == maxWidth && entry.firstLineIndent == firstLineIndent && entry.fontId == fontId && @@ -1400,7 +1436,7 @@ class TextDrawer { if (!cacheHit) { entry.textPtr = text; - entry.textHashPrefix = hash; + entry.textHash = hash; entry.maxWidth = maxWidth; entry.firstLineIndent = firstLineIndent; entry.fontId = fontId; @@ -1877,12 +1913,14 @@ void Tree::setText(int node, const char *text) int gid_preW = 0; int gid_rightW = 0; if (gid_canPartial) { + // Pieces of either text may collapse when either one does. + const bool gid_collapsible = target.text.collapsible() || hasCollapsibleWhitespace(text); const std::string gid_pre = gid_new.substr(0, static_cast(gid_prefix)); const std::string gid_oldHead = target.text.substr(0, static_cast(gid_oldLen - gid_suffix)); const std::string gid_newHead = gid_new.substr(0, static_cast(gid_newLen - gid_suffix)); - gid_preW = measureSingleLineTextWidthForNode(target, gid_pre.c_str()); - const int gid_oldHeadW = measureSingleLineTextWidthForNode(target, gid_oldHead.c_str()); - const int gid_newHeadW = measureSingleLineTextWidthForNode(target, gid_newHead.c_str()); + gid_preW = measureSingleLineTextWidthForNode(target, gid_pre.c_str(), gid_collapsible); + const int gid_oldHeadW = measureSingleLineTextWidthForNode(target, gid_oldHead.c_str(), gid_collapsible); + const int gid_newHeadW = measureSingleLineTextWidthForNode(target, gid_newHead.c_str(), gid_collapsible); if (gid_oldHeadW == gid_newHeadW) { // The unchanged suffix stays at the same x position, so it does not need // repainting; stop at the end of the changed head. @@ -1890,8 +1928,8 @@ void Tree::setText(int node, const char *text) } else { // The suffix moved, or the new run is shorter. Cover both complete run // extents so all shifted/removed glyph pixels are restored from backdrop. - const int gid_oldTextW = measureSingleLineTextWidthForNode(target, target.text.c_str()); - const int gid_newTextW = measureSingleLineTextWidthForNode(target, gid_new.c_str()); + const int gid_oldTextW = measureSingleLineTextWidthForNode(target, target.text.c_str(), gid_collapsible); + const int gid_newTextW = measureSingleLineTextWidthForNode(target, gid_new.c_str(), gid_collapsible); gid_rightW = gid_oldTextW > gid_newTextW ? gid_oldTextW : gid_newTextW; } } @@ -1934,7 +1972,7 @@ void Tree::setText(int node, const char *text) const int content_w = fixedWidth ? fixedBoxWidth - target.computedStyle().padding[1] - target.computedStyle().padding[3] : 0x4000; // 16384 px — well beyond any panel dimension - const TextLayoutMeasure measured = measureTextLayoutForNode(target, target.text.c_str(), content_w); + const TextLayoutMeasure measured = measureTextLayoutForNode(target, content_w); if (absolute) { target.layout.width = measured.width; target.layout.height = measured.height; @@ -2019,7 +2057,7 @@ int TextRenderer::baselineOffset(const Node &node, bool last) int offset = boxInset(node.computedStyle(), 0) + halfLeading + font.ascender(); if (last) { std::string storage; - const char *text = prepareText(node.text.c_str(), node.computedStyle().text_transform, node.computedStyle().white_space, storage); + const char *text = textForNode(node, storage); const int width = node.computedStyle().white_space == 1 || node.computedStyle().white_space == 2 ? 32767 : std::max(1, node.layout.width - boxInsets(node.computedStyle(), true)); const auto measured = TextMetrics::measure(text, width, node.computedStyle().font_id, fontSize, node.computedStyle().line_height, node.computedStyle().font_weight); @@ -2042,7 +2080,7 @@ InlineFlowMeasure TextRenderer::measureInlineFlow(const Node &node, int firstAva if (!advance.valid || advance.lineAdvance <= 0) return out; std::string transformed; - const char *text = textWithTransform(raw, node.computedStyle().text_transform, transformed, node.computedStyle().white_space); + const char *text = textForNode(node, transformed); if (!text || !text[0]) return out; if (contentWidth < 1) contentWidth = 1; @@ -2096,7 +2134,7 @@ InlineFlowMeasure TextRenderer::measureInlineFlow(const Node &node, int firstAva int TextRenderer::firstUnbreakableWidth(const Node &node) { std::string storage; - const char *text = prepareText(node.text.c_str(), node.computedStyle().text_transform, node.computedStyle().white_space, storage); + const char *text = textForNode(node, storage); if (!text) return 0; if (node.computedStyle().white_space == 0 || node.computedStyle().white_space == 1 || node.computedStyle().white_space == 4) text += leadingCollapsibleBytes(text); @@ -2108,20 +2146,21 @@ int TextRenderer::firstUnbreakableWidth(const Node &node) if (cp == '\n' || (wrapping && isWrappingSpace(cp))) { end = start; break; } if (wrapping && canBreakAfter(cp)) break; } - return measureSingleLineTextWidthForNode(node, std::string(text, end - text).c_str()); + // Already normalized above. + return measureSingleLineTextWidthForNode(node, std::string(text, end - text).c_str(), false); } int TextRenderer::minContentWidth(const Node &node, int *pendingWord) { std::string storage; - const char *text = prepareText(node.text.c_str(), node.computedStyle().text_transform, node.computedStyle().white_space, storage); + const char *text = textForNode(node, storage); const bool wrapping = node.computedStyle().white_space != 1 && node.computedStyle().white_space != 2; const char *start = text; int width = 0, current = pendingWord ? *pendingWord : 0; for (const char *p = text;; ++p) { if (!*p || *p == '\n' || (wrapping && (*p == ' ' || *p == '\t'))) { const std::string word(start, p - start); - current += measureSingleLineTextWidthForNode(node, word.c_str()); + current += measureSingleLineTextWidthForNode(node, word.c_str(), false); width = std::max(width, current); if (!*p) { if (pendingWord) *pendingWord = current; break; } current = 0; @@ -2133,7 +2172,7 @@ int TextRenderer::minContentWidth(const Node &node, int *pendingWord) const char *TextRenderer::prepareText(const char *text, int textTransform, int whiteSpace, std::string &storage) { - return textWithTransform(text, textTransform, storage, whiteSpace); + return textWithTransform(text, textTransform, storage, whiteSpace, collapsesWhiteSpace(whiteSpace) && hasCollapsibleWhitespace(text)); } void TextRenderer::layout(int id, int avail_w) @@ -2146,7 +2185,7 @@ void TextRenderer::layout(int id, int avail_w) else if ((n->computedStyle().width_percent != kUnset || n->computedStyle().width_expression >= 0) && n->layout.width > 0) content_w = n->layout.width - boxInset(n->computedStyle(), 1) - boxInset(n->computedStyle(), 3); if (content_w < 0) content_w = 0; - const TextLayoutMeasure measured = measureTextLayoutForNode(*n, n->text.c_str(), content_w); + const TextLayoutMeasure measured = measureTextLayoutForNode(*n, content_w); n->layout.width = measured.width; n->layout.height = measured.height; } @@ -2172,7 +2211,7 @@ bool TextRenderer::remeasureContentBox(int id, bool keepBoxWidth) else if (n.computedStyle().width != kUnset) content_w = contentSizeToBorderSize(n.computedStyle(), n.computedStyle().width, true) - boxInset(n.computedStyle(), 1) - boxInset(n.computedStyle(), 3); else if ((n.computedStyle().width_percent != kUnset || n.computedStyle().width_expression >= 0) && n.layout.width > 0) content_w = n.layout.width - boxInset(n.computedStyle(), 1) - boxInset(n.computedStyle(), 3); if (content_w < 0) content_w = 0; - const TextLayoutMeasure measured = measureTextLayoutForNode(n, n.text.c_str(), content_w); + const TextLayoutMeasure measured = measureTextLayoutForNode(n, content_w); if (keepBoxWidth) { if (measured.height == n.layout.height) return false; n.layout.height = measured.height; @@ -2188,14 +2227,44 @@ int TextRenderer::reflowToBoxWidth(int id) { Node &n = Tree::instance().nodes()[id]; const int content_w = std::max(1, n.layout.width - boxInsets(n.computedStyle(), true)); - const TextLayoutMeasure measured = measureTextLayoutForNode(n, n.text.c_str(), content_w); + const TextLayoutMeasure measured = measureTextLayoutForNode(n, content_w); n.layout.height = static_cast(std::min(measured.height, 32767)); return measured.width; } +void TextRenderer::drawWrapped(const DisplayCommand &command) +{ + TextDrawer::drawWrapped(command); +} + void TextRenderer::drawWrapped(const char *text, int x, int y, int maxWidth, gea::framework::graphics::pixel::native_t color, float scale, int text_align, int containerWidth, int fontId, int textTransform, int lineHeight, int whiteSpace, int textOverflow, int maxHeight, int firstLineIndent, int alignLast, int lineLimit, int blockEllipsis, int ellipsisWidth, int emphasis, gea::framework::graphics::pixel::native_t emphasisColor) { - TextDrawer::drawWrapped(text, x, y, maxWidth, color, scale, text_align, containerWidth, fontId, textTransform, lineHeight, whiteSpace, textOverflow, maxHeight, firstLineIndent, alignLast, lineLimit, blockEllipsis, ellipsisWidth, emphasis, emphasisColor); + DisplayCommand command{}; + command.type = DisplayCommandType::DrawText; + command.text.text = text; + command.text.x = static_cast(x); + command.text.y = static_cast(y); + command.text.maxWidth = static_cast(maxWidth); + command.text.color = color; + command.text.scale = scale; + command.text.align = static_cast(text_align); + command.text.textTransform = static_cast(textTransform); + command.text.lineHeight = static_cast(lineHeight); + command.text.containerWidth = static_cast(containerWidth); + command.text.fontId = static_cast(fontId); + command.text.whiteSpace = static_cast(whiteSpace); + command.text.textOverflow = static_cast(textOverflow); + command.text.maxHeight = static_cast(maxHeight); + command.text.firstLineIndent = static_cast(firstLineIndent); + command.text.alignLast = static_cast(alignLast); + command.text.lineLimit = static_cast(lineLimit); + command.text.blockEllipsis = static_cast(blockEllipsis); + command.text.ellipsisWidth = static_cast(ellipsisWidth); + command.text.emphasis = static_cast(emphasis); + command.text.emphasisColor = emphasisColor; + command.text.textBytes = static_cast(text ? saturatedTextBytes(std::strlen(text)) : 0); + command.text.collapsible = hasCollapsibleWhitespace(text) ? 1 : 0; + TextDrawer::drawWrapped(command); } void TextRenderer::unionCoverageRow(const DisplayCommand &command, int screenY, int screenX, @@ -2210,7 +2279,7 @@ int TextRenderer::measureWidth(const char *text, int fontId, int fontSize, int t // 32767 = no horizontal wrapping; we want the full single-line width // that the chosen font would render. Returning that as int16-safe. std::string transformed; - const char *measureText = textWithTransform(text, textTransform, transformed); + const char *measureText = textWithTransform(text, textTransform, transformed, -1, false); const TextMeasure measured = TextMetrics::measure(measureText, 32767, fontId, fontSize); return measured.width; } @@ -2219,7 +2288,7 @@ int TextRenderer::measureHeight(const char *text, int fontId, int fontSize, int { if (!text || !text[0]) return 0; std::string transformed; - const char *measureText = textWithTransform(text, textTransform, transformed); + const char *measureText = textWithTransform(text, textTransform, transformed, -1, false); const TextMeasure measured = TextMetrics::measure(measureText, 32767, fontId, fontSize, lineHeight); return measured.height; } @@ -2379,7 +2448,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph } } std::string transformedText; - const char *measureText = textWithTransform(n->text.c_str(), n->computedStyle().text_transform, transformedText, n->computedStyle().white_space); + const char *measureText = textForNode(*n, transformedText); #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS if (transformed && recordProjectedRasterText(*n, measureText, parentAlpha, tx, ty, tw)) return; #endif @@ -2627,6 +2696,8 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->type = DisplayCommandType::DrawText; cmd->bx = bx; cmd->by = by; cmd->bw = bw; cmd->bh = bh; cmd->text.text = n->text.c_str(); + cmd->text.textBytes = static_cast(saturatedTextBytes(n->text.size())); + cmd->text.collapsible = n->text.collapsible() ? 1 : 0; cmd->text.x = drawX; cmd->text.y = drawY; cmd->text.maxWidth = drawW; cmd->text.color = n->computedStyle().text_color; diff --git a/packages/engine/ui/tree_state.cpp b/packages/engine/ui/tree_state.cpp index 16aaf54..724fd19 100644 --- a/packages/engine/ui/tree_state.cpp +++ b/packages/engine/ui/tree_state.cpp @@ -169,19 +169,19 @@ NodeText &NodeText::operator=(NodeText &&other) noexcept } const std::string &NodeText::str() const { - return empty() ? g_emptyNodeText : nodeTextPool().at(handle_).text; + return empty() ? g_emptyNodeText : nodeTextPool().at(index()).text; } void NodeText::clear() { if (empty()) return; auto &pool = nodeTextPool(); - auto &entry = pool.at(handle_); + auto &entry = pool.at(index()); // Release long text allocations on removal, rather than retaining the // largest label ever seen in every reusable slot. entry.text.~basic_string(); entry.nextFree = pool.freeHead; - pool.freeHead = handle_; + pool.freeHead = index(); handle_ = kEmpty; } void NodeText::assign(const char *text) @@ -202,7 +202,8 @@ void NodeText::assign(const char *text) } new (&pool.at(handle_).text) std::string; } - pool.at(handle_).text.assign(text); + pool.at(index()).text.assign(text); + handle_ = index() | (hasCollapsibleWhitespace(text) ? kCollapsible : 0u); } namespace {