From 021d0c09039530a022c4387aa3f7d6983bdb48e7 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/46] 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 c03a8fc..dc4dff9 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -439,13 +439,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 b98c25c9de1791e3b67fa96c3b707b8d59cde284 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/46] 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 00789f0..f49c3a2 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -29,6 +29,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 ead3ec8..4f783a4 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -7540,19 +7540,19 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::MarginLeft: if (rstyle(style).margin_expression[3] >= 0) rstyleMut(style).margin_expression[3] = -1; style.margin_auto &= ~8; style.margin[3] = value; return true; case Property::Position: if (value == kPositionFixed) treeState().fixedPositionUsed = true; style.position = value; return true; case Property::Top: - style.pos_offsets[0] = value; + style.pos_offsets[0] = storedPositionOffset(value); style.pos_offset_percent[0] = kUnset; return true; case Property::Right: - style.pos_offsets[1] = value; + style.pos_offsets[1] = storedPositionOffset(value); style.pos_offset_percent[1] = kUnset; return true; case Property::Bottom: - style.pos_offsets[2] = value; + style.pos_offsets[2] = storedPositionOffset(value); style.pos_offset_percent[2] = kUnset; return true; case Property::Left: - style.pos_offsets[3] = value; + style.pos_offsets[3] = storedPositionOffset(value); style.pos_offset_percent[3] = kUnset; return true; case Property::TopPercent: diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 0b56502..4e042f7 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -400,29 +400,29 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::MarginLeft: if (rstyle(n->style).margin_expression[3] >= 0) { rstyleMut(n->style).margin_expression[3] = -1; changed = 1; } if (n->style.margin[3] != value || (n->style.margin_auto & 8)) { n->style.margin[3] = value; n->style.margin_auto &= ~8; changed = 1; } break; case Property::Position: if (value == kPositionFixed) state.fixedPositionUsed = true; if (n->style.position != value) { n->style.position = value; changed = 1; } break; case Property::Top: - if (n->style.pos_offsets[0] != value || n->style.pos_offset_percent[0] != kUnset) { - n->style.pos_offsets[0] = value; + if (n->style.pos_offsets[0] != storedPositionOffset(value) || n->style.pos_offset_percent[0] != kUnset) { + n->style.pos_offsets[0] = storedPositionOffset(value); n->style.pos_offset_percent[0] = kUnset; changed = 1; } break; case Property::Right: - if (n->style.pos_offsets[1] != value || n->style.pos_offset_percent[1] != kUnset) { - n->style.pos_offsets[1] = value; + if (n->style.pos_offsets[1] != storedPositionOffset(value) || n->style.pos_offset_percent[1] != kUnset) { + n->style.pos_offsets[1] = storedPositionOffset(value); n->style.pos_offset_percent[1] = kUnset; changed = 1; } break; case Property::Bottom: - if (n->style.pos_offsets[2] != value || n->style.pos_offset_percent[2] != kUnset) { - n->style.pos_offsets[2] = value; + if (n->style.pos_offsets[2] != storedPositionOffset(value) || n->style.pos_offset_percent[2] != kUnset) { + n->style.pos_offsets[2] = storedPositionOffset(value); n->style.pos_offset_percent[2] = kUnset; changed = 1; } break; case Property::Left: - if (n->style.pos_offsets[3] != value || n->style.pos_offset_percent[3] != kUnset) { - n->style.pos_offsets[3] = value; + if (n->style.pos_offsets[3] != storedPositionOffset(value) || n->style.pos_offset_percent[3] != kUnset) { + n->style.pos_offsets[3] = storedPositionOffset(value); n->style.pos_offset_percent[3] = kUnset; changed = 1; } From 2331810ce54eab222c2c552e3bc5eaa46bd2b58c 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/46] 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 dc4dff9..e3af2ce 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1707,7 +1707,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) 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.style.margin[2] + probe.layout.height - baseline); @@ -1742,7 +1742,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; } @@ -1777,10 +1777,23 @@ class FlexLayoutPass { return inlineRow_ ? 5 : node_.style.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) { 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_) != ((node_.style.flex_wrap & 3) == 2); if (reversed) align = align == 2 ? 6 : 2; From 1319e4c0f4c0d3cf2a7b612f92e55f739d194d15 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/46] 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 e3af2ce..0d6035e 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -896,14 +896,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 @@ -971,6 +972,7 @@ class FlexLayoutPass { for (int id : trailingStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(continuedBaseline - inheritedAscent); + position.lineHeight = 0; position.valid = true; } } @@ -990,6 +992,7 @@ class FlexLayoutPass { for (int id : trailingStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(lineTop); + position.lineHeight = 0; position.valid = true; } } @@ -1007,6 +1010,7 @@ class FlexLayoutPass { for (int id : emptyStaticChildren) { auto &position = ensureRareData(id).inlineStaticPosition; position.y = clampInt16(contentTop); + position.lineHeight = 0; position.valid = true; } } @@ -3662,8 +3666,20 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod } if (cursor >= 0) continuationOrigin = boxInset(treeNodes[cursor].style, 3) - inlineOffset; } - return (horizontal ? rare->inlineStaticPosition.x + continuationOrigin + childNode.style.margin[3] - : rare->inlineStaticPosition.y + childNode.style.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; } if (parent.style.display == kDisplayBlock && !parent.style.flex_direction_explicit && childNode.layout.static_block_axis == (horizontal ? 2 : 1)) { diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index 5772778..7939118 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -99,6 +99,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 b15d092e99fc882054980052b755da5acba128b6 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/46] 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 4f783a4..f5ea95b 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -16164,19 +16164,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] = std::max(node.style.border_width, rstyle(node.style).border_side_width[i]) + - (out.origin == 2 ? std::max(0, node.style.padding[i]) : 0); + for (int i = 0; i < 4; ++i) inset[i] = std::max(node->style.border_width, rstyle(node->style).border_side_width[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 97ad6f362030837e6e41bd93533ae51b15d094ba 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/46] 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 f5ea95b..393df44 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -16146,9 +16146,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 90c6d828ec3cb41ef04844fe8ad40d3952f35536 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/46] 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 fdedaed..3d14711 100644 --- a/packages/engine/canvas.cpp +++ b/packages/engine/canvas.cpp @@ -3756,57 +3756,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 d028e1077fa84d084cdcc7b490013889ae32a509 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/46] Support background-clip: border-area for background colors The keyword was rejected, so the declaration was dropped and the color filled the whole border box. Clip value 4 now paints the color along the border's own geometry: the same stroke for a uniform width (radii included), the side rectangles otherwise. Gradient layers still ignore background-clip, as they already did for padding-box and content-box. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/style.cpp | 2 +- packages/engine/ui/view.cpp | 157 ++++++++++++++++++++--------------- 2 files changed, 92 insertions(+), 67 deletions(-) diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 393df44..1c3d8ee 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -6550,7 +6550,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 6e9db53..d5068d3 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -673,9 +673,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.style, 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); @@ -701,13 +701,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.style, 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.style, 0); + cmd->stroke.color = color; } } @@ -715,6 +715,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]; @@ -1528,10 +1533,74 @@ void appendReliefBordersWithAlpha(const Node &node, uint8_t parentAlpha) } } -void appendSideBordersWithAlpha(const Node &node, uint8_t parentAlpha) +void appendBorderSideWithAlpha(const Node &node, int side, uint16_t color, uint8_t alpha, uint8_t parentAlpha, bool transformed) { const int x = node.layout.x; const int y = node.layout.y; + const int w = node.layout.width; + const int h = node.layout.height; + const int borderWidth = std::max(node.style.border_width, rstyle(node.style).border_side_width[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(std::max(node.style.border_width, rstyle(node.style).border_side_width[0]), h); + const int bottom = std::min(std::max(node.style.border_width, rstyle(node.style).border_side_width[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; } @@ -1540,68 +1609,22 @@ void appendSideBordersWithAlpha(const Node &node, uint8_t parentAlpha) if (w <= 0 || h <= 0 || (!hasSideBorder(node.style) && !borderColorsDiffer(node.style) && !textClippedTransform)) return; const bool transformed = ViewGeometry::hasTransformChain(node, false); - for (int side = 0; side < 4; ++side) { - const int borderWidth = std::max(node.style.border_width, rstyle(node.style).border_side_width[side]); - const auto color = borderPaintColor(node.style, side); - const auto alpha = borderPaintAlpha(node.style, 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(std::max(node.style.border_width, rstyle(node.style).border_side_width[0]), h); - const int bottom = std::min(std::max(node.style.border_width, rstyle(node.style).border_side_width[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) @@ -2496,6 +2519,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 e5fec083cf8c28122b439cf4ea92a817420db551 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/46] Support text-align-last and ignore edge spaces when aligning lines text-align-last is a new inherited style field, stored in ComputedStyle padding so the struct keeps its size. A DrawText command now carries it: the drawer applies it to lines a forced break ends and, when LayoutEngine::endsFormattingLine says the run closes its paragraph (a
, a block box or the container end follows), to the run's final line. Aligning a line also stops counting the collapsible spaces that hang at its end and the leading space the inline flow trims from line 0. Both put right- and center-aligned text a space off before. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/input_render.cpp | 1 + packages/engine/ui/internal.h | 9 ++- packages/engine/ui/layout.cpp | 23 ++++++++ packages/engine/ui/node_lifecycle.cpp | 1 + packages/engine/ui/node_model.h | 3 + packages/engine/ui/render.cpp | 3 +- packages/engine/ui/style.cpp | 32 ++++++++++- packages/engine/ui/style.h | 4 +- packages/engine/ui/text.cpp | 76 ++++++++++++++++++++----- packages/engine/ui/tree_style.cpp | 1 + packages/geatsc-plugin-gea/src/utils.ts | 14 +++++ 11 files changed, 148 insertions(+), 19 deletions(-) diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index 146bc5b..659bcac 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -167,6 +167,7 @@ void InputRenderer::record(int id) cmd->text.textOverflow = n.style.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 df14394..9c618bf 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 { @@ -452,6 +456,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. @@ -533,7 +540,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 0d6035e..48813f5 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3809,6 +3809,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.style.display == kDisplayNone || n.style.display_explicit) return false; diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index 7f23e57..b4be039 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -88,6 +88,7 @@ void NodeLifecycle::init(Node *n, NodeType type) n->style.line_height = 0; n->style.line_height_multiplier = -1; n->style.text_align = 0; + n->style.text_align_last = 0; n->style.text_decoration = 0; n->style.text_transform = 0; n->style.white_space = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index f49c3a2..fcc1ae6 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -353,6 +353,9 @@ struct ComputedStyle { int16_t blink_interval_ms; int32_t blink_started_ms; uint8_t blink_visible; + // 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; int16_t border_width; style_color_t border_color; diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 927fe21..fe99b4c 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6369,7 +6369,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 1c3d8ee..021f965 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -559,6 +559,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 || @@ -661,6 +662,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 || @@ -1133,6 +1135,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; @@ -4663,6 +4666,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 @@ -6824,6 +6836,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; @@ -7702,6 +7717,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) if (rstyle(style).line_height_expression >= 0) rstyleMut(style).line_height_expression = -1; style.line_height = resolveLineHeightMultiplier(static_cast(&target - treeState().nodes), value); return true; case Property::TextAlign: style.text_align = value; return true; + case Property::TextAlignLast: style.text_align_last = value; return true; case Property::TextDecoration: style.text_decoration = value; return true; case Property::TextTransform: style.text_transform = value; return true; case Property::WhiteSpace: style.white_space = static_cast(value); return true; @@ -8547,7 +8563,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); @@ -8571,6 +8587,7 @@ void applyInheritedStyleDefaults(int node) if (parentStyle.line_height_multiplier >= 0) style.line_height_multiplier = parentStyle.line_height_multiplier; style.text_align = parentStyle.text_align; + style.text_align_last = parentStyle.text_align_last; style.text_transform = parentStyle.text_transform; style.white_space = parentStyle.white_space; style.visibility = parentStyle.visibility; @@ -9227,6 +9244,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; @@ -10252,6 +10272,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; @@ -10765,6 +10786,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}); @@ -12953,6 +12975,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); @@ -13847,6 +13870,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; @@ -14947,6 +14971,7 @@ struct ParentStyleSnapshot { std::int16_t line_height; std::int32_t line_height_multiplier; std::uint8_t text_align; + std::uint8_t text_align_last; std::uint8_t text_transform; std::uint8_t white_space; std::uint8_t visibility; @@ -14958,7 +14983,8 @@ ParentStyleSnapshot snapshotParentStyle(const ComputedStyle &s) return ParentStyleSnapshot{s.text_color, s.text_alpha, static_cast(s.font_id), static_cast(s.font_size), static_cast(s.font_weight), static_cast(s.line_height), s.line_height_multiplier, - static_cast(s.text_align), static_cast(s.text_transform), + static_cast(s.text_align), static_cast(s.text_align_last), + static_cast(s.text_transform), static_cast(s.white_space), static_cast(s.visibility), {computedBorderWidth(s, 0), computedBorderWidth(s, 1), computedBorderWidth(s, 2), computedBorderWidth(s, 3)}}; } @@ -14967,7 +14993,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 aa1f967..7f41031 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 7d702f4..c90dfc6 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1076,7 +1076,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 @@ -1126,7 +1126,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; @@ -1151,16 +1151,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; } } @@ -1168,14 +1171,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 @@ -1187,12 +1190,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. @@ -1261,7 +1268,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; @@ -1300,7 +1309,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; @@ -1392,7 +1403,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); @@ -1411,11 +1422,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; @@ -1507,6 +1521,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 @@ -1875,9 +1906,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, @@ -1906,6 +1937,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; @@ -2227,6 +2267,15 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph } + const int8_t alignLast = transformed ? -1 : textAlignLastCommand(*n); + // The bounds above assume text-align for every line; realigned lines may + // sit anywhere across the content box. + if (alignLast >= 0) { + const int right = std::max(bx + bw, drawX + containerW + 2); + bx = std::min(bx, drawX - 2); + bw = right - bx; + } + const uint8_t effectiveAlpha = combineAlpha(parentAlpha, n->style.text_alpha); if (effectiveAlpha != parentAlpha) appendAlphaCommand(effectiveAlpha, bx, by, bw, bh); @@ -2250,6 +2299,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.whiteSpace = n->style.white_space; cmd->text.textOverflow = n->style.text_overflow; cmd->text.firstLineIndent = static_cast(inlineIndent); + cmd->text.alignLast = alignLast; { const int contentH = h - boxInset(n->style, 0) - boxInset(n->style, 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 4e042f7..74d5361 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -669,6 +669,7 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn } 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; case Property::TextDecoration: if (n->style.text_decoration != value) { n->style.text_decoration = value; changed = 1; } break; case Property::TextTransform: if (n->style.text_transform != value) { n->style.text_transform = value; changed = 1; } break; case Property::WhiteSpace: if (n->style.white_space != static_cast(value)) { n->style.white_space = static_cast(value); changed = 1; } break; 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 9e371a2aac324f8553814fc3a40482e6fb4439c9 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/46] 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 | 231 ++++++++++++++++++++++++- 5 files changed, 272 insertions(+), 40 deletions(-) diff --git a/packages/engine/ui/layout_snapshot.cpp b/packages/engine/ui/layout_snapshot.cpp index 62d4a4a..b07506e 100644 --- a/packages/engine/ui/layout_snapshot.cpp +++ b/packages/engine/ui/layout_snapshot.cpp @@ -61,6 +61,7 @@ void LayoutSnapshot::capture() state.nodes[i].render.previous_perspective_origin_y = rs.perspective_origin_y; } state.nodes[i].render.previous_filter_blur_radius = rs.filter_blur_radius; + 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; state.nodes[i].render.scroll_dirty = 0; diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index b4be039..15e9911 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -152,6 +152,7 @@ void NodeLifecycle::init(Node *n, NodeType type) n->render.previous_perspective_origin_y = 500; n->render.previous_transformable_box = 0; n->render.previous_filter_blur_radius = 0; + n->render.previous_box_shadow_extent = 0; n->render.inline_baseline = 0; n->render.dirty = 0; n->render.scroll_dirty = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index fcc1ae6..8376b0b 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -714,6 +714,17 @@ inline bool hasSideBorder(const ComputedStyle &style) r.border_side_width[3] > 0; } +// 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); @@ -848,6 +859,9 @@ struct RenderState { // 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; + // 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 021f965..3242035 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4819,7 +4819,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; @@ -4837,6 +4839,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; @@ -4856,18 +4859,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; @@ -4877,8 +4882,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); @@ -4894,7 +4899,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) @@ -6144,6 +6149,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; @@ -6170,26 +6176,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); @@ -7366,12 +7362,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); @@ -10181,7 +10172,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 d5068d3..3c484b8 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2204,11 +2204,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 = rstyle(node.style).box_shadow_color; - const auto alpha = rstyle(node.style).box_shadow_alpha; if (!ViewGeometry::hasTransformChain(node, false)) { appendFillRectWithAlpha(x, y, w, h, color, alpha, parentAlpha, x, y, w, h); return; @@ -2250,7 +2249,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) { @@ -2275,6 +2274,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 (!rstyle(node.style).box_shadow_inset || rstyle(node.style).box_shadow_alpha == 0) return; @@ -2343,6 +2561,12 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_transformed_bounds") ViewRenderer: int rotate = use_prev ? n->render.previous_transform_rotate : rstyle(n->style).transform_rotate; auto expandForBlur = [&]() { + // An outer box-shadow paints beyond the box as well. + const int shadow = use_prev ? n->render.previous_box_shadow_extent : boxShadowExtent(n->style); + *x0 -= shadow; + *y0 -= shadow; + *x1 += shadow; + *y1 += shadow; const int radius = use_prev ? n->render.previous_filter_blur_radius : rstyle(n->style).filter_blur_radius; if (radius <= 0) return; const int extentX = std::max(1, radius) * 5; @@ -2510,6 +2734,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB } } if (w <= 0 || h <= 0) return; + recordOuterBoxShadow(*n, parentAlpha); if (n->style.has_bg) { // The color is the bottom layer; image longhands never replace it. From 9d42214b8309c5b1e166aa05231972fec3455517 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/46] 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 fe99b4c..11eaa64 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -5445,6 +5445,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 10788065237aaef4d5f421027e099f032d0324e5 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/46] 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 | 2 + packages/engine/ui/node_model.h | 12 ++ packages/engine/ui/render.cpp | 6 +- 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, 325 insertions(+), 18 deletions(-) diff --git a/packages/engine/ui/input.cpp b/packages/engine/ui/input.cpp index 4eaf7c5..ebb923a 100644 --- a/packages/engine/ui/input.cpp +++ b/packages/engine/ui/input.cpp @@ -441,7 +441,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 || 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 @@ -480,7 +480,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 || 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 659bcac..e5de46c 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -168,6 +168,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 9c618bf..386cd26 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 { @@ -540,7 +542,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 48813f5..465f2cf 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -300,6 +300,112 @@ bool hasExplicitHeight(const Node &node) !layoutSizeExpressionUsesPercentage(static_cast(&node - Tree::instance().nodes()), node.style.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; @@ -3982,6 +4088,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. @@ -4011,6 +4118,7 @@ void LayoutEngine::repositionChildren(int id) 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 15e9911..673297f 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -114,6 +114,8 @@ void NodeLifecycle::init(Node *n, NodeType type) n->layout.width = 0; n->layout.height = 0; n->layout.inline_indent = 0; + n->layout.line_clamp_hidden = 0; + n->layout.line_clamp_lines = 0; n->layout.scroll_x = 0; n->layout.scroll_y = 0; n->layout.scroll_content_width = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 8376b0b..bb599de 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -271,6 +271,12 @@ struct RareStyle { uint16_t bg_grid_step_y = 0; uint8_t bg_grid_line_x = 0; uint8_t bg_grid_line_y = 0; + // 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 @@ -754,9 +760,15 @@ struct LayoutBox { // and absolute-coordinate conversion; retained refresh reuses this anchor. int16_t static_block_start = 0; uint8_t static_block_axis = 0; // 0: unavailable, 1: y, 2: x + // 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 11eaa64..bfee1d7 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6390,7 +6390,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 @@ -6660,7 +6660,7 @@ int gLastScrollUiFrame = -1000; return; } Node *n = &Tree::instance().nodes()[id]; - if (n->style.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; @@ -12275,7 +12275,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 && !isCollapsedFlexSubtree(*n) && !ViewRenderer::backfaceSubtreeHidden(*n) && + 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) && diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 3242035..d85e349 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -388,6 +388,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 || @@ -587,6 +589,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 || @@ -1088,6 +1092,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; @@ -6590,6 +6602,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)); @@ -7077,6 +7172,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; @@ -7482,6 +7591,16 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::Float: style.float_side = value; return true; case Property::AspectRatio: rstyleMut(style).aspect_ratio = value; return true; case Property::MarginTrim: rstyleMut(style).margin_trim = value; return true; + 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; + } case Property::Clear: style.clear_side = value; return true; case Property::WritingMode: style.writing_mode = value; return true; case Property::Direction: style.direction = value; return true; @@ -8836,6 +8955,20 @@ bool applyKnownResolvedPropertyWithSource(NodeHandle node, CssDeclarationId decl if (value == "left") setStyleValue(node, Property::Float, 1, source); if (value == "right") setStyleValue(node, Property::Float, 2, source); return true; + 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" @@ -10716,6 +10849,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 7f41031..633d819 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 c90dfc6..9fafd74 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1076,7 +1076,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 @@ -1126,7 +1126,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; @@ -1163,7 +1163,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; } } @@ -1173,12 +1173,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 @@ -1256,7 +1256,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, @@ -1291,7 +1291,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, @@ -1403,7 +1403,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); @@ -1417,7 +1417,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; @@ -1906,9 +1906,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, @@ -2300,6 +2300,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.textOverflow = n->style.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->style, 0) - boxInset(n->style, 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 74d5361..a43e8d3 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -80,6 +80,11 @@ bool isLayoutProperty(Property prop) case Property::BoxSizing: case Property::Float: case Property::MarginTrim: + case Property::MaxLines: + case Property::LineClampContinue: + case Property::BlockEllipsis: + case Property::ColumnCountSet: + case Property::ColumnWidthSet: case Property::Clear: case Property::WritingMode: case Property::Direction: @@ -305,6 +310,16 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::Containment: if (rstyle(n->style).containment != value) { rstyleMut(n->style).containment = value; changed = 1; } break; case Property::FlexLineCount: if (rstyle(n->style).flex_line_count != value) { rstyleMut(n->style).flex_line_count = value; changed = 1; } break; case Property::MarginTrim: if (rstyle(n->style).margin_trim != value) { rstyleMut(n->style).margin_trim = value; changed = 1; } break; + 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; + } case Property::Clear: if (n->style.clear_side != value) { n->style.clear_side = value; changed = 1; } break; case Property::Direction: if (n->style.direction != value) { n->style.direction = value; changed = 1; } break; case Property::WritingMode: if (n->style.writing_mode != value) { n->style.writing_mode = value; changed = 1; } break; 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 92efa1e955defd5212c8a8ad9aecf930e607a468 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/46] Support vertical-align keywords in inline formatting vertical-align was not parsed, so every inline item sat on the baseline. One-line inline items now shift for middle, text-top, text-bottom, sub and super, and top/bottom items align to the line box edges. As in CSS, non-replaced inline boxes align by their line-height box, which differs from Gea's layout box when the line-height is below the font size, and a line holding aligned items is sized from the block's strut and every item's reach, negative ones included. The strut is only applied to lines with aligned items for now: applying it everywhere is correct CSS but would lengthen lines of small inline text that current layouts rely on. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/internal.h | 4 + packages/engine/ui/layout.cpp | 99 ++++++++++++++++++++++++- packages/engine/ui/node_lifecycle.cpp | 1 + packages/engine/ui/node_model.h | 3 + packages/engine/ui/style.cpp | 22 ++++++ packages/engine/ui/style.h | 4 +- packages/engine/ui/text.cpp | 19 +++++ packages/engine/ui/tree_style.cpp | 2 + packages/geatsc-plugin-gea/src/utils.ts | 14 ++++ 9 files changed, 163 insertions(+), 5 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 386cd26..d71b5c9 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -526,6 +526,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 465f2cf..15ddf50 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -881,6 +881,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++) { @@ -987,12 +989,46 @@ class FlexLayoutPass { j, m.firstLineTrailingSpace}); } } - const int ascent = cn.style.margin[0] + childBaseline(cn); - const int descent = cn.layout.height + cn.style.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) @@ -1249,6 +1285,9 @@ 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]; + 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.style.margin[0]; @@ -1949,6 +1988,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.style.font_id >= 0) diff --git a/packages/engine/ui/node_lifecycle.cpp b/packages/engine/ui/node_lifecycle.cpp index 673297f..df3c496 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -89,6 +89,7 @@ void NodeLifecycle::init(Node *n, NodeType type) n->style.line_height_multiplier = -1; n->style.text_align = 0; n->style.text_align_last = 0; + n->style.vertical_align = 0; n->style.text_decoration = 0; n->style.text_transform = 0; n->style.white_space = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index bb599de..ea97a95 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -353,6 +353,9 @@ struct ComputedStyle { // linear/overlay/radial gradients + background-grid moved to RareStyle (rare). style_color_t active_bg_color; int8_t has_active_bg; + // 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; uint8_t text_alpha; uint8_t opacity; diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index d85e349..5c087af 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -562,6 +562,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 || @@ -667,6 +668,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 || @@ -1148,6 +1150,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; @@ -4678,6 +4681,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) @@ -6930,6 +6941,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; @@ -7828,6 +7842,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) style.line_height = resolveLineHeightMultiplier(static_cast(&target - treeState().nodes), value); return true; 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; case Property::TextDecoration: style.text_decoration = value; return true; case Property::TextTransform: style.text_transform = value; return true; case Property::WhiteSpace: style.white_space = static_cast(value); return true; @@ -9371,6 +9386,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; @@ -10397,6 +10415,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; @@ -10919,6 +10938,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}); @@ -13108,6 +13128,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); @@ -14003,6 +14024,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 633d819..e3c8d6e 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 9fafd74..21dce8a 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1737,6 +1737,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.style.font_size > 0 ? node.style.font_size : 16; diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index a43e8d3..85df795 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -85,6 +85,7 @@ bool isLayoutProperty(Property prop) case Property::BlockEllipsis: case Property::ColumnCountSet: case Property::ColumnWidthSet: + case Property::VerticalAlign: case Property::Clear: case Property::WritingMode: case Property::Direction: @@ -685,6 +686,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; case Property::TextDecoration: if (n->style.text_decoration != value) { n->style.text_decoration = value; changed = 1; } break; case Property::TextTransform: if (n->style.text_transform != value) { n->style.text_transform = value; changed = 1; } break; case Property::WhiteSpace: if (n->style.white_space != static_cast(value)) { n->style.white_space = static_cast(value); changed = 1; } break; 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 000c073b1a65d68a7304b67d45c9c8c2150fecdd 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/46] 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 | 5 ++ packages/engine/ui/style.cpp | 1 + packages/engine/ui/tree_state.h | 4 ++ packages/geatsc-plugin-gea/src/utils.ts | 2 + 7 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index d71b5c9..32655e4 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -448,6 +448,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 15ddf50..9aca941 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3551,7 +3551,8 @@ bool establishesTransformContainingBlock(const Node &node) bool establishesAbsoluteContainingBlock(const Node &node) { - return isOutOfFlowPosition(node.style.position) || node.style.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) @@ -4326,7 +4327,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]; @@ -4335,6 +4404,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; @@ -4342,9 +4415,15 @@ void LayoutEngine::resolveAbsoluteCoords(int id, int parent_x, int parent_y) if (scrollsOverflowX(node->style)) childParentX -= node->layout.scroll_x; if (scrollsOverflowY(node->style)) 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 ea97a95..d8ef6e3 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -75,6 +75,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 5b5b4bf..ebb1d95 100644 --- a/packages/engine/ui/root_scroll_refresh.cpp +++ b/packages/engine/ui/root_scroll_refresh.cpp @@ -485,6 +485,11 @@ int RootScrollOnlyRefresh::refresh(int root, int width, int height) auto &state = treeState(); auto &perf = refreshPerfStatsMutable(); 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 5c087af..39c984b 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4671,6 +4671,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 7939118..680353c 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -276,6 +276,10 @@ struct TreeState { // old mode-gate regression was that re-scan, not the gate itself). A cached `true` // is NOT held here — it still re-scans per refreshSerial so a removal is seen. bool transformScanValid = false; + // 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 2a285deab8c915795a171117fdd719294c6973ea 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/46] 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 | 147 ++++++++++++++++++++++++++++++++-- 1 file changed, 141 insertions(+), 6 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 9aca941..c59e777 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -620,6 +620,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) @@ -926,7 +928,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; @@ -974,8 +976,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.style, 1) + boxInset(subject.style, 3); const int rest = contentW - marginL - marginR - padH; @@ -1242,6 +1251,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). @@ -1268,19 +1359,23 @@ class FlexLayoutPass { const int marginL = cn.style.margin[3]; const int marginR = cn.style.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); } @@ -2126,7 +2221,8 @@ bool splitInlineWrapper(const Node *nodes, int id) const Node &child = nodes[c]; if (isDisplayNone(child.style)) continue; if (isOutOfFlowPosition(child.style.position)) return false; - if (child.style.float_side) continue; + // 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; } @@ -2350,9 +2446,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 062a5f0cbe76c2d7de176380242284b24b2a1d43 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/46] 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 ebb923a..294e0b5 100644 --- a/packages/engine/ui/input.cpp +++ b/packages/engine/ui/input.cpp @@ -441,7 +441,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 @@ -480,7 +480,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 e5de46c..17b3894 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -169,6 +169,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 32655e4..b9ac495 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 { @@ -549,7 +557,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 c59e777..b608872 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -306,6 +306,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; @@ -314,6 +315,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; }; @@ -353,6 +356,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); @@ -372,13 +381,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; @@ -390,6 +402,7 @@ void walkLineClamp(LineClampWalk &walk, int parent, int originY) continue; } walk.keptBottom = std::max(walk.keptBottom, bottom); + walk.lastRun = -1; } } @@ -401,6 +414,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); @@ -2206,6 +2222,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 d8ef6e3..ab23664 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -143,6 +143,9 @@ inline bool isNativeButtonNodeType(NodeType type) struct RareStyle { // Logical edges: block-start/end, inline-start/end. uint8_t margin_trim = 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; // IEEE float bits travel through the existing integer style-value transport. // Negative ratios mean `auto ` (prefer a replaced element's natural ratio). int32_t aspect_ratio = 0; @@ -765,8 +768,10 @@ struct LayoutBox { // and absolute-coordinate conversion; retained refresh reuses this anchor. int16_t static_block_start = 0; uint8_t static_block_axis = 0; // 0: unavailable, 1: y, 2: x - // 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 bfee1d7..d48c614 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6390,7 +6390,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 @@ -6660,7 +6661,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; @@ -12275,7 +12276,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 39c984b..6438375 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -390,6 +390,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 || @@ -592,6 +593,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 || @@ -6614,23 +6616,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())); @@ -6651,8 +6658,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; @@ -6660,7 +6668,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; @@ -6673,8 +6682,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. @@ -7187,7 +7197,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) { @@ -7607,6 +7617,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::AspectRatio: rstyleMut(style).aspect_ratio = value; return true; case Property::MarginTrim: rstyleMut(style).margin_trim = value; return true; 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: @@ -8979,7 +8990,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); @@ -10870,10 +10881,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 e3c8d6e..1a05aa7 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 21dce8a..178ed77 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 @@ -1076,7 +1077,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 @@ -1126,13 +1128,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; @@ -1149,8 +1155,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; @@ -1160,10 +1166,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; } } @@ -1171,14 +1177,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 @@ -1255,8 +1321,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, @@ -1308,6 +1382,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; @@ -1403,7 +1484,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); @@ -1425,6 +1525,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]); @@ -1925,9 +2031,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, @@ -1956,6 +2062,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) { @@ -2295,6 +2416,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); @@ -2320,6 +2451,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->style, 0) - boxInset(n->style, 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 85df795..8e80c34 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -85,6 +85,7 @@ bool isLayoutProperty(Property prop) case Property::BlockEllipsis: case Property::ColumnCountSet: case Property::ColumnWidthSet: + case Property::BlockEllipsisString: case Property::VerticalAlign: case Property::Clear: case Property::WritingMode: @@ -312,6 +313,7 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::FlexLineCount: if (rstyle(n->style).flex_line_count != value) { rstyleMut(n->style).flex_line_count = value; changed = 1; } break; case Property::MarginTrim: if (rstyle(n->style).margin_trim != value) { rstyleMut(n->style).margin_trim = value; changed = 1; } break; 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 cc57d96a865194fc3dc4bb8a384d1ce880c5010b 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/46] 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 | 313 +++++++++++++++++++++++++++++++--- packages/engine/ui/view.cpp | 135 ++++++++++++++- 3 files changed, 439 insertions(+), 34 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index b9ac495..25cf7fb 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 { @@ -485,9 +497,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 d48c614..db2b3ee 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -1507,6 +1507,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; @@ -1649,7 +1676,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; @@ -1850,7 +1877,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); @@ -1881,6 +1908,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, @@ -1915,6 +1949,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); @@ -1957,6 +1993,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) { @@ -1981,13 +2019,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); @@ -4457,6 +4679,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) { @@ -4508,7 +4732,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)) @@ -4674,7 +4900,9 @@ int gLastScrollUiFrame = -1000; r.blRx8, r.blRy8, sx, - sy) + sy) && + (!transformedRoundedRectIsRing(r) || + !roundedRectOuterContainsFast(transformedRoundedRectHole(r), static_cast(sx), static_cast(sy))) ? 1 : 0; } @@ -4741,9 +4969,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) @@ -4924,10 +5154,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->style)) c->clip.x += dx; - else c->bx -= dx; - if (!owner || overflowY(owner->style)) 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: @@ -7435,7 +7675,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; @@ -7444,7 +7685,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) @@ -7484,12 +7726,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; @@ -8009,6 +8251,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; @@ -8022,6 +8265,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) @@ -8347,10 +8591,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) { @@ -8426,10 +8671,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) { @@ -8643,6 +8889,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; @@ -8684,6 +8931,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) @@ -8857,6 +9105,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; @@ -8977,8 +9231,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); @@ -8987,16 +9243,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 3c484b8..5013ea4 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -1627,6 +1627,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; @@ -1684,8 +1692,7 @@ void recordLinearGradientBackground(const Node &node, uint8_t parentAlpha) // span rasterizer paints the first/last edgeWidth px of every span, tracing // the quad outline). Emitting projected stroke quads instead would disarm // the transform-reproject fast path every frame. - if (!StyleValues::hasTextBackgroundClip(node.style) && node.style.border_width > 0 && !hasBorderRelief(node.style) && !hasSideBorder(node.style) && !borderColorsDiffer(node.style) && borderPaintAlpha(node.style, 0) > 0 && - !isFullyRoundedShape(node)) { + 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; @@ -1769,6 +1776,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}; @@ -1897,6 +1905,55 @@ void recordTransformedRoundedRectFill(const Node &node, uint8_t parentAlpha, int r.blRx8 = rx8[3]; r.blRy8 = ry8[3]; 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(std::max(node.style.border_width, rstyle(node.style).border_side_width[i]), 1, 255)); } if (effectiveAlpha != parentAlpha) appendAlphaCommand(parentAlpha, bx0, by0, bx1 - bx0 + 1, by1 - by0 + 1); @@ -2642,23 +2699,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->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; } @@ -2735,6 +2849,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB } if (w <= 0 || h <= 0) return; recordOuterBoxShadow(*n, parentAlpha); + bool borderOnGradientEdge = false; if (n->style.has_bg) { // The color is the bottom layer; image longhands never replace it. @@ -2772,6 +2887,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB if (n->style.bg_fill == 1) { TextBackgroundClipScope textClip(*n, StyleValues::backgroundClip(n->style, rstyle(n->style).bg_gradient_layer)); recordLinearGradientBackground(*n, parentAlpha); + borderOnGradientEdge = ViewGeometry::hasTransformChain(*n, false) && gradientEdgeCarriesBorder(*n); } { TextBackgroundClipScope textClip(*n, StyleValues::backgroundClip(n->style, rstyle(n->style).bg_radial_gradient_layer)); @@ -2791,6 +2907,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB if (n->style.border_width > 0 && !hasBorderRelief(n->style) && !hasSideBorder(n->style) && !borderColorsDiffer(n->style) && !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 146b753ea84a7bdf4f515e468ab60caf62185085 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/46] Keep opaque glyphs out of the background-clip: text mask The text-clip mask painted the background with glyph coverage and the text then painted over it with the same coverage, so the background bled through every partly covered edge pixel. Glyphs with no opacity between them and the clip owner paint over that background completely; leaving them out of the mask removes the fringe and matches CSS, where no part of such a background shows. Transparent and translucent text keep showing the background as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/render.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index db2b3ee..52ab3b9 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6454,17 +6454,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.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}); } @@ -6482,7 +6489,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 26f45fc702ca0191b76565c3a40427dc2600209c 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/46] 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 | 154 +++++++++++++++++++++++++++++- packages/engine/ui/node_model.h | 11 ++- packages/engine/ui/render.cpp | 122 +++++++++++++++++++++-- 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, 371 insertions(+), 26 deletions(-) diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 25cf7fb..81f48e0 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -484,6 +484,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 b608872..a8b5cd2 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -617,8 +617,12 @@ bool flexRowDirection(const ComputedStyle &style) return !style.flex_direction_explicit || (style.flex_direction & 1); } +bool isMulticolContainer(const Node &node); + int resolvedGap(const Node &node, bool rowGap, int width, int height) { + // A multicol container's column-gap separates its column boxes, not its content. + if (isMulticolContainer(node)) return 0; const int length = rowGap ? node.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; @@ -2222,6 +2226,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) { @@ -2313,6 +2340,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(); @@ -2377,6 +2412,9 @@ class LayoutNodePass { if (node_.type == NodeType::Text || node_.type == NodeType::Image) return; invalidateInlineChildMetadata(); if (NodeRareData *rare = rareDataFor(id_)) rare->firstLineBackground.lineValid = false; + // 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(); @@ -2423,6 +2461,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 @@ -2545,7 +2687,7 @@ class LayoutNodePass { 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) || + 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]))); } @@ -4166,6 +4308,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(); diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index ab23664..577e342 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -143,6 +143,8 @@ inline bool isNativeButtonNodeType(NodeType type) struct RareStyle { // Logical edges: block-start/end, inline-start/end. uint8_t margin_trim = 0; + // 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; @@ -235,6 +237,8 @@ struct RareStyle { // NodeLifecycle::init: alphas 255, stops 500/1000, angle 1800, radial // center/extent 500/1000) --- 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; uint16_t bg_gradient_layer = 0, bg_overlay_gradient_layer = 0, bg_radial_gradient_layer = 0; @@ -277,9 +281,10 @@ struct RareStyle { uint8_t bg_grid_line_x = 0; uint8_t bg_grid_line_y = 0; // 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 52ab3b9..63929b4 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -2028,6 +2028,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 @@ -6459,7 +6484,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; @@ -6468,7 +6493,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; @@ -6479,7 +6504,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) { @@ -6489,7 +6514,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) @@ -6506,7 +6531,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; @@ -6892,6 +6917,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)) @@ -7067,6 +7154,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 @@ -7095,6 +7197,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--; @@ -9306,8 +9410,10 @@ int gLastScrollUiFrame = -1000; 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)); + // 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)); + (!n.style.z_index_auto && (n.style.position != 0 || item)) || LayoutEngine::multicolContainer(n); } bool PaintOrder::isGroup(int id) @@ -9483,6 +9589,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++) @@ -10100,6 +10207,9 @@ int gLastScrollUiFrame = -1000; return false; if (nodes[i].style.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 6438375..0e07901 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -391,6 +391,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 || @@ -594,6 +596,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 || @@ -1104,6 +1108,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; @@ -6653,7 +6659,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"); @@ -6688,22 +6695,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; } @@ -7618,10 +7650,15 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::MarginTrim: rstyleMut(style).margin_trim = value; return true; 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); @@ -8989,7 +9026,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); @@ -10883,11 +10922,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 1a05aa7..11cdd29 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 680353c..9f27aa0 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -106,6 +106,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; std::string value; @@ -179,6 +192,7 @@ struct NodeRareData { FirstLineBackground firstLineBackground; FirstLineFragment firstLineFragment; InlineStaticPosition inlineStaticPosition; + MulticolLayout multicol; void clear() { @@ -195,6 +209,7 @@ struct NodeRareData { firstLineBackground = FirstLineBackground{}; firstLineFragment = FirstLineFragment{}; inlineStaticPosition = InlineStaticPosition{}; + multicol = MulticolLayout{}; } }; diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index 8e80c34..80f7983 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::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: case Property::Clear: case Property::WritingMode: @@ -314,10 +319,15 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn case Property::MarginTrim: if (rstyle(n->style).margin_trim != value) { rstyleMut(n->style).margin_trim = value; changed = 1; } break; 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 737d93b106ffec0b10eaf6987cd845c210f09dcd 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/46] 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 | 10 ++++- packages/engine/ui/style.cpp | 70 ++++++++++++++++++++++++++++--- packages/engine/ui/style.h | 5 +++ packages/engine/ui/tree_style.cpp | 2 + packages/engine/ui/view.cpp | 2 +- 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 577e342..790ee61 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -220,6 +220,8 @@ struct RareStyle { // only relevant when a side width > 0, which is rare) --- int16_t border_side_width[4] = {}; // 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). uint8_t border_relief[4] = {}; // Bits 0..3: explicit side colors; bit 4: literal common color; // bits 5..8: explicit side currentColor. Unspecified colors use currentColor. @@ -551,6 +553,9 @@ inline int computedBorderWidth(const ComputedStyle &style, int side) return specific > style.border_width ? specific : style.border_width; } +// 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) { int widths[4]; @@ -558,6 +563,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. @@ -752,7 +759,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); } struct LayoutBox { diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index 0e07901..4ae965f 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -1143,6 +1143,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; @@ -6694,6 +6699,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 @@ -7232,6 +7274,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; @@ -7812,12 +7855,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; + } case Property::BorderTopWidth: setComputedBorderWidth(style, 0, value, target.parent >= 0 ? &treeState().nodes[target.parent].style : nullptr); return true; case Property::BorderRightWidth: setComputedBorderWidth(style, 1, value, target.parent >= 0 ? &treeState().nodes[target.parent].style : nullptr); return true; case Property::BorderBottomWidth: setComputedBorderWidth(style, 2, value, target.parent >= 0 ? &treeState().nodes[target.parent].style : nullptr); return true; @@ -8412,9 +8458,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; - setUniformBorderWidth(node, 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); + setUniformBorderWidth(node, width, source); for (const auto &part : parts) { if (part.empty()) continue; if (part[0] == '#' || part.find("rgb") != std::string::npos || toLowerAscii(part) == "transparent") { @@ -9028,10 +9075,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; } @@ -10230,9 +10283,9 @@ bool applyRuntimeBorderShorthandValue(NodeHandle node, Node &target = state.nodes[nodeId]; const int snappedWidth = resolveBorderWidth(width, nodeId); if (snappedWidth < 0) return true; - setUniformBorderWidth(node, snappedWidth, source); for (int side = 0; side < 4; ++side) setStyleValue(node, static_cast(static_cast(Property::BorderTopRelief) + side), relief, source); + setUniformBorderWidth(node, snappedWidth, source); if (alpha >= 0) { setStyleValue(node, Property::BorderColor, color, source); setStyleValue(node, Property::BorderAlpha, alpha, source); @@ -10963,6 +11016,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}); @@ -13560,8 +13618,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 11cdd29..06658d0 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 80f7983..152f850 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -586,6 +586,8 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn rstyleMut(n->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; case Property::BorderRightWidth: changed |= setComputedBorderWidth(n->style, 1, value, n->parent >= 0 ? &state.nodes[n->parent].style : nullptr); break; diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 5013ea4..7a6a56b 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -1499,7 +1499,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]; + const int relief = rstyle(node.style).border_relief[side] & ~kBorderStyleNone; const int bands = relief == 1 || relief == 2 ? 2 : 1; const auto color = borderPaintColor(node.style, side); int r, g, b; pixel::unpackRgb565(color, &r, &g, &b); From 330c3b53f8f80832190901844908db5932576d62 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/46] 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 4ae965f..d30b521 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -1026,10 +1026,96 @@ 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; 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 || @@ -9311,6 +9397,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: @@ -10879,6 +10988,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) { NodeRareData *rare = rareDataFor(node.id()); if (!rare) return false; diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index 06658d0..65ebcbb 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 8347666b817a3e9503ccfd0d58ad8114905a14b3 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/46] 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 | 2 +- packages/engine/ui/internal.h | 2 ++ packages/engine/ui/layout.cpp | 45 +++++++++++++++++++++---- packages/engine/ui/node_model.h | 4 ++- packages/engine/ui/style.cpp | 13 ++++--- packages/engine/ui/text.cpp | 24 +++++++------ packages/geatsc-plugin-gea/src/utils.ts | 5 +-- 7 files changed, 71 insertions(+), 24 deletions(-) diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index 17b3894..21f0a3e 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -156,7 +156,7 @@ void InputRenderer::record(int id) cmd->text.maxWidth = contentW; cmd->text.color = color; cmd->text.scale = textScale; - cmd->text.align = n.style.text_align; + 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; diff --git a/packages/engine/ui/internal.h b/packages/engine/ui/internal.h index 81f48e0..9cff61b 100644 --- a/packages/engine/ui/internal.h +++ b/packages/engine/ui/internal.h @@ -488,6 +488,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 a8b5cd2..668477b 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1084,8 +1084,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 (i == 0 && captureFirstLine) { int firstFormattedLineHeight = actualLineCross; if (!firstLineCandidates.empty()) firstFormattedLineHeight = 0; @@ -1114,7 +1125,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. @@ -1363,8 +1374,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 = @@ -1387,7 +1408,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; @@ -1417,12 +1438,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; } } } @@ -4318,6 +4340,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 790ee61..fdb7274 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -392,7 +392,7 @@ struct ComputedStyle { int16_t font_size; int16_t font_weight; int16_t line_height; - int8_t text_align; + int8_t text_align; // 0 start, 1 center, 2 right, 3 left, 4 end int8_t overflow; int8_t overflow_x; int8_t overflow_y; @@ -893,6 +893,8 @@ 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. + // 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; // boxShadowExtent at the last snapshot, so a moved or restyled shadow // invalidates the pixels it used to cover. Sits in trailing padding. diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index d30b521..bd1d095 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4774,10 +4774,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; } @@ -4789,12 +4793,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 178ed77..9d2cb55 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -733,7 +733,7 @@ bool recordProjectedRasterText(const Node &node, const char *text, uint8_t /*par 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 srcX = tx + alignedOffset(node.style.text_align, tw, lineWidth); + const int srcX = tx + alignedOffset(LayoutEngine::physicalTextAlign(node), tw, lineWidth); const int srcY = ty + lineBoxOffset; const int srcW = lineWidth; const int srcH = fontLineHeight; @@ -1730,7 +1730,7 @@ void Tree::setText(int node, const char *text) // "San Francisco" -> "Lisbon" then dirtied only the new, shorter run (or even a // sliver at the box edge), leaving the old trailing glyphs on the physical panel. const bool gid_canPartial = gid_singleLine && target.style.white_space == 1 && - target.style.text_align == 0 && gid_prefix < gid_newLen; + LayoutEngine::physicalTextAlign(target) == 0 && gid_prefix < gid_newLen; int gid_preW = 0; int gid_rightW = 0; if (gid_canPartial) { @@ -2081,7 +2081,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)); } @@ -2161,6 +2161,10 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph } + // 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; @@ -2253,7 +2257,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph int iy1 = -1; if (rasterizedTextInkBounds(measureText, drawW, - n->style.text_align, + textAlign, containerW, n->style.font_id, commandFontSize, @@ -2325,7 +2329,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->style.text_align, + textAlign, containerW, n->style.font_id, commandFontSize, @@ -2333,7 +2337,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph } } #endif - const int xOffset = alignedOffset(n->style.text_align, containerW, paintWidth); + const int xOffset = alignedOffset(textAlign, containerW, paintWidth); int paintX0 = drawX + xOffset; int paintY0 = drawY; int paintX1 = paintX0 + paintWidth - 1; @@ -2346,7 +2350,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph int iy1 = -1; if (rasterizedTextInkBounds(measureText, noSoftWrap ? 32767 : drawW, - n->style.text_align, + textAlign, containerW, n->style.font_id, commandFontSize, @@ -2388,7 +2392,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph int inkOff = 0; #ifdef GEA_EMBEDDED_HAS_GENERATED_FONTS const int genFonts = 1; - inkOff = rasterizedSingleLineInkCenterOffsetY(measureText, n->style.text_align, containerW, + inkOff = rasterizedSingleLineInkCenterOffsetY(measureText, textAlign, containerW, n->style.font_id, dbgFontSize, commandLineHeight); #else const int genFonts = 0; @@ -2407,7 +2411,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) { @@ -2441,7 +2445,7 @@ void GEA_TEXT_HOT_SRAM TextRenderer::record(const Node &node, uint8_t parentAlph cmd->text.maxWidth = drawW; cmd->text.color = n->style.text_color; cmd->text.scale = textScale; - cmd->text.align = n->style.text_align; + cmd->text.align = static_cast(textAlign); cmd->text.textTransform = n->style.text_transform; cmd->text.lineHeight = commandLineHeight; cmd->text.containerWidth = containerW; 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 14053eafc509c401a2e5cbaf3b33d2d9d423b8ac 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/46] 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 668477b..ab2a73a 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -618,6 +618,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) { @@ -936,7 +937,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 @@ -1009,7 +1011,8 @@ class FlexLayoutPass { const int padH = boxInset(subject.style, 1) + boxInset(subject.style, 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, @@ -1374,6 +1377,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) { @@ -2291,9 +2302,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; } @@ -2401,6 +2416,7 @@ class LayoutNodePass { encloseFloatDescendants(); updateScrollContentSize(); positionAbsoluteChildren(); + positionFragmentedInlineChildren(); applyRelativeOffsets(); return; } @@ -2463,6 +2479,7 @@ class LayoutNodePass { if (layoutFloatChildren(scratch.children(), childCount, padWidth, padHeight, false)) { updateScrollContentSize(); positionAbsoluteChildren(); + positionFragmentedInlineChildren(); applyRelativeOffsets(); return; } @@ -3752,6 +3769,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 80aa24518c1d8426b26e297b4a5ed8dda2af4ecf 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/46] 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 | 7 ++ packages/engine/ui/render.cpp | 2 +- packages/engine/ui/style.cpp | 85 ++++++++++++++++++- packages/engine/ui/style.h | 8 ++ packages/engine/ui/text.cpp | 118 +++++++++++++++++++++++--- packages/engine/ui/tree_style.cpp | 13 +++ 9 files changed, 229 insertions(+), 15 deletions(-) diff --git a/packages/engine/ui/input_render.cpp b/packages/engine/ui/input_render.cpp index 21f0a3e..b88c4b4 100644 --- a/packages/engine/ui/input_render.cpp +++ b/packages/engine/ui/input_render.cpp @@ -171,6 +171,8 @@ void InputRenderer::record(int id) cmd->text.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 9cff61b..00e49bc 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 { @@ -588,7 +592,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 df3c496..52efca1 100644 --- a/packages/engine/ui/node_lifecycle.cpp +++ b/packages/engine/ui/node_lifecycle.cpp @@ -89,6 +89,8 @@ void NodeLifecycle::init(Node *n, NodeType type) n->style.line_height_multiplier = -1; n->style.text_align = 0; n->style.text_align_last = 0; + n->style.text_emphasis = 0; + n->style.text_emphasis_color = 0; n->style.vertical_align = 0; n->style.text_decoration = 0; n->style.text_transform = 0; diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index fdb7274..c71f80a 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -322,6 +322,9 @@ struct ComputedStyle { int8_t align_content; int8_t align_self; int16_t gap; + // 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 int8_t float_side; // 0: none, 1: left, 2: right int8_t clear_side; // 0: none, 1: left, 2: right, 3: both @@ -362,6 +365,10 @@ struct ComputedStyle { int8_t has_bg; uint8_t bg_alpha; int8_t bg_fill; + // 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). style_color_t active_bg_color; int8_t has_active_bg; diff --git a/packages/engine/ui/render.cpp b/packages/engine/ui/render.cpp index 63929b4..67120b8 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -6663,7 +6663,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 bd1d095..b998cf7 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -565,6 +565,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 || @@ -674,6 +676,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 || @@ -1249,6 +1253,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; @@ -6827,6 +6835,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 @@ -7366,6 +7427,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; @@ -8028,6 +8090,10 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) style.line_height = resolveLineHeightMultiplier(static_cast(&target - treeState().nodes), value); return true; 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; case Property::TextDecoration: style.text_decoration = value; return true; case Property::TextTransform: style.text_transform = value; return true; @@ -8876,6 +8942,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); @@ -8900,6 +8968,8 @@ void applyInheritedStyleDefaults(int node) style.line_height_multiplier = parentStyle.line_height_multiplier; 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; style.text_transform = parentStyle.text_transform; style.white_space = parentStyle.white_space; style.visibility = parentStyle.visibility; @@ -9171,11 +9241,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; } @@ -11187,6 +11262,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}); @@ -15375,6 +15454,8 @@ struct ParentStyleSnapshot { std::int32_t line_height_multiplier; std::uint8_t text_align; std::uint8_t text_align_last; + std::uint8_t text_emphasis; + style_color_t text_emphasis_color; std::uint8_t text_transform; std::uint8_t white_space; std::uint8_t visibility; @@ -15387,6 +15468,7 @@ ParentStyleSnapshot snapshotParentStyle(const ComputedStyle &s) static_cast(s.font_size), static_cast(s.font_weight), static_cast(s.line_height), s.line_height_multiplier, static_cast(s.text_align), static_cast(s.text_align_last), + s.text_emphasis, s.text_emphasis_color, static_cast(s.text_transform), static_cast(s.white_space), static_cast(s.visibility), {computedBorderWidth(s, 0), computedBorderWidth(s, 1), computedBorderWidth(s, 2), computedBorderWidth(s, 3)}}; @@ -15397,6 +15479,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 65ebcbb..edf3a34 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 9d2cb55..7942032 100644 --- a/packages/engine/ui/text.cpp +++ b/packages/engine/ui/text.cpp @@ -1078,7 +1078,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 @@ -1128,7 +1128,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; @@ -1136,9 +1136,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; @@ -1155,7 +1157,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; @@ -1190,8 +1192,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). @@ -1266,6 +1341,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. @@ -1323,7 +1405,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, @@ -1340,7 +1422,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; @@ -1353,6 +1435,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; @@ -1365,7 +1448,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, @@ -1389,7 +1472,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)); @@ -1401,6 +1484,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; @@ -1550,6 +1634,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; @@ -2031,9 +2117,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, @@ -2423,6 +2509,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); @@ -2457,6 +2549,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->style, 0) - boxInset(n->style, 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 152f850..6477e00 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -700,6 +700,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; case Property::TextDecoration: if (n->style.text_decoration != value) { n->style.text_decoration = value; changed = 1; } break; case Property::TextTransform: if (n->style.text_transform != value) { n->style.text_transform = value; changed = 1; } break; From fd123d78ab4c7bac451438a676c9d6707f6e3232 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/46] 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 | 1 + packages/engine/ui/view.cpp | 28 +- 9 files changed, 355 insertions(+), 33 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 00e49bc..bf68e56 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 c71f80a..6943b71 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -255,6 +255,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 67120b8..bd2018f 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -5438,6 +5438,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; @@ -5687,6 +5802,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); @@ -5709,6 +5880,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. @@ -5805,31 +5981,8 @@ int gLastScrollUiFrame = -1000; if (transCacheable && transCache.blit(transKey, 14, x0, y0, x1, y1, scrW, scrH, transSpan)) return; - 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(); @@ -6295,6 +6448,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 @@ -6517,6 +6685,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; @@ -6535,10 +6736,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); @@ -6560,9 +6763,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 b998cf7..9fed88b 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -463,6 +463,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 || @@ -1212,6 +1213,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; @@ -2297,10 +2299,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(); } @@ -6712,6 +6721,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; @@ -7422,6 +7456,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); @@ -7823,6 +7865,7 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::FlexWrap: style.flex_wrap = value; return true; case Property::FlexLineCount: rstyleMut(style).flex_line_count = value; return true; 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; @@ -9164,6 +9207,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" @@ -11206,6 +11257,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}); @@ -16654,6 +16706,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 edf3a34..445c7b7 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 50b2bc0..498d820 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 6477e00..cddf111 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -503,6 +503,7 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn 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; diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index 7a6a56b..57c2488 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -283,10 +283,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; @@ -313,6 +328,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; } @@ -332,7 +348,8 @@ DisplayCommand *appendLinearGradientRectRaw(const Node &node, int x, int y, int 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); + 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) @@ -361,6 +378,7 @@ DisplayCommand *appendRadialGradientRectRaw(const Node &node, int x, int y, int 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; } @@ -1739,7 +1757,8 @@ void recordOverlayLinearGradientBackground(const Node &node) 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); + 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 @@ -1798,7 +1817,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 8075a8ccdae3bd82f72557eefc19df127ac234d9 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/46] 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/node_model.h | 7 + packages/engine/ui/style.cpp | 298 +++++++++++++++++++- 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 ++++++ 8 files changed, 438 insertions(+), 8 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/node_model.h b/packages/engine/ui/node_model.h index 6943b71..79ba719 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -250,9 +250,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. @@ -262,11 +266,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 9fed88b..7c8af4d 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -464,6 +464,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 || @@ -1214,6 +1219,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; @@ -1882,6 +1893,9 @@ std::uint16_t storeDirectPropertyCompiledValue(Property property, int value) return static_cast(list.size() - 1); } +// CssCompiledValue::values holds the count and up to five property/value pairs. +constexpr int kDirectPropertyGroupCapacity = 5; + std::uint16_t storeDirectPropertyGroupCompiledValue(std::initializer_list properties) { auto &list = compiledCssValues(); @@ -1890,7 +1904,7 @@ std::uint16_t storeDirectPropertyGroupCompiledValue(std::initializer_list= 4) break; + if (count >= kDirectPropertyGroupCapacity) break; compiled.values[1 + count * 2] = static_cast(entry.property); compiled.values[2 + count * 2] = entry.value; count++; @@ -2305,11 +2319,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(); } @@ -6871,6 +6899,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; @@ -7465,11 +7705,12 @@ 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; @@ -7866,6 +8107,11 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) case Property::FlexLineCount: rstyleMut(style).flex_line_count = value; return true; 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; @@ -9296,12 +9542,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; } @@ -10732,7 +10985,7 @@ bool applyCompiledCssValueWithSource(NodeHandle node, const CssCompiledValue &co case CssCompiledKind::DirectPropertyGroup: { int count = compiled.values[0]; 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[1 + i * 2]; if (propertyIndex < 0 || propertyIndex >= static_cast(Property::Count)) return false; @@ -11258,6 +11511,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}); @@ -14377,7 +14638,7 @@ bool addCompiledValueWrites(const CssCompiledValue &compiled, PropertyWriteMask case CssCompiledKind::DirectPropertyGroup: { int count = compiled.values[0]; 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[1 + i * 2]; if (propertyIndex < 0 || propertyIndex >= static_cast(Property::Count)) return false; @@ -16706,6 +16967,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 445c7b7..a486840 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 498d820..4ce2d60 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 dcc65cc..62077ee 100644 --- a/packages/engine/ui/tree_render.cpp +++ b/packages/engine/ui/tree_render.cpp @@ -7,6 +7,7 @@ #include "layout_snapshot.h" #include "refresh_perf.h" #include "root_scroll_refresh.h" +#include "style_values.h" #include "tree_state.h" #include @@ -260,7 +261,7 @@ namespace gea::embedded::ui return true; if (node.style.mask_right_fade_width > 0) return true; - if (rstyle(node.style).box_shadow_alpha > 0) + if (rstyle(node.style).box_shadow_alpha > 0 || rstyle(node.style).border_image_source) return true; return node.style.has_bg && (styleHasRoundedRasterEdge(node.style) || node.style.bg_alpha < 255); } @@ -282,7 +283,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 cddf111..ab81ae9 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -504,6 +504,11 @@ void setStyleValue(Tree &tree, int node, Property prop, int value, bool recordIn } 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 57c2488..b7a441c 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2843,6 +2843,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; @@ -2923,6 +3015,8 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB } recordInsetBoxShadow(*n, parentAlpha); + // A painted border image replaces the border styles. + if (recordBorderImage(*n)) return; if (n->style.border_width > 0 && !hasBorderRelief(n->style) && !hasSideBorder(n->style) && !borderColorsDiffer(n->style) && !borderIsSameOpaqueSolidBackground(*n, parentAlpha)) { if (ViewGeometry::hasTransformChain(*n, false)) { From b1825783e9e102f39e6471f8208df00c1d759de2 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/46] 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 | 109 +++++++++++++++++++++++++++----- packages/engine/ui/tree_state.h | 9 +++ packages/engine/ui/view.cpp | 29 +++++++++ 3 files changed, 130 insertions(+), 17 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index ab2a73a..c685505 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2315,6 +2315,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; @@ -2377,7 +2395,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(); @@ -2997,6 +3018,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.style.float_side) { @@ -3007,6 +3056,15 @@ class LayoutNodePass { end < count && !blockBox(nodes_[children[end]]) && !nodes_[children[end]].style.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) { @@ -3016,7 +3074,7 @@ class LayoutNodePass { }; NodeRareData *ownerRare = rareDataFor(id_); const bool ownerLineWasValid = ownerRare && ownerRare->firstLineBackground.lineValid; - 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. @@ -3031,20 +3089,20 @@ class LayoutNodePass { minimum += item.style.margin[1] + item.style.margin[3]; if (item.type == NodeType::Text) minimum += boxInsets(item.style, 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 (!ownerLineWasValid && ownerRare && ownerRare->firstLineBackground.lineValid) ownerRare->firstLineBackground.lineY = clampInt16(ownerRare->firstLineBackground.lineY + flowY); for (int j = i; j < end; ++j) { @@ -3055,18 +3113,24 @@ 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); } } } } 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.style.clear_side & f.side) flowY = std::max(flowY, f.bottom - originY); @@ -3075,7 +3139,14 @@ class LayoutNodePass { i = end - 1; continue; } - engine_.layoutNode(children[i], contentWidth, contentHeight, measureWidth); + 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) && @@ -3083,7 +3154,7 @@ class LayoutNodePass { // Ordinary blocks still fill their containing block beside floats; // only their line boxes are shortened by float exclusions. if (automaticWidth) { - const int width = clampLayoutSize(child, std::max(0, contentWidth - child.style.margin[1] - child.style.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]); @@ -3114,11 +3185,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.style.float_side || child.style.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) { @@ -3134,7 +3205,7 @@ class LayoutNodePass { // Automatic width uses the remaining interval. Even a // zero-width float prevents a negative margin crossing it. const int start = hasLeft ? std::max(child.style.margin[3], left) : child.style.margin[3]; - const int end = hasRight ? std::min(contentWidth - child.style.margin[1], right) : contentWidth - child.style.margin[1]; + 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); @@ -3154,13 +3225,15 @@ class LayoutNodePass { y = nextY; } } - const int x = child.style.float_side == 2 ? right - outerW : independentBlock ? contextX : left; + // In right-to-left flow a block that does not fill the line hugs the right. + const int x = (child.style.float_side == 2 || (block && rtl && !independentBlock) ? right - outerW : independentBlock ? contextX : left) - + (splitBlock ? outer[3] : 0); intrinsicWidth = std::max(intrinsicWidth, child.style.float_side ? left + outerW + contentWidth - right : measuredOuterW); child.layout.x = boxInset(node_.style, 3) + x + child.style.margin[3]; child.layout.y = boxInset(node_.style, 0) + y + (block ? 0 : child.style.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()) { @@ -3217,6 +3290,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 9f27aa0..8c76d26 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -110,6 +110,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; @@ -193,6 +200,7 @@ struct NodeRareData { FirstLineFragment firstLineFragment; InlineStaticPosition inlineStaticPosition; MulticolLayout multicol; + std::vector inlineParts; void clear() { @@ -210,6 +218,7 @@ struct NodeRareData { firstLineFragment = FirstLineFragment{}; inlineStaticPosition = InlineStaticPosition{}; multicol = MulticolLayout{}; + inlineParts.clear(); } }; diff --git a/packages/engine/ui/view.cpp b/packages/engine/ui/view.cpp index b7a441c..3b4f241 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -2843,6 +2843,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 @@ -2960,6 +2988,7 @@ void GEA_VIEW_HOT_SRAM_SECTION("view_renderer_record_box") ViewRenderer::recordB } } if (w <= 0 || h <= 0) return; + if (recordInlineParts(*n, parentAlpha)) return; recordOuterBoxShadow(*n, parentAlpha); bool borderOnGradientEdge = false; From 9b7648152a993548b6d5afaf3029c3268a766704 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/46] 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 | 37 +++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index c685505..d07b857 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -1847,13 +1847,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.style.flex <= 0) continue; lastGrowChild = child; - const int change = (delta * childNode.style.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; @@ -2865,6 +2871,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_.style.display != kDisplayBlock || node_.style.flex_direction_explicit) return false; @@ -2939,6 +2958,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) { @@ -2958,6 +2981,16 @@ 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; + 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 877eb2e4504658a70b031b6983cb65cd52bbbe15 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/46] Establish block formatting contexts for flow-root and align-content display: flow-root now keeps a flag next to the block display kind, and a block container with a non-normal align-content becomes a BFC root as CSS Align 3 requires. Both contain floats and child margins and avoid outer floats. An automatic-width BFC beside floats now tries the widest layout opportunity at each position first and only narrows as its height meets more floats. Before, a box whose height follows its width (aspect-ratio) could flip between two widths forever. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 21 +++++++++++++++------ packages/engine/ui/node_model.h | 13 ++++++++++++- packages/engine/ui/style.cpp | 5 +++-- packages/engine/ui/tree_style.cpp | 8 ++++++-- 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index d07b857..45a7b44 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2750,10 +2750,13 @@ class LayoutNodePass { !nodes_[child].style.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]))); } @@ -3221,13 +3224,19 @@ class LayoutNodePass { int left = 0, right = area, contextX = 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 + // 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); @@ -3240,22 +3249,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.style.margin[1] + child.style.margin[3], contentHeight); child.layout.width = clampInt16(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]; - if (previousHeight != child.layout.height) continue; } + if (child.layout.height > extent) { extent = child.layout.height; continue; } } fits = child.layout.width <= end - start; } if (fits || nextY == 32767) break; y = nextY; + if (searchWidth) extent = 1; } } // In right-to-left flow a block that does not fill the line hugs the right. diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 79ba719..8f65753 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -74,6 +74,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; @@ -319,7 +325,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. + // flex_direction_explicit. Other bits: kDisplayFlowRoot. int8_t display_explicit; int8_t flex_direction; int8_t flex_direction_explicit; @@ -693,6 +699,11 @@ inline bool isDisplayGrid(const ComputedStyle &style) return 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 7c8af4d..0660361 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4695,6 +4695,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; } @@ -8096,8 +8097,8 @@ bool setClassRuleValueFastUnchecked(Node &target, Property property, int value) ComputedStyle &style = target.style; switch (property) { case Property::Display: - style.display_explicit = 1; - style.display = value; + style.display_explicit = kDisplayExplicit | (value >> kDisplayFlagShift); + style.display = value & kDisplayKindMask; return true; case Property::FlexDirection: style.flex_direction_explicit = 1; diff --git a/packages/engine/ui/tree_style.cpp b/packages/engine/ui/tree_style.cpp index ab81ae9..6365f6d 100644 --- a/packages/engine/ui/tree_style.cpp +++ b/packages/engine/ui/tree_style.cpp @@ -298,8 +298,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 (!n->style.display_explicit) { n->style.display_explicit = 1; changed = 1; } - if (n->style.display != value) { n->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; case Property::FlexDirection: if (!n->style.flex_direction_explicit) { From 1b788fcfcf82765bf5c2338cebfb0818d98cb566 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/46] 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 0660361..78f435a 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -3761,26 +3761,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) @@ -5587,37 +5622,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 7fca60a0ffa271e289ae5603739616f13574cfd7 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/46] 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 45a7b44..e83c774 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -317,7 +317,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) @@ -363,6 +383,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); @@ -370,7 +396,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); @@ -380,29 +407,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; } } @@ -2751,11 +2798,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 d0a7d488930890e8ae959669d93b1365c63e68c4 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/46] Lay out inline-flex and inline-grid as atomic inline-level boxes inline-flex and inline-grid were plain flex and grid containers, so they filled the line and stood on their own. They now carry an inline flag next to the box kind and sit on the line like an image: their automatic width is fit-content, measured the way floats are, and their own aspect-ratio applies. Floated and absolutely positioned ones are blockified. blockLevelView() now answers whether a view is block-level in one place. The static style compilers emit the same display values as the engine (inline-flex 67, inline-grid 66, flow-root 32), so a declaration means the same whether it is resolved at build time or at runtime. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../core/scripts/build-gea-vite-geatsc.mjs | 3 +- packages/engine/ui/layout.cpp | 34 ++++++++++++------- packages/engine/ui/node_model.h | 10 +++++- packages/engine/ui/style.cpp | 6 ++-- packages/geatsc-plugin-gea/src/utils.ts | 7 ++-- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index 170a049..5ceb955 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -2043,7 +2043,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 e83c774..35482ee 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -572,7 +572,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; @@ -1500,7 +1500,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; } @@ -2341,6 +2342,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. @@ -2362,7 +2371,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; @@ -2381,7 +2390,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; } @@ -2787,8 +2796,7 @@ class LayoutNodePass { bool blockBox(const Node &n) const { - return n.type == NodeType::View && !isDisplayNone(n.style) && - (n.style.display_explicit || !isInlineLevelTag(tagFromId(n.tag_id))); + return !isDisplayNone(n.style) && blockLevelView(n); } bool flowChild(int parent, int child) const @@ -4595,8 +4603,9 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod bool LayoutEngine::isInlineLevelNode(const Node &n) { - if (n.style.display == kDisplayFlex || n.style.display == kDisplayGrid || n.style.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. @@ -4720,10 +4729,11 @@ void LayoutEngine::layoutNode(int id, int avail_w, int avail_h, bool intrinsicBo { Node &node = Tree::instance().nodes()[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.style.float_side && + // Horizontal non-replaced floats and atomic inline boxes (inline-flex) use + // the same min/max-content measurement and final used-width phase as + // fit-content. Flex and grid items are blockified, so they are left alone; + // orthogonal automatic inline sizing is handled separately. + const bool shrinkFloat = node.type == NodeType::View && (node.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 && diff --git a/packages/engine/ui/node_model.h b/packages/engine/ui/node_model.h index 8f65753..cfd87cb 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -80,6 +80,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; @@ -325,7 +326,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; int8_t flex_direction_explicit; @@ -704,6 +705,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 78f435a..bb6f281 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4728,8 +4728,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 cef148daf3623e9914c5a92d7380ac104f6a6b19 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/46] 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 | 26 ++++++++++++++++++++++++-- packages/engine/ui/style.cpp | 4 ++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 35482ee..562dd0f 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3906,7 +3906,9 @@ class LayoutNodePass { Node &child = nodes_[id]; if (isDisplayNone(child.style)) return; if (isOutOfFlowPosition(child.style.position)) { - if (child.parent != id_ || !blockBox(child)) return; + // An inline-level box that no line box placed (inlineStaticPosition) + // starts a hypothetical line here, where a block-level one would. + if (child.parent != id_) return; const int edge = cursor + (first && collapsesWithChildren(id_, false) ? 0 : pending.value()); child.layout.static_block_start = clampInt16(reversed ? node_.layout.width - edge : edge); child.layout.static_block_axis = vertical ? 2 : 1; @@ -4496,8 +4498,18 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod if (parent.style.display == kDisplayBlock && !parent.style.flex_direction_explicit && childNode.layout.static_block_axis == (horizontal ? 2 : 1)) { const bool reversed = horizontal && gridAxisReversed(parent, true); - return childNode.layout.static_block_start + (reversed ? -childNode.layout.width - childNode.style.margin[1] + 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 @@ -4506,6 +4518,16 @@ int LayoutEngine::alignedAbsoluteOffset(const Node &parent, const Node &childNod 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.style, horizontal ? 1 : 2) - (horizontal ? childNode.layout.width : childNode.layout.height) - childNode.style.margin[horizontal ? 1 : 2] diff --git a/packages/engine/ui/style.cpp b/packages/engine/ui/style.cpp index bb6f281..c97a222 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4771,6 +4771,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); @@ -4785,6 +4788,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 0fdbdf6885b41359b4ad66e1092fc953b8d0e1b2 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/46] 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 | 66 +++++++++++++++---- packages/engine/ui/node_model.h | 18 +++-- packages/engine/ui/render.cpp | 4 +- packages/engine/ui/style.cpp | 2 + packages/geatsc-plugin-gea/src/utils.ts | 4 +- 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index 5ceb955..b32ce8e 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -2044,7 +2044,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 562dd0f..69b976d 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 } static bool isInlineLevelTag(const char *tag); +static bool inlineBoxView(const Node &n); +bool blockLevelView(const Node &node); class FlexLayoutPass { public: @@ -1339,7 +1341,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; @@ -2242,10 +2244,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.style.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- @@ -2257,7 +2289,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.style) || isOutOfFlowPosition(inner.style.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); } } @@ -2313,6 +2346,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. @@ -2342,12 +2382,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 @@ -2356,8 +2395,7 @@ bool blockLevelView(const Node &node) bool splitInlineWrapper(const Node *nodes, int id) { const Node &n = nodes[id]; - if (n.type != NodeType::View || n.style.display_explicit || n.style.display != kDisplayBlock || - !isInlineLevelTag(tagFromId(n.tag_id)) || n.style.float_side || isOutOfFlowPosition(n.style.position) || + 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; @@ -3896,7 +3934,7 @@ class LayoutNodePass { 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_) && !isOutOfFlowPosition(node_.style.position))) return; + (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_.style, blockMarginSide(false)); @@ -4516,7 +4554,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 @@ -4634,14 +4672,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.style.display == kDisplayBlock && n.style.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.style.margin[0] != 0 || n.style.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 @@ -4690,14 +4728,14 @@ bool LayoutEngine::endsFormattingLine(int id) bool LayoutEngine::isCssInlineLevelBox(const Node &n, bool hypothetical) { - if (n.style.display == kDisplayNone || n.style.display_explicit) 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.style.display == kDisplayFlex || isDisplayGrid(parent.style)) 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 cfd87cb..dcd426c 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -80,7 +80,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; @@ -705,11 +707,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 bd2018f..664d9c4 100644 --- a/packages/engine/ui/render.cpp +++ b/packages/engine/ui/render.cpp @@ -9651,7 +9651,9 @@ int gLastScrollUiFrame = -1000; const auto &n = nodes[id]; const bool item = n.parent >= 0 && (nodes[n.parent].style.display == kDisplayFlex || isDisplayGrid(nodes[n.parent].style)); - return isContext(id) || n.style.position != 0 || n.style.float_side || item; + // An inline-block, inline-flex or inline-grid paints atomically, like a + // float (CSS 2.2 Appendix E). + return isContext(id) || n.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 c97a222..3fcb519 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -4733,6 +4733,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 493d7f7cfa871dc4e8226ab7dd9f2ead59a9b7eb 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/46] 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 | 170 +++++++++++++++++++++++--------- packages/engine/ui/tree_state.h | 4 + 2 files changed, 125 insertions(+), 49 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 69b976d..f88f736 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -651,6 +651,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); @@ -691,6 +712,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: @@ -882,17 +904,29 @@ class FlexLayoutPass { } std::vector> staticBoundaries; int flowIndex = 0; - for (int child = node_.first_child; child >= 0; child = nodes[child].next_sibling) { - if (isDisplayNone(nodes[child].style) || suppressAnonymousWhitespace(nodes, parentId, child)) continue; - if (isOutOfFlowPosition(nodes[child].style.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; @@ -938,6 +972,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); } }; @@ -4121,10 +4157,19 @@ class LayoutNodePass { { int x, y, width, height; LayoutEngine::absoluteContainingArea(node_, childNode, x, y, width, height); - if (hasPositionOffset(childNode, 3)) childNode.layout.x = x + resolvedPositionOffsetWithBasis(childNode, 3, width) + childNode.style.margin[3]; + int offset = 0; + if (hasPositionOffset(childNode, 3) && hasPositionOffset(childNode, 1) && + insetAlignedOffset(node_, childNode, true, width, resolvedPositionOffsetWithBasis(childNode, 3, width), + resolvedPositionOffsetWithBasis(childNode, 1, width), offset)) + childNode.layout.x = x + offset; + else if (hasPositionOffset(childNode, 3)) childNode.layout.x = x + resolvedPositionOffsetWithBasis(childNode, 3, width) + childNode.style.margin[3]; else if (hasPositionOffset(childNode, 1)) childNode.layout.x = x + width - childNode.layout.width - resolvedPositionOffsetWithBasis(childNode, 1, width) - childNode.style.margin[1]; else childNode.layout.x = alignedAbsoluteChildPosition(childNode, true); - if (hasPositionOffset(childNode, 0)) childNode.layout.y = y + resolvedPositionOffsetWithBasis(childNode, 0, height) + childNode.style.margin[0]; + if (hasPositionOffset(childNode, 0) && hasPositionOffset(childNode, 2) && + insetAlignedOffset(node_, childNode, false, height, resolvedPositionOffsetWithBasis(childNode, 0, height), + resolvedPositionOffsetWithBasis(childNode, 2, height), offset)) + childNode.layout.y = y + offset; + else if (hasPositionOffset(childNode, 0)) childNode.layout.y = y + resolvedPositionOffsetWithBasis(childNode, 0, height) + childNode.style.margin[0]; else if (hasPositionOffset(childNode, 2)) childNode.layout.y = y + height - childNode.layout.height - resolvedPositionOffsetWithBasis(childNode, 2, height) - childNode.style.margin[2]; else childNode.layout.y = alignedAbsoluteChildPosition(childNode, false); } @@ -4368,15 +4413,23 @@ void positionAbsoluteNodeInContainingBlock(int node, int containing, Node *nodes if (!hasPositionOffsetValue(absolute, 0) && !hasPositionOffsetValue(absolute, 2)) absolute.layout.y = LayoutEngine::alignedAbsoluteOffset(nodes[parent], absolute, false, &containingNode, areaY - parentOffsetY, areaHeight); - int containingX = absolute.layout.x + parentOffsetX; - if (hasPositionOffsetValue(absolute, 3)) + int containingX = absolute.layout.x + parentOffsetX, offset = 0; + if (hasPositionOffsetValue(absolute, 3) && hasPositionOffsetValue(absolute, 1) && + insetAlignedOffset(containingNode, absolute, true, areaWidth, resolvedPositionOffsetForBasis(absolute, 3, areaWidth), + resolvedPositionOffsetForBasis(absolute, 1, areaWidth), offset)) + containingX = areaX + offset; + else if (hasPositionOffsetValue(absolute, 3)) containingX = areaX + resolvedPositionOffsetForBasis(absolute, 3, areaWidth) + absolute.style.margin[3]; else if (hasPositionOffsetValue(absolute, 1)) containingX = areaX + areaWidth - absolute.layout.width - resolvedPositionOffsetForBasis(absolute, 1, areaWidth) - absolute.style.margin[1]; int containingY = absolute.layout.y + parentOffsetY; - if (hasPositionOffsetValue(absolute, 0)) + if (hasPositionOffsetValue(absolute, 0) && hasPositionOffsetValue(absolute, 2) && + insetAlignedOffset(containingNode, absolute, false, areaHeight, resolvedPositionOffsetForBasis(absolute, 0, areaHeight), + resolvedPositionOffsetForBasis(absolute, 2, areaHeight), offset)) + containingY = areaY + offset; + else if (hasPositionOffsetValue(absolute, 0)) containingY = areaY + resolvedPositionOffsetForBasis(absolute, 0, areaHeight) + absolute.style.margin[0]; else if (hasPositionOffsetValue(absolute, 2)) containingY = areaY + areaHeight - absolute.layout.height - @@ -4504,50 +4557,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].style, 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; } 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 @@ -4560,11 +4630,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.style, horizontal ? 1 : 2) - diff --git a/packages/engine/ui/tree_state.h b/packages/engine/ui/tree_state.h index 8c76d26..dbdee7a 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -102,7 +102,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 6b3cf074b870613c50b9953a18ec68e699f21f3e 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/46] 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 f88f736..5e39b3c 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3973,7 +3973,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_.style, blockMarginSide(false)); + int cursor = boxInset(node_.style, blockMarginSide(false)) + contentShift_; bool first = true; CollapsedMargin pending; visitFormattingChildren(nodes_, id_, [&](int id) { @@ -4001,8 +4001,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 143b5a476cdf6d2cce13fe0980a80acafca0516b 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/46] Place inline-level absolute boxes on a hypothetical line in block flow An inline-level absolutely positioned box whose parent lays out only block-level children has no line box to take its static position from. It now opens a hypothetical empty line at its block position: preceding floats shorten that line and text-align places the static position on it. In a right-to-left block the box's right margin edge meets the position (CSS 2.2 10.3.7). Positions recorded by laid-out lines keep anchoring the box's left edge: those lines keep their items in left-to-right order, so the content that follows the box is on its right. WPT: inline-level-absolute-in-block-level-context-001..012 pass (548 pass / 43 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 31 ++++++++++++++++++++++++++++++- packages/engine/ui/tree_state.h | 4 ++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 5e39b3c..163ed60 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -973,6 +973,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); } }; @@ -3986,6 +3987,7 @@ class LayoutNodePass { 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; + if (!vertical && LayoutEngine::isCssInlineLevelBox(child, true)) captureHypotheticalLine(id, edge); return; } if (child.style.float_side || suppressAnonymousWhitespace(nodes_, child.parent, id)) return; @@ -4001,6 +4003,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 @@ -4626,7 +4654,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 dbdee7a..d4843de 100644 --- a/packages/engine/ui/tree_state.h +++ b/packages/engine/ui/tree_state.h @@ -107,6 +107,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 02bea8e7e71446476c5d983c6f0fde455e60c306 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/46] Size block flex items to their content in a flex row A sole text run is widened to the line it was offered so text-align has room to work in. When the block holding it then autosized to its content, the run kept that width, and the flex automatic minimum size, measured from the children's extents, grew the item back to the full line: every block item carrying one line of text filled its flex row. Widened runs now span the block's final content width. A document's root element fills the initial containing block whatever its display (CSS 2.2 10.3.3); a flex html root no longer shrinks to its items. Native roots keep sizing to their content. WPT: 548 pass / 43 fail, unchanged; the rows of clip-text-stacking-context-child-002 that depend on it now match. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 163ed60..a63d89b 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -863,6 +863,7 @@ class FlexLayoutPass { { Node *nodes = Tree::instance().nodes(); const int parentId = static_cast(&node_ - nodes); + widenedRuns_.clear(); int firstLineStyleOwner = -1; for (int ancestor = parentId; ancestor >= 0; ancestor = nodes[ancestor].parent) { const NodeRareData *rare = rareDataFor(ancestor); @@ -1300,6 +1301,11 @@ class FlexLayoutPass { if (!hasExplicitWidth(node_) && !keepScrollWidth) { const int width = maxLineWidth + boxInset(node_.style, 1) + boxInset(node_.style, 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; } @@ -1514,7 +1520,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); @@ -1717,7 +1726,11 @@ class FlexLayoutPass { node_.layout.height = clampLayoutSize(node_, height, false); } - if (!hasExplicitWidth(node_) && !keepScrollWidth) { + // A document's root element is block-level in the initial containing + // block, so its automatic width fills it whatever its display (CSS 2.2 + // 10.3.3). A native root sizes to its content. + const bool fillsRoot = node_.parent < 0 && std::strcmp(tagFromId(node_.tag_id), "html") == 0; + if (!hasExplicitWidth(node_) && !keepScrollWidth && !fillsRoot) { const int width = (isRow_ ? maxMainSize(lineCount) : totalCrossSize(lineCount)) + boxInset(node_.style, 1) + boxInset(node_.style, 3); node_.layout.width = clampLayoutSize(node_, width, true); } @@ -2364,6 +2377,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 5b806bbbddcccbe74521c71db810bbdd80d7005e 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/46] 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 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index a63d89b..c39a10e 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3286,6 +3286,20 @@ 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 (ownerRare) ownerRare->firstLineBackground.lineValid = ownerLineWasValid; + std::rotate(children + i, children + end, children + end + 1); + --i; + continue; + } + } if (!ownerLineWasValid && ownerRare && ownerRare->firstLineBackground.lineValid) ownerRare->firstLineBackground.lineY = clampInt16(ownerRare->firstLineBackground.lineY + flowY); for (int j = i; j < end; ++j) { From 36b48488fd545601355ae8906324639f2c747942 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/46] 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 c39a10e..f9e26d6 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -714,6 +714,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) @@ -779,6 +814,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); @@ -2082,18 +2120,65 @@ 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 { const bool flex = node_.style.display == kDisplayFlex; const bool logicalRow = flexRowDirection(node_.style); const int mode = writingMode(node_); const bool blockReversed = mode == 2 || mode == 3; const bool inlineReversed = rightToLeft(node_) != (mode == 4); - const bool reverseMain = flex && ((node_.style.flex_direction_explicit && node_.style.flex_direction >= 2) != (logicalRow ? inlineReversed : blockReversed)); - const bool reverseCross = flex && (((node_.style.flex_wrap & 3) == 2) != (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 && (((node_.style.flex_wrap & 3) == 2) != (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) { @@ -3723,6 +3808,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 b4a1ab578f26c4c5c01e92ae1fab92168b6b5bf9 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/46] 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 3fcb519..fe7fdb6 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -8776,6 +8776,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); @@ -10076,7 +10092,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 56fde5f32d492f0a016e3b45d9d6c6d487c39c5d 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/46] 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 | 11 +++ 4 files changed, 107 insertions(+), 12 deletions(-) diff --git a/packages/core/scripts/build-gea-vite-geatsc.mjs b/packages/core/scripts/build-gea-vite-geatsc.mjs index b32ce8e..fe8ce61 100755 --- a/packages/core/scripts/build-gea-vite-geatsc.mjs +++ b/packages/core/scripts/build-gea-vite-geatsc.mjs @@ -1533,22 +1533,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 } @@ -1561,7 +1566,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 fe7fdb6..7a91b67 100644 --- a/packages/engine/ui/style.cpp +++ b/packages/engine/ui/style.cpp @@ -11775,6 +11775,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(); @@ -11940,12 +11950,16 @@ ParsedSimpleSelector parseSimpleSelector(const char *rawSimple, std::size_t rawL struct SelectorPart { 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 { SmallSelectorList parts; SmallSelectorList ancestorClasses; SmallSelectorList ancestorTags; + SmallSelectorList siblingClasses; + SmallSelectorList siblingTags; CssAtomId rightmostId = kInvalidCssAtom; CssAtomId rightmostClass = kInvalidCssAtom; int16_t rightmostTag = -1; @@ -11993,13 +12007,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); } } @@ -12008,22 +12025,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)); @@ -12110,6 +12132,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)); @@ -12179,6 +12202,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; @@ -12936,6 +12972,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 @@ -13026,6 +13067,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(), {}); @@ -13081,6 +13125,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; } @@ -15907,6 +15957,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; @@ -16041,10 +16103,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); } @@ -17935,6 +18001,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 { rebuildRuleIndexIfNeeded(); diff --git a/packages/engine/ui/style.h b/packages/engine/ui/style.h index a486840..4edeb6d 100644 --- a/packages/engine/ui/style.h +++ b/packages/engine/ui/style.h @@ -468,6 +468,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 { @@ -921,6 +923,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 8ebc3a5..5be6bd7 100644 --- a/packages/engine/ui/tree_nodes.cpp +++ b/packages/engine/ui/tree_nodes.cpp @@ -506,6 +506,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; @@ -540,6 +541,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) @@ -557,6 +560,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) { @@ -595,6 +599,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) @@ -619,8 +626,12 @@ void Tree::removeNode(int id) if (p->first_child == id) p->first_child = n->next_sibling; if (p->last_child == id) p->last_child = n->prev_sibling; } + const int next = n->next_sibling; if (n->prev_sibling >= 0) state.nodes[n->prev_sibling].next_sibling = n->next_sibling; if (n->next_sibling >= 0) state.nodes[n->next_sibling].prev_sibling = n->prev_sibling; + // Sibling selectors (+, ~) of the siblings that followed it. + StyleSheet::instance().recomputeSiblingsFrom(next); + n = &state.nodes[id]; n->style.display = 1; n->parent = -1; n->first_child = -1; From dac860cc82b78cb3077662a462a670fd23a1d5ae 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/46] 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 f9e26d6..32b84af 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -555,8 +555,14 @@ int ratioTransferredSize(const Node &node, int crossBorderSize, bool horizontal) { const double ratio = preferredRatio(node); if (ratio <= 0) return 0; - const int crossEdges = node.style.box_sizing ? 0 : boxInsets(node.style, !horizontal); - const int mainEdges = node.style.box_sizing ? 0 : boxInsets(node.style, 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))); } @@ -2066,6 +2072,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.style.max_width : childNode.style.max_height; if (max != kUnset) extent = std::min(extent, contentSizeToBorderSize(childNode.style, max, isRow_)); From b89644de5304a3f666fb9c5a0f83a8e2fabbbe78 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/46] Honor auto margins for blocks laid out in a float context When a block formatting context contains a float anywhere, its normal blocks, including those inside non-BFC wrappers, are placed by the float path, which ignored auto margins: a single float left every margin: 0 auto block in that context at the start edge. Block placement now shares blockInlineOffset between the block and float paths, and a block formatting context beside floats centers with auto margins in the space between them. WPT: unchanged (599 pass / 55 fail). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/engine/ui/layout.cpp | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index 32b84af..4800c66 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -3136,6 +3136,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_.style.display != kDisplayBlock || node_.style.flex_direction_explicit) return false; @@ -3200,11 +3210,7 @@ class LayoutNodePass { } } const int slack = contentWidth - child.layout.width - child.style.margin[1] - child.style.margin[3]; - const bool autoLeft = child.style.margin_auto & (1 << 3); - const bool autoRight = child.style.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_.style, 3) + child.style.margin[3] + offset; + child.layout.x = boxInset(node_.style, 3) + child.style.margin[3] + blockInlineOffset(child, slack); } } const bool mergeTop = collapsesWithChildren(id_, false); @@ -3484,7 +3490,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 @@ -3524,14 +3530,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 bfe74963d94117cc4d402a9361b51068730e2c66 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/46] 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 4800c66..efca370 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -2266,8 +2266,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 dea97af40960f3ef0ec40f7b0bf7ccfe514f7381 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/46] 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 | 62 ++++++++++++++++----------------- packages/engine/ui/node_model.h | 14 ++++++-- packages/engine/ui/view.cpp | 36 ++++++++++++++++++- 3 files changed, 77 insertions(+), 35 deletions(-) diff --git a/packages/engine/ui/layout.cpp b/packages/engine/ui/layout.cpp index efca370..6aca02b 100644 --- a/packages/engine/ui/layout.cpp +++ b/packages/engine/ui/layout.cpp @@ -466,7 +466,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) @@ -603,13 +603,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.style.min_width : node.style.min_height) == kUnset && !isScrollableOverflow(horizontal ? overflowX(node.style) : overflowY(node.style)); 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) @@ -810,8 +810,8 @@ class FlexLayoutPass { applyFlexBasis(child); Node &item = Tree::instance().nodes()[child]; const int floor = automaticMinimumMainSize(item); - int16_t &main = isRow_ ? item.layout.width : item.layout.height; - if (main < floor) { main = clampInt16(floor); engine_.repositionChildren(child); } + int32_t &main = isRow_ ? item.layout.width : item.layout.height; + if (main < floor) { main = clampLayoutExtent(floor); engine_.repositionChildren(child); } } } } @@ -1093,8 +1093,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 @@ -1102,8 +1102,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) { @@ -1340,16 +1340,16 @@ class FlexLayoutPass { const bool keepScrollWidth = !intrinsicWidthConstraint(node_) && scrollsOverflowX(node_.style) && node_.layout.width > 0; if (!hasExplicitHeight(node_) && !keepScrollHeight) { const int height = (flowHeight < 0 ? 0 : flowHeight) + boxInset(node_.style, 0) + boxInset(node_.style, 2); - node_.layout.height = clampInt16(clampLayoutSize(node_, height, false)); + node_.layout.height = clampLayoutExtent(clampLayoutSize(node_, height, false)); } if (!hasExplicitWidth(node_) && !keepScrollWidth) { const int width = maxLineWidth + boxInset(node_.style, 1) + boxInset(node_.style, 3); - node_.layout.width = clampInt16(clampLayoutSize(node_, width, true)); + node_.layout.width = clampLayoutExtent(clampLayoutSize(node_, width, true)); // A widened sole run spans the box's final content width instead, so // its extent does not hold the box at the width it was offered. const int content = node_.layout.width - boxInsets(node_.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; } @@ -1566,7 +1566,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); } } @@ -1583,7 +1583,7 @@ class FlexLayoutPass { if (align == 1) y += (lineCross - crossTotal) / 2; else if (align == 2) y += lineCross - crossTotal; else if (align == 0 && !hasExplicitHeight(cn) && !isIntrinsicSizeExpression(cn.style.height_expression)) { - cn.layout.height = clampInt16(lineCross - crossBefore - crossAfter); + cn.layout.height = clampLayoutExtent(lineCross - crossBefore - crossAfter); engine_.repositionChildren(child); } } @@ -1705,8 +1705,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_.style, !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_.style, isRow_ ? 0 : 3); const int crossBox = (isRow_ ? node_.layout.height : node_.layout.width) - boxInsets(node_.style, !isRow_); @@ -2111,8 +2111,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; @@ -2636,7 +2636,7 @@ class LayoutNodePass { if (isLineBreak(node_) && LayoutEngine::isInlineLevelNode(node_) && (node_.parent < 0 || (nodes_[node_.parent].style.display != kDisplayFlex && !isDisplayGrid(nodes_[node_.parent].style)))) { node_.layout.width = 0; - node_.layout.height = clampInt16(TextRenderer::measureHeight(" ", node_.style.font_id, + node_.layout.height = clampLayoutExtent(TextRenderer::measureHeight(" ", node_.style.font_id, node_.style.font_size, 0, node_.style.line_height)); return; } @@ -2813,7 +2813,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; } }; @@ -2839,7 +2839,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); @@ -3178,7 +3178,7 @@ class LayoutNodePass { !(preferredRatio(child) > 0 && hasExplicitWidth(child))) { const int height = clampLayoutSize(child, std::max(0, contentHeight - child.style.margin[0] - child.style.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.style.margin[0] - child.style.margin[2]; @@ -3212,7 +3212,7 @@ class LayoutNodePass { if (!hasExplicitWidth(child) && !isIntrinsicSizeExpression(child.style.width_expression) && writingMode(child) == 0 && !(preferredRatio(child) > 0 && hasExplicitHeight(child))) { const int width = clampLayoutSize(child, std::max(0, contentWidth - 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(id); } } @@ -3251,7 +3251,7 @@ class LayoutNodePass { 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); + child.layout.height = clampLayoutExtent(height); engine_.repositionChildren(id); } drift = bottom - cursor - child.layout.height; @@ -3468,7 +3468,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]); } } @@ -3529,7 +3529,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]; @@ -3620,7 +3620,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_.style, 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_.style, false), false); @@ -4267,8 +4267,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); @@ -5296,8 +5296,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 dcd426c..791788e 100644 --- a/packages/engine/ui/node_model.h +++ b/packages/engine/ui/node_model.h @@ -35,6 +35,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, @@ -356,7 +364,7 @@ struct ComputedStyle { int32_t line_height_multiplier; int32_t width_expression, height_expression; - int16_t width, height; + int32_t width, height; // kUnset: auto; see kMaxLayoutExtent int16_t width_percent, height_percent; int16_t min_width, min_height; int16_t max_width, max_height; @@ -810,7 +818,7 @@ inline bool hasBorderRelief(const ComputedStyle &style) struct LayoutBox { int16_t x, y; int32_t width_expression, height_expression; - 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 @@ -832,10 +840,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 3b4f241..37f7f27 100644 --- a/packages/engine/ui/view.cpp +++ b/packages/engine/ui/view.cpp @@ -67,8 +67,20 @@ bool borderIsSameOpaqueSolidBackground(const Node &node, uint8_t parentAlpha) combineAlpha(parentAlpha, node.style.bg_alpha) == combineAlpha(parentAlpha, borderPaintAlpha(node.style, 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; @@ -186,6 +198,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(); @@ -201,6 +215,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; @@ -1940,6 +1956,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); @@ -2723,8 +2750,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],