From f7aa93c48e1e0e6b1d5057b8ec64391e9f8111be 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 0e9e0dc..a5f0d57 100644 --- a/packages/core/test/test_css_block_and_flexbasis_main.cpp +++ b/packages/core/test/test_css_block_and_flexbasis_main.cpp @@ -4370,11 +4370,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 3e2386f..a5b74b9 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -508,13 +508,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 60131afd2c6a0b62e3b6ec5b4b19cd117bb0166e 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 59130ca..b4dd262 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; // Dimension expression slots use nonnegative values for pooled expressions, diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index f5f2fa6..360bfbe 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -7855,7 +7855,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 @@ -7865,7 +7865,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 @@ -7875,7 +7875,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 @@ -7885,7 +7885,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 61bf5f5..2546a4e 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -617,8 +617,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(n->style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_0(n->style) != kUnset) { + GEA_CSS_POSITION_PX_0(n->style) = storedPositionOffset(value); #if GEA_CSS_POSITION_TOP_PERCENT GEA_CSS_POSITION_PERCENT_0(style) = kUnset; #endif @@ -628,8 +628,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(n->style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_1(n->style) != kUnset) { + GEA_CSS_POSITION_PX_1(n->style) = storedPositionOffset(value); #if GEA_CSS_POSITION_RIGHT_PERCENT GEA_CSS_POSITION_PERCENT_1(style) = kUnset; #endif @@ -639,8 +639,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(n->style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_2(n->style) != kUnset) { + GEA_CSS_POSITION_PX_2(n->style) = storedPositionOffset(value); #if GEA_CSS_POSITION_BOTTOM_PERCENT GEA_CSS_POSITION_PERCENT_2(style) = kUnset; #endif @@ -650,8 +650,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(n->style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_3(n->style) != kUnset) { + GEA_CSS_POSITION_PX_3(n->style) = storedPositionOffset(value); #if GEA_CSS_POSITION_LEFT_PERCENT GEA_CSS_POSITION_PERCENT_3(style) = kUnset; #endif From d388dc87bbdcfebe071186e8318fb98a64f69973 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 a5b74b9..44238cb 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1814,7 +1814,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.style.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); @@ -1849,7 +1849,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; } @@ -1884,6 +1884,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_.style.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 @@ -1893,6 +1904,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 bf4856760279c9dde2c60d71f41fa60d11ffed0f 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 44238cb..c689dae 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -976,14 +976,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 @@ -1053,6 +1054,7 @@ class FlexLayoutPass { for (int id : trailingStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(continuedBaseline - inheritedAscent); + position.lineHeight = 0; position.valid = true; } } @@ -1072,6 +1074,7 @@ class FlexLayoutPass { for (int id : trailingStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(lineTop); + position.lineHeight = 0; position.valid = true; } } @@ -1089,6 +1092,7 @@ class FlexLayoutPass { for (int id : emptyStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(contentTop); + position.lineHeight = 0; position.valid = true; } } @@ -3835,8 +3839,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.style.margin[0] - childNode.style.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 84a472cfe888f9892abd72601b3d5ae05062e417 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 360bfbe..2ad2d27 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -17183,19 +17183,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->style, i) + + (out.origin == 2 ? std::max(0, node->style.padding[i]) : 0); x += inset[3]; y += inset[0]; width -= inset[1]+inset[3]; height -= inset[0]+inset[2]; } } From 6530c13d1435a13767e1e867b5b37c7122a5713e 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 2ad2d27..386ef78 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -17165,9 +17165,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 0d0ad99a2e897b48d4f2edf491578c46a17c4cc5 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 42985bf..f9e4364 100644 --- a/packages/engine/canvas.cpp +++ b/packages/engine/canvas.cpp @@ -4122,57 +4122,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 d6cadee994c594d90fff199d5034261e01d00f82 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 | 161 ++++++++++++++++++++--------------- 2 files changed, 94 insertions(+), 69 deletions(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 386ef78..91ad634 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -6662,7 +6662,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 8524715..7854a81 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.style, 0), borderPaintAlpha(node.style, 0)); +} + void boundsFromCorners(const int16_t *xs, const int16_t *ys, int *x0, int *y0, int *x1, int *y1) { *x0 = *x1 = xs[0]; @@ -1585,80 +1590,98 @@ 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; - if (hasBorderRelief(node.computedStyle())) { appendReliefBordersWithAlpha(node, parentAlpha); return; } - const bool textClippedTransform = StyleValues::hasTextBackgroundClip(node.computedStyle()) && + const int borderWidth = computedBorderWidth(node.style, 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.style, 0), h); + const int bottom = std::min(computedBorderWidth(node.style, 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.style)) { appendReliefBordersWithAlpha(node, parentAlpha); return; } + const bool textClippedTransform = StyleValues::hasTextBackgroundClip(node.style) && ViewGeometry::hasTransformChain(node, false) && !isFullyRoundedShape(node); 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.style, side), borderPaintAlpha(node.style, 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.style.border_width > 0 && !hasSideBorder(node.style) && !transformed) { + appendStrokeWithAlpha(node, parentAlpha, node.style.bg_color, node.style.bg_alpha); + return; } + for (int side = 0; side < 4; ++side) + appendBorderSideWithAlpha(node, side, node.style.bg_color, node.style.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 408a8eb7e471c5adc6243300d7199330a7787e15 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 | 2 +- packages/engine/ui/node_model.h | 3 + packages/engine/ui/render.cpp | 3 +- packages/engine/ui/style.cpp | 36 +++++++++--- packages/engine/ui/style.h | 4 +- packages/engine/ui/text.cpp | 78 ++++++++++++++++++++----- packages/engine/ui/tree_style.cpp | 7 +-- packages/geatsc-plugin-gea/src/utils.ts | 14 +++++ 11 files changed, 149 insertions(+), 31 deletions(-) diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index 9a2fdbd..b3062c8 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -171,6 +171,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 3be58e5..91181e1 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -200,6 +200,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 { @@ -474,6 +478,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. @@ -555,7 +562,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 c689dae..b2e17d0 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3983,6 +3983,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.style) || isOutOfFlowPosition(next.style.position) || next.style.float_side) + continue; + if (isAnonymousTextNode(next) && + (next.style.white_space == 0 || next.style.white_space == 1 || next.style.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 56ed433..4fa0195 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -204,7 +204,7 @@ constexpr ComputedStyle makeDefaultStyle() #endif #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 b4dd262..91d9bbd 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -783,6 +783,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 b4ec361..f667163 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6728,7 +6728,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 91ad634..20ca292 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -600,6 +600,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 || @@ -719,6 +720,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 || @@ -1210,6 +1212,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; @@ -4775,6 +4778,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 @@ -6938,6 +6950,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; @@ -8121,7 +8136,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #endif #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 @@ -9130,7 +9145,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); @@ -9163,7 +9178,7 @@ void applyInheritedStyleDefaults(int node) #endif #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 @@ -9861,6 +9876,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; @@ -10925,6 +10943,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; @@ -11500,6 +11519,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}); @@ -13744,6 +13764,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); @@ -14655,6 +14676,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; @@ -15832,9 +15854,7 @@ struct ParentStyleSnapshot { #endif #if GEA_CSS_TEXT_ALIGN std::uint8_t text_align; -#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 @@ -15877,7 +15897,7 @@ ParentStyleSnapshot snapshotParentStyle(const ComputedStyle &s) #endif #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 @@ -15900,7 +15920,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 59ab2bc..c0baf74 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -199,6 +199,7 @@ enum class Property : int { BorderBottomRelief, BorderLeftRelief, BorderRelief, + TextAlignLast, Count }; @@ -331,7 +332,8 @@ enum class StyleDeclaration : std::uint8_t { BackgroundRepeat, BackgroundAttachment, BackgroundOrigin, - MarginTrim + MarginTrim, + TextAlignLast }; class Style { diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index 40bdbe6..6e94995 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1075,7 +1075,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 @@ -1125,7 +1125,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; @@ -1150,16 +1150,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; } } @@ -1167,14 +1170,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 @@ -1186,12 +1189,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. @@ -1259,7 +1266,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; @@ -1298,7 +1307,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; @@ -1390,7 +1401,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); @@ -1409,11 +1420,14 @@ 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) { 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; + int penX = lineOriginX + alignedOffset(align, containerWidth, line.width - hanging - trimmedIndent); if (penX <= clipX1 && penX + line.width - 1 >= clipX0) { const char *glyph = lineStart; const char *renderEnd = lineStart + line.renderBytes; @@ -1505,6 +1519,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 @@ -1886,9 +1917,9 @@ bool TextRenderer::remeasureContentBox(int id, bool keepBoxWidth) return true; } -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, @@ -1917,6 +1948,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.style.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; @@ -2248,7 +2288,16 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph } - const uint8_t effectiveAlpha = combineAlpha(parentAlpha, n->computedStyle().text_alpha); + 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->style.text_alpha); if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, bx, by, bw, bh); DisplayCommand *cmd = DisplayList::instance().append(); @@ -2271,6 +2320,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 2546a4e..bc5705a 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -972,11 +972,8 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn style.line_height_multiplier = value; style.line_height = height; changed = 1; } break; } -#endif -#endif -#if GEA_CSS_TEXT_ALIGN - case Property::TextAlign: if (style.text_align != value) { style.text_align = value; changed = 1; } break; -#endif + case Property::TextAlign: if (n->style.text_align != value) { n->style.text_align = value; changed = 1; } break; + case Property::TextAlignLast: if (n->style.text_align_last != value) { n->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 10da0f60356ea3eab9bd20de30ddd48d2cb494c5 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. Node bounds grow by the shadow's reach, and the reach at the last snapshot is kept in RenderState padding, so dirty regions cover both the new and the old shadow. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout_snapshot.cpp | 1 + packages/engine/ui/node_lifecycle.cpp | 1 + packages/engine/ui/node_model.h | 14 ++ packages/engine/ui/style.cpp | 65 +++---- packages/engine/ui/view.cpp | 237 ++++++++++++++++++++++++- 5 files changed, 276 insertions(+), 42 deletions(-) diff --git a/packages/engine/ui/layout_snapshot.cpp b/packages/engine/ui/layout_snapshot.cpp index cc031ff..b46f95f 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 = static_cast(std::min(32767, boxShadowExtent(state.nodes[i].style))); state.nodes[i].render.dirty = 0; state.nodes[i].render.layout_dirty = 0; #if GEA_CSS_SCROLLING diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index 4fa0195..4dca4d9 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -419,6 +419,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; #if GEA_CSS_SCROLLING diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 91d9bbd..0ceed58 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -1334,6 +1334,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); @@ -1526,6 +1537,9 @@ 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. Sits in trailing padding. + int16_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 20ca292..c4954f5 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4931,7 +4931,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; @@ -4949,6 +4951,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; @@ -4968,18 +4971,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; @@ -4989,8 +4994,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); @@ -5006,7 +5011,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) @@ -6256,6 +6261,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; @@ -6282,26 +6288,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); @@ -7489,12 +7485,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); @@ -10847,7 +10838,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 7854a81..42ece07 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); + const auto color = (GEA_CSS_BOX_SHADOW ? rstyle(node.style).box_shadow_color : 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.style).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,225 @@ 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.style).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.style).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.style).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.style.has_bg || combineAlpha(parentAlpha, node.style.bg_alpha) != 255) return false; + if (StyleValues::backgroundClip(node.style, rstyle(node.style).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; +} + +// 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. +void recordOuterBoxShadow(const Node &node, uint8_t parentAlpha) +{ + const auto &r = rstyle(node.style); + 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); + // 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) { + if (!native) appendShadowSpans(node, parentAlpha, r.box_shadow_alpha, shape, nullptr, 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,7 +2621,13 @@ 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 = [&]() { - const int radius = use_prev ? n->render.previous_filter_blur_radius : (GEA_CSS_FILTERS ? rstyle(n->computedStyle()).filter_blur_radius : 0); + // An outer box-shadow paints beyond the box as well. + const int shadow = use_prev ? n->render.previous_box_shadow_extent : boxShadowExtent(n->style); + *x0 -= shadow; + *y0 -= shadow; + *x1 += shadow; + *y1 += shadow; + const int radius = use_prev ? n->render.previous_filter_blur_radius : (GEA_CSS_FILTERS ? rstyle(n->style).filter_blur_radius : 0); if (radius <= 0) return; const int extentX = std::max(1, radius) * 5; const int extentY = std::max(1, radius) * 5; @@ -2578,6 +2802,9 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB } } if (w <= 0 || h <= 0) return; +#if GEA_CSS_BOX_SHADOW + recordOuterBoxShadow(*n, parentAlpha); +#endif if (n->computedStyle().has_bg) { // The color is the bottom layer; image longhands never replace it. From 5b300e3a39600a46e372844eeb1b755daea501da 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 f667163..e72f1df 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -5805,6 +5805,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 b69967095fddc8f6d4c733967ff8c2d76aa5c233 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. 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. The new style fields and layout flags sit in existing padding: RareStyle, LayoutBox and ComputedStyle keep their sizes. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/input.cpp | 4 +- packages/engine/ui/input_render.cpp | 1 + packages/engine/ui/internal.h | 4 +- packages/engine/ui/layout.cpp | 108 ++++++++++++++++++ packages/engine/ui/node_lifecycle.cpp | 6 +- packages/engine/ui/node_model.h | 13 ++- 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 + 12 files changed, 328 insertions(+), 26 deletions(-) diff --git a/packages/engine/ui/input.cpp b/packages/engine/ui/input.cpp index 52610c1..790e8e0 100644 --- a/packages/engine/ui/input.cpp +++ b/packages/engine/ui/input.cpp @@ -443,7 +443,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 @@ -482,7 +482,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 b3062c8..261f8b0 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -172,6 +172,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 91181e1..494ebe2 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -204,6 +204,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 { @@ -562,7 +564,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 b2e17d0..bc45a25 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -369,6 +369,112 @@ 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; +}; + +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); +} + +// line-clamp applies to block containers, never to inline boxes, flex or +// grid containers, or multicol containers. +bool clampsLines(const Node &node) +{ + const int flags = rstyle(node.style).line_clamp_flags; + return (flags & 1) && !(flags & 12) && node.type == NodeType::View && node.style.display == kDisplayBlock && + !(LayoutEngine::isCssInlineLevelBox(node) && LayoutEngine::isInlineLevelNode(node)); +} + +// 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.style) || isOutOfFlowPosition(child.style.position) || child.style.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.style.font_id, child.style.font_size, 0, child.style.line_height); + const int inner = child.layout.height - boxInsets(child.style, 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.style, 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.style).max_lines, node.layout.height - boxInset(node.style, 2)}; + walk.keptBottom = boxInset(node.style, 0); + walkLineClamp(walk, id, 0); + if (!walk.clamped || hasExplicitHeight(node)) return; + const int height = clampBorderBoxSize(node.style, walk.keptBottom + boxInset(node.style, 2), false); + if (height < node.layout.height) node.layout.height = clampInt16(height); +} + int resolvedStyleWidth(const Node &node, int basis) { if (const auto *scope = flexBasisScope(node, true)) return scope->value == kUnset ? basis : scope->value; @@ -4288,6 +4394,7 @@ 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 (clampsLines(node)) applyLineClamp(id); 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. @@ -4318,6 +4425,7 @@ 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 (clampsLines(*node)) applyLineClamp(id); } bool LayoutEngine::layoutNodeScoped(int scope, int treeRoot) diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index 4dca4d9..86afb29 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -369,10 +369,8 @@ void NodeLifecycle::init(Node *n, NodeType type) n->layout.width = 0; n->layout.height = 0; n->layout.inline_indent = 0; -#if !GEA_EMBEDDED_SHARED_STYLES - n->layout.memo_avail_w = n->layout.memo_avail_h = 0; - n->layout.memo_pass = 0; -#endif + n->layout.line_clamp_hidden = 0; + n->layout.line_clamp_lines = 0; #if GEA_CSS_SCROLLING n->layout.scroll_x = 0; n->layout.scroll_y = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 0ceed58..d043bdb 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -463,6 +463,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 @@ -1377,10 +1383,15 @@ struct LayoutBox { // and absolute-coordinate conversion; retained refresh reuses this anchor. #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; int16_t previous_x, previous_y; int16_t previous_width, previous_height; + // 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; // 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 e72f1df..eb31df4 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6749,7 +6749,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 @@ -7019,7 +7019,7 @@ int gLastScrollUiFrame = -1000; return; } Node *n = &Tree::instance().nodes()[id]; - 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; @@ -12722,10 +12722,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 c4954f5..ee211bb 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -402,6 +402,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 || @@ -628,6 +630,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 || @@ -1165,6 +1169,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; @@ -6702,6 +6714,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)); @@ -7193,6 +7288,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; @@ -7645,6 +7754,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 @@ -9473,6 +9592,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" @@ -11449,6 +11582,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 c0baf74..125c00a 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -200,9 +200,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, @@ -333,7 +345,15 @@ enum class StyleDeclaration : std::uint8_t { BackgroundAttachment, BackgroundOrigin, MarginTrim, - 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 6e94995..44b3d16 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1075,7 +1075,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 @@ -1125,7 +1125,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; @@ -1162,7 +1162,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; } } @@ -1172,12 +1172,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 @@ -1254,7 +1254,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, @@ -1289,7 +1289,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, @@ -1401,7 +1401,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); @@ -1415,7 +1415,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; @@ -1917,9 +1917,9 @@ bool TextRenderer::remeasureContentBox(int id, bool keepBoxWidth) return true; } -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, @@ -2321,6 +2321,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 bc5705a..1bcca9c 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -86,6 +86,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 @@ -342,6 +347,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(n->style).line_clamp_flags | bit : rstyle(n->style).line_clamp_flags & ~bit; + if (rstyle(n->style).line_clamp_flags != next) { rstyleMut(n->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 010bff48fac993afca6f8db039538c99d18abeab 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 | 101 ++++++++++++++++++++++-- packages/engine/ui/node_lifecycle.cpp | 1 + packages/engine/ui/node_model.h | 6 +- packages/engine/ui/style.cpp | 22 ++++++ packages/engine/ui/style.h | 4 +- packages/engine/ui/text.cpp | 19 +++++ packages/engine/ui/tree_style.cpp | 2 + packages/geatsc-plugin-gea/src/utils.ts | 14 ++++ 9 files changed, 164 insertions(+), 9 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 494ebe2..ccd21b4 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -548,6 +548,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 bc45a25..f41fa78 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -961,6 +961,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++) { @@ -1067,12 +1069,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.style.margin[0] + cn.layout.height + cn.style.margin[2]; + if (cn.style.vertical_align) lineAligned = true; + if (cn.style.vertical_align == 1) topItemHeight = std::max(topItemHeight, outerHeight); + else if (cn.style.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_.style); + 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.style.vertical_align == 1 || item.style.vertical_align == 2) continue; + int ascent = 0, descent = 0; + if (k == wrapIdx) { + const int run = isFragmentableRun(item) ? -1 : transparentInlineRun(item); + ascent = item.style.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) @@ -1331,7 +1367,10 @@ class FlexLayoutPass { } int y = baseline - childBaseline(cn); - const int align = usedAlignment(crossAlignFor(cn), lineCross - cn.layout.height - cn.computedStyle().margin[0] - cn.computedStyle().margin[2]); + if (cn.style.vertical_align == 1) y = lineTop + cn.style.margin[0]; + else if (cn.style.vertical_align == 2) y = lineTop + lineCross - cn.layout.height - cn.style.margin[2]; + else y += verticalAlignShift(cn); + const int align = usedAlignment(crossAlignFor(cn), lineCross - cn.layout.height - cn.style.margin[0] - cn.style.margin[2]); if (align != 5) { const int crossBefore = cn.computedStyle().margin[0]; const int crossAfter = cn.computedStyle().margin[2]; @@ -2061,6 +2100,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.style); + above = own.strutAscent; + below = own.strutDescent; + return; + } + above = item.style.margin[0] + childBaseline(item); + below = item.layout.height + item.style.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.style.vertical_align; + if (align < 3) return 0; + const auto parent = TextRenderer::inlineFontMetrics(node_.style); + 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.style.vertical_align >= 3) alignedBox(item, ascent, descent); + else { + ascent = item.style.margin[0] + childBaseline(item); + descent = item.layout.height + item.style.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 86afb29..b31e7a3 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; 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 d043bdb..7de2381 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -771,9 +771,9 @@ struct ComputedStyle { // linear/overlay/radial gradients + background-grid moved to RareStyle (rare). #if GEA_CSS_ACTIVE_BACKGROUND style_color_t active_bg_color; -#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 ee211bb..095d127 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -603,6 +603,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 || @@ -725,6 +726,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 || @@ -1225,6 +1227,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; @@ -4790,6 +4793,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) @@ -7044,6 +7055,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; @@ -8247,6 +8261,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #if GEA_CSS_TEXT_ALIGN case Property::TextAlign: style.text_align = value; return true; 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 @@ -10003,6 +10018,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; @@ -11068,6 +11086,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; @@ -11652,6 +11671,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}); @@ -13897,6 +13917,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); @@ -14809,6 +14830,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 125c00a..7d33685 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -205,6 +205,7 @@ enum class Property : int { BlockEllipsis, ColumnCountSet, ColumnWidthSet, + VerticalAlign, Count }; @@ -353,7 +354,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 44b3d16..974cf1e 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1748,6 +1748,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 1bcca9c..1781eb7 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -91,6 +91,7 @@ bool isLayoutProperty(Property prop) case Property::BlockEllipsis: case Property::ColumnCountSet: case Property::ColumnWidthSet: + case Property::VerticalAlign: #if GEA_CSS_FLOATS case Property::Clear: #endif @@ -989,6 +990,7 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn } case Property::TextAlign: if (n->style.text_align != value) { n->style.text_align = value; changed = 1; } break; case Property::TextAlignLast: if (n->style.text_align_last != value) { n->style.text_align_last = value; changed = 1; } break; + case Property::VerticalAlign: if (n->style.vertical_align != value) { n->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 06bbf0fa4af9721012c91aa42c53826a5387c88f 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 while any sticky box exists the root scroll-only refresh stands down instead of dragging it along with the content; the regular path re-resolves coordinates. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/internal.h | 3 + packages/engine/ui/layout.cpp | 83 +++++++++++++++++++++- packages/engine/ui/node_model.h | 2 + packages/engine/ui/root_scroll_refresh.cpp | 7 +- packages/engine/ui/style.cpp | 1 + packages/engine/ui/tree_state.h | 4 ++ packages/geatsc-plugin-gea/src/utils.ts | 2 + 7 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index ccd21b4..e79fe2a 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -470,6 +470,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 f41fa78..727537b 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3708,7 +3708,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.style.position) || node.style.position == 2 || node.style.position == kPositionSticky || + establishesTransformContainingBlock(node); } int containingBlockForAbsoluteNode(int node, int root, Node *nodes) @@ -4638,7 +4639,75 @@ bool LayoutEngine::layoutNodeScoped(int scope, int treeRoot) return true; } +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.style, 3), blockX1 = block.layout.x + block.layout.width - boxInset(block.style, 1); + const int blockY0 = block.layout.y + boxInset(block.style, 0), blockY1 = block.layout.y + block.layout.height - boxInset(block.style, 2); + node.layout.y += axis(node.layout.y, node.layout.height, node.style.margin[0], node.style.margin[2], port[1], port[3], + blockY0, blockY1, 0, 2, portH); + node.layout.x += axis(node.layout.x, node.layout.width, node.style.margin[3], node.style.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]); +} + +} // namespace + void LayoutEngine::resolveAbsoluteCoords(int id, int parent_x, int parent_y) +{ + Node *nodes = Tree::instance().nodes(); + if (nodes[id].parent < 0) treeState().stickyPresent = false; + // 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].style)) continue; + scrollportOf(nodes[ancestor], port); + break; + } + resolveAbsoluteCoordsIn(id, parent_x, parent_y, port); +} + +void LayoutEngine::resolveAbsoluteCoordsIn(int id, int parent_x, int parent_y, const int *scrollport) { Node *nodes = Tree::instance().nodes(); Node *node = &nodes[id]; @@ -4647,6 +4716,10 @@ void LayoutEngine::resolveAbsoluteCoords(int id, int parent_x, int parent_y) node->layout.x += parent_x; node->layout.y += parent_y; } + if (node->style.position == kPositionSticky && node->parent >= 0 && !isDisplayNone(node->style)) { + applyStickyOffset(*node, nodes[node->parent], scrollport); + treeState().stickyPresent = true; + } int childParentX = node->layout.x; int childParentY = node->layout.y; @@ -4654,9 +4727,15 @@ 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; } + // A scroll container's padding box is the scrollport of sticky descendants. + int port[4]; + if (overflowEstablishesContext(node->style)) { + scrollportOf(*node, port); + scrollport = port; + } 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 7de2381..96a8fdf 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -80,6 +80,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/root_scroll_refresh.cpp b/packages/engine/ui/root_scroll_refresh.cpp index d9491ad..460dd9e 100644 --- a/packages/engine/ui/root_scroll_refresh.cpp +++ b/packages/engine/ui/root_scroll_refresh.cpp @@ -489,7 +489,12 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) { auto &state = treeState(); auto &perf = refreshPerfStatsMutable(); - GEA_REFRESH_PERF(perf.rootScrollCalls++); + perf.rootScrollCalls++; + // Shifting content by the scroll delta would drag sticky boxes along. + if (state.stickyPresent) { + perf.rootScrollRejected++; + return 0; + } DirtyNode extraDirtyNodes[kMaxExtraDirtyNodes]; int extraDirtyCount = 0; int slotNodes[kMaxSlotRepositionNodes]; diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 095d127..132b3c6 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4783,6 +4783,7 @@ int positionValue(const std::string &value) if (value == "absolute") return 1; if (value == "fixed") return kPositionFixed; if (value == "relative") return 2; + if (value == "sticky" || value == "-webkit-sticky") return kPositionSticky; return 0; } diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index 36c32d9..f1a820c 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -366,6 +366,10 @@ 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 stand down. + bool stickyPresent = false; // 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 15ac8d94f632ff3ae1a0869848c9cc8f50dceac5 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 | 153 ++++++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 9 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 727537b..d51c030 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -689,6 +689,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) @@ -1006,7 +1008,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; @@ -1054,8 +1056,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; @@ -1324,6 +1333,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].style) || isOutOfFlowPosition(nodes[c].style.position) || nodes[c].style.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.style.white_space == 0 || text.style.white_space >= 3; } + static bool collapsesSpaces(const Node &text) { return text.style.white_space == 0 || text.style.white_space == 1 || text.style.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.style.white_space == 0 || item.style.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.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.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.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.back()) || !collapsesSpaces(previous)) return 0; + } + return TextRenderer::measureWidth(" ", item.style.font_id, item.style.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.style.margin[3] + TextRenderer::firstUnbreakableWidth(inner >= 0 ? nodes[inner] : item); + break; + } + width += item.layout.width + item.style.margin[1] + item.style.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). @@ -1350,19 +1441,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); } @@ -2236,10 +2331,11 @@ bool splitInlineWrapper(const Node *nodes, int id) bool containsBlock = false; 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 (isDisplayNone(child.style)) continue; + if (isOutOfFlowPosition(child.style.position)) return false; + // A float inside an inline belongs to the enclosing block's float context. + if (child.style.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; @@ -2470,9 +2566,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.style.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]].style.float_side) continue; + int group = k + 1; + while (group < count && nodes_[children[group]].style.float_side) ++group; + int target = group; + if (!softWrapBefore(children, k)) { + while (target < count) { + const Node &item = nodes_[children[target]]; + if (item.style.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 682afabd18a423614031d14f0519aa09fbf68b67 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 | 9 +- packages/engine/ui/render.cpp | 7 +- packages/engine/ui/style.cpp | 37 ++++--- packages/engine/ui/style.h | 1 + packages/engine/ui/text.cpp | 157 +++++++++++++++++++++++++--- packages/engine/ui/tree_style.cpp | 2 + 10 files changed, 219 insertions(+), 34 deletions(-) diff --git a/packages/engine/ui/input.cpp b/packages/engine/ui/input.cpp index 790e8e0..ca93a5a 100644 --- a/packages/engine/ui/input.cpp +++ b/packages/engine/ui/input.cpp @@ -443,7 +443,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.style.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 @@ -482,7 +482,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.style.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 261f8b0..6d1ac63 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.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 e79fe2a..1efa0ef 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -74,6 +74,9 @@ void rootScrollImageHoldTick(); FillTransformedRoundedRect }; + // DrawText::blockEllipsis for the default ellipsis ("\u2026", or "..."). + constexpr uint16_t kAutoBlockEllipsis = 0xFFFF; + struct DisplayCommand { DisplayCommandType type; @@ -206,6 +209,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 { @@ -571,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 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 d51c030..8011862 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -375,6 +375,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; @@ -383,6 +384,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; }; @@ -422,6 +425,12 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) } if (isDisplayNone(child.style) || isOutOfFlowPosition(child.style.position) || child.style.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.style.white_space == 0 || child.style.white_space == 1 || child.style.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.style.font_id, child.style.font_size, 0, child.style.line_height); @@ -441,13 +450,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; @@ -459,6 +471,7 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) continue; } walk.keptBottom = std::max(walk.keptBottom, bottom); + walk.lastRun = -1; } } @@ -470,6 +483,9 @@ void applyLineClamp(int id) LineClampWalk walk{nodes, rstyle(node.style).max_lines, node.layout.height - boxInset(node.style, 2)}; walk.keptBottom = boxInset(node.style, 0); walkLineClamp(walk, id, 0); + // The block ellipsis ends the last line before the clamp point. + if (walk.clamped && (rstyle(node.style).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.style, walk.keptBottom + boxInset(node.style, 2), false); if (height < node.layout.height) node.layout.height = clampInt16(height); @@ -2318,6 +2334,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.style, true) && !boxInsets(node.style, 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 96a8fdf..9ca56f8 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -153,6 +153,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 @@ -1385,8 +1388,10 @@ struct LayoutBox { // and absolute-coordinate conversion; retained refresh reuses this anchor. #if !GEA_EMBEDDED_SHARED_STYLES int16_t static_block_start = 0; - // 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. uint8_t line_clamp_hidden = 0; int16_t previous_x, previous_y; diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index eb31df4..8ec90d9 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6749,7 +6749,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 @@ -7019,7 +7020,7 @@ int gLastScrollUiFrame = -1000; return; } Node *n = &Tree::instance().nodes()[id]; - if (n->style.display == 1 || n->layout.line_clamp_hidden || isCollapsedFlexSubtree(*n) || ViewRenderer::backfaceSubtreeHidden(*n)) + if (n->style.display == 1 || (n->layout.line_clamp_hidden & 1) || isCollapsedFlexSubtree(*n) || ViewRenderer::backfaceSubtreeHidden(*n)) { state.clearNodeRange(id); return; @@ -12722,7 +12723,7 @@ 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) && + if (!outsideRecordClip && n->style.visibility == 0 && !(n->layout.line_clamp_hidden & 1) && !isCollapsedFlexSubtree(*n) && !ViewRenderer::backfaceSubtreeHidden(*n) && n->style.display != 1 && n->style.opacity != 0 && !(n->style.blink_interval_ms > 0 && !n->style.blink_visible) && diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 132b3c6..ec09e1b 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -404,6 +404,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 || @@ -633,6 +634,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 || @@ -6726,23 +6728,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[4]) { 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())); @@ -6763,8 +6770,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; @@ -6772,7 +6780,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; @@ -6785,8 +6794,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. @@ -7303,7 +7313,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) { @@ -7770,6 +7780,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: @@ -9616,7 +9627,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); @@ -11603,10 +11614,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 7d33685..ef71338 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -206,6 +206,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 974cf1e..622edd2 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 @@ -1075,7 +1076,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 @@ -1125,13 +1127,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; @@ -1148,8 +1154,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; @@ -1159,10 +1165,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; } } @@ -1170,14 +1176,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 @@ -1253,8 +1319,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, @@ -1306,6 +1380,13 @@ class TextDrawer { }); 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; @@ -1401,7 +1482,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); @@ -1423,6 +1523,12 @@ class TextDrawer { const int trimmedIndent = lineIndex == 0 && firstLineIndent < 0 ? -firstLineIndent : 0; const WrappedLine line = nextWrappedLine(lineStart, budget < 1 ? 1 : budget, [&](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]); @@ -1936,9 +2042,9 @@ bool TextRenderer::remeasureContentBox(int id, bool keepBoxWidth) return true; } -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, @@ -1967,6 +2073,21 @@ int TextRenderer::measureHeight(const char *text, int fontId, int fontSize, int return measured.height; } +// 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.style).line_clamp_flags & 1)) continue; + const int right = box.layout.x + box.layout.width - boxInset(box.style, 1); + width = std::max(0, std::min(32767, right - textX)); + return rstyle(box.style).block_ellipsis ? rstyle(box.style).block_ellipsis : kAutoBlockEllipsis; + } + return 0; +} + // DrawText::alignLast for a run: -1 unless text-align-last moves some line. static int8_t textAlignLastCommand(const Node &node) { @@ -2316,6 +2437,16 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph bw = right - bx; } + int ellipsisWidth = 0; + const uint16_t blockEllipsis = !transformed && (n->layout.line_clamp_hidden & 2) + ? blockEllipsisCommand(*n, n->layout.x + boxInset(n->style, 3), ellipsisWidth) : 0; + // 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->style.text_alpha); if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, bx, by, bw, bh); @@ -2341,6 +2472,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 1781eb7..0b667f1 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -91,6 +91,7 @@ bool isLayoutProperty(Property prop) case Property::BlockEllipsis: case Property::ColumnCountSet: case Property::ColumnWidthSet: + case Property::BlockEllipsisString: case Property::VerticalAlign: #if GEA_CSS_FLOATS case Property::Clear: @@ -349,6 +350,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 f5f1cf5c0a3e495d1f6e47d18cac3532753cf40d 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. 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 and the node-range dirty-region replays both do this, and record-time culling uses the same clip bounds. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/internal.h | 25 +++ packages/engine/ui/render.cpp | 324 ++++++++++++++++++++++++++++++---- packages/engine/ui/view.cpp | 147 +++++++++++++-- 3 files changed, 447 insertions(+), 49 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 1efa0ef..4739a84 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -94,6 +94,14 @@ 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; + int16_t qx[4], qy[4]; + int16_t lx, ly, lw, lh; + int16_t rx8[4], ry8[4]; } clip; struct { @@ -269,6 +277,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 { @@ -507,9 +519,22 @@ 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; + int16_t qx[4], qy[4]; + int16_t lx, ly, lw, lh; + int16_t rx8[4], ry8[4]; + }; + 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 8ec90d9..2374cec 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -1518,6 +1518,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; @@ -1679,7 +1706,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; @@ -1994,7 +2021,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); @@ -2025,6 +2052,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, @@ -2059,6 +2093,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); @@ -2101,6 +2137,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) { @@ -2125,13 +2163,197 @@ 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). + class ShapedClips + { + public: + static std::size_t depth() { return entries().size(); } + + static void begin(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]; + begin(shape); + } + + static void begin(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]; + begin(shape); + } + + 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; + }; + struct Entry + { + TransformedRoundedRectCommand shape; + float inv00, inv01, inv10, inv11; + Box boxes[4]; + int boxCount; + std::size_t offset; + }; + + static std::vector &entries() + { + static std::vector value; + return value; + } + + static std::vector &saved() + { + static std::vector value; + return value; + } + + static void begin(const TransformedRoundedRectCommand &shape) + { + Entry entry{}; + entry.shape = shape; + entry.offset = saved().size(); + auto *canvas = gea::platform::display::Display::canvas(); + const float a = shape.lw ? static_cast(shape.x1 - shape.x0) / shape.lw : 0.0f; + const float b = shape.lh ? static_cast(shape.x3 - shape.x0) / shape.lh : 0.0f; + const float c = shape.lw ? static_cast(shape.y1 - shape.y0) / shape.lw : 0.0f; + const float d = shape.lh ? static_cast(shape.y3 - shape.y0) / shape.lh : 0.0f; + const float det = a * d - b * c; + if (canvas && canvas->pixels() && std::fabs(det) >= 1e-6f) + { + entry.inv00 = d / det; + entry.inv01 = -b / det; + entry.inv10 = -c / det; + entry.inv11 = a / det; + Box candidates[4]; + 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 auto extent = [](int radius8) { return (std::max(0, radius8) + 7) / 8; }; + 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) candidates[count++] = {left, top, left + tlW - 1, top + tlH - 1}; + if (trW && trH) candidates[count++] = {right - trW + 1, top, right, top + trH - 1}; + if (brW && brH) candidates[count++] = {right - brW + 1, bottom - brH + 1, right, bottom}; + if (blW && blH) candidates[count++] = {left, bottom - blH + 1, left + blW - 1, bottom}; + } + else + { + int x0, y0, x1, y1; + 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}; + x0 = std::min({xs[0], xs[1], xs[2], xs[3]}); x1 = std::max({xs[0], xs[1], xs[2], xs[3]}); + y0 = std::min({ys[0], ys[1], ys[2], ys[3]}); y1 = std::max({ys[0], ys[1], ys[2], ys[3]}); + candidates[count++] = {x0, y0, x1, y1}; + } + 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); + const int stride = canvas->strideBytes() / static_cast(sizeof(gea::framework::graphics::pixel::native_t)); + 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; + for (int y = box.y0; y <= box.y1; ++y) + { + const auto *row = canvas->pixels() + canvas->rowToPhysical(y) * stride; + saved().insert(saved().end(), row + box.x0, row + box.x1 + 1); + } + entry.boxes[entry.boxCount++] = box; + } + } + entries().push_back(entry); + } + + static void restore(const Entry &entry) + { + auto *canvas = gea::platform::display::Display::canvas(); + if (canvas && canvas->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; + std::size_t index = entry.offset; + for (int i = 0; i < entry.boxCount; ++i) + { + const Box &box = entry.boxes[i]; + for (int y = box.y0; y <= box.y1; ++y) + { + auto *row = canvas->pixels() + canvas->rowToPhysical(y) * stride; + const float py = static_cast(y) + 0.5f - static_cast(r.y0); + for (int x = box.x0; x <= box.x1; ++x, ++index) + { + 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); + if (coverage >= sampleCount) + continue; + const auto outside = saved()[index]; + row[x] = coverage <= 0 ? outside + : gea::framework::graphics::pixel::blendNative(row[x], outside, combinedCoverageAlpha(255, 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); @@ -4642,6 +4864,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) { @@ -4693,7 +4917,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)) @@ -5011,7 +5237,9 @@ int gLastScrollUiFrame = -1000; r.blRx8, r.blRy8, sx, - sy) + sy) && + (!transformedRoundedRectIsRing(r) || + !roundedRectOuterContainsFast(transformedRoundedRectHole(r), static_cast(sx), static_cast(sy))) ? 1 : 0; } @@ -5078,9 +5306,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) @@ -5261,10 +5491,20 @@ 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->style) ? dx : 0; + const int clipDy = !owner || overflowY(owner->style) ? dy : 0; + c->clip.x += clipDx; + c->bx -= dx - clipDx; + c->clip.y += clipDy; + c->by -= dy - clipDy; + 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; + } break; } case DisplayCommandType::FillRect: @@ -7794,7 +8034,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; @@ -7803,7 +8044,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) @@ -7843,12 +8085,12 @@ int gLastScrollUiFrame = -1000; 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::begin(shape); pushed++; } return pushed; @@ -8376,6 +8618,7 @@ int gLastScrollUiFrame = -1000; if (start < 0 || end <= start || end > state.commandCount) return; + const std::size_t shapedMark = ShapedClips::depth(); const int pushedClips = pushNodeReplayClips(node_id, nodes, nodeCount); if (pushedClips < 0) return; @@ -8389,6 +8632,7 @@ int gLastScrollUiFrame = -1000; replayCommandRange(start, end, x0, y0, x1, y1, roundedRects); if (stateChanged) roundedRects.flush(); + ShapedClips::endTo(shapedMark); for (int i = 0; i < pushedClips; i++) gea::platform::display::Display::popClip(); if (stateChanged) @@ -8714,10 +8958,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) { @@ -8826,19 +9071,13 @@ 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) - { - clipped = true; - break; - } - if (spanX0 < clip.layout.x) - spanX0 = clip.layout.x; - if (spanX1 > clip.layout.x + clip.layout.width - 1) - spanX1 = clip.layout.x + clip.layout.width - 1; - if (spanX0 > spanX1) + 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) { clipped = true; break; @@ -9072,6 +9311,7 @@ int gLastScrollUiFrame = -1000; if (!commandRangeOverlapsAnyRegion(start, end, regions, count)) return; + const std::size_t shapedMark = ShapedClips::depth(); const int pushedClips = pushNodeReplayClips(node_id, nodes, nodeCount); if (pushedClips < 0) return; @@ -9113,6 +9353,7 @@ int gLastScrollUiFrame = -1000; } if (stateChanged || alphaTouched) roundedRects.flush(); + ShapedClips::endTo(shapedMark); for (int i = 0; i < pushedClips; i++) gea::platform::display::Display::popClip(); if (stateChanged || alphaTouched) @@ -9286,6 +9527,12 @@ int gLastScrollUiFrame = -1000; 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; @@ -9406,8 +9653,10 @@ int gLastScrollUiFrame = -1000; ny1 = cy1; int changed = nx0 != cx0 || ny0 != cy0 || nx1 != cx1 || ny1 != cy1; + // Bit 0: a Display clip was pushed; bit 1: a shaped clip began. + const bool shaped = c->clip.shaped && state.ensureReplayClipCapacity(clip_stack_depth + 1); if (state.ensureReplayClipCapacity(clip_stack_depth + 1)) - state.replayClipPushed[clip_stack_depth++] = (uint8_t)changed; + state.replayClipPushed[clip_stack_depth++] = static_cast(changed | (shaped ? 2 : 0)); if (changed) { gea::platform::display::Display::pushClip(c->clip.x, c->clip.y, c->clip.w, c->clip.h); @@ -9416,16 +9665,23 @@ int gLastScrollUiFrame = -1000; cx1 = nx1; cy1 = ny1; } + if (shaped) + ShapedClips::begin(*c); 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); diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 42ece07..82d5003 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.style.bg_color, node.style.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.style) && node.style.border_width > 0 && !hasBorderRelief(node.style) && + !hasSideBorder(node.style) && !borderColorsDiffer(node.style) && borderPaintAlpha(node.style, 0) > 0 && + !isFullyRoundedShape(node); +} + void recordLinearGradientBackground(const Node &node, uint8_t parentAlpha) { const int x = node.layout.x; @@ -1741,11 +1749,10 @@ 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)) { - 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; + if (gradientEdgeCarriesBorder(node)) { + g.edgeColor = borderPaintColor(node.style, 0); + g.edgeAlpha = borderPaintAlpha(node.style, 0); + const int ew = node.style.border_width < 1 ? 1 : node.style.border_width; g.edgeWidth = static_cast(ew > 8 ? 8 : ew); } else { g.edgeColor = 0; @@ -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}; @@ -1952,8 +1960,57 @@ void recordTransformedRoundedRectFill(const Node &node, uint8_t parentAlpha, int 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 = node.computedStyle().bg_color; - r.backfaceHidden = node.computedStyle().backface_hidden ? 1 : 0; + r.color = node.style.bg_color; + r.backfaceHidden = node.style.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.style, 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.style, 0); + r.backfaceHidden = node.style.backface_hidden ? 1 : 0; + for (int i = 0; i < 4; ++i) + r.ring[i] = static_cast(std::clamp(computedBorderWidth(node.style, i), 1, 255)); } if (effectiveAlpha != parentAlpha) appendAlphaCommand(parentAlpha, bx0, by0, bx1 - bx0 + 1, by1 - by0 + 1); @@ -2702,23 +2759,80 @@ 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.style; + 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; + } + // A clip open on one axis stays a rectangle; a transformed one clips nothing. + if (!overflowX(s) || !overflowY(s) || (!transformed && !rounded)) return !transformed; + 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 (!isViewLikeNodeType(n->type) || n->style.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; + 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; } return 1; } @@ -2805,6 +2919,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB #if GEA_CSS_BOX_SHADOW recordOuterBoxShadow(*n, parentAlpha); #endif + bool borderOnGradientEdge = false; if (n->computedStyle().has_bg) { // The color is the bottom layer; image longhands never replace it. @@ -2842,6 +2957,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)); @@ -2863,6 +2979,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 3d8c3ce7738752de99ec70fc47ac8b3bd3e46a7b 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 | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 2374cec..aaf0d76 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6813,17 +6813,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; - if (state.hasNodeScratchFor(id) && node.computedStyle().visibility == 0) { + if (node.style.display == 1 || (id != owner && isOutOfFlowPosition(node.style.position))) return; + opaque = opaque && node.style.opacity == 255; + if (state.hasNodeScratchFor(id) && node.style.visibility == 0) { + const bool hidesBackground = opaque && node.type == NodeType::Text && node.style.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}); } @@ -6841,7 +6848,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 8541b68d560d3cc180d4c034ab59592a0d721a29 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. 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 cannot follow the copies, so they are off while a multicol container exists. A container with a column-span: all child keeps a single column, since column sets are not implemented. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/internal.h | 4 + packages/engine/ui/layout.cpp | 170 ++++++++++++++++++++++++++++-- packages/engine/ui/node_model.h | 11 +- packages/engine/ui/render.cpp | 128 ++++++++++++++++++++-- packages/engine/ui/style.cpp | 69 +++++++++--- packages/engine/ui/style.h | 10 +- packages/engine/ui/tree_state.h | 15 +++ packages/engine/ui/tree_style.cpp | 12 ++- 8 files changed, 382 insertions(+), 37 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 4739a84..72802e3 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -506,6 +506,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 8011862..e824218 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -686,10 +686,14 @@ 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) { - 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; + // A multicol container's column-gap separates its column boxes, not its content. + if (isMulticolContainer(node)) return 0; + const int length = rowGap ? node.style.row_gap : node.style.column_gap; + const int percent = rowGap ? node.style.row_gap_percent : node.style.column_gap_percent; const bool vertical = writingMode(node) != 0; const bool horizontalAxis = rowGap == vertical; if (percent != kUnset) { @@ -2334,6 +2338,29 @@ 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.style); + if ((rs.column_count <= 0 && rs.column_width <= 0) || node.type != NodeType::View || node.style.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].style).line_clamp_flags & 64) return false; + return true; +} + +// column-gap: normal is 1em in a multicol container. +int multicolGap(const Node &node, int available) +{ + const auto &s = node.style; + 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; +} + // An inline box with nothing to show: no insets and no visible box. bool emptyInlineBox(const Node &node) { @@ -2427,6 +2454,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(); @@ -2493,6 +2528,9 @@ class LayoutNodePass { #if GEA_CSS_FIRST_LINE if (NodeRareData *rare = rareDataFor(id_)) rare->firstLineBackground.lineValid = false; #endif + // 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_.style, true) : -1); const int padWidth = paddedWidth(); const int padHeight = paddedHeight(); @@ -2539,6 +2577,110 @@ class LayoutNodePass { applyRelativeOffsets(); } + struct ColumnGeometry { + int count = 0, width = 0, gap = 0; + }; + + // 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_.style); + 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_.style, true); + const int insetsY = boxInsets(node_.style, 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_.style).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_.style, 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.style) || isOutOfFlowPosition(child.style.position)) continue; + const int top = originY + child.layout.y; + if (child.type == NodeType::Text) { + const int advance = TextRenderer::measureHeight("X", child.style.font_id, child.style.font_size, 0, child.style.line_height); + const int inner = child.layout.height - boxInsets(child.style, 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.style, 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); + } + } + } + bool containsChildMargins() const { // A stable border box is not sufficient for isolated relayout: child @@ -2664,9 +2806,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]))); } @@ -4340,6 +4482,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(); @@ -4841,10 +4993,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.style, 3); + port[1] = node.layout.y + computedBorderWidth(node.style, 0); + port[2] = node.layout.x + node.layout.width - computedBorderWidth(node.style, 1); + port[3] = node.layout.y + node.layout.height - computedBorderWidth(node.style, 2); } } // namespace diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 9ca56f8..37f96d4 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -153,6 +153,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; @@ -380,6 +382,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 @@ -469,9 +473,10 @@ 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. uint8_t max_lines = 0; uint8_t line_clamp_flags = 0; }; diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index aaf0d76..1ff69ad 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -2172,6 +2172,31 @@ int gLastScrollUiFrame = -1000; } + // 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; + } + // 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 @@ -6818,7 +6843,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; @@ -6827,7 +6852,7 @@ int gLastScrollUiFrame = -1000; opaque = opaque && node.style.opacity == 255; if (state.hasNodeScratchFor(id) && node.style.visibility == 0) { const bool hidesBackground = opaque && node.type == NodeType::Text && node.style.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; @@ -6838,7 +6863,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) { @@ -6848,7 +6873,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) @@ -6865,7 +6890,7 @@ 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); + collectTextInk(command.textClipOwner, command.textClipOwner, x0, y0, x1, y1, ink, true, columnCopyOffset(command)); if (ink.empty()) return; DisplayCommand paint = command; paint.textClipOwner = -1; @@ -7251,6 +7276,68 @@ int gLastScrollUiFrame = -1000; ~SuspendedRecordClips() { restore(clips); } }; + // 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.style, 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.style, 1) - boxInset(n.style, 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); + } + } + 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)) @@ -7426,6 +7513,21 @@ 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. + MulticolLayout columns{}; + if (const NodeRareData *rare = rareDataFor(id); rare && rare->multicol.valid && !state.appendOverride && + (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; + } + const RecordClipFrame ownClip{*n, clips}; for (int child : children) { // A positioned descendant can paint in this context while its @@ -7454,6 +7556,8 @@ 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 (columnFlowBegin >= 0) + replicateColumns(list, *n, columns, columnFlowBegin, state.commandCount); state.recordDepth--; @@ -9726,10 +9830,12 @@ int gLastScrollUiFrame = -1000; { const auto *nodes = Tree::instance().nodes(); 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())); - return n.parent < 0 || n.computedStyle().position == kPositionFixed || ChildZSorter::effectGroup(n) || - (!n.computedStyle().z_index_auto && (n.computedStyle().position != 0 || item)); + const bool item = n.parent >= 0 && !isOutOfFlowPosition(n.style.position) && + (nodes[n.parent].style.display == kDisplayFlex || isDisplayGrid(nodes[n.parent].style)); + // A multicol container paints its whole flow thread, positioned content + // included, so every column box can repeat it. + return n.parent < 0 || n.style.position == kPositionFixed || ChildZSorter::effectGroup(n) || + (!n.style.z_index_auto && (n.style.position != 0 || item)) || LayoutEngine::multicolContainer(n); } bool PaintOrder::isGroup(int id) @@ -9905,6 +10011,7 @@ int gLastScrollUiFrame = -1000; state.textClippedBackgrounds = false; state.commandOverflow = false; state.drawNodeOrderCount = 0; + columnCopies().clear(); if (!state.ensureNodeScratchCapacity(tree.nodeCount())) return; for (int i = 0; i < tree.nodeCount(); i++) @@ -10522,6 +10629,9 @@ int gLastScrollUiFrame = -1000; return false; if (nodes[i].computedStyle().overflow == 2) return false; + // Multicol content paints as copies no node range describes. + if (LayoutEngine::multicolContainer(nodes[i])) + return false; } (void)width; (void)height; diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index ec09e1b..4c32995 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -405,6 +405,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 || @@ -635,6 +637,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 || @@ -1181,6 +1185,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; @@ -6765,7 +6771,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"); @@ -6800,22 +6807,47 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat } // 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; } @@ -7781,10 +7813,15 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #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::ColumnCount: rstyleMut(style).column_count = static_cast(value); return true; + case Property::ColumnWidth: rstyleMut(style).column_width = static_cast(value); return true; 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); @@ -9626,7 +9663,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); @@ -11616,11 +11655,13 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) if (property == "line-clamp" || property == "-webkit-line-clamp") 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 == "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 ef71338..9905ef0 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -207,6 +207,11 @@ enum class Property : int { ColumnWidthSet, VerticalAlign, BlockEllipsisString, + ColumnCount, + ColumnWidth, + ColumnFillAuto, + LineClampDiscard, + ColumnSpanAll, Count }; @@ -214,7 +219,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 { @@ -356,6 +362,8 @@ enum class StyleDeclaration : std::uint8_t { Columns, ColumnCount, ColumnWidth, + ColumnFill, + ColumnSpan, VerticalAlign }; diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index f1a820c..ba9095b 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,7 @@ struct NodeRareData { inline static constexpr FirstLineBackground firstLineBackground{}; inline static constexpr FirstLineFragment firstLineFragment{}; #endif + MulticolLayout multicol; void clear() { @@ -267,6 +281,7 @@ struct NodeRareData { firstLineFragment = FirstLineFragment{}; #endif inlineStaticPosition = InlineStaticPosition{}; + multicol = MulticolLayout{}; } }; diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 0b667f1..b08b637 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -92,6 +92,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_FLOATS case Property::Clear: @@ -351,10 +356,15 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn #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::ColumnCount: if (rstyle(n->style).column_count != value) { rstyleMut(n->style).column_count = value; changed = 1; } break; + case Property::ColumnWidth: if (rstyle(n->style).column_width != value) { rstyleMut(n->style).column_width = value; changed = 1; } break; 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(n->style).line_clamp_flags | bit : rstyle(n->style).line_clamp_flags & ~bit; if (rstyle(n->style).line_clamp_flags != next) { rstyleMut(n->style).line_clamp_flags = next; changed = 1; } From 5e63ea51c085e9100906e4163a7387739278535c 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's width drops to zero and the width setter keeps it there, so the order of border-style and border-width declarations does not matter. The border shorthand resets the styles before it writes the widths, so a side that was none takes the new width. Painters mask the bit out of the relief. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/node_model.h | 14 ++++-- packages/engine/ui/style.cpp | 72 ++++++++++++++++++++++++++++--- packages/engine/ui/style.h | 5 +++ packages/engine/ui/tree_style.cpp | 2 + packages/engine/ui/view.cpp | 2 +- 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 37f96d4..6620bae 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -155,6 +155,8 @@ struct RareStyle { #endif // 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; // 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; @@ -352,6 +354,8 @@ 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] = {}; #else @@ -382,8 +386,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 @@ -1083,6 +1085,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 @@ -1092,6 +1097,8 @@ 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; + // A side whose style is none has no border, whatever width it declares. + if (rstyle(style).border_relief[i] & kBorderStyleNone) widths[i] = 0; } 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. @@ -1369,7 +1376,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 4c32995..8fe949a 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -1220,6 +1220,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; @@ -6806,6 +6811,43 @@ int lineClampWrites(CssDeclarationId declaration, const std::string &value, Stat return 4; } +// 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 @@ -7348,6 +7390,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; @@ -8201,12 +8244,15 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) 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 (value & kBorderStyleNone) setComputedBorderWidth(style, side, 0, nullptr); return true; } - case Property::BorderRelief: - if (value == 0 && !hasBorderRelief(style)) return true; + case Property::BorderRelief: { + const auto &relief = rstyle(style).border_relief; + if (value == 0 && !(relief[0] | relief[1] | relief[2] | relief[3])) 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; @@ -8944,9 +8990,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 (part[0] == '#' || part.find("rgb") != std::string::npos || toLowerAscii(part) == "transparent") { @@ -9665,10 +9712,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; } @@ -10892,11 +10945,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 @@ -11696,6 +11749,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}); @@ -14357,8 +14415,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 9905ef0..f631bd2 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -364,6 +364,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 b08b637..4c06a79 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -863,6 +863,8 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn rstyleMut(style).border_relief[side] = static_cast(value); changed = 1; } + // border-style: none leaves the side without a border. + if (value & kBorderStyleNone) changed |= setComputedBorderWidth(n->style, side, 0, nullptr); } break; #endif diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 82d5003..98da682 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.style).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 2fdbf2b3f1fef903f21cc247d4e9fb61a968ea2b 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 8fe949a..13d1c8f 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -1102,6 +1102,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; @@ -1111,6 +1193,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 || @@ -9943,6 +10029,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: @@ -11604,6 +11713,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 f631bd2..b1e3d19 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -369,6 +369,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 ecb4e71ebe2a2d27b432eb20119f693c3de5a5d5 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, 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. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/input_render.cpp | 6 ++-- packages/engine/ui/internal.h | 2 ++ packages/engine/ui/layout.cpp | 45 +++++++++++++++++++++---- packages/engine/ui/node_model.h | 7 ++-- packages/engine/ui/style.cpp | 13 ++++--- packages/engine/ui/text.cpp | 34 ++++++++++--------- packages/geatsc-plugin-gea/src/utils.ts | 5 +-- 7 files changed, 80 insertions(+), 32 deletions(-) diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index 6d1ac63..edcffed 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -160,9 +160,9 @@ 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.textTransform = n.computedStyle().text_transform; - cmd->text.lineHeight = n.computedStyle().line_height; + cmd->text.align = static_cast(LayoutEngine::physicalTextAlign(n)); + cmd->text.textTransform = n.style.text_transform; + cmd->text.lineHeight = n.style.line_height; cmd->text.containerWidth = contentW; cmd->text.fontId = n.computedStyle().font_id; // append() hands back uninitialized scratch — set these explicitly so diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 72802e3..b9f1296 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -510,6 +510,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 e824218..289c859 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1164,8 +1164,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; @@ -1195,7 +1206,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. @@ -1445,8 +1456,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.back()) || !collapsesSpaces(text)) return 0; + return TextRenderer::measureWidth(" ", text.style.font_id, text.style.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 = @@ -1469,7 +1490,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; @@ -1499,12 +1520,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; } } } @@ -4492,6 +4514,17 @@ bool LayoutEngine::rightToLeftDirection(const Node &node) return rightToLeft(node); } +int LayoutEngine::physicalTextAlign(const Node &node) +{ + switch (node.style.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 6620bae..cf02ea8 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -840,7 +840,8 @@ struct ComputedStyle { #elif !GEA_CSS_U8_LINE_HEIGHT int16_t line_height; #endif - + int8_t text_align; // 0 start, 1 center, 2 right, 3 left, 4 end + int8_t overflow; #if GEA_CSS_OVERFLOW_AXES int8_t overflow_x; int8_t overflow_y; @@ -1561,7 +1562,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; // 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 13d1c8f..c67f71e 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4886,10 +4886,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; } @@ -4901,12 +4905,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; } diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index 622edd2..9f0b8fb 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -730,9 +730,9 @@ bool recordProjectedRasterText(const Node &node, const char *text, uint8_t /*par if (lineWidth <= 0) return true; 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 lineAdvance = node.style.line_height > 0 ? node.style.line_height : fontLineHeight; + const int lineBoxOffset = node.style.line_height > 0 ? (lineAdvance - fontLineHeight) / 2 : 0; + const int srcX = tx + alignedOffset(LayoutEngine::physicalTextAlign(node), tw, lineWidth); const int srcY = ty + lineBoxOffset; const int srcW = lineWidth; const int srcH = fontLineHeight; @@ -1731,8 +1731,8 @@ void Tree::setText(int node, const char *text) const int gid_previousX0 = gid_hadPartial ? target.render.text_dirty.x0 : 0; 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; + !target.render.bg_recolor_pending && gid_singleLine && target.style.white_space == 1 && + 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. @@ -2092,7 +2092,7 @@ static uint16_t blockEllipsisCommand(const Node &run, int textX, int &width) static int8_t textAlignLastCommand(const Node &node) { const int last = node.style.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)); } @@ -2174,6 +2174,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; @@ -2269,7 +2273,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, @@ -2346,7 +2350,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, @@ -2354,7 +2358,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; @@ -2367,7 +2371,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, @@ -2409,8 +2413,8 @@ 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, - n->computedStyle().font_id, dbgFontSize, commandLineHeight); + inkOff = rasterizedSingleLineInkCenterOffsetY(measureText, textAlign, containerW, + n->style.font_id, dbgFontSize, commandLineHeight); #else const int genFonts = 0; #endif @@ -2428,7 +2432,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) { @@ -2462,8 +2466,8 @@ 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.textTransform = n->computedStyle().text_transform; + cmd->text.align = static_cast(textAlign); + cmd->text.textTransform = n->style.text_transform; cmd->text.lineHeight = commandLineHeight; cmd->text.containerWidth = containerW; cmd->text.fontId = n->computedStyle().font_id; 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 3f3da0ae899d669b1bc12ddc219a89120c4a0749 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 289c859..3fdb465 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -687,6 +687,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) { @@ -1016,7 +1017,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 @@ -1089,7 +1091,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, @@ -1456,6 +1459,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.back()) && collapsesSpaces(text); + } + // The collapsible space that ends a line item hangs past the line's end. static int hangingSpace(const Node &item) { @@ -2403,9 +2414,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.style)) 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.style.position)) continue; // A float inside an inline belongs to the enclosing block's float context. if (child.style.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; } @@ -2515,6 +2530,7 @@ class LayoutNodePass { encloseFloatDescendants(); updateScrollContentSize(); positionAbsoluteChildren(); + positionFragmentedInlineChildren(); applyRelativeOffsets(); return; } @@ -2579,6 +2595,7 @@ class LayoutNodePass { if (layoutFloatChildren(scratch.children(), childCount, padWidth, padHeight, false)) { updateScrollContentSize(); positionAbsoluteChildren(); + positionFragmentedInlineChildren(); applyRelativeOffsets(); return; } @@ -3905,6 +3922,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].style) && isOutOfFlowPosition(nodes_[k].style.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].style.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.style) || isOutOfFlowPosition(child.style.position) || child.style.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_.style, 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.style, 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 ff40b0cde71c3827e651f22b7cb027f906cc71ce 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. 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. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/input_render.cpp | 2 + packages/engine/ui/internal.h | 7 +- packages/engine/ui/node_lifecycle.cpp | 2 + packages/engine/ui/node_model.h | 8 +- packages/engine/ui/render.cpp | 2 +- packages/engine/ui/style.cpp | 86 ++++++++++++++++++- packages/engine/ui/style.h | 8 ++ packages/engine/ui/text.cpp | 118 +++++++++++++++++++++++--- packages/engine/ui/tree_style.cpp | 13 +++ 9 files changed, 230 insertions(+), 16 deletions(-) diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index edcffed..e59641e 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -175,6 +175,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 b9f1296..117d846 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -222,6 +222,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 { @@ -610,7 +614,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 b31e7a3..eb1e938 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -205,6 +205,8 @@ constexpr ComputedStyle makeDefaultStyle() #if GEA_CSS_TEXT_ALIGN style.text_align = 0; style.text_align_last = 0; + style.text_emphasis = 0; + style.text_emphasis_color = 0; 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 cf02ea8..23a7be7 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -574,7 +574,9 @@ struct ComputedStyle { #if GEA_CSS_GAP && !GEA_CSS_U8_GAP int16_t gap; #endif -#if GEA_CSS_BOX_SIZING + // text-emphasis-color when text_emphasis says it is explicit. Inherited; + // sits in padding on 16-bit colour builds. + style_color_t text_emphasis_color; int8_t box_sizing; // 0: content-box (CSS initial), 1: border-box #else static constexpr int8_t box_sizing = 0; @@ -780,6 +782,10 @@ 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. + uint8_t text_emphasis; // 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 1ff69ad..a66dc6b 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -7022,7 +7022,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 c67f71e..e08fe6d 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -606,6 +606,8 @@ 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.text_emphasis != b.text_emphasis || + a.text_emphasis_color != b.text_emphasis_color || a.vertical_align != b.vertical_align || a.overflow != b.overflow || a.overflow_x != b.overflow_x || @@ -732,6 +734,8 @@ 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.text_emphasis != b.text_emphasis || + a.text_emphasis_color != b.text_emphasis_color || a.vertical_align != b.vertical_align || a.overflow != b.overflow || a.overflow_x != b.overflow_x || @@ -1326,6 +1330,10 @@ 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, "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; 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; @@ -6939,6 +6947,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 @@ -7482,6 +7543,7 @@ 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 (clampCount < 0 && !hasVar) clampCount = textEmphasisWrites(declaration, value, clampWrites); if (const int count = clampCount; count >= 0) { if (count == 0) return kNoCompiledCssValue; compiled.kind = CssCompiledKind::DirectPropertyGroup; @@ -8447,6 +8509,10 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #if GEA_CSS_TEXT_ALIGN case Property::TextAlign: style.text_align = value; return true; case Property::TextAlignLast: style.text_align_last = value; return true; + 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; case Property::VerticalAlign: style.vertical_align = value; return true; #if GEA_CSS_TEXT_DECORATION case Property::TextDecoration: style.text_decoration = value; return true; @@ -9458,6 +9524,8 @@ bool propertyAffectsDescendantStyle(Property property) property == Property::FontWeight || property == Property::LineHeight || property == Property::LineHeightExpression || property == Property::LineHeightMultiplier || property == Property::TextAlign || property == Property::TextAlignLast || + property == Property::TextEmphasisStyle || property == Property::TextEmphasisPosition || + property == Property::TextEmphasisColorMode || property == Property::TextEmphasisColor || property == Property::TextTransform || property == Property::WhiteSpace || property == Property::Visibility || property == Property::BorderWidth || (property >= Property::BorderTopWidth && property <= Property::BorderLeftWidth); @@ -9491,6 +9559,8 @@ void applyInheritedStyleDefaults(int node) #if GEA_CSS_TEXT_ALIGN style.text_align = parentStyle.text_align; style.text_align_last = parentStyle.text_align_last; + style.text_emphasis = parentStyle.text_emphasis; + style.text_emphasis_color = parentStyle.text_emphasis_color; #if GEA_CSS_TEXT_TRANSFORM style.text_transform = parentStyle.text_transform; #endif @@ -9808,11 +9878,16 @@ 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 (count < 0) count = textEmphasisWrites(declaration, value, writes); for (int i = 0; i < count; ++i) setStyleValue(node, writes[i].property, writes[i].value, source); return true; } @@ -11920,6 +11995,10 @@ 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 == "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}); if (property == "vertical-align") return removeInlineStyleProperties(id, {Property::VerticalAlign}); if (property == "text-decoration" || property == "text-decoration-line") return removeInlineStyleProperties(id, {Property::TextDecoration}); @@ -16258,6 +16337,8 @@ struct ParentStyleSnapshot { #if GEA_CSS_TEXT_ALIGN std::uint8_t text_align; std::uint8_t text_align_last; + std::uint8_t text_emphasis; + style_color_t text_emphasis_color; #if GEA_CSS_TEXT_TRANSFORM std::uint8_t text_transform; #else @@ -16301,6 +16382,8 @@ ParentStyleSnapshot snapshotParentStyle(const ComputedStyle &s) #if GEA_CSS_TEXT_ALIGN out.text_align = s.text_align; out.text_align_last = s.text_align_last; + out.text_emphasis = s.text_emphasis; + out.text_emphasis_color = s.text_emphasis_color; #if GEA_CSS_TEXT_TRANSFORM out.text_transform = s.text_transform; #endif @@ -16324,6 +16407,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_align_last != b.text_align_last || a.text_transform != b.text_transform || + a.text_emphasis != b.text_emphasis || a.text_emphasis_color != b.text_emphasis_color || 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 b1e3d19..d8bcc40 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -212,6 +212,10 @@ enum class Property : int { ColumnFillAuto, LineClampDiscard, ColumnSpanAll, + TextEmphasisStyle, + TextEmphasisPosition, + TextEmphasisColorMode, + TextEmphasisColor, Count }; @@ -369,6 +373,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 9f0b8fb..20af813 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1077,7 +1077,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 @@ -1127,7 +1127,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; @@ -1135,9 +1135,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, (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; @@ -1154,7 +1156,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; @@ -1189,8 +1191,81 @@ 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; }; + // 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; + } + } + // 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). @@ -1265,6 +1340,13 @@ class TextDrawer { 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; + const auto glyphAdvance = [&font](int cp) { + gea::framework::graphics::Glyph glyph{}; + return font.glyph(cp, &glyph) ? glyph.advance : (font.sizePx() / 2); + }; + // Emphasis marks reach past a line's glyph box, which a row clip must include. + const int markAbove = (ellipsis.emphasis & 7) && !(ellipsis.emphasis & 0x10) ? fontSize : 0; + const int markBelow = (ellipsis.emphasis & 7) && (ellipsis.emphasis & 0x10) ? fontSize : 0; // 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. @@ -1321,7 +1403,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, @@ -1338,7 +1420,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; @@ -1351,6 +1433,7 @@ class TextDrawer { if (coverageSink) rasterizedGlyphRun(lineBuffer, lineX, lineBoxY, fontId, fontSize, *coverageSink); else gea::platform::display::Display::drawTextFontFamily(lineBuffer, lineX, lineBoxY, color, fontId, fontSize); } + drawEmphasisMarks(lineStart, chars, lineX, lineBoxY, glyphHeight, fontSize, ellipsis, glyphAdvance, coverageSink); } lineStart += entry.consumedBytes[li]; penY += lineAdvance; @@ -1363,7 +1446,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, @@ -1387,7 +1470,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)); @@ -1399,6 +1482,7 @@ class TextDrawer { if (coverageSink) rasterizedGlyphRun(lineBuffer, lineX, lineBoxY, fontId, fontSize, *coverageSink); else gea::platform::display::Display::drawTextFontFamily(lineBuffer, lineX, lineBoxY, color, fontId, fontSize); } + drawEmphasisMarks(lineStart, line.renderBytes, lineX, lineBoxY, glyphHeight, fontSize, ellipsis, glyphAdvance, coverageSink); } lineStart += line.consumedBytes; @@ -1548,6 +1632,8 @@ class TextDrawer { penX += glyphWidth; } } + drawEmphasisMarks(lineStart, line.renderBytes, lineOriginX + alignedOffset(lineAlignment(textAlign, alignLast, lineStart, line.consumedBytes, !lineStart[line.consumedBytes]), containerWidth, line.width), + penY, glyphHeight, glyphHeight, ellipsis, [glyphWidth](int) { return glyphWidth; }, coverageSink); } lineStart += line.consumedBytes; @@ -2042,9 +2128,9 @@ bool TextRenderer::remeasureContentBox(int id, bool keepBoxWidth) return true; } -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, @@ -2444,6 +2530,12 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph int ellipsisWidth = 0; const uint16_t blockEllipsis = !transformed && (n->layout.line_clamp_hidden & 2) ? blockEllipsisCommand(*n, n->layout.x + boxInset(n->style, 3), ellipsisWidth) : 0; + // Emphasis marks sit above (or below) the glyph box, up to half an em. + if (n->style.text_emphasis & 7) { + const int reach = n->style.font_size; + if (n->style.text_emphasis & 0x10) bh += reach; + else { by -= reach; bh += reach; } + } // 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); @@ -2478,6 +2570,8 @@ 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); + cmd->text.emphasis = (n->style.text_emphasis & 7) ? n->style.text_emphasis : 0; + cmd->text.emphasisColor = (n->style.text_emphasis & 0x60) == 0x20 ? n->style.text_emphasis_color : n->style.text_color; { 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 4c06a79..e29e574 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -1004,6 +1004,19 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn } case Property::TextAlign: if (n->style.text_align != value) { n->style.text_align = value; changed = 1; } break; case Property::TextAlignLast: if (n->style.text_align_last != value) { n->style.text_align_last = value; changed = 1; } break; + case Property::TextEmphasisStyle: + case Property::TextEmphasisPosition: + case Property::TextEmphasisColorMode: { + const int mask = prop == Property::TextEmphasisStyle ? 0x0f : prop == Property::TextEmphasisPosition ? 0x10 : 0x60; + const int next = (n->style.text_emphasis & ~mask) | (value & mask); + if (n->style.text_emphasis != next) { n->style.text_emphasis = static_cast(next); changed = 1; } + break; + } + case Property::TextEmphasisColor: { + const auto color = StyleValues::pixelFromStyleValue(value); + if (n->style.text_emphasis_color != color) { n->style.text_emphasis_color = color; changed = 1; } + break; + } case Property::VerticalAlign: if (n->style.vertical_align != value) { n->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 5727d0e5780eccb2cf03fc5b95fb8d4e07c010a2 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. 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. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/display_invalidation.cpp | 1 + packages/engine/ui/internal.h | 3 + packages/engine/ui/node_model.h | 1 + packages/engine/ui/render.cpp | 287 ++++++++++++++++++-- packages/engine/ui/style.cpp | 61 +++++ packages/engine/ui/style.h | 2 + packages/engine/ui/style_values.h | 4 + packages/engine/ui/tree_style.cpp | 13 +- packages/engine/ui/view.cpp | 90 +++--- 9 files changed, 392 insertions(+), 70 deletions(-) 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 117d846..e75bd1d 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -154,6 +154,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 { @@ -166,6 +168,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 23a7be7..c11b941 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -406,6 +406,7 @@ struct RareStyle { uint8_t bg_gradient_has_mid = 0; int16_t bg_gradient_angle = 1800; uint8_t bg_overlay_gradient = 0; + uint8_t bg_blend = 0; // Interned background-blend-mode list; zero is all normal. style_color_t bg_overlay_gradient_from_color = 0; style_color_t bg_overlay_gradient_mid_color = 0; style_color_t bg_overlay_gradient_to_color = 0; diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index a66dc6b..ae9131c 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -5775,6 +5775,121 @@ int gLastScrollUiFrame = -1000; } }; + // 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. + 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; + } + if (lo == hi) { 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)); + } + + // 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. + 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; + x0 = std::max(x0, 0); y0 = std::max(y0, 0); + x1 = std::min(x1, canvas->width() - 1); y1 = std::min(y1, canvas->height() - 1); + const int baseAlpha = gea::platform::display::Display::alpha(); + for (int y = y0; y <= y1; ++y) { + int rowX0 = x0, rowX1 = x1; + span(y, &rowX0, &rowX1); + for (int x = rowX0; x <= rowX1; ++x) { + int rgb[3], alpha = 0; + if (!color(x, y, rgb, &alpha)) continue; + alpha = alpha * baseAlpha / 255; + if (alpha <= 0) continue; + int br, bg, bb, ba; + gea::framework::graphics::pixel::unpackNative8(canvas->readPixelNative(x, y), &br, &bg, &bb, &ba); + const float cb[3] = {br / 255.0f, bg / 255.0f, bb / 255.0f}; + const float cs[3] = {rgb[0] / 255.0f, rgb[1] / 255.0f, rgb[2] / 255.0f}; + float mixed[3]; + blend(mode, cb, cs, mixed); + const float a = alpha / 255.0f; + int out[3]; + for (int i = 0; i < 3; ++i) + out[i] = std::clamp(static_cast((cb[i] + (mixed[i] - cb[i]) * a) * 255.0f + 0.5f), 0, 255); + canvas->writePixelNativeExact(x, y, gea::framework::graphics::pixel::packNative8(out[0], out[1], out[2])); + } + } + } + }; + struct LinearGradientDrawer { static constexpr double kPi = 3.14159265358979323846; @@ -6047,6 +6162,62 @@ 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; + } + + 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, lastRgb[3] = {}, lastAlpha = 0; + 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 double projection = (x + 0.5) * dx + (y + 0.5) * dy; + const int permille = clampPermille(static_cast((projection - minProjection) * invSpanPermille + 0.5)); + if (permille != lastPermille) { + rgb888At(c, permille, &lastRgb[0], &lastRgb[1], &lastRgb[2]); + lastAlpha = alphaAt(c, permille); + lastPermille = permille; + } + for (int i = 0; i < 3; ++i) rgb[i] = lastRgb[i]; + *alpha = lastAlpha; + return true; + }); + } + 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); @@ -6069,6 +6240,11 @@ int gLastScrollUiFrame = -1000; const int y1 = std::min(c.gradient.y + c.gradient.h - 1, cy1); if (x1 < x0 || y1 < y0) return; + if (c.gradient.blend) + { + replayBlended(c, x0, y0, x1, y1); + 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. @@ -6168,31 +6344,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(); @@ -6659,6 +6812,21 @@ 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 (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; + } // Static-gradient bitmap cache (shared StaticGradientCache). The shell's // warm-glow radial is static — fixed colors + box, identical across themes @@ -6876,6 +7044,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; @@ -6894,10 +7095,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); @@ -6919,9 +7122,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 e08fe6d..4b908bc 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -504,6 +504,7 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) a.bg_fill != b.bg_fill || ar.containment != br.containment || ar.bg_clip != br.bg_clip || + ar.bg_blend != br.bg_blend || ar.bg_size_list != br.bg_size_list || ar.bg_position_list != br.bg_position_list || ar.bg_repeat_list != br.bg_repeat_list || @@ -1289,6 +1290,7 @@ 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 (std::strcmp(property, "background-blend-mode") == 0) return CssDeclarationId::BackgroundBlendMode; 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; @@ -2391,10 +2393,17 @@ std::vector> &backgroundClipLists() return lists; } +std::vector> &backgroundBlendLists() +{ + static std::vector> lists; + return lists; +} + void clearCompiledCssBackgrounds() { compiledCssBackgrounds().clear(); backgroundClipLists().clear(); + backgroundBlendLists().clear(); backgroundPlacementLists().clear(); } @@ -6824,6 +6833,31 @@ int compileBackgroundClip(const std::string &value, bool shorthand = false) return storeBackgroundClipList(clips); } +// 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()); +} + ParsedCssColor backgroundBaseColor(const std::string &value) { ParsedCssColor color; @@ -7538,6 +7572,14 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa compiled.values[1] = clip; return storeCompiledCssValue(compiled); } + 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); + } StaticStylePropertyValue clampWrites[4]; int clampCount = hasVar ? -1 : lineClampWrites(declaration, value, clampWrites); @@ -7961,6 +8003,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #endif #if GEA_CSS_BACKGROUND_LAYERS case Property::BackgroundClip: rstyleMut(style).bg_clip = value; return true; + case Property::BackgroundBlendMode: rstyleMut(style).bg_blend = value; return true; case Property::BackgroundSizeList: rstyleMut(style).bg_size_list = value; return true; case Property::BackgroundPositionList: rstyleMut(style).bg_position_list = value; return true; case Property::BackgroundRepeatList: rstyleMut(style).bg_repeat_list = value; return true; @@ -9799,6 +9842,14 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl if (clip >= 0) setStyleValue(node, Property::BackgroundClip, clip, source); return true; } + case CssDeclarationId::BackgroundBlendMode: { + const int parent = Tree::instance().node(nodeId).parent; + const int blend = toLowerAscii(trimCssValue(value)) == "inherit" + ? (parent >= 0 ? rstyle(Tree::instance().node(parent).style).bg_blend : 0) + : compileBackgroundBlendMode(value); + if (blend >= 0) setStyleValue(node, Property::BackgroundBlendMode, blend, source); + return true; + } case CssDeclarationId::Contain: { const int parent = Tree::instance().node(nodeId).parent; const int flags = toLowerAscii(trimCssValue(value)) == "inherit" @@ -11939,6 +11990,7 @@ 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 (property == "background-blend-mode") return removeInlineStyleProperties(id, {Property::BackgroundBlendMode}); 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}); @@ -17668,6 +17720,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 d8bcc40..629edbf 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -216,6 +216,7 @@ enum class Property : int { TextEmphasisPosition, TextEmphasisColorMode, TextEmphasisColor, + BackgroundBlendMode, Count }; @@ -377,6 +378,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..55dc372 100644 --- a/packages/engine/ui/style_values.h +++ b/packages/engine/ui/style_values.h @@ -22,6 +22,10 @@ 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); + // 0 normal, then multiply, screen, overlay, darken, lighten, color-dodge, + // color-burn, hard-light, soft-light, difference, exclusion, hue, + // saturation, color, luminosity. + static int backgroundBlendMode(const ComputedStyle &style, int layer); 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 e29e574..50af6ac 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -753,12 +753,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 98da682..9962ff5 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -284,10 +284,25 @@ 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) +{ + const int mode = StyleValues::backgroundBlendMode(node.style, layer); + if (!mode) return 0; + const auto &r = rstyle(node.style); + const bool backdrop = (node.style.has_bg && node.style.bg_alpha > 0) || + (node.style.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; +} + 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 +329,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; } @@ -324,16 +340,17 @@ DisplayCommand *appendLinearGradientRectRaw(const Node &node, int x, int y, int y, w, h, - rstyle(node.computedStyle()).bg_gradient_from_color, - rstyle(node.computedStyle()).bg_gradient_mid_color, - rstyle(node.computedStyle()).bg_gradient_to_color, - rstyle(node.computedStyle()).bg_gradient_mid_stop, - rstyle(node.computedStyle()).bg_gradient_to_stop, - rstyle(node.computedStyle()).bg_gradient_angle, - 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.style).bg_gradient_from_color, + rstyle(node.style).bg_gradient_mid_color, + rstyle(node.style).bg_gradient_to_color, + rstyle(node.style).bg_gradient_mid_stop, + rstyle(node.style).bg_gradient_to_stop, + rstyle(node.style).bg_gradient_angle, + rstyle(node.style).bg_gradient_from_alpha, + rstyle(node.style).bg_gradient_mid_alpha, + rstyle(node.style).bg_gradient_to_alpha, + rstyle(node.style).bg_gradient_has_mid, + backgroundLayerBlend(node, rstyle(node.style).bg_gradient_layer)); } DisplayCommand *appendRadialGradientRectRaw(const Node &node, int x, int y, int w, int h) @@ -349,19 +366,20 @@ DisplayCommand *appendRadialGradientRectRaw(const Node &node, int x, int y, int cmd->radialGradient.y = y; cmd->radialGradient.w = w; cmd->radialGradient.h = h; - cmd->radialGradient.tl = node.computedStyle().border_radius[GEA_CSS_RADIUS_INDEX(0)]; - cmd->radialGradient.tr = node.computedStyle().border_radius[GEA_CSS_RADIUS_INDEX(1)]; - cmd->radialGradient.br = node.computedStyle().border_radius[GEA_CSS_RADIUS_INDEX(2)]; - cmd->radialGradient.bl = node.computedStyle().border_radius[GEA_CSS_RADIUS_INDEX(3)]; - cmd->radialGradient.cxPermille = rstyle(node.computedStyle()).bg_radial_gradient_cx; - cmd->radialGradient.cyPermille = rstyle(node.computedStyle()).bg_radial_gradient_cy; - cmd->radialGradient.rxPermille = rstyle(node.computedStyle()).bg_radial_gradient_rx; - cmd->radialGradient.ryPermille = rstyle(node.computedStyle()).bg_radial_gradient_ry; - cmd->radialGradient.fromColor = rstyle(node.computedStyle()).bg_radial_gradient_from_color; - cmd->radialGradient.toColor = rstyle(node.computedStyle()).bg_radial_gradient_to_color; - 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.tl = node.style.border_radius[GEA_CSS_RADIUS_INDEX(0)]; + cmd->radialGradient.tr = node.style.border_radius[GEA_CSS_RADIUS_INDEX(1)]; + cmd->radialGradient.br = node.style.border_radius[GEA_CSS_RADIUS_INDEX(2)]; + cmd->radialGradient.bl = node.style.border_radius[GEA_CSS_RADIUS_INDEX(3)]; + cmd->radialGradient.cxPermille = rstyle(node.style).bg_radial_gradient_cx; + cmd->radialGradient.cyPermille = rstyle(node.style).bg_radial_gradient_cy; + cmd->radialGradient.rxPermille = rstyle(node.style).bg_radial_gradient_rx; + cmd->radialGradient.ryPermille = rstyle(node.style).bg_radial_gradient_ry; + cmd->radialGradient.fromColor = rstyle(node.style).bg_radial_gradient_from_color; + cmd->radialGradient.toColor = rstyle(node.style).bg_radial_gradient_to_color; + cmd->radialGradient.stopPermille = rstyle(node.style).bg_radial_gradient_stop; + cmd->radialGradient.fromAlpha = rstyle(node.style).bg_radial_gradient_from_alpha; + cmd->radialGradient.toAlpha = rstyle(node.style).bg_radial_gradient_to_alpha; + cmd->radialGradient.blend = backgroundLayerBlend(node, rstyle(node.style).bg_radial_gradient_layer); return cmd; } @@ -1787,16 +1805,17 @@ void recordOverlayLinearGradientBackground(const Node &node) y, w, h, - rstyle(node.computedStyle()).bg_overlay_gradient_from_color, - rstyle(node.computedStyle()).bg_overlay_gradient_mid_color, - rstyle(node.computedStyle()).bg_overlay_gradient_to_color, - rstyle(node.computedStyle()).bg_overlay_gradient_mid_stop, - rstyle(node.computedStyle()).bg_overlay_gradient_to_stop, - rstyle(node.computedStyle()).bg_overlay_gradient_angle, - 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.style).bg_overlay_gradient_from_color, + rstyle(node.style).bg_overlay_gradient_mid_color, + rstyle(node.style).bg_overlay_gradient_to_color, + rstyle(node.style).bg_overlay_gradient_mid_stop, + rstyle(node.style).bg_overlay_gradient_to_stop, + rstyle(node.style).bg_overlay_gradient_angle, + rstyle(node.style).bg_overlay_gradient_from_alpha, + rstyle(node.style).bg_overlay_gradient_mid_alpha, + rstyle(node.style).bg_overlay_gradient_to_alpha, + rstyle(node.style).bg_overlay_gradient_has_mid, + backgroundLayerBlend(node, rstyle(node.style).bg_overlay_gradient_layer)); } // Background positioning and painting areas are distinct. In particular, the @@ -1855,7 +1874,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 0705e8b371ea29385f563d5e07db7f81df0c0db1 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 a gradient box scaled so its slice lands on the tile, clipped to the tile. A painted border image replaces the border styles, and the node's dirty rect grows by the outset. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/display_invalidation.cpp | 5 + packages/engine/ui/layout.cpp | 39 ++- packages/engine/ui/node_model.h | 7 + packages/engine/ui/style.cpp | 312 +++++++++++++++++++- packages/engine/ui/style.h | 11 + packages/engine/ui/style_values.h | 20 ++ packages/engine/ui/tree_render.cpp | 6 +- packages/engine/ui/tree_style.cpp | 5 + packages/engine/ui/view.cpp | 94 ++++++ 9 files changed, 475 insertions(+), 24 deletions(-) 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/layout.cpp b/packages/engine/ui/layout.cpp index 3fdb465..1eacf11 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1402,7 +1402,7 @@ class FlexLayoutPass { 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.front()) && wrapsLines(text); + return !text.text.empty() && collapsibleSpace(text.text.str().front()) && wrapsLines(text); } static bool trailingSoftWrap(const Node &item) @@ -1410,7 +1410,7 @@ class FlexLayoutPass { 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.back()) && wrapsLines(text); + return !text.text.empty() && collapsibleSpace(text.text.str().back()) && wrapsLines(text); } // A collapsible space that opens an atomic text item is removed at the @@ -1421,12 +1421,12 @@ class FlexLayoutPass { { const Node &item = nodes[children_[k]]; if (item.type != NodeType::Text || isFragmentableRun(item) || item.text.empty() || - !collapsibleSpace(item.text.front()) || !collapsesSpaces(item)) return 0; + !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.back()) || !collapsesSpaces(previous)) return 0; + if (previous.text.empty() || !collapsibleSpace(previous.text.str().back()) || !collapsesSpaces(previous)) return 0; } return TextRenderer::measureWidth(" ", item.style.font_id, item.style.font_size); } @@ -1464,7 +1464,7 @@ class FlexLayoutPass { 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.back()) && collapsesSpaces(text); + 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. @@ -1473,7 +1473,7 @@ class FlexLayoutPass { 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.back()) || !collapsesSpaces(text)) return 0; + if (text.text.empty() || !collapsibleSpace(text.text.str().back()) || !collapsesSpaces(text)) return 0; return TextRenderer::measureWidth(" ", text.style.font_id, text.style.font_size); } @@ -2676,8 +2676,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 @@ -5104,16 +5106,33 @@ bool LayoutEngine::layoutNodeScoped(int scope, int treeRoot) 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.style, side) != kUnset ? GEA_CSS_POSITION_PX(node.style, side) : 0; + if (GEA_CSS_POSITION_PERCENT(node.style, side) != kUnset) inset += basis * GEA_CSS_POSITION_PERCENT(node.style, 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 c11b941..a9c35f3 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -401,9 +401,13 @@ struct RareStyle { uint8_t bg_gradient_from_alpha = 255; uint8_t bg_gradient_mid_alpha = 255; uint8_t bg_gradient_to_alpha = 255; + // border-image-* (see StyleValues::borderImageSource/Sides/Repeat) sit in + // padding here: interned handles, zero is the initial value. + uint8_t border_image_source = 0; uint16_t bg_gradient_mid_stop = 500; uint16_t bg_gradient_to_stop = 1000; uint8_t bg_gradient_has_mid = 0; + uint8_t border_image_slice = 0; int16_t bg_gradient_angle = 1800; uint8_t bg_overlay_gradient = 0; uint8_t bg_blend = 0; // Interned background-blend-mode list; zero is all normal. @@ -413,11 +417,14 @@ struct RareStyle { uint8_t bg_overlay_gradient_from_alpha = 255; uint8_t bg_overlay_gradient_mid_alpha = 255; uint8_t bg_overlay_gradient_to_alpha = 255; + uint8_t border_image_width = 0; uint16_t bg_overlay_gradient_mid_stop = 500; uint16_t bg_overlay_gradient_to_stop = 1000; uint8_t bg_overlay_gradient_has_mid = 0; + uint8_t border_image_outset = 0; int16_t bg_overlay_gradient_angle = 1800; uint8_t bg_radial_gradient = 0; + uint8_t border_image_repeat = 0; // bits 0-1 horizontal, 2-3 vertical: stretch, repeat, round, space style_color_t bg_radial_gradient_from_color = 0; style_color_t bg_radial_gradient_to_color = 0; uint8_t bg_radial_gradient_from_alpha = 255; diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 4b908bc..eb782aa 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -505,6 +505,11 @@ bool styleEqualExceptTextPaint(const ComputedStyle &a, const ComputedStyle &b) ar.containment != br.containment || ar.bg_clip != br.bg_clip || ar.bg_blend != br.bg_blend || + 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 || ar.bg_size_list != br.bg_size_list || ar.bg_position_list != br.bg_position_list || ar.bg_repeat_list != br.bg_repeat_list || @@ -1291,6 +1296,12 @@ CssDeclarationId classifyDeclaration(const char *property) if (std::strcmp(property, "background-color") == 0) return CssDeclarationId::BackgroundColor; if (std::strcmp(property, "background-clip") == 0) return CssDeclarationId::BackgroundClip; if (std::strcmp(property, "background-blend-mode") == 0) return CssDeclarationId::BackgroundBlendMode; + 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; 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; @@ -1546,9 +1557,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 border-image shorthand) 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); @@ -1976,6 +1988,10 @@ 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. +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(); @@ -1984,7 +2000,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++; @@ -2399,11 +2415,25 @@ std::vector> &backgroundBlendLists() return lists; } +std::vector &borderImageSources() +{ + static std::vector list; + return list; +} + +std::vector &borderImageSideLists() +{ + static std::vector list; + return list; +} + void clearCompiledCssBackgrounds() { compiledCssBackgrounds().clear(); backgroundClipLists().clear(); backgroundBlendLists().clear(); + borderImageSources().clear(); + borderImageSideLists().clear(); backgroundPlacementLists().clear(); } @@ -6983,6 +7013,218 @@ int borderStyleWrites(CssDeclarationId declaration, const std::string &value, St // 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. +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; +} + int textEmphasisWrites(CssDeclarationId declaration, const std::string &value, StaticStylePropertyValue out[4]) { const bool shorthand = declaration == CssDeclarationId::TextEmphasis; @@ -7581,18 +7823,19 @@ std::uint16_t compileCssValue(CssDeclarationId declaration, const CssText &rawVa return storeCompiledCssValue(compiled); } - 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 (clampCount < 0 && !hasVar) clampCount = textEmphasisWrites(declaration, value, clampWrites); + if (clampCount < 0 && !hasVar) clampCount = borderImageWrites(declaration, value, clampWrites); 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); } @@ -8004,6 +8247,11 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #if GEA_CSS_BACKGROUND_LAYERS case Property::BackgroundClip: rstyleMut(style).bg_clip = value; return true; case Property::BackgroundBlendMode: rstyleMut(style).bg_blend = value; return true; + 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; case Property::BackgroundSizeList: rstyleMut(style).bg_size_list = value; return true; case Property::BackgroundPositionList: rstyleMut(style).bg_position_list = value; return true; case Property::BackgroundRepeatList: rstyleMut(style).bg_repeat_list = value; return true; @@ -9933,12 +10181,19 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::TextEmphasis: case CssDeclarationId::TextEmphasisStyleDeclaration: case CssDeclarationId::TextEmphasisColorDeclaration: - case CssDeclarationId::TextEmphasisPositionDeclaration: { - StaticStylePropertyValue writes[4]; + case CssDeclarationId::TextEmphasisPositionDeclaration: + 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 (count < 0) count = textEmphasisWrites(declaration, value, writes); + if (count < 0) count = borderImageWrites(declaration, value, writes); for (int i = 0; i < count; ++i) setStyleValue(node, writes[i].property, writes[i].value, source); return true; } @@ -11401,7 +11656,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; @@ -11991,6 +12246,14 @@ bool removeInlineStyleProperty(NodeHandle node, const std::string &property) 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 (property == "background-blend-mode") return removeInlineStyleProperties(id, {Property::BackgroundBlendMode}); + 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}); 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}); @@ -15181,7 +15444,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; @@ -17720,6 +17983,31 @@ bool StyleValues::hasTextBackgroundClip(const ComputedStyle &style) return std::find(list.begin(), list.end(), 3) != list.end(); } +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)); +} + int StyleValues::backgroundBlendMode(const ComputedStyle &style, int layer) { const auto handle = rstyle(style).bg_blend; diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 629edbf..89926c3 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -217,6 +217,11 @@ enum class Property : int { TextEmphasisColorMode, TextEmphasisColor, BackgroundBlendMode, + BorderImageSource, + BorderImageSlice, + BorderImageWidth, + BorderImageOutset, + BorderImageRepeat, Count }; @@ -379,6 +384,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 55dc372..15e383b 100644 --- a/packages/engine/ui/style_values.h +++ b/packages/engine/ui/style_values.h @@ -8,6 +8,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 @@ -27,6 +41,12 @@ class StyleValues { // saturation, color, luminosity. static int backgroundBlendMode(const ComputedStyle &style, int layer); static bool hasTextBackgroundClip(const ComputedStyle &style); + // 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. + static int borderImageOutsetExtent(const ComputedStyle &style); 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 3b9d8cb..e93ff87 100644 --- a/packages/engine/ui/tree_render.cpp +++ b/packages/engine/ui/tree_render.cpp @@ -8,6 +8,7 @@ #include "layout_snapshot.h" #include "refresh_perf.h" #include "root_scroll_refresh.h" +#include "style_values.h" #include "tree_state.h" #include @@ -384,7 +385,7 @@ namespace gea::embedded::ui 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 ? rstyle(node.style).box_shadow_alpha : 0) > 0 || rstyle(node.style).border_image_source) return true; return node.computedStyle().has_bg && (styleHasRoundedRasterEdge(node.computedStyle()) || node.computedStyle().bg_alpha < 255); } @@ -406,7 +407,8 @@ namespace gea::embedded::ui { if (!dirtyRectNeedsRasterGuard(node)) return rect; - return expandDirtyRect(rect, 1); + // border-image-outset paints outside the border box. + return expandDirtyRect(rect, 1 + StyleValues::borderImageOutsetExtent(node.style)); } DirtyRegions::Rect dirtyRectWithRetainedMoveGuard(DirtyRegions::Rect rect) diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 50af6ac..d2b3e45 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -755,6 +755,11 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn #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::BorderImageSource: if (rstyle(n->style).border_image_source != value) { rstyleMut(n->style).border_image_source = value; changed = 1; } break; + case Property::BorderImageSlice: if (rstyle(n->style).border_image_slice != value) { rstyleMut(n->style).border_image_slice = value; changed = 1; } break; + case Property::BorderImageWidth: if (rstyle(n->style).border_image_width != value) { rstyleMut(n->style).border_image_width = value; changed = 1; } break; + case Property::BorderImageOutset: if (rstyle(n->style).border_image_outset != value) { rstyleMut(n->style).border_image_outset = value; changed = 1; } break; + case Property::BorderImageRepeat: if (rstyle(n->style).border_image_repeat != value) { rstyleMut(n->style).border_image_repeat = 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; diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 9962ff5..194b64f 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2911,6 +2911,98 @@ int ViewRenderer::canvasBackgroundSource() return root; } +// 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, clipped to the tile. +bool recordBorderImage(const Node &node) +{ + const auto *source = StyleValues::borderImageSource(node.style); + if (!source || ViewGeometry::hasTransformChain(node, false)) return false; + const auto slice = StyleValues::borderImageSides(node.style, 0); + const auto widths = StyleValues::borderImageSides(node.style, 1); + const auto outset = StyleValues::borderImageSides(node.style, 2); + const int repeat = rstyle(node.style).border_image_repeat; + double border[4], out[4]; + for (int i = 0; i < 4; ++i) { + border[i] = computedBorderWidth(node.style, i); + out[i] = outset.kind[i] == 1 ? outset.value[i] : outset.value[i] * border[i]; + } + const double ax = node.layout.x - out[3], ay = node.layout.y - out[0]; + const double aw = node.layout.width + out[1] + out[3], ah = node.layout.height + out[0] + out[2]; + if (aw <= 0 || ah <= 0) return true; + // A gradient has no natural size: the image is the border image area, and + // auto widths fall back to the border widths. + double s[4], w[4]; + for (int i = 0; i < 4; ++i) { + const double size = i % 2 ? aw : ah; + s[i] = std::min(size, slice.kind[i] == 2 ? slice.value[i] * size / 100.0 : 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.0 : border[i]; + } + const double fit = std::min({1.0, w[1] + w[3] > aw ? aw / (w[1] + w[3]) : 1.0, w[0] + w[2] > ah ? ah / (w[0] + w[2]) : 1.0}); + for (double &v : w) v *= fit; + const double 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 double sx[4] = {0, s[3], aw - s[1], aw}, sy[4] = {0, s[0], ah - s[2], ah}; + // A part's scale along the tiled axis, from the edge it follows. + auto factor = [](double first, double firstSlice, double second, double secondSlice) { + if (firstSlice > 0 && first > 0) return first / firstSlice; + return secondSlice > 0 && second > 0 ? second / secondSlice : 1.0; + }; + // Tile origins and size along one axis of a part. + struct Tiles { double start, size, step; int count; }; + auto tile = [](int mode, double d0, double d1, double size) -> Tiles { + const double span = d1 - d0; + if (mode == 0 || size <= 0 || span / size > 64) return {d0, span, span, 1}; + if (mode == 2) { + const int n = std::max(1, static_cast(std::lround(span / size))); + return {d0, span / n, span / n, n}; + } + if (mode == 3) { + const int n = static_cast(span / size); + const double gap = n ? (span - n * size) / (n + 1) : 0; + return {d0 + gap, size, size + gap, n}; + } + const double first = d0 + (span - size) / 2 - std::ceil((span - size) / 2 / 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 double dw = dx[col + 1] - dx[col], dh = dy[row + 1] - dy[row]; + const double sw = sx[col + 1] - sx[col], sh = sy[row + 1] - sy[row]; + if (dw <= 0 || dh <= 0 || sw <= 0 || sh <= 0) continue; + const double kx = col != 1 ? dw / sw : row == 1 ? factor(w[0], s[0], w[2], s[2]) : dh / sh; + const double 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}; + for (int j = 0; j < ty.count; ++j) for (int i = 0; i < tx.count; ++i) { + const double t0 = tx.start + i * tx.step, u0 = ty.start + 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 double gx = tx.size / sw, gy = ty.size / sh; + const long vx = std::lround(t0 - sx[col] * gx), vy = std::lround(u0 - sy[row] * gy); + const long vw = std::lround(aw * gx), vh = std::lround(ah * gy); + if (std::max({std::labs(vx), std::labs(vy), vx + vw, vy + vh}) > 32000) continue; + auto *begin = DisplayList::instance().append(); + if (!begin) return true; + begin->type = DisplayCommandType::PushClip; + begin->bx = begin->clip.x = cx0; begin->by = begin->clip.y = cy0; + begin->bw = begin->clip.w = cx1 - cx0; begin->bh = begin->clip.h = cy1 - cy0; + begin->clip.nodeId = -1; + begin->clip.shaped = 0; + if (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)) { + paint->gradient.tl = paint->gradient.tr = paint->gradient.br = paint->gradient.bl = 0; + paint->bx = cx0; paint->by = cy0; paint->bw = cx1 - cx0; paint->bh = cy1 - cy0; + } + auto *end = DisplayList::instance().append(); + if (end) { end->type = DisplayCommandType::PopClip; end->bx = cx0; end->by = cy0; end->bw = cx1 - cx0; end->bh = cy1 - cy0; end->clip.nodeId = -1; } + } + } + return true; +} + void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordBox(const Node &node, uint8_t parentAlpha) { const Node *n = &node; @@ -2995,6 +3087,8 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB #if GEA_CSS_BOX_SHADOW recordInsetBoxShadow(*n, parentAlpha); #endif + // A painted border image replaces the border styles. + if (recordBorderImage(*n)) return; if (n->computedStyle().border_width > 0 && !hasBorderRelief(n->computedStyle()) && !hasSideBorder(n->computedStyle()) && !borderColorsDiffer(n->computedStyle()) && !borderIsSameOpaqueSolidBackground(*n, parentAlpha)) { if (ViewGeometry::hasTransformChain(*n, false)) { From 2f1495438a7a56129614e15b505bcf0d84c69ef3 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 | 121 ++++++++++++++++++++++++++------ packages/engine/ui/tree_state.h | 9 +++ packages/engine/ui/view.cpp | 29 ++++++++ 3 files changed, 136 insertions(+), 23 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 1eacf11..cc783bc 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2427,6 +2427,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.style.display != kDisplayBlock || emptyInlineBox(n) || overflowEstablishesContext(n.style)) return false; + for (int c = n.first_child; c >= 0; c = nodes[c].next_sibling) { + const Node &child = nodes[c]; + if (!isDisplayNone(child.style) && !isOutOfFlowPosition(child.style.position) && !child.style.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; @@ -2491,7 +2509,10 @@ class LayoutNodePass { return; } - if (NodeRareData *rare = rareDataFor(id_)) rare->multicol.valid = false; + if (NodeRareData *rare = rareDataFor(id_)) { + rare->multicol.valid = false; + rare->inlineParts.clear(); + } const ColumnGeometry columns = columnGeometry(); if (columns.count > 0) layoutColumns(columns); else layoutContent(); @@ -3129,6 +3150,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_.style.margin[s] + boxInset(node_.style, 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]].style.float_side) return true; + return false; + }; + auto closePart = [&]() { + const int left = rtl ? contentWidth + trimRight - partWidth - (trimLeft ? 0 : boxInset(node_.style, 3)) : -trimLeft - (trimLeft ? 0 : boxInset(node_.style, 3)); + const int right = rtl ? contentWidth + trimRight + (trimRight ? 0 : boxInset(node_.style, 1)) : -trimLeft + partWidth + (trimRight ? 0 : boxInset(node_.style, 1)); + const int top = partTop - boxInset(node_.style, 0), bottom = flowY + boxInset(node_.style, 2); + InlinePart part; + part.x = clampInt16(boxInset(node_.style, 3) + left); + part.y = clampInt16(boxInset(node_.style, 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) { @@ -3139,6 +3188,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) { @@ -3150,7 +3208,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. @@ -3165,20 +3223,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); @@ -3190,7 +3248,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); } } @@ -3198,11 +3256,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); @@ -3211,15 +3275,22 @@ class LayoutNodePass { i = end - 1; continue; } - engine_.layoutNode(children[i], contentWidth, 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) && + const bool splitBlock = split && !child.style.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.style.float_side && blockBox(child); + const int measuredOuterW = child.layout.width + child.style.margin[3] + child.style.margin[1]; + const bool automaticWidth = block && !hasExplicitWidth(child) && !isIntrinsicSizeExpression(child.style.width_expression) && writingMode(child) == 0 && !(preferredRatio(child) > 0 && hasExplicitHeight(child)); // 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.style.margin[1] - child.style.margin[3]), true); if (child.layout.width != width) { child.layout.width = clampInt16(width); engine_.repositionChildren(children[i]); @@ -3250,11 +3321,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) { @@ -3269,9 +3340,9 @@ class LayoutNodePass { // A BFC's border box cannot overlap float margin boxes. // 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]; - contextX = start - child.computedStyle().margin[3]; + const int start = hasLeft ? std::max(child.style.margin[3], left) : child.style.margin[3]; + const int end = hasRight ? std::min(area - child.style.margin[1], right) : area - child.style.margin[1]; + contextX = start - child.style.margin[3]; if (automaticWidth) { const int width = clampLayoutSize(child, std::max(0, end - start), true); if (child.layout.width != width) { @@ -3290,13 +3361,15 @@ class LayoutNodePass { y = nextY; } } - const int x = child.computedStyle().float_side == 2 ? right - outerW : independentBlock ? contextX : left; - intrinsicWidth = std::max(intrinsicWidth, child.computedStyle().float_side + // 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.style.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()) { @@ -3353,6 +3426,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 ba9095b..5c39669 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; @@ -259,6 +266,7 @@ struct NodeRareData { inline static constexpr FirstLineFragment firstLineFragment{}; #endif MulticolLayout multicol; + std::vector inlineParts; void clear() { @@ -282,6 +290,7 @@ struct NodeRareData { #endif inlineStaticPosition = InlineStaticPosition{}; multicol = MulticolLayout{}; + inlineParts.clear(); } }; diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 194b64f..f894b6c 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2911,6 +2911,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.style.has_bg && node.style.bg_alpha > 0) + appendFillRectWithAlpha(x, y, w, h, node.style.bg_color, node.style.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.style, 0), h) : 0; + const int bottom = part.sides & 4 ? std::min(computedBorderWidth(node.style, 2), h - top) : 0; + for (int side = 0; side < 4; ++side) { + const int width = computedBorderWidth(node.style, side); + if (!(part.sides & (1 << side)) || width <= 0 || borderPaintAlpha(node.style, 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.style, side), borderPaintAlpha(node.style, side), parentAlpha, sx, sy, sw, sh); + } + } + return true; +} + // 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 @@ -3028,6 +3056,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 recordOuterBoxShadow(*n, parentAlpha); #endif From 63227c36267feb1383197e9f27f1d78656a3be64 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 | 39 ++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index cc783bc..10e490e 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1945,13 +1945,19 @@ class FlexLayoutPass { int applied = 0; int lastGrowChild = -1; + // 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 < line.count; i++) { const int child = children_[line.start + i]; Node &childNode = nodes[child]; if (childNode.computedStyle().flex <= 0) continue; lastGrowChild = child; - const int change = (delta * childNode.computedStyle().flex) / totalGrow; - applied += applyFlexMainSizeDelta(child, childNode, change); + const long long before = (2LL * delta * weight + totalGrow) / (2LL * totalGrow); + weight += childNode.style.flex; + const long long after = (2LL * delta * weight + totalGrow) / (2LL * totalGrow); + applied += applyFlexMainSizeDelta(child, childNode, static_cast(after - before)); } const int remainder = delta - applied; @@ -2991,6 +2997,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.style; + 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; @@ -3065,6 +3084,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) { @@ -3083,7 +3106,17 @@ 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; + (vertical ? child.layout.x : child.layout.y) = boxInset(node_.style, 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 96a297eebb2ac72f89a901f0484156c01c0fa9a9 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 | 29 +++++++++++++++-------- packages/engine/ui/node_model.h | 38 +++++++++++-------------------- packages/engine/ui/style.cpp | 7 +++--- packages/engine/ui/tree_style.cpp | 10 ++++---- 4 files changed, 41 insertions(+), 43 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 10e490e..a737db4 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2872,10 +2872,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]))); } @@ -3356,14 +3359,20 @@ class LayoutNodePass { if (clearance && !context.hasClearance(children[i])) context.cleared.push_back(children[i]); int left = 0, right = area, contextX = 0; const bool independentBlock = block && establishesBlockContext(child); - if (child.computedStyle().float_side || child.computedStyle().display != kDisplayBlock || establishesBlockContext(child)) { + if (child.style.float_side || child.style.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); @@ -3376,22 +3385,22 @@ class LayoutNodePass { const int start = hasLeft ? std::max(child.style.margin[3], left) : child.style.margin[3]; const int end = hasRight ? std::min(area - child.style.margin[1], right) : area - child.style.margin[1]; contextX = start - child.style.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); + engine_.layoutNode(children[i], width + child.style.margin[1] + child.style.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; + outerW = width + child.style.margin[1] + child.style.margin[3]; + outerH = child.layout.height + child.style.margin[0] + child.style.margin[2]; } + 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 a9c35f3..7051586 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -79,6 +79,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; @@ -515,31 +521,8 @@ 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. -#if GEA_CSS_DISPLAY_EXPLICIT - uint8_t display_explicit : 1; -#else - static constexpr uint8_t display_explicit = 0; -#endif -#if GEA_CSS_TEXT_ALIGN - int8_t text_align; -#else - static constexpr int8_t text_align = 0; -#endif - int8_t overflow : 3; - uint8_t position : 2; - uint8_t has_bg : 1; -#if GEA_CSS_Z_INDEX - uint8_t z_index_auto : 1; -#else - static constexpr int8_t z_index_auto = 1; -#endif -// A proven single byte radius fills the leading text-alignment slot when -// that field is absent. Its read remains one ordinary byte load. -#if GEA_CSS_U8_RADIUS && GEA_CSS_RADIUS_COUNT == 1 && !GEA_CSS_TEXT_ALIGN - uint8_t border_radius[1]; -#endif -#if GEA_CSS_FLEX_DIRECTION + // flex_direction_explicit. Other bits: kDisplayFlowRoot. + int8_t display_explicit; int8_t flex_direction; #else static constexpr int8_t flex_direction = 0; @@ -1277,6 +1260,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 eb782aa..404f5ff 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4809,6 +4809,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; } @@ -8227,10 +8228,8 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) ComputedStyle &style = target.mutableStyle(); switch (property) { case Property::Display: -#if GEA_CSS_DISPLAY_EXPLICIT - style.display_explicit = 1; -#endif - style.display = value; + style.display_explicit = kDisplayExplicit | (value >> kDisplayFlagShift); + 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 d2b3e45..6d36bdf 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -319,10 +319,12 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn // Any application of the `display` property is explicit authoring — record // 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; } -#endif - if (style.display != value) { style.display = value; changed = 1; } + // Keyword bits above the box kind (flow-root) are recorded with it. + if (n->style.display_explicit != (kDisplayExplicit | (value >> kDisplayFlagShift))) { + n->style.display_explicit = kDisplayExplicit | (value >> kDisplayFlagShift); + changed = 1; + } + if (n->style.display != (value & kDisplayKindMask)) { n->style.display = value & kDisplayKindMask; changed = 1; } break; #if GEA_CSS_FLEX_DIRECTION case Property::FlexDirection: From f429e0a24a5026fe1fb26471e61a5a09f5e2e9da 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 404f5ff..fd7479c 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -3871,26 +3871,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) @@ -5701,37 +5736,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 df58839c7a5d83e3e92fa82e69abb2c0082d8798 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 | 63 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index a737db4..5711641 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -386,7 +386,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; + } }; void hideAfterLineClamp(Node *nodes, int id) @@ -432,6 +452,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.style.margin[2] - child.style.margin[0]) : int(child.style.margin[2]); + }; if (child.type == NodeType::Text) { const int advance = TextRenderer::measureHeight("X", child.style.font_id, child.style.font_size, 0, child.style.line_height); const int inner = child.layout.height - boxInsets(child.style, false); @@ -439,7 +465,8 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) for (int line = 0; line < count; ++line) { const int lineTop = top + boxInset(child.style, 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); @@ -449,29 +476,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) && + !isFlowRoot(child.style) && child.style.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.style.min_height > 0 ? contentSizeToBorderSize(child.style, child.style.min_height, false) : 0; + walk.open.push_back({top, boxInset(child.style, 2), minHeight, child.style.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; } } @@ -2873,11 +2920,11 @@ 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) || + n.style.display != kDisplayBlock || isFlowRoot(n.style) || (blockBox(n) && n.style.align_content != 0) || clampsLines(n) || 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]))); From e426d1654a3a97d8da78379f92e98eeea3ecd1d1 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 | 40 ++++++++++++------- packages/engine/ui/node_model.h | 10 ++++- packages/engine/ui/style.cpp | 6 ++- packages/geatsc-plugin-gea/src/utils.ts | 7 +++- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index 4aa7d71..68b6203 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -2046,7 +2046,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 5711641..9fad61a 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -641,7 +641,7 @@ bool definiteFlexCrossSize(const Node &parent, const Node &child, bool row) 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.style) && 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; @@ -1582,7 +1582,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.style)) { 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; } @@ -2453,6 +2454,14 @@ bool emptyInlineBox(const Node &node) return !boxInsets(node.style, true) && !boxInsets(node.style, 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. @@ -2474,7 +2483,7 @@ bool splitInlineWrapper(const Node *nodes, int id) if (child.style.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; @@ -2493,7 +2502,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.style) && !isOutOfFlowPosition(child.style.position) && !child.style.float_side && - child.type == NodeType::View && (child.style.display_explicit || !isInlineLevelTag(tagFromId(child.tag_id)))) return true; + blockLevelView(child)) return true; } return false; } @@ -2909,8 +2918,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.style) && blockLevelView(n); } bool flowChild(int parent, int child) const @@ -4771,8 +4779,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.style.display == kDisplayNone) return false; + if (isAtomicInline(n.style)) return true; + if (n.style.display == kDisplayFlex || n.style.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. @@ -5020,13 +5029,14 @@ 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 && - !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 && + // 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.style.float_side || isAtomicInline(node.style)) && + !isOutOfFlowPosition(node.style.position) && + (!parent || (parent->style.display != kDisplayFlex && !isDisplayGrid(parent->style))) && + node.style.width == kUnset && node.style.width_percent == kUnset && node.style.width_expression == -1 && writingMode(node) == 0; // CSS Writing Modes 7.3.2: an orthogonal flow root's automatic inline // size is fit-content. Measure before fixing the used size so cyclic diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 7051586..5e766b5 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -85,6 +85,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; @@ -521,7 +522,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. int8_t display_explicit; int8_t flex_direction; #else @@ -1265,6 +1266,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 fd7479c..22f56c7 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4842,8 +4842,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 eb8fbafc0609f981ede3e5ed51d08f2b408cb1d6 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 | 38 +++++++++++++++++++++++++++-------- packages/engine/ui/style.cpp | 4 ++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 9fad61a..75d5389 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -4058,9 +4058,11 @@ class LayoutNodePass { CollapsedMargin pending; visitFormattingChildren(nodes_, id_, [&](int id) { Node &child = nodes_[id]; - if (isDisplayNone(child.computedStyle())) return; - if (isOutOfFlowPosition(child.computedStyle().position)) { - if (child.parent != id_ || !blockBox(child)) return; + if (isDisplayNone(child.style)) return; + if (isOutOfFlowPosition(child.style.position)) { + // 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); @@ -4672,16 +4674,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.style).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 22f56c7..b335555 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4885,6 +4885,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); @@ -4899,6 +4902,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 53f12653412bf4d338bf93d948ef736b19d93904 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 | 76 ++++++++++++++----- packages/engine/ui/node_model.h | 18 ++++- packages/engine/ui/render.cpp | 6 +- packages/engine/ui/style.cpp | 2 + packages/geatsc-plugin-gea/src/utils.ts | 4 +- 6 files changed, 81 insertions(+), 27 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index 68b6203..0d9bc69 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -2047,7 +2047,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 75d5389..73dc1c7 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -758,6 +758,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: @@ -1421,7 +1423,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].style) || isOutOfFlowPosition(nodes[c].style.position) || nodes[c].style.float_side)) c = last ? nodes[c].prev_sibling : nodes[c].next_sibling; @@ -2354,10 +2356,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.style) || isOutOfFlowPosition(inner.style.position) || inner.style.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.style.display == kDisplayBlock && !isAtomicInline(inner.style)) { + if (blockLevelView(inner) && overflowEstablishesContext(inner.style)) + return inner.layout.y + inner.layout.height + inner.style.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.style.display == kDisplayBlock && isAtomicInline(childNode.style)) { + const int line = overflowEstablishesContext(childNode.style) ? -1 : lastLineBaseline(childNode); + return line >= 0 ? line : childNode.layout.height + childNode.style.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- @@ -2369,7 +2401,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.style) || isOutOfFlowPosition(inner.style.position) || collapsibleSpaceOnly(inner)) continue; return inner.layout.y + childBaseline(inner, last); } } @@ -2425,6 +2458,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.style.display_explicit ? isInlineBoxDisplay(n.style) : 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. @@ -2454,12 +2494,11 @@ bool emptyInlineBox(const Node &node) return !boxInsets(node.style, true) && !boxInsets(node.style, 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.style) && !inlineBoxView(node); } // Box-tree projection for inline ancestors split by in-flow blocks. The DOM @@ -2468,10 +2507,9 @@ 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) || - !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; + if (!inlineBoxView(n) || n.style.float_side || isOutOfFlowPosition(n.style.position) || + !FlexLayoutPass::paintsNoBox(n) || boxInsets(n.style, true) || boxInsets(n.style, false) || + n.style.margin[1] || n.style.margin[3] || n.style.opacity != 255 || overflowEstablishesContext(n.style)) return false; bool containsBlock = false; for (int c = n.first_child; c >= 0; c = nodes[c].next_sibling) { const Node &child = nodes[c]; @@ -4048,9 +4086,9 @@ class LayoutNodePass { void captureBlockStaticPositions() { 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; + if (isOutOfFlowPosition(nodes_[c].style.position)) nodes_[c].layout.static_block_axis = 0; + if (node_.style.display != kDisplayBlock || node_.style.flex_direction_explicit || + (LayoutEngine::isInlineLevelNode(node_) && !isAtomicInline(node_.style) && !isOutOfFlowPosition(node_.style.position))) return; const bool vertical = writingMode(node_) != 0; const bool reversed = vertical && gridAxisReversed(node_, true); int cursor = boxInset(node_.computedStyle(), blockMarginSide(false)); @@ -4692,7 +4730,7 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod // 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)) && + (!isInlineLevelNode(parent) || isAtomicInline(parent.style) || 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 @@ -4810,14 +4848,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.style.display == kDisplayBlock && n.style.display_explicit && !isInlineBoxDisplay(n.style)) 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 @@ -4866,14 +4904,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 (!hypothetical && (n.computedStyle().float_side || isOutOfFlowPosition(n.computedStyle().position))) return false; + if (n.style.display == kDisplayNone || (n.style.display_explicit && !isInlineBoxDisplay(n.style))) return false; + if (!hypothetical && (n.style.float_side || isOutOfFlowPosition(n.style.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 5e766b5..d32eaa3 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -85,7 +85,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; @@ -1266,11 +1268,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 ae9131c..59499ea 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -10072,8 +10072,10 @@ int gLastScrollUiFrame = -1000; const auto *nodes = Tree::instance().nodes(); 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; + (nodes[n.parent].style.display == kDisplayFlex || isDisplayGrid(nodes[n.parent].style)); + // An inline-block, inline-flex or inline-grid paints atomically, like a + // float (CSS 2.2 Appendix E). + return isContext(id) || n.style.position != 0 || n.style.float_side || item || isAtomicInline(n.style); } 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 b335555..b07c2a8 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4847,6 +4847,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 f3f7642df7de36b676e0a648af549f8cf9eb10e9 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 | 183 ++++++++++++++++++++++---------- packages/engine/ui/tree_state.h | 4 + 2 files changed, 131 insertions(+), 56 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 73dc1c7..bb37ce0 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -720,6 +720,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.style).justify_self : absolute.style.align_self; + if (align <= 0 || (absolute.style.margin_auto & (horizontal ? 10 : 5))) return false; + const int before = absolute.style.margin[horizontal ? 3 : 0], after = absolute.style.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); @@ -760,6 +781,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: @@ -962,17 +984,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].style) || suppressAnonymousWhitespace(nodes, parent, child)) continue; + if (isOutOfFlowPosition(nodes[child].style.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; @@ -1018,6 +1052,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); } }; @@ -4276,11 +4312,20 @@ 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]; - else if (hasPositionOffset<1>(childNode)) childNode.layout.x = x + width - childNode.layout.width - resolvedPositionOffsetWithBasis<1>(childNode, width) - childNode.computedStyle().margin[1]; + 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.style.margin[3]; + else if (hasPositionOffset<1>(childNode)) childNode.layout.x = x + width - childNode.layout.width - resolvedPositionOffsetWithBasis<1>(childNode, width) - childNode.style.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]; - else if (hasPositionOffset<2>(childNode)) childNode.layout.y = y + height - childNode.layout.height - resolvedPositionOffsetWithBasis<2>(childNode, height) - childNode.computedStyle().margin[2]; + 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.style.margin[0]; + else if (hasPositionOffset<2>(childNode)) childNode.layout.y = y + height - childNode.layout.height - resolvedPositionOffsetWithBasis<2>(childNode, height) - childNode.style.margin[2]; else childNode.layout.y = alignedAbsoluteChildPosition(childNode, false); } @@ -4532,16 +4577,24 @@ 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)) - containingX = areaX + resolvedPositionOffsetForBasis<3>(absolute, areaWidth) + absolute.computedStyle().margin[3]; + 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.style.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)) - containingY = areaY + resolvedPositionOffsetForBasis<0>(absolute, areaHeight) + absolute.computedStyle().margin[0]; + 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.style.margin[0]; else if (hasPositionOffsetValue<2>(absolute)) containingY = areaY + areaHeight - absolute.layout.height - resolvedPositionOffsetForBasis<2>(absolute, areaHeight) - absolute.computedStyle().margin[2]; @@ -4679,51 +4732,67 @@ 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].style, 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.style).justify_self; + const int free = -(childNode.layout.width + childNode.style.margin[1] + childNode.style.margin[3]); + const int used = justify > 0 && writingMode(parent) == 0 ? usedGridAlignment(parent, childNode, justify, free, true, true) : 6; + return x + childNode.style.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]; + int y = position.y - dy + (nextLine ? position.lineHeight : 0) + 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 - + const int free = position.lineHeight - childNode.layout.height - childNode.style.margin[0] - childNode.style.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; } - const auto &persistent = layoutState(childId); - if (parent.computedStyle().display == kDisplayBlock && !parent.computedStyle().flex_direction_explicit && - persistent.static_block_axis == (horizontal ? 2 : 1)) { + if (parent.style.display == kDisplayBlock && !parent.style.flex_direction_explicit && + childNode.layout.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 free = -((horizontal ? childNode.layout.width : childNode.layout.height) + + childNode.style.margin[horizontal ? 3 : 0] + childNode.style.margin[horizontal ? 1 : 2]); 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 used = align >= 0 ? usedGridAlignment(parent, childNode, align, free, horizontal, true) : reversed ? 2 : 6; + return childNode.layout.static_block_start + childNode.style.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 @@ -4736,11 +4805,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.style).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.style, horizontal) - + (horizontal ? childNode.layout.width : childNode.layout.height) - + childNode.style.margin[horizontal ? 3 : 0] - childNode.style.margin[horizontal ? 1 : 2]; + const int used = usedGridAlignment(parent, childNode, justify, free, horizontal, true); + return boxInset(parent.style, horizontal ? 3 : 0) + childNode.style.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 5c39669..11fa1d1 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 0ede6924b8ea07aea3a50cec8556a08d52b68d18 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 bb37ce0..d4332d1 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -4127,7 +4127,7 @@ class LayoutNodePass { (LayoutEngine::isInlineLevelNode(node_) && !isAtomicInline(node_.style) && !isOutOfFlowPosition(node_.style.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_.style, blockMarginSide(false)) + contentShift_; bool first = true; CollapsedMargin pending; visitFormattingChildren(nodes_, id_, [&](int id) { @@ -4156,8 +4156,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_.style.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_.style.min_height <= 0) || + node_.style.display != kDisplayBlock || node_.style.flex_direction_explicit || + inlineBoxView(node_) || writingMode(node_) != 0 || overflowEstablishesContext(node_.style) || isMulticolContainer(node_)) return; + const int top = boxInset(node_.style, 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.style) || isOutOfFlowPosition(child.style.position) || suppressAnonymousWhitespace(nodes_, id_, c)) continue; + first = std::min(first, child.layout.y - child.style.margin[0]); + last = std::max(last, child.layout.y + child.layout.height + child.style.margin[2]); + } + if (first == INT_MAX) first = top; + const int free = node_.layout.height - boxInsets(node_.style, 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.style) || isOutOfFlowPosition(child.style.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 bcd8a2bbc30dfa8e7bfe9b21a5ffe16e71aec212 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 | 36 +++++++++++++++++++++++++++++---- packages/engine/ui/tree_state.h | 4 ++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index d4332d1..4436110 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1053,6 +1053,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); } }; @@ -4138,9 +4139,9 @@ class LayoutNodePass { // 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); - persistent.static_block_axis = vertical ? 2 : 1; + child.layout.static_block_start = clampInt16(reversed ? node_.layout.width - edge : edge); + child.layout.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; @@ -4156,6 +4157,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_.style, 3), right = node_.layout.width - boxInset(node_.style, 1); + for (int f = node_.first_child; f >= 0 && f != id; f = nodes_[f].next_sibling) { + const Node &box = nodes_[f]; + if (!box.style.float_side || isDisplayNone(box.style) || isOutOfFlowPosition(box.style.position)) continue; + if (edge < box.layout.y - box.style.margin[0] || edge >= box.layout.y + box.layout.height + box.style.margin[2]) continue; + if (box.style.float_side == 1) left = std::max(left, box.layout.x + box.layout.width + box.style.margin[1]); + else right = std::min(right, box.layout.x - box.style.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 @@ -4801,7 +4828,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.style).justify_self; const int free = -(childNode.layout.width + childNode.style.margin[1] + childNode.style.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.style.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 11fa1d1..b1d229c 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 671571e8951201ba54715920c006a33a0efae677 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 | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 4436110..2d6db25 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -939,6 +939,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) { @@ -1382,6 +1383,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_.style, true); + for (const WidenedRun &run : widenedRuns_) + nodes[run.id].layout.width = clampInt16(std::max(run.natural, content + run.extra)); } return extent; } @@ -1596,7 +1602,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); @@ -1808,8 +1817,12 @@ class FlexLayoutPass { node_.layout.height = clampLayoutSize(node_, height, false); } - if (!hasExplicitWidth(node_) && !keepScrollWidth) { - const int width = (isRow_ ? maxMainSize(lineCount) : totalCrossSize(lineCount)) + boxInset(node_.computedStyle(), 1) + boxInset(node_.computedStyle(), 3); + // 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_.style, 1) + boxInset(node_.style, 3); node_.layout.width = clampLayoutSize(node_, width, true); } } @@ -2476,6 +2489,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; }; From 81a61d5d45baeda219f9f1cf076d0d9d8af62913 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 2d6db25..fe389ff 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3261,7 +3261,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); } @@ -3420,6 +3422,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]].style.float_side && !nodes_[children[end]].style.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.style.margin[1] + next.style.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 a5b809f844d7ca69e1d5ba3279637c1b8d37ea27 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 | 106 ++++++++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index fe389ff..42c23d3 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -783,6 +783,41 @@ 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.style.margin[side] && !(item.style.margin_auto & (1 << side))) return; + entries.push_back({child, side, item.style.margin[side], static_cast(item.style.margin_auto & (1 << side))}); + item.style.margin[side] = 0; + item.style.margin_auto &= static_cast(~(1 << side)); + } + void restore() + { + Node *nodes = Tree::instance().nodes(); + for (auto it = entries.rbegin(); it != entries.rend(); ++it) { + nodes[it->child].style.margin[it->side] = it->value; + if (it->automatic) nodes[it->child].style.margin_auto |= static_cast(1 << it->side); + } + 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) @@ -855,6 +890,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); @@ -2184,7 +2222,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; @@ -2196,11 +2236,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_.style.flex_direction_explicit && node_.style.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_.style).margin_trim; + if (!trim || node_.style.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.style.margin[1] + item.style.margin[3] + : item.layout.height + item.style.margin[0] + item.style.margin[2]; + crossSize = std::max(crossSize, isRow_ ? item.layout.height + item.style.margin[0] + item.style.margin[2] + : item.layout.width + item.style.margin[1] + item.style.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) { @@ -3864,6 +3949,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_.style).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 e3c38de717fcaf18c0784f99fffcd389ae44888c 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 b07c2a8..379ad1d 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -9281,6 +9281,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).style); + 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); @@ -10711,7 +10727,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 9e18010886f49f8b728161042582e2447057b01b 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 change touching a key used left of a + or ~ restyles the siblings after the node, and inserting, moving or removing a node restyles the siblings after its old and new positions. 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) --- .../core/scripts/build-gea-vite-geatsc.mjs | 15 ++-- packages/engine/ui/style.cpp | 89 +++++++++++++++++-- packages/engine/ui/style.h | 4 + packages/engine/ui/tree_nodes.cpp | 13 ++- 4 files changed, 108 insertions(+), 13 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index 0d9bc69..e3d83de 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -1536,22 +1536,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 } @@ -1564,7 +1569,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 379ad1d..fedafc1 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -12515,6 +12515,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(); @@ -12680,6 +12690,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 { @@ -12689,6 +12701,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; @@ -12735,13 +12749,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); } } @@ -12750,22 +12767,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)); @@ -12852,6 +12874,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)); @@ -12921,6 +12944,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; @@ -13686,6 +13722,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 @@ -13778,6 +13819,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(), {}); @@ -13833,6 +13877,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; } @@ -16838,6 +16888,18 @@ struct NodeClassSnapshot { } }; +bool classTokensTouchSiblingSelectors(int node, const NodeClassSnapshot &oldTokens, const NodeClassList ¤t) +{ + rebuildRuleIndexIfNeeded(); + if (!g_ruleIndex.hasSiblingRules) return false; + if (g_ruleIndex.siblingTags.contains(treeState().nodes[node].tag_id)) return true; + for (std::size_t i = 0; i < oldTokens.count; ++i) + if (g_ruleIndex.siblingClasses.contains(oldTokens.at(i))) return true; + for (std::size_t i = 0, n = current.size(); i < n; ++i) + if (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; @@ -16980,10 +17042,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 the old or new classes (or the tag) restyles + // the siblings after this node. + if (classTokensTouchSiblingSelectors(node, 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); } @@ -19003,6 +19069,15 @@ void StyleSheet::recomputeSubtree(int nodeId) const recomputeSubtreeClassStyles(nodeId); } +void StyleSheet::recomputeSiblingsFrom(int nodeId) const +{ + rebuildRuleIndexIfNeeded(); + if (!g_ruleIndex.hasSiblingRules) return; + auto &state = treeState(); + for (int sibling = nodeId; sibling >= 0 && sibling < state.nodeCount; sibling = state.nodes[sibling].next_sibling) + 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 89926c3..1c151ec 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -469,6 +469,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 { @@ -922,6 +924,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_nodes.cpp b/packages/engine/ui/tree_nodes.cpp index f49b7b4..004c65f 100644 --- a/packages/engine/ui/tree_nodes.cpp +++ b/packages/engine/ui/tree_nodes.cpp @@ -527,6 +527,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; @@ -567,6 +568,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) @@ -584,6 +587,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) { @@ -628,6 +632,9 @@ 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) @@ -656,9 +663,13 @@ 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; - n->mutableStyle().display = 1; + // Sibling selectors (+, ~) of the siblings that followed it. + StyleSheet::instance().recomputeSiblingsFrom(next); + n = &state.nodes[id]; + n->style.display = 1; n->parent = -1; n->first_child = -1; n->last_child = -1; From 8c87c3c2d57360718b8c18dc5e6689873108b41a 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 42c23d3..5a28cb9 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -624,8 +624,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.style).aspect_ratio; + std::memcpy(&stored, &bits, sizeof(stored)); + const bool contentBox = !node.style.box_sizing || stored < 0; + const int crossEdges = contentBox ? boxInsets(node.style, !horizontal) : 0; + const int mainEdges = contentBox ? boxInsets(node.style, 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))); } @@ -2164,6 +2170,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.style.max_height : childNode.style.max_width; + const int minCross = isRow_ ? childNode.style.min_height : childNode.style.min_width; + if (maxCross != kUnset) + extent = std::min(extent, ratioTransferredSize(childNode, contentSizeToBorderSize(childNode.style, maxCross, !isRow_), isRow_)); + if (minCross != kUnset && minCross > 0) + extent = std::max(extent, ratioTransferredSize(childNode, contentSizeToBorderSize(childNode.style, 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 faebbd5af70367f68437f7a7a59bc1448fc1994e 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 | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 5a28cb9..2134b19 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3262,6 +3262,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.style.margin_auto & (1 << 3), autoRight = child.style.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; @@ -3325,12 +3335,8 @@ class LayoutNodePass { engine_.repositionChildren(id); } } - 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; + const int slack = contentWidth - child.layout.width - child.style.margin[1] - child.style.margin[3]; + child.layout.x = boxInset(node_.style, 3) + child.style.margin[3] + blockInlineOffset(child, slack); } } const bool mergeTop = collapsesWithChildren(id_, false); @@ -3624,7 +3630,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.style.float_side || child.style.display != kDisplayBlock || establishesBlockContext(child)) { // An automatic-width BFC tries the widest opportunity at each y @@ -3664,14 +3670,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.style.margin_auto & 10; + const int x = (block && !independentBlock ? left + blockInlineOffset(child, right - left - outerW) : + child.style.float_side == 2 ? right - outerW : + independentBlock ? contextX + (autoMargins ? blockInlineOffset(child, contextFree) : 0) : left) - (splitBlock ? outer[3] : 0); intrinsicWidth = std::max(intrinsicWidth, child.style.float_side ? left + outerW + contentWidth - right : measuredOuterW); From 9357cd7cc87be89bd3e6f9199cd8d24b4aa1240e 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 2134b19..cf2655f 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2373,8 +2373,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_.style.justify_content; + const bool centredText = verticalAlignment >= 0 && (verticalAlignment & 15) == 1; + const bool baselineText = childNode.type == NodeType::Text && node_.style.display == kDisplayFlex && !centredText; + childNode.render.inline_baseline = baselineText ? 1 : 0; } } From 941f5fed58607f4c700713ae10842212117279d6 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 int32, bounded by kMaxLayoutExtent (2^24, exact in the float projection math); the layout writes that clamped to int16 use clampLayoutExtent instead. Display commands still carry int16 rects. Rather than grow every command, recording saturates what overflows: overflow clip shapes 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. Node grows from 388 to 400 bytes on 32-bit targets (+6 KB at 512 nodes). 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) --- packages/engine/ui/layout.cpp | 79 +++++++++++++++------------------ packages/engine/ui/node_model.h | 25 +++++------ packages/engine/ui/view.cpp | 36 ++++++++++++++- 3 files changed, 83 insertions(+), 57 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index cf2655f..a7ac9f3 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -535,7 +535,7 @@ void applyLineClamp(int id) nodes[walk.lastRun].layout.line_clamp_hidden |= 2; if (!walk.clamped || hasExplicitHeight(node)) return; const int height = clampBorderBoxSize(node.style, walk.keptBottom + boxInset(node.style, 2), false); - if (height < node.layout.height) node.layout.height = clampInt16(height); + if (height < node.layout.height) node.layout.height = clampLayoutExtent(height); } int resolvedStyleWidth(const Node &node, int basis) @@ -672,13 +672,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; + int32_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) @@ -878,16 +878,9 @@ class FlexLayoutPass { if (node_.computedStyle().display == kDisplayFlex) { applyFlexBasis(child); Node &item = Tree::instance().nodes()[child]; - // A growing item with a zero flex basis (`flex: 1`) starts from zero; its - // automatic minimum floors the FINAL flexed size (growFlexChildren), not the - // basis. Flooring the basis hands content-bearing items a head start over their - // siblings and unbalances equal-flex rows (a tic-tac-toe cell holding "X" grew - // 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); } - } + const int floor = automaticMinimumMainSize(item); + int32_t &main = isRow_ ? item.layout.width : item.layout.height; + if (main < floor) { main = clampLayoutExtent(floor); engine_.repositionChildren(child); } } } } @@ -1173,8 +1166,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 @@ -1182,8 +1175,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) { @@ -1421,17 +1414,17 @@ class FlexLayoutPass { const bool keepScrollHeight = scrollsOverflowY(node_.computedStyle()) && node_.layout.height > 0; 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)); + const int height = (flowHeight < 0 ? 0 : flowHeight) + boxInset(node_.style, 0) + boxInset(node_.style, 2); + 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)); + const int width = maxLineWidth + boxInset(node_.style, 1) + boxInset(node_.style, 3); + 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_.style, 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; } @@ -1648,7 +1641,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); } } @@ -1664,8 +1657,8 @@ class FlexLayoutPass { y = lineTop + crossBefore; 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); + else if (align == 0 && !hasExplicitHeight(cn) && !isIntrinsicSizeExpression(cn.style.height_expression)) { + cn.layout.height = clampLayoutExtent(lineCross - crossBefore - crossAfter); engine_.repositionChildren(child); } } @@ -1791,8 +1784,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_)); + int32_t &cross = isRow_ ? node_.layout.height : node_.layout.width; + cross = clampLayoutExtent(clampLayoutSize(node_, totalCrossSize(lineCount) + boxInsets(node_.style, !isRow_), !isRow_)); } int crossOffset = boxInset(node_.computedStyle(), isRow_ ? 0 : 3); const int crossBox = (isRow_ ? node_.layout.height : node_.layout.width) - boxInsets(node_.computedStyle(), !isRow_); @@ -2213,8 +2206,8 @@ class FlexLayoutPass { else childNode.layout.height = after; 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_)); + int32_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; @@ -2750,8 +2743,8 @@ 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_.computedStyle().font_size, 0, node_.computedStyle().line_height)); + node_.layout.height = clampLayoutExtent(TextRenderer::measureHeight(" ", node_.style.font_id, + node_.style.font_size, 0, node_.style.line_height)); return; } @@ -2929,7 +2922,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; } }; @@ -2955,7 +2948,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); @@ -3304,7 +3297,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]; @@ -3338,7 +3331,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); } } @@ -3379,7 +3372,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; @@ -3608,7 +3601,7 @@ class LayoutNodePass { if (automaticWidth) { const int width = clampLayoutSize(child, std::max(0, area - child.style.margin[1] - child.style.margin[3]), true); if (child.layout.width != width) { - child.layout.width = clampInt16(width); + child.layout.width = clampLayoutExtent(width); engine_.repositionChildren(children[i]); } } @@ -3669,7 +3662,7 @@ class LayoutNodePass { const int width = clampLayoutSize(child, end - start, true); if (child.layout.width != width) { engine_.layoutNode(children[i], width + child.style.margin[1] + child.style.margin[3], contentHeight); - child.layout.width = clampInt16(width); + child.layout.width = clampLayoutExtent(width); engine_.repositionChildren(children[i]); outerW = width + child.style.margin[1] + child.style.margin[3]; outerH = child.layout.height + child.style.margin[0] + child.style.margin[2]; @@ -3760,7 +3753,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_.style, 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); @@ -4425,8 +4418,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].style.position)) continue; nodes_[k].layout.x = clampInt16(nodes_[k].layout.x - dx); @@ -5612,8 +5605,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 d32eaa3..80fd8ea 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -40,6 +40,14 @@ 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: 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. +inline constexpr int kMaxLayoutExtent = 1 << 24; +inline int32_t clampLayoutExtent(int value) +{ + return std::clamp(value, -kMaxLayoutExtent, kMaxLayoutExtent); +} // Internal value carrier for CSS z-index:auto; numeric stack levels stay int16. inline constexpr int kZIndexAuto = INT32_MIN; // Dimension expression slots use nonnegative values for pooled expressions, @@ -640,17 +648,8 @@ struct ComputedStyle { #else static constexpr int32_t height_expression = -1; #endif - int16_t width, height; -#if GEA_CSS_WIDTH_PERCENT - int16_t width_percent; -#else - static constexpr int16_t width_percent = kUnset; -#endif -#if GEA_CSS_HEIGHT_PERCENT - int16_t height_percent; -#else - static constexpr int16_t height_percent = kUnset; -#endif + int32_t width, height; // kUnset: auto; see kMaxLayoutExtent + int16_t width_percent, height_percent; #if GEA_CSS_MIN_WIDTH int16_t min_width; #else @@ -1407,7 +1406,7 @@ inline bool hasBorderRelief(const ComputedStyle &style) struct LayoutBox { int16_t x, y; - int16_t width, height; + int32_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 @@ -1429,10 +1428,10 @@ struct LayoutBox { uint8_t line_clamp_hidden = 0; int16_t previous_x, previous_y; - int16_t previous_width, previous_height; // 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; + int32_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 f894b6c..794ae73 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; @@ -1997,6 +2013,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.style.border_width, rstyle(node.style).border_side_width[side]}); + if (w > 32767 || h > 32767 || ring > 255) { + for (int side = 0; side < 4; ++side) + appendBorderSideWithAlpha(node, side, borderPaintColor(node.style, side), borderPaintAlpha(node.style, side), parentAlpha, true); + return; + } int16_t xs[4], ys[4]; ViewGeometry::transformRectCorners(node, false, x, y, w, h, xs, ys); @@ -2783,8 +2810,15 @@ 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); + // 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) { + w = std::min({w, 32767 - x, 32767}); + h = std::min({h, 32767 - y, 32767}); + } + out = OverflowClipShape{x, y, w, h, false, {}, {}, 0, 0, 0, 0, {}, {}}; // The padding edge is rounded with the outer radii less the border widths. const auto &s = node.style; const int border[4] = {boxInset(s, 0) - s.padding[0], boxInset(s, 1) - s.padding[1], From 3849be70ba25d37bd7a74eae30424163e3f8e04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 29 Sep 2026 20:42:23 +0300 Subject: [PATCH 47/50] Fix stale paint, clip and style paths flagged in review - Store the previous box-shadow extent as 32-bit and pad the raster-guard dirty rect by the current or previous shadow extent. - Give each render core its own ShapedClips storage so the parallel band replay does not share the clip stacks. - Force a full record when multicol column copies exist, since in-place translation and re-recording do not update them. - Reset LineClampDiscard from the line-clamp shorthand, its none value and its inline removal. - Skip generated pseudo nodes when restyling siblings, and skip the sibling restyle for children of a subtree being removed. - Draw bitmap emphasis marks at the same line origin as their glyphs. - Saturate both bounds of an untransformed overflow clip rect. - Encode static text-align left and end as 3 and 4, as the engine does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/scripts/build-gea-vite-geatsc.mjs | 7 +++++-- packages/engine/ui/internal.h | 4 ++++ packages/engine/ui/layout_snapshot.cpp | 2 +- packages/engine/ui/node_model.h | 5 +++-- packages/engine/ui/render.cpp | 19 +++++++++++++++---- packages/engine/ui/root_scroll_refresh.cpp | 3 ++- packages/engine/ui/style.cpp | 18 ++++++++++++------ packages/engine/ui/text.cpp | 6 +++--- packages/engine/ui/tree_internal.h | 3 +++ packages/engine/ui/tree_nodes.cpp | 9 +++++++-- packages/engine/ui/tree_render.cpp | 14 +++++++++++--- packages/engine/ui/view.cpp | 5 +---- 12 files changed, 67 insertions(+), 28 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index e3d83de..4ec7f89 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -2147,9 +2147,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/internal.h b/packages/engine/ui/internal.h index e75bd1d..ece5e99 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -440,6 +440,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; diff --git a/packages/engine/ui/layout_snapshot.cpp b/packages/engine/ui/layout_snapshot.cpp index b46f95f..c6ed62d 100644 --- a/packages/engine/ui/layout_snapshot.cpp +++ b/packages/engine/ui/layout_snapshot.cpp @@ -71,7 +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 = static_cast(std::min(32767, boxShadowExtent(state.nodes[i].style))); + state.nodes[i].render.previous_box_shadow_extent = boxShadowExtent(state.nodes[i].style); state.nodes[i].render.dirty = 0; state.nodes[i].render.layout_dirty = 0; #if GEA_CSS_SCROLLING diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 80fd8ea..d5f095d 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -1591,8 +1591,9 @@ struct RenderState { 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. Sits in trailing padding. - int16_t previous_box_shadow_extent; + // 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/render.cpp b/packages/engine/ui/render.cpp index 59499ea..ab782f2 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -2265,16 +2265,18 @@ int gLastScrollUiFrame = -1000; std::size_t offset; }; + // 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; - return value; + static std::vector value[2]; + return value[gea_current_render_core() & 1]; } static std::vector &saved() { - static std::vector value; - return value; + static std::vector value[2]; + return value[gea_current_render_core() & 1]; } static void begin(const TransformedRoundedRectCommand &shape) @@ -10876,6 +10878,11 @@ int gLastScrollUiFrame = -1000; return state.textClippedBackgrounds; } + bool DisplayList::hasColumnCopies() const + { + return !columnCopies().empty(); + } + void DisplayList::clearRetainedBackgroundRecolors() { state.retainedBackgroundRecolorCount = 0; @@ -13309,6 +13316,10 @@ int gLastScrollUiFrame = -1000; int end = state.nodeDrawEnd[node]; if (start < 0 || end < start || end > state.commandCount) return false; + // 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; 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 460dd9e..4560383 100644 --- a/packages/engine/ui/root_scroll_refresh.cpp +++ b/packages/engine/ui/root_scroll_refresh.cpp @@ -619,7 +619,8 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) !horizontal && n->type != NodeType::VirtualList && slotCount == 0 && - extraDirtyCount == 0; + extraDirtyCount == 0 && + !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 fedafc1..664da77 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -6939,10 +6939,10 @@ 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[4]) +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 && @@ -6992,7 +6992,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; @@ -7007,7 +7008,8 @@ 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; } // border-style and its longhands as per-side relief writes (CSS Backgrounds @@ -12276,7 +12278,7 @@ 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, Property::LineClampDiscard}); if (property == "block-ellipsis") return removeInlineStyleProperties(id, {Property::BlockEllipsis, Property::BlockEllipsisString}); @@ -19074,8 +19076,12 @@ void StyleSheet::recomputeSiblingsFrom(int nodeId) const rebuildRuleIndexIfNeeded(); if (!g_ruleIndex.hasSiblingRules) return; auto &state = treeState(); - for (int sibling = nodeId; sibling >= 0 && sibling < state.nodeCount; sibling = state.nodes[sibling].next_sibling) + // ::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 diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index 20af813..a078f74 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1617,7 +1617,8 @@ class TextDrawer { if (penY + glyphHeight - 1 >= clipY0) { const int align = lineAlignment(textAlign, alignLast, lineStart, line.consumedBytes, !lineStart[line.consumedBytes]); const int hanging = hangSpaces && align ? hangingSpaceWidth(lineStart, line.renderBytes, glyphWidth) : 0; - int penX = lineOriginX + alignedOffset(align, containerWidth, line.width - hanging - trimmedIndent); + 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; @@ -1632,8 +1633,7 @@ class TextDrawer { penX += glyphWidth; } } - drawEmphasisMarks(lineStart, line.renderBytes, lineOriginX + alignedOffset(lineAlignment(textAlign, alignLast, lineStart, line.consumedBytes, !lineStart[line.consumedBytes]), containerWidth, line.width), - penY, glyphHeight, glyphHeight, ellipsis, [glyphWidth](int) { return glyphWidth; }, coverageSink); + drawEmphasisMarks(lineStart, line.renderBytes, lineX, penY, glyphHeight, glyphHeight, ellipsis, [glyphWidth](int) { return glyphWidth; }, coverageSink); } lineStart += line.consumedBytes; diff --git a/packages/engine/ui/tree_internal.h b/packages/engine/ui/tree_internal.h index 2024a38..c04080b 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 004c65f..d51d513 100644 --- a/packages/engine/ui/tree_nodes.cpp +++ b/packages/engine/ui/tree_nodes.cpp @@ -638,12 +638,17 @@ void Tree::insertBefore(int child, int parent, int 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]; @@ -667,7 +672,7 @@ void Tree::removeNode(int id) 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. - StyleSheet::instance().recomputeSiblingsFrom(next); + if (restyleSiblings) StyleSheet::instance().recomputeSiblingsFrom(next); n = &state.nodes[id]; n->style.display = 1; n->parent = -1; diff --git a/packages/engine/ui/tree_render.cpp b/packages/engine/ui/tree_render.cpp index e93ff87..81ae249 100644 --- a/packages/engine/ui/tree_render.cpp +++ b/packages/engine/ui/tree_render.cpp @@ -11,6 +11,7 @@ #include "style_values.h" #include "tree_state.h" +#include #include #include #include @@ -385,7 +386,7 @@ namespace gea::embedded::ui return true; if (node.computedStyle().mask_right_fade_width > 0) return true; - if ((GEA_CSS_BOX_SHADOW ? rstyle(node.style).box_shadow_alpha : 0) > 0 || rstyle(node.style).border_image_source) + if ((GEA_CSS_BOX_SHADOW ? rstyle(node.style).box_shadow_alpha : 0) > 0 || node.render.previous_box_shadow_extent > 0 || rstyle(node.style).border_image_source) return true; return node.computedStyle().has_bg && (styleHasRoundedRasterEdge(node.computedStyle()) || node.computedStyle().bg_alpha < 255); } @@ -407,8 +408,10 @@ namespace gea::embedded::ui { if (!dirtyRectNeedsRasterGuard(node)) return rect; - // border-image-outset paints outside the border box. - return expandDirtyRect(rect, 1 + StyleValues::borderImageOutsetExtent(node.style)); + // 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.style), static_cast(node.render.previous_box_shadow_extent)); + return expandDirtyRect(rect, 1 + std::max(StyleValues::borderImageOutsetExtent(node.style), shadow)); } DirtyRegions::Rect dirtyRectWithRetainedMoveGuard(DirtyRegions::Rect rect) @@ -2401,6 +2404,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/view.cpp b/packages/engine/ui/view.cpp index 794ae73..9ef51a5 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2814,10 +2814,7 @@ bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) // 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) { - w = std::min({w, 32767 - x, 32767}); - h = std::min({h, 32767 - y, 32767}); - } + if (!transformed) saturateRect16(x, y, w, h); out = OverflowClipShape{x, y, w, h, false, {}, {}, 0, 0, 0, 0, {}, {}}; // The padding edge is rounded with the outer radii less the border widths. const auto &s = node.style; From 31ca9f5c8981aa64f565bbcee374530c1a7eba0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Mon, 5 Oct 2026 22:02:08 +0300 Subject: [PATCH 48/50] Fix the major issues flagged in review - layout: a container that stops clamping lines clears the hidden and line-count state its last clamp left on descendants (line_clamp_owner marks the container; the bit sits in LayoutBox padding). - node_model: the line-clamp and multicol RareStyle members gain the GEA_CSS_LINE_CLAMP and GEA_CSS_MULTICOL families with constexpr defaults when disabled, and both join GEA_CSS_RARE_STYLE, so a build with every rare family off compiles again. border-image-* and bg_blend move from the gradient block to the background-layer block their setters belong to. - node_model / style / tree_style: border-style: none and hidden keep the side's declared width in border_none_width and restore it when the style changes back; the fast class-rule setter applies the none rule for the all-sides relief property too; the border shorthand records kBorderStyleNone for none and hidden so a later border-width alone cannot enable the border. Relief properties mark layout dirty. - render: BlendDrawer::setSat treats an achromatic colour by value, not by channel index, so hue and saturation blends no longer divide by zero. - render: UniformRoundedRectBatch::append rejects border rings, which the span rasterizers cannot paint; they stay on the ring-aware replay path. - style: a class mutation restyles following siblings only when a class in a + or ~ selector was added or removed, not whenever the tag or an unchanged class appears in one. - tree_style: text-align is a layout property, since line shifts and inline static positions follow it. - view: an outer box-shadow whose contour (blur included) leaves the int16 range takes the saturating span path instead of native rounded rects. Co-Authored-By: Claude Fable 5.1 --- packages/engine/ui/css_features.h | 9 ++++ packages/engine/ui/layout.cpp | 17 +++++++- packages/engine/ui/node_lifecycle.cpp | 1 + packages/engine/ui/node_model.h | 57 +++++++++++++++++++----- packages/engine/ui/render.cpp | 9 +++- packages/engine/ui/style.cpp | 63 +++++++++++++++++++-------- packages/engine/ui/tree_style.cpp | 34 ++++++++++++--- packages/engine/ui/view.cpp | 4 ++ 8 files changed, 157 insertions(+), 37 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/layout.cpp b/packages/engine/ui/layout.cpp index a7ac9f3..b58459d 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -5481,7 +5481,14 @@ 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 (clampsLines(node)) applyLineClamp(id); + 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; + } 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. @@ -5512,7 +5519,13 @@ 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 (clampsLines(*node)) applyLineClamp(id); + 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; + } } bool LayoutEngine::layoutNodeScoped(int scope, int treeRoot) diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index eb1e938..3a21033 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -373,6 +373,7 @@ void NodeLifecycle::init(Node *n, NodeType type) n->layout.height = 0; n->layout.inline_indent = 0; n->layout.line_clamp_hidden = 0; + n->layout.line_clamp_owner = 0; n->layout.line_clamp_lines = 0; #if GEA_CSS_SCROLLING n->layout.scroll_x = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index d5f095d..169137a 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -170,13 +170,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 @@ -375,8 +384,12 @@ struct RareStyle { // 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] = { @@ -403,10 +416,24 @@ struct RareStyle { // center/extent 500/1000) --- #if GEA_CSS_BACKGROUND_LAYERS uint16_t bg_image_layer_count = 1; // Includes none layers. + uint8_t bg_blend = 0; // Interned background-blend-mode list; zero is all normal. + // 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 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 static constexpr uint16_t bg_image_layer_count = 1; // Includes none layers. + static constexpr uint8_t bg_blend = 0; + 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; 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; #endif @@ -418,30 +445,22 @@ struct RareStyle { uint8_t bg_gradient_from_alpha = 255; uint8_t bg_gradient_mid_alpha = 255; uint8_t bg_gradient_to_alpha = 255; - // border-image-* (see StyleValues::borderImageSource/Sides/Repeat) sit in - // padding here: interned handles, zero is the initial value. - uint8_t border_image_source = 0; uint16_t bg_gradient_mid_stop = 500; uint16_t bg_gradient_to_stop = 1000; uint8_t bg_gradient_has_mid = 0; - uint8_t border_image_slice = 0; int16_t bg_gradient_angle = 1800; uint8_t bg_overlay_gradient = 0; - uint8_t bg_blend = 0; // Interned background-blend-mode list; zero is all normal. style_color_t bg_overlay_gradient_from_color = 0; style_color_t bg_overlay_gradient_mid_color = 0; style_color_t bg_overlay_gradient_to_color = 0; uint8_t bg_overlay_gradient_from_alpha = 255; uint8_t bg_overlay_gradient_mid_alpha = 255; uint8_t bg_overlay_gradient_to_alpha = 255; - uint8_t border_image_width = 0; uint16_t bg_overlay_gradient_mid_stop = 500; uint16_t bg_overlay_gradient_to_stop = 1000; uint8_t bg_overlay_gradient_has_mid = 0; - uint8_t border_image_outset = 0; int16_t bg_overlay_gradient_angle = 1800; uint8_t bg_radial_gradient = 0; - uint8_t border_image_repeat = 0; // bits 0-1 horizontal, 2-3 vertical: stretch, repeat, round, space style_color_t bg_radial_gradient_from_color = 0; style_color_t bg_radial_gradient_to_color = 0; uint8_t bg_radial_gradient_from_alpha = 255; @@ -504,8 +523,16 @@ struct RareStyle { // 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 @@ -521,7 +548,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 { @@ -1097,8 +1124,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. - if (rstyle(style).border_relief[i] & kBorderStyleNone) widths[i] = 0; + // 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. @@ -1426,6 +1460,9 @@ struct LayoutBox { // clamp point and carries the block ellipsis. 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; // line-clamp: a text run its container's clamp point cuts paints only diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index ab782f2..b3e6bca 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -5834,7 +5834,9 @@ int gLastScrollUiFrame = -1000; if (c[i] < c[lo]) lo = i; if (c[i] >= c[hi]) hi = i; } - if (lo == hi) { c[0] = c[1] = c[2] = 0.0f; return; } + // 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; @@ -7935,7 +7937,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)) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 664da77..945f2c9 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -6546,7 +6546,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; @@ -8334,10 +8339,15 @@ 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: @@ -8350,6 +8360,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) 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 @@ -8718,19 +8729,25 @@ 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 (value & kBorderStyleNone) setComputedBorderWidth(style, side, 0, nullptr); - return true; - } - case Property::BorderRelief: { - const auto &relief = rstyle(style).border_relief; - if (value == 0 && !(relief[0] | relief[1] | relief[2] | relief[3])) return true; - for (int side = 0; side < 4; ++side) 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; } #endif @@ -16890,15 +16907,27 @@ struct NodeClassSnapshot { } }; -bool classTokensTouchSiblingSelectors(int node, const NodeClassSnapshot &oldTokens, const NodeClassList ¤t) +// 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; - if (g_ruleIndex.siblingTags.contains(treeState().nodes[node].tag_id)) return true; + 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 (g_ruleIndex.siblingClasses.contains(oldTokens.at(i))) return true; + 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 (g_ruleIndex.siblingClasses.contains(current.at(i))) return true; + if (!inOld(current.at(i)) && g_ruleIndex.siblingClasses.contains(current.at(i))) return true; return false; } @@ -17046,9 +17075,9 @@ void noteClassMutationForIncremental(int node, const NodeClassSnapshot &oldToken { const auto &state = treeState(); if (node < 0 || node >= state.nodeCount) return; - // A + or ~ selector keyed on the old or new classes (or the tag) restyles - // the siblings after this node. - if (classTokensTouchSiblingSelectors(node, oldTokens, state.classLists[node])) + // 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(); diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 6d36bdf..382b10c 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -98,6 +98,18 @@ bool isLayoutProperty(Property prop) case Property::LineClampDiscard: case Property::ColumnSpanAll: 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 @@ -356,10 +368,15 @@ 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; - case Property::ColumnCount: if (rstyle(n->style).column_count != value) { rstyleMut(n->style).column_count = value; changed = 1; } break; - case Property::ColumnWidth: if (rstyle(n->style).column_width != value) { rstyleMut(n->style).column_width = 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: @@ -372,6 +389,7 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn if (rstyle(n->style).line_clamp_flags != next) { rstyleMut(n->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 @@ -867,12 +885,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. - if (value & kBorderStyleNone) changed |= setComputedBorderWidth(n->style, side, 0, nullptr); + // 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 9ef51a5..6ed0a84 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2630,6 +2630,10 @@ void recordOuterBoxShadow(const Node &node, uint8_t parentAlpha) 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) { From f86c5c07e7beedde81e99a89af71067d0a06b44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 6 Oct 2026 00:00:01 +0300 Subject: [PATCH 49/50] Reconcile the rebase with main The branch was rebased onto main keeping each commit's own side where it overlapped main's changes, so the rebased commits still read styles through `.style` and lack main's edits in those hunks. This commit brings the tree back to the reviewed merge result: the computedStyle() accessor throughout, main's refresh-perf macros and GEA_CSS_MARGINS guards, the 32-bit layout extents beside main's zero-basis flex floor guard, and the shaped-clip test ahead of main's span narrowing. The resulting tree is byte-identical to the pre-rebase tip 0e9d125, the state the review fixes and the static checks were run against. Co-Authored-By: Claude Fable 5.1 --- packages/engine/ui/input.cpp | 4 +- packages/engine/ui/input_render.cpp | 4 +- packages/engine/ui/layout.cpp | 501 +++++++++++---------- packages/engine/ui/layout_snapshot.cpp | 2 +- packages/engine/ui/node_lifecycle.cpp | 5 + packages/engine/ui/node_model.h | 46 +- packages/engine/ui/render.cpp | 52 ++- packages/engine/ui/root_scroll_refresh.cpp | 4 +- packages/engine/ui/style.cpp | 16 +- packages/engine/ui/text.cpp | 32 +- packages/engine/ui/tree_nodes.cpp | 2 +- packages/engine/ui/tree_render.cpp | 6 +- packages/engine/ui/tree_style.cpp | 68 +-- packages/engine/ui/view.cpp | 178 ++++---- 14 files changed, 500 insertions(+), 420 deletions(-) diff --git a/packages/engine/ui/input.cpp b/packages/engine/ui/input.cpp index ca93a5a..b8b9a64 100644 --- a/packages/engine/ui/input.cpp +++ b/packages/engine/ui/input.cpp @@ -443,7 +443,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 & 1) || 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 @@ -482,7 +482,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 & 1) || 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 e59641e..95fc837 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -161,8 +161,8 @@ void InputRenderer::record(int id) cmd->text.color = color; cmd->text.scale = textScale; cmd->text.align = static_cast(LayoutEngine::physicalTextAlign(n)); - cmd->text.textTransform = n.style.text_transform; - cmd->text.lineHeight = n.style.line_height; + cmd->text.textTransform = n.computedStyle().text_transform; + cmd->text.lineHeight = n.computedStyle().line_height; cmd->text.containerWidth = contentW; cmd->text.fontId = n.computedStyle().font_id; // append() hands back uninitialized scratch — set these explicitly so diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index b58459d..2e1b6c5 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -419,8 +419,8 @@ void hideAfterLineClamp(Node *nodes, int id) // grid containers, or multicol containers. bool clampsLines(const Node &node) { - const int flags = rstyle(node.style).line_clamp_flags; - return (flags & 1) && !(flags & 12) && node.type == NodeType::View && node.style.display == kDisplayBlock && + const int flags = rstyle(node.computedStyle()).line_clamp_flags; + return (flags & 1) && !(flags & 12) && node.type == NodeType::View && node.computedStyle().display == kDisplayBlock && !(LayoutEngine::isCssInlineLevelBox(node) && LayoutEngine::isInlineLevelNode(node)); } @@ -443,12 +443,12 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) hideAfterLineClamp(nodes, id); continue; } - if (isDisplayNone(child.style) || isOutOfFlowPosition(child.style.position) || child.style.float_side || + 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.style.white_space == 0 || child.style.white_space == 1 || child.style.white_space == 4; + 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; @@ -456,14 +456,14 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) // 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.style.margin[2] - child.style.margin[0]) : int(child.style.margin[2]); + 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.style.font_id, child.style.font_size, 0, child.style.line_height); - const int inner = child.layout.height - boxInsets(child.style, false); + 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.style, 0) + line * advance; + const int lineTop = top + boxInset(child.computedStyle(), 0) + line * advance; const bool newLine = lineTop > walk.lastLineTop; const int end = walk.endAfter(lineTop + advance, 0); if (walk.maxLines ? newLine && walk.lines >= walk.maxLines : end > walk.limit) { @@ -482,8 +482,8 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) } continue; } - const bool plainBlock = child.style.display == kDisplayBlock && !overflowEstablishesContext(child.style) && - !isFlowRoot(child.style) && child.style.align_content == 0 && !clampsLines(child) && + 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 @@ -492,8 +492,8 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) if (!inlineWrapper) walk.lastRun = -1; const int kept = walk.kept; if (!inlineWrapper) { - const int minHeight = child.style.min_height > 0 ? contentSizeToBorderSize(child.style, child.style.min_height, false) : 0; - walk.open.push_back({top, boxInset(child.style, 2), minHeight, child.style.margin[2]}); + 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 (!inlineWrapper) walk.open.pop_back(); @@ -527,14 +527,14 @@ void applyLineClamp(int id) Node *nodes = Tree::instance().nodes(); Node &node = nodes[id]; clearLineClamp(nodes, id); - LineClampWalk walk{nodes, rstyle(node.style).max_lines, node.layout.height - boxInset(node.style, 2)}; - walk.keptBottom = boxInset(node.style, 0); + 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.style).line_clamp_flags & 2) && walk.lastRun >= 0) + 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.style, walk.keptBottom + boxInset(node.style, 2), false); + const int height = clampBorderBoxSize(node.computedStyle(), walk.keptBottom + boxInset(node.computedStyle(), 2), false); if (height < node.layout.height) node.layout.height = clampLayoutExtent(height); } @@ -627,11 +627,11 @@ int ratioTransferredSize(const Node &node, int crossBorderSize, bool 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.style).aspect_ratio; + const int bits = rstyle(node.computedStyle()).aspect_ratio; std::memcpy(&stored, &bits, sizeof(stored)); - const bool contentBox = !node.style.box_sizing || stored < 0; - const int crossEdges = contentBox ? boxInsets(node.style, !horizontal) : 0; - const int mainEdges = contentBox ? boxInsets(node.style, horizontal) : 0; + 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))); } @@ -647,7 +647,7 @@ bool definiteFlexCrossSize(const Node &parent, const Node &child, bool row) 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 && !isAtomicInline(node.style) && 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; @@ -738,9 +738,9 @@ int roundedHalf(int free) { return free >= -1 ? (free + 1) / 2 : -(-free / 2); } 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.style).justify_self : absolute.style.align_self; - if (align <= 0 || (absolute.style.margin_auto & (horizontal ? 10 : 5))) return false; - const int before = absolute.style.margin[horizontal ? 3 : 0], after = absolute.style.margin[horizontal ? 1 : 2]; + 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); @@ -767,8 +767,8 @@ 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.style.row_gap : node.style.column_gap; - const int percent = rowGap ? node.style.row_gap_percent : node.style.column_gap_percent; + 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; const bool horizontalAxis = rowGap == vertical; if (percent != kUnset) { @@ -807,17 +807,25 @@ struct TrimmedMargins { void cut(int child, int side) { Node &item = Tree::instance().nodes()[child]; - if (!item.style.margin[side] && !(item.style.margin_auto & (1 << side))) return; - entries.push_back({child, side, item.style.margin[side], static_cast(item.style.margin_auto & (1 << side))}); - item.style.margin[side] = 0; - item.style.margin_auto &= static_cast(~(1 << side)); + 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) { - nodes[it->child].style.margin[it->side] = it->value; - if (it->automatic) nodes[it->child].style.margin_auto |= static_cast(1 << it->side); +#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(); } @@ -878,9 +886,16 @@ class FlexLayoutPass { if (node_.computedStyle().display == kDisplayFlex) { applyFlexBasis(child); Node &item = Tree::instance().nodes()[child]; - const int floor = automaticMinimumMainSize(item); - int32_t &main = isRow_ ? item.layout.width : item.layout.height; - if (main < floor) { main = clampLayoutExtent(floor); engine_.repositionChildren(child); } + // A growing item with a zero flex basis (`flex: 1`) starts from zero; its + // automatic minimum floors the FINAL flexed size (growFlexChildren), not the + // basis. Flooring the basis hands content-bearing items a head start over their + // siblings and unbalances equal-flex rows (a tic-tac-toe cell holding "X" grew + // wider than its empty neighbours). + if (!zeroBasisGrowItem(item)) { + const int floor = automaticMinimumMainSize(item); + int32_t &main = isRow_ ? item.layout.width : item.layout.height; + if (main < floor) { main = clampLayoutExtent(floor); engine_.repositionChildren(child); } + } } } } @@ -1028,8 +1043,8 @@ class FlexLayoutPass { 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].style) || suppressAnonymousWhitespace(nodes, parent, child)) continue; - if (isOutOfFlowPosition(nodes[child].style.position)) { + 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); @@ -1225,10 +1240,10 @@ class FlexLayoutPass { j, m.firstLineTrailingSpace}); } } - const int outerHeight = cn.style.margin[0] + cn.layout.height + cn.style.margin[2]; - if (cn.style.vertical_align) lineAligned = true; - if (cn.style.vertical_align == 1) topItemHeight = std::max(topItemHeight, outerHeight); - else if (cn.style.vertical_align == 2) bottomItemHeight = std::max(bottomItemHeight, outerHeight); + 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); @@ -1242,16 +1257,16 @@ class FlexLayoutPass { // 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_.style); + 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.style.vertical_align == 1 || item.style.vertical_align == 2) continue; + 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.style.margin[0] + childBaseline(item); + ascent = item.computedStyle().margin[0] + childBaseline(item); descent = runLineDescent(run >= 0 ? nodes[run] : item, wrapAdvance); } else if (isLineBreak(item)) { ascent = childBaseline(item); @@ -1414,15 +1429,15 @@ class FlexLayoutPass { const bool keepScrollHeight = scrollsOverflowY(node_.computedStyle()) && node_.layout.height > 0; 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_.style, 0) + boxInset(node_.style, 2); + const int height = (flowHeight < 0 ? 0 : flowHeight) + boxInset(node_.computedStyle(), 0) + boxInset(node_.computedStyle(), 2); node_.layout.height = clampLayoutExtent(clampLayoutSize(node_, height, false)); } if (!hasExplicitWidth(node_) && !keepScrollWidth) { - const int width = maxLineWidth + boxInset(node_.style, 1) + boxInset(node_.style, 3); + const int width = maxLineWidth + boxInset(node_.computedStyle(), 1) + boxInset(node_.computedStyle(), 3); 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_.style, true); + const int content = node_.layout.width - boxInsets(node_.computedStyle(), true); for (const WidenedRun &run : widenedRuns_) nodes[run.id].layout.width = clampLayoutExtent(std::max(run.natural, content + run.extra)); } @@ -1505,7 +1520,7 @@ class FlexLayoutPass { while (n->type == NodeType::View) { if (!inlineBoxView(*n) || isLineBreak(*n)) return -1; int c = last ? n->last_child : n->first_child; - while (c >= 0 && (isDisplayNone(nodes[c].style) || isOutOfFlowPosition(nodes[c].style.position) || nodes[c].style.float_side)) + 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]; @@ -1513,14 +1528,14 @@ class FlexLayoutPass { return static_cast(n - nodes); } - static bool wrapsLines(const Node &text) { return text.style.white_space == 0 || text.style.white_space >= 3; } - static bool collapsesSpaces(const Node &text) { return text.style.white_space == 0 || text.style.white_space == 1 || text.style.white_space == 4; } + 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.style.white_space == 0 || item.style.white_space == 1) && + 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; } @@ -1557,7 +1572,7 @@ class FlexLayoutPass { const Node &previous = nodes[before]; if (previous.text.empty() || !collapsibleSpace(previous.text.str().back()) || !collapsesSpaces(previous)) return 0; } - return TextRenderer::measureWidth(" ", item.style.font_id, item.style.font_size); + 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 @@ -1570,10 +1585,10 @@ class FlexLayoutPass { 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.style.margin[3] + TextRenderer::firstUnbreakableWidth(inner >= 0 ? nodes[inner] : item); + width += item.computedStyle().margin[3] + TextRenderer::firstUnbreakableWidth(inner >= 0 ? nodes[inner] : item); break; } - width += item.layout.width + item.style.margin[1] + item.style.margin[3] - collapsedLead(nodes, k, lineStart && k == j); + width += item.layout.width + item.computedStyle().margin[1] + item.computedStyle().margin[3] - collapsedLead(nodes, k, lineStart && k == j); } return width; } @@ -1603,7 +1618,7 @@ class FlexLayoutPass { 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.style.font_id, text.style.font_size); + 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, @@ -1646,10 +1661,10 @@ class FlexLayoutPass { } int y = baseline - childBaseline(cn); - if (cn.style.vertical_align == 1) y = lineTop + cn.style.margin[0]; - else if (cn.style.vertical_align == 2) y = lineTop + lineCross - cn.layout.height - cn.style.margin[2]; + 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.style.margin[0] - cn.style.margin[2]); + 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]; const int crossAfter = cn.computedStyle().margin[2]; @@ -1657,7 +1672,7 @@ class FlexLayoutPass { y = lineTop + crossBefore; if (align == 1) y += (lineCross - crossTotal) / 2; else if (align == 2) y += lineCross - crossTotal; - else if (align == 0 && !hasExplicitHeight(cn) && !isIntrinsicSizeExpression(cn.style.height_expression)) { + else if (align == 0 && !hasExplicitHeight(cn) && !isIntrinsicSizeExpression(cn.computedStyle().height_expression)) { cn.layout.height = clampLayoutExtent(lineCross - crossBefore - crossAfter); engine_.repositionChildren(child); } @@ -1668,7 +1683,7 @@ class FlexLayoutPass { // An inline wrapper (text) contributes its CHILD's glyphs to // this line box, so the flag has to reach the node that actually draws. // An atomic inline box (inline-flex) lays out its own children. - if (cn.type == NodeType::View && !isAtomicInline(cn.style)) { + 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; } @@ -1762,7 +1777,7 @@ class FlexLayoutPass { const int mainSize = isRow_ ? childNode.layout.width : childNode.layout.height; if (mainSize == 0) { 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); } } @@ -1785,7 +1800,7 @@ class FlexLayoutPass { if (ratioCrossChanged) { expandCrossSizes(lineCount, true); int32_t &cross = isRow_ ? node_.layout.height : node_.layout.width; - cross = clampLayoutExtent(clampLayoutSize(node_, totalCrossSize(lineCount) + boxInsets(node_.style, !isRow_), !isRow_)); + 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_); @@ -1859,7 +1874,7 @@ class FlexLayoutPass { // 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_.style, 1) + boxInset(node_.style, 3); + const int width = (isRow_ ? maxMainSize(lineCount) : totalCrossSize(lineCount)) + boxInset(node_.computedStyle(), 1) + boxInset(node_.computedStyle(), 3); node_.layout.width = clampLayoutSize(node_, width, true); } } @@ -2092,7 +2107,7 @@ class FlexLayoutPass { if (childNode.computedStyle().flex <= 0) continue; lastGrowChild = child; const long long before = (2LL * delta * weight + totalGrow) / (2LL * totalGrow); - weight += childNode.style.flex; + weight += childNode.computedStyle().flex; const long long after = (2LL * delta * weight + totalGrow) / (2LL * totalGrow); applied += applyFlexMainSizeDelta(child, childNode, static_cast(after - before)); } @@ -2167,12 +2182,12 @@ class FlexLayoutPass { // the definite minimum and maximum cross sizes converted through it // (CSS Flexbox 4.5). if (preferredRatio(childNode) > 0) { - const int maxCross = isRow_ ? childNode.style.max_height : childNode.style.max_width; - const int minCross = isRow_ ? childNode.style.min_height : childNode.style.min_width; + 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.style, maxCross, !isRow_), isRow_)); + 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.style, minCross, !isRow_), isRow_)); + 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; @@ -2246,7 +2261,7 @@ class FlexLayoutPass { const int mode = writingMode(node_); const bool blockReversed = mode == 2 || mode == 3; const bool inlineReversed = rightToLeft(node_) != (mode == 4); - reverseMain = flex && ((node_.style.flex_direction_explicit && node_.style.flex_direction >= 2) != (logicalRow ? inlineReversed : blockReversed)); + 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); @@ -2257,8 +2272,8 @@ class FlexLayoutPass { // 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_.style).margin_trim; - if (!trim || node_.style.display != kDisplayFlex || lineCount <= 0) return; + 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; @@ -2280,10 +2295,10 @@ class FlexLayoutPass { 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.style.margin[1] + item.style.margin[3] - : item.layout.height + item.style.margin[0] + item.style.margin[2]; - crossSize = std::max(crossSize, isRow_ ? item.layout.height + item.style.margin[0] + item.style.margin[2] - : item.layout.width + item.style.margin[1] + item.style.margin[3]); + 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)); @@ -2312,7 +2327,7 @@ class FlexLayoutPass { if (isRow_) { for (int i = 0; i < line.count; i++) { Node &probe = nodes[children_[line.start + i]]; - if ((probe.style.margin_auto & 5) || usesBaselineFallback(probe)) 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); @@ -2371,9 +2386,9 @@ class FlexLayoutPass { // 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_.style.justify_content; + 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_.style.display == kDisplayFlex && !centredText; + const bool baselineText = childNode.type == NodeType::Text && node_.computedStyle().display == kDisplayFlex && !centredText; childNode.render.inline_baseline = baselineText ? 1 : 0; } } @@ -2396,7 +2411,7 @@ class FlexLayoutPass { bool usesBaselineFallback(const Node &childNode) const { const int keyword = crossAlignFor(childNode) & 15; - return node_.style.display == kDisplayFlex && !inlineRow_ && (keyword == 5 || keyword == kAlignLastBaseline) && + return node_.computedStyle().display == kDisplayFlex && !inlineRow_ && (keyword == 5 || keyword == kAlignLastBaseline) && (writingMode(childNode) == 0) == !isRow_; } @@ -2470,22 +2485,22 @@ class FlexLayoutPass { void alignedBox(const Node &item, int &above, int &below) const { if (alignsByLineHeight(item)) { - const auto own = TextRenderer::inlineFontMetrics(item.style); + const auto own = TextRenderer::inlineFontMetrics(item.computedStyle()); above = own.strutAscent; below = own.strutDescent; return; } - above = item.style.margin[0] + childBaseline(item); - below = item.layout.height + item.style.margin[2] - childBaseline(item); + 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.style.vertical_align; + const int align = item.computedStyle().vertical_align; if (align < 3) return 0; - const auto parent = TextRenderer::inlineFontMetrics(node_.style); + 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; @@ -2499,10 +2514,10 @@ class FlexLayoutPass { void lineExtent(const Node &item, int &ascent, int &descent) const { const int shift = verticalAlignShift(item); - if (item.style.vertical_align >= 3) alignedBox(item, ascent, descent); + if (item.computedStyle().vertical_align >= 3) alignedBox(item, ascent, descent); else { - ascent = item.style.margin[0] + childBaseline(item); - descent = item.layout.height + item.style.margin[2] - childBaseline(item); + ascent = item.computedStyle().margin[0] + childBaseline(item); + descent = item.layout.height + item.computedStyle().margin[2] - childBaseline(item); } ascent -= shift; descent += shift; @@ -2516,13 +2531,13 @@ class FlexLayoutPass { 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.style) || isOutOfFlowPosition(inner.style.position) || inner.style.float_side || + 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.style.display == kDisplayBlock && !isAtomicInline(inner.style)) { - if (blockLevelView(inner) && overflowEstablishesContext(inner.style)) - return inner.layout.y + inner.layout.height + inner.style.margin[2]; + 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; @@ -2538,9 +2553,9 @@ class FlexLayoutPass { 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.style.display == kDisplayBlock && isAtomicInline(childNode.style)) { - const int line = overflowEstablishesContext(childNode.style) ? -1 : lastLineBaseline(childNode); - return line >= 0 ? line : childNode.layout.height + childNode.style.margin[2]; + 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 @@ -2554,7 +2569,7 @@ class FlexLayoutPass { 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]; // Collapsible white space alone makes no line box, so no baseline. - if (isDisplayNone(inner.style) || isOutOfFlowPosition(inner.style.position) || collapsibleSpaceOnly(inner)) continue; + if (isDisplayNone(inner.computedStyle()) || isOutOfFlowPosition(inner.computedStyle().position) || collapsibleSpaceOnly(inner)) continue; return inner.layout.y + childBaseline(inner, last); } } @@ -2618,7 +2633,7 @@ static bool isInlineLevelTag(const char *tag) // tag with no authored display. static bool inlineBoxView(const Node &n) { - return n.type == NodeType::View && (n.style.display_explicit ? isInlineBoxDisplay(n.style) : isInlineLevelTag(tagFromId(n.tag_id))); + 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). @@ -2626,19 +2641,19 @@ static bool inlineBoxView(const Node &n) // this layout does not do: such a container keeps a single column. bool isMulticolContainer(const Node &node) { - const RareStyle &rs = rstyle(node.style); - if ((rs.column_count <= 0 && rs.column_width <= 0) || node.type != NodeType::View || node.style.display != kDisplayBlock || + 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].style).line_clamp_flags & 64) return false; + if (rstyle(nodes[c].computedStyle()).line_clamp_flags & 64) return false; return true; } // column-gap: normal is 1em in a multicol container. int multicolGap(const Node &node, int available) { - const auto &s = node.style; + 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; @@ -2647,14 +2662,14 @@ int multicolGap(const Node &node, int available) // An inline box with nothing to show: no insets and no visible box. bool emptyInlineBox(const Node &node) { - return !boxInsets(node.style, true) && !boxInsets(node.style, false) && FlexLayoutPass::paintsNoBox(node); + return !boxInsets(node.computedStyle(), true) && !boxInsets(node.computedStyle(), false) && FlexLayoutPass::paintsNoBox(node); } // 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) && !inlineBoxView(node); + return node.type == NodeType::View && !isAtomicInline(node.computedStyle()) && !inlineBoxView(node); } // Box-tree projection for inline ancestors split by in-flow blocks. The DOM @@ -2663,18 +2678,18 @@ bool blockLevelView(const Node &node) bool splitInlineWrapper(const Node *nodes, int id) { const Node &n = nodes[id]; - if (!inlineBoxView(n) || n.style.float_side || isOutOfFlowPosition(n.style.position) || - !FlexLayoutPass::paintsNoBox(n) || boxInsets(n.style, true) || boxInsets(n.style, false) || - n.style.margin[1] || n.style.margin[3] || n.style.opacity != 255 || overflowEstablishesContext(n.style)) return false; + 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; for (int c = n.first_child; c >= 0; c = nodes[c].next_sibling) { const Node &child = nodes[c]; - if (isDisplayNone(child.style)) continue; + if (isDisplayNone(child.computedStyle())) continue; // Out-of-flow children stay with the wrapper, whose fragments contain them // (LayoutNodePass::positionFragmentedInlineChildren). - if (isOutOfFlowPosition(child.style.position)) continue; + if (isOutOfFlowPosition(child.computedStyle().position)) continue; // A float inside an inline belongs to the enclosing block's float context. - if (child.style.float_side) { containsBlock = true; continue; } + if (child.computedStyle().float_side) { containsBlock = true; continue; } // A forced break splits the inline across line boxes. if (isLineBreak(child)) { containsBlock = true; continue; } if (blockLevelView(child)) containsBlock = true; @@ -2692,10 +2707,10 @@ bool splitDecoratedInline(const Node *nodes, int id) { const Node &n = nodes[id]; if (!LayoutEngine::isCssInlineLevelBox(n) || LayoutEngine::isInlineLevelNode(n) || n.type != NodeType::View || - n.style.display != kDisplayBlock || emptyInlineBox(n) || overflowEstablishesContext(n.style)) return false; + 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.style) && !isOutOfFlowPosition(child.style.position) && !child.style.float_side && + if (!isDisplayNone(child.computedStyle()) && !isOutOfFlowPosition(child.computedStyle().position) && !child.computedStyle().float_side && blockLevelView(child)) return true; } return false; @@ -2743,8 +2758,8 @@ 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 = clampLayoutExtent(TextRenderer::measureHeight(" ", node_.style.font_id, - node_.style.font_size, 0, node_.style.line_height)); + node_.layout.height = clampLayoutExtent(TextRenderer::measureHeight(" ", node_.computedStyle().font_id, + node_.computedStyle().font_size, 0, node_.computedStyle().line_height)); return; } @@ -2845,7 +2860,7 @@ class LayoutNodePass { #endif // 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_.style, true) : -1); + ColumnWidthScope columnWidth(node_, columns.count > 0 ? columns.width + boxInsets(node_.computedStyle(), true) : -1); const int padWidth = paddedWidth(); const int padHeight = paddedHeight(); @@ -2903,7 +2918,7 @@ class LayoutNodePass { { ColumnGeometry g; if (!isMulticolContainer(node_)) return g; - const RareStyle &rs = rstyle(node_.style); + const RareStyle &rs = rstyle(node_.computedStyle()); const int available = paddedWidth(); g.gap = multicolGap(node_, available); int count = rs.column_count; @@ -2933,8 +2948,8 @@ class LayoutNodePass { // column-fill: auto, else balances the flow without cutting a line box. void layoutColumns(const ColumnGeometry &g) { - const int insetsX = boxInsets(node_.style, true); - const int insetsY = boxInsets(node_.style, false); + 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; @@ -2944,7 +2959,7 @@ class LayoutNodePass { 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_.style).line_clamp_flags; + 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); @@ -2965,7 +2980,7 @@ class LayoutNodePass { { if (count <= 1 || flow <= 0) return flow; std::vector> units; - collectMonolithicUnits(id_, -boxInset(node_.style, 0), 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) { @@ -2981,14 +2996,14 @@ class LayoutNodePass { { for (int id = nodes_[parent].first_child; id >= 0; id = nodes_[id].next_sibling) { const Node &child = nodes_[id]; - if (isDisplayNone(child.style) || isOutOfFlowPosition(child.style.position)) continue; + 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.style.font_id, child.style.font_size, 0, child.style.line_height); - const int inner = child.layout.height - boxInsets(child.style, false); + 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.style, 0) + line * advance; + 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) { @@ -3059,7 +3074,7 @@ class LayoutNodePass { { for (int prev = k - 1; prev >= 0; --prev) { const Node &item = nodes_[children[prev]]; - if (item.style.float_side) continue; + 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; @@ -3074,14 +3089,14 @@ class LayoutNodePass { void deferUnbreakableFloats(int *children, int count) const { for (int k = 0; k < count; ++k) { - if (!nodes_[children[k]].style.float_side) continue; + if (!nodes_[children[k]].computedStyle().float_side) continue; int group = k + 1; - while (group < count && nodes_[children[group]].style.float_side) ++group; + 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.style.float_side || blockBox(item) || isLineBreak(item) || FlexLayoutPass::leadingSoftWrap(item)) break; + if (item.computedStyle().float_side || blockBox(item) || isLineBreak(item) || FlexLayoutPass::leadingSoftWrap(item)) break; if (FlexLayoutPass::trailingSoftWrap(nodes_[children[target++]])) break; } } @@ -3112,7 +3127,7 @@ class LayoutNodePass { bool blockBox(const Node &n) const { - return !isDisplayNone(n.style) && blockLevelView(n); + return !isDisplayNone(n.computedStyle()) && blockLevelView(n); } bool flowChild(int parent, int child) const @@ -3125,10 +3140,10 @@ class LayoutNodePass { // 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) || clampsLines(n) || - 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]))); } @@ -3254,7 +3269,7 @@ class LayoutNodePass { // calc(), or min/max clamping). double exactPercentHeight(const Node &child, int basis) const { - const auto &s = child.style; + 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; @@ -3267,7 +3282,7 @@ class LayoutNodePass { // the container's start edge. int blockInlineOffset(const Node &child, int slack) const { - const bool autoLeft = child.style.margin_auto & (1 << 3), autoRight = child.style.margin_auto & (1 << 1); + 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; } @@ -3335,8 +3350,8 @@ class LayoutNodePass { engine_.repositionChildren(id); } } - const int slack = contentWidth - child.layout.width - child.style.margin[1] - child.style.margin[3]; - child.layout.x = boxInset(node_.style, 3) + child.style.margin[3] + blockInlineOffset(child, slack); + const int slack = contentWidth - child.layout.width - child.computedStyle().margin[1] - child.computedStyle().margin[3]; + child.layout.x = boxInset(node_.computedStyle(), 3) + child.computedStyle().margin[3] + blockInlineOffset(child, slack); } } const bool mergeTop = collapsesWithChildren(id_, false); @@ -3364,7 +3379,7 @@ class LayoutNodePass { } pending.add(childEdgeMargin(id_, id, false)); cursor += first && mergeTop ? 0 : pending.value(); - (vertical ? child.layout.x : child.layout.y) = boxInset(node_.style, blockMarginSide(false)) + cursor; + (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; @@ -3449,21 +3464,21 @@ class LayoutNodePass { 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_.style.margin[s] + boxInset(node_.style, s) : 0; + 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]].style.float_side) return true; + 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_.style, 3)) : -trimLeft - (trimLeft ? 0 : boxInset(node_.style, 3)); - const int right = rtl ? contentWidth + trimRight + (trimRight ? 0 : boxInset(node_.style, 1)) : -trimLeft + partWidth + (trimRight ? 0 : boxInset(node_.style, 1)); - const int top = partTop - boxInset(node_.style, 0), bottom = flowY + boxInset(node_.style, 2); + 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_.style, 3) + left); - part.y = clampInt16(boxInset(node_.style, 0) + top); + 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)); @@ -3533,11 +3548,11 @@ class LayoutNodePass { // 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]].style.float_side && !nodes_[children[end]].style.clear_side && + 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.style.margin[1] + next.style.margin[3] <= right - left) { + 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 @@ -3584,7 +3599,7 @@ class LayoutNodePass { i = end - 1; continue; } - const bool splitBlock = split && !child.style.float_side && blockBox(child); + const bool splitBlock = split && !child.computedStyle().float_side && blockBox(child); if (splitBlock && partTop >= 0) { closePart(); flowY += outer[2]; @@ -3592,14 +3607,14 @@ class LayoutNodePass { // 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.style.float_side && blockBox(child); - const int measuredOuterW = child.layout.width + child.style.margin[3] + child.style.margin[1]; - const bool automaticWidth = block && !hasExplicitWidth(child) && !isIntrinsicSizeExpression(child.style.width_expression) && + 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) && writingMode(child) == 0 && !(preferredRatio(child) > 0 && hasExplicitHeight(child)); // 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, area - child.style.margin[1] - child.style.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 = clampLayoutExtent(width); engine_.repositionChildren(children[i]); @@ -3632,7 +3647,7 @@ class LayoutNodePass { if (clearance && !context.hasClearance(children[i])) context.cleared.push_back(children[i]); int left = 0, right = area, contextX = 0, contextFree = 0; const bool independentBlock = block && establishesBlockContext(child); - if (child.style.float_side || child.style.display != kDisplayBlock || 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 @@ -3655,17 +3670,17 @@ class LayoutNodePass { // A BFC's border box cannot overlap float margin boxes. // Automatic width uses the remaining interval. Even a // zero-width float prevents a negative margin crossing it. - const int start = hasLeft ? std::max(child.style.margin[3], left) : child.style.margin[3]; - const int end = hasRight ? std::min(area - child.style.margin[1], right) : area - child.style.margin[1]; - contextX = start - child.style.margin[3]; + 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 (searchWidth && end >= start) { const int width = clampLayoutSize(child, end - start, true); if (child.layout.width != width) { - engine_.layoutNode(children[i], width + child.style.margin[1] + child.style.margin[3], contentHeight); + engine_.layoutNode(children[i], width + child.computedStyle().margin[1] + child.computedStyle().margin[3], contentHeight); child.layout.width = clampLayoutExtent(width); engine_.repositionChildren(children[i]); - outerW = width + child.style.margin[1] + child.style.margin[3]; - outerH = child.layout.height + child.style.margin[0] + child.style.margin[2]; + outerW = width + child.computedStyle().margin[1] + child.computedStyle().margin[3]; + outerH = child.layout.height + child.computedStyle().margin[0] + child.computedStyle().margin[2]; } if (child.layout.height > extent) { extent = child.layout.height; continue; } } @@ -3681,12 +3696,12 @@ class LayoutNodePass { // 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.style.margin_auto & 10; + const bool autoMargins = child.computedStyle().margin_auto & 10; const int x = (block && !independentBlock ? left + blockInlineOffset(child, right - left - outerW) : - child.style.float_side == 2 ? right - 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.style.float_side + 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]); @@ -3753,7 +3768,7 @@ class LayoutNodePass { if (clearedThrough || (!collapsesWithChildren(id_, true) && !(first && collapsesWithChildren(id_, false)))) flowY += pending.value(); if (measureWidth) - node_.layout.width = clampLayoutExtent(clampLayoutSize(node_, intrinsicWidth + boxInsets(node_.style, 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); @@ -3982,7 +3997,7 @@ class LayoutNodePass { // 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_.style).margin_trim) { + 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}; @@ -4284,24 +4299,25 @@ class LayoutNodePass { void captureBlockStaticPositions() { for (int c = node_.first_child; c >= 0; c = nodes_[c].next_sibling) - if (isOutOfFlowPosition(nodes_[c].style.position)) nodes_[c].layout.static_block_axis = 0; - if (node_.style.display != kDisplayBlock || node_.style.flex_direction_explicit || - (LayoutEngine::isInlineLevelNode(node_) && !isAtomicInline(node_.style) && !isOutOfFlowPosition(node_.style.position))) return; + 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_) && !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_.style, blockMarginSide(false)) + contentShift_; + int cursor = boxInset(node_.computedStyle(), blockMarginSide(false)) + contentShift_; bool first = true; CollapsedMargin pending; visitFormattingChildren(nodes_, id_, [&](int id) { Node &child = nodes_[id]; - if (isDisplayNone(child.style)) return; - if (isOutOfFlowPosition(child.style.position)) { + if (isDisplayNone(child.computedStyle())) return; + if (isOutOfFlowPosition(child.computedStyle().position)) { // 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()); - child.layout.static_block_start = clampInt16(reversed ? node_.layout.width - edge : edge); - child.layout.static_block_axis = vertical ? 2 : 1; + 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; } @@ -4325,13 +4341,13 @@ class LayoutNodePass { void captureHypotheticalLine(int id, int edge) { if (const NodeRareData *rare = rareDataFor(id); rare && rare->inlineStaticPosition.valid) return; - int left = boxInset(node_.style, 3), right = node_.layout.width - boxInset(node_.style, 1); + 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.style.float_side || isDisplayNone(box.style) || isOutOfFlowPosition(box.style.position)) continue; - if (edge < box.layout.y - box.style.margin[0] || edge >= box.layout.y + box.layout.height + box.style.margin[2]) continue; - if (box.style.float_side == 1) left = std::max(left, box.layout.x + box.layout.width + box.style.margin[1]); - else right = std::min(right, box.layout.x - box.style.margin[3]); + 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_); @@ -4352,29 +4368,29 @@ class LayoutNodePass { void alignBlockContent() { contentShift_ = 0; - const int keyword = node_.style.align_content & 15; + 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_.style.min_height <= 0) || - node_.style.display != kDisplayBlock || node_.style.flex_direction_explicit || - inlineBoxView(node_) || writingMode(node_) != 0 || overflowEstablishesContext(node_.style) || isMulticolContainer(node_)) return; - const int top = boxInset(node_.style, 0); + 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.style) || isOutOfFlowPosition(child.style.position) || suppressAnonymousWhitespace(nodes_, id_, c)) continue; - first = std::min(first, child.layout.y - child.style.margin[0]); - last = std::max(last, child.layout.y + child.layout.height + child.style.margin[2]); + 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_.style, false) - (last - first); + 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.style) || isOutOfFlowPosition(child.style.position)) continue; + if (isDisplayNone(child.computedStyle()) || isOutOfFlowPosition(child.computedStyle().position)) continue; child.layout.y = clampInt16(child.layout.y + delta); } } @@ -4412,7 +4428,7 @@ class LayoutNodePass { 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].style) && isOutOfFlowPosition(nodes_[k].style.position); + 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; @@ -4421,7 +4437,7 @@ class LayoutNodePass { 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].style.position)) continue; + 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); } @@ -4445,7 +4461,7 @@ class LayoutNodePass { 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.style) || isOutOfFlowPosition(child.style.position) || child.style.float_side || + 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)) { @@ -4482,7 +4498,7 @@ class LayoutNodePass { firstStart = lastStart = x; firstEnd = lastEnd = x + item.layout.width; if (item.type != NodeType::Text) return; - const int contentLeft = boxInset(node_.style, 3); + 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); @@ -4492,7 +4508,7 @@ class LayoutNodePass { 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.style, true); + 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; @@ -4541,15 +4557,15 @@ class LayoutNodePass { 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.style.margin[3]; - else if (hasPositionOffset<1>(childNode)) childNode.layout.x = x + width - childNode.layout.width - resolvedPositionOffsetWithBasis<1>(childNode, width) - childNode.style.margin[1]; + 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) && 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.style.margin[0]; - else if (hasPositionOffset<2>(childNode)) childNode.layout.y = y + height - childNode.layout.height - resolvedPositionOffsetWithBasis<2>(childNode, height) - childNode.style.margin[2]; + 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); } @@ -4633,7 +4649,7 @@ bool establishesTransformContainingBlock(const Node &node) bool establishesAbsoluteContainingBlock(const Node &node) { - return isOutOfFlowPosition(node.style.position) || node.style.position == 2 || node.style.position == kPositionSticky || + return isOutOfFlowPosition(node.computedStyle().position) || node.computedStyle().position == 2 || node.computedStyle().position == kPositionSticky || establishesTransformContainingBlock(node); } @@ -4807,7 +4823,7 @@ void positionAbsoluteNodeInContainingBlock(int node, int containing, Node *nodes resolvedPositionOffsetForBasis<1>(absolute, areaWidth), offset)) containingX = areaX + offset; else if (hasPositionOffsetValue<3>(absolute)) - containingX = areaX + resolvedPositionOffsetForBasis<3>(absolute, areaWidth) + absolute.style.margin[3]; + 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]; @@ -4818,7 +4834,7 @@ void positionAbsoluteNodeInContainingBlock(int node, int containing, Node *nodes resolvedPositionOffsetForBasis<2>(absolute, areaHeight), offset)) containingY = areaY + offset; else if (hasPositionOffsetValue<0>(absolute)) - containingY = areaY + resolvedPositionOffsetForBasis<0>(absolute, areaHeight) + absolute.style.margin[0]; + containingY = areaY + resolvedPositionOffsetForBasis<0>(absolute, areaHeight) + absolute.computedStyle().margin[0]; else if (hasPositionOffsetValue<2>(absolute)) containingY = areaY + areaHeight - absolute.layout.height - resolvedPositionOffsetForBasis<2>(absolute, areaHeight) - absolute.computedStyle().margin[2]; @@ -4982,60 +4998,61 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod inlineOffset += treeNodes[cursor].layout.x; cursor = treeNodes[cursor].parent; } - const int origin = cursor >= 0 ? boxInset(treeNodes[cursor].style, 3) - inlineOffset : 0; + const int origin = cursor >= 0 ? boxInset(treeNodes[cursor].computedStyle(), 3) - inlineOffset : 0; x = (nextLine ? 0 : position.x) + origin; } // 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.style).justify_self; - const int free = -(childNode.layout.width + childNode.style.margin[1] + childNode.style.margin[3]); + 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) : position.anchorRight ? 2 : 6; - return x + childNode.style.margin[3] + (used == 1 ? roundedHalf(free) : used == 2 ? free : 0); + 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 // explicit align-self aligns within it (CSS Position 3, 4.1). - int y = position.y - dy + (nextLine ? position.lineHeight : 0) + 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 = position.lineHeight - childNode.layout.height - - childNode.style.margin[0] - childNode.style.margin[2]; + childNode.computedStyle().margin[0] - childNode.computedStyle().margin[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; } - if (parent.style.display == kDisplayBlock && !parent.style.flex_direction_explicit && - childNode.layout.static_block_axis == (horizontal ? 2 : 1)) { + 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); const int free = -((horizontal ? childNode.layout.width : childNode.layout.height) + - childNode.style.margin[horizontal ? 3 : 0] + childNode.style.margin[horizontal ? 1 : 2]); - const int align = childNode.style.align_self; + 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 childNode.layout.static_block_start + childNode.style.margin[horizontal ? 3 : 0] + + 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 // 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) || isAtomicInline(parent.style) || 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). - const int justify = rstyle(childNode.style).justify_self; + const int justify = rstyle(childNode.computedStyle()).justify_self; if (justify >= 0) { - const int free = (horizontal ? parent.layout.width : parent.layout.height) - boxInsets(parent.style, horizontal) - + const int free = (horizontal ? parent.layout.width : parent.layout.height) - boxInsets(parent.computedStyle(), horizontal) - (horizontal ? childNode.layout.width : childNode.layout.height) - - childNode.style.margin[horizontal ? 3 : 0] - childNode.style.margin[horizontal ? 1 : 2]; + 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.style, horizontal ? 3 : 0) + childNode.style.margin[horizontal ? 3 : 0] + + return boxInset(parent.computedStyle(), horizontal ? 3 : 0) + childNode.computedStyle().margin[horizontal ? 3 : 0] + (used == 1 ? roundedHalf(free) : used == 2 ? free : 0); } return rightToLeft(parent) @@ -5135,16 +5152,16 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod bool LayoutEngine::isInlineLevelNode(const Node &n) { - if (n.style.display == kDisplayNone) return false; - if (isAtomicInline(n.style)) return true; - if (n.style.display == kDisplayFlex || n.style.display == kDisplayGrid) 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. // 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.style.display == kDisplayBlock && n.style.display_explicit && !isInlineBoxDisplay(n.style)) 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 @@ -5168,7 +5185,7 @@ bool LayoutEngine::rightToLeftDirection(const Node &node) int LayoutEngine::physicalTextAlign(const Node &node) { - switch (node.style.text_align) { + switch (node.computedStyle().text_align) { case 1: return 1; case 2: return 2; case 3: return 0; @@ -5183,10 +5200,10 @@ bool LayoutEngine::endsFormattingLine(int id) 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.style) || isOutOfFlowPosition(next.style.position) || next.style.float_side) + if (isDisplayNone(next.computedStyle()) || isOutOfFlowPosition(next.computedStyle().position) || next.computedStyle().float_side) continue; if (isAnonymousTextNode(next) && - (next.style.white_space == 0 || next.style.white_space == 1 || next.style.white_space == 4) && + (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; @@ -5200,8 +5217,8 @@ bool LayoutEngine::endsFormattingLine(int id) bool LayoutEngine::isCssInlineLevelBox(const Node &n, bool hypothetical) { - if (n.style.display == kDisplayNone || (n.style.display_explicit && !isInlineBoxDisplay(n.style))) return false; - if (!hypothetical && (n.style.float_side || isOutOfFlowPosition(n.style.position))) 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; @@ -5389,10 +5406,10 @@ void LayoutEngine::layoutNode(int id, int avail_w, int avail_h, bool intrinsicBo // 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.style.float_side || isAtomicInline(node.style)) && - !isOutOfFlowPosition(node.style.position) && - (!parent || (parent->style.display != kDisplayFlex && !isDisplayGrid(parent->style))) && - node.style.width == kUnset && node.style.width_percent == kUnset && node.style.width_expression == -1 && + 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 && writingMode(node) == 0; // CSS Writing Modes 7.3.2: an orthogonal flow root's automatic inline // size is fit-content. Measure before fixing the used size so cyclic @@ -5653,8 +5670,8 @@ namespace { template int stickyInsetFor(const Node &node, int basis) { - int inset = GEA_CSS_POSITION_PX(node.style, side) != kUnset ? GEA_CSS_POSITION_PX(node.style, side) : 0; - if (GEA_CSS_POSITION_PERCENT(node.style, side) != kUnset) inset += basis * GEA_CSS_POSITION_PERCENT(node.style, 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; } @@ -5698,21 +5715,21 @@ void applyStickyOffset(Node &node, const Node &block, const int port[4]) } return shift; }; - const int blockX0 = block.layout.x + boxInset(block.style, 3), blockX1 = block.layout.x + block.layout.width - boxInset(block.style, 1); - const int blockY0 = block.layout.y + boxInset(block.style, 0), blockY1 = block.layout.y + block.layout.height - boxInset(block.style, 2); - node.layout.y += axis(node.layout.y, node.layout.height, node.style.margin[0], node.style.margin[2], port[1], port[3], + 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.style.margin[3], node.style.margin[1], port[0], port[2], + 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 + computedBorderWidth(node.style, 3); - port[1] = node.layout.y + computedBorderWidth(node.style, 0); - port[2] = node.layout.x + node.layout.width - computedBorderWidth(node.style, 1); - port[3] = node.layout.y + node.layout.height - computedBorderWidth(node.style, 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); } } // namespace @@ -5725,7 +5742,7 @@ void LayoutEngine::resolveAbsoluteCoords(int id, int parent_x, int parent_y) // 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].style)) continue; + if (!overflowEstablishesContext(nodes[ancestor].computedStyle())) continue; scrollportOf(nodes[ancestor], port); break; } @@ -5741,7 +5758,7 @@ void LayoutEngine::resolveAbsoluteCoordsIn(int id, int parent_x, int parent_y, c node->layout.x += parent_x; node->layout.y += parent_y; } - if (node->style.position == kPositionSticky && node->parent >= 0 && !isDisplayNone(node->style)) { + if (node->computedStyle().position == kPositionSticky && node->parent >= 0 && !isDisplayNone(node->computedStyle())) { applyStickyOffset(*node, nodes[node->parent], scrollport); treeState().stickyPresent = true; } @@ -5754,7 +5771,7 @@ void LayoutEngine::resolveAbsoluteCoordsIn(int id, int parent_x, int parent_y, c } // A scroll container's padding box is the scrollport of sticky descendants. int port[4]; - if (overflowEstablishesContext(node->style)) { + if (overflowEstablishesContext(node->computedStyle())) { scrollportOf(*node, port); scrollport = port; } diff --git a/packages/engine/ui/layout_snapshot.cpp b/packages/engine/ui/layout_snapshot.cpp index c6ed62d..a9ba3b6 100644 --- a/packages/engine/ui/layout_snapshot.cpp +++ b/packages/engine/ui/layout_snapshot.cpp @@ -71,7 +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].style); + state.nodes[i].render.previous_box_shadow_extent = boxShadowExtent(state.nodes[i].computedStyle()); state.nodes[i].render.dirty = 0; state.nodes[i].render.layout_dirty = 0; #if GEA_CSS_SCROLLING diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index 3a21033..be2dceb 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -204,6 +204,7 @@ constexpr ComputedStyle makeDefaultStyle() #endif #if GEA_CSS_TEXT_ALIGN style.text_align = 0; +#endif style.text_align_last = 0; style.text_emphasis = 0; style.text_emphasis_color = 0; @@ -375,6 +376,10 @@ void NodeLifecycle::init(Node *n, NodeType type) n->layout.line_clamp_hidden = 0; n->layout.line_clamp_owner = 0; n->layout.line_clamp_lines = 0; +#if !GEA_EMBEDDED_SHARED_STYLES + n->layout.memo_avail_w = n->layout.memo_avail_h = 0; + n->layout.memo_pass = 0; +#endif #if GEA_CSS_SCROLLING n->layout.scroll_x = 0; n->layout.scroll_y = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 169137a..0900795 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -560,7 +560,30 @@ struct ComputedStyle { // 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, kDisplayInline. - int8_t display_explicit; +#if GEA_CSS_DISPLAY_EXPLICIT + uint8_t display_explicit : 3; +#else + static constexpr uint8_t display_explicit = 0; +#endif +#if GEA_CSS_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 : 3; // kPositionSticky needs the third bit + uint8_t has_bg : 1; +#if GEA_CSS_Z_INDEX + uint8_t z_index_auto : 1; +#else + static constexpr int8_t z_index_auto = 1; +#endif +// A proven single byte radius fills the leading text-alignment slot when +// that field is absent. Its read remains one ordinary byte load. +#if GEA_CSS_U8_RADIUS && GEA_CSS_RADIUS_COUNT == 1 && !GEA_CSS_TEXT_ALIGN + uint8_t border_radius[1]; +#endif +#if GEA_CSS_FLEX_DIRECTION int8_t flex_direction; #else static constexpr int8_t flex_direction = 0; @@ -606,6 +629,7 @@ struct ComputedStyle { // text-emphasis-color when text_emphasis says it is explicit. Inherited; // sits in padding on 16-bit colour builds. style_color_t text_emphasis_color; +#if GEA_CSS_BOX_SIZING int8_t box_sizing; // 0: content-box (CSS initial), 1: border-box #else static constexpr int8_t box_sizing = 0; @@ -676,7 +700,16 @@ struct ComputedStyle { static constexpr int32_t height_expression = -1; #endif int32_t width, height; // kUnset: auto; see kMaxLayoutExtent - int16_t width_percent, height_percent; +#if GEA_CSS_WIDTH_PERCENT + int16_t width_percent; +#else + static constexpr int16_t width_percent = kUnset; +#endif +#if GEA_CSS_HEIGHT_PERCENT + int16_t height_percent; +#else + static constexpr int16_t height_percent = kUnset; +#endif #if GEA_CSS_MIN_WIDTH int16_t min_width; #else @@ -809,6 +842,9 @@ struct ComputedStyle { // linear/overlay/radial gradients + background-grid moved to RareStyle (rare). #if GEA_CSS_ACTIVE_BACKGROUND style_color_t active_bg_color; +#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; @@ -866,8 +902,7 @@ struct ComputedStyle { #elif !GEA_CSS_U8_LINE_HEIGHT int16_t line_height; #endif - int8_t text_align; // 0 start, 1 center, 2 right, 3 left, 4 end - int8_t overflow; + #if GEA_CSS_OVERFLOW_AXES int8_t overflow_x; int8_t overflow_y; @@ -1455,6 +1490,7 @@ struct LayoutBox { // and absolute-coordinate conversion; retained refresh reuses this anchor. #if !GEA_EMBEDDED_SHARED_STYLES int16_t static_block_start = 0; + #endif // 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 @@ -1620,7 +1656,7 @@ struct RenderState { // end up at different heights. A run that owns its line keeps the centring. // 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; + 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/render.cpp b/packages/engine/ui/render.cpp index b3e6bca..16ed020 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -5518,8 +5518,8 @@ 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; - const int clipDx = !owner || overflowX(owner->style) ? dx : 0; - const int clipDy = !owner || overflowY(owner->style) ? dy : 0; + 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; @@ -7020,10 +7020,10 @@ int gLastScrollUiFrame = -1000; auto &tree = Tree::instance(); if (id < 0 || id >= tree.nodeCount()) return; const auto &node = tree.nodes()[id]; - if (node.style.display == 1 || (id != owner && isOutOfFlowPosition(node.style.position))) return; - opaque = opaque && node.style.opacity == 255; - if (state.hasNodeScratchFor(id) && node.style.visibility == 0) { - const bool hidesBackground = opaque && node.type == NodeType::Text && node.style.text_alpha == 255; + 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] + 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]; @@ -7517,7 +7517,7 @@ int gLastScrollUiFrame = -1000; DisplayCommand *cmd = list.append(); if (!cmd) return false; - const int top = n.layout.y + boxInset(n.style, 0); + 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; @@ -7547,7 +7547,7 @@ int gLastScrollUiFrame = -1000; 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.style, 1) - boxInset(n.style, 3); + 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) @@ -7587,7 +7587,7 @@ int gLastScrollUiFrame = -1000; return; } Node *n = &Tree::instance().nodes()[id]; - if (n->style.display == 1 || (n->layout.line_clamp_hidden & 1) || 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; @@ -9422,9 +9422,17 @@ int gLastScrollUiFrame = -1000; if (!nodeRecordsOverflowClip(cursor, nodes, nodeCount, &shape)) continue; const Node &clip = nodes[cursor]; - 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) + // 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; + } + if (spanX0 < clip.layout.x) + spanX0 = clip.layout.x; + if (spanX1 > clip.layout.x + clip.layout.width - 1) + spanX1 = clip.layout.x + clip.layout.width - 1; + if (spanX0 > spanX1) { clipped = true; break; @@ -10066,12 +10074,12 @@ int gLastScrollUiFrame = -1000; { const auto *nodes = Tree::instance().nodes(); const auto &n = nodes[id]; - const bool item = n.parent >= 0 && !isOutOfFlowPosition(n.style.position) && - (nodes[n.parent].style.display == kDisplayFlex || isDisplayGrid(nodes[n.parent].style)); + 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.style.position == kPositionFixed || ChildZSorter::effectGroup(n) || - (!n.style.z_index_auto && (n.style.position != 0 || item)) || LayoutEngine::multicolContainer(n); + return n.parent < 0 || n.computedStyle().position == kPositionFixed || ChildZSorter::effectGroup(n) || + (!n.computedStyle().z_index_auto && (n.computedStyle().position != 0 || item)) || LayoutEngine::multicolContainer(n); } bool PaintOrder::isGroup(int id) @@ -10079,10 +10087,10 @@ int gLastScrollUiFrame = -1000; const auto *nodes = Tree::instance().nodes(); const auto &n = nodes[id]; const bool item = n.parent >= 0 && - (nodes[n.parent].style.display == kDisplayFlex || isDisplayGrid(nodes[n.parent].style)); + (nodes[n.parent].computedStyle().display == kDisplayFlex || isDisplayGrid(nodes[n.parent].computedStyle())); // An inline-block, inline-flex or inline-grid paints atomically, like a // float (CSS 2.2 Appendix E). - return isContext(id) || n.style.position != 0 || n.style.float_side || item || isAtomicInline(n.style); + 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) @@ -13343,10 +13351,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 & 1) && !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/root_scroll_refresh.cpp b/packages/engine/ui/root_scroll_refresh.cpp index 4560383..d17951e 100644 --- a/packages/engine/ui/root_scroll_refresh.cpp +++ b/packages/engine/ui/root_scroll_refresh.cpp @@ -489,10 +489,10 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) { auto &state = treeState(); auto &perf = refreshPerfStatsMutable(); - perf.rootScrollCalls++; + GEA_REFRESH_PERF(perf.rootScrollCalls++); // Shifting content by the scroll delta would drag sticky boxes along. if (state.stickyPresent) { - perf.rootScrollRejected++; + GEA_REFRESH_PERF(perf.rootScrollRejected++); return 0; } DirtyNode extraDirtyNodes[kMaxExtraDirtyNodes]; diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 945f2c9..b29a926 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -8270,7 +8270,9 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) ComputedStyle &style = target.mutableStyle(); switch (property) { case Property::Display: +#if GEA_CSS_DISPLAY_EXPLICIT style.display_explicit = kDisplayExplicit | (value >> kDisplayFlagShift); +#endif style.display = value & kDisplayKindMask; return true; #if GEA_CSS_FLEX_DIRECTION @@ -8852,6 +8854,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) #endif #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; 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; @@ -9306,7 +9309,7 @@ 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).style); + 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; @@ -9918,6 +9921,7 @@ void applyInheritedStyleDefaults(int node) #endif #if GEA_CSS_TEXT_ALIGN style.text_align = parentStyle.text_align; +#endif style.text_align_last = parentStyle.text_align_last; style.text_emphasis = parentStyle.text_emphasis; style.text_emphasis_color = parentStyle.text_emphasis_color; @@ -10162,7 +10166,7 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl case CssDeclarationId::BackgroundBlendMode: { const int parent = Tree::instance().node(nodeId).parent; const int blend = toLowerAscii(trimCssValue(value)) == "inherit" - ? (parent >= 0 ? rstyle(Tree::instance().node(parent).style).bg_blend : 0) + ? (parent >= 0 ? rstyle(Tree::instance().node(parent).computedStyle()).bg_blend : 0) : compileBackgroundBlendMode(value); if (blend >= 0) setStyleValue(node, Property::BackgroundBlendMode, blend, source); return true; @@ -16771,6 +16775,9 @@ struct ParentStyleSnapshot { #endif #if GEA_CSS_TEXT_ALIGN std::uint8_t text_align; +#else + static constexpr std::uint8_t text_align = 0; +#endif std::uint8_t text_align_last; std::uint8_t text_emphasis; style_color_t text_emphasis_color; @@ -16816,6 +16823,7 @@ ParentStyleSnapshot snapshotParentStyle(const ComputedStyle &s) #endif #if GEA_CSS_TEXT_ALIGN out.text_align = s.text_align; +#endif out.text_align_last = s.text_align_last; out.text_emphasis = s.text_emphasis; out.text_emphasis_color = s.text_emphasis_color; @@ -18210,8 +18218,8 @@ BackgroundPlacement StyleValues::backgroundPlacement(const ComputedStyle &style, } if (out.origin != 0) { int inset[4]; - for (int i = 0; i < 4; ++i) inset[i] = computedBorderWidth(node->style, i) + - (out.origin == 2 ? std::max(0, node->style.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]; } } diff --git a/packages/engine/ui/text.cpp b/packages/engine/ui/text.cpp index a078f74..46ebe9b 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -730,8 +730,8 @@ bool recordProjectedRasterText(const Node &node, const char *text, uint8_t /*par if (lineWidth <= 0) return true; const int fontLineHeight = font.lineHeight(); - const int lineAdvance = node.style.line_height > 0 ? node.style.line_height : fontLineHeight; - const int lineBoxOffset = node.style.line_height > 0 ? (lineAdvance - fontLineHeight) / 2 : 0; + 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(LayoutEngine::physicalTextAlign(node), tw, lineWidth); const int srcY = ty + lineBoxOffset; const int srcW = lineWidth; @@ -1817,7 +1817,7 @@ void Tree::setText(int node, const char *text) const int gid_previousX0 = gid_hadPartial ? target.render.text_dirty.x0 : 0; 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.style.white_space == 1 && + !target.render.bg_recolor_pending && gid_singleLine && target.computedStyle().white_space == 1 && 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 @@ -2166,10 +2166,10 @@ 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.style).line_clamp_flags & 1)) continue; - const int right = box.layout.x + box.layout.width - boxInset(box.style, 1); + 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.style).block_ellipsis ? rstyle(box.style).block_ellipsis : kAutoBlockEllipsis; + return rstyle(box.computedStyle()).block_ellipsis ? rstyle(box.computedStyle()).block_ellipsis : kAutoBlockEllipsis; } return 0; } @@ -2177,7 +2177,7 @@ static uint16_t blockEllipsisCommand(const Node &run, int textX, int &width) // DrawText::alignLast for a run: -1 unless text-align-last moves some line. static int8_t textAlignLastCommand(const Node &node) { - const int last = node.style.text_align_last - 1; + const int last = node.computedStyle().text_align_last - 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)); @@ -2500,7 +2500,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS const int genFonts = 1; inkOff = rasterizedSingleLineInkCenterOffsetY(measureText, textAlign, containerW, - n->style.font_id, dbgFontSize, commandLineHeight); + n->computedStyle().font_id, dbgFontSize, commandLineHeight); #else const int genFonts = 0; #endif @@ -2529,11 +2529,11 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph int ellipsisWidth = 0; const uint16_t blockEllipsis = !transformed && (n->layout.line_clamp_hidden & 2) - ? blockEllipsisCommand(*n, n->layout.x + boxInset(n->style, 3), ellipsisWidth) : 0; + ? blockEllipsisCommand(*n, n->layout.x + boxInset(n->computedStyle(), 3), ellipsisWidth) : 0; // Emphasis marks sit above (or below) the glyph box, up to half an em. - if (n->style.text_emphasis & 7) { - const int reach = n->style.font_size; - if (n->style.text_emphasis & 0x10) bh += reach; + 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; } } // The ellipsis may reach past the run's own box to the end of its line. @@ -2543,7 +2543,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph bw = right - bx; } - const uint8_t effectiveAlpha = combineAlpha(parentAlpha, n->style.text_alpha); + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, n->computedStyle().text_alpha); if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, bx, by, bw, bh); DisplayCommand *cmd = DisplayList::instance().append(); @@ -2559,7 +2559,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.color = n->computedStyle().text_color; cmd->text.scale = textScale; cmd->text.align = static_cast(textAlign); - cmd->text.textTransform = n->style.text_transform; + cmd->text.textTransform = n->computedStyle().text_transform; cmd->text.lineHeight = commandLineHeight; cmd->text.containerWidth = containerW; cmd->text.fontId = n->computedStyle().font_id; @@ -2570,8 +2570,8 @@ 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); - cmd->text.emphasis = (n->style.text_emphasis & 7) ? n->style.text_emphasis : 0; - cmd->text.emphasisColor = (n->style.text_emphasis & 0x60) == 0x20 ? n->style.text_emphasis_color : n->style.text_color; + 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; { 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_nodes.cpp b/packages/engine/ui/tree_nodes.cpp index d51d513..22e4c6d 100644 --- a/packages/engine/ui/tree_nodes.cpp +++ b/packages/engine/ui/tree_nodes.cpp @@ -674,7 +674,7 @@ void Tree::removeNode(int id, bool restyleSiblings) // Sibling selectors (+, ~) of the siblings that followed it. if (restyleSiblings) StyleSheet::instance().recomputeSiblingsFrom(next); n = &state.nodes[id]; - n->style.display = 1; + n->mutableStyle().display = 1; n->parent = -1; n->first_child = -1; n->last_child = -1; diff --git a/packages/engine/ui/tree_render.cpp b/packages/engine/ui/tree_render.cpp index 81ae249..48fd4ef 100644 --- a/packages/engine/ui/tree_render.cpp +++ b/packages/engine/ui/tree_render.cpp @@ -386,7 +386,7 @@ namespace gea::embedded::ui return true; if (node.computedStyle().mask_right_fade_width > 0) return true; - if ((GEA_CSS_BOX_SHADOW ? rstyle(node.style).box_shadow_alpha : 0) > 0 || node.render.previous_box_shadow_extent > 0 || rstyle(node.style).border_image_source) + if ((GEA_CSS_BOX_SHADOW ? rstyle(node.computedStyle()).box_shadow_alpha : 0) > 0 || node.render.previous_box_shadow_extent > 0 || rstyle(node.computedStyle()).border_image_source) return true; return node.computedStyle().has_bg && (styleHasRoundedRasterEdge(node.computedStyle()) || node.computedStyle().bg_alpha < 255); } @@ -410,8 +410,8 @@ namespace gea::embedded::ui return rect; // 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.style), static_cast(node.render.previous_box_shadow_extent)); - return expandDirtyRect(rect, 1 + std::max(StyleValues::borderImageOutsetExtent(node.style), shadow)); + const int shadow = std::max(boxShadowExtent(node.computedStyle()), static_cast(node.render.previous_box_shadow_extent)); + return expandDirtyRect(rect, 1 + std::max(StyleValues::borderImageOutsetExtent(node.computedStyle()), shadow)); } DirtyRegions::Rect dirtyRectWithRetainedMoveGuard(DirtyRegions::Rect rect) diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 382b10c..a41f0da 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -331,12 +331,14 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn // Any application of the `display` property is explicit authoring — record // 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 // Keyword bits above the box kind (flow-root) are recorded with it. - if (n->style.display_explicit != (kDisplayExplicit | (value >> kDisplayFlagShift))) { - n->style.display_explicit = kDisplayExplicit | (value >> kDisplayFlagShift); + if (style.display_explicit != (kDisplayExplicit | (value >> kDisplayFlagShift))) { + style.display_explicit = kDisplayExplicit | (value >> kDisplayFlagShift); changed = 1; } - if (n->style.display != (value & kDisplayKindMask)) { n->style.display = value & kDisplayKindMask; changed = 1; } +#endif + if (style.display != (value & kDisplayKindMask)) { style.display = value & kDisplayKindMask; changed = 1; } break; #if GEA_CSS_FLEX_DIRECTION case Property::FlexDirection: @@ -385,8 +387,8 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::LineClampDiscard: case Property::ColumnSpanAll: { const int bit = lineClampFlagBit(prop); - const int next = value ? rstyle(n->style).line_clamp_flags | bit : rstyle(n->style).line_clamp_flags & ~bit; - if (rstyle(n->style).line_clamp_flags != next) { rstyleMut(n->style).line_clamp_flags = next; changed = 1; } + 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 @@ -665,8 +667,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(n->style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_0(n->style) != kUnset) { - GEA_CSS_POSITION_PX_0(n->style) = storedPositionOffset(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 @@ -676,8 +678,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(n->style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_1(n->style) != kUnset) { - GEA_CSS_POSITION_PX_1(n->style) = storedPositionOffset(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 @@ -687,8 +689,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(n->style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_2(n->style) != kUnset) { - GEA_CSS_POSITION_PX_2(n->style) = storedPositionOffset(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 @@ -698,8 +700,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(n->style) != storedPositionOffset(value) || GEA_CSS_POSITION_PERCENT_3(n->style) != kUnset) { - GEA_CSS_POSITION_PX_3(n->style) = storedPositionOffset(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 @@ -773,18 +775,18 @@ 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::BorderImageSource: if (rstyle(n->style).border_image_source != value) { rstyleMut(n->style).border_image_source = value; changed = 1; } break; - case Property::BorderImageSlice: if (rstyle(n->style).border_image_slice != value) { rstyleMut(n->style).border_image_slice = value; changed = 1; } break; - case Property::BorderImageWidth: if (rstyle(n->style).border_image_width != value) { rstyleMut(n->style).border_image_width = value; changed = 1; } break; - case Property::BorderImageOutset: if (rstyle(n->style).border_image_outset != value) { rstyleMut(n->style).border_image_outset = value; changed = 1; } break; - case Property::BorderImageRepeat: if (rstyle(n->style).border_image_repeat != value) { rstyleMut(n->style).border_image_repeat = 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::BackgroundBlendMode: if (rstyle(style).bg_blend != value) { rstyleMut(style).bg_blend = value; changed = 1; } break; + 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; + 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 case Property::BackgroundImage: @@ -1032,22 +1034,26 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn style.line_height_multiplier = value; style.line_height = height; changed = 1; } break; } - case Property::TextAlign: if (n->style.text_align != value) { n->style.text_align = value; changed = 1; } break; - case Property::TextAlignLast: if (n->style.text_align_last != value) { n->style.text_align_last = value; changed = 1; } break; +#endif +#endif +#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; case Property::TextEmphasisStyle: case Property::TextEmphasisPosition: case Property::TextEmphasisColorMode: { const int mask = prop == Property::TextEmphasisStyle ? 0x0f : prop == Property::TextEmphasisPosition ? 0x10 : 0x60; - const int next = (n->style.text_emphasis & ~mask) | (value & mask); - if (n->style.text_emphasis != next) { n->style.text_emphasis = static_cast(next); changed = 1; } + 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 (n->style.text_emphasis_color != color) { n->style.text_emphasis_color = color; changed = 1; } + if (style.text_emphasis_color != color) { style.text_emphasis_color = color; changed = 1; } break; } - case Property::VerticalAlign: if (n->style.vertical_align != value) { n->style.vertical_align = 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/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 6ed0a84..e69aa6e 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -305,11 +305,11 @@ void boundsFromCorners(const int16_t *xs, const int16_t *ys, int *x0, int *y0, i // lower layer) has a transparent backdrop and paints normally. uint8_t backgroundLayerBlend(const Node &node, int layer) { - const int mode = StyleValues::backgroundBlendMode(node.style, layer); + const int mode = StyleValues::backgroundBlendMode(node.computedStyle(), layer); if (!mode) return 0; - const auto &r = rstyle(node.style); - const bool backdrop = (node.style.has_bg && node.style.bg_alpha > 0) || - (node.style.bg_fill == 1 && r.bg_gradient_layer > layer) || + const auto &r = rstyle(node.computedStyle()); + 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; @@ -356,17 +356,17 @@ DisplayCommand *appendLinearGradientRectRaw(const Node &node, int x, int y, int y, w, h, - rstyle(node.style).bg_gradient_from_color, - rstyle(node.style).bg_gradient_mid_color, - rstyle(node.style).bg_gradient_to_color, - rstyle(node.style).bg_gradient_mid_stop, - rstyle(node.style).bg_gradient_to_stop, - rstyle(node.style).bg_gradient_angle, - rstyle(node.style).bg_gradient_from_alpha, - rstyle(node.style).bg_gradient_mid_alpha, - rstyle(node.style).bg_gradient_to_alpha, - rstyle(node.style).bg_gradient_has_mid, - backgroundLayerBlend(node, rstyle(node.style).bg_gradient_layer)); + rstyle(node.computedStyle()).bg_gradient_from_color, + rstyle(node.computedStyle()).bg_gradient_mid_color, + rstyle(node.computedStyle()).bg_gradient_to_color, + rstyle(node.computedStyle()).bg_gradient_mid_stop, + rstyle(node.computedStyle()).bg_gradient_to_stop, + rstyle(node.computedStyle()).bg_gradient_angle, + 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, + backgroundLayerBlend(node, rstyle(node.computedStyle()).bg_gradient_layer)); } DisplayCommand *appendRadialGradientRectRaw(const Node &node, int x, int y, int w, int h) @@ -382,20 +382,20 @@ DisplayCommand *appendRadialGradientRectRaw(const Node &node, int x, int y, int cmd->radialGradient.y = y; cmd->radialGradient.w = w; cmd->radialGradient.h = h; - cmd->radialGradient.tl = node.style.border_radius[GEA_CSS_RADIUS_INDEX(0)]; - cmd->radialGradient.tr = node.style.border_radius[GEA_CSS_RADIUS_INDEX(1)]; - cmd->radialGradient.br = node.style.border_radius[GEA_CSS_RADIUS_INDEX(2)]; - cmd->radialGradient.bl = node.style.border_radius[GEA_CSS_RADIUS_INDEX(3)]; - cmd->radialGradient.cxPermille = rstyle(node.style).bg_radial_gradient_cx; - cmd->radialGradient.cyPermille = rstyle(node.style).bg_radial_gradient_cy; - cmd->radialGradient.rxPermille = rstyle(node.style).bg_radial_gradient_rx; - cmd->radialGradient.ryPermille = rstyle(node.style).bg_radial_gradient_ry; - cmd->radialGradient.fromColor = rstyle(node.style).bg_radial_gradient_from_color; - cmd->radialGradient.toColor = rstyle(node.style).bg_radial_gradient_to_color; - cmd->radialGradient.stopPermille = rstyle(node.style).bg_radial_gradient_stop; - cmd->radialGradient.fromAlpha = rstyle(node.style).bg_radial_gradient_from_alpha; - cmd->radialGradient.toAlpha = rstyle(node.style).bg_radial_gradient_to_alpha; - cmd->radialGradient.blend = backgroundLayerBlend(node, rstyle(node.style).bg_radial_gradient_layer); + cmd->radialGradient.tl = node.computedStyle().border_radius[GEA_CSS_RADIUS_INDEX(0)]; + cmd->radialGradient.tr = node.computedStyle().border_radius[GEA_CSS_RADIUS_INDEX(1)]; + cmd->radialGradient.br = node.computedStyle().border_radius[GEA_CSS_RADIUS_INDEX(2)]; + cmd->radialGradient.bl = node.computedStyle().border_radius[GEA_CSS_RADIUS_INDEX(3)]; + cmd->radialGradient.cxPermille = rstyle(node.computedStyle()).bg_radial_gradient_cx; + cmd->radialGradient.cyPermille = rstyle(node.computedStyle()).bg_radial_gradient_cy; + cmd->radialGradient.rxPermille = rstyle(node.computedStyle()).bg_radial_gradient_rx; + cmd->radialGradient.ryPermille = rstyle(node.computedStyle()).bg_radial_gradient_ry; + cmd->radialGradient.fromColor = rstyle(node.computedStyle()).bg_radial_gradient_from_color; + cmd->radialGradient.toColor = rstyle(node.computedStyle()).bg_radial_gradient_to_color; + 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; } @@ -755,7 +755,7 @@ void appendStrokeWithAlpha(const Node &node, uint8_t parentAlpha, uint16_t color void appendStrokeWithAlpha(const Node &node, uint8_t parentAlpha) { - appendStrokeWithAlpha(node, parentAlpha, borderPaintColor(node.style, 0), borderPaintAlpha(node.style, 0)); + 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) @@ -1590,7 +1590,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.style).border_relief[side] & ~kBorderStyleNone; + 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); @@ -1630,7 +1630,7 @@ void appendBorderSideWithAlpha(const Node &node, int side, uint16_t color, uint8 const int y = node.layout.y; const int w = node.layout.width; const int h = node.layout.height; - const int borderWidth = computedBorderWidth(node.style, side); + const int borderWidth = computedBorderWidth(node.computedStyle(), side); if (borderWidth <= 0) return; int sx = x; int sy = y; @@ -1650,8 +1650,8 @@ void appendBorderSideWithAlpha(const Node &node, int side, uint16_t color, uint8 // 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.style, 0), h); - const int bottom = std::min(computedBorderWidth(node.style, 2), h - top); + 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; } @@ -1694,14 +1694,14 @@ void appendSideBordersWithAlpha(const Node &node, uint8_t parentAlpha) { const int w = node.layout.width; const int h = node.layout.height; - if (hasBorderRelief(node.style)) { appendReliefBordersWithAlpha(node, parentAlpha); return; } - const bool textClippedTransform = StyleValues::hasTextBackgroundClip(node.style) && + if (hasBorderRelief(node.computedStyle())) { appendReliefBordersWithAlpha(node, parentAlpha); return; } + const bool textClippedTransform = StyleValues::hasTextBackgroundClip(node.computedStyle()) && ViewGeometry::hasTransformChain(node, false) && !isFullyRoundedShape(node); 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) - appendBorderSideWithAlpha(node, side, borderPaintColor(node.style, side), borderPaintAlpha(node.style, side), parentAlpha, transformed); + appendBorderSideWithAlpha(node, side, borderPaintColor(node.computedStyle(), side), borderPaintAlpha(node.computedStyle(), side), parentAlpha, transformed); } // background-clip: border-area paints the background only where the border @@ -1710,19 +1710,19 @@ void appendSideBordersWithAlpha(const Node &node, uint8_t parentAlpha) void recordBorderAreaFill(const Node &node, uint8_t parentAlpha) { const bool transformed = ViewGeometry::hasTransformChain(node, false); - if (node.style.border_width > 0 && !hasSideBorder(node.style) && !transformed) { - appendStrokeWithAlpha(node, parentAlpha, node.style.bg_color, node.style.bg_alpha); + 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.style.bg_color, node.style.bg_alpha, parentAlpha, transformed); + 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.style) && node.style.border_width > 0 && !hasBorderRelief(node.style) && - !hasSideBorder(node.style) && !borderColorsDiffer(node.style) && borderPaintAlpha(node.style, 0) > 0 && + 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); } @@ -1784,9 +1784,9 @@ void recordLinearGradientBackground(const Node &node, uint8_t parentAlpha) // the quad outline). Emitting projected stroke quads instead would disarm // the transform-reproject fast path every frame. if (gradientEdgeCarriesBorder(node)) { - g.edgeColor = borderPaintColor(node.style, 0); - g.edgeAlpha = borderPaintAlpha(node.style, 0); - const int ew = node.style.border_width < 1 ? 1 : node.style.border_width; + 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; g.edgeWidth = static_cast(ew > 8 ? 8 : ew); } else { g.edgeColor = 0; @@ -1821,17 +1821,17 @@ void recordOverlayLinearGradientBackground(const Node &node) y, w, h, - rstyle(node.style).bg_overlay_gradient_from_color, - rstyle(node.style).bg_overlay_gradient_mid_color, - rstyle(node.style).bg_overlay_gradient_to_color, - rstyle(node.style).bg_overlay_gradient_mid_stop, - rstyle(node.style).bg_overlay_gradient_to_stop, - rstyle(node.style).bg_overlay_gradient_angle, - rstyle(node.style).bg_overlay_gradient_from_alpha, - rstyle(node.style).bg_overlay_gradient_mid_alpha, - rstyle(node.style).bg_overlay_gradient_to_alpha, - rstyle(node.style).bg_overlay_gradient_has_mid, - backgroundLayerBlend(node, rstyle(node.style).bg_overlay_gradient_layer)); + rstyle(node.computedStyle()).bg_overlay_gradient_from_color, + rstyle(node.computedStyle()).bg_overlay_gradient_mid_color, + rstyle(node.computedStyle()).bg_overlay_gradient_to_color, + rstyle(node.computedStyle()).bg_overlay_gradient_mid_stop, + rstyle(node.computedStyle()).bg_overlay_gradient_to_stop, + rstyle(node.computedStyle()).bg_overlay_gradient_angle, + 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, + backgroundLayerBlend(node, rstyle(node.computedStyle()).bg_overlay_gradient_layer)); } // Background positioning and painting areas are distinct. In particular, the @@ -1996,8 +1996,8 @@ void recordTransformedRoundedRectFill(const Node &node, uint8_t parentAlpha, int 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 = node.style.bg_color; - r.backfaceHidden = node.style.backface_hidden ? 1 : 0; + 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; } @@ -2018,10 +2018,10 @@ void recordTransformedBorderRing(const Node &node, uint8_t parentAlpha) // its sides as transformed quads instead, with square corners. int ring = 0; for (int side = 0; side < 4; ++side) - ring = std::max({ring, node.style.border_width, rstyle(node.style).border_side_width[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.style, side), borderPaintAlpha(node.style, side), parentAlpha, true); + appendBorderSideWithAlpha(node, side, borderPaintColor(node.computedStyle(), side), borderPaintAlpha(node.computedStyle(), side), parentAlpha, true); return; } @@ -2029,7 +2029,7 @@ void recordTransformedBorderRing(const Node &node, uint8_t parentAlpha) 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.style, 0)); + 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]{}; @@ -2054,10 +2054,10 @@ void recordTransformedBorderRing(const Node &node, uint8_t parentAlpha) 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.style, 0); - r.backfaceHidden = node.style.backface_hidden ? 1 : 0; + 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.style, i), 1, 255)); + 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); @@ -2369,7 +2369,7 @@ bool shadowContourRow(const ShadowContour &shape, int y, int &left, int &right) 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.style).box_shadow_color : 0); + const auto color = (GEA_CSS_BOX_SHADOW ? rstyle(node.computedStyle()).box_shadow_color : 0); if (!ViewGeometry::hasTransformChain(node, false)) { appendFillRectWithAlpha(x, y, w, h, color, alpha, parentAlpha, x, y, w, h); return; @@ -2411,7 +2411,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, rstyle(node.style).box_shadow_alpha, 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) { @@ -2520,7 +2520,7 @@ void appendShadowSpans(const Node &node, uint8_t parentAlpha, uint8_t alpha, con 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.style).box_shadow_color, 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) { @@ -2575,13 +2575,13 @@ void appendShadowRoundedRect(const Node &node, uint8_t parentAlpha, uint8_t alph 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.style).box_shadow_color; + 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.style).box_shadow_color; + fill.color = rstyle(node.computedStyle()).box_shadow_color; } } if (effectiveAlpha != parentAlpha) appendAlphaCommand(parentAlpha, x, y, w, h); @@ -2610,8 +2610,8 @@ bool shadowContourInside(const ShadowContour &inner, const ShadowContour &outer) // the box, so the knockout can be skipped in favour of native rounded fills. bool outerShadowKnockoutHidden(const Node &node, uint8_t parentAlpha) { - if (!node.style.has_bg || combineAlpha(parentAlpha, node.style.bg_alpha) != 255) return false; - if (StyleValues::backgroundClip(node.style, rstyle(node.style).bg_image_layer_count - 1) != 0) return false; + 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; @@ -2622,7 +2622,7 @@ bool outerShadowKnockoutHidden(const Node &node, uint8_t parentAlpha) // across [-blur, blur] around the shape edge. void recordOuterBoxShadow(const Node &node, uint8_t parentAlpha) { - const auto &r = rstyle(node.style); + 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); @@ -2730,12 +2730,12 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_transformed_bounds") ViewRenderer: 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->style); + 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->style).filter_blur_radius : 0); + 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; const int extentY = std::max(1, radius) * 5; @@ -2821,7 +2821,7 @@ bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) if (!transformed) saturateRect16(x, y, w, h); out = OverflowClipShape{x, y, w, h, false, {}, {}, 0, 0, 0, 0, {}, {}}; // The padding edge is rounded with the outer radii less the border widths. - const auto &s = node.style; + const auto &s = node.computedStyle(); 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]{}; @@ -2864,7 +2864,7 @@ bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) bool ViewRenderer::recordClipBegin(const Node &node) { const Node *n = &node; - if (!isViewLikeNodeType(n->type) || n->style.overflow == 0) return 0; + if (!isViewLikeNodeType(n->type) || n->computedStyle().overflow == 0) return 0; if (n->first_child < 0) return 0; OverflowClipShape shape; @@ -2956,19 +2956,19 @@ bool recordInlineParts(const Node &node, uint8_t parentAlpha) 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.style.has_bg && node.style.bg_alpha > 0) - appendFillRectWithAlpha(x, y, w, h, node.style.bg_color, node.style.bg_alpha, parentAlpha, x, y, w, h); + 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.style, 0), h) : 0; - const int bottom = part.sides & 4 ? std::min(computedBorderWidth(node.style, 2), h - top) : 0; + 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.style, side); - if (!(part.sides & (1 << side)) || width <= 0 || borderPaintAlpha(node.style, side) == 0) continue; + 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.style, side), borderPaintAlpha(node.style, side), parentAlpha, sx, sy, sw, sh); + appendFillRectWithAlpha(sx, sy, sw, sh, borderPaintColor(node.computedStyle(), side), borderPaintAlpha(node.computedStyle(), side), parentAlpha, sx, sy, sw, sh); } } return true; @@ -2980,15 +2980,15 @@ bool recordInlineParts(const Node &node, uint8_t parentAlpha) // tile, clipped to the tile. bool recordBorderImage(const Node &node) { - const auto *source = StyleValues::borderImageSource(node.style); + const auto *source = StyleValues::borderImageSource(node.computedStyle()); if (!source || ViewGeometry::hasTransformChain(node, false)) return false; - const auto slice = StyleValues::borderImageSides(node.style, 0); - const auto widths = StyleValues::borderImageSides(node.style, 1); - const auto outset = StyleValues::borderImageSides(node.style, 2); - const int repeat = rstyle(node.style).border_image_repeat; + 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; double border[4], out[4]; for (int i = 0; i < 4; ++i) { - border[i] = computedBorderWidth(node.style, i); + border[i] = computedBorderWidth(node.computedStyle(), i); out[i] = outset.kind[i] == 1 ? outset.value[i] : outset.value[i] * border[i]; } const double ax = node.layout.x - out[3], ay = node.layout.y - out[0]; From 5939dde245fb219b73c9ba91f58c96e9875f8b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6kt=C3=BCrk=20Acar?= Date: Tue, 6 Oct 2026 13:57:05 +0300 Subject: [PATCH 50/50] Bound the cost of shaped overflow clips A rounded or transformed overflow clip saved and restored its exposed pixels once per node-range replay, so a list of N rows under one rounded clip paid for its corners N times a frame, the saved area ignored the dirty region, every pixel restored through per-pixel float sampling, a transformed clip could save most of the panel, and every overflow clip, rounded or not, paid for the full shape computation in replay's inner loops. Replay now: - keeps a shaped clip 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; - cuts the saved boxes to the dirty region, so a strip that misses a corner saves nothing; - restores an untransformed corner through a cached coverage mask that samples exactly as the per-pixel path does (verified bit-identical on 349 million corner pixels across radii, positions and 1/2/4 samples); - leaves a clip whose exposed area exceeds kMaxSavedPixels as a plain rectangle instead of saving a screenful; - answers overflowClipShape for an untransformed box without radii before computing borders and radii. 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. Co-Authored-By: Claude Fable 5.1 --- packages/engine/ui/css_features.h | 8 + packages/engine/ui/internal.h | 4 + packages/engine/ui/render.cpp | 391 ++++++++++++++++++++++++++---- packages/engine/ui/view.cpp | 23 +- 4 files changed, 372 insertions(+), 54 deletions(-) diff --git a/packages/engine/ui/css_features.h b/packages/engine/ui/css_features.h index dbe87e6..c27e492 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 ece5e99..4d2f983 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -99,9 +99,11 @@ void rootScrollImageHoldTick(); // 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 { @@ -542,9 +544,11 @@ void rootScrollImageHoldTick(); { 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 diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 16ed020..a78c5a7 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -2197,45 +2197,77 @@ int gLastScrollUiFrame = -1000; return 0; } +#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 // (in part where antialiasing covers it in part). + // + // Three bounds keep this cheap. The saved area is cut to the dirty region, + // so a strip 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(); } - static void begin(const DisplayCommand &clip) + // 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 { - 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]; - begin(shape); + 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: one entry per PushClip command, ended by its PopClip. + static void begin(const DisplayCommand &clip) { begin(shapeOf(clip), -1, nullptr); } + + // 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) + { + const std::size_t at = base + static_cast(index); + auto &stack = entries(); + if (at < stack.size() && stack[at].owner == owner) + return; + endTo(at); + begin(shapeOf(shape), owner, region); } - static void begin(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]; - begin(shape); - } + // 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) { @@ -2256,14 +2288,30 @@ int gLastScrollUiFrame = -1000; { 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; + }; 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. @@ -2279,10 +2327,145 @@ int gLastScrollUiFrame = -1000; return value[gea_current_render_core() & 1]; } - static void begin(const TransformedRoundedRectCommand &shape) + 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; + } + + static int extent(int radius8) { return (std::max(0, radius8) + 7) / 8; } + 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; + } + + // 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); + } + } + return &m; + } + + static void begin(const TransformedRoundedRectCommand &shape, int owner, const int *region) { Entry entry{}; entry.shape = shape; + entry.owner = owner; entry.offset = saved().size(); auto *canvas = gea::platform::display::Display::canvas(); const float a = shape.lw ? static_cast(shape.x1 - shape.x0) / shape.lw : 0.0f; @@ -2297,18 +2480,18 @@ int gLastScrollUiFrame = -1000; entry.inv10 = -c / det; entry.inv11 = a / det; Box candidates[4]; + int8_t corners[4] = {-1, -1, -1, -1}; 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 auto extent = [](int radius8) { return (std::max(0, radius8) + 7) / 8; }; 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) candidates[count++] = {left, top, left + tlW - 1, top + tlH - 1}; - if (trW && trH) candidates[count++] = {right - trW + 1, top, right, top + trH - 1}; - if (brW && brH) candidates[count++] = {right - brW + 1, bottom - brH + 1, right, bottom}; - if (blW && blH) candidates[count++] = {left, bottom - blH + 1, left + blW - 1, bottom}; + if (tlW && tlH) { corners[count] = 0; candidates[count++] = {left, top, left + tlW - 1, top + tlH - 1}; } + if (trW && trH) { corners[count] = 1; candidates[count++] = {right - trW + 1, top, right, top + trH - 1}; } + if (brW && brH) { corners[count] = 2; candidates[count++] = {right - brW + 1, bottom - brH + 1, right, bottom}; } + if (blW && blH) { corners[count] = 3; candidates[count++] = {left, bottom - blH + 1, left + blW - 1, bottom}; } } else { @@ -2325,19 +2508,46 @@ int gLastScrollUiFrame = -1000; clipY0 = std::max(clipY0, 0); clipX1 = std::min(clipX1, canvas->width() - 1); clipY1 = std::min(clipY1, canvas->height() - 1); - const int stride = canvas->strideBytes() / static_cast(sizeof(gea::framework::graphics::pixel::native_t)); + if (region) + { + // Only the dirty rect repaints, so only its pixels 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; - for (int y = box.y0; y <= box.y1; ++y) + 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. + if (total <= static_cast(kMaxSavedPixels)) + { + entry.maskable = exactlyAxisAligned(shape) && !transformedRoundedRectIsRing(shape) && + !transformedRoundedRectIsPillLike(shape) && cornersDisjoint(shape); + const int stride = canvas->strideBytes() / static_cast(sizeof(gea::framework::graphics::pixel::native_t)); + for (int i = 0; i < boxCount; ++i) { - const auto *row = canvas->pixels() + canvas->rowToPhysical(y) * stride; - saved().insert(saved().end(), row + box.x0, row + box.x1 + 1); + const Box &box = boxes[i]; + for (int y = box.y0; y <= box.y1; ++y) + { + const auto *row = canvas->pixels() + canvas->rowToPhysical(y) * stride; + saved().insert(saved().end(), row + box.x0, row + box.x1 + 1); + } + entry.corner[entry.boxCount] = boxCorners[i]; + entry.boxes[entry.boxCount++] = box; } - entry.boxes[entry.boxCount++] = box; } } entries().push_back(entry); @@ -2352,22 +2562,40 @@ int gLastScrollUiFrame = -1000; 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, bottom = r.ly + r.lh - 1; 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; for (int y = box.y0; y <= box.y1; ++y) { auto *row = canvas->pixels() + canvas->rowToPhysical(y) * stride; const float py = static_cast(y) + 0.5f - static_cast(r.y0); + // Row of the mask: from the top edge for the top corners, from the + // bottom edge for the bottom ones. + const int my = corner < 2 ? y - r.ly : bottom - y; for (int x = box.x0; x <= box.x1; ++x, ++index) { - 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); + int coverage; + if (mask) + { + const int mx = (corner == 0 || corner == 3) ? x - r.lx : right - x; + coverage = mask->coverage[static_cast(my) * static_cast(mask->w) + static_cast(mx)]; + } + else + { + 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; + coverage = samples >= 2 + ? roundedRectCoverageFast(r, localX, localY, entry.inv00, entry.inv10, entry.inv01, entry.inv11, samples) + : (roundedRectContainsFast(r, localX, localY) ? 1 : 0); + } if (coverage >= sampleCount) continue; const auto outside = saved()[index]; @@ -2380,6 +2608,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 void begin(const DisplayCommand &) {} + 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) { @@ -5524,6 +5777,7 @@ int gLastScrollUiFrame = -1000; 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; @@ -5532,6 +5786,7 @@ int gLastScrollUiFrame = -1000; c->clip.lx += clipDx; c->clip.ly += clipDy; } +#endif break; } case DisplayCommandType::FillRect: @@ -8417,7 +8672,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; @@ -8429,6 +8689,8 @@ 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]; @@ -8437,9 +8699,10 @@ int gLastScrollUiFrame = -1000; continue; gea::platform::display::Display::pushClip(shape.x, shape.y, shape.w, shape.h); if (shape.shaped) - ShapedClips::begin(shape); + ShapedClips::reconcile(shapedBase, shapedCount++, id, shape, region); pushed++; } + ShapedClips::settle(shapedBase, shapedCount); return pushed; } @@ -8966,7 +9229,8 @@ int gLastScrollUiFrame = -1000; return; const std::size_t shapedMark = ShapedClips::depth(); - const int pushedClips = pushNodeReplayClips(node_id, nodes, nodeCount); + 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); @@ -8979,7 +9243,9 @@ int gLastScrollUiFrame = -1000; replayCommandRange(start, end, x0, y0, x1, y1, roundedRects); if (stateChanged) roundedRects.flush(); - ShapedClips::endTo(shapedMark); + // 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) @@ -9024,6 +9290,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) @@ -9042,6 +9309,7 @@ int gLastScrollUiFrame = -1000; continue; replayNodeCommandRange(node_id, x0, y0, x1, y1, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); gea::platform::display::Display::popClip(); return true; @@ -9074,6 +9342,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 @@ -9108,6 +9377,7 @@ int gLastScrollUiFrame = -1000; continue; replayNodeCommandRange(node_id, x0, y0, x1, y1, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); gea::platform::display::Display::popClip(); return true; @@ -9143,6 +9413,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++) @@ -9168,6 +9439,7 @@ int gLastScrollUiFrame = -1000; if (node_id < kMaxNodes) treeSt.nodeInBackdrop[node_id] = 1; } + shapedClipPass.finish(); roundedRects.flush(); gea::platform::display::Display::popClip(); @@ -9478,6 +9750,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 @@ -9510,6 +9783,7 @@ int gLastScrollUiFrame = -1000; replayNodeCommandRange(node_id, x0, y0, x1, y1, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); gea::platform::display::Display::popClip(); } @@ -9667,7 +9941,18 @@ int gLastScrollUiFrame = -1000; return; const std::size_t shapedMark = ShapedClips::depth(); - const int pushedClips = pushNodeReplayClips(node_id, nodes, nodeCount); + // 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); @@ -9708,7 +9993,9 @@ int gLastScrollUiFrame = -1000; } if (stateChanged || alphaTouched) roundedRects.flush(); - ShapedClips::endTo(shapedMark); + // 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) @@ -9828,6 +10115,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 @@ -9877,6 +10165,7 @@ int gLastScrollUiFrame = -1000; replayNodeCommandRangeInRegions(node_id, regions, count, roundedRects); } + shapedClipPass.finish(); roundedRects.flush(); } @@ -11405,6 +11694,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]; @@ -11417,6 +11707,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 e69aa6e..c6fd10c 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2819,9 +2819,22 @@ bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) // 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{x, y, w, h, false, {}, {}, 0, 0, 0, 0, {}, {}}; - // The padding edge is rounded with the outer radii less the border widths. + 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; + // 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]{}; @@ -2833,8 +2846,7 @@ bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) 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; } - // A clip open on one axis stays a rectangle; a transformed one clips nothing. - if (!overflowX(s) || !overflowY(s) || (!transformed && !rounded)) return !transformed; + if (!transformed && !rounded) return true; out.lx = static_cast(x); out.ly = static_cast(y); out.lw = static_cast(w); @@ -2859,6 +2871,7 @@ bool ViewRenderer::overflowClipShape(const Node &node, OverflowClipShape &out) } out.shaped = true; return true; +#endif } bool ViewRenderer::recordClipBegin(const Node &node) @@ -2878,6 +2891,7 @@ bool ViewRenderer::recordClipBegin(const Node &node) 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]; @@ -2888,6 +2902,7 @@ bool ViewRenderer::recordClipBegin(const Node &node) cmd->clip.ly = shape.ly; cmd->clip.lw = shape.lw; cmd->clip.lh = shape.lh; +#endif } return 1; }