diff --git a/CodenameOne/src/com/codename1/ui/Sheet.java b/CodenameOne/src/com/codename1/ui/Sheet.java index ff7bd12c6d7..12500b09708 100644 --- a/CodenameOne/src/com/codename1/ui/Sheet.java +++ b/CodenameOne/src/com/codename1/ui/Sheet.java @@ -988,6 +988,9 @@ private void updateBorderForPosition() { nb.strokeColor(b.getStrokeColor()); nb.strokeOpacity(b.getStrokeOpacity()); nb.stroke(b.getStrokeThickness(), b.isStrokeMM()); + // A border that came out of a stylesheet must keep sizing like a CSS box, + // otherwise repositioning the sheet silently inflates it by twice the radius + nb.cssBoxModel(b.isCssBoxModel()); b = nb; switch (getPositionInt()) { case C: diff --git a/CodenameOne/src/com/codename1/ui/plaf/RoundRectBorder.java b/CodenameOne/src/com/codename1/ui/plaf/RoundRectBorder.java index 4313a38ea30..f3ec9697080 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/RoundRectBorder.java +++ b/CodenameOne/src/com/codename1/ui/plaf/RoundRectBorder.java @@ -156,6 +156,8 @@ public final class RoundRectBorder extends Border { private boolean topRight = true; private boolean bottomLeft = true; private boolean bottomRight = true; + /// True when this border should size itself according to the CSS box model, see cssBoxModel(boolean) + private boolean cssBoxModel; private int arrowPosition = -1; private int arrowDirection = -1; @@ -165,6 +167,11 @@ public final class RoundRectBorder extends Border { private Stroke stroke1; private RoundRectBorder() { + // Every reader of shadowSpread treats it as millimeters, so seeding it with a pixel + // count makes the default spread density dependent and far larger than the 0.2mm it + // reads as. Correcting it would resize and reshadow every existing hand written + // border, so the value stays as is; borders that follow the CSS box model do not + // reserve any spread unless a shadow is actually drawn, see minimumSize(). shadowSpread = Display.getInstance().convertToPixels(0.2f); instanceCounter++; instanceVal = instanceCounter; @@ -544,6 +551,45 @@ public RoundRectBorder bezierCorners(boolean bezierCorners) { return this; } + /// Makes this border follow the CSS box model when the component is measured. + /// + /// By default a `RoundRectBorder` reports a minimum size of twice the corner + /// radius (see [#getMinimumHeight()]), which grows the component so a fully + /// rounded "pill" always fits. That is the behavior hand written code and the + /// designer rely on, but it is not how CSS behaves: in CSS `border-radius` + /// never contributes to the size of the box, it is simply scaled down when the + /// box is too small to fit it. This flag only changes the reported minimum size; + /// scaling the radius down to fit the component it is actually given happens in + /// both modes. + /// + /// Borders generated by the CSS compiler enable this mode so a rule such as + /// `border-radius: 0 3mm 3mm 0` renders with the padding the stylesheet asked + /// for instead of a component inflated to 6mm tall. + /// + /// #### Parameters + /// + /// - `cssBoxModel`: true to measure like CSS, false for the legacy pill sizing + /// + /// #### Returns + /// + /// border instance so these calls can be chained + public RoundRectBorder cssBoxModel(boolean cssBoxModel) { + if (cssBoxModel != this.cssBoxModel) { + this.cssBoxModel = cssBoxModel; + dirty = true; + } + return this; + } + + /// True if this border is measured according to the CSS box model + /// + /// #### Returns + /// + /// the cssBoxModel value + public boolean isCssBoxModel() { + return cssBoxModel; + } + /// True to draw the top left corner rounded, false to draw it as a corner /// /// #### Parameters @@ -1026,6 +1072,9 @@ private GeneralPath createShape(int shapeW, int shapeH, boolean rtl) { } + radius = scaleRadiusToFit(radius, widthF, heightF, + roundTopLeft, roundTopRight, roundBottomLeft, roundBottomRight); + if (roundTopLeft) { gp.moveTo(x + radius, y); } else { @@ -1120,13 +1169,81 @@ private GeneralPath createShape(int shapeW, int shapeH, boolean rtl) { return gp; } + /// Shrinks the corner radius until the rounded corners fit inside the shape. + /// + /// This is the corner overlap rule from the CSS backgrounds spec: for every edge the + /// radii of the two corners it connects may not add up to more than the length of that + /// edge, and when they do every corner is scaled by the same factor. A square corner + /// contributes nothing, so a shape with only its right corners rounded is allowed a + /// radius of the full height rather than half of it. + /// + /// Without this the path would fold over itself whenever a component ends up smaller + /// than the radius asks for. This applies in both sizing modes: in CSS box model mode a + /// small component is the normal case because the radius no longer inflates it, and in + /// the legacy mode a layout can still force a component below the minimum size the + /// border asked for. A folded path is never what the caller wanted, so the scaling is + /// not conditional on the mode. + private static float scaleRadiusToFit(float radius, float widthF, float heightF, + boolean roundTopLeft, boolean roundTopRight, + boolean roundBottomLeft, boolean roundBottomRight) { + if (radius <= 0) { + return radius; + } + float scale = 1f; + scale = Math.min(scale, edgeScale(widthF, radius, roundTopLeft, roundTopRight)); + scale = Math.min(scale, edgeScale(widthF, radius, roundBottomLeft, roundBottomRight)); + scale = Math.min(scale, edgeScale(heightF, radius, roundTopLeft, roundBottomLeft)); + scale = Math.min(scale, edgeScale(heightF, radius, roundTopRight, roundBottomRight)); + if (scale >= 1f) { + return radius; + } + return Math.max(0f, radius * scale); + } + + /// The factor the radius has to be multiplied by for the two corners of a single edge to fit it + private static float edgeScale(float edgeLength, float radius, boolean roundStart, boolean roundEnd) { + float used = 0; + if (roundStart) { + used += radius; + } + if (roundEnd) { + used += radius; + } + if (used <= edgeLength || used <= 0) { + return 1f; + } + return edgeLength / used; + } + @Override public int getMinimumHeight() { - return Display.getInstance().convertToPixels(shadowSpread) + Display.getInstance().convertToPixels(cornerRadius) * 2; + return minimumSize(); } @Override public int getMinimumWidth() { + return minimumSize(); + } + + /// The space this border needs on its own, regardless of the content of the component. + /// + /// In CSS mode only the shadow needs reserving: the corner radius is scaled down to fit + /// a small box exactly like CSS does, so it never dictates the size of the component. + /// The legacy mode reserves twice the radius so a component sized from its preferred + /// size has room to draw the corners at their full radius. Neither mode is a guarantee + /// about what gets painted: a layout is free to hand the border a smaller component + /// than this, and [#scaleRadiusToFit(float, float, float, boolean, boolean, boolean, boolean)] + /// then shrinks the corners to fit whatever it actually got. + private int minimumSize() { + // The shadow is painted only when shadowOpacity is positive (see + // paintBorderBackground), and a spread that rounds down to zero pixels paints + // nothing either way, so converting it here reserves exactly what gets drawn. + if (cssBoxModel) { + if (shadowOpacity <= 0) { + return 0; + } + return Display.getInstance().convertToPixels(shadowSpread); + } return Display.getInstance().convertToPixels(shadowSpread) + Display.getInstance().convertToPixels(cornerRadius) * 2; } diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index 0ba571c90f3..0ecf7e7700f 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -2109,8 +2109,8 @@ private Object createBorder(DataInputStream input, int type) throws IOException //bottomOnlyMode(input.readBoolean()); // round rect border with top-left, top-right, bottom-right, bottom-left corners // specified independently - case 0xff15: - return RoundRectBorder.create(). + case 0xff15: { + RoundRectBorder roundRect = RoundRectBorder.create(). stroke(input.readFloat(), input.readBoolean()). strokeColor(input.readInt()). strokeOpacity(input.readInt()). @@ -2125,6 +2125,13 @@ private Object createBorder(DataInputStream input, int type) throws IOException topRightMode(input.readBoolean()). bottomRightMode(input.readBoolean()). bottomLeftMode(input.readBoolean()); + // CSS box model sizing was added in resource format 1.16, themes written + // before that were all generated with the legacy pill sizing + if (minorVersion >= 16) { + roundRect.cssBoxModel(input.readBoolean()); + } + return roundRect; + } case 0xff16: // CSS border diff --git a/Ports/Android/src/AndroidMaterialTheme.res b/Ports/Android/src/AndroidMaterialTheme.res index cd754e2de98..dc9bf64f231 100644 Binary files a/Ports/Android/src/AndroidMaterialTheme.res and b/Ports/Android/src/AndroidMaterialTheme.res differ diff --git a/Ports/iOSPort/nativeSources/iOSModernTheme.res b/Ports/iOSPort/nativeSources/iOSModernTheme.res index 5f87bbb0917..df6888d7767 100644 Binary files a/Ports/iOSPort/nativeSources/iOSModernTheme.res and b/Ports/iOSPort/nativeSources/iOSModernTheme.res differ diff --git a/Themes/AndroidMaterialTheme.res b/Themes/AndroidMaterialTheme.res index 8ddb327a6b8..dc9bf64f231 100644 Binary files a/Themes/AndroidMaterialTheme.res and b/Themes/AndroidMaterialTheme.res differ diff --git a/Themes/iOSModernTheme.res b/Themes/iOSModernTheme.res index 5f87bbb0917..df6888d7767 100644 Binary files a/Themes/iOSModernTheme.res and b/Themes/iOSModernTheme.res differ diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc index 4edb67ed2af..589da8ae5da 100644 --- a/docs/developer-guide/css.asciidoc +++ b/docs/developer-guide/css.asciidoc @@ -216,6 +216,8 @@ Rounded borders can be achieved in a few different ways. The easiest methods are * **The `cn1-pill-border` style**. This will render a pill-shaped border in the background natively. This also doesn't require generation of an image border * **The `border-radius` property**. This will round the corners of the border. If the style can be achieved using the `RoundRectBorder` in CodenameOne, then it will use that border. If not, this will cause the style to be generated as an image border +A border generated from `border-radius` follows the CSS box model, so the radius rounds the component without changing its size. A radius larger than the component scales down to fit it. This differs from a `RoundRectBorder` you create in Java, which grows the component to twice the radius so the corners are always drawn in full. Use `cn1-pill-border` when a shape has to stay a pill at any height. + **Examples using `cn1-round-border`** [source,css] diff --git a/maven/core-unittests/src/test/java/com/codename1/designer/css/CSSThemeBorderRadiusTest.java b/maven/core-unittests/src/test/java/com/codename1/designer/css/CSSThemeBorderRadiusTest.java new file mode 100644 index 00000000000..59667168836 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/designer/css/CSSThemeBorderRadiusTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.plaf.Border; +import com.codename1.ui.plaf.CSSBorder; +import com.codename1.ui.plaf.RoundRectBorder; +import org.junit.jupiter.api.Test; +import org.w3c.css.sac.LexicalUnit; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Build-time tests for the border a `border-radius` rule compiles to. +/// +/// A stylesheet expects `border-radius` to round the box it already asked for. The +/// `RoundRectBorder` the compiler emits defaults to reserving twice the radius instead, +/// which is what turned a 3mm radius into an inflated button in +/// [discussion 5454](https://github.com/codenameone/CodenameOne/discussions/5454), so every +/// border the compiler generates has to be marked as sized by the CSS box model. +/// Extends the UI test base because generating a `RoundRectBorder` measures millimetres, +/// which needs a live `Display`, not just the implementation `CSSTheme.load` reads through. +class CSSThemeBorderRadiusTest extends UITestBase { + + @Test + void asymmetricRadiusCompilesToACssSizedRoundRectBorder() throws Exception { + // The exact rule from the report. + Border border = borderOf("btnSend { border-radius: 0mm 3mm 3mm 0mm; margin: 2mm 2mm 2mm 0mm;" + + " padding: 0.6mm 4mm 0.6mm 4mm; border: none; font-size: 3mm;" + + " color: white; background: black; }", "btnSend"); + + RoundRectBorder roundRect = assertInstanceOf(RoundRectBorder.class, border); + assertTrue(roundRect.isCssBoxModel(), + "a generated border must not inflate the box the stylesheet sized"); + assertEquals(3f, roundRect.getCornerRadius(), 0.001f); + assertFalse(roundRect.isTopLeft(), "the left corners stay square"); + assertFalse(roundRect.isBottomLeft(), "the left corners stay square"); + assertTrue(roundRect.isTopRight()); + assertTrue(roundRect.isBottomRight()); + assertEquals(0, roundRect.getMinimumHeight(), + "the radius may not reserve any height of its own"); + } + + @Test + void uniformRadiusCompilesToACssSizedRoundRectBorder() throws Exception { + Border border = borderOf("Dialog { border-radius: 4mm; border: none; background: white; }", "Dialog"); + + RoundRectBorder roundRect = assertInstanceOf(RoundRectBorder.class, border); + assertTrue(roundRect.isCssBoxModel()); + assertTrue(roundRect.isTopLeft() && roundRect.isTopRight() + && roundRect.isBottomLeft() && roundRect.isBottomRight(), + "all four corners are rounded"); + } + + @Test + void perCornerRadiiStillCompileToACssBorder() throws Exception { + // RoundRectBorder carries a single radius, so corners that differ from each other + // have to keep going to CSSBorder rather than being flattened into one radius. + Border border = borderOf("Mixed { border-radius: 0mm 2mm 4mm 0mm; border: none;" + + " background: black; }", "Mixed"); + + assertInstanceOf(CSSBorder.class, border); + } + + private Border borderOf(String css, String uiid) throws Exception { + File f = File.createTempFile("cn1-test-", ".css"); + f.deleteOnExit(); + // Explicit UTF-8 rather than the platform default, so the parser reads back what + // was written no matter which charset the host defaults to. + try (Writer w = new OutputStreamWriter(new FileOutputStream(f), StandardCharsets.UTF_8)) { + w.write(css); + } + CSSTheme theme = CSSTheme.load(f.toURI().toURL()); + CSSTheme.Element element = theme.elements.get(uiid); + assertNotNull(element, "Missing UIID: " + uiid); + Map styles = element.getUnselected().getFlattenedStyle(); + return element.getThemeBorder(styles); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/BorderAndPlafTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/BorderAndPlafTest.java index a6eb69dfbad..cd8eb332eee 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/BorderAndPlafTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/BorderAndPlafTest.java @@ -24,7 +24,9 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; +import com.codename1.ui.Button; import com.codename1.ui.Component; +import com.codename1.ui.Display; import com.codename1.ui.Font; import com.codename1.ui.Graphics; import com.codename1.ui.Image; @@ -39,6 +41,7 @@ import com.codename1.ui.plaf.StyleParser.ScalarValue; import com.codename1.ui.plaf.StyleParser.StyleInfo; import java.lang.reflect.Field; +import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -177,6 +180,174 @@ void testRoundRectBorderMirrorsAsymmetricCornersInRTL() { "RTL should mirror the square top-left corner to the top-right"); } + @FormTest + void testRoundRectBorderReservesTwiceTheRadiusByDefault() { + RoundRectBorder border = RoundRectBorder.create().cornerRadius(3f).shadowSpread(0f); + + assertFalse(border.isCssBoxModel(), "the legacy pill sizing is the default"); + int radius = Display.getInstance().convertToPixels(3f); + assertEquals(radius * 2, border.getMinimumHeight(), + "hand written borders keep growing the component so the full radius is drawn"); + assertEquals(radius * 2, border.getMinimumWidth()); + } + + @FormTest + void testCssBoxModelBorderDoesNotReserveSpaceForTheRadius() { + RoundRectBorder border = RoundRectBorder.create().cornerRadius(3f).cssBoxModel(true); + + assertTrue(border.isCssBoxModel()); + assertEquals(0, border.getMinimumHeight(), + "border-radius never contributes to the size of a CSS box"); + assertEquals(0, border.getMinimumWidth()); + } + + @FormTest + void testCssBoxModelBorderStillReservesRoomForItsShadow() { + RoundRectBorder border = RoundRectBorder.create() + .cornerRadius(3f) + .cssBoxModel(true) + .shadowSpread(2f) + .shadowOpacity(128); + + assertEquals(Display.getInstance().convertToPixels(2f), border.getMinimumHeight(), + "a drawn shadow still needs its spread reserved, the radius does not"); + } + + /// Regression test for https://github.com/codenameone/CodenameOne/discussions/5454: + /// `border-radius: 0 3mm 3mm 0` on a button used to inflate its height because the + /// generated `RoundRectBorder` reserved twice the radius. + @FormTest + void testCssBoxModelBorderDoesNotInflateAButton() { + Button squareCorners = new Button("Send"); + squareCorners.getAllStyles().setBorder(Border.createEmpty()); + int expected = squareCorners.getPreferredSize().getHeight(); + + // A radius in millimeters that converts to more pixels than the button is tall, so + // the legacy reservation is the value that wins in the preferred size calculation + // and the difference between the two modes is visible here at any density. + float pxPerMm = Display.getInstance().convertToPixels(1f); + float radius = expected / pxPerMm + 1f; + assertTrue(rightRoundedCorners(radius).getMinimumHeight() > expected, + "test setup: the radius has to exceed the natural height of the button"); + + Button cssRounded = new Button("Send"); + cssRounded.getAllStyles().setBorder(rightRoundedCorners(radius).cssBoxModel(true)); + + Button legacyRounded = new Button("Send"); + legacyRounded.getAllStyles().setBorder(rightRoundedCorners(radius)); + + assertEquals(expected, cssRounded.getPreferredSize().getHeight(), + "rounding the corners must not change the height the stylesheet asked for"); + assertEquals(rightRoundedCorners(radius).getMinimumHeight(), + legacyRounded.getPreferredSize().getHeight(), + "the legacy sizing is unchanged, it still grows the button to twice the radius"); + } + + private static RoundRectBorder rightRoundedCorners(float radius) { + return RoundRectBorder.create() + .cornerRadius(radius) + .shadowSpread(0f) + .topLeftMode(false) + .bottomLeftMode(false) + .topRightMode(true) + .bottomRightMode(true); + } + + @FormTest + void testRoundRectBorderScalesTheRadiusDownToFitTheShape() { + // Both corners of every edge are rounded and the box is only as big as a single + // radius, so each corner may use at most half of what the border asks for. + int radius = Display.getInstance().convertToPixels(4f); + + List shape = shapeOf(RoundRectBorder.create().cornerRadius(4f).cssBoxModel(true), + radius, radius); + + // The path opens at the point where the rounded top-left corner ends. + float[] moveTo = shape.get(0); + assertEquals(PathIterator.SEG_MOVETO, (int) moveTo[0]); + assertTrue(moveTo[1] > 0, "the corner is still rounded"); + assertTrue(moveTo[1] <= radius / 2f + 0.001f, + "two rounded corners sharing an edge may use at most half of it each, was " + + moveTo[1] + " of " + radius); + } + + @FormTest + void testRoundRectBorderScalesTheRadiusPerEdgeNotPerShape() { + // Only the top-right corner is rounded, so nothing shares the top or the right edge + // with it and CSS lets it use a whole edge rather than half of one. + int radius = Display.getInstance().convertToPixels(4f); + int width = radius * 2; + int height = radius / 2; + + RoundRectBorder border = RoundRectBorder.create() + .cornerRadius(4f) + .cssBoxModel(true) + .topLeftMode(false) + .bottomLeftMode(false) + .bottomRightMode(false) + .topRightMode(true); + + List shape = shapeOf(border, width, height); + + // A square top-left corner opens the path at the origin, the line that follows runs + // along the top edge and stops where the single rounded corner begins. + float[] moveTo = shape.get(0); + assertEquals(PathIterator.SEG_MOVETO, (int) moveTo[0]); + assertEquals(0f, moveTo[1], 0.001f, "the square top-left corner starts at the origin"); + + float[] topEdge = shape.get(1); + assertEquals(PathIterator.SEG_LINETO, (int) topEdge[0]); + assertEquals(width - height, topEdge[1], 0.001f, + "the lone rounded corner scales to the whole height, not to half of it"); + } + + @FormTest + void testLegacyBorderAlsoScalesTheRadiusWhenForcedSmaller() { + // The legacy minimum size asks for room to draw the full radius, but a layout is + // free to ignore it. The corners then scale to whatever the component actually got + // rather than folding the path over itself, in this mode too. + int radius = Display.getInstance().convertToPixels(4f); + RoundRectBorder border = RoundRectBorder.create().cornerRadius(4f).shadowSpread(0f); + assertFalse(border.isCssBoxModel(), "this is the legacy sizing"); + assertTrue(border.getMinimumHeight() > radius, "test setup: the border wants more than it gets"); + + List shape = shapeOf(border, radius, radius); + + float[] moveTo = shape.get(0); + assertEquals(PathIterator.SEG_MOVETO, (int) moveTo[0]); + assertTrue(moveTo[1] > 0 && moveTo[1] <= radius / 2f + 0.001f, + "the corner scales to the component it was given, was " + moveTo[1] + " of " + radius); + } + + /// Paints the border into a component of the given size and returns the shape it filled + /// as `{segmentType, x, y}` rows. The tracked shape has to be walked with a single + /// iterator, a second one over the same shape does not start from the beginning. + private List shapeOf(RoundRectBorder border, int width, int height) { + Label label = new Label(); + label.setWidth(width); + label.setHeight(height); + label.getStyle().setBackgroundType(Style.BACKGROUND_NONE); + label.getStyle().setBgTransparency(0xff); + label.getStyle().setBgColor(0); + label.getStyle().setBorder(border); + + implementation.setShapeSupported(true); + implementation.resetShapeTracking(); + border.paintBorderBackground(Image.createImage(width, height).getGraphics(), label); + + assertTrue(implementation.wasFillShapeInvoked()); + List segments = new ArrayList(); + PathIterator path = implementation.getLastFillShape().getPathIterator(); + float[] coordinates = new float[6]; + while (!path.isDone()) { + int type = path.currentSegment(coordinates); + segments.add(new float[]{type, coordinates[0], coordinates[1]}); + path.next(); + } + assertFalse(segments.isEmpty(), "the border should have filled a shape"); + return segments; + } + @FormTest void testDefaultLookAndFeelBidiAlignmentReversal() { Component component = new com.codename1.ui.Label(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/util/RoundRectBorderCssBoxModelResourceTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/util/RoundRectBorderCssBoxModelResourceTest.java new file mode 100644 index 00000000000..221f90bd012 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/util/RoundRectBorderCssBoxModelResourceTest.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.util; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.plaf.RoundRectBorder; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.util.Hashtable; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Round-trips a {@link RoundRectBorder} through the resource writer + * ({@link EditableResources#save}) and reader ({@link Resources}) to guard the CSS box + * model flag added in resource format 1.16. Every border the CSS compiler generates + * carries the flag, so a broken read/write pairing would silently restore the sizing + * regression from https://github.com/codenameone/CodenameOne/discussions/5454 in a + * shipped theme. + */ +public class RoundRectBorderCssBoxModelResourceTest extends UITestBase { + + @Test + public void cssBoxModelFlagSurvivesResSaveLoad() throws Exception { + RoundRectBorder border = RoundRectBorder.create() + .cornerRadius(3f) + .cssBoxModel(true) + .topLeftMode(false) + .bottomLeftMode(false) + .topRightMode(true) + .bottomRightMode(true); + + RoundRectBorder loaded = saveAndReload(border); + assertTrue(loaded.isCssBoxModel(), "CSS box model flag survived the round-trip"); + // The fields written around the new one must be unaffected by it. + assertEquals(3f, loaded.getCornerRadius(), 0.001f, "corner radius survived"); + assertFalse(loaded.isTopLeft(), "square top-left corner survived"); + assertTrue(loaded.isTopRight(), "rounded top-right corner survived"); + assertTrue(loaded.isBottomRight(), "rounded bottom-right corner survived"); + assertFalse(loaded.isBottomLeft(), "square bottom-left corner survived"); + } + + @Test + public void handWrittenBorderStaysOnTheLegacySizing() throws Exception { + RoundRectBorder border = RoundRectBorder.create().cornerRadius(2f); + assertFalse(border.isCssBoxModel(), "borders default to the legacy pill sizing"); + + assertFalse(saveAndReload(border).isCssBoxModel(), "legacy sizing survived the round-trip"); + } + + @Test + public void resourcesWrittenBeforeTheFlagExistedLoadAsLegacy() throws Exception { + byte[] resource = save(RoundRectBorder.create().cornerRadius(2f).cssBoxModel(true)); + // Rewrite the header as the last format that had no CSS box model flag. The reader + // must then ignore the trailing byte rather than mistaking it for another field. + assertEquals(16, minorVersionOf(resource), "the writer bumped the format to 1.16"); + setMinorVersion(resource, 15); + + Resources loaded = new Resources(new ByteArrayInputStream(resource), -1); + RoundRectBorder border = (RoundRectBorder) loaded.getTheme("t").get("RoundButton.border"); + assertFalse(border.isCssBoxModel(), "a 1.15 resource predates the flag, so it is legacy"); + assertEquals(2f, border.getCornerRadius(), 0.001f, "the fields before it still read back"); + } + + private static RoundRectBorder saveAndReload(RoundRectBorder border) throws Exception { + Resources loaded = new Resources(new ByteArrayInputStream(save(border)), -1); + Object roundTripped = loaded.getTheme("t").get("RoundButton.border"); + assertInstanceOf(RoundRectBorder.class, roundTripped, "border round-trips as a RoundRectBorder"); + return (RoundRectBorder) roundTripped; + } + + private static byte[] save(RoundRectBorder border) throws Exception { + Hashtable theme = new Hashtable(); + theme.put("RoundButton.border", border); + + EditableResources editable = new EditableResources(); + editable.setTheme("t", theme); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + editable.save(out); + return out.toByteArray(); + } + + /// The minor version lives in the header, after the resource count, the magic byte, an + /// empty UTF string, the header size and the major version. + private static int minorVersionOfOffset(byte[] resource) throws Exception { + DataInputStream in = new DataInputStream(new ByteArrayInputStream(resource)); + in.readShort(); + in.readByte(); + int utfLength = in.readUnsignedShort(); + return 2 + 1 + 2 + utfLength + 2 + 2; + } + + private static int minorVersionOf(byte[] resource) throws Exception { + int offset = minorVersionOfOffset(resource); + return ((resource[offset] & 0xff) << 8) | (resource[offset + 1] & 0xff); + } + + private static void setMinorVersion(byte[] resource, int version) throws Exception { + int offset = minorVersionOfOffset(resource); + resource[offset] = (byte) (version >> 8); + resource[offset + 1] = (byte) version; + } +} diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 55f571881ea..ca66f814860 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -6099,7 +6099,13 @@ private com.codename1.ui.plaf.Border createRoundRectBorder(Map\n"); + + "cornerRadius=\"" + rb.getCornerRadius()+ "\" " + + "bezierCorners=\"" + rb.isBezierCorners()+ "\" " + + "cssBoxModel=\"" + rb.isCssBoxModel()+ "\" />\n"); continue; } @@ -2340,6 +2342,8 @@ private void writeBorder(DataOutputStream output, Border border, boolean newVers output.writeBoolean(rb.isTopRight()); output.writeBoolean(rb.isBottomRight()); output.writeBoolean(rb.isBottomLeft()); + // CSS box model sizing, added in resource format 1.16 + output.writeBoolean(rb.isCssBoxModel()); return; } int type = Accessor.getType(border); diff --git a/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Border.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Border.java index cb8ceb94f89..14bf8bea3a8 100644 --- a/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Border.java +++ b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Border.java @@ -100,9 +100,11 @@ public class Border { private boolean bezierCorners; private boolean topOnlyMode; - + private boolean bottomOnlyMode; - + + private boolean cssBoxModel; + /** * @return the key */ @@ -359,6 +361,20 @@ public boolean isBottomOnlyMode() { return bottomOnlyMode; } + /** + * @return the cssBoxModel + */ + public boolean isCssBoxModel() { + return cssBoxModel; + } + + /** + * @param cssBoxModel the cssBoxModel to set + */ + public void setCssBoxModel(boolean cssBoxModel) { + this.cssBoxModel = cssBoxModel; + } + /** * @return the millimeters */ diff --git a/native-themes/android-material/theme.css b/native-themes/android-material/theme.css index 54d0a956bf5..8b854a28ca8 100644 --- a/native-themes/android-material/theme.css +++ b/native-themes/android-material/theme.css @@ -57,6 +57,16 @@ * --accent-pressed to "state-pressed". Keep light/dark variants in * sync (-dark suffix) so an app override affecting only the light * constant doesn't desynchronise dark mode. + * + * border-radius sizing: a rule with border-radius and no round/pill + * background type compiles to a RoundRectBorder flagged as CSS box + * model, so the radius rounds the box the padding below asks for + * instead of growing it to twice the radius (the RoundRectBorder + * default that hand written code still gets). A radius bigger than the + * box scales down to fit, per the CSS corner overlap rule. Size every + * UIID here through padding/margin and treat the radius as decoration. + * A shape that must stay a pill at any height wants cn1-pill-border, + * not a large border-radius. */ #Constants { diff --git a/native-themes/ios-modern/theme.css b/native-themes/ios-modern/theme.css index fe19855cc34..959129430a4 100644 --- a/native-themes/ios-modern/theme.css +++ b/native-themes/ios-modern/theme.css @@ -51,6 +51,16 @@ * in. See PaletteOverrideThemeScreenshotTest for a worked example. * Keep light/dark variants in sync (-dark suffix) so an app override * affecting only the light constant doesn't desynchronise dark mode. + * + * border-radius sizing: a rule with border-radius and no round/pill + * background type compiles to a RoundRectBorder flagged as CSS box + * model, so the radius rounds the box the padding below asks for + * instead of growing it to twice the radius (the RoundRectBorder + * default that hand written code still gets). A radius bigger than the + * box scales down to fit, per the CSS corner overlap rule. Size every + * UIID here through padding/margin and treat the radius as decoration. + * A shape that must stay a pill at any height wants cn1-pill-border, + * not a large border-radius. */ #Constants { diff --git a/scripts/android/screenshots/ChatView_dark.png b/scripts/android/screenshots/ChatView_dark.png index fe4a27ccacf..a407a445b1b 100644 Binary files a/scripts/android/screenshots/ChatView_dark.png and b/scripts/android/screenshots/ChatView_dark.png differ diff --git a/scripts/android/screenshots/ChatView_light.png b/scripts/android/screenshots/ChatView_light.png index 698fb342f44..8c6f168185c 100644 Binary files a/scripts/android/screenshots/ChatView_light.png and b/scripts/android/screenshots/ChatView_light.png differ diff --git a/scripts/ios/screenshots-metal/ChatView_dark.png b/scripts/ios/screenshots-metal/ChatView_dark.png index 93f815f011a..a317dd9cdff 100644 Binary files a/scripts/ios/screenshots-metal/ChatView_dark.png and b/scripts/ios/screenshots-metal/ChatView_dark.png differ diff --git a/scripts/ios/screenshots-metal/ChatView_light.png b/scripts/ios/screenshots-metal/ChatView_light.png index 10a9753c00f..763b6d987fa 100644 Binary files a/scripts/ios/screenshots-metal/ChatView_light.png and b/scripts/ios/screenshots-metal/ChatView_light.png differ diff --git a/scripts/ios/screenshots-tv/ChatView_dark.png b/scripts/ios/screenshots-tv/ChatView_dark.png index 0f98713e841..f480750385a 100644 Binary files a/scripts/ios/screenshots-tv/ChatView_dark.png and b/scripts/ios/screenshots-tv/ChatView_dark.png differ diff --git a/scripts/ios/screenshots-tv/ChatView_light.png b/scripts/ios/screenshots-tv/ChatView_light.png index 54b965f9590..28285dabb51 100644 Binary files a/scripts/ios/screenshots-tv/ChatView_light.png and b/scripts/ios/screenshots-tv/ChatView_light.png differ diff --git a/scripts/ios/screenshots/ChatView_dark.png b/scripts/ios/screenshots/ChatView_dark.png index 8caeda9e3f6..bf5ed67437f 100644 Binary files a/scripts/ios/screenshots/ChatView_dark.png and b/scripts/ios/screenshots/ChatView_dark.png differ diff --git a/scripts/ios/screenshots/ChatView_light.png b/scripts/ios/screenshots/ChatView_light.png index 4f1b76661bf..10ea91a8a67 100644 Binary files a/scripts/ios/screenshots/ChatView_light.png and b/scripts/ios/screenshots/ChatView_light.png differ diff --git a/scripts/javascript/screenshots/ChatView_dark.png b/scripts/javascript/screenshots/ChatView_dark.png index ed459dddbcc..dfed073d35f 100644 Binary files a/scripts/javascript/screenshots/ChatView_dark.png and b/scripts/javascript/screenshots/ChatView_dark.png differ diff --git a/scripts/javascript/screenshots/ChatView_ios_dark.png b/scripts/javascript/screenshots/ChatView_ios_dark.png index 2158f59626d..2408d73dbaf 100644 Binary files a/scripts/javascript/screenshots/ChatView_ios_dark.png and b/scripts/javascript/screenshots/ChatView_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ChatView_ios_light.png b/scripts/javascript/screenshots/ChatView_ios_light.png index 2a665bdfed7..fc3606a9bce 100644 Binary files a/scripts/javascript/screenshots/ChatView_ios_light.png and b/scripts/javascript/screenshots/ChatView_ios_light.png differ diff --git a/scripts/javascript/screenshots/ChatView_light.png b/scripts/javascript/screenshots/ChatView_light.png index c480f6f6877..e15ab6abe85 100644 Binary files a/scripts/javascript/screenshots/ChatView_light.png and b/scripts/javascript/screenshots/ChatView_light.png differ diff --git a/scripts/mac-native/screenshots/ChatView_dark.png b/scripts/mac-native/screenshots/ChatView_dark.png index 675f8d9226c..c78f208f1c8 100644 Binary files a/scripts/mac-native/screenshots/ChatView_dark.png and b/scripts/mac-native/screenshots/ChatView_dark.png differ diff --git a/scripts/mac-native/screenshots/ChatView_light.png b/scripts/mac-native/screenshots/ChatView_light.png index aaf0ba3e8e7..5fa83409ded 100644 Binary files a/scripts/mac-native/screenshots/ChatView_light.png and b/scripts/mac-native/screenshots/ChatView_light.png differ