From 2f785f22fcb036ee0a1c71e9d8e2bdc454ec1ebc Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 21:44:49 +0200 Subject: [PATCH 01/14] Add img and external texture rendering --- .../lwjgl/nanovg/NvgElementRenderer.java | 134 +++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgElementRenderer.java b/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgElementRenderer.java index e961eb93..ac7b4959 100644 --- a/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgElementRenderer.java +++ b/spinygui.core.backend.lwjgl.nanovg/src/main/java/com/spinyowl/spinygui/core/backend/renderer/lwjgl/nanovg/NvgElementRenderer.java @@ -4,18 +4,41 @@ import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgRenderUtils.withPresentedOpacity; import static com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.util.NvgShapes.drawRect; import static com.spinyowl.spinygui.core.util.NodeUtilities.visible; +import static org.lwjgl.nanovg.NanoVG.NVG_IMAGE_FLIPY; +import static org.lwjgl.nanovg.NanoVG.nvgBeginPath; +import static org.lwjgl.nanovg.NanoVG.nvgCreateImage; +import static org.lwjgl.nanovg.NanoVG.nvgFill; +import static org.lwjgl.nanovg.NanoVG.nvgFillPaint; +import static org.lwjgl.nanovg.NanoVG.nvgImagePattern; +import static org.lwjgl.nanovg.NanoVG.nvgRect; import static org.lwjgl.nanovg.NanoVG.nvgRestore; +import static org.lwjgl.nanovg.NanoVG.nvgRoundedRect; +import static org.lwjgl.nanovg.NanoVG.nvgRoundedRectVarying; import static org.lwjgl.nanovg.NanoVG.nvgSave; +import static org.lwjgl.system.MemoryStack.stackPush; import com.spinyowl.spinygui.core.backend.renderer.lwjgl.nanovg.diagnostic.NvgDiagnosticCounter; import com.spinyowl.spinygui.core.diagnostic.DiagnosticSession; import com.spinyowl.spinygui.core.node.Element; import com.spinyowl.spinygui.core.node.Node; import com.spinyowl.spinygui.core.style.types.Display; +import java.util.HashMap; +import java.util.Map; import org.joml.Vector2f; +import org.joml.Vector4f; +import org.lwjgl.nanovg.NVGPaint; +import org.lwjgl.nanovg.NanoVGGL2; +import org.lwjgl.nanovg.NanoVGGL3; +import org.lwjgl.opengl.GL30; public class NvgElementRenderer { + private static final String IMG = "img"; + private static final String SRC = "src"; + private static final String TEXTURE_ID = "texture-id"; + private static final String FLIP_Y = "flip-y"; + private final DiagnosticSession diagnostics; + private final Map imageCache = new HashMap<>(); public NvgElementRenderer() { this(DiagnosticSession.disabled()); @@ -54,15 +77,124 @@ public void render(Node node, long nanovg) { var position = element.layoutAbsolutePosition(); var size = element.size(); - // render self + // Always render the element rectangle first. For , this is also the fallback when the + // source cannot be resolved or loaded. diagnostics.increment(NvgDiagnosticCounter.SAVE_CALLS); nvgSave(nanovg); drawRect(nanovg, position, size, backgroundColor, borderRadius); + if (IMG.equalsIgnoreCase(element.nodeName())) { + renderImage(element, nanovg, position, size, borderRadius); + } diagnostics.increment(NvgDiagnosticCounter.RESTORE_CALLS); nvgRestore(nanovg); } } + private void renderImage( + Element element, long nanovg, Vector2f position, Vector2f size, Vector4f borderRadius) { + if (size.x <= 0 || size.y <= 0) { + return; + } + + int image = resolveImage(element, nanovg, size); + if (image <= 0) { + return; + } + + try (var stack = stackPush()) { + NVGPaint paint = NVGPaint.malloc(stack); + nvgImagePattern(nanovg, position.x, position.y, size.x, size.y, 0f, image, 1f, paint); + nvgBeginPath(nanovg); + if (borderRadius != null && !borderRadius.equals(new Vector4f(0))) { + if (borderRadius.x == borderRadius.y + && borderRadius.x == borderRadius.z + && borderRadius.x == borderRadius.w) { + nvgRoundedRect(nanovg, position.x, position.y, size.x, size.y, borderRadius.x); + } else { + nvgRoundedRectVarying( + nanovg, + position.x, + position.y, + size.x, + size.y, + borderRadius.x, + borderRadius.y, + borderRadius.z, + borderRadius.w); + } + } else { + nvgRect(nanovg, position.x, position.y, size.x, size.y); + } + nvgFillPaint(nanovg, paint); + nvgFill(nanovg); + } + } + + private int resolveImage(Element element, long nanovg, Vector2f size) { + String textureId = attribute(element, TEXTURE_ID); + if (textureId != null) { + return resolveExternalTexture(element, nanovg, size, textureId); + } + + String src = attribute(element, SRC); + if (src == null) { + return 0; + } + return imageCache.computeIfAbsent("src:" + src, ignored -> createImage(nanovg, src)); + } + + private int resolveExternalTexture( + Element element, long nanovg, Vector2f size, String textureIdValue) { + final int textureId; + try { + textureId = Integer.parseUnsignedInt(textureIdValue); + } catch (NumberFormatException ignored) { + return 0; + } + if (textureId == 0) { + return 0; + } + + int width = Math.max(1, Math.round(size.x)); + int height = Math.max(1, Math.round(size.y)); + boolean flipY = Boolean.parseBoolean(attribute(element, FLIP_Y)); + String key = "texture:" + textureId + ':' + width + 'x' + height + ':' + flipY; + return imageCache.computeIfAbsent( + key, ignored -> importExternalTexture(nanovg, textureId, width, height, flipY)); + } + + private int createImage(long nanovg, String src) { + try (var stack = stackPush()) { + return nvgCreateImage(nanovg, stack.UTF8(src), 0); + } catch (RuntimeException ignored) { + return 0; + } + } + + private int importExternalTexture( + long nanovg, int textureId, int width, int height, boolean flipY) { + int imageFlags = flipY ? NVG_IMAGE_FLIPY : 0; + try { + boolean gl3 = + (GL30.glGetInteger(GL30.GL_MAJOR_VERSION) > 3) + || GL30.glGetInteger(GL30.GL_MAJOR_VERSION) == 3 + && GL30.glGetInteger(GL30.GL_MINOR_VERSION) >= 2; + if (gl3) { + return NanoVGGL3.nvglCreateImageFromHandle( + nanovg, textureId, width, height, imageFlags | NanoVGGL3.NVG_IMAGE_NODELETE); + } + return NanoVGGL2.nvglCreateImageFromHandle( + nanovg, textureId, width, height, imageFlags | NanoVGGL2.NVG_IMAGE_NODELETE); + } catch (RuntimeException ignored) { + return 0; + } + } + + private String attribute(Element element, String name) { + String value = element.attributes().get(name); + return value == null || value.isBlank() ? null : value.trim(); + } + Vector2f inlineFormattingOffset(Element element) { Element parent = element.parent(); while (parent != null && Display.INLINE.equals(parent.resolvedStyle().display())) { From 44767bc3f009961239310d4e77db4f9580f4cb9c Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 21:45:11 +0200 Subject: [PATCH 02/14] Add img element node type --- .../spinygui/core/node/ImgElement.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/ImgElement.java diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/ImgElement.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/ImgElement.java new file mode 100644 index 00000000..f665b2e1 --- /dev/null +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/node/ImgElement.java @@ -0,0 +1,39 @@ +package com.spinyowl.spinygui.core.node; + +/** HTML {@code img} element. */ +public class ImgElement extends EmptyElement { + + public static final String IMG_TAG_NAME = "img"; + public static final String SRC_ATTRIBUTE = "src"; + public static final String TEXTURE_ID_ATTRIBUTE = "texture-id"; + public static final String FLIP_Y_ATTRIBUTE = "flip-y"; + + public ImgElement() { + super(IMG_TAG_NAME); + } + + /** Returns the conventional image source, or {@code null} when it is not specified. */ + public String src() { + return attribute(SRC_ATTRIBUTE); + } + + /** + * Returns an external renderer texture identifier, or {@code null} when it is not specified. + * + *

The interpretation of this value is backend-specific. The LWJGL NanoVG backend interprets + * it as an OpenGL texture handle and imports it without taking ownership of the texture. + */ + public String textureId() { + return attribute(TEXTURE_ID_ATTRIBUTE); + } + + /** Whether the image should be vertically flipped by the renderer. */ + public boolean flipY() { + return Boolean.parseBoolean(attribute(FLIP_Y_ATTRIBUTE)); + } + + private String attribute(String name) { + String value = attributes().get(name); + return value == null || value.isBlank() ? null : value.trim(); + } +} From 2d24a4fbc80a50b7d3648b821250f2b2a8059e41 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 21:45:35 +0200 Subject: [PATCH 03/14] Parse img as ImgElement --- .../spinygui/core/parser/impl/DefaultNodeParser.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/DefaultNodeParser.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/DefaultNodeParser.java index 34d91003..86d59968 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/DefaultNodeParser.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/parser/impl/DefaultNodeParser.java @@ -8,6 +8,7 @@ import com.spinyowl.spinygui.core.node.Element; import com.spinyowl.spinygui.core.node.EmptyElement; import com.spinyowl.spinygui.core.node.Frame; +import com.spinyowl.spinygui.core.node.ImgElement; import com.spinyowl.spinygui.core.node.InputElement; import com.spinyowl.spinygui.core.node.Node; import com.spinyowl.spinygui.core.node.Text; @@ -78,6 +79,8 @@ private Node createNodeFromElement( Node node; if ("input".equals(tagName)) { node = new InputElement(); + } else if (ImgElement.IMG_TAG_NAME.equals(tagName)) { + node = new ImgElement(); } else if ("button".equals(tagName)) { node = new ButtonElement(); createChildNodes(element, context, node); @@ -107,8 +110,6 @@ private Node createNodeFromElement( return node; } - // unmarshaller section - private void createChildNodes( org.jsoup.nodes.Element element, NodeConverterContext context, Node node) { var childNodes = element.childNodes(); From af762fe31a416d7f5fd9cf46ab5d9df7381a9662 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 17:59:59 +0200 Subject: [PATCH 04/14] Layout stretched absolute blocks before descendants --- .../core/layout/impl/BlockLayout.java | 63 ++++++++++++------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java index b1a34daf..1d233d07 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java @@ -79,14 +79,10 @@ public void layout(Element element, boolean skipChildren, LayoutContext ctx) { ResolvedStyle style = element.resolvedStyle(); - // calculate borders setBorders(style, element.box().border()); - - // calculate paddings setPadding( parentBox.content().width(), parentBox.content().height(), style, element.box().padding()); - // calculate content position Position elementPosition = element.resolvedStyle().position(); if (Position.STATIC.equals(elementPosition)) { layoutStaticBlock(element, parentBox, style, skipChildren, ctx); @@ -200,13 +196,19 @@ private void layoutAbsoluteBlock( + e.box().padding().left() + e.box().padding().right(); - // should be called here to calculate children before calculating content width - float childrenHeight = childrenHeight(e, style, skipChildren, ctx); - - // calculate content x position and width calculateHorizontalPositionAndWidth( parentBox, style, ancestor.box(), e.box(), horizontalAdditions); + if (stretchesVertically(style)) { + layoutVerticallyStretchedAbsoluteBlock(e, ancestor, style, verticalAdditions); + if (!skipChildren) { + layoutFlowChildren(e); + } + return; + } + + float childrenHeight = childrenHeight(e, style, skipChildren, ctx); + float contentY; float borderBoxHeight; if (style.top().isAuto() && style.bottom().isAuto()) { @@ -260,6 +262,34 @@ private void layoutAbsoluteBlock( e.box().content().height(borderBoxHeight - verticalAdditions); } + private boolean stretchesVertically(ResolvedStyle style) { + return style.height().isAuto() && style.top().isLength() && style.bottom().isLength(); + } + + private void layoutVerticallyStretchedAbsoluteBlock( + Element e, Element ancestor, ResolvedStyle style, float verticalAdditions) { + float parentPaddingBoxHeight = + ancestor.box().padding().top() + + ancestor.box().padding().bottom() + + ancestor.box().content().height(); + + float contentY = + ancestor.box().border().top() + + e.box().border().top() + + e.box().padding().top() + + getFloatLength(style.top(), parentPaddingBoxHeight); + float bottom = + ancestor.box().border().top() + + ancestor.box().paddingBox().height() + - getFloatLength(style.bottom(), parentPaddingBoxHeight); + float borderBoxHeight = + getBorderBoxHeight( + e, style, verticalAdditions, 0f, contentY, parentPaddingBoxHeight, bottom); + + e.box().content().y(contentY); + e.box().content().height(Math.max(0f, borderBoxHeight - verticalAdditions)); + } + private float getBorderBoxHeight( Element e, ResolvedStyle style, @@ -630,18 +660,9 @@ private List findFonts(ResolvedStyle style) { } return Font.semanticOwner().resolver() .resolve( - style.fontFamilies(), style.fontStyle(), style.fontWeight(), FontStretch.NORMAL) - ; - } - - /** - * Returns the height of the element's content, i.e. the height of the element's content box. - * - * @param parentHeight the height of the element's containing block. - * @param borderBoxHeight the height of the element's children with border and padding. - * @param style the element's style. - * @return the height of the element's content. - */ + style.fontFamilies(), style.fontStyle(), style.fontWeight(), FontStretch.NORMAL); + } + private float getHeight(float parentHeight, float borderBoxHeight, ResolvedStyle style) { Optional height; if (!style.height().isAuto()) { @@ -659,8 +680,6 @@ private float getHeight(float parentHeight, float borderBoxHeight, ResolvedStyle } private boolean shouldSkip(Element element) { - // skip layout if element has no frame - that means that it is not attached to any - // node tree (and tree root is frame). return element.frame() == null || (element.parent() == null && !(element instanceof Frame)); } From 2e71e0f5429a9dc751fc813705bfa2b4ded28b46 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 18:01:52 +0200 Subject: [PATCH 05/14] Pre-size definite containing blocks before child layout --- .../core/layout/impl/BlockLayout.java | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java index 1d233d07..11e781e5 100644 --- a/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java +++ b/spinygui.core/src/main/java/com/spinyowl/spinygui/core/layout/impl/BlockLayout.java @@ -76,7 +76,6 @@ public void layout(Element element, boolean skipChildren, LayoutContext ctx) { } Box parentBox = getParentDimensions(element, element.parent()); - ResolvedStyle style = element.resolvedStyle(); setBorders(style, element.box().border()); @@ -144,13 +143,27 @@ private void layoutStaticBlock( contentWidth -= horizontalAdditions; box.content().width(contentWidth); - float borderBoxHeight; if (e instanceof Frame frame) { + box.content().height(Math.max(0f, frame.frameSize().y - verticalAdditions)); if (!skipChildren) { layoutService.layoutChildNodes(e, ctx); } - borderBoxHeight = frame.frameSize().y; - } else if (e instanceof InputElement input && input.textInput()) { + finishBlockFlow(e, ctx); + return; + } + + if (!style.height().isAuto()) { + float borderBoxHeight = getHeight(parentBox.content().height(), verticalAdditions, style); + box.content().height(Math.max(0f, borderBoxHeight - verticalAdditions)); + if (!skipChildren) { + layoutFlowChildren(e); + } + finishBlockFlow(e, ctx); + return; + } + + float borderBoxHeight; + if (e instanceof InputElement input && input.textInput()) { borderBoxHeight = getTextInputHeight(e, style, parentBox.content().height(), verticalAdditions); } else if (e instanceof InputElement input && input.buttonInput()) { @@ -173,12 +186,14 @@ private void layoutStaticBlock( borderBoxHeight = getHeight(parentBox.content().height(), childrenHeight + verticalAdditions, style); } - float contentHeight = borderBoxHeight - verticalAdditions; - box.content().height(contentHeight); + box.content().height(Math.max(0f, borderBoxHeight - verticalAdditions)); + finishBlockFlow(e, ctx); + } + private void finishBlockFlow(Element e, LayoutContext ctx) { ctx.lastTextEndY(null); ctx.previousNode(e); - ctx.lastBlockBottomY(box.borderBox().y() + box.borderBox().height()); + ctx.lastBlockBottomY(e.box().borderBox().y() + e.box().borderBox().height()); } private void layoutAbsoluteBlock( @@ -215,10 +230,8 @@ private void layoutAbsoluteBlock( float parentPaddingBoxHeight = parentBox.paddingBox().height(); float parentOffset = parentBox.content().y(); contentY = getAutoVerticalContentY(ctx, e.box().border(), e.box().padding(), parentOffset); - borderBoxHeight = getHeight(parentPaddingBoxHeight, childrenHeight + verticalAdditions, style); - } else { float parentPaddingBoxHeight = ancestor.box().padding().top() @@ -259,7 +272,7 @@ private void layoutAbsoluteBlock( } e.box().content().y(contentY); - e.box().content().height(borderBoxHeight - verticalAdditions); + e.box().content().height(Math.max(0f, borderBoxHeight - verticalAdditions)); } private boolean stretchesVertically(ResolvedStyle style) { @@ -310,9 +323,7 @@ private float getBorderBoxHeight( private static float getAutoVerticalContentY( LayoutContext ctx, Edges border, Edges padding, float parentOffset) { - float contentY; - contentY = parentOffset + border.top() + padding.top(); - + float contentY = parentOffset + border.top() + padding.top(); Float blockBottomY = ctx.lastBlockBottomY(); if (blockBottomY != null) { contentY = blockBottomY + border.top(); @@ -359,9 +370,8 @@ private void calculateHorizontalPositionAndWidth( } contentWidth -= horizontalAdditions; - box.content().x(contentX); - box.content().width(contentWidth); + box.content().width(Math.max(0f, contentWidth)); } private void layoutRelativeBlock( From ef8b54f033a6c585ed2d4af14009803c1eb149b8 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 18:03:12 +0200 Subject: [PATCH 06/14] Add regression coverage for stretched absolute containers --- .../AbsoluteContainingBlockLayoutTest.java | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java diff --git a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java new file mode 100644 index 00000000..91b769d2 --- /dev/null +++ b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java @@ -0,0 +1,150 @@ +package com.spinyowl.spinygui.core.layout.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.spinyowl.spinygui.core.font.Font; +import com.spinyowl.spinygui.core.layout.ElementLayout; +import com.spinyowl.spinygui.core.layout.LayoutContext; +import com.spinyowl.spinygui.core.layout.LayoutService; +import com.spinyowl.spinygui.core.node.Element; +import com.spinyowl.spinygui.core.node.Frame; +import com.spinyowl.spinygui.core.node.Node; +import com.spinyowl.spinygui.core.node.NodeBuilder; +import com.spinyowl.spinygui.core.style.ResolvedStyle; +import com.spinyowl.spinygui.core.style.types.Display; +import com.spinyowl.spinygui.core.style.types.Position; +import com.spinyowl.spinygui.core.style.types.border.BorderStyle; +import com.spinyowl.spinygui.core.style.types.length.Length; +import com.spinyowl.spinygui.core.style.types.length.Unit; +import com.spinyowl.spinygui.core.system.font.FontMetrics; +import com.spinyowl.spinygui.core.system.font.TextLineMetrics; +import java.util.List; +import lombok.NonNull; +import org.junit.jupiter.api.Test; + +class AbsoluteContainingBlockLayoutTest { + + @Test + void layout_whenAbsoluteAutoHeightUsesTopAndBottom_sizesBeforeNestedLayout() { + Frame frame = NodeBuilder.frame(); + frame.frameSize(400, 300); + style(frame); + + Element root = NodeBuilder.div(); + style(root); + root.resolvedStyle().position(Position.RELATIVE); + root.resolvedStyle().width(Length.pixel(400)); + root.resolvedStyle().height(Length.pixel(300)); + + Element workspace = NodeBuilder.div(); + style(workspace); + workspace.resolvedStyle().position(Position.ABSOLUTE); + workspace.resolvedStyle().left(Length.ZERO); + workspace.resolvedStyle().right(Length.ZERO); + workspace.resolvedStyle().top(Length.pixel(48)); + workspace.resolvedStyle().bottom(Length.pixel(28)); + workspace.resolvedStyle().height(Unit.AUTO); + + Element nested = NodeBuilder.div(); + style(nested); + nested.resolvedStyle().position(Position.ABSOLUTE); + nested.resolvedStyle().left(Length.ZERO); + nested.resolvedStyle().right(Length.ZERO); + nested.resolvedStyle().top(Length.ZERO); + nested.resolvedStyle().bottom(Length.ZERO); + nested.resolvedStyle().height(Unit.AUTO); + + workspace.addChild(nested); + root.addChild(workspace); + frame.addChild(root); + + RecursiveLayoutService layoutService = new RecursiveLayoutService(); + FixedTextMeasurer measurer = new FixedTextMeasurer(); + BlockLayout blockLayout = + new BlockLayout(layoutService, new InlineFormattingContext(measurer), measurer); + layoutService.blockLayout(blockLayout); + + blockLayout.layout(frame, new LayoutContext()); + + assertEquals(300f, root.box().content().height()); + assertEquals(224f, workspace.box().content().height()); + assertEquals(224f, nested.box().content().height()); + assertTrue(Float.isFinite(workspace.box().borderBox().height())); + assertTrue(workspace.box().borderBox().height() >= 0f); + assertTrue(Float.isFinite(nested.box().borderBox().height())); + assertTrue(nested.box().borderBox().height() >= 0f); + } + + private static void style(Element element) { + ResolvedStyle style = element.resolvedStyle(); + style.display(Display.BLOCK); + style.position(Position.STATIC); + style.width(Unit.AUTO); + style.height(Unit.AUTO); + style.minWidth(null); + style.maxWidth(null); + style.minHeight(null); + style.maxHeight(null); + style.paddingTop(Length.ZERO); + style.paddingRight(Length.ZERO); + style.paddingBottom(Length.ZERO); + style.paddingLeft(Length.ZERO); + style.marginTop(Length.ZERO); + style.marginRight(Length.ZERO); + style.marginBottom(Length.ZERO); + style.marginLeft(Length.ZERO); + style.borderTopWidth(Length.ZERO); + style.borderRightWidth(Length.ZERO); + style.borderBottomWidth(Length.ZERO); + style.borderLeftWidth(Length.ZERO); + style.borderTopStyle(BorderStyle.NONE); + style.borderRightStyle(BorderStyle.NONE); + style.borderBottomStyle(BorderStyle.NONE); + style.borderLeftStyle(BorderStyle.NONE); + } + + private static final class RecursiveLayoutService implements LayoutService { + private ElementLayout blockLayout; + + void blockLayout(ElementLayout blockLayout) { + this.blockLayout = blockLayout; + } + + @Override + public void layout(@NonNull Frame frame) { + layoutNode(frame, new LayoutContext()); + } + + @Override + public void layoutNode(@NonNull Node node, @NonNull LayoutContext context) { + if (node instanceof Element element) { + blockLayout.layout(element, context); + } + } + + @Override + public void layoutChildNodes(@NonNull Element element, @NonNull LayoutContext context) { + LayoutContext inner = new LayoutContext(); + element.childNodes().forEach(node -> layoutNode(node, inner)); + } + } + + private static final class FixedTextMeasurer extends AbstractFixedTextMeasurer { + @Override + public TextLineMetrics getTextLineMetrics( + @NonNull String text, @NonNull Font font, float fontSize, float lineHeight) { + FontMetrics fontMetrics = new FontMetrics(8, 2, 0, 10, 8); + return TextLineMetrics.builder() + .characters(text) + .startIndex(0) + .endIndex(text.length()) + .charCount(text.length()) + .width(text.length() * 10f) + .height(fontMetrics.lineHeight()) + .baseline(fontMetrics.baseline()) + .fontMetrics(fontMetrics) + .build(); + } + } +} From 73ac9809f0580f5cc29245a64e51c1ec0bc62766 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 19:55:42 +0200 Subject: [PATCH 07/14] Fix CI Java version for Java 25 build --- .github/workflows/gradle.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 932523e4..7237003a 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -11,14 +11,14 @@ jobs: runs-on: ubuntu-latest steps: # CHECKOUT - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # PREPARE - name: Setup Java JDK uses: actions/setup-java@v4.2.1 with: - java-version: 21 - distribution: 'adopt' + java-version: 25 + distribution: 'temurin' cache: 'gradle' # BUILD From 290810f57deb822d5adab7a56d16da023fa9155c Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 19:55:53 +0200 Subject: [PATCH 08/14] Fix CodeQL Java version for Java 25 build --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index be634859..12acb737 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL @@ -40,8 +40,8 @@ jobs: - name: Setup Java JDK uses: actions/setup-java@v4.2.1 with: - java-version: 21 - distribution: 'adopt' + java-version: 25 + distribution: 'temurin' cache: 'gradle' # BUILD From 60c9c22cbbc03d0d3c516ffcdd87588a329053a7 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 20:03:13 +0200 Subject: [PATCH 09/14] Ignore local Git worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a4d76839..c15c6469 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ hs_err_pid* .project /.settings/ /lwjgl-extract/ +/.worktrees/ # Ignore Gradle project-specific cache directory .gradle From 78dd1282e8acf78a013fba11cfa6df2a00304cb7 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 20:03:34 +0200 Subject: [PATCH 10/14] Remove accidentally tracked worktree gitlink --- .worktrees/nested-scroll-text-rendering | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .worktrees/nested-scroll-text-rendering diff --git a/.worktrees/nested-scroll-text-rendering b/.worktrees/nested-scroll-text-rendering deleted file mode 160000 index dcbd3bdf..00000000 --- a/.worktrees/nested-scroll-text-rendering +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dcbd3bdf6590b3292e5e2d677fe983c9e9cdf78f From 1d9a586cad12ff4eb8fbc6fb217525ab38846182 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 20:13:04 +0200 Subject: [PATCH 11/14] Fix absolute containing block test border widths --- .../layout/impl/AbsoluteContainingBlockLayoutTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java index 91b769d2..396cbfb6 100644 --- a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java +++ b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java @@ -94,10 +94,10 @@ private static void style(Element element) { style.marginRight(Length.ZERO); style.marginBottom(Length.ZERO); style.marginLeft(Length.ZERO); - style.borderTopWidth(Length.ZERO); - style.borderRightWidth(Length.ZERO); - style.borderBottomWidth(Length.ZERO); - style.borderLeftWidth(Length.ZERO); + style.borderTopWidth(Length.pixel(0)); + style.borderRightWidth(Length.pixel(0)); + style.borderBottomWidth(Length.pixel(0)); + style.borderLeftWidth(Length.pixel(0)); style.borderTopStyle(BorderStyle.NONE); style.borderRightStyle(BorderStyle.NONE); style.borderBottomStyle(BorderStyle.NONE); From 648f7ddf9559c2c50544c36ae3da45f5b3248394 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 20:19:41 +0200 Subject: [PATCH 12/14] Initialize font owner in absolute layout regression test --- .../layout/impl/AbsoluteContainingBlockLayoutTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java index 396cbfb6..11891f16 100644 --- a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java +++ b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java @@ -18,13 +18,20 @@ import com.spinyowl.spinygui.core.style.types.length.Length; import com.spinyowl.spinygui.core.style.types.length.Unit; import com.spinyowl.spinygui.core.system.font.FontMetrics; +import com.spinyowl.spinygui.core.system.font.FontTestOwner; import com.spinyowl.spinygui.core.system.font.TextLineMetrics; import java.util.List; import lombok.NonNull; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class AbsoluteContainingBlockLayoutTest { + @BeforeEach + void installFontOwner() { + FontTestOwner.install(); + } + @Test void layout_whenAbsoluteAutoHeightUsesTopAndBottom_sizesBeforeNestedLayout() { Frame frame = NodeBuilder.frame(); From b0139c2d8b494e2ddc7e12bba331754a72757068 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 20:47:44 +0200 Subject: [PATCH 13/14] Complete absolute layout test style fixture --- .../impl/AbsoluteContainingBlockLayoutTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java index 11891f16..884e01e6 100644 --- a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java +++ b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.spinyowl.spinygui.core.font.Font; +import com.spinyowl.spinygui.core.font.FontStyle; +import com.spinyowl.spinygui.core.font.FontWeight; import com.spinyowl.spinygui.core.layout.ElementLayout; import com.spinyowl.spinygui.core.layout.LayoutContext; import com.spinyowl.spinygui.core.layout.LayoutService; @@ -12,8 +14,12 @@ import com.spinyowl.spinygui.core.node.Node; import com.spinyowl.spinygui.core.node.NodeBuilder; import com.spinyowl.spinygui.core.style.ResolvedStyle; +import com.spinyowl.spinygui.core.style.types.Color; import com.spinyowl.spinygui.core.style.types.Display; +import com.spinyowl.spinygui.core.style.types.OverflowWrap; import com.spinyowl.spinygui.core.style.types.Position; +import com.spinyowl.spinygui.core.style.types.TextAlign; +import com.spinyowl.spinygui.core.style.types.WhiteSpace; import com.spinyowl.spinygui.core.style.types.border.BorderStyle; import com.spinyowl.spinygui.core.style.types.length.Length; import com.spinyowl.spinygui.core.style.types.length.Unit; @@ -109,6 +115,16 @@ private static void style(Element element) { style.borderRightStyle(BorderStyle.NONE); style.borderBottomStyle(BorderStyle.NONE); style.borderLeftStyle(BorderStyle.NONE); + style.fontFamilies(List.of("Roboto")); + style.fontStyle(FontStyle.NORMAL); + style.fontWeight(FontWeight.NORMAL); + style.fontSize(Length.pixel(10)); + style.lineHeight(1f); + style.color(Color.BLACK); + style.whiteSpace(WhiteSpace.NORMAL); + style.textAlign(TextAlign.LEFT); + style.overflowWrap(OverflowWrap.NORMAL); + style.tabSize(4); } private static final class RecursiveLayoutService implements LayoutService { From 95c77eb2c5b94183f7bb0f5caf86e00495f6e981 Mon Sep 17 00:00:00 2001 From: Aliaksandr Shcherbin Date: Tue, 18 Aug 2026 22:15:57 +0200 Subject: [PATCH 14/14] Fix absolute layout test default insets --- .../core/layout/impl/AbsoluteContainingBlockLayoutTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java index 884e01e6..f019f218 100644 --- a/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java +++ b/spinygui.core/src/test/java/com/spinyowl/spinygui/core/layout/impl/AbsoluteContainingBlockLayoutTest.java @@ -95,6 +95,10 @@ private static void style(Element element) { style.position(Position.STATIC); style.width(Unit.AUTO); style.height(Unit.AUTO); + style.top(Unit.AUTO); + style.right(Unit.AUTO); + style.bottom(Unit.AUTO); + style.left(Unit.AUTO); style.minWidth(null); style.maxWidth(null); style.minHeight(null);