Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CodenameOne/src/com/codename1/ui/Sheet.java
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
119 changes: 118 additions & 1 deletion CodenameOne/src/com/codename1/ui/plaf/RoundRectBorder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1026,6 +1072,9 @@ private GeneralPath createShape(int shapeW, int shapeH, boolean rtl) {

}

radius = scaleRadiusToFit(radius, widthF, heightF,
roundTopLeft, roundTopRight, roundBottomLeft, roundBottomRight);
Comment thread
shai-almog marked this conversation as resolved.

Comment thread
shai-almog marked this conversation as resolved.
if (roundTopLeft) {
gp.moveTo(x + radius, y);
} else {
Expand Down Expand Up @@ -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);
}
Comment thread
shai-almog marked this conversation as resolved.
return Display.getInstance().convertToPixels(shadowSpread) + Display.getInstance().convertToPixels(cornerRadius) * 2;
}

Expand Down
11 changes: 9 additions & 2 deletions CodenameOne/src/com/codename1/ui/util/Resources.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()).
Expand All @@ -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
Expand Down
Binary file modified Ports/Android/src/AndroidMaterialTheme.res
Binary file not shown.
Binary file modified Ports/iOSPort/nativeSources/iOSModernTheme.res
Binary file not shown.
Binary file modified Themes/AndroidMaterialTheme.res
Binary file not shown.
Binary file modified Themes/iOSModernTheme.res
Binary file not shown.
2 changes: 2 additions & 0 deletions docs/developer-guide/css.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
shai-almog marked this conversation as resolved.
CSSTheme theme = CSSTheme.load(f.toURI().toURL());
CSSTheme.Element element = theme.elements.get(uiid);
assertNotNull(element, "Missing UIID: " + uiid);
Map<String, LexicalUnit> styles = element.getUnselected().getFlattenedStyle();
return element.getThemeBorder(styles);
}
}
Loading
Loading