Skip to content

Commit 78d9a0e

Browse files
authored
fix(scenegraph): LayoutGroup counts a hidden child's height in a vertical stack (#1158)
A hidden Label/ScrollingLabel can carry a stale, non-zero-height bounding rect (set by a forced re-measure while still momentarily visible, e.g. a redundant `label.text = ""` write ahead of `label.visible = false`). LayoutGroup's own child measurement reads that cached rect directly and never checks visibility, unlike the rest of the engine's parent-bounds propagation, so the hidden child's height gets counted toward the stack — pushing a bottom-aligned visible sibling up by a full line height. #1154 removed an incidental width-based gate that used to mask this for ScrollingLabel. Fix LayoutGroup.measureChild to treat an invisible child as zero-size, mirroring Group.nodeRenderingDone's existing visibility guard, instead of special-casing width again.
1 parent 0d1bda4 commit 78d9a0e

2 files changed

Lines changed: 90 additions & 1 deletion

File tree

src/extensions/scenegraph/nodes/LayoutGroup.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,26 @@ export class LayoutGroup extends Group {
487487
direction: LayoutDirection,
488488
metricsMap?: WeakMap<Node, LayoutMetrics>
489489
): LayoutMetrics {
490+
if (!child.isVisible()) {
491+
// Mirrors Group.nodeRenderingDone's own rule ("an invisible node must not contribute to
492+
// the parent's bounds"), which LayoutGroup's custom measurement otherwise bypasses. A
493+
// hidden child's rect can be stale (Label.renderNodeContent hard-skips when invisible,
494+
// so its rectToParent/rectToScene/rectLocal are never recomputed) and would otherwise
495+
// still pass chooseActiveRect's width/height>0 gate, inflating the stack with a child
496+
// that draws nothing — position is irrelevant since it never renders, so the current
497+
// translation is reused as-is rather than a stale measured origin.
498+
const translation = this.getChildTranslation(child);
499+
const metrics: LayoutMetrics = {
500+
primary: 0,
501+
cross: 0,
502+
crossStart: direction === "horiz" ? translation[1] : translation[0],
503+
primaryStart: direction === "horiz" ? translation[0] : translation[1],
504+
};
505+
if (metricsMap) {
506+
metricsMap.set(child, metrics);
507+
}
508+
return metrics;
509+
}
490510
const { rect, cached } = this.chooseActiveRect(child);
491511
let originX = rect.x;
492512
let originY = rect.y;

test/extensions/scenegraph/LayoutGroup.test.js

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const scenegraph = require("../../../packages/scenegraph/lib/brs-sg.node.js");
44
const core = require("../../../packages/node/bin/brs.node.js");
55

66
const { SGNodeFactory, sgRoot } = scenegraph;
7-
const { BrsDevice, BrsString, Float, Int32, RoArray } = core;
7+
const { BrsBoolean, BrsDevice, BrsString, Float, Int32, RoArray } = core;
88

99
/** Minimal interpreter accepted by renderNode → renderChildren when draw2D is absent. */
1010
const fakeInterpreter = {};
@@ -129,3 +129,72 @@ describe("LayoutGroup positions siblings after a non-scrolling ScrollingLabel's
129129
expect(length.getValueJS("translation")[0]).toBeCloseTo(expectedX);
130130
});
131131
});
132+
133+
/**
134+
* Regression: a HIDDEN ScrollingLabel must not contribute to a vertical LayoutGroup's stack height.
135+
* SGDEX's StandardGridItemComponent (playusb's main-menu grid items) uses exactly this shape — a
136+
* "labelsLayout" LayoutGroup(vert, vertAlignment="bottom") with two ScrollingLabel lines, where an
137+
* unused second line is hidden via setLabelDataOrHide: `label.text = ""` (always run, even redundantly,
138+
* which Label.setValue's "force re-measure" path turns into an immediate getMeasured() call while the
139+
* label is still momentarily visible), THEN `label.visible = false` and `label.scale = [0,0]`.
140+
*
141+
* Before #1154, ScrollingLabel's reported width for that forced empty-text measurement was 0
142+
* (fullTextWidth for empty text), which failed LayoutGroup.chooseActiveRect's width>0-AND-height>0
143+
* gate, so the stale rect fell back to zero size. #1154 made ScrollingLabel always report
144+
* width = maxWidth (non-zero) regardless of text, so that same stale-but-nonzero-height rect started
145+
* passing the gate — counting a full line-height + itemSpacing for a label that never draws anything,
146+
* and shifting the bottom-aligned visible line upward (overlapping content above it, e.g. an icon).
147+
*/
148+
describe("LayoutGroup ignores a hidden ScrollingLabel's height in a vertical stack", () => {
149+
beforeAll(() => {
150+
const commonZip = fs.readFileSync(path.join(__dirname, "../../../packages/scenegraph/assets/common.zip"));
151+
BrsDevice.fileSystem.setup(commonZip.buffer, new ArrayBuffer(1024 * 1024), new ArrayBuffer(1024 * 1024));
152+
});
153+
154+
afterEach(() => {
155+
sgRoot.setFocused();
156+
});
157+
158+
function buildLabelsLayout(includeHiddenLine2) {
159+
const layout = SGNodeFactory.createNode("LayoutGroup");
160+
layout.setValue("layoutDirection", new BrsString("vert"));
161+
layout.setValue("itemSpacings", vector([5]));
162+
layout.setValue("vertAlignment", new BrsString("bottom"));
163+
164+
const line1 = SGNodeFactory.createNode("ScrollingLabel");
165+
line1.setValue("font", new BrsString("font:SmallSystemFont"));
166+
line1.setValue("maxWidth", new Int32(200));
167+
line1.setValue("text", new BrsString("Recent Files"));
168+
layout.appendChildToParent(line1);
169+
170+
if (includeHiddenLine2) {
171+
const line2 = SGNodeFactory.createNode("ScrollingLabel");
172+
line2.setValue("font", new BrsString("font:SmallSystemFont"));
173+
line2.setValue("maxWidth", new Int32(200));
174+
// Matches setLabelDataOrHide's exact field-write order: text cleared first (redundant —
175+
// the field already defaults to "" — but still forces a re-measure), THEN hidden.
176+
line2.setValue("text", new BrsString(""));
177+
line2.setValue("visible", BrsBoolean.False);
178+
line2.setValue("scale", vector([0, 0]));
179+
layout.appendChildToParent(line2);
180+
}
181+
182+
return { layout, line1 };
183+
}
184+
185+
test("a hidden second line does not push the visible first line up", () => {
186+
const { layout: layoutAlone, line1: line1Alone } = buildLabelsLayout(false);
187+
layoutAlone.renderNode(fakeInterpreter, [0, 0], 0, 1);
188+
// A hidden-but-still-present child contributes zero HEIGHT (this fix), but — pre-dating
189+
// #1154 too, and out of scope here — calculateTotalPrimary still adds its one itemSpacings
190+
// entry regardless of visibility. So the correct baseline is "line1 alone" minus that single
191+
// spacing, not an exact match: this still pins the actual regression (a full extra line
192+
// height, ~4x the spacing here) while not asserting an unrelated, pre-existing quirk away.
193+
const expectedY = line1Alone.getValueJS("translation")[1] - 5;
194+
195+
const { layout: layoutWithHidden, line1: line1WithHidden } = buildLabelsLayout(true);
196+
layoutWithHidden.renderNode(fakeInterpreter, [0, 0], 0, 1);
197+
198+
expect(line1WithHidden.getValueJS("translation")[1]).toBeCloseTo(expectedY);
199+
});
200+
});

0 commit comments

Comments
 (0)