From 17f33e22728db61116226277ac27b41761a8d70c Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Thu, 19 Mar 2026 22:45:17 -0300 Subject: [PATCH 01/18] feat(gfx): add RRect geometry and native helper bindings Introduce the RRect and Span gfx primitives on the SDK side and wire the private geometry helpers to native implementations. Register the new bindings in the VM and include gfx_RRect.c in the build. --- .../main/java/totalcross/ui/gfx/RRect.java | 269 ++++++++++++++++++ .../src/main/java/totalcross/ui/gfx/Span.java | 36 +++ TotalCrossVM/CMakeLists.txt | 1 + TotalCrossVM/src/init/nativeProcAddressesTC.c | 6 + TotalCrossVM/src/nm/NativeMethods.h | 6 + TotalCrossVM/src/nm/NativeMethods.txt | 6 + .../src/nm/NativeMethodsPrototypes.txt | 30 ++ TotalCrossVM/src/nm/ui/gfx.h | 21 ++ TotalCrossVM/src/nm/ui/gfx_RRect.c | 134 +++++++++ 9 files changed, 509 insertions(+) create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/gfx/RRect.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/gfx/Span.java create mode 100644 TotalCrossVM/src/nm/ui/gfx.h create mode 100644 TotalCrossVM/src/nm/ui/gfx_RRect.c diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/RRect.java b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/RRect.java new file mode 100644 index 000000000..03a656c02 --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/RRect.java @@ -0,0 +1,269 @@ +// Copyright (C) 2000-2013 SuperWaba Ltda. +// Copyright (C) 2014-2020 TotalCross Global Mobile Platform Ltda. +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.gfx; + +import com.totalcross.annotations.ReplacedByNativeOnDeploy; +import java.util.Arrays; + +/** + * Describes a rounded rectangle. + *

+ * The {@link #radii} array is always normalized to 8 components, storing + * {@code rx, ry} for each corner in clockwise order: + * top-left, top-right, bottom-right, bottom-left. + *

+ * The constructor accepts radii arrays with 1, 2, 4, or 8 values: + *

+ */ +public class RRect extends Rect { + private static final double[] ZERO_RADII = new double[] {0, 0, 0, 0, 0, 0, 0, 0}; + + /** + * Corner radii normalized to 8 components in the order + * top-left, top-right, bottom-right, bottom-left, each one as {@code rx, ry}. + */ + public final double[] radii; + + /** + * Creates a rounded rectangle. + * + * @param x the x coordinate + * @param y the y coordinate + * @param width the width + * @param height the height + * @param radii the corner radii in 1, 2, 4, or 8-component form + */ + public RRect(int x, int y, int width, int height, double[] radii) { + super(x, y, width, height); + this.radii = normalizeRadii(radii); + } + + /** + * Creates a rounded rectangle with the same radius on every corner axis. + * + * @param x the x coordinate + * @param y the y coordinate + * @param width the width + * @param height the height + * @param radius the radius repeated across all corner components + */ + public RRect(int x, int y, int width, int height, double radius) { + this(x, y, width, height, new double[] {radius}); + } + + /** + * Creates a rounded rectangle with zero radii on every corner. + * + * @param x the x coordinate + * @param y the y coordinate + * @param width the width + * @param height the height + */ + public RRect(int x, int y, int width, int height) { + this(x, y, width, height, 0d); + } + + private static double[] normalizeRadii(double[] radii) { + if (radii == null) { + double[] copy = new double[8]; + System.arraycopy(ZERO_RADII, 0, copy, 0, 8); + return copy; + } + + switch (radii.length) { + case 1: + return new double[] { + radii[0], radii[0], + radii[0], radii[0], + radii[0], radii[0], + radii[0], radii[0] + }; + case 2: + return new double[] { + radii[0], radii[1], + radii[0], radii[1], + radii[0], radii[1], + radii[0], radii[1] + }; + case 4: + return new double[] { + radii[0], radii[0], + radii[1], radii[1], + radii[2], radii[2], + radii[3], radii[3] + }; + case 8: + double[] copy = new double[8]; + System.arraycopy(radii, 0, copy, 0, 8); + return copy; + default: + throw new IllegalArgumentException( + "RRect.radii must have length 1, 2, 4, or 8" + ); + } + } + + /** + * Returns whether the given point is inside this rounded rectangle. + */ + @Override + public boolean contains(int xx, int yy) { + double px = xx + 0.5d; + double py = yy + 0.5d; + double left = x; + double top = y; + double right = x + width; + double bottom = y + height; + + if (px < left || px >= right || py < top || py >= bottom) { + return false; + } + + return containsCorner(px, py, left, top, radii[0], radii[1], 0) + && containsCorner(px, py, right, top, radii[2], radii[3], 1) + && containsCorner(px, py, right, bottom, radii[4], radii[5], 2) + && containsCorner(px, py, left, bottom, radii[6], radii[7], 3); + } + + @ReplacedByNativeOnDeploy + private static boolean containsCorner(double px, double py, double x, double y, double rx, double ry, int corner) { + if (rx <= 0 || ry <= 0) { + return true; + } + + double cx = (corner == 0 || corner == 3) ? x + rx : x - rx; + double cy = (corner == 0 || corner == 1) ? y + ry : y - ry; + boolean inCornerBox = + (corner == 0 && px < x + rx && py < y + ry) || + (corner == 1 && px >= x - rx && py < y + ry) || + (corner == 2 && px >= x - rx && py >= y - ry) || + (corner == 3 && px < x + rx && py >= y - ry); + + if (!inCornerBox) { + return true; + } + + double dx = (px - cx) / rx; + double dy = (py - cy) / ry; + return dx * dx + dy * dy <= 1d; + } + + /** + * Computes the horizontal span occupied by this rounded rectangle for a given scanline. + * + * @param yy the scanline y coordinate + * @return the horizontal span for the scanline, or an invalid span if the scanline does not intersect this shape + */ + public Span horizontalSpan(int yy) { + double py = yy + 0.5d; + double left = x; + double top = y; + double right = x + width; + double bottom = y + height; + + if (py < top || py >= bottom) { + return new Span(1, 0); + } + + double start = Math.max(left, leftBoundForY(py, left, top, bottom, radii[0], radii[1], radii[6], radii[7])); + double end = Math.min(right, rightBoundForY(py, right, top, bottom, radii[2], radii[3], radii[4], radii[5])); + + return new Span((int) Math.ceil(start - 0.5d), (int) Math.floor(end - 0.5d)); + } + + @ReplacedByNativeOnDeploy + private static double leftBoundForY(double py, double left, double top, double bottom, + double topRx, double topRy, double bottomRx, double bottomRy) { + if (topRx > 0 && topRy > 0 && py < top + topRy) { + return ellipseLeftBound(left + topRx, top + topRy, topRx, topRy, py); + } + if (bottomRx > 0 && bottomRy > 0 && py >= bottom - bottomRy) { + return ellipseLeftBound(left + bottomRx, bottom - bottomRy, bottomRx, bottomRy, py); + } + return left; + } + + @ReplacedByNativeOnDeploy + private static double rightBoundForY(double py, double right, double top, double bottom, + double topRx, double topRy, double bottomRx, double bottomRy) { + if (topRx > 0 && topRy > 0 && py < top + topRy) { + return ellipseRightBound(right - topRx, top + topRy, topRx, topRy, py); + } + if (bottomRx > 0 && bottomRy > 0 && py >= bottom - bottomRy) { + return ellipseRightBound(right - bottomRx, bottom - bottomRy, bottomRx, bottomRy, py); + } + return right; + } + + @ReplacedByNativeOnDeploy + private static double ellipseLeftBound(double cx, double cy, double rx, double ry, double py) { + return cx - ellipseDx(rx, ry, py - cy); + } + + @ReplacedByNativeOnDeploy + private static double ellipseRightBound(double cx, double cy, double rx, double ry, double py) { + return cx + ellipseDx(rx, ry, py - cy); + } + + @ReplacedByNativeOnDeploy + private static double ellipseDx(double rx, double ry, double dy) { + if (rx <= 0 || ry <= 0) { + return 0d; + } + double t = 1d - (dy * dy) / (ry * ry); + if (t <= 0d) { + return 0d; + } + return rx * Math.sqrt(t); + } + + /** + * Returns a modified copy of this rounded rectangle. + */ + @Override + public RRect modifiedBy(int deltaX, int deltaY, int deltaW, int deltaH) { + return new RRect(x + deltaX, y + deltaY, width + deltaW, height + deltaH, radii); + } + + /** + * Compares this rounded rectangle with another object. + */ + @Override + public boolean equals(Object other) { + if (!(other instanceof RRect)) { + return false; + } + RRect r = (RRect) other; + return r.x == this.x + && r.y == this.y + && r.width == this.width + && r.height == this.height + && Arrays.equals(r.radii, this.radii); + } + + /** + * Returns the hash code for this rounded rectangle. + */ + @Override + public int hashCode() { + int result = super.hashCode(); + result = 31 * result + Arrays.hashCode(radii); + return result; + } + + /** + * Returns a string representation of this rounded rectangle. + */ + @Override + public String toString() { + return super.toString() + ",radii=" + Arrays.toString(radii); + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Span.java b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Span.java new file mode 100644 index 000000000..5801b40c3 --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Span.java @@ -0,0 +1,36 @@ +// Copyright (C) 2000-2013 SuperWaba Ltda. +// Copyright (C) 2014-2020 TotalCross Global Mobile Platform Ltda. +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.gfx; + +/** + * Represents a horizontal span on a scanline. + */ +public final class Span { + /** Inclusive start coordinate of the span. */ + public final int start; + /** Inclusive end coordinate of the span. */ + public final int end; + + /** + * Creates a span. + * + * @param start inclusive start coordinate + * @param end inclusive end coordinate + */ + public Span(int start, int end) { + this.start = start; + this.end = end; + } + + /** + * Returns whether this span contains at least one valid pixel. + * + * @return {@code true} when {@code end >= start} + */ + public boolean isValid() { + return end >= start; + } +} diff --git a/TotalCrossVM/CMakeLists.txt b/TotalCrossVM/CMakeLists.txt index 1f5ac58ee..45d4fbd6f 100644 --- a/TotalCrossVM/CMakeLists.txt +++ b/TotalCrossVM/CMakeLists.txt @@ -466,6 +466,7 @@ set(SOURCES ${TC_SRCDIR}/nm/sys/Convert.c ${TC_SRCDIR}/nm/ui/gfx_Graphics.c + ${TC_SRCDIR}/nm/ui/gfx_RRect.c ${TC_SRCDIR}/nm/ui/event_Event.c ${TC_SRCDIR}/nm/ui/Control.c ${TC_SRCDIR}/nm/ui/font_Font.c diff --git a/TotalCrossVM/src/init/nativeProcAddressesTC.c b/TotalCrossVM/src/init/nativeProcAddressesTC.c index 12e84699a..4c23acb96 100644 --- a/TotalCrossVM/src/init/nativeProcAddressesTC.c +++ b/TotalCrossVM/src/init/nativeProcAddressesTC.c @@ -156,6 +156,12 @@ void fillNativeProcAddressesTC() htPutPtr(&htNativeProcAddresses, hashCode("tugG_fillPolygonGradient_IIi"), &tugG_fillPolygonGradient_IIi); htPutPtr(&htNativeProcAddresses, hashCode("tugG_getRGB_Iiiiii"), &tugG_getRGB_Iiiiii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_setRGB_Iiiiii"), &tugG_setRGB_Iiiiii); + htPutPtr(&htNativeProcAddresses, hashCode("tugRR_containsCorner_ddddddi"), &tugRR_containsCorner_ddddddi); + htPutPtr(&htNativeProcAddresses, hashCode("tugRR_leftBoundForY_dddddddd"), &tugRR_leftBoundForY_dddddddd); + htPutPtr(&htNativeProcAddresses, hashCode("tugRR_rightBoundForY_dddddddd"), &tugRR_rightBoundForY_dddddddd); + htPutPtr(&htNativeProcAddresses, hashCode("tugRR_ellipseLeftBound_ddddd"), &tugRR_ellipseLeftBound_ddddd); + htPutPtr(&htNativeProcAddresses, hashCode("tugRR_ellipseRightBound_ddddd"), &tugRR_ellipseRightBound_ddddd); + htPutPtr(&htNativeProcAddresses, hashCode("tugRR_ellipseDx_ddd"), &tugRR_ellipseDx_ddd); htPutPtr(&htNativeProcAddresses, hashCode("tugG_fadeScreen_i"), &tugG_fadeScreen_i); htPutPtr(&htNativeProcAddresses, hashCode("tufF_fontCreate"), &tufF_fontCreate); htPutPtr(&htNativeProcAddresses, hashCode("tufFM_fontMetricsCreate"), &tufFM_fontMetricsCreate); diff --git a/TotalCrossVM/src/nm/NativeMethods.h b/TotalCrossVM/src/nm/NativeMethods.h index d296aa0ea..9e3e8ed56 100644 --- a/TotalCrossVM/src/nm/NativeMethods.h +++ b/TotalCrossVM/src/nm/NativeMethods.h @@ -157,6 +157,12 @@ TC_API void tugG_drawText_Ciiii(NMParams p); TC_API void tugG_drawText_siii(NMParams p); TC_API void tugG_drawRoundRect_iiiii(NMParams p); TC_API void tugG_fillRoundRect_iiiii(NMParams p); +TC_API void tugRR_containsCorner_ddddddi(NMParams p); +TC_API void tugRR_leftBoundForY_dddddddd(NMParams p); +TC_API void tugRR_rightBoundForY_dddddddd(NMParams p); +TC_API void tugRR_ellipseLeftBound_ddddd(NMParams p); +TC_API void tugRR_ellipseRightBound_ddddd(NMParams p); +TC_API void tugRR_ellipseDx_ddd(NMParams p); TC_API void tugG_copyRect_giiiiii(NMParams p); TC_API void tugG_drawRoundGradient_iiiiiiiii(NMParams p); TC_API void tugG_drawImage_iiib(NMParams p); diff --git a/TotalCrossVM/src/nm/NativeMethods.txt b/TotalCrossVM/src/nm/NativeMethods.txt index 53611c745..394b6e2ab 100644 --- a/TotalCrossVM/src/nm/NativeMethods.txt +++ b/TotalCrossVM/src/nm/NativeMethods.txt @@ -133,6 +133,12 @@ totalcross/ui/gfx/Graphics|native public void drawPolyline(int []xPoints, int [] totalcross/ui/gfx/Graphics|native public void drawText(String text, int x, int y, int justifyWidth); totalcross/ui/gfx/Graphics|native public void drawRoundRect(int x, int y, int width, int height, int r); totalcross/ui/gfx/Graphics|native public void fillRoundRect(int x, int y, int width, int height, int r); +totalcross/ui/gfx/RRect|native private static boolean containsCorner(double px, double py, double x, double y, double rx, double ry, int corner); +totalcross/ui/gfx/RRect|native private static double leftBoundForY(double py, double left, double top, double bottom, double topRx, double topRy, double bottomRx, double bottomRy); +totalcross/ui/gfx/RRect|native private static double rightBoundForY(double py, double right, double top, double bottom, double topRx, double topRy, double bottomRx, double bottomRy); +totalcross/ui/gfx/RRect|native private static double ellipseLeftBound(double cx, double cy, double rx, double ry, double py); +totalcross/ui/gfx/RRect|native private static double ellipseRightBound(double cx, double cy, double rx, double ry, double py); +totalcross/ui/gfx/RRect|native private static double ellipseDx(double rx, double ry, double dy); totalcross/ui/gfx/Graphics|native public void copyRect(totalcross.ui.gfx.GfxSurface surface, int x, int y, int width, int height, int dstX, int dstY); totalcross/ui/gfx/Graphics|native public void drawRoundGradient(int startX, int startY, int endX, int endY, int topLeftRadius, int topRightRadius, int bottomLeftRadius, int bottomRightRadius,int startColor, int endColor, boolean vertical); totalcross/ui/gfx/Graphics|native public void drawImage(totalcross.ui.image.Image image, int x, int y, boolean doClip); diff --git a/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt b/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt index 80dc82926..0d7dd2b62 100644 --- a/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt +++ b/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt @@ -135,6 +135,12 @@ TC_API void tugG_drawPolyline_IIi(NMParams p); TC_API void tugG_drawText_siii(NMParams p); TC_API void tugG_drawRoundRect_iiiii(NMParams p); TC_API void tugG_fillRoundRect_iiiii(NMParams p); +TC_API void tugRR_containsCorner_ddddddi(NMParams p); +TC_API void tugRR_leftBoundForY_dddddddd(NMParams p); +TC_API void tugRR_rightBoundForY_dddddddd(NMParams p); +TC_API void tugRR_ellipseLeftBound_ddddd(NMParams p); +TC_API void tugRR_ellipseRightBound_ddddd(NMParams p); +TC_API void tugRR_ellipseDx_ddd(NMParams p); TC_API void tugG_copyRect_giiiiii(NMParams p); TC_API void tugG_drawRoundGradient_iiiiiiiii(NMParams p); TC_API void tugG_drawImage_iiib(NMParams p); @@ -1090,6 +1096,30 @@ TC_API void tugG_fillRoundRect_iiiii(NMParams p) // totalcross/ui/gfx/Graphics n { } ////////////////////////////////////////////////////////////////////////// +TC_API void tugRR_containsCorner_ddddddi(NMParams p) // totalcross/ui/gfx/RRect native private static boolean containsCorner(double px, double py, double x, double y, double rx, double ry, int corner); +{ +} +////////////////////////////////////////////////////////////////////////// +TC_API void tugRR_leftBoundForY_dddddddd(NMParams p) // totalcross/ui/gfx/RRect native private static double leftBoundForY(double py, double left, double top, double bottom, double topRx, double topRy, double bottomRx, double bottomRy); +{ +} +////////////////////////////////////////////////////////////////////////// +TC_API void tugRR_rightBoundForY_dddddddd(NMParams p) // totalcross/ui/gfx/RRect native private static double rightBoundForY(double py, double right, double top, double bottom, double topRx, double topRy, double bottomRx, double bottomRy); +{ +} +////////////////////////////////////////////////////////////////////////// +TC_API void tugRR_ellipseLeftBound_ddddd(NMParams p) // totalcross/ui/gfx/RRect native private static double ellipseLeftBound(double cx, double cy, double rx, double ry, double py); +{ +} +////////////////////////////////////////////////////////////////////////// +TC_API void tugRR_ellipseRightBound_ddddd(NMParams p) // totalcross/ui/gfx/RRect native private static double ellipseRightBound(double cx, double cy, double rx, double ry, double py); +{ +} +////////////////////////////////////////////////////////////////////////// +TC_API void tugRR_ellipseDx_ddd(NMParams p) // totalcross/ui/gfx/RRect native private static double ellipseDx(double rx, double ry, double dy); +{ +} +////////////////////////////////////////////////////////////////////////// TC_API void tugG_copyRect_giiiiii(NMParams p) // totalcross/ui/gfx/Graphics native public void copyRect(totalcross.ui.gfx.GfxSurface surface, int x, int y, int width, int height, int dstX, int dstY); { } diff --git a/TotalCrossVM/src/nm/ui/gfx.h b/TotalCrossVM/src/nm/ui/gfx.h new file mode 100644 index 000000000..71ea09082 --- /dev/null +++ b/TotalCrossVM/src/nm/ui/gfx.h @@ -0,0 +1,21 @@ +// Copyright (C) 2000-2013 SuperWaba Ltda. +// Copyright (C) 2014-2020 TotalCross Global Mobile Platform Ltda. +// +// SPDX-License-Identifier: LGPL-2.1-only + +#ifndef TC_NM_UI_GFX_H +#define TC_NM_UI_GFX_H + +// totalcross.ui.gfx.RRect +#define RRect_radii(o) ((o) == null ? null : (double*)ARRAYOBJ_START(FIELD_OBJ(o, OBJ_CLASS(o), 0))) + +double gfxEllipseDxRRect(double rx, double ry, double dy); +double gfxEllipseLeftBoundRRect(double cx, double cy, double rx, double ry, double py); +double gfxEllipseRightBoundRRect(double cx, double cy, double rx, double ry, double py); +double gfxLeftBoundForYRRect(double py, double left, double top, double bottom, + double topRx, double topRy, double bottomRx, double bottomRy); +double gfxRightBoundForYRRect(double py, double right, double top, double bottom, + double topRx, double topRy, double bottomRx, double bottomRy); +bool gfxComputeRRectSpan(int32 x, int32 y, int32 w, int32 h, const double *radii, int32 scanY, int32 *start, int32 *end); + +#endif diff --git a/TotalCrossVM/src/nm/ui/gfx_RRect.c b/TotalCrossVM/src/nm/ui/gfx_RRect.c new file mode 100644 index 000000000..74f59764b --- /dev/null +++ b/TotalCrossVM/src/nm/ui/gfx_RRect.c @@ -0,0 +1,134 @@ +// Copyright (C) 2000-2013 SuperWaba Ltda. +// Copyright (C) 2014-2020 TotalCross Global Mobile Platform Ltda. +// +// SPDX-License-Identifier: LGPL-2.1-only + +#include "tcvm.h" +#include "gfx.h" +#include + +static bool gfxContainsCornerRRect(double px, double py, double x, double y, double rx, double ry, int32 corner) +{ + double cx, cy, dx, dy; + bool inCornerBox; + + if (rx <= 0 || ry <= 0) + return true; + + cx = (corner == 0 || corner == 3) ? x + rx : x - rx; + cy = (corner == 0 || corner == 1) ? y + ry : y - ry; + inCornerBox = + (corner == 0 && px < x + rx && py < y + ry) || + (corner == 1 && px >= x - rx && py < y + ry) || + (corner == 2 && px >= x - rx && py >= y - ry) || + (corner == 3 && px < x + rx && py >= y - ry); + + if (!inCornerBox) + return true; + + dx = (px - cx) / rx; + dy = (py - cy) / ry; + return dx * dx + dy * dy <= 1.0; +} + +double gfxEllipseDxRRect(double rx, double ry, double dy) +{ + double t; + if (rx <= 0 || ry <= 0) + return 0; + t = 1.0 - (dy * dy) / (ry * ry); + if (t <= 0) + return 0; + return rx * sqrt(t); +} + +double gfxEllipseLeftBoundRRect(double cx, double cy, double rx, double ry, double py) +{ + return cx - gfxEllipseDxRRect(rx, ry, py - cy); +} + +double gfxEllipseRightBoundRRect(double cx, double cy, double rx, double ry, double py) +{ + return cx + gfxEllipseDxRRect(rx, ry, py - cy); +} + +double gfxLeftBoundForYRRect(double py, double left, double top, double bottom, + double topRx, double topRy, double bottomRx, double bottomRy) +{ + if (topRx > 0 && topRy > 0 && py < top + topRy) + return gfxEllipseLeftBoundRRect(left + topRx, top + topRy, topRx, topRy, py); + if (bottomRx > 0 && bottomRy > 0 && py >= bottom - bottomRy) + return gfxEllipseLeftBoundRRect(left + bottomRx, bottom - bottomRy, bottomRx, bottomRy, py); + return left; +} + +double gfxRightBoundForYRRect(double py, double right, double top, double bottom, + double topRx, double topRy, double bottomRx, double bottomRy) +{ + if (topRx > 0 && topRy > 0 && py < top + topRy) + return gfxEllipseRightBoundRRect(right - topRx, top + topRy, topRx, topRy, py); + if (bottomRx > 0 && bottomRy > 0 && py >= bottom - bottomRy) + return gfxEllipseRightBoundRRect(right - bottomRx, bottom - bottomRy, bottomRx, bottomRy, py); + return right; +} + +bool gfxComputeRRectSpan(int32 x, int32 y, int32 w, int32 h, const double *radii, int32 scanY, int32 *start, int32 *end) +{ + double py = scanY + 0.5; + double left = x; + double top = y; + double right = x + w; + double bottom = y + h; + double rr[8] = {0,0,0,0,0,0,0,0}; + double spanStart, spanEnd; + int i; + + if (py < top || py >= bottom) + return false; + + if (radii != null) + for (i = 0; i < 8; i++) + rr[i] = radii[i]; + + spanStart = gfxLeftBoundForYRRect(py, left, top, bottom, rr[0], rr[1], rr[6], rr[7]); + if (spanStart < left) + spanStart = left; + + spanEnd = gfxRightBoundForYRRect(py, right, top, bottom, rr[2], rr[3], rr[4], rr[5]); + if (spanEnd > right) + spanEnd = right; + + *start = (int32)ceil(spanStart - 0.5); + *end = (int32)floor(spanEnd - 0.5); + return *end >= *start; +} + +TC_API void tugRR_containsCorner_ddddddi(NMParams p) +{ + p->retI = gfxContainsCornerRRect(p->dbl[0], p->dbl[1], p->dbl[2], p->dbl[3], p->dbl[4], p->dbl[5], p->i32[0]); +} + +TC_API void tugRR_leftBoundForY_dddddddd(NMParams p) +{ + p->retD = gfxLeftBoundForYRRect(p->dbl[0], p->dbl[1], p->dbl[2], p->dbl[3], p->dbl[4], p->dbl[5], p->dbl[6], p->dbl[7]); +} + +TC_API void tugRR_rightBoundForY_dddddddd(NMParams p) +{ + p->retD = gfxRightBoundForYRRect(p->dbl[0], p->dbl[1], p->dbl[2], p->dbl[3], p->dbl[4], p->dbl[5], p->dbl[6], p->dbl[7]); +} + +TC_API void tugRR_ellipseLeftBound_ddddd(NMParams p) +{ + p->retD = gfxEllipseLeftBoundRRect(p->dbl[0], p->dbl[1], p->dbl[2], p->dbl[3], p->dbl[4]); +} + +TC_API void tugRR_ellipseRightBound_ddddd(NMParams p) +{ + p->retD = gfxEllipseRightBoundRRect(p->dbl[0], p->dbl[1], p->dbl[2], p->dbl[3], p->dbl[4]); +} + +TC_API void tugRR_ellipseDx_ddd(NMParams p) +{ + p->retD = gfxEllipseDxRRect(p->dbl[0], p->dbl[1], p->dbl[2]); +} From a972eb0bbed5d7f76fce09c3a1e2352a0a242944 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Thu, 19 Mar 2026 22:59:21 -0300 Subject: [PATCH 02/18] feat(gfx): add RRect drawing support to Graphics Add drawRRect support across the VM gfx pipeline, including Skia integration, raster fallback, and the required native bindings for Graphics and rounded-rectangle geometry. --- .../main/java/totalcross/ui/gfx/Graphics.java | 68 ++++++++++++++++++ TotalCrossVM/src/init/nativeProcAddressesTC.c | 1 + TotalCrossVM/src/nm/NativeMethods.h | 1 + TotalCrossVM/src/nm/NativeMethods.txt | 1 + .../src/nm/NativeMethodsPrototypes.txt | 5 ++ TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h | 70 +++++++++++++++++++ TotalCrossVM/src/nm/ui/android/skia.cpp | 25 +++++++ TotalCrossVM/src/nm/ui/android/skia.h | 1 + TotalCrossVM/src/nm/ui/gfx_Graphics.c | 35 ++++++++++ 9 files changed, 207 insertions(+) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java index 36774d1bd..a9305e8d7 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java @@ -1192,6 +1192,74 @@ public void fillRoundRect(int xx, int yy, int width, int height, int r) { } while (x < 0); } + /** + * Draws a rounded rectangle. + * + * @param rrect the rounded rectangle to draw + * @param color the color in {@code 0xRRGGBB} format + * @param filled whether the rounded rectangle should be filled + * @throws NullPointerException if {@code rrect} is {@code null} + * @throws IllegalArgumentException if {@code rrect.width < 0} or {@code rrect.height < 0} + */ + @ReplacedByNativeOnDeploy + public void drawRRect(RRect rrect, int color, boolean filled) { + if (rrect == null) { + throw new NullPointerException("rrect"); + } + if (rrect.width < 0) { + throw new IllegalArgumentException("rrect.width must be >= 0"); + } + if (rrect.height < 0) { + throw new IllegalArgumentException("rrect.height must be >= 0"); + } + if (rrect.width == 0 || rrect.height == 0) { + return; + } + + int top = rrect.y; + int bottom = rrect.y + rrect.height - 1; + int previousStart = Integer.MIN_VALUE; + int previousEnd = Integer.MIN_VALUE; + + for (int yy = top; yy <= bottom; yy++) { + Span span = rrect.horizontalSpan(yy); + if (!span.isValid()) { + continue; + } + int start = span.start; + int end = span.end; + + if (filled) { + drawLine(start, yy, end, yy, color); + } else { + if (previousStart == Integer.MIN_VALUE) { + drawLine(start, yy, end, yy, color); + } else { + if (start < previousStart) { + drawLine(start, yy, previousStart, yy, color); + } else if (start > previousStart) { + drawLine(previousStart, yy - 1, start, yy - 1, color); + } + if (end > previousEnd) { + drawLine(previousEnd, yy, end, yy, color); + } else if (end < previousEnd) { + drawLine(end, yy - 1, previousEnd, yy - 1, color); + } + } + + setPixel(start, yy, color); + setPixel(end, yy, color); + } + + previousStart = start; + previousEnd = end; + } + + if (!filled && previousStart != Integer.MIN_VALUE) { + drawLine(previousStart, bottom, previousEnd, bottom, color); + } + } + /** * Sets the clipping rectangle, translated to the current translated origin. Anything drawn outside of the * rectangular area specified will be clipped. Setting a clip overrides any previous clip. This clipping rectangle diff --git a/TotalCrossVM/src/init/nativeProcAddressesTC.c b/TotalCrossVM/src/init/nativeProcAddressesTC.c index 4c23acb96..d469c9a1d 100644 --- a/TotalCrossVM/src/init/nativeProcAddressesTC.c +++ b/TotalCrossVM/src/init/nativeProcAddressesTC.c @@ -142,6 +142,7 @@ void fillNativeProcAddressesTC() htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawText_siii"), &tugG_drawText_siii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawRoundRect_iiiii"), &tugG_drawRoundRect_iiiii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_fillRoundRect_iiiii"), &tugG_fillRoundRect_iiiii); + htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawRRect_rib"), &tugG_drawRRect_rib); htPutPtr(&htNativeProcAddresses, hashCode("tugG_copyRect_giiiiii"), &tugG_copyRect_giiiiii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawRoundGradient_iiiiiiiii"), &tugG_drawRoundGradient_iiiiiiiii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawImage_iiib"), &tugG_drawImage_iiib); diff --git a/TotalCrossVM/src/nm/NativeMethods.h b/TotalCrossVM/src/nm/NativeMethods.h index 9e3e8ed56..6b99c29db 100644 --- a/TotalCrossVM/src/nm/NativeMethods.h +++ b/TotalCrossVM/src/nm/NativeMethods.h @@ -157,6 +157,7 @@ TC_API void tugG_drawText_Ciiii(NMParams p); TC_API void tugG_drawText_siii(NMParams p); TC_API void tugG_drawRoundRect_iiiii(NMParams p); TC_API void tugG_fillRoundRect_iiiii(NMParams p); +TC_API void tugG_drawRRect_rib(NMParams p); TC_API void tugRR_containsCorner_ddddddi(NMParams p); TC_API void tugRR_leftBoundForY_dddddddd(NMParams p); TC_API void tugRR_rightBoundForY_dddddddd(NMParams p); diff --git a/TotalCrossVM/src/nm/NativeMethods.txt b/TotalCrossVM/src/nm/NativeMethods.txt index 394b6e2ab..fa470135a 100644 --- a/TotalCrossVM/src/nm/NativeMethods.txt +++ b/TotalCrossVM/src/nm/NativeMethods.txt @@ -133,6 +133,7 @@ totalcross/ui/gfx/Graphics|native public void drawPolyline(int []xPoints, int [] totalcross/ui/gfx/Graphics|native public void drawText(String text, int x, int y, int justifyWidth); totalcross/ui/gfx/Graphics|native public void drawRoundRect(int x, int y, int width, int height, int r); totalcross/ui/gfx/Graphics|native public void fillRoundRect(int x, int y, int width, int height, int r); +totalcross/ui/gfx/Graphics|native public void drawRRect(totalcross.ui.gfx.RRect rrect, int color, boolean filled); totalcross/ui/gfx/RRect|native private static boolean containsCorner(double px, double py, double x, double y, double rx, double ry, int corner); totalcross/ui/gfx/RRect|native private static double leftBoundForY(double py, double left, double top, double bottom, double topRx, double topRy, double bottomRx, double bottomRy); totalcross/ui/gfx/RRect|native private static double rightBoundForY(double py, double right, double top, double bottom, double topRx, double topRy, double bottomRx, double bottomRy); diff --git a/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt b/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt index 0d7dd2b62..e12e0244c 100644 --- a/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt +++ b/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt @@ -135,6 +135,7 @@ TC_API void tugG_drawPolyline_IIi(NMParams p); TC_API void tugG_drawText_siii(NMParams p); TC_API void tugG_drawRoundRect_iiiii(NMParams p); TC_API void tugG_fillRoundRect_iiiii(NMParams p); +TC_API void tugG_drawRRect_rib(NMParams p); TC_API void tugRR_containsCorner_ddddddi(NMParams p); TC_API void tugRR_leftBoundForY_dddddddd(NMParams p); TC_API void tugRR_rightBoundForY_dddddddd(NMParams p); @@ -1096,6 +1097,10 @@ TC_API void tugG_fillRoundRect_iiiii(NMParams p) // totalcross/ui/gfx/Graphics n { } ////////////////////////////////////////////////////////////////////////// +TC_API void tugG_drawRRect_rib(NMParams p) // totalcross/ui/gfx/Graphics native public void drawRRect(totalcross.ui.gfx.RRect rrect, int color, boolean filled); +{ +} +////////////////////////////////////////////////////////////////////////// TC_API void tugRR_containsCorner_ddddddi(NMParams p) // totalcross/ui/gfx/RRect native private static boolean containsCorner(double px, double py, double x, double y, double rx, double ry, int corner); { } diff --git a/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h b/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h index a658b470c..60655fb7f 100644 --- a/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h +++ b/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h @@ -2205,6 +2205,76 @@ static void drawRoundRect(Context currentContext, TCObject g, int32 x, int32 y, } #endif +//////////////////////////////////////////////////////////////////////////// +#ifndef SKIA_H +static void drawRRect(Context currentContext, TCObject g, int32 x, int32 y, int32 w, int32 h, const double *radii, Pixel c, bool filled) +{ + int32 top = y; + int32 bottom = y + h - 1; + int32 yy; + int32 start, end; + int32 previousStart = 0x7FFFFFFF; + int32 previousEnd = 0x7FFFFFFF; + + if (w <= 0 || h <= 0) + return; + + if (radii == null) + { + if (filled) + fillRect(currentContext, g, x, y, w, h, c); + else + drawRect(currentContext, g, x, y, w, h, c); + return; + } + + for (yy = top; yy <= bottom; yy++) + { + if (!gfxComputeRRectSpan(x, y, w, h, radii, yy, &start, &end)) + continue; + + if (filled) + drawLine(currentContext, g, start, yy, end, yy, c); + else + { + if (previousStart == 0x7FFFFFFF) + drawLine(currentContext, g, start, yy, end, yy, c); + else + { + if (start < previousStart) + drawLine(currentContext, g, start, yy, previousStart, yy, c); + else if (start > previousStart) + drawLine(currentContext, g, previousStart, yy - 1, start, yy - 1, c); + + if (end > previousEnd) + drawLine(currentContext, g, previousEnd, yy, end, yy, c); + else if (end < previousEnd) + drawLine(currentContext, g, end, yy - 1, previousEnd, yy - 1, c); + } + setPixel(currentContext, g, start, yy, c); + setPixel(currentContext, g, end, yy, c); + } + + previousStart = start; + previousEnd = end; + } + + if (!filled && previousStart != 0x7FFFFFFF) + drawLine(currentContext, g, previousStart, bottom, previousEnd, bottom, c); +} +#else +static void drawRRect(Context currentContext, TCObject g, int32 x, int32 y, int32 w, int32 h, const double *radii, Pixel c, bool filled) +{ + x += Graphics_transX(g); + y += Graphics_transY(g); + skia_setClip(Get_Clip(g)); + skia_drawRRect(0, x, y, w, h, radii, c | Graphics_alpha(g), filled); + skia_restoreClip(); + + markDirty(currentContext, g, x, y, w, h); +} +#endif + #ifndef SKIA_H //////////////////////////////////////////////////////////////////////////// static void setPixelA(Context currentContext, TCObject g, int32 x, int32 y, PixelConv color, int32 alpha); diff --git a/TotalCrossVM/src/nm/ui/android/skia.cpp b/TotalCrossVM/src/nm/ui/android/skia.cpp index 2262e0f6c..3c0b0c805 100644 --- a/TotalCrossVM/src/nm/ui/android/skia.cpp +++ b/TotalCrossVM/src/nm/ui/android/skia.cpp @@ -72,6 +72,7 @@ #include "include/core/SkImageInfo.h" #include "include/core/SkImageEncoder.h" #include "include/core/SkPath.h" +#include "include/core/SkRRect.h" #include "include/effects/SkGradientShader.h" #include "include/core/SkTextBlob.h" @@ -646,6 +647,30 @@ void skia_fillRoundRect(int32 skiaSurface, int32 x, int32 y, int32 w, int32 h, i canvas->drawRRect(SkRRect::MakeRectXY(SkRect::MakeXYWH(x, y, w, h), r, r), backPaint); } +void skia_drawRRect(int32 skiaSurface, int32 x, int32 y, int32 w, int32 h, const double *radii, Pixel c, bool filled) +{ + SKIA_TRACE() + SkRect rect = SkRect::MakeXYWH(x, y, w, h); + SkPaint &paint = filled ? backPaint : forePaint; + paint.setColor(c); + + if (radii == nullptr) { + canvas->drawRect(rect, paint); + return; + } + + SkVector corners[4] = { + SkVector::Make((SkScalar)radii[0], (SkScalar)radii[1]), + SkVector::Make((SkScalar)radii[2], (SkScalar)radii[3]), + SkVector::Make((SkScalar)radii[4], (SkScalar)radii[5]), + SkVector::Make((SkScalar)radii[6], (SkScalar)radii[7]) + }; + + SkRRect rr; + rr.setRectRadii(rect, corners); + canvas->drawRRect(rr, paint); +} + void skia_drawRoundGradient(int32 skiaSurface, int32 startX, int32 startY, int32 endX, int32 endY, int32 topLeftRadius, int32 topRightRadius, int32 bottomLeftRadius, int32 bottomRightRadius, int32 startColor, int32 endColor, bool vertical) { SKIA_TRACE() diff --git a/TotalCrossVM/src/nm/ui/android/skia.h b/TotalCrossVM/src/nm/ui/android/skia.h index 1a995de8d..4451a312e 100644 --- a/TotalCrossVM/src/nm/ui/android/skia.h +++ b/TotalCrossVM/src/nm/ui/android/skia.h @@ -46,6 +46,7 @@ void skia_drawPolygon(int32 skiaSurface, int32 *xPoints, int32 *yPoints, int32 n void skia_arcPiePointDrawAndFill(int32 skiaSurface, int32 xc, int32 yc, int32 rx, int32 ry, double startAngle, double endAngle, Pixel c, Pixel c2, bool fill, bool pie, bool gradient); void skia_drawRoundRect(int32 skiaSurface, int32 x, int32 y, int32 w, int32 h, int32 r, Pixel c); void skia_fillRoundRect(int32 skiaSurface, int32 x, int32 y, int32 w, int32 h, int32 r, Pixel c); +void skia_drawRRect(int32 skiaSurface, int32 x, int32 y, int32 w, int32 h, const double *radii, Pixel c, bool filled); void skia_drawRoundGradient(int32 skiaSurface, int32 startX, int32 startY, int32 endX, int32 endY, int32 topLeftRadius, int32 topRightRadius, int32 bottomLeftRadius, int32 bottomRightRadius, int32 startColor, int32 endColor, bool vertical); int skia_getsetRGB(int32 skiaSurface, void *dataObj, int32 offset, int32 x, int32 y, int32 w, int32 h, bool isGet); void skia_shiftScreen(float w, float h, float glShiftY); diff --git a/TotalCrossVM/src/nm/ui/gfx_Graphics.c b/TotalCrossVM/src/nm/ui/gfx_Graphics.c index 66cf99300..7a36b084f 100644 --- a/TotalCrossVM/src/nm/ui/gfx_Graphics.c +++ b/TotalCrossVM/src/nm/ui/gfx_Graphics.c @@ -4,6 +4,7 @@ // SPDX-License-Identifier: LGPL-2.1-only #include "tcvm.h" +#include "gfx.h" #if defined USE_SKIA && (defined ANDROID || defined darwin || defined HEADLESS) #define Graphics_forePixel(o) (Graphics_foreColor(o) | 0xFF000000) #define Graphics_backPixel(o) (Graphics_backColor(o) | 0xFF000000) @@ -275,6 +276,40 @@ TC_API void tugG_fillRoundRect_iiiii(NMParams p) // totalcross/ui/gfx/Graphics n fillRoundRect(p->currentContext, g, p->i32[0], p->i32[1], p->i32[2], p->i32[3], p->i32[4], Graphics_backPixel(g)); } ////////////////////////////////////////////////////////////////////////// +TC_API void tugG_drawRRect_rib(NMParams p) // totalcross/ui/gfx/Graphics native public void drawRRect(totalcross.ui.gfx.RRect rrect, int color, boolean filled); +{ + TCObject g = p->obj[0]; + TCObject rrect = p->obj[1]; + double *radii; + int32 width, height; + if (rrect == null) + { + throwNullArgumentException(p->currentContext, "rrect"); + return; + } + + width = Rect_width(rrect); + height = Rect_height(rrect); + if (width < 0) + { + throwException(p->currentContext, IllegalArgumentException, "rrect.width must be >= 0"); + return; + } + if (height < 0) + { + throwException(p->currentContext, IllegalArgumentException, "rrect.height must be >= 0"); + return; + } + if (width == 0 || height == 0) + return; + + radii = RRect_radii(rrect); + + drawRRect(p->currentContext, g, + Rect_x(rrect), Rect_y(rrect), width, height, + radii, p->i32[0], p->i32[1] != 0); +} +////////////////////////////////////////////////////////////////////////// TC_API void tugG_copyRect_giiiiii(NMParams p) // totalcross/ui/gfx/Graphics native public void copyRect(totalcross.ui.gfx.GfxSurface surface, int x, int y, int width, int height, int dstX, int dstY); { TCObject hDest = p->obj[0]; From f4aa7930b77bed1293a87353dbc6e6f511ae69df Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Fri, 20 Mar 2026 19:41:18 -0300 Subject: [PATCH 03/18] feat(gfx): add Paint and PathEffect primitives Introduce gfx.Paint and gfx.PathEffect as low-level drawing primitives inspired by Skia. Update Graphics.drawLine to accept a Paint object and wire the native binding to read the line color from Paint for now. --- .../main/java/totalcross/ui/gfx/Graphics.java | 79 ++-- .../main/java/totalcross/ui/gfx/Paint.java | 377 ++++++++++++++++++ .../java/totalcross/ui/gfx/PathEffect.java | 67 ++++ TotalCrossVM/src/init/nativeProcAddressesTC.c | 2 +- TotalCrossVM/src/nm/NativeMethods.h | 2 +- TotalCrossVM/src/nm/NativeMethods.txt | 2 +- .../src/nm/NativeMethodsPrototypes.txt | 4 +- TotalCrossVM/src/nm/ui/gfx.h | 14 + TotalCrossVM/src/nm/ui/gfx_Graphics.c | 10 +- 9 files changed, 519 insertions(+), 38 deletions(-) create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/gfx/Paint.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/gfx/PathEffect.java diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java index a9305e8d7..db3627cbc 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java @@ -573,7 +573,7 @@ private boolean surelyOutsideClip(int x1, int y1, int x2, int y2) { */ @ReplacedByNativeOnDeploy public void drawLine(int Ax, int Ay, int Bx, int By) { - drawLine(Ax, Ay, Bx, By, foreColor | alpha); + drawLine(Ax, Ay, Bx, By, paintOfColor(foreColor | alpha)); } /** @@ -695,7 +695,7 @@ public void drawPolygon(int[] xPoints, int[] yPoints, int nPoints) { throw new ArrayIndexOutOfBoundsException("array index out of range at drawPolygon: " + nPoints); } else { drawPolygon(xPoints, yPoints, 0, nPoints, foreColor | alpha); - drawLine(xPoints[0], yPoints[0], xPoints[nPoints - 1], yPoints[nPoints - 1], foreColor | alpha); + drawLine(xPoints[0], yPoints[0], xPoints[nPoints - 1], yPoints[nPoints - 1], paintOfColor(foreColor | alpha)); } } @@ -1134,8 +1134,8 @@ public void fillRoundRect(int xx, int yy, int width, int height, int r) { do { i = 255 - 255 * Math.abs(err - 2 * (x + y) - 2) / r; - drawLine(px1 + x + 1, py1 - y, px2 - x - 1, py1 - y, c); - drawLine(px1 + x + 1, py2 + y, px2 - x - 1, py2 + y, c); + drawLine(px1 + x + 1, py1 - y, px2 - x - 1, py1 - y, paintOfColor(c)); + drawLine(px1 + x + 1, py2 + y, px2 - x - 1, py2 + y, paintOfColor(c)); if (i < 256 && i > 0) { xm = px2; @@ -1230,20 +1230,20 @@ public void drawRRect(RRect rrect, int color, boolean filled) { int end = span.end; if (filled) { - drawLine(start, yy, end, yy, color); + drawLine(start, yy, end, yy, paintOfColor(color)); } else { if (previousStart == Integer.MIN_VALUE) { - drawLine(start, yy, end, yy, color); + drawLine(start, yy, end, yy, paintOfColor(color)); } else { if (start < previousStart) { - drawLine(start, yy, previousStart, yy, color); + drawLine(start, yy, previousStart, yy, paintOfColor(color)); } else if (start > previousStart) { - drawLine(previousStart, yy - 1, start, yy - 1, color); + drawLine(previousStart, yy - 1, start, yy - 1, paintOfColor(color)); } if (end > previousEnd) { - drawLine(previousEnd, yy, end, yy, color); + drawLine(previousEnd, yy, end, yy, paintOfColor(color)); } else if (end < previousEnd) { - drawLine(end, yy - 1, previousEnd, yy - 1, color); + drawLine(end, yy - 1, previousEnd, yy - 1, paintOfColor(color)); } } @@ -1256,7 +1256,7 @@ public void drawRRect(RRect rrect, int color, boolean filled) { } if (!filled && previousStart != Integer.MIN_VALUE) { - drawLine(previousStart, bottom, previousEnd, bottom, color); + drawLine(previousStart, bottom, previousEnd, bottom, paintOfColor(color)); } } @@ -1641,13 +1641,13 @@ public void drawVistaRect(int x, int y, int width, int height, int topColor, int int x2 = x + width - 1; int y2 = y + height - 1; foreColor = topColor | alpha; - drawLine(x1, y, x2 - 1, y, foreColor); + drawLine(x1, y, x2 - 1, y, paintOfColor(foreColor)); foreColor = rightColor | alpha; - drawLine(x2, y1, x2, y2 - 1, foreColor); + drawLine(x2, y1, x2, y2 - 1, paintOfColor(foreColor)); foreColor = bottomColor | alpha; - drawLine(x1, y2, x2 - 1, y2, foreColor); + drawLine(x1, y2, x2 - 1, y2, paintOfColor(foreColor)); foreColor = leftColor | alpha; - drawLine(x, y1, x, y2 - 1, foreColor); + drawLine(x, y1, x, y2 - 1, paintOfColor(foreColor)); } /** @@ -1735,12 +1735,12 @@ public void draw3dRect(int x, int y, int width, int height, byte type, boolean y fillRect(x, y, width, height); } else if (type == R3D_SHADED) { boolean menu = simple; // is menu? - drawLine(menu ? 0 : 1, 0, menu ? width - 1 : width - 3, 0, foreColor | alpha); - drawLine(0, 1, 0, height - 3, foreColor | alpha); - drawLine(width - 2, 1, width - 2, height - 3, foreColor | alpha); - drawLine(width - 1, menu ? 1 : 2, width - 1, height - 3, foreColor | alpha); - drawLine(1, height - 2, width - 2, height - 2, foreColor | alpha); - drawLine(2, height - 1, width - 3, height - 1, foreColor | alpha); + drawLine(menu ? 0 : 1, 0, menu ? width - 1 : width - 3, 0, paintOfColor(foreColor | alpha)); + drawLine(0, 1, 0, height - 3, paintOfColor(foreColor | alpha)); + drawLine(width - 2, 1, width - 2, height - 3, paintOfColor(foreColor | alpha)); + drawLine(width - 1, menu ? 1 : 2, width - 1, height - 3, paintOfColor(foreColor | alpha)); + drawLine(1, height - 2, width - 2, height - 2, paintOfColor(foreColor | alpha)); + drawLine(2, height - 1, width - 3, height - 1, paintOfColor(foreColor | alpha)); } else { switch (Settings.uiStyle) { case Settings.Flat: @@ -1832,7 +1832,7 @@ public void drawArrow(int x, int y, int h, byte type, boolean pressed, int foreC } h--; while (h >= 0) { - drawLine(x, y, x, y + (h << 1), foreColor); + drawLine(x, y, x, y + (h << 1), paintOfColor(foreColor)); x += step; y++; h--; @@ -1844,7 +1844,7 @@ public void drawArrow(int x, int y, int h, byte type, boolean pressed, int foreC } h--; while (h >= 0) { - drawLine(x, y, x + (h << 1), y, foreColor); + drawLine(x, y, x + (h << 1), y, paintOfColor(foreColor)); y += step; x++; h--; @@ -2392,7 +2392,7 @@ private void drawPolygon(int[] xPoints1, int[] yPoints1, int base1, int nPoints1 } for (i = 1; i < nPoints1; i++) { - drawLine(xPoints1[base1 + i - 1], yPoints1[base1 + i - 1], xPoints1[base1 + i], yPoints1[base1 + i], c); + drawLine(xPoints1[base1 + i - 1], yPoints1[base1 + i - 1], xPoints1[base1 + i], yPoints1[base1 + i], paintOfColor(c)); } } @@ -2466,9 +2466,22 @@ private void drawVLine(int x, int y, int h, int color) { } } + /** + * Draws a line using the provided paint state. + *

+ * The current implementation only reads {@link Paint#getColor()} from the supplied paint. + * + * @param Ax x coordinate of the start of the line + * @param Ay y coordinate of the start of the line + * @param Bx x coordinate of the end of the line + * @param By y coordinate of the end of the line + * @param paint the paint used to draw the line + * @throws NullPointerException if {@code paint} is {@code null} + */ // Bresenham algorithm to draw lines (modified by guich to improve performance in vertical and horizontal lines) @ReplacedByNativeOnDeploy - private void drawLine(int Ax, int Ay, int Bx, int By, int c) { + public void drawLine(int Ax, int Ay, int Bx, int By, Paint paint) { + int c = paint.getColor(); if (surelyOutsideClip(Ax, Ay, Bx, By)) { return; } @@ -2541,6 +2554,10 @@ private void drawLine(int Ax, int Ay, int Bx, int By, int c) { } } + private static Paint paintOfColor(int color) { + return new Paint().setColor(color); + } + //////////////////////////////////////////////////////////////////////////// // Bresenham algorithm to draw lines. draws one pixel with color c1 and one pixel with color c2 private void drawDottedLine(int Ax, int Ay, int Bx, int By, int c1, int c2) { @@ -3073,20 +3090,20 @@ public void drawWindowBorder(int xx, int yy, int ww, int hh, int titleH, int foo kx = x1l; ky = yy + i; c = getPixel(kx, ky); - drawLine(kx, ky, x2r, yy + i, interpolate(borderColorR, borderColorG, borderColorB, c, a)); // top + drawLine(kx, ky, x2r, yy + i, paintOfColor(interpolate(borderColorR, borderColorG, borderColorB, c, a))); // top ky = y2 - i; c = getPixel(kx, ky); - drawLine(kx, ky, x2r, y2 - i, interpolate(borderColorR, borderColorG, borderColorB, c, a)); // bottom + drawLine(kx, ky, x2r, y2 - i, paintOfColor(interpolate(borderColorR, borderColorG, borderColorB, c, a))); // bottom kx = xx + i; ky = y1l; c = getPixel(kx, ky); - drawLine(kx, ky, xx + i, y2r, interpolate(borderColorR, borderColorG, borderColorB, c, a)); // left + drawLine(kx, ky, xx + i, y2r, paintOfColor(interpolate(borderColorR, borderColorG, borderColorB, c, a))); // left kx = x2 - i; c = getPixel(kx, ky); - drawLine(kx, ky, x2 - i, y2r, interpolate(borderColorR, borderColorG, borderColorB, c, a)); // right + drawLine(kx, ky, x2 - i, y2r, paintOfColor(interpolate(borderColorR, borderColorG, borderColorB, c, a))); // right } // round corners for (int j = 0; j < 7; j++) { @@ -3162,7 +3179,7 @@ public void drawWindowBorder(int xx, int yy, int ww, int hh, int titleH, int foo // separator if (drawSeparators && titleH > 0 && titleColor == bodyColor) { drawLine(rectX1, ty - 1, rectX2, ty - 1, - interpolate(borderColorR, borderColorG, borderColorB, titleColorR, titleColorG, titleColorB, 64)); + paintOfColor(interpolate(borderColorR, borderColorG, borderColorB, titleColorR, titleColorG, titleColorB, 64))); } // body backColor = bodyColor | alpha; @@ -3171,7 +3188,7 @@ public void drawWindowBorder(int xx, int yy, int ww, int hh, int titleH, int foo // separator if (drawSeparators && footerH > 0 && bodyColor == footerColor) { drawLine(rectX1, ty, rectX2, ty, - interpolate(borderColorR, borderColorG, borderColorB, titleColorR, titleColorG, titleColorB, 64)); + paintOfColor(interpolate(borderColorR, borderColorG, borderColorB, titleColorR, titleColorG, titleColorB, 64))); ty++; footerH--; } diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Paint.java b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Paint.java new file mode 100644 index 000000000..8e44100cb --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Paint.java @@ -0,0 +1,377 @@ +// Copyright (C) 2000-2013 SuperWaba Ltda. +// Copyright (C) 2014-2020 TotalCross Global Mobile Platform Ltda. +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.gfx; + +/** + * Describes low-level drawing parameters in a way similar to Skia's paint state. + *

+ * This class is intentionally mutable so a single instance can be configured and + * reused across multiple drawing operations. + */ +public final class Paint { + /** + * Stroke and fill styles. + */ + public static final class Style { + /** Fills the geometry interior. */ + public static final int FILL = 0; + /** Draws only the geometry outline. */ + public static final int STROKE = 1; + /** Fills and also draws the geometry outline. */ + public static final int STROKE_AND_FILL = 2; + + private Style() { + } + } + + /** + * Stroke cap styles. + */ + public static final class Cap { + /** Flat cap that ends exactly at the segment boundary. */ + public static final int BUTT = 0; + /** Rounded cap centered at the segment boundary. */ + public static final int ROUND = 1; + /** Square cap that extends half the stroke width beyond the segment boundary. */ + public static final int SQUARE = 2; + + private Cap() { + } + } + + /** + * Stroke join styles. + */ + public static final class Join { + /** Sharp corner limited by the stroke miter. */ + public static final int MITER = 0; + /** Rounded corner between segments. */ + public static final int ROUND = 1; + /** Beveled corner between segments. */ + public static final int BEVEL = 2; + + private Join() { + } + } + + private int color; + private int style; + private double strokeWidth; + private int strokeCap; + private int strokeJoin; + private double strokeMiter; + private boolean antiAlias; + private boolean dither; + private PathEffect pathEffect; + + /** + * Creates a paint with defaults equivalent to a freshly constructed SkPaint. + */ + public Paint() { + reset(); + } + + /** + * Creates a paint copying the state from another instance. + * + * @param source the paint to copy from + * @throws NullPointerException if {@code source} is {@code null} + */ + public Paint(Paint source) { + set(source); + } + + /** + * Restores this paint to its default state. + * + * @return this paint + */ + public Paint reset() { + color = 0xFF000000; + style = Style.FILL; + strokeWidth = 0; + strokeCap = Cap.BUTT; + strokeJoin = Join.MITER; + strokeMiter = 4; + antiAlias = false; + dither = false; + pathEffect = null; + return this; + } + + /** + * Copies the state from another paint. + * + * @param source the paint to copy from + * @return this paint + * @throws NullPointerException if {@code source} is {@code null} + */ + public Paint set(Paint source) { + if (source == null) { + throw new NullPointerException("source"); + } + color = source.color; + style = source.style; + strokeWidth = source.strokeWidth; + strokeCap = source.strokeCap; + strokeJoin = source.strokeJoin; + strokeMiter = source.strokeMiter; + antiAlias = source.antiAlias; + dither = source.dither; + pathEffect = source.pathEffect; + return this; + } + + /** + * Returns the ARGB color used by this paint. + */ + public int getColor() { + return color; + } + + /** + * Sets the ARGB color used by this paint. + * + * @param color the color in {@code 0xAARRGGBB} format + * @return this paint + */ + public Paint setColor(int color) { + this.color = color; + return this; + } + + /** + * Sets the color using separate alpha, red, green and blue channels. + * + * @param alpha the alpha channel in the range 0-255 + * @param red the red channel in the range 0-255 + * @param green the green channel in the range 0-255 + * @param blue the blue channel in the range 0-255 + * @return this paint + * @throws IllegalArgumentException if any component is outside the range 0-255 + */ + public Paint setARGB(int alpha, int red, int green, int blue) { + validateChannel("alpha", alpha); + validateChannel("red", red); + validateChannel("green", green); + validateChannel("blue", blue); + this.color = (alpha << 24) | Color.getRGB(red, green, blue); + return this; + } + + /** + * Returns the alpha channel in the range 0-255. + */ + public int getAlpha() { + return (color >>> 24) & 0xFF; + } + + /** + * Sets the alpha channel in the range 0-255. + * + * @param alpha the alpha channel value + * @return this paint + * @throws IllegalArgumentException if {@code alpha} is outside the range 0-255 + */ + public Paint setAlpha(int alpha) { + validateChannel("alpha", alpha); + this.color = (color & 0x00FFFFFF) | (alpha << 24); + return this; + } + + /** + * Returns the drawing style. + */ + public int getStyle() { + return style; + } + + /** + * Sets the drawing style. + * + * @param style one of {@link Style#FILL}, {@link Style#STROKE}, or {@link Style#STROKE_AND_FILL} + * @return this paint + * @throws IllegalArgumentException if {@code style} is invalid + */ + public Paint setStyle(int style) { + if (!isValidStyle(style)) { + throw new IllegalArgumentException("Invalid paint style: " + style); + } + this.style = style; + return this; + } + + /** + * Returns the stroke width. + */ + public double getStrokeWidth() { + return strokeWidth; + } + + /** + * Sets the stroke width. + * + * @param strokeWidth the stroke width, which must be non-negative + * @return this paint + * @throws IllegalArgumentException if {@code strokeWidth} is negative + */ + public Paint setStrokeWidth(double strokeWidth) { + validateNonNegative("strokeWidth", strokeWidth); + this.strokeWidth = strokeWidth; + return this; + } + + /** + * Returns the stroke cap. + */ + public int getStrokeCap() { + return strokeCap; + } + + /** + * Sets the stroke cap. + * + * @param strokeCap one of {@link Cap#BUTT}, {@link Cap#ROUND}, or {@link Cap#SQUARE} + * @return this paint + * @throws IllegalArgumentException if {@code strokeCap} is invalid + */ + public Paint setStrokeCap(int strokeCap) { + if (!isValidCap(strokeCap)) { + throw new IllegalArgumentException("Invalid stroke cap: " + strokeCap); + } + this.strokeCap = strokeCap; + return this; + } + + /** + * Returns the stroke join. + */ + public int getStrokeJoin() { + return strokeJoin; + } + + /** + * Sets the stroke join. + * + * @param strokeJoin one of {@link Join#MITER}, {@link Join#ROUND}, or {@link Join#BEVEL} + * @return this paint + * @throws IllegalArgumentException if {@code strokeJoin} is invalid + */ + public Paint setStrokeJoin(int strokeJoin) { + if (!isValidJoin(strokeJoin)) { + throw new IllegalArgumentException("Invalid stroke join: " + strokeJoin); + } + this.strokeJoin = strokeJoin; + return this; + } + + /** + * Returns the stroke miter limit. + */ + public double getStrokeMiter() { + return strokeMiter; + } + + /** + * Sets the stroke miter limit. + * + * @param strokeMiter the miter limit, which must be non-negative + * @return this paint + * @throws IllegalArgumentException if {@code strokeMiter} is negative + */ + public Paint setStrokeMiter(double strokeMiter) { + validateNonNegative("strokeMiter", strokeMiter); + this.strokeMiter = strokeMiter; + return this; + } + + /** + * Returns whether anti-aliasing is enabled. + */ + public boolean isAntiAlias() { + return antiAlias; + } + + /** + * Enables or disables anti-aliasing. + * + * @param antiAlias whether anti-aliasing should be enabled + * @return this paint + */ + public Paint setAntiAlias(boolean antiAlias) { + this.antiAlias = antiAlias; + return this; + } + + /** + * Returns whether dithering is enabled. + */ + public boolean isDither() { + return dither; + } + + /** + * Enables or disables dithering. + * + * @param dither whether dithering should be enabled + * @return this paint + */ + public Paint setDither(boolean dither) { + this.dither = dither; + return this; + } + + /** + * Returns the path effect applied to geometry drawn with this paint, or {@code null} if none is configured. + */ + public PathEffect getPathEffect() { + return pathEffect; + } + + /** + * Sets the path effect applied to geometry drawn with this paint. + * + * @param pathEffect the path effect to use, or {@code null} to clear it + * @return this paint + */ + public Paint setPathEffect(PathEffect pathEffect) { + this.pathEffect = pathEffect; + return this; + } + + /** + * Returns whether the given value is a valid paint style. + */ + public static boolean isValidStyle(int style) { + return style == Style.FILL || style == Style.STROKE || style == Style.STROKE_AND_FILL; + } + + /** + * Returns whether the given value is a valid stroke cap. + */ + public static boolean isValidCap(int cap) { + return cap == Cap.BUTT || cap == Cap.ROUND || cap == Cap.SQUARE; + } + + /** + * Returns whether the given value is a valid stroke join. + */ + public static boolean isValidJoin(int join) { + return join == Join.MITER || join == Join.ROUND || join == Join.BEVEL; + } + + private static void validateChannel(String name, int value) { + if (value < 0 || value > 0xFF) { + throw new IllegalArgumentException(name + " must be in the range 0-255: " + value); + } + } + + private static void validateNonNegative(String name, double value) { + if (value < 0) { + throw new IllegalArgumentException(name + " must be >= 0: " + value); + } + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/PathEffect.java b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/PathEffect.java new file mode 100644 index 000000000..b53a27344 --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/PathEffect.java @@ -0,0 +1,67 @@ +// Copyright (C) 2000-2013 SuperWaba Ltda. +// Copyright (C) 2014-2020 TotalCross Global Mobile Platform Ltda. +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.gfx; + +import java.util.Arrays; + +/** + * Describes geometric effects applied to stroked paths, in a role similar to Skia's path effects. + *

+ * This class is immutable. Use one of the factory methods to create an instance. + */ +public final class PathEffect { + /** + * Supported path effect types. + */ + public static final class Type { + /** Dash path effect. */ + public static final int DASH = 0; + + private Type() { + } + } + + public final int type; + public final double[] intervals; + public final double phase; + + private PathEffect(int type, double[] intervals, double phase) { + this.type = type; + this.intervals = intervals; + this.phase = phase; + } + + /** + * Creates a dash path effect. + * + * @param intervals alternating on/off lengths; each value must be positive and the array length must be even + * @param phase the dash phase + * @return a new dash path effect + * @throws NullPointerException if {@code intervals} is {@code null} + * @throws IllegalArgumentException if the intervals array is empty, has odd length, or contains non-positive values + */ + public static PathEffect dash(double[] intervals, double phase) { + if (intervals == null) { + throw new NullPointerException("intervals"); + } + if (intervals.length == 0 || (intervals.length & 1) != 0) { + throw new IllegalArgumentException("Dash intervals must have a positive even length"); + } + for (int i = 0; i < intervals.length; i++) { + if (!(intervals[i] > 0)) { + throw new IllegalArgumentException("Dash intervals must be > 0: intervals[" + i + "]=" + intervals[i]); + } + } + return new PathEffect(Type.DASH, Arrays.copyOf(intervals, intervals.length), phase); + } + + /** + * Returns whether the given value is a valid path effect type. + */ + public static boolean isValidType(int type) { + return type == Type.DASH; + } +} diff --git a/TotalCrossVM/src/init/nativeProcAddressesTC.c b/TotalCrossVM/src/init/nativeProcAddressesTC.c index d469c9a1d..1ddb88b01 100644 --- a/TotalCrossVM/src/init/nativeProcAddressesTC.c +++ b/TotalCrossVM/src/init/nativeProcAddressesTC.c @@ -132,7 +132,7 @@ void fillNativeProcAddressesTC() htPutPtr(&htNativeProcAddresses, hashCode("tugG_getPixel_ii"), &tugG_getPixel_ii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_setPixel_ii"), &tugG_setPixel_ii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawLine_iiii"), &tugG_drawLine_iiii); - htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawLine_iiiii"), &tugG_drawLine_iiiii); + htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawLine_iiiip"), &tugG_drawLine_iiiip); htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawDots_iiii"), &tugG_drawDots_iiii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_drawRect_iiii"), &tugG_drawRect_iiii); htPutPtr(&htNativeProcAddresses, hashCode("tugG_fillRect_iiii"), &tugG_fillRect_iiii); diff --git a/TotalCrossVM/src/nm/NativeMethods.h b/TotalCrossVM/src/nm/NativeMethods.h index 6b99c29db..83592da31 100644 --- a/TotalCrossVM/src/nm/NativeMethods.h +++ b/TotalCrossVM/src/nm/NativeMethods.h @@ -145,7 +145,7 @@ TC_API void tugG_fillCircle_iii(NMParams p); TC_API void tugG_getPixel_ii(NMParams p); TC_API void tugG_setPixel_ii(NMParams p); TC_API void tugG_drawLine_iiii(NMParams p); -TC_API void tugG_drawLine_iiiii(NMParams p); +TC_API void tugG_drawLine_iiiip(NMParams p); TC_API void tugG_drawDots_iiii(NMParams p); TC_API void tugG_drawRect_iiii(NMParams p); TC_API void tugG_fillRect_iiii(NMParams p); diff --git a/TotalCrossVM/src/nm/NativeMethods.txt b/TotalCrossVM/src/nm/NativeMethods.txt index fa470135a..121e314e7 100644 --- a/TotalCrossVM/src/nm/NativeMethods.txt +++ b/TotalCrossVM/src/nm/NativeMethods.txt @@ -123,7 +123,7 @@ totalcross/ui/gfx/Graphics|native public void fillCircle(int xc, int yc, int r); totalcross/ui/gfx/Graphics|native public int getPixel(int x, int y); totalcross/ui/gfx/Graphics|native public void setPixel(int x, int y); totalcross/ui/gfx/Graphics|native public void drawLine(int ax, int ay, int bx, int by); -totalcross/ui/gfx/Graphics|native public void drawLine(int ax, int ay, int bx, int by, int c); +totalcross/ui/gfx/Graphics|native public void drawLine(int ax, int ay, int bx, int by, totalcross.ui.gfx.Paint paint); totalcross/ui/gfx/Graphics|native public void drawDots(int ax, int ay, int bx, int by); totalcross/ui/gfx/Graphics|native public void drawRect(int x, int y, int w, int h); totalcross/ui/gfx/Graphics|native public void fillRect(int x, int y, int w, int h); diff --git a/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt b/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt index e12e0244c..8e9d230b4 100644 --- a/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt +++ b/TotalCrossVM/src/nm/NativeMethodsPrototypes.txt @@ -125,7 +125,7 @@ TC_API void tugG_fillCircle_iii(NMParams p); TC_API void tugG_getPixel_ii(NMParams p); TC_API void tugG_setPixel_ii(NMParams p); TC_API void tugG_drawLine_iiii(NMParams p); -TC_API void tugG_drawLine_iiiii(NMParams p); +TC_API void tugG_drawLine_iiiip(NMParams p); TC_API void tugG_drawDots_iiii(NMParams p); TC_API void tugG_drawRect_iiii(NMParams p); TC_API void tugG_fillRect_iiii(NMParams p); @@ -1057,7 +1057,7 @@ TC_API void tugG_drawLine_iiii(NMParams p) // totalcross/ui/gfx/Graphics native { } ////////////////////////////////////////////////////////////////////////// -TC_API void tugG_drawLine_iiiii(NMParams p) // totalcross/ui/gfx/Graphics native public void drawLine(int ax, int ay, int bx, int by, int c); +TC_API void tugG_drawLine_iiiip(NMParams p) // totalcross/ui/gfx/Graphics native public void drawLine(int ax, int ay, int bx, int by, totalcross.ui.gfx.Paint paint); { } ////////////////////////////////////////////////////////////////////////// diff --git a/TotalCrossVM/src/nm/ui/gfx.h b/TotalCrossVM/src/nm/ui/gfx.h index 71ea09082..dc5f1751a 100644 --- a/TotalCrossVM/src/nm/ui/gfx.h +++ b/TotalCrossVM/src/nm/ui/gfx.h @@ -6,9 +6,19 @@ #ifndef TC_NM_UI_GFX_H #define TC_NM_UI_GFX_H +#include "tcclass.h" + +#ifdef __cplusplus +extern "C" +{ +#endif + // totalcross.ui.gfx.RRect #define RRect_radii(o) ((o) == null ? null : (double*)ARRAYOBJ_START(FIELD_OBJ(o, OBJ_CLASS(o), 0))) +// totalcross.ui.gfx.Paint +#define Paint_color(o) FIELD_I32(o, 0) + double gfxEllipseDxRRect(double rx, double ry, double dy); double gfxEllipseLeftBoundRRect(double cx, double cy, double rx, double ry, double py); double gfxEllipseRightBoundRRect(double cx, double cy, double rx, double ry, double py); @@ -18,4 +28,8 @@ double gfxRightBoundForYRRect(double py, double right, double top, double bottom double topRx, double topRy, double bottomRx, double bottomRy); bool gfxComputeRRectSpan(int32 x, int32 y, int32 w, int32 h, const double *radii, int32 scanY, int32 *start, int32 *end); +#ifdef __cplusplus +} +#endif + #endif diff --git a/TotalCrossVM/src/nm/ui/gfx_Graphics.c b/TotalCrossVM/src/nm/ui/gfx_Graphics.c index 7a36b084f..a5d2e18bd 100644 --- a/TotalCrossVM/src/nm/ui/gfx_Graphics.c +++ b/TotalCrossVM/src/nm/ui/gfx_Graphics.c @@ -173,10 +173,16 @@ TC_API void tugG_drawLine_iiii(NMParams p) // totalcross/ui/gfx/Graphics native drawLine(p->currentContext, g, p->i32[0], p->i32[1], p->i32[2], p->i32[3], Graphics_forePixel(g)); } ////////////////////////////////////////////////////////////////////////// -TC_API void tugG_drawLine_iiiii(NMParams p) // totalcross/ui/gfx/Graphics native public void drawLine(int ax, int ay, int bx, int by, int c); +TC_API void tugG_drawLine_iiiip(NMParams p) // totalcross/ui/gfx/Graphics native public void drawLine(int ax, int ay, int bx, int by, totalcross.ui.gfx.Paint paint); { TCObject g = p->obj[0]; - drawLine(p->currentContext, g, p->i32[0], p->i32[1], p->i32[2], p->i32[3], p->i32[4]); + TCObject paint = p->obj[1]; + if (paint == null) + { + throwNullArgumentException(p->currentContext, "paint"); + return; + } + drawLine(p->currentContext, g, p->i32[0], p->i32[1], p->i32[2], p->i32[3], Paint_color(paint)); } ////////////////////////////////////////////////////////////////////////// TC_API void tugG_drawDots_iiii(NMParams p) // totalcross/ui/gfx/Graphics native public void drawDots(int ax, int ay, int bx, int by); From 6e0c9175c78a93cac8c2abc4995e9923de040d46 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Sat, 21 Mar 2026 18:22:06 -0300 Subject: [PATCH 04/18] chore(gfx): pass Paint through native line rendering --- TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h | 46 +++++++++------ TotalCrossVM/src/nm/ui/android/skia.cpp | 10 ++-- TotalCrossVM/src/nm/ui/android/skia.h | 4 +- TotalCrossVM/src/nm/ui/gfx.h | 58 ++++++++++++++++++- TotalCrossVM/src/nm/ui/gfx_Graphics.c | 11 ++-- 5 files changed, 102 insertions(+), 27 deletions(-) diff --git a/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h b/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h index 60655fb7f..0366b21b1 100644 --- a/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h +++ b/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h @@ -997,19 +997,21 @@ static int32 abs32(int32 a) return a < 0 ? -a : a; } -static void drawLine(Context currentContext, TCObject g, int32 x1, int32 y1, int32 x2, int32 y2, Pixel pixel) +static void drawLine(Context currentContext, TCObject g, int32 x1, int32 y1, int32 x2, int32 y2, GfxPaint paint) { + Pixel pixel = *paint.color; drawDottedLine(currentContext, g, x1, y1, x2, y2, pixel, pixel); } #else -static void drawLine(Context currentContext, TCObject g, int32 x1, int32 y1, int32 x2, int32 y2, Pixel pixel) +static void drawLine(Context currentContext, TCObject g, int32 x1, int32 y1, int32 x2, int32 y2, GfxPaint paint) { + Pixel pixel = *paint.color; x1 += Graphics_transX(g); y1 += Graphics_transY(g); x2 += Graphics_transX(g); y2 += Graphics_transY(g); skia_setClip(Get_Clip(g)); - skia_drawLine(0, x1, y1, x2, y2, pixel | Graphics_alpha(g)); + skia_drawLine(0, x1, y1, x2, y2, paint); skia_restoreClip(); markDirty(currentContext, g, min32(x1, x2), min32(y1, y2), abs(x2 - x1), abs(y2 - y1)); @@ -1864,10 +1866,11 @@ static void drawPolygon(Context currentContext, TCObject g, int32 *xPoints1, int #endif { int32 i; + GfxPaint paint = gfxPaintFromColor(&pixel); for (i=1; i < nPoints1; i++) - drawLine(currentContext, g,tx + xPoints1[i-1], ty + yPoints1[i-1], tx + xPoints1[i], ty + yPoints1[i], pixel); + drawLine(currentContext, g,tx + xPoints1[i-1], ty + yPoints1[i-1], tx + xPoints1[i], ty + yPoints1[i], paint); for (i=1; i < nPoints2; i++) - drawLine(currentContext, g,tx + xPoints2[i-1], ty + yPoints2[i-1], tx + xPoints2[i], ty + yPoints2[i], pixel); + drawLine(currentContext, g,tx + xPoints2[i-1], ty + yPoints2[i-1], tx + xPoints2[i], ty + yPoints2[i], paint); } } } @@ -2150,7 +2153,10 @@ static void arcPiePointDrawAndFill(Context currentContext, TCObject g, int32 xc, yPoints[endIndex] = oldY1; #ifdef ANDROID if (!gradient && endAngle == 360) - drawLine(currentContext,g, xc,yc, xc+xPoints[endIndex-1], yc+yPoints[endIndex-1], c); + { + Pixel lineColor = c; + drawLine(currentContext,g, xc,yc, xc+xPoints[endIndex-1], yc+yPoints[endIndex-1], gfxPaintFromColor(&lineColor)); + } #endif } } @@ -2215,6 +2221,8 @@ static void drawRRect(Context currentContext, TCObject g, int32 x, int32 y, int3 int32 start, end; int32 previousStart = 0x7FFFFFFF; int32 previousEnd = 0x7FFFFFFF; + Pixel lineColor = c; + GfxPaint paint = gfxPaintFromColor(&lineColor); if (w <= 0 || h <= 0) return; @@ -2234,22 +2242,22 @@ static void drawRRect(Context currentContext, TCObject g, int32 x, int32 y, int3 continue; if (filled) - drawLine(currentContext, g, start, yy, end, yy, c); + drawLine(currentContext, g, start, yy, end, yy, paint); else { if (previousStart == 0x7FFFFFFF) - drawLine(currentContext, g, start, yy, end, yy, c); + drawLine(currentContext, g, start, yy, end, yy, paint); else { if (start < previousStart) - drawLine(currentContext, g, start, yy, previousStart, yy, c); + drawLine(currentContext, g, start, yy, previousStart, yy, paint); else if (start > previousStart) - drawLine(currentContext, g, previousStart, yy - 1, start, yy - 1, c); + drawLine(currentContext, g, previousStart, yy - 1, start, yy - 1, paint); if (end > previousEnd) - drawLine(currentContext, g, previousEnd, yy, end, yy, c); + drawLine(currentContext, g, previousEnd, yy, end, yy, paint); else if (end < previousEnd) - drawLine(currentContext, g, end, yy - 1, previousEnd, yy - 1, c); + drawLine(currentContext, g, end, yy - 1, previousEnd, yy - 1, paint); } setPixel(currentContext, g, start, yy, c); setPixel(currentContext, g, end, yy, c); @@ -2260,7 +2268,7 @@ static void drawRRect(Context currentContext, TCObject g, int32 x, int32 y, int3 } if (!filled && previousStart != 0x7FFFFFFF) - drawLine(currentContext, g, previousStart, bottom, previousEnd, bottom, c); + drawLine(currentContext, g, previousStart, bottom, previousEnd, bottom, paint); } #else static void drawRRect(Context currentContext, TCObject g, int32 x, int32 y, int32 w, int32 h, const double *radii, Pixel c, bool filled) @@ -2284,6 +2292,8 @@ static void fillRoundRect(Context currentContext, TCObject g, int32 xx, int32 yy { int32 px1,px2,py1,py2,xm,ym,x,y=0, i, x2, e2, err; PixelConv color; + Pixel lineColor = c; + GfxPaint gfxPaint = gfxPaintFromColor(&lineColor); if (r > (width/2) || r > (height/2)) r = min32(width/2,height/2); // guich@200b4_6: correct bug that crashed the device. x = -r; @@ -2305,8 +2315,8 @@ static void fillRoundRect(Context currentContext, TCObject g, int32 xx, int32 yy { i = 255 - 255 * abs(err - 2 * (x + y) - 2) / r; - drawLine(currentContext, g, px1+x+1,py1-y,px2-x-1,py1-y,c); - drawLine(currentContext, g, px1+x+1,py2+y,px2-x-1,py2+y,c); + drawLine(currentContext, g, px1+x+1,py1-y,px2-x-1,py1-y,gfxPaint); + drawLine(currentContext, g, px1+x+1,py2+y,px2-x-1,py2+y,gfxPaint); if (i < 256 && i > 0) { @@ -2967,6 +2977,7 @@ static void drawRoundGradient(Context currentContext, TCObject g, int32 startX, for (i = 0; i < numSteps; i++) { + GfxPaint paint; if (hasRadius) { leftOffset = rightOffset = 0; @@ -2987,19 +2998,20 @@ static void drawRoundGradient(Context currentContext, TCObject g, int32 startX, if (rightOffset < 0) rightOffset = 0; } p = makePixel(red >> 16, green >> 16, blue >> 16); + paint = gfxPaintFromColor(&p); if (!optimize || leftOffset != 0 || rightOffset != 0) { if (vertical) { int32 fc = p; - drawLine(currentContext, g, startX + leftOffset, startY + i, endX - rightOffset, startY + i, p); + drawLine(currentContext, g, startX + leftOffset, startY + i, endX - rightOffset, startY + i, paint); if (drawFadedPixels && rightOffset != 0) // since there's no fading of pixels in opengl, we can safely ignore this drawFadedPixel(currentContext, g, endX - rightOffset + 1, startY + i, fc); if (drawFadedPixels && leftOffset != 0) drawFadedPixel(currentContext, g, startX + leftOffset - 1, startY + i, fc); } else - drawLine(currentContext, g, startX + i, startY + leftOffset, startX + i, endY - rightOffset, p); + drawLine(currentContext, g, startX + i, startY + leftOffset, startX + i, endY - rightOffset, paint); } if (stage < 2) // find starting and ending colors { diff --git a/TotalCrossVM/src/nm/ui/android/skia.cpp b/TotalCrossVM/src/nm/ui/android/skia.cpp index 3c0b0c805..cea8d6759 100644 --- a/TotalCrossVM/src/nm/ui/android/skia.cpp +++ b/TotalCrossVM/src/nm/ui/android/skia.cpp @@ -424,19 +424,21 @@ void skia_drawDottedLine(int32 skiaSurface, int32 x1, int32 y1, int32 x2, int32 { SKIA_TRACE() float intervals[] = {5, 5}; + GfxPaint paint1 = gfxPaintFromColor((int32*)&pixel1); + GfxPaint paint2 = gfxPaintFromColor((int32*)&pixel2); forePaint.setPathEffect(SkDashPathEffect::Make(intervals, 2, 2.5f)); - skia_drawLine(skiaSurface, x1, y1, x2, y2, pixel1); + skia_drawLine(skiaSurface, x1, y1, x2, y2, paint1); forePaint.setPathEffect(nullptr); forePaint.setPathEffect(SkDashPathEffect::Make(intervals, 2, 7.5f)); - skia_drawLine(skiaSurface, x1, y1, x2, y2, pixel2); + skia_drawLine(skiaSurface, x1, y1, x2, y2, paint2); forePaint.setPathEffect(nullptr); } -void skia_drawLine(int32 skiaSurface, int32 x1, int32 y1, int32 x2, int32 y2, Pixel pixel) +void skia_drawLine(int32 skiaSurface, int32 x1, int32 y1, int32 x2, int32 y2, GfxPaint paint) { SKIA_TRACE() - forePaint.setColor(pixel); + forePaint.setColor(*paint.color); canvas->drawLine(x1, y1, x2, y2, forePaint); } diff --git a/TotalCrossVM/src/nm/ui/android/skia.h b/TotalCrossVM/src/nm/ui/android/skia.h index 4451a312e..47f8e3b1f 100644 --- a/TotalCrossVM/src/nm/ui/android/skia.h +++ b/TotalCrossVM/src/nm/ui/android/skia.h @@ -6,6 +6,8 @@ #ifndef SKIA_H #define SKIA_H +#include "../gfx.h" + #ifdef __cplusplus extern "C" { @@ -36,7 +38,7 @@ void skia_drawSurface(int32 skiaSurface, int32 id, float srcLeft, float srcTop, void skia_drawDottedLine(int32 skiaSurface, int32 x1, int32 y1, int32 x2, int32 y2, Pixel pixel1, Pixel pixel2); Pixel skia_getPixel(int32 skiaSurface, int32 x, int32 y); void skia_setPixel(int32 skiaSurface, int32 x, int32 y, Pixel pixel); -void skia_drawLine(int32 skiaSurface, int32 x1, int32 y1, int32 x2, int32 y2, Pixel pixel); +void skia_drawLine(int32 skiaSurface, int32 x1, int32 y1, int32 x2, int32 y2, GfxPaint paint); void skia_drawRect(int32 skiaSurface, int32 x, int32 y, int32 w, int32 h, Pixel pixel); void skia_fillRect(int32 skiaSurface, int32 x, int32 y, int32 w, int32 h, Pixel pixel); void skia_drawText(int32 skiaSurface, const void *text, int32 chrCount, int32 x0, int32 y0, Pixel foreColor, int32 justifyWidth, int32 fontSize, int32 typefaceIndex); diff --git a/TotalCrossVM/src/nm/ui/gfx.h b/TotalCrossVM/src/nm/ui/gfx.h index dc5f1751a..4e217fda6 100644 --- a/TotalCrossVM/src/nm/ui/gfx.h +++ b/TotalCrossVM/src/nm/ui/gfx.h @@ -6,7 +6,9 @@ #ifndef TC_NM_UI_GFX_H #define TC_NM_UI_GFX_H +#include "xtypes.h" #include "tcclass.h" +#include "objectmemorymanager.h" #ifdef __cplusplus extern "C" @@ -17,7 +19,61 @@ extern "C" #define RRect_radii(o) ((o) == null ? null : (double*)ARRAYOBJ_START(FIELD_OBJ(o, OBJ_CLASS(o), 0))) // totalcross.ui.gfx.Paint -#define Paint_color(o) FIELD_I32(o, 0) +typedef struct GfxPaint { + const int32 *color; + const int32 *style; + const double *strokeWidth; + const int32 *strokeCap; + const int32 *strokeJoin; + const double *strokeMiter; + const int32 *antiAlias; + const int32 *dither; + const TCObject *pathEffect; +} GfxPaint; + +static __inline GfxPaint gfxPaint(TCObject o) +{ + GfxPaint fields; + if (o == null) + { + fields.color = null; + fields.style = null; + fields.strokeWidth = null; + fields.strokeCap = null; + fields.strokeJoin = null; + fields.strokeMiter = null; + fields.antiAlias = null; + fields.dither = null; + fields.pathEffect = null; + return fields; + } + + fields.color = &FIELD_I32(o, 0); + fields.style = &FIELD_I32(o, 1); + fields.strokeWidth = &FIELD_DBL(o, OBJ_CLASS(o), 0); + fields.strokeCap = &FIELD_I32(o, 2); + fields.strokeJoin = &FIELD_I32(o, 3); + fields.strokeMiter = &FIELD_DBL(o, OBJ_CLASS(o), 1); + fields.antiAlias = &FIELD_I32(o, 4); + fields.dither = &FIELD_I32(o, 5); + fields.pathEffect = &FIELD_OBJ(o, OBJ_CLASS(o), 0); + return fields; +} + +static __inline GfxPaint gfxPaintFromColor(const int32 *color) +{ + GfxPaint fields; + fields.color = color; + fields.style = null; + fields.strokeWidth = null; + fields.strokeCap = null; + fields.strokeJoin = null; + fields.strokeMiter = null; + fields.antiAlias = null; + fields.dither = null; + fields.pathEffect = null; + return fields; +} double gfxEllipseDxRRect(double rx, double ry, double dy); double gfxEllipseLeftBoundRRect(double cx, double cy, double rx, double ry, double py); diff --git a/TotalCrossVM/src/nm/ui/gfx_Graphics.c b/TotalCrossVM/src/nm/ui/gfx_Graphics.c index a5d2e18bd..fd8889476 100644 --- a/TotalCrossVM/src/nm/ui/gfx_Graphics.c +++ b/TotalCrossVM/src/nm/ui/gfx_Graphics.c @@ -170,19 +170,21 @@ TC_API void tugG_setPixel_ii(NMParams p) // totalcross/ui/gfx/Graphics native pu TC_API void tugG_drawLine_iiii(NMParams p) // totalcross/ui/gfx/Graphics native public void drawLine(int ax, int ay, int bx, int by); { TCObject g = p->obj[0]; - drawLine(p->currentContext, g, p->i32[0], p->i32[1], p->i32[2], p->i32[3], Graphics_forePixel(g)); + Pixel pixel = Graphics_forePixel(g); + drawLine(p->currentContext, g, p->i32[0], p->i32[1], p->i32[2], p->i32[3], gfxPaintFromColor(&pixel)); } ////////////////////////////////////////////////////////////////////////// TC_API void tugG_drawLine_iiiip(NMParams p) // totalcross/ui/gfx/Graphics native public void drawLine(int ax, int ay, int bx, int by, totalcross.ui.gfx.Paint paint); { TCObject g = p->obj[0]; TCObject paint = p->obj[1]; + GfxPaint paintFields = gfxPaint(paint); if (paint == null) { throwNullArgumentException(p->currentContext, "paint"); return; } - drawLine(p->currentContext, g, p->i32[0], p->i32[1], p->i32[2], p->i32[3], Paint_color(paint)); + drawLine(p->currentContext, g, p->i32[0], p->i32[1], p->i32[2], p->i32[3], paintFields); } ////////////////////////////////////////////////////////////////////////// TC_API void tugG_drawDots_iiii(NMParams p) // totalcross/ui/gfx/Graphics native public void drawDots(int ax, int ay, int bx, int by); @@ -237,14 +239,15 @@ TC_API void tugG_drawPolygon_IIi(NMParams p) // totalcross/ui/gfx/Graphics nativ TCObject xPoints = p->obj[1]; TCObject yPoints = p->obj[2]; int32 nPoints = p->i32[0]; + Pixel pixel = Graphics_forePixel(g); // fdie@ the vm has a 4bytes pointer! int32* xp = (int32 *)ARRAYOBJ_START(xPoints); int32* yp = (int32 *)ARRAYOBJ_START(yPoints); if (checkArrayRange(p->currentContext, xPoints, 0, nPoints) && checkArrayRange(p->currentContext, yPoints, 0, nPoints)) { - drawPolygon(p->currentContext, g, xp, yp, nPoints, 0, 0, 0, 0, 0, Graphics_forePixel(g)); - drawLine(p->currentContext, g, xp[0],yp[0],xp[nPoints-1],yp[nPoints-1], Graphics_forePixel(g)); + drawPolygon(p->currentContext, g, xp, yp, nPoints, 0, 0, 0, 0, 0, pixel); + drawLine(p->currentContext, g, xp[0],yp[0],xp[nPoints-1],yp[nPoints-1], gfxPaintFromColor(&pixel)); } } ////////////////////////////////////////////////////////////////////////// From 84dcdfa45d14296e90cada34d4ccfd1d3d6abd49 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Wed, 25 Mar 2026 23:58:05 -0300 Subject: [PATCH 05/18] feat(gfx): add rounded clip support to Graphics --- .../main/java/totalcross/ui/gfx/Graphics.java | 215 ++++++++++--- TotalCrossVM/src/nm/instancefields.h | 13 +- TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h | 302 +++++++++++++++--- TotalCrossVM/src/nm/ui/android/skia.cpp | 40 +++ TotalCrossVM/src/nm/ui/android/skia.h | 1 + 5 files changed, 476 insertions(+), 95 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java index db3627cbc..eb3ce9696 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/gfx/Graphics.java @@ -63,6 +63,7 @@ public final class Graphics { private int transX, transY; private int clipX1, clipY1, clipX2, clipY2; // clip1 <= k < clip2 + private RRect roundClip; private int minX, minY, maxX, maxY; @@ -270,6 +271,7 @@ public void refresh(int sx, int sy, int sw, int sh, int tx, int ty, Font f) { clipY1 = minY = Math.max(0, sy); clipX2 = maxX = Math.min(sx + sw, scrW); clipY2 = maxY = Math.min(sy + sh, scrH); + roundClip = null; transX = tx; transY = ty; if (f != null) { @@ -534,7 +536,7 @@ public void fillCircleGradient(int xc, int yc, int r) { public int getPixel(int x, int y) { x += transX; y += transY; - if (clipX1 <= x && x < clipX2 && clipY1 <= y && y < clipY2) { + if (isInsideClip(x, y)) { return getSurfacePixels(surface)[y * pitch + x] & 0xFFFFFF; } return -1; @@ -639,8 +641,16 @@ public void fillRect(int x, int y, int w, int h) { return; } int[] pix = getSurfacePixels(surface); - for (x += y * pitch; h-- > 0; x += pitch) { - Convert.fill(pix, x, x + w, backColor | alpha); + int rowX = x; + int rowY = y; + int color = backColor | alpha; + for (int row = 0; row < h; row++, rowY++) { + if (!clipHorizontalSpanToCurrentClip(rowY, rowX, rowX + w - 1, translateAndClipResults)) { + continue; + } + int start = translateAndClipResults[0]; + int width = translateAndClipResults[2]; + Convert.fill(pix, start + rowY * pitch, start + rowY * pitch + width, color); } if (isControlSurface) { needsUpdate = true; @@ -959,13 +969,20 @@ public void drawText(String text, int x, int y, int justifyWidth) { // draws the char for (; r < rmax; start += rowWIB, r++, y++) { + int rowStart; + int rowEnd; // draw each row + if (!clipHorizontalSpanToCurrentClip(y, x0, xMax - 1, translateAndClipResults)) { + continue; + } + rowStart = translateAndClipResults[0]; + rowEnd = rowStart + translateAndClipResults[2]; int yy = y * pitch; current = start; currentBit = startBit; for (x = x0; x < xMax; x++) { // draw each column - if (bitmapTable != null && (bitmapTable[current] & ands8[currentBit]) != 0 && x >= xMin) { + if (bitmapTable != null && (bitmapTable[current] & ands8[currentBit]) != 0 && rowStart <= x && x < rowEnd) { pixels[yy + x] = foreColor | alpha; } if (++currentBit == 8) { // finished this byte? @@ -982,7 +999,14 @@ public void drawText(String text, int x, int y, int justifyWidth) { int transparency; // draw the char for (; r < rmax; start += rowWIB, r++, y++) { + int rowStart; + int rowEnd; // draw each row + if (!clipHorizontalSpanToCurrentClip(y, x0, xMax - 1, translateAndClipResults)) { + continue; + } + rowStart = translateAndClipResults[0]; + rowEnd = rowStart + translateAndClipResults[2]; int yy = y * pitch; current = start; boolean isLowNibble = isNibbleStartingLow; @@ -990,7 +1014,7 @@ public void drawText(String text, int x, int y, int justifyWidth) { // draw each column transparency = isLowNibble ? (bitmapTable[current++] & 0xF) : ((bitmapTable[current] >> 4) & 0xF); isLowNibble = !isLowNibble; - if (transparency == 0 || x < xMin) { + if (transparency == 0 || x < rowStart || x >= rowEnd) { continue; } if (transparency == 0xF) { @@ -1011,13 +1035,20 @@ public void drawText(String text, int x, int y, int justifyWidth) { int[] imgPixels = font.nativeFonts[bits.index].getPixels(); // draw the char for (; r < rmax; start += rowWIB, r++, y++) { + int rowStart; + int rowEnd; // draw each row + if (!clipHorizontalSpanToCurrentClip(y, x0, xMax - 1, translateAndClipResults)) { + continue; + } + rowStart = translateAndClipResults[0]; + rowEnd = rowStart + translateAndClipResults[2]; int yy = y * pitch; current = start; for (x = x0; x < xMax; x++) { // draw each column transparency = (imgPixels[current++] >>> 24) & 0xFF; - if (transparency == 0 || x < xMin) { + if (transparency == 0 || x < rowStart || x >= rowEnd) { continue; } if (transparency == 0xFF) { @@ -1316,6 +1347,7 @@ public void setClip(int x, int y, int w, int h) { this.clipY1 = clipY1; this.clipX2 = clipX2; this.clipY2 = clipY2; + this.roundClip = null; } /** @@ -1329,6 +1361,24 @@ public void setClip(Rect r) { setClip(r.x, r.y, r.width, r.height); } + /** + * Sets the clipping area using a rounded rectangle. Backends without rounded clipping support + * currently fall back to the bounding rectangle. + * + * @param r the rounded rectangle with the clipping coordinates + */ + public void setClip(RRect r) { + if (r == null) { + clearClip(); + return; + } + setClip(r.x, r.y, r.width, r.height); + if (r.width <= 0 || r.height <= 0) { + return; + } + this.roundClip = new RRect(r.x + transX, r.y + transY, r.width, r.height, r.radii); + } + /** * Gets the current clipping rectangle. * @@ -1342,6 +1392,23 @@ public Rect getClip(Rect r) { return r; } + /** + * Gets the current clipping shape. If the current clip is rounded, the radii are preserved; + * otherwise this returns the current rectangular clip with zero radii. + */ + public RRect getClip() { + if (roundClip != null) { + return new RRect( + roundClip.x - transX, + roundClip.y - transY, + roundClip.width, + roundClip.height, + roundClip.radii); + } + Rect rect = getClip(new Rect()); + return new RRect(rect.x, rect.y, rect.width, rect.height, null); + } + /** Returns the clip's width */ public int getClipWidth() { return clipX2 - clipX1; @@ -1378,6 +1445,7 @@ public void clearClip() { clipY1 = minY; clipX2 = maxX; clipY2 = maxY; + roundClip = null; } /** @@ -2047,10 +2115,17 @@ public int getRGB(int[] data, int offset, int x, int y, int w, int h) { h = results[3]; int[] pixels = getSurfacePixels(surface); - int inc = pitch, pos = y * inc + x, count = w * h; + int inc = pitch, count = 0; - for (; h-- > 0; pos += inc, offset += w) { - System.arraycopy(pixels, pos, data, offset, w); + for (int yy = y; yy < y + h; yy++) { + if (!clipHorizontalSpanToCurrentClip(yy, x, x + w - 1, translateAndClipResults)) { + continue; + } + int start = translateAndClipResults[0]; + int width = translateAndClipResults[2]; + System.arraycopy(pixels, yy * inc + start, data, offset, width); + offset += width; + count += width; } return count; @@ -2084,10 +2159,17 @@ public int setRGB(int[] data, int offset, int x, int y, int w, int h) { h = results[3]; int[] pixels = getSurfacePixels(surface); - int inc = pitch, pos = y * inc + x, count = w * h; + int inc = pitch, count = 0; - for (; h-- > 0; pos += inc, offset += w) { - Vm.arrayCopy(data, offset, pixels, pos, w); + for (int yy = y; yy < y + h; yy++) { + if (!clipHorizontalSpanToCurrentClip(yy, x, x + w - 1, translateAndClipResults)) { + continue; + } + int start = translateAndClipResults[0]; + int width = translateAndClipResults[2]; + Vm.arrayCopy(data, offset, pixels, yy * inc + start, width); + offset += width; + count += width; } return count; @@ -2205,15 +2287,21 @@ private void drawSurface(int[] pixels, Object srcSurface, int x, int y, int widt int psrc = (bmpY + y) * scrPitch + bmpX + x; int pdst = dstY * pitch + dstX; int alphaMask = srcSurface instanceof Image ? ((Image) srcSurface).alphaMask : 255; - for (j = height; --j >= 0; psrc += scrPitch, pdst += pitch) { - int srcIdx = psrc; // guich@450_1 - int dstIdx = pdst; + for (j = height; --j >= 0; psrc += scrPitch, pdst += pitch, dstY++) { + if (!clipHorizontalSpanToCurrentClip(dstY, dstX, dstX + width - 1, translateAndClipResults)) { + continue; + } + int spanStart = translateAndClipResults[0]; + int spanWidth = translateAndClipResults[2]; + int skip = spanStart - dstX; + int srcIdx = psrc + skip; // guich@450_1 + int dstIdx = pdst + skip; if (isSrcScreen) { - for (i = width; --i >= 0;) { + for (i = spanWidth; --i >= 0;) { dst[dstIdx++] = pixels[srcIdx++] | 0xFF000000; } } else { - for (i = width; --i >= 0; dstIdx++) { + for (i = spanWidth; --i >= 0; dstIdx++) { bmpPt = pixels[srcIdx++]; int a = (bmpPt >>> 24) & 0xFF; a = alphaMask * a / 255; @@ -2401,7 +2489,7 @@ private void drawPolygon(int[] xPoints1, int[] yPoints1, int base1, int nPoints1 private void setPixel(int x, int y, int color) { x += transX; y += transY; - if (clipX1 <= x && x < clipX2 && clipY1 <= y && y < clipY2) { + if (isInsideClip(x, y)) { if (x < 0 || y < 0) { return; } @@ -2430,6 +2518,11 @@ private void drawHLine(int x, int y, int w, int color) { if (x < 0 || y < 0 || w < 0) { return; } + if (!clipHorizontalSpanToCurrentClip(y, x, x + w - 1, translateAndClipResults)) { + return; + } + x = translateAndClipResults[0]; + w = translateAndClipResults[2]; int[] pix = getSurfacePixels(surface); x += y * pitch; Convert.fill(pix, x, x + w, color); @@ -2457,8 +2550,11 @@ private void drawVLine(int x, int y, int h, int color) { return; } int[] pixels = getSurfacePixels(surface); - for (x += y * pitch; h-- > 0; x += pitch) { - pixels[x] = color; + int baseX = x; + for (x += y * pitch; h-- > 0; x += pitch, y++) { + if (isInsideClip(baseX, y)) { + pixels[x] = color; + } } if (isControlSurface) { needsUpdate = true; @@ -2661,6 +2757,48 @@ private boolean translateAndClip(int x, int y, int w, int h) { return true; } + private boolean isInsideClip(int x, int y) { + if (!(clipX1 <= x && x < clipX2 && clipY1 <= y && y < clipY2)) { + return false; + } + return roundClip == null || roundClip.contains(x, y); + } + + private boolean clipHorizontalSpanToCurrentClip(int y, int start, int end, int[] out) { + if (y < clipY1 || y >= clipY2 || end < start) { + return false; + } + if (start < clipX1) { + start = clipX1; + } + if (end >= clipX2) { + end = clipX2 - 1; + } + if (end < start) { + return false; + } + if (roundClip != null) { + Span span = roundClip.horizontalSpan(y); + if (!span.isValid()) { + return false; + } + if (start < span.start) { + start = span.start; + } + if (end > span.end) { + end = span.end; + } + if (end < start) { + return false; + } + } + out[0] = start; + out[1] = y; + out[2] = end - start + 1; + out[3] = 1; + return true; + } + private int doClipX(int x) { return ((x + transX) < clipX1) ? (clipX1 - transX) : ((x + transX) >= clipX2) ? (clipX2 - transX) : x; // guich@401_2: added TRANSX/Y } @@ -2796,9 +2934,6 @@ private void arcPiePointDrawAndFill(int xc, int yc, int rx, int ry, double start double ppd; int startIndex, endIndex, index, i, oldX1 = 0, oldY1 = 0, oldX2 = 0, oldY2 = 0; int nq, size = 0; - boolean checkClipX = (xc + rx + transX) > clipX2 || (xc - rx + transX) < clipX1; // guich@340_3 - guich@401_2: - // added TRANSX/Y - boolean checkClipY = (yc + ry + transY) > clipY2 || (yc - ry + transY) < clipY1; if (rx < 0 || ry < 0) { return; } @@ -2813,7 +2948,7 @@ private void arcPiePointDrawAndFill(int xc, int yc, int rx, int ry, double start int[] xPoints = gxPoints; int[] yPoints = gyPoints; // step 0: if possible, use cached results - int clipFactor = clipX1 * 1000000000 + clipX2 * 10000000 + clipY1 * 100000 + clipY2; + int clipFactor = 0; boolean sameClip = clipFactor == lastClipFactor; boolean sameC = sameClip && xc == lastXC && yc == lastYC; boolean sameR = sameClip && rx == lastRX && ry == lastRY; @@ -2884,20 +3019,20 @@ private void arcPiePointDrawAndFill(int xc, int yc, int rx, int ry, double start // save 4 points using symmetry // guich@340_3: added clipping index = nq * 0 + i; // 0/3 - xPoints[index] = checkClipX ? doClipX(xc + x) : (xc + x); - yPoints[index] = checkClipY ? doClipY(yc - y) : (yc - y); + xPoints[index] = xc + x; + yPoints[index] = yc - y; index = nq * 2 - i - 1; // 1/3 - xPoints[index] = checkClipX ? doClipX(xc - x) : (xc - x); - yPoints[index] = checkClipY ? doClipY(yc - y) : (yc - y); + xPoints[index] = xc - x; + yPoints[index] = yc - y; index = nq * 2 + i; // 2/3 - xPoints[index] = checkClipX ? doClipX(xc - x) : (xc - x); - yPoints[index] = checkClipY ? doClipY(yc + y) : (yc + y); + xPoints[index] = xc - x; + yPoints[index] = yc + y; index = nq * 4 - i - 1; // 3/3 - xPoints[index] = checkClipX ? doClipX(xc + x) : (xc + x); - yPoints[index] = checkClipY ? doClipY(yc + y) : (yc + y); + xPoints[index] = xc + x; + yPoints[index] = yc + y; i++; y++; // always move up here @@ -2917,20 +3052,20 @@ private void arcPiePointDrawAndFill(int xc, int yc, int rx, int ry, double start // save 4 points using symmetry // guich@340_3: added clipping index = nq * 0 + i; // 0/3 - xPoints[index] = checkClipX ? doClipX(xc + x) : (xc + x); - yPoints[index] = checkClipY ? doClipY(yc - y) : (yc - y); + xPoints[index] = xc + x; + yPoints[index] = yc - y; index = nq * 2 - i - 1; // 1/3 - xPoints[index] = checkClipX ? doClipX(xc - x) : (xc - x); - yPoints[index] = checkClipY ? doClipY(yc - y) : (yc - y); + xPoints[index] = xc - x; + yPoints[index] = yc - y; index = nq * 2 + i; // 2/3 - xPoints[index] = checkClipX ? doClipX(xc - x) : (xc - x); - yPoints[index] = checkClipY ? doClipY(yc + y) : (yc + y); + xPoints[index] = xc - x; + yPoints[index] = yc + y; index = nq * 4 - i - 1; // 3/3 - xPoints[index] = checkClipX ? doClipX(xc + x) : (xc + x); - yPoints[index] = checkClipY ? doClipY(yc + y) : (yc + y); + xPoints[index] = xc + x; + yPoints[index] = yc + y; i++; x--; // always move left here diff --git a/TotalCrossVM/src/nm/instancefields.h b/TotalCrossVM/src/nm/instancefields.h index 4f67f76cf..362ad17bf 100644 --- a/TotalCrossVM/src/nm/instancefields.h +++ b/TotalCrossVM/src/nm/instancefields.h @@ -143,11 +143,14 @@ #define Graphics_lastPPD(o) FIELD_DBL(o, OBJ_CLASS(o), 0) -#define Graphics_surface(o) FIELD_OBJ(o, OBJ_CLASS(o), 0) -#define Graphics_font(o) FIELD_OBJ(o, OBJ_CLASS(o), 1) -#define Graphics_xPoints(o) FIELD_OBJ(o, OBJ_CLASS(o), 2) -#define Graphics_yPoints(o) FIELD_OBJ(o, OBJ_CLASS(o), 3) -#define Graphics_ints(o) FIELD_OBJ(o, OBJ_CLASS(o), 4) +#define Graphics_roundClip(o) FIELD_OBJ(o, OBJ_CLASS(o), 0) +#define Graphics_surface(o) FIELD_OBJ(o, OBJ_CLASS(o), 1) +#define Graphics_font(o) FIELD_OBJ(o, OBJ_CLASS(o), 2) +#define Graphics_xPoints(o) FIELD_OBJ(o, OBJ_CLASS(o), 3) +#define Graphics_yPoints(o) FIELD_OBJ(o, OBJ_CLASS(o), 4) +#define Graphics_ints(o) FIELD_OBJ(o, OBJ_CLASS(o), 5) +#define Graphics_cyPoints(o) FIELD_OBJ(o, OBJ_CLASS(o), 6) +#define Graphics_cxPoints(o) FIELD_OBJ(o, OBJ_CLASS(o), 7) // totalcross.ui.image.Image #define Image_width(o) FIELD_I32(o, 1) diff --git a/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h b/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h index 0366b21b1..ac0b75828 100644 --- a/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h +++ b/TotalCrossVM/src/nm/ui/GraphicsPrimitives_c.h @@ -65,6 +65,68 @@ static bool translateAndClip(TCObject g, int32 *pX, int32 *pY, int32 *pWidth, in #define Get_Clip(g) Graphics_clipX1(g), Graphics_clipY1(g), Graphics_clipX2(g), Graphics_clipY2(g) +static bool gfxClipContains(TCObject g, int32 x, int32 y) +{ + TCObject roundClip = Graphics_roundClip(g); + int32 start, end; + + if (x < Graphics_clipX1(g) || x >= Graphics_clipX2(g) || y < Graphics_clipY1(g) || y >= Graphics_clipY2(g)) + return false; + + if (roundClip == null) + return true; + + if (!gfxComputeRRectSpan( + Rect_x(roundClip), + Rect_y(roundClip), + Rect_width(roundClip), + Rect_height(roundClip), + RRect_radii(roundClip), + y, + &start, + &end)) + return false; + + return start <= x && x <= end; +} + +static bool gfxClipHorizontalSpan(TCObject g, int32 y, int32 *start, int32 *end) +{ + TCObject roundClip = Graphics_roundClip(g); + int32 clipStart = Graphics_clipX1(g); + int32 clipEnd = Graphics_clipX2(g) - 1; + int32 roundStart, roundEnd; + + if (y < Graphics_clipY1(g) || y >= Graphics_clipY2(g)) + return false; + + if (roundClip != null) + { + if (!gfxComputeRRectSpan( + Rect_x(roundClip), + Rect_y(roundClip), + Rect_width(roundClip), + Rect_height(roundClip), + RRect_radii(roundClip), + y, + &roundStart, + &roundEnd)) + return false; + + if (clipStart < roundStart) + clipStart = roundStart; + if (clipEnd > roundEnd) + clipEnd = roundEnd; + } + + if (clipEnd < clipStart) + return false; + + *start = clipStart; + *end = clipEnd; + return true; +} + // >>>>>>>>> // DO NOT LOCK THE SCREEN ON THESE METHODS. THE CALLER MUST DO THAT. static Pixel* getSurfacePixels(TCObject surf) @@ -228,6 +290,7 @@ static void drawSurface(Context currentContext, TCObject dstSurf, TCObject srcSu Pixel * srcPixels; Pixel * dstPixels; int32 srcPitch, srcWidth, srcHeight, alphaMask = 0; + TCObject roundClip = Graphics_roundClip(dstSurf); bool isSrcScreen = !Surface_isImage(srcSurf); bool unlockSrc = false; if (Surface_isImage(srcSurf)) @@ -357,9 +420,39 @@ static void drawSurface(Context currentContext, TCObject dstSurf, TCObject srcSu #endif for (i=0; i < (uint32)height; i++) // in opengl, only case of image drawing on image { - PixelConv *ps = (PixelConv*)srcPixels; - PixelConv *pt = (PixelConv*)dstPixels; - uint32 count = width; + int32 currentY = dstY + (int32)i; + int32 rowStart = dstX; + int32 rowEnd = dstX + width - 1; + int32 rowWidth; + PixelConv *ps; + PixelConv *pt; + uint32 count; + + if (roundClip != null) + { + int32 spanStart, spanEnd; + if (!gfxClipHorizontalSpan(dstSurf, currentY, &spanStart, &spanEnd)) + { + srcPixels += srcPitch; + dstPixels += Graphics_pitch(dstSurf); + continue; + } + if (rowStart < spanStart) + rowStart = spanStart; + if (rowEnd > spanEnd) + rowEnd = spanEnd; + if (rowEnd < rowStart) + { + srcPixels += srcPitch; + dstPixels += Graphics_pitch(dstSurf); + continue; + } + } + + rowWidth = rowEnd - rowStart + 1; + ps = (PixelConv*)srcPixels + (rowStart - dstX); + pt = (PixelConv*)dstPixels + (rowStart - dstX); + count = (uint32)rowWidth; if (isSrcScreen) for (;count != 0; pt++,ps++, count--) { @@ -534,7 +627,7 @@ static void drawSurface(Context currentContext, TCObject dstSurf, TCObject srcSu } if (doClip) { - skia_setClip(Get_Clip(dstSurf)); + skia_applyClip(dstSurf); clipSet = true; } if (frameCount > 1) { @@ -574,7 +667,7 @@ static int32 getPixel(TCObject g, int32 x, int32 y) int32 ret = -1; x += Graphics_transX(g); y += Graphics_transY(g); - if (Graphics_clipX1(g) <= x && x < Graphics_clipX2(g) && Graphics_clipY1(g) <= y && y < Graphics_clipY2(g)) + if (gfxClipContains(g, x, y)) { PixelConv p; #ifdef SKIA_H @@ -599,7 +692,7 @@ static PixelConv getPixelConv(TCObject g, int32 x, int32 y) p.pixel = -1; x += Graphics_transX(g); y += Graphics_transY(g); - if (Graphics_clipX1(g) <= x && x < Graphics_clipX2(g) && Graphics_clipY1(g) <= y && y < Graphics_clipY2(g)) + if (gfxClipContains(g, x, y)) { #ifdef __gl2_h_ if (Graphics_useOpenGL(g)) @@ -619,7 +712,7 @@ static void setPixel(Context currentContext, TCObject g, int32 x, int32 y, Pixel { x += Graphics_transX(g); y += Graphics_transY(g); - if (Graphics_clipX1(g) <= x && x < Graphics_clipX2(g) && Graphics_clipY1(g) <= y && y < Graphics_clipY2(g)) + if (gfxClipContains(g, x, y)) { #ifdef __gl2_h_ if (Graphics_useOpenGL(g)) @@ -643,7 +736,7 @@ static void setPixel(Context currentContext, TCObject g, int32 x, int32 y, Pixel { x += Graphics_transX(g); y += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_setPixel(0, x, y, pixel | Graphics_alpha(g)); skia_restoreClip(); @@ -684,22 +777,22 @@ static bool surelyOutsideClip(TCObject g, int32 x1, int32 y1, int32 x2, int32 y2 // Draws a horizontal line from x to x+w-1, translating and clipping static void drawHLine(Context currentContext, TCObject g, int32 x, int32 y, int32 width, Pixel pixel1, Pixel pixel2) { + int32 clipStart, clipEnd; + x += Graphics_transX(g); y += Graphics_transY(g); - /* - | line must lie inside y clip bounds, must not end before clip x1 - | and must not start after clip x2 - */ - if (Graphics_clipY1(g) <= y && y < Graphics_clipY2(g) && Graphics_clipX1(g) <= (x+width) && x < Graphics_clipX2(g)) // NOPT + if (width <= 0 || !gfxClipHorizontalSpan(g, y, &clipStart, &clipEnd) || clipStart > (x + width - 1) || x > clipEnd) + return; + { Pixel* pTgt; - if (x < Graphics_clipX1(g)) // line start before clip x1 + if (x < clipStart) { - width -= Graphics_clipX1(g)-x; - x = Graphics_clipX1(g); + width -= clipStart - x; + x = clipStart; } - if ((x+width) > Graphics_clipX2(g)) // line stops after clip x2 - width = Graphics_clipX2(g)-x; + if ((x + width - 1) > clipEnd) + width = clipEnd - x + 1; if (width <= 0) return; @@ -736,8 +829,46 @@ static void drawHLine(Context currentContext, TCObject g, int32 x, int32 y, int3 // Draws a vertical line from y to y+h-1 using the given color static void drawVLine(Context currentContext, TCObject g, int32 x, int32 y, int32 height, Pixel pixel1, Pixel pixel2) { + TCObject roundClip = Graphics_roundClip(g); x += Graphics_transX(g); y += Graphics_transY(g); + + if (roundClip != null) + { + int32 startY = y; + int32 endY = y + height; + Pixel *pTgt; + int32 pitch = Graphics_pitch(g); + uint32 i = 0; + + if (x < Graphics_clipX1(g) || x >= Graphics_clipX2(g)) + return; + if (startY < Graphics_clipY1(g)) + startY = Graphics_clipY1(g); + if (endY > Graphics_clipY2(g)) + endY = Graphics_clipY2(g); + if (endY <= startY) + return; +#ifdef __gl2_h_ + if (Graphics_useOpenGL(g)) + { + int32 yy; + for (yy = startY; yy < endY; yy++) + if (gfxClipContains(g, x, yy)) + glDrawPixel(x, yy, pixel1 == pixel2 ? pixel1 : ((i++ & 1) ? pixel1 : pixel2), 255); + currentContext->fullDirty = true; + return; + } +#endif + pTgt = getGraphicsPixels(g) + startY * pitch + x; + for (y = startY; y < endY; y++, pTgt += pitch) + if (gfxClipContains(g, x, y)) + *pTgt = pixel1 == pixel2 ? pixel1 : ((i++ & 1) ? pixel1 : pixel2); + + if (!currentContext->fullDirty && !Graphics_isImageSurface(g)) + markScreenDirty(currentContext, x, startY, 1, endY - startY); + return; + } /* | line must lie inside x clip bounds, must not end before clip y1 | and must not start after clip y2 @@ -842,7 +973,7 @@ static void drawDottedLine(Context currentContext, TCObject g, int32 x1, int32 y Pixel *row; int32 on = 1; int32 clipX1 = Graphics_clipX1(g), clipY1 = Graphics_clipY1(g), clipX2 = Graphics_clipX2(g), clipY2 = Graphics_clipY2(g); - bool dontClip = true; // the most common will be draw lines that do not cross the clip bounds, so we may speedup a little + bool dontClip = Graphics_roundClip(g) == null; // the most common will be draw lines that do not cross the clip bounds, so we may speedup a little xMin += tX; yMin += tY; @@ -869,7 +1000,7 @@ static void drawDottedLine(Context currentContext, TCObject g, int32 x1, int32 y if (pixel1 == pixel2) // quick optimization for (; dX >= 0; dX--) // process each point in the line one at a time (just use dX) { - if (dontClip || (clipX1 <= currentX && currentX < clipX2 && clipY1 <= currentY && currentY < clipY2)) + if (dontClip || gfxClipContains(g, currentX, currentY)) { #ifdef __gl2_h_ if (Graphics_useOpenGL(g)) @@ -892,7 +1023,7 @@ static void drawDottedLine(Context currentContext, TCObject g, int32 x1, int32 y else for (; dX >= 0; dX--) // process each point in the line one at a time (just use dX) { - if (dontClip || (clipX1 <= currentX && currentX < clipX2 && clipY1 <= currentY && currentY < clipY2)) + if (dontClip || gfxClipContains(g, currentX, currentY)) { #ifdef __gl2_h_ if (Graphics_useOpenGL(g)) @@ -922,7 +1053,7 @@ static void drawDottedLine(Context currentContext, TCObject g, int32 x1, int32 y if (pixel1 == pixel2) // quick optimization for (; dY >= 0; dY--) // process each point in the line one at a time (just use dY) { - if (dontClip || (clipX1 <= currentX && currentX < clipX2 && clipY1 <= currentY && currentY < clipY2)) + if (dontClip || gfxClipContains(g, currentX, currentY)) { #ifdef __gl2_h_ if (Graphics_useOpenGL(g)) @@ -945,7 +1076,7 @@ static void drawDottedLine(Context currentContext, TCObject g, int32 x1, int32 y else for (; dY >= 0; dY--) // process each point in the line one at a time (just use dY) { - if (dontClip || (clipX1 <= currentX && currentX < clipX2 && clipY1 <= currentY && currentY < clipY2)) + if (dontClip || gfxClipContains(g, currentX, currentY)) { #ifdef __gl2_h_ if (Graphics_useOpenGL(g)) @@ -983,7 +1114,7 @@ static void drawDottedLine(Context currentContext, TCObject g, int32 x1, int32 y y1 += Graphics_transY(g); x2 += Graphics_transX(g); y2 += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_drawDottedLine(0, x1, y1, x2, y2, pixel1 | Graphics_alpha(g), pixel2 | Graphics_alpha(g)); skia_restoreClip(); @@ -1010,7 +1141,7 @@ static void drawLine(Context currentContext, TCObject g, int32 x1, int32 y1, int y1 += Graphics_transY(g); x2 += Graphics_transX(g); y2 += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_drawLine(0, x1, y1, x2, y2, paint); skia_restoreClip(); @@ -1032,7 +1163,7 @@ static void drawRect(Context currentContext, TCObject g, int32 x, int32 y, int32 { x += Graphics_transX(g); y += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_drawRect(0, x, y, w, h, pixel | Graphics_alpha(g)); skia_restoreClip(); @@ -1046,6 +1177,7 @@ static void drawRect(Context currentContext, TCObject g, int32 x, int32 y, int32 #ifndef SKIA_H static void fillRect(Context currentContext, TCObject g, int32 x, int32 y, int32 width, int32 height, Pixel pixel) { + TCObject roundClip = Graphics_roundClip(g); int32 clipX1 = Graphics_clipX1(g); int32 clipX2 = Graphics_clipX2(g); int32 clipY1 = Graphics_clipY1(g); @@ -1071,6 +1203,40 @@ static void fillRect(Context currentContext, TCObject g, int32 x, int32 y, int32 if (height > 0 && width > 0) { + if (roundClip != null) + { + int32 pitch = Graphics_pitch(g); + Pixel *base = getGraphicsPixels(g); + int32 yy; + + for (yy = y; yy < y + height; yy++) + { + int32 spanStart, spanEnd; + int32 rowX = x; + int32 rowWidth = width; + Pixel *to; + uint32 i; + + if (!gfxClipHorizontalSpan(g, yy, &spanStart, &spanEnd)) + continue; + if (rowX < spanStart) + { + rowWidth -= spanStart - rowX; + rowX = spanStart; + } + if ((rowX + rowWidth - 1) > spanEnd) + rowWidth = spanEnd - rowX + 1; + if (rowWidth <= 0) + continue; + + to = base + yy * pitch + rowX; + for (i = (uint32)rowWidth; i != 0; i--) + *to++ = pixel; + } + if (!currentContext->fullDirty && !Graphics_isImageSurface(g)) + markScreenDirty(currentContext, x, y, width, height); + return; + } #ifdef __gl2_h_ if (Graphics_useOpenGL(g)) { @@ -1110,7 +1276,7 @@ static void fillRect(Context currentContext, TCObject g, int32 x, int32 y, int32 { x += Graphics_transX(g); y += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_fillRect(0, x, y, w, h, pixel | Graphics_alpha(g)); skia_restoreClip(); @@ -1321,13 +1487,16 @@ static void drawText(Context currentContext, TCObject g, JCharP text, int32 chrC } else #endif - for (row=row0; r < rmax; start+=rowWIB, r++,row += pitch) // draw each row + for (row=row0; r < rmax; start+=rowWIB, r++,row += pitch,y++) // draw each row { + int32 rowStart, rowEnd; + if (!gfxClipHorizontalSpan(g, y, &rowStart, &rowEnd)) + continue; current = start; ands = ands8 + (currentBit = startBit); for (x=x0; x < xMax; x++) { - if ((*current & *ands++) != 0 && x >= xMin) + if ((*current & *ands++) != 0 && rowStart <= x && x <= rowEnd) row[x] = foreColor; if (++currentBit == 8) // finished this uint8? { @@ -1374,8 +1543,11 @@ static void drawText(Context currentContext, TCObject g, JCharP text, int32 chrC } else #endif - for (row=row0; r < rmax; start+=rowWIB, r++,row += pitch) // draw each row + for (row=row0; r < rmax; start+=rowWIB, r++,row += pitch,y++) // draw each row { + int32 rowStart, rowEnd; + if (!gfxClipHorizontalSpan(g, y, &rowStart, &rowEnd)) + continue; current = start; isLowNibble = isNibbleStartingLow; i = (PixelConv*)&row[x0]; @@ -1383,7 +1555,7 @@ static void drawText(Context currentContext, TCObject g, JCharP text, int32 chrC { transparency = isLowNibble ? (*current++ & 0xF) : ((*current >> 4) & 0xF); isLowNibble = !isLowNibble; - if (transparency == 0 || x < xMin) + if (transparency == 0 || x < rowStart || x > rowEnd) continue; if (transparency == 0xF) i->pixel = foreColor; @@ -1447,14 +1619,17 @@ static void drawText(Context currentContext, TCObject g, JCharP text, int32 chrC { rowWIB = width+diffW; start = alpha + istart * rowWIB; - for (row=row0; r < rmax; start+=rowWIB, r++,row += pitch) // draw each row + for (row=row0; r < rmax; start+=rowWIB, r++,row += pitch,y++) // draw each row { + int32 rowStart, rowEnd; + if (!gfxClipHorizontalSpan(g, y, &rowStart, &rowEnd)) + continue; current = start; i = (PixelConv*)&row[x0]; for (x=x0; x < xMax; x++,i++) { transparency = *current++; - if (transparency == 0 || x < xMin) + if (transparency == 0 || x < rowStart || x > rowEnd) continue; if (transparency == 0xFF) i->pixel = foreColor; @@ -1507,7 +1682,7 @@ static void drawText(Context currentContext, TCObject g, JCharP text, int32 chrC x += Graphics_transX(g); y += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_drawText(0, text, chrCount * sizeof(JChar), x, y + fontSize, foreColor | Graphics_alpha(g), justifyWidth, fontSize, typefaceIndex); skia_restoreClip(); @@ -1639,7 +1814,7 @@ static void ellipseDrawAndFill(Context currentContext, TCObject g, int32 xc, int { xc += Graphics_transX(g); yc += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_ellipseDrawAndFill(0, xc, yc, rx, ry, pc1 | Graphics_alpha(g), pc2 | Graphics_alpha(g), fill, gradient); skia_restoreClip(); @@ -1838,7 +2013,7 @@ static void fillPolygon(Context currentContext, TCObject g, int32 *xPoints1, int #else static void fillPolygon(Context currentContext, TCObject g, int32 *xPoints1, int32 *yPoints1, int32 nPoints1, int32 *xPoints2, int32 *yPoints2, int32 nPoints2, int32 tx, int32 ty, Pixel c1, Pixel c2, bool gradient, bool isPie) { - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_fillPolygon(0, xPoints1, yPoints1, nPoints1, Graphics_transX(g), Graphics_transY(g), c1 | Graphics_alpha(g), c2 | Graphics_alpha(g), gradient, isPie); skia_restoreClip(); @@ -1877,7 +2052,7 @@ static void drawPolygon(Context currentContext, TCObject g, int32 *xPoints1, int #else static void drawPolygon(Context currentContext, TCObject g, int32 *xPoints1, int32 *yPoints1, int32 nPoints1, int32 *xPoints2, int32 *yPoints2, int32 nPoints2, int32 tx, int32 ty, Pixel pixel) { - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_drawPolygon(0, xPoints1, yPoints1, nPoints1, Graphics_transX(g), Graphics_transY(g), pixel | Graphics_alpha(g)); skia_restoreClip(); @@ -1894,7 +2069,7 @@ static void arcPiePointDrawAndFill(Context currentContext, TCObject g, int32 xc, { xc += Graphics_transX(g); yc += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_arcPiePointDrawAndFill(0, xc, yc, rx, ry, startAngle, endAngle, c | Graphics_alpha(g), c2 | Graphics_alpha(g), fill, pie, gradient); skia_restoreClip(); @@ -1911,7 +2086,7 @@ static void arcPiePointDrawAndFill(Context currentContext, TCObject g, int32 xc, TCObject *yPointsObj = &Graphics_yPoints(g); int32 *xPoints = *xPointsObj ? (int32*)ARRAYOBJ_START(*xPointsObj) : null; int32 *yPoints = *yPointsObj ? (int32*)ARRAYOBJ_START(*yPointsObj) : null; - int32 clipFactor = Graphics_minX(g) * 1000000000 + Graphics_maxX(g) * 10000000 + Graphics_minY(g) * 100000 + Graphics_maxY(g); + int32 clipFactor = 0; bool sameClipFactor = Graphics_lastClipFactor(g) == clipFactor; if (rx < 0 || ry < 0) // guich@501_13 @@ -2203,7 +2378,7 @@ static void drawRoundRect(Context currentContext, TCObject g, int32 x, int32 y, { x += Graphics_transX(g); y += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_drawRoundRect(0, x, y, w, h, r, c | Graphics_alpha(g)); skia_restoreClip(); @@ -2275,7 +2450,7 @@ static void drawRRect(Context currentContext, TCObject g, int32 x, int32 y, int3 { x += Graphics_transX(g); y += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_drawRRect(0, x, y, w, h, radii, c | Graphics_alpha(g), filled); skia_restoreClip(); @@ -2358,7 +2533,7 @@ static void fillRoundRect(Context currentContext, TCObject g, int32 x, int32 y, { x += Graphics_transX(g); y += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_fillRoundRect(0, x, y, w, h, r, c | Graphics_alpha(g)); skia_restoreClip(); @@ -3059,7 +3234,7 @@ static void drawRoundGradient(Context currentContext, TCObject g, int32 startX, startY += Graphics_transY(g); endX += Graphics_transX(g); endY += Graphics_transY(g); - skia_setClip(Get_Clip(g)); + skia_applyClip(g); skia_drawRoundGradient(0, startX, startY, endX, endY, topLeftRadius, topRightRadius, bottomLeftRadius, bottomRightRadius, startColor | Graphics_alpha(g), endColor | Graphics_alpha(g), vertical); skia_restoreClip(); @@ -3080,9 +3255,10 @@ static int getsetRGB(Context currentContext, TCObject g, TCObject dataObj, int32 if (translateAndClip(g, &x, &y, &w, &h)) { Pixel* data = ((Pixel*)ARRAYOBJ_START(dataObj)) + offset; - int32 inc = Graphics_pitch(g), count = w * h; - Pixel* pixels = getGraphicsPixels(g) + y * inc + x; + int32 inc = Graphics_pitch(g), count = 0; + Pixel* pixels = getGraphicsPixels(g); bool markDirty = !currentContext->fullDirty && !Graphics_isImageSurface(g); + int32 yy; #if 0//def __gl2_h_ currentContext->fullDirty |= markDirty; if (isGet && Graphics_useOpenGL(g)) @@ -3090,15 +3266,41 @@ static int getsetRGB(Context currentContext, TCObject g, TCObject dataObj, int32 else #endif if (isGet) - for (; h-- > 0; pixels += inc, data += w) - xmemmove(data, pixels, w<<2); + for (yy = y; yy < y + h; yy++) + { + int32 start, end, width; + if (!gfxClipHorizontalSpan(g, yy, &start, &end)) + continue; + if (start < x) + start = x; + if (end > (x + w - 1)) + end = x + w - 1; + if (end < start) + continue; + width = end - start + 1; + xmemmove(data, pixels + yy * inc + start, width << 2); + data += width; + count += width; + } else - for (; h-- > 0; pixels += inc, data += w) + for (yy = y; yy < y + h; yy++) { - xmemmove(pixels, data, w<<2); + int32 start, end, width; + if (!gfxClipHorizontalSpan(g, yy, &start, &end)) + continue; + if (start < x) + start = x; + if (end > (x + w - 1)) + end = x + w - 1; + if (end < start) + continue; + width = end - start + 1; + xmemmove(pixels + yy * inc + start, data, width << 2); + data += width; + count += width; #ifndef __gl2_h_ if (markDirty) - markScreenDirty(currentContext, x, y++, w, 1); + markScreenDirty(currentContext, start, yy, width, 1); #endif } return count; diff --git a/TotalCrossVM/src/nm/ui/android/skia.cpp b/TotalCrossVM/src/nm/ui/android/skia.cpp index cea8d6759..82426cee5 100644 --- a/TotalCrossVM/src/nm/ui/android/skia.cpp +++ b/TotalCrossVM/src/nm/ui/android/skia.cpp @@ -93,6 +93,9 @@ #include #include +#include "../gfx.h" +#include "../../instancefields.h" + extern "C" { #if !defined APPLE && !defined ANDROID && !defined darwin && defined linux && defined __arm__ && !defined __aarch64__ @@ -350,6 +353,43 @@ void skia_setClip(int32 x1, int32 y1, int32 x2, int32 y2) canvas->save(); canvas->clipRect(SkRect::MakeLTRB(x1, y1, x2, y2)); } + +void skia_applyClip(TCObject g) +{ + TCObject roundClip = Graphics_roundClip(g); + + canvas->save(); + if (roundClip != null) + { + const double *radii = RRect_radii(roundClip); + SkVector corners[4]; + SkRRect rrect; + int i; + + for (i = 0; i < 4; i++) + { + corners[i].set((SkScalar)radii[i * 2], (SkScalar)radii[i * 2 + 1]); + } + + rrect.setRectRadii( + SkRect::MakeXYWH( + (SkScalar)Rect_x(roundClip), + (SkScalar)Rect_y(roundClip), + (SkScalar)Rect_width(roundClip), + (SkScalar)Rect_height(roundClip)), + corners); + canvas->clipRRect(rrect, true); + } + else + { + canvas->clipRect(SkRect::MakeLTRB( + Graphics_clipX1(g), + Graphics_clipY1(g), + Graphics_clipX2(g), + Graphics_clipY2(g))); + } +} + void skia_restoreClip() { canvas->restore(); diff --git a/TotalCrossVM/src/nm/ui/android/skia.h b/TotalCrossVM/src/nm/ui/android/skia.h index 47f8e3b1f..c24d6c79e 100644 --- a/TotalCrossVM/src/nm/ui/android/skia.h +++ b/TotalCrossVM/src/nm/ui/android/skia.h @@ -32,6 +32,7 @@ int skia_makeBitmap(int32 id, void *data, int32 w, int32 h); void skia_deleteBitmap(int32 id); void skia_setClip(int32 x1, int32 y1, int32 x2, int32 y2); +void skia_applyClip(TCObject g); void skia_restoreClip(); void skia_drawSurface(int32 skiaSurface, int32 id, float srcLeft, float srcTop, float srcRight, float srcBottom, float dstLeft, float dstTop, float dstRight, float dstBottom, int32 alphaMask); From 87673c973071940ff60d068bf1491b37831463ae Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Thu, 26 Mar 2026 01:03:48 -0300 Subject: [PATCH 06/18] feat(style): add box style model and CSS parser Introduce the box style model, including layout, paint, border, clip, shape, elevation, and shadow types. Add the CSS parser, geometry resolver, and painter used to turn box styles into computed geometry and rendered output. --- .../ui/style/css/BoxStyleCssParser.java | 809 ++++++++++++++++++ .../totalcross/ui/style/geom/BoxGeometry.java | 224 +++++ .../totalcross/ui/style/model/BorderSide.java | 109 +++ .../totalcross/ui/style/model/BoxBorder.java | 118 +++ .../totalcross/ui/style/model/BoxClip.java | 91 ++ .../totalcross/ui/style/model/BoxLayout.java | 100 +++ .../totalcross/ui/style/model/BoxPaint.java | 79 ++ .../totalcross/ui/style/model/BoxShape.java | 73 ++ .../totalcross/ui/style/model/BoxStyle.java | 112 +++ .../ui/style/model/CornerRadii.java | 129 +++ .../totalcross/ui/style/model/Elevation.java | 87 ++ .../totalcross/ui/style/model/Shadow.java | 48 ++ .../totalcross/ui/style/paint/BoxPainter.java | 227 +++++ 13 files changed, 2206 insertions(+) create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/BorderSide.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxBorder.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxClip.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxLayout.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxPaint.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxShape.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxStyle.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/CornerRadii.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/Elevation.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/model/Shadow.java create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java new file mode 100644 index 000000000..74902767f --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java @@ -0,0 +1,809 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.css; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import totalcross.ui.Insets; +import totalcross.ui.gfx.Color; +import totalcross.ui.style.model.BorderSide; +import totalcross.ui.style.model.BoxBorder; +import totalcross.ui.style.model.BoxClip; +import totalcross.ui.style.model.BoxLayout; +import totalcross.ui.style.model.BoxPaint; +import totalcross.ui.style.model.BoxShape; +import totalcross.ui.style.model.BoxStyle; +import totalcross.ui.style.model.CornerRadii; +import totalcross.ui.style.model.Elevation; +import totalcross.ui.style.model.Shadow; + +/** + * Parses a CSS-like subset into a {@link BoxStyle}. + */ +public final class BoxStyleCssParser { + private BoxStyleCssParser() { + } + + /** + * Parses the given CSS-like declaration block into a box style. + */ + public static BoxStyle parse(String css) { + String body = extractRuleBody(css); + + BorderDecl top = new BorderDecl(); + BorderDecl right = new BorderDecl(); + BorderDecl bottom = new BorderDecl(); + BorderDecl left = new BorderDecl(); + + BoxStyle defaults = new BoxStyle(); + CornerRadii radii = defaults.shape.radii; + Insets padding = new Insets(); + int backgroundColor = defaults.paint.backgroundColor; + int pressedColor = defaults.paint.pressedColor; + int overflow = defaults.clip.overflow; + Elevation elevation = defaults.elevation; + + for (String decl : splitDeclarations(body)) { + int colon = decl.indexOf(':'); + if (colon <= 0) { + continue; + } + + String property = normalize(decl.substring(0, colon)); + String value = decl.substring(colon + 1).trim(); + if (value.isEmpty()) { + continue; + } + + switch (property) { + case "border": + applyBorderShorthand(value, top, right, bottom, left); + break; + case "border-top": + applyBorderSideShorthand(value, top); + break; + case "border-right": + applyBorderSideShorthand(value, right); + break; + case "border-bottom": + applyBorderSideShorthand(value, bottom); + break; + case "border-left": + applyBorderSideShorthand(value, left); + break; + case "border-width": + applyWidths(parse1To4BorderWidths(value), top, right, bottom, left); + break; + case "border-style": + applyStyles(parse1To4Keywords(value), top, right, bottom, left); + break; + case "border-color": + applyColors(parse1To4Colors(value), top, right, bottom, left); + break; + case "border-top-width": + top.width = parseLengthDouble(value); + break; + case "border-right-width": + right.width = parseLengthDouble(value); + break; + case "border-bottom-width": + bottom.width = parseLengthDouble(value); + break; + case "border-left-width": + left.width = parseLengthDouble(value); + break; + case "border-top-style": + top.style = parseBorderStyle(value); + break; + case "border-right-style": + right.style = parseBorderStyle(value); + break; + case "border-bottom-style": + bottom.style = parseBorderStyle(value); + break; + case "border-left-style": + left.style = parseBorderStyle(value); + break; + case "border-top-color": + top.color = parseColor(value); + break; + case "border-right-color": + right.color = parseColor(value); + break; + case "border-bottom-color": + bottom.color = parseColor(value); + break; + case "border-left-color": + left.color = parseColor(value); + break; + case "border-radius": + radii = applyBorderRadius(value); + break; + case "border-top-left-radius": + radii = CornerRadii.of( + parseLengthDouble(firstToken(value)), + parseLengthDouble(firstToken(value)), + radii.topRightX, + radii.topRightY, + radii.bottomRightX, + radii.bottomRightY, + radii.bottomLeftX, + radii.bottomLeftY + ); + break; + case "border-top-right-radius": + radii = CornerRadii.of( + radii.topLeftX, + radii.topLeftY, + parseLengthDouble(firstToken(value)), + parseLengthDouble(firstToken(value)), + radii.bottomRightX, + radii.bottomRightY, + radii.bottomLeftX, + radii.bottomLeftY + ); + break; + case "border-bottom-right-radius": + radii = CornerRadii.of( + radii.topLeftX, + radii.topLeftY, + radii.topRightX, + radii.topRightY, + parseLengthDouble(firstToken(value)), + parseLengthDouble(firstToken(value)), + radii.bottomLeftX, + radii.bottomLeftY + ); + break; + case "border-bottom-left-radius": + radii = CornerRadii.of( + radii.topLeftX, + radii.topLeftY, + radii.topRightX, + radii.topRightY, + radii.bottomRightX, + radii.bottomRightY, + parseLengthDouble(firstToken(value)), + parseLengthDouble(firstToken(value)) + ); + break; + case "background": + case "background-color": + Integer bg = tryParseBackgroundColor(value); + if (bg != null) { + backgroundColor = bg.intValue(); + } + break; + case "padding": + applyPadding(padding, normalize1To4Ints(parseLengths(value))); + break; + case "padding-top": + padding.top = parseLength(value); + break; + case "padding-right": + padding.right = parseLength(value); + break; + case "padding-bottom": + padding.bottom = parseLength(value); + break; + case "padding-left": + padding.left = parseLength(value); + break; + case "overflow": + overflow = parseOverflow(value); + break; + case "box-shadow": + elevation = parseBoxShadow(value); + break; + default: + break; + } + } + + BoxShape shape = BoxShape.builder() + .radii(radii) + .build(); + BoxLayout layout = BoxLayout.builder() + .padding(padding) + .build(); + BoxClip clip = BoxClip.builder() + .overflow(overflow) + .build(); + BoxBorder border = BoxBorder.builder() + .top(top.toBorderSide()) + .right(right.toBorderSide()) + .bottom(bottom.toBorderSide()) + .left(left.toBorderSide()) + .build(); + BoxPaint paint = BoxPaint.builder() + .backgroundColor(backgroundColor) + .pressedColor(pressedColor) + .border(border) + .build(); + + return BoxStyle.builder() + .layout(layout) + .shape(shape) + .paint(paint) + .clip(clip) + .elevation(elevation) + .build(); + } + + private static void applyPadding(Insets padding, int[] values) { + padding.top = values[0]; + padding.right = values[1]; + padding.bottom = values[2]; + padding.left = values[3]; + } + + private static String extractRuleBody(String css) { + if (css == null) { + return ""; + } + String s = removeComments(css).trim(); + int open = s.indexOf('{'); + int close = s.lastIndexOf('}'); + if (open >= 0 && close > open) { + return s.substring(open + 1, close).trim(); + } + return s; + } + + private static String removeComments(String s) { + StringBuilder out = new StringBuilder(s.length()); + int i = 0; + while (i < s.length()) { + if (i + 1 < s.length() && s.charAt(i) == '/' && s.charAt(i + 1) == '*') { + i += 2; + while (i + 1 < s.length() && !(s.charAt(i) == '*' && s.charAt(i + 1) == '/')) { + i++; + } + i = Math.min(i + 2, s.length()); + } else { + out.append(s.charAt(i++)); + } + } + return out.toString(); + } + + private static List splitDeclarations(String body) { + List list = new ArrayList(); + StringBuilder cur = new StringBuilder(); + int parenDepth = 0; + + for (int i = 0; i < body.length(); i++) { + char ch = body.charAt(i); + if (ch == '(') { + parenDepth++; + } else if (ch == ')') { + parenDepth = Math.max(0, parenDepth - 1); + } + + if (ch == ';' && parenDepth == 0) { + String item = cur.toString().trim(); + if (!item.isEmpty()) { + list.add(item); + } + cur.setLength(0); + } else { + cur.append(ch); + } + } + + String item = cur.toString().trim(); + if (!item.isEmpty()) { + list.add(item); + } + return list; + } + + private static String normalize(String s) { + return s.trim().toLowerCase(/* Locale.ROOT */); + } + + private static void applyBorderShorthand(String value, BorderDecl top, BorderDecl right, BorderDecl bottom, BorderDecl left) { + BorderDecl d = new BorderDecl(); + applyBorderSideShorthand(value, d); + copyDecl(d, top); + copyDecl(d, right); + copyDecl(d, bottom); + copyDecl(d, left); + } + + private static void applyBorderSideShorthand(String value, BorderDecl decl) { + String[] tokens = tokenize(value); + for (int i = 0; i < tokens.length; i++) { + String t = normalize(tokens[i]); + if (isBorderStyleToken(t)) { + decl.style = parseBorderStyle(t); + } else if (looksLikeLength(t)) { + decl.width = parseLengthDouble(t); + } else if (looksLikeColor(t)) { + decl.color = parseColor(t); + } + } + } + + private static void applyWidths(double[] values, BorderDecl top, BorderDecl right, BorderDecl bottom, BorderDecl left) { + top.width = values[0]; + right.width = values[1]; + bottom.width = values[2]; + left.width = values[3]; + } + + private static void applyStyles(String[] values, BorderDecl top, BorderDecl right, BorderDecl bottom, BorderDecl left) { + top.style = parseBorderStyle(values[0]); + right.style = parseBorderStyle(values[1]); + bottom.style = parseBorderStyle(values[2]); + left.style = parseBorderStyle(values[3]); + } + + private static void applyColors(int[] values, BorderDecl top, BorderDecl right, BorderDecl bottom, BorderDecl left) { + top.color = values[0]; + right.color = values[1]; + bottom.color = values[2]; + left.color = values[3]; + } + + private static CornerRadii applyBorderRadius(String value) { + String[] parts = value.split("/"); + double[] horizontal = normalize1To4Doubles(parseLengthsAsDouble(parts[0])); + if (parts.length == 1) { + return CornerRadii.of(horizontal[0], horizontal[1], horizontal[2], horizontal[3]); + } + double[] vertical = normalize1To4Doubles(parseLengthsAsDouble(parts[1])); + return CornerRadii.of( + horizontal[0], vertical[0], + horizontal[1], vertical[1], + horizontal[2], vertical[2], + horizontal[3], vertical[3] + ); + } + + private static double[] parseLengthsAsDouble(String value) { + String[] tokens = tokenize(value); + double[] values = new double[tokens.length]; + for (int i = 0; i < tokens.length; i++) { + values[i] = parseLengthDouble(tokens[i]); + } + return values; + } + + private static int[] parseLengths(String value) { + String[] tokens = tokenize(value); + int[] values = new int[tokens.length]; + for (int i = 0; i < tokens.length; i++) { + values[i] = parseLength(tokens[i]); + } + return values; + } + + private static int[] parse1To4Lengths(String value) { + return normalize1To4Ints(parseLengths(value)); + } + + private static double[] parse1To4BorderWidths(String value) { + return normalize1To4Doubles(parseLengthsAsDouble(value)); + } + + private static String[] parse1To4Keywords(String value) { + return normalize1To4Strings(tokenize(value)); + } + + private static int[] parse1To4Colors(String value) { + String[] t = tokenize(value); + int[] v = new int[t.length]; + for (int i = 0; i < t.length; i++) { + v[i] = parseColor(t[i]); + } + return normalize1To4Ints(v); + } + + private static int[] normalize1To4Ints(int[] in) { + if (in.length == 0) { + return new int[] {0, 0, 0, 0}; + } + if (in.length == 1) { + return new int[] {in[0], in[0], in[0], in[0]}; + } + if (in.length == 2) { + return new int[] {in[0], in[1], in[0], in[1]}; + } + if (in.length == 3) { + return new int[] {in[0], in[1], in[2], in[1]}; + } + return new int[] {in[0], in[1], in[2], in[3]}; + } + + private static double[] normalize1To4Doubles(double[] in) { + if (in.length == 0) { + return new double[] {0, 0, 0, 0}; + } + if (in.length == 1) { + return new double[] {in[0], in[0], in[0], in[0]}; + } + if (in.length == 2) { + return new double[] {in[0], in[1], in[0], in[1]}; + } + if (in.length == 3) { + return new double[] {in[0], in[1], in[2], in[1]}; + } + return new double[] {in[0], in[1], in[2], in[3]}; + } + + private static String[] normalize1To4Strings(String[] in) { + if (in.length == 0) { + return new String[] {"none", "none", "none", "none"}; + } + if (in.length == 1) { + return new String[] {in[0], in[0], in[0], in[0]}; + } + if (in.length == 2) { + return new String[] {in[0], in[1], in[0], in[1]}; + } + if (in.length == 3) { + return new String[] {in[0], in[1], in[2], in[1]}; + } + return new String[] {in[0], in[1], in[2], in[3]}; + } + + private static String[] tokenize(String value) { + String s = value.trim(); + if (s.isEmpty()) { + return new String[0]; + } + return s.split("\\s+"); + } + + private static String firstToken(String value) { + String[] t = tokenize(value); + return t.length == 0 ? "0" : t[0]; + } + + private static boolean looksLikeLength(String s) { + s = normalize(s); + return s.endsWith("px") || isNumber(s); + } + + private static boolean looksLikeSignedLength(String s) { + s = normalize(s); + if (s.endsWith("px")) { + s = s.substring(0, s.length() - 2).trim(); + } + return isNumber(s); + } + + private static int parseLength(String s) { + s = normalize(s); + if (s.endsWith("px")) { + s = s.substring(0, s.length() - 2).trim(); + } + if (s.isEmpty()) { + return 0; + } + try { + return Math.max(0, (int) Math.round(Double.parseDouble(s))); + } catch (NumberFormatException e) { + return 0; + } + } + + private static double parseLengthDouble(String s) { + s = normalize(s); + if (s.endsWith("px")) { + s = s.substring(0, s.length() - 2).trim(); + } + if (s.isEmpty()) { + return 0; + } + try { + return Math.max(0, Double.parseDouble(s)); + } catch (NumberFormatException e) { + return 0; + } + } + + private static int parseSignedLength(String s) { + s = normalize(s); + if (s.endsWith("px")) { + s = s.substring(0, s.length() - 2).trim(); + } + if (s.isEmpty()) { + return 0; + } + try { + return (int) Math.round(Double.parseDouble(s)); + } catch (NumberFormatException e) { + return 0; + } + } + + private static double parseSignedLengthDouble(String s) { + s = normalize(s); + if (s.endsWith("px")) { + s = s.substring(0, s.length() - 2).trim(); + } + if (s.isEmpty()) { + return 0; + } + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + return 0; + } + } + + private static boolean isNumber(String s) { + try { + Double.parseDouble(s); + return true; + } catch (Exception e) { + return false; + } + } + + private static boolean isBorderStyleToken(String s) { + s = normalize(s); + return "none".equals(s) || "solid".equals(s) || "dashed".equals(s) || "dotted".equals(s); + } + + private static int parseBorderStyle(String s) { + s = normalize(s); + if ("solid".equals(s)) { + return BorderSide.Style.SOLID; + } + if ("dashed".equals(s)) { + return BorderSide.Style.DASHED; + } + if ("dotted".equals(s)) { + return BorderSide.Style.DOTTED; + } + return BorderSide.Style.NONE; + } + + private static int parseOverflow(String value) { + String normalized = normalize(value); + if ("hidden".equals(normalized)) { + return BoxClip.Overflow.HIDDEN; + } + if ("clip".equals(normalized)) { + return BoxClip.Overflow.CLIP; + } + return BoxClip.Overflow.VISIBLE; + } + + private static Elevation parseBoxShadow(String value) { + String normalized = normalize(value); + if (normalized.length() == 0 || "none".equals(normalized)) { + return Elevation.NONE; + } + + List layers = splitTopLevelCommaSeparated(value); + List parsed = new ArrayList(layers.size()); + for (int i = 0; i < layers.size(); i++) { + Shadow shadow = parseSingleBoxShadow(layers.get(i)); + if (shadow != Shadow.NONE) { + parsed.add(shadow); + } + } + + if (parsed.isEmpty()) { + return Elevation.NONE; + } + return Elevation.of(parsed.toArray(new Shadow[parsed.size()])); + } + + private static Shadow parseSingleBoxShadow(String value) { + String[] tokens = tokenize(value); + double[] lengths = new double[4]; + int lengthCount = 0; + int alpha = 76; + int color = Color.BLACK; + + for (int i = 0; i < tokens.length; i++) { + String token = normalize(tokens[i]); + if ("inset".equals(token)) { + continue; + } + if (looksLikeColor(token)) { + color = parseColor(token); + alpha = parseShadowAlpha(token); + continue; + } + if (looksLikeSignedLength(token) && lengthCount < lengths.length) { + lengths[lengthCount++] = parseSignedLengthDouble(token); + } + } + + if (lengthCount < 2) { + return Shadow.NONE; + } + + double dx = lengths[0]; + double dy = lengths[1]; + double blurRadius = lengthCount >= 3 ? Math.max(0, lengths[2]) : 0; + double spread = lengthCount >= 4 ? lengths[3] : 0; + return Shadow.of(dx, dy, blurRadius, alpha, spread, color); + } + + private static List splitTopLevelCommaSeparated(String value) { + List items = new ArrayList(); + StringBuilder current = new StringBuilder(); + int parenDepth = 0; + + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + if (ch == '(') { + parenDepth++; + } else if (ch == ')') { + parenDepth = Math.max(0, parenDepth - 1); + } + + if (ch == ',' && parenDepth == 0) { + String item = current.toString().trim(); + if (!item.isEmpty()) { + items.add(item); + } + current.setLength(0); + } else { + current.append(ch); + } + } + + String item = current.toString().trim(); + if (!item.isEmpty()) { + items.add(item); + } + return items; + } + + private static Integer tryParseBackgroundColor(String value) { + String[] tokens = tokenize(value); + for (int i = 0; i < tokens.length; i++) { + if (looksLikeColor(tokens[i])) { + return Integer.valueOf(parseColor(tokens[i])); + } + } + return null; + } + + private static boolean looksLikeColor(String s) { + s = normalize(s); + return s.startsWith("#") || s.startsWith("rgb(") || s.startsWith("rgba(") || NAMED_COLORS.containsKey(s); + } + + private static int parseColor(String s) { + s = normalize(s); + if (s.startsWith("#")) { + return parseHexColor(s); + } + if (s.startsWith("rgb(") && s.endsWith(")")) { + String inner = s.substring(4, s.length() - 1).trim(); + String[] parts = inner.split(","); + if (parts.length == 3) { + int r = clamp255(parseInt(parts[0].trim(), 0)); + int g = clamp255(parseInt(parts[1].trim(), 0)); + int b = clamp255(parseInt(parts[2].trim(), 0)); + return (r << 16) | (g << 8) | b; + } + } + if (s.startsWith("rgba(") && s.endsWith(")")) { + String inner = s.substring(5, s.length() - 1).trim(); + String[] parts = inner.split(","); + if (parts.length >= 3) { + int r = clamp255(parseInt(parts[0].trim(), 0)); + int g = clamp255(parseInt(parts[1].trim(), 0)); + int b = clamp255(parseInt(parts[2].trim(), 0)); + return (r << 16) | (g << 8) | b; + } + } + Integer named = NAMED_COLORS.get(s); + return named != null ? named.intValue() : 0; + } + + private static int parseShadowAlpha(String s) { + s = normalize(s); + if (s.startsWith("rgba(") && s.endsWith(")")) { + String inner = s.substring(5, s.length() - 1).trim(); + String[] parts = inner.split(","); + if (parts.length == 4) { + try { + double alpha = Double.parseDouble(parts[3].trim()); + return clamp255((int) Math.round(alpha * 255)); + } catch (Exception e) { + return 76; + } + } + } + if (s.startsWith("#")) { + String hex = s.substring(1).trim(); + if (hex.length() == 4) { + return Integer.parseInt("" + hex.charAt(0) + hex.charAt(0), 16); + } + if (hex.length() == 8) { + return Integer.parseInt(hex.substring(0, 2), 16); + } + } + return 76; + } + + private static int parseHexColor(String s) { + String hex = s.substring(1).trim(); + if (hex.length() == 3) { + int r = Integer.parseInt("" + hex.charAt(0) + hex.charAt(0), 16); + int g = Integer.parseInt("" + hex.charAt(1) + hex.charAt(1), 16); + int b = Integer.parseInt("" + hex.charAt(2) + hex.charAt(2), 16); + return (r << 16) | (g << 8) | b; + } + if (hex.length() == 6) { + return Integer.parseInt(hex, 16); + } + if (hex.length() == 8) { + return Integer.parseInt(hex.substring(2), 16); + } + return 0; + } + + private static int parseInt(String s, int def) { + try { + return Integer.parseInt(s); + } catch (Exception e) { + return def; + } + } + + private static int clamp255(int v) { + return v < 0 ? 0 : (v > 255 ? 255 : v); + } + + private static void copyDecl(BorderDecl src, BorderDecl dst) { + dst.width = src.width; + dst.style = src.style; + dst.color = src.color; + dst.align = src.align; + } + + private static final class BorderDecl { + double width = 0; + int style = BorderSide.Style.NONE; + int color = 0; + int align = BorderSide.Align.INSIDE; + + BorderSide toBorderSide() { + return BorderSide.with(width, style, color, align); + } + } + + private static final Map NAMED_COLORS = createNamedColors(); + + private static Map createNamedColors() { + Map map = new HashMap(); + map.put("black", Integer.valueOf(Color.BLACK)); + map.put("white", Integer.valueOf(Color.WHITE)); + map.put("red", Integer.valueOf(Color.RED)); + map.put("green", Integer.valueOf(0x008000)); + map.put("blue", Integer.valueOf(Color.BLUE)); + map.put("yellow", Integer.valueOf(Color.YELLOW)); + map.put("orange", Integer.valueOf(0xFFA500)); + map.put("gray", Integer.valueOf(0x808080)); + map.put("grey", Integer.valueOf(0x808080)); + map.put("silver", Integer.valueOf(0xC0C0C0)); + map.put("maroon", Integer.valueOf(0x800000)); + map.put("purple", Integer.valueOf(0x800080)); + map.put("fuchsia", Integer.valueOf(0xFF00FF)); + map.put("lime", Integer.valueOf(0x00FF00)); + map.put("olive", Integer.valueOf(0x808000)); + map.put("navy", Integer.valueOf(0x000080)); + map.put("teal", Integer.valueOf(0x008080)); + map.put("aqua", Integer.valueOf(0x00FFFF)); + map.put("transparent", Integer.valueOf(0x000000)); + return map; + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java new file mode 100644 index 000000000..c9f758bfe --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java @@ -0,0 +1,224 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.geom; + +import totalcross.ui.Control; +import totalcross.ui.Insets; +import totalcross.ui.gfx.RRect; +import totalcross.ui.gfx.Rect; +import totalcross.ui.style.model.BorderSide; +import totalcross.ui.style.model.BoxBorder; +import totalcross.ui.style.model.BoxLayout; +import totalcross.ui.style.model.BoxPaint; +import totalcross.ui.style.model.BoxShape; +import totalcross.ui.style.model.BoxStyle; +import totalcross.ui.style.model.CornerRadii; +import totalcross.ui.style.model.Elevation; +import totalcross.ui.style.model.Shadow; +import totalcross.util.UnitsConverter; + +/** + * Resolves the concrete rectangles and corner radii used to paint a box. + */ +public final class BoxGeometry { + public final Rect borderBox = new Rect(); + public final Rect paddingBox = new Rect(); + public final Rect contentBox = new Rect(); + + public CornerRadii borderRadii = CornerRadii.ZERO; + public CornerRadii paddingRadii = CornerRadii.ZERO; + public CornerRadii contentRadii = CornerRadii.ZERO; + + /** + * Returns the border box as a rounded rectangle. + */ + public RRect borderRRect() { + return new RRect( + borderBox.x, + borderBox.y, + borderBox.width, + borderBox.height, + new double[] { + borderRadii.topLeftX, borderRadii.topLeftY, + borderRadii.topRightX, borderRadii.topRightY, + borderRadii.bottomRightX, borderRadii.bottomRightY, + borderRadii.bottomLeftX, borderRadii.bottomLeftY + }); + } + + /** + * Returns the padding box as a rounded rectangle. + */ + public RRect paddingRRect() { + return new RRect( + paddingBox.x, + paddingBox.y, + paddingBox.width, + paddingBox.height, + new double[] { + paddingRadii.topLeftX, paddingRadii.topLeftY, + paddingRadii.topRightX, paddingRadii.topRightY, + paddingRadii.bottomRightX, paddingRadii.bottomRightY, + paddingRadii.bottomLeftX, paddingRadii.bottomLeftY + }); + } + + /** + * Returns the content box as a rounded rectangle. + */ + public RRect contentRRect() { + return new RRect( + contentBox.x, + contentBox.y, + contentBox.width, + contentBox.height, + new double[] { + contentRadii.topLeftX, contentRadii.topLeftY, + contentRadii.topRightX, contentRadii.topRightY, + contentRadii.bottomRightX, contentRadii.bottomRightY, + contentRadii.bottomLeftX, contentRadii.bottomLeftY + }); + } + + /** + * Computes the extra paint area required by the style, such as shadows. + */ + public static int paintExpand(BoxStyle style) { + Elevation elevation = style == null ? null : style.elevation; + Shadow[] shadows = elevation == null ? null : elevation.shadows; + if (shadows == null) { + return 0; + } + + int expand = 0; + for (int i = 0; i < shadows.length; i++) { + Shadow shadow = shadows[i]; + if (shadow == null || shadow == Shadow.NONE || shadow.alpha <= 0) { + continue; + } + expand = Math.max( + expand, + Math.max(Math.abs(toPixels(shadow.dx)), Math.abs(toPixels(shadow.dy))) + + Math.max(0, toPixels(shadow.spread)) + + Math.max(0, toPixels(shadow.blurRadius))); + } + return expand; + } + + /** + * Computes box geometry for the given bounds and style. + */ + public static BoxGeometry compute(int x, int y, int w, int h, BoxStyle style) { + BoxLayout layout = style == null ? null : style.layout; + BoxPaint paint = style == null ? null : style.paint; + BoxShape shape = style == null ? null : style.shape; + BoxGeometry g = new BoxGeometry(); + int safeW = Math.max(0, w); + int safeH = Math.max(0, h); + Insets padding = layout == null ? null : layout.padding; + BoxBorder border = paint == null ? null : paint.border; + Insets borderInsets = border == null ? new Insets() : border.getInsets(); + int align = resolveStrokeAlign(border); + + int borderBoxX = x; + int borderBoxY = y; + int borderBoxW = safeW; + int borderBoxH = safeH; + + if (align == BorderSide.Align.CENTER) { + borderBoxX -= borderInsets.left / 2; + borderBoxY -= borderInsets.top / 2; + borderBoxW += (borderInsets.left + borderInsets.right) / 2; + borderBoxH += (borderInsets.top + borderInsets.bottom) / 2; + } else if (align == BorderSide.Align.OUTSIDE) { + borderBoxX -= borderInsets.left; + borderBoxY -= borderInsets.top; + borderBoxW += borderInsets.left + borderInsets.right; + borderBoxH += borderInsets.top + borderInsets.bottom; + } + + g.borderBox.set(borderBoxX, borderBoxY, Math.max(0, borderBoxW), Math.max(0, borderBoxH)); + + g.borderRadii = clampRadii( + shape == null ? CornerRadii.ZERO : shape.radii, + g.borderBox.width, + g.borderBox.height); + + insetRect(g.borderBox, borderInsets, g.paddingBox); + g.paddingRadii = clampRadii( + insetRadii(g.borderRadii, borderInsets), + g.paddingBox.width, + g.paddingBox.height); + + Insets safePadding = padding == null ? new Insets() : padding; + insetRect(g.paddingBox, safePadding, g.contentBox); + g.contentRadii = clampRadii( + insetRadii(g.paddingRadii, safePadding), + g.contentBox.width, + g.contentBox.height); + + return g; + } + + /** + * Insets the source rectangle into the destination rectangle. + */ + public static void insetRect(Rect src, Insets insets, Rect dst) { + int x = src.x + insets.left; + int y = src.y + insets.top; + int w = src.width - insets.left - insets.right; + int h = src.height - insets.top - insets.bottom; + dst.set(x, y, Math.max(0, w), Math.max(0, h)); + } + + /** + * Insets corner radii according to the provided insets. + */ + public static CornerRadii insetRadii(CornerRadii src, Insets insets) { + if (src == null) { + return CornerRadii.ZERO; + } + return CornerRadii.of( + Math.max(0, src.topLeftX - insets.left), + Math.max(0, src.topLeftY - insets.top), + Math.max(0, src.topRightX - insets.right), + Math.max(0, src.topRightY - insets.top), + Math.max(0, src.bottomRightX - insets.right), + Math.max(0, src.bottomRightY - insets.bottom), + Math.max(0, src.bottomLeftX - insets.left), + Math.max(0, src.bottomLeftY - insets.bottom)); + } + + /** + * Clamps corner radii so they fit inside the given dimensions. + */ + public static CornerRadii clampRadii(CornerRadii radii, int w, int h) { + if (radii == null) { + return CornerRadii.ZERO; + } + double maxX = Math.max(0, w / 2.0); + double maxY = Math.max(0, h / 2.0); + return CornerRadii.of( + Math.min(radii.topLeftX, maxX), + Math.min(radii.topLeftY, maxY), + Math.min(radii.topRightX, maxX), + Math.min(radii.topRightY, maxY), + Math.min(radii.bottomRightX, maxX), + Math.min(radii.bottomRightY, maxY), + Math.min(radii.bottomLeftX, maxX), + Math.min(radii.bottomLeftY, maxY)); + } + + private static int resolveStrokeAlign(BoxBorder border) { + if (border == null || border.top == null) { + return BorderSide.Align.INSIDE; + } + return border.top.align; + } + + private static int toPixels(double value) { + return (int) Math.round(UnitsConverter.toPixels(Control.DP + value)); + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BorderSide.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BorderSide.java new file mode 100644 index 000000000..a55d68641 --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BorderSide.java @@ -0,0 +1,109 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +import totalcross.ui.Control; +import totalcross.util.UnitsConverter; + +/** + * Describes a single side of a box border. + */ +public final class BorderSide { + /** + * Supported border styles. + */ + public static final class Style { + public static final int NONE = 0; + public static final int SOLID = 1; + public static final int DASHED = 2; + public static final int DOTTED = 3; + + private Style() { + } + } + + /** + * Supported border alignment modes. + */ + public static final class Align { + public static final int INSIDE = 0; + public static final int CENTER = 1; + public static final int OUTSIDE = 2; + + private Align() { + } + } + + public final double width; + public final int style; + public final int color; + + public final int align; + + /** Constant for an invisible border side. */ + public static final BorderSide NONE = with(0, Style.NONE, 0); + + private BorderSide(double width, int style, int color, int align) { + validateStyle(style); + validateAlign(align); + this.width = UnitsConverter.toPixels(Control.DP + width); + this.style = style; + this.color = color; + this.align = align; + } + + /** + * Creates a border side using inside alignment. + */ + public static BorderSide with(double width, int style, int color) { + return with(width, style, color, Align.INSIDE); + } + + /** + * Creates a border side. + */ + public static BorderSide with(double width, int style, int color, int align) { + return new BorderSide(width, style, color, align); + } + + /** + * Returns whether this side should be painted. + */ + public boolean isVisible() { + return style != Style.NONE && width > 0; + } + + private static void validateStyle(int style) { + if (!isValidStyle(style)) { + throw new IllegalArgumentException("Invalid BorderSide.Style: " + style); + } + } + + private static void validateAlign(int align) { + if (!isValidAlign(align)) { + throw new IllegalArgumentException("Invalid BorderSide.Align: " + align); + } + } + + /** + * Returns whether the given value is a valid border style. + */ + public static boolean isValidStyle(int style) { + return style == Style.NONE + || style == Style.SOLID + || style == Style.DASHED + || style == Style.DOTTED; + } + + /** + * Returns whether the given value is a valid border alignment. + */ + public static boolean isValidAlign(int align) { + return align == Align.INSIDE + || align == Align.CENTER + || align == Align.OUTSIDE; + } + +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxBorder.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxBorder.java new file mode 100644 index 000000000..f0b4dda6c --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxBorder.java @@ -0,0 +1,118 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +import java.util.Objects; + +import totalcross.ui.Insets; + +/** + * Describes the border of a box, including all four sides. + */ +public final class BoxBorder { + public final BorderSide top; + public final BorderSide right; + public final BorderSide bottom; + public final BorderSide left; + + private BoxBorder( + BorderSide top, + BorderSide right, + BorderSide bottom, + BorderSide left) { + this.top = Objects.requireNonNull(top, "BoxBorder.top cannot be null"); + this.right = Objects.requireNonNull(right, "BoxBorder.right cannot be null"); + this.bottom = Objects.requireNonNull(bottom, "BoxBorder.bottom cannot be null"); + this.left = Objects.requireNonNull(left, "BoxBorder.left cannot be null"); + } + + /** + * Creates a builder for a box border. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the border widths as insets. + */ + public Insets getInsets() { + return new Insets( + (int) Math.round(top.width), + (int) Math.round(left.width), + (int) Math.round(bottom.width), + (int) Math.round(right.width)); + } + + /** + * Returns whether at least one side should be painted. + */ + public boolean isVisible() { + return top.isVisible() || right.isVisible() + || bottom.isVisible() || left.isVisible(); + } + + /** + * Builder for {@link BoxBorder}. + */ + public static final class Builder { + private BorderSide top = BorderSide.NONE; + private BorderSide right = BorderSide.NONE; + private BorderSide bottom = BorderSide.NONE; + private BorderSide left = BorderSide.NONE; + + private Builder() { + } + + /** + * Sets all sides to the same border side. + */ + public Builder all(BorderSide side) { + return top(side) + .right(side) + .bottom(side) + .left(side); + } + + /** + * Sets the top border side. + */ + public Builder top(BorderSide top) { + this.top = Objects.requireNonNull(top, "BoxBorder.Builder.top cannot be null"); + return this; + } + + /** + * Sets the right border side. + */ + public Builder right(BorderSide right) { + this.right = Objects.requireNonNull(right, "BoxBorder.Builder.right cannot be null"); + return this; + } + + /** + * Sets the bottom border side. + */ + public Builder bottom(BorderSide bottom) { + this.bottom = Objects.requireNonNull(bottom, "BoxBorder.Builder.bottom cannot be null"); + return this; + } + + /** + * Sets the left border side. + */ + public Builder left(BorderSide left) { + this.left = Objects.requireNonNull(left, "BoxBorder.Builder.left cannot be null"); + return this; + } + + /** + * Builds the immutable border. + */ + public BoxBorder build() { + return new BoxBorder(top, right, bottom, left); + } + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxClip.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxClip.java new file mode 100644 index 000000000..f64700579 --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxClip.java @@ -0,0 +1,91 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +/** + * Describes overflow clipping behavior for a box. + */ +public final class BoxClip { + /** + * Supported overflow modes. + */ + public static final class Overflow { + public static final int VISIBLE = 0; + public static final int HIDDEN = 1; + public static final int CLIP = 2; + + private Overflow() { + } + } + + public final int overflow; + + /** + * Creates a clip configuration with visible overflow. + */ + public BoxClip() { + this(Overflow.VISIBLE); + } + + private BoxClip(int overflow) { + validateOverflow(overflow); + this.overflow = overflow; + } + + /** + * Creates a builder for a clip configuration. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns whether overflow should be clipped. + */ + public boolean shouldClip() { + return overflow == Overflow.HIDDEN || overflow == Overflow.CLIP; + } + + /** + * Returns whether the given value is a valid overflow mode. + */ + public static boolean isValidOverflow(int overflow) { + return overflow == Overflow.VISIBLE + || overflow == Overflow.HIDDEN + || overflow == Overflow.CLIP; + } + + private static void validateOverflow(int overflow) { + if (!isValidOverflow(overflow)) { + throw new IllegalArgumentException("Invalid BoxClip overflow: " + overflow); + } + } + + /** + * Builder for {@link BoxClip}. + */ + public static final class Builder { + private int overflow = Overflow.VISIBLE; + + private Builder() { + } + + /** + * Sets the overflow mode. + */ + public Builder overflow(int overflow) { + validateOverflow(overflow); + this.overflow = overflow; + return this; + } + + /** + * Builds the immutable clip configuration. + */ + public BoxClip build() { + return new BoxClip(overflow); + } + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxLayout.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxLayout.java new file mode 100644 index 000000000..877cbb37d --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxLayout.java @@ -0,0 +1,100 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +import java.util.Objects; + +import totalcross.ui.Insets; + +/** + * Describes box layout properties such as padding. + */ +public final class BoxLayout { + public final Insets padding; + + /** + * Creates a layout with zero padding. + */ + public BoxLayout() { + this(new Insets()); + } + + private BoxLayout(Insets padding) { + Objects.requireNonNull(padding, "BoxLayout.padding cannot be null"); + validateNonNegative("top", padding.top); + validateNonNegative("right", padding.right); + validateNonNegative("bottom", padding.bottom); + validateNonNegative("left", padding.left); + this.padding = new Insets(padding.top, padding.left, padding.bottom, padding.right); + } + + /** + * Creates a builder for {@link BoxLayout}. + */ + public static Builder builder() { + return new Builder(); + } + + private static void validateNonNegative(String edge, int value) { + if (value < 0) { + throw new IllegalArgumentException("BoxLayout padding " + edge + " must be >= 0: " + value); + } + } + + /** + * Builder for {@link BoxLayout}. + */ + public static final class Builder { + private Insets padding = new Insets(); + + private Builder() { + } + + /** + * Sets the same padding on all sides. + */ + public Builder padding(int all) { + return padding(all, all, all, all); + } + + /** + * Sets vertical and horizontal padding. + */ + public Builder padding(int vertical, int horizontal) { + return padding(vertical, horizontal, vertical, horizontal); + } + + /** + * Sets padding for each side. + */ + public Builder padding(int top, int right, int bottom, int left) { + validateNonNegative("top", top); + validateNonNegative("right", right); + validateNonNegative("bottom", bottom); + validateNonNegative("left", left); + padding = new Insets(top, left, bottom, right); + return this; + } + + /** + * Copies padding from an existing {@link Insets} instance. + */ + public Builder padding(Insets padding) { + this.padding = Objects.requireNonNull(padding, "BoxLayout.Builder.padding cannot be null"); + validateNonNegative("top", padding.top); + validateNonNegative("right", padding.right); + validateNonNegative("bottom", padding.bottom); + validateNonNegative("left", padding.left); + return this; + } + + /** + * Builds the immutable layout object. + */ + public BoxLayout build() { + return new BoxLayout(padding); + } + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxPaint.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxPaint.java new file mode 100644 index 000000000..4d4ee6016 --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxPaint.java @@ -0,0 +1,79 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +import java.util.Objects; + +/** + * Describes paint properties for a box, including background and border. + */ +public final class BoxPaint { + public final int backgroundColor; + public final int pressedColor; + public final BoxBorder border; + + /** + * Creates a paint configuration with no background and no visible border. + */ + public BoxPaint() { + this(0, 0, BoxBorder.builder().all(BorderSide.NONE).build()); + } + + private BoxPaint(int backgroundColor, int pressedColor, BoxBorder border) { + this.backgroundColor = backgroundColor; + this.pressedColor = pressedColor; + this.border = Objects.requireNonNull(border, "BoxPaint.border cannot be null"); + } + + /** + * Creates a builder for {@link BoxPaint}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link BoxPaint}. + */ + public static final class Builder { + private int backgroundColor; + private int pressedColor; + private BoxBorder border = BoxBorder.builder().all(BorderSide.NONE).build(); + + private Builder() { + } + + /** + * Sets the normal background color. + */ + public Builder backgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + return this; + } + + /** + * Sets the pressed-state background color. + */ + public Builder pressedColor(int pressedColor) { + this.pressedColor = pressedColor; + return this; + } + + /** + * Sets the border configuration. + */ + public Builder border(BoxBorder border) { + this.border = Objects.requireNonNull(border, "BoxPaint.Builder.border cannot be null"); + return this; + } + + /** + * Builds the immutable paint configuration. + */ + public BoxPaint build() { + return new BoxPaint(backgroundColor, pressedColor, border); + } + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxShape.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxShape.java new file mode 100644 index 000000000..d50addf3b --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxShape.java @@ -0,0 +1,73 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +import java.util.Objects; + +/** + * Describes the geometric shape of a box. + */ +public final class BoxShape { + public final CornerRadii radii; + + /** + * Creates a shape with zero radii. + */ + public BoxShape() { + this(CornerRadii.ZERO); + } + + private BoxShape(CornerRadii radii) { + this.radii = Objects.requireNonNull(radii, "BoxShape.radii cannot be null"); + } + + /** + * Creates a builder for {@link BoxShape}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link BoxShape}. + */ + public static final class Builder { + private CornerRadii radii = CornerRadii.ZERO; + + private Builder() { + } + + /** + * Sets the same radius for all corners. + */ + public Builder radius(double all) { + radii = CornerRadii.all(all); + return this; + } + + /** + * Sets one radius per corner, using the same value for x and y. + */ + public Builder radius(double topLeft, double topRight, double bottomRight, double bottomLeft) { + radii = CornerRadii.of(topLeft, topRight, bottomRight, bottomLeft); + return this; + } + + /** + * Sets the full corner radii object. + */ + public Builder radii(CornerRadii radii) { + this.radii = Objects.requireNonNull(radii, "BoxShape.Builder.radii cannot be null"); + return this; + } + + /** + * Builds the immutable shape. + */ + public BoxShape build() { + return new BoxShape(radii); + } + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxStyle.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxStyle.java new file mode 100644 index 000000000..3f184d8db --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/BoxStyle.java @@ -0,0 +1,112 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +import java.util.Objects; + +/** + * Aggregates all style properties used to paint and clip a box. + */ +public final class BoxStyle { + public final BoxLayout layout; + public final BoxShape shape; + public final BoxPaint paint; + public final BoxClip clip; + public final Elevation elevation; + + /** + * Creates a default box style. + */ + public BoxStyle() { + this(new BoxLayout(), new BoxShape(), new BoxPaint(), new BoxClip(), Elevation.NONE); + } + + /** + * Creates a box style with explicit values for all parts. + */ + public BoxStyle(BoxLayout layout, BoxShape shape, BoxPaint paint, BoxClip clip, Elevation elevation) { + this.layout = Objects.requireNonNull(layout, "BoxStyle.layout cannot be null"); + this.shape = Objects.requireNonNull(shape, "BoxStyle.shape cannot be null"); + this.paint = Objects.requireNonNull(paint, "BoxStyle.paint cannot be null"); + this.clip = Objects.requireNonNull(clip, "BoxStyle.clip cannot be null"); + this.elevation = Objects.requireNonNull(elevation, "BoxStyle.elevation cannot be null"); + } + + /** + * Creates a builder for {@link BoxStyle}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for {@link BoxStyle}. + */ + public static final class Builder { + private BoxLayout layout = new BoxLayout(); + private BoxShape shape = new BoxShape(); + private BoxPaint paint = new BoxPaint(); + private BoxClip clip = new BoxClip(); + private Elevation elevation = Elevation.NONE; + + private Builder() { + } + + /** + * Sets layout properties. + */ + public Builder layout(BoxLayout layout) { + this.layout = Objects.requireNonNull(layout, "BoxStyle.Builder.layout cannot be null"); + return this; + } + + /** + * Sets shape properties. + */ + public Builder shape(BoxShape shape) { + this.shape = Objects.requireNonNull(shape, "BoxStyle.Builder.shape cannot be null"); + return this; + } + + /** + * Sets paint properties. + */ + public Builder paint(BoxPaint paint) { + this.paint = Objects.requireNonNull(paint, "BoxStyle.Builder.paint cannot be null"); + return this; + } + + /** + * Sets clip properties. + */ + public Builder clip(BoxClip clip) { + this.clip = Objects.requireNonNull(clip, "BoxStyle.Builder.clip cannot be null"); + return this; + } + + /** + * Sets elevation properties. + */ + public Builder elevation(Elevation elevation) { + this.elevation = Objects.requireNonNull(elevation, "BoxStyle.Builder.elevation cannot be null"); + return this; + } + + /** + * Sets elevation from a list of shadows. + */ + public Builder shadows(Shadow... shadows) { + this.elevation = Elevation.of(shadows); + return this; + } + + /** + * Builds the immutable box style. + */ + public BoxStyle build() { + return new BoxStyle(layout, shape, paint, clip, elevation); + } + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/CornerRadii.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/CornerRadii.java new file mode 100644 index 000000000..54d27f981 --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/CornerRadii.java @@ -0,0 +1,129 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +/** + * Represents the x and y radii for each corner of a box. + */ +public final class CornerRadii { + /** Constant for zero radii on all corners. */ + public static final CornerRadii ZERO = new CornerRadii(0, 0, 0, 0, 0, 0, 0, 0); + + public final double topLeftX; + public final double topLeftY; + public final double topRightX; + public final double topRightY; + public final double bottomRightX; + public final double bottomRightY; + public final double bottomLeftX; + public final double bottomLeftY; + + private CornerRadii( + double topLeftX, + double topLeftY, + double topRightX, + double topRightY, + double bottomRightX, + double bottomRightY, + double bottomLeftX, + double bottomLeftY + ) { + validateNonNegative("topLeftX", topLeftX); + validateNonNegative("topLeftY", topLeftY); + validateNonNegative("topRightX", topRightX); + validateNonNegative("topRightY", topRightY); + validateNonNegative("bottomRightX", bottomRightX); + validateNonNegative("bottomRightY", bottomRightY); + validateNonNegative("bottomLeftX", bottomLeftX); + validateNonNegative("bottomLeftY", bottomLeftY); + this.topLeftX = topLeftX; + this.topLeftY = topLeftY; + this.topRightX = topRightX; + this.topRightY = topRightY; + this.bottomRightX = bottomRightX; + this.bottomRightY = bottomRightY; + this.bottomLeftX = bottomLeftX; + this.bottomLeftY = bottomLeftY; + } + + /** + * Creates radii with the same value on all corners. + */ + public static CornerRadii all(double value) { + return of(value, value, value, value); + } + + /** + * Creates radii with one value per corner, using the same value for x and y. + */ + public static CornerRadii of(double topLeft, double topRight, double bottomRight, double bottomLeft) { + return of( + topLeft, topLeft, + topRight, topRight, + bottomRight, bottomRight, + bottomLeft, bottomLeft + ); + } + + /** + * Creates radii with explicit x and y values for each corner. + */ + public static CornerRadii of( + double topLeftX, + double topLeftY, + double topRightX, + double topRightY, + double bottomRightX, + double bottomRightY, + double bottomLeftX, + double bottomLeftY + ) { + if (topLeftX == 0 && topLeftY == 0 + && topRightX == 0 && topRightY == 0 + && bottomRightX == 0 && bottomRightY == 0 + && bottomLeftX == 0 && bottomLeftY == 0) { + return ZERO; + } + return new CornerRadii( + topLeftX, topLeftY, + topRightX, topRightY, + bottomRightX, bottomRightY, + bottomLeftX, bottomLeftY + ); + } + + /** + * Returns whether all radii are zero. + */ + public boolean isZero() { + return this == ZERO + || (topLeftX == 0 && topLeftY == 0 + && topRightX == 0 && topRightY == 0 + && bottomRightX == 0 && bottomRightY == 0 + && bottomLeftX == 0 && bottomLeftY == 0); + } + + /** + * Returns a copy with the given delta applied to all radii, clamped at zero. + */ + public CornerRadii offset(double delta) { + return of( + Math.max(0, topLeftX + delta), + Math.max(0, topLeftY + delta), + Math.max(0, topRightX + delta), + Math.max(0, topRightY + delta), + Math.max(0, bottomRightX + delta), + Math.max(0, bottomRightY + delta), + Math.max(0, bottomLeftX + delta), + Math.max(0, bottomLeftY + delta) + ); + } + + private static void validateNonNegative(String name, double value) { + if (value < 0) { + throw new IllegalArgumentException("CornerRadii " + name + " must be >= 0: " + value); + } + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Elevation.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Elevation.java new file mode 100644 index 000000000..2413c655e --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Elevation.java @@ -0,0 +1,87 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Represents the elevation of a box as one or more shadow layers. + */ +public final class Elevation { + /** Constant for no elevation. */ + public static final Elevation NONE = new Elevation(new Shadow[] {Shadow.NONE}); + + public final Shadow[] shadows; + + private Elevation(Shadow[] shadows) { + this.shadows = copyShadows(shadows); + } + + /** + * Creates an elevation from explicit shadow layers. + */ + public static Elevation of(Shadow... shadows) { + return new Elevation(shadows); + } + + /** + * Returns a preset elevation for the given elevation level. + */ + public static Elevation of(int elevation) { + switch (elevation) { + case 0: + return NONE; + case 1: + return of( + Shadow.of(0, 1, 2f, 18, 0), + Shadow.of(0, 1, 3f, 10, 0) + ); + case 2: + return of( + Shadow.of(0, 2, 4f, 18, 0), + Shadow.of(0, 1, 2f, 12, 0) + ); + case 3: + return of( + Shadow.of(0, 3, 6f, 18, 0), + Shadow.of(0, 1, 3f, 12, 0) + ); + case 4: + return of( + Shadow.of(0, 2, 4f, 46, 0), + Shadow.of(0, 3, 8f, 31, 0) + ); + case 6: + return of( + Shadow.of(0, 4, 8f, 42, 0), + Shadow.of(0, 6, 16f, 24, 0) + ); + case 8: + return of( + Shadow.of(0, 6, 12f, 40, 0), + Shadow.of(0, 8, 20f, 22, 0) + ); + default: + return of( + Shadow.of(0, elevation, elevation * 2f, 48, 0), + Shadow.of(0, Math.max(1, elevation / 2), Math.max(1f, elevation * 1.25f), 24, 0) + ); + } + } + + private static Shadow[] copyShadows(Shadow[] shadows) { + Objects.requireNonNull(shadows, "Elevation.shadows cannot be null"); + if (shadows.length == 0) { + return new Shadow[] {Shadow.NONE}; + } + + Shadow[] copy = Arrays.copyOf(shadows, shadows.length); + for (int i = 0; i < copy.length; i++) { + copy[i] = Objects.requireNonNull(copy[i], "Elevation.shadows[" + i + "] cannot be null"); + } + return copy; + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Shadow.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Shadow.java new file mode 100644 index 000000000..94d66e66d --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Shadow.java @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.model; + +import totalcross.ui.gfx.Color; + +/** + * Describes a single box shadow layer. + */ +public final class Shadow { + /** Constant for an empty shadow. */ + public static final Shadow NONE = new Shadow(0, 0, 0, 0, 0, Color.BLACK); + + public final double dx; + public final double dy; + public final double blurRadius; + public final int alpha; + public final double spread; + public final int color; + + private Shadow(double dx, double dy, double blurRadius, int alpha, double spread, int color) { + this.dx = dx; + this.dy = dy; + this.blurRadius = blurRadius; + this.alpha = alpha; + this.spread = spread; + this.color = color; + } + + /** + * Creates a shadow using black as the color. + */ + public static Shadow of(double dx, double dy, double blurRadius, int alpha, double spread) { + return of(dx, dy, blurRadius, alpha, spread, Color.BLACK); + } + + /** + * Creates a shadow. + */ + public static Shadow of(double dx, double dy, double blurRadius, int alpha, double spread, int color) { + if (dx == 0 && dy == 0 && blurRadius == 0 && alpha == 0 && spread == 0 && color == Color.BLACK) { + return NONE; + } + return new Shadow(dx, dy, blurRadius, alpha, spread, color); + } +} diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java new file mode 100644 index 000000000..5a7ba3cc3 --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java @@ -0,0 +1,227 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.paint; + +import totalcross.ui.Control; +import totalcross.ui.gfx.Color; +import totalcross.ui.gfx.Graphics; +import totalcross.ui.gfx.RRect; +import totalcross.ui.gfx.Span; +import totalcross.ui.style.geom.BoxGeometry; +import totalcross.ui.style.model.BorderSide; +import totalcross.ui.style.model.BoxBorder; +import totalcross.ui.style.model.BoxPaint; +import totalcross.ui.style.model.BoxShape; +import totalcross.ui.style.model.BoxStyle; +import totalcross.ui.style.model.CornerRadii; +import totalcross.ui.style.model.Elevation; +import totalcross.ui.style.model.Shadow; +import totalcross.util.UnitsConverter; + +/** + * Paints the visual representation of a {@link BoxStyle}. + */ +public final class BoxPainter { + private static final double SHADOW_ALPHA_GAIN = 1.8d; + + public final BoxStyle style; + public final BoxBorder spec; + public final CornerRadii radii; + public final int backgroundColor; + public final int pressedColor; + public final Elevation elevation; + + /** + * Creates a painter for the given style. + */ + public BoxPainter(BoxStyle style) { + this.style = style; + BoxPaint paint = style == null ? null : style.paint; + BoxShape shape = style == null ? null : style.shape; + this.spec = paint == null ? null : paint.border; + this.radii = shape == null || shape.radii == null ? CornerRadii.ZERO : shape.radii; + this.backgroundColor = paint == null ? 0 : paint.backgroundColor; + this.pressedColor = paint == null ? Color.WHITE : paint.pressedColor; + this.elevation = style == null ? Elevation.NONE : style.elevation; + } + + /** + * Paints the box for the given bounds. + */ + public void draw(Graphics g, int x, int y, int w, int h, boolean pressed) { + BoxGeometry geom = BoxGeometry.compute(x, y, w, h, style); + drawElevation(g, geom, elevation); + int bg = pressed ? pressedColor : backgroundColor; + g.drawRRect(geom.borderRRect(), bg, true); + drawBorderStrip(g, geom, spec); + } + + /** + * Paints only the border strip for an already computed geometry. + */ + public static void drawBorderStrip(Graphics g, BoxGeometry geom, BoxBorder spec) { + if (!spec.isVisible()) { + return; + } + + int outerTop = geom.borderBox.y; + int outerBottom = geom.borderBox.y + geom.borderBox.height - 1; + int innerTop = geom.paddingBox.y; + int innerBottom = geom.paddingBox.y + geom.paddingBox.height - 1; + + for (int yy = outerTop; yy <= outerBottom; yy++) { + Span outer = geom.borderRRect().horizontalSpan(yy); + if (!outer.isValid()) { + continue; + } + + if (geom.paddingBox.width <= 0 || geom.paddingBox.height <= 0 || yy < innerTop || yy > innerBottom) { + BorderSide side = yy < innerTop ? spec.top : spec.bottom; + drawStyledHorizontal(g, side, outer.start, yy, outer.end); + continue; + } + + Span inner = geom.paddingRRect().horizontalSpan(yy); + if (!inner.isValid()) { + drawStyledHorizontal(g, spec.top, outer.start, yy, outer.end); + continue; + } + + if (outer.start < inner.start) { + drawStyledHorizontal(g, spec.left, outer.start, yy, inner.start - 1); + } + if (inner.end < outer.end) { + drawStyledHorizontal(g, spec.right, inner.end + 1, yy, outer.end); + } + } + } + + private static void drawElevation(Graphics g, BoxGeometry geom, Elevation elevation) { + if (elevation == null || elevation.shadows == null || elevation.shadows.length == 0) { + return; + } + for (int i = 0; i < elevation.shadows.length; i++) { + drawShadow(g, geom, elevation.shadows[i]); + } + } + + private static void drawShadow(Graphics g, BoxGeometry geom, Shadow shadow) { + if (shadow == null || shadow.alpha <= 0) { + return; + } + + int dx = toPixels(shadow.dx); + int dy = toPixels(shadow.dy); + int spread = Math.max(0, toPixels(shadow.spread)); + int blurPixels = Math.max(0, toPixels(shadow.blurRadius)); + int layers = blurPixels <= 0 ? 0 : Math.max(1, Math.min(4, (blurPixels + 2) / 3)); + int layerStep = layers <= 0 ? 0 : Math.max(1, (int) Math.ceil((double) blurPixels / layers)); + int totalWeight = layers <= 0 ? 1 : (layers + 1) * (layers + 2) / 2; + int savedAlpha = g.alpha; + int savedForeColor = g.foreColor; + + try { + for (int layer = layers; layer >= 0; layer--) { + int blurOffset = layer * layerStep; + int inset = -(spread + blurOffset); + int shadowX = geom.borderBox.x + dx + inset; + int shadowY = geom.borderBox.y + dy + inset; + int shadowW = geom.borderBox.width - inset * 2; + int shadowH = geom.borderBox.height - inset * 2; + + if (shadowW <= 0 || shadowH <= 0) { + continue; + } + + int weight = layers - layer + 1; + int layerAlpha = Math.max(1, Math.min(255, + (int) Math.round(((shadow.alpha * weight) / (double) totalWeight) * SHADOW_ALPHA_GAIN))); + CornerRadii shadowRadii = BoxGeometry.clampRadii( + geom.borderRadii.offset(spread + blurOffset), + shadowW, + shadowH + ); + + g.alpha = (layerAlpha & 0xFF) << 24; + g.foreColor = shadow.color; + g.drawRRect( + new RRect( + shadowX, + shadowY, + shadowW, + shadowH, + new double[] { + shadowRadii.topLeftX, shadowRadii.topLeftY, + shadowRadii.topRightX, shadowRadii.topRightY, + shadowRadii.bottomRightX, shadowRadii.bottomRightY, + shadowRadii.bottomLeftX, shadowRadii.bottomLeftY + } + ), + shadow.color, + true + ); + } + } finally { + g.alpha = savedAlpha; + g.foreColor = savedForeColor; + } + } + + private static int toPixels(double value) { + return (int) Math.round(UnitsConverter.toPixels(Control.DP + value)); + } + + private static void drawStyledHorizontal(Graphics g, BorderSide side, int x1, int y, int x2) { + if (side == null || !side.isVisible() || x2 < x1) { + return; + } + g.foreColor = side.color; + if (side.style == BorderSide.Style.DASHED) { + drawDashedLine(g, x1, y, x2, y); + } else if (side.style == BorderSide.Style.DOTTED) { + drawDottedLine(g, x1, y, x2, y); + } else { + g.drawLine(x1, y, x2, y); + } + } + + private static void drawDashedLine(Graphics g, int x1, int y1, int x2, int y2) { + int dash = 4; + int gap = 3; + int dx = x2 - x1; + int dy = y2 - y1; + int len = Math.max(Math.abs(dx), Math.abs(dy)); + if (len == 0) { + g.drawLine(x1, y1, x2, y2); + return; + } + + for (int i = 0; i < len; i += dash + gap) { + int sx = x1 + (dx * i) / len; + int sy = y1 + (dy * i) / len; + int ex = x1 + (dx * Math.min(i + dash, len)) / len; + int ey = y1 + (dy * Math.min(i + dash, len)) / len; + g.drawLine(sx, sy, ex, ey); + } + } + + private static void drawDottedLine(Graphics g, int x1, int y1, int x2, int y2) { + int step = 3; + int dx = x2 - x1; + int dy = y2 - y1; + int len = Math.max(Math.abs(dx), Math.abs(dy)); + if (len == 0) { + g.setPixel(x1, y1); + return; + } + + for (int i = 0; i < len; i += step) { + int px = x1 + (dx * i) / len; + int py = y1 + (dy * i) / len; + g.setPixel(px, py); + } + } + +} From bfac08ab6945d023a0ac9c75d80a27123eb34f3a Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Thu, 26 Mar 2026 01:08:48 -0300 Subject: [PATCH 07/18] feat(util): add double overload to UnitsConverter Introduce UnitsConverter.toPixels(double) for callers that need fractional unit conversion and document the UnitsConverter API with Javadocs for the class and its public methods. --- .../java/totalcross/util/UnitsConverter.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/TotalCrossSDK/src/main/java/totalcross/util/UnitsConverter.java b/TotalCrossSDK/src/main/java/totalcross/util/UnitsConverter.java index b6f0f6713..753fdda7c 100644 --- a/TotalCrossSDK/src/main/java/totalcross/util/UnitsConverter.java +++ b/TotalCrossSDK/src/main/java/totalcross/util/UnitsConverter.java @@ -1,14 +1,41 @@ +// Copyright (C) 2014-2020 TotalCross Global Mobile Platform Ltda. +// Copyright (C) 2020-2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + package totalcross.util; import totalcross.sys.Settings; import totalcross.ui.Control; +/** + * Utility methods for converting unit-encoded values into screen pixels. + */ public abstract class UnitsConverter { + /** + * Converts an integer unit-encoded value to pixels. + * + * @param value the raw value, optionally encoded with a unit marker such as {@code Control.DP} + * @return the converted pixel value, or the original value when no conversion is needed + */ public static int toPixels(int value) { if ((Control.DP - Control.RANGE) <= value && value <= (Control.DP + Control.RANGE)) { return (int) (Settings.screenDensity * (value - Control.DP)); } return value; } + + /** + * Converts a floating-point unit-encoded value to pixels. + * + * @param value the raw value, optionally encoded with a unit marker such as {@code Control.DP} + * @return the converted pixel value, or the original value when no conversion is needed + */ + public static double toPixels(double value) { + if ((Control.DP - Control.RANGE) <= value && value <= (Control.DP + Control.RANGE)) { + return Settings.screenDensity * (value - Control.DP); + } + return value; + } } From ce8ba2feae26cbf5ebf373c6e24d120c509706f2 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Thu, 26 Mar 2026 17:22:43 -0300 Subject: [PATCH 08/18] refactor(style): move elevation outset calculation into model Move the shadow outset calculation from BoxGeometry into Elevation so the derived value lives with the immutable elevation model. This keeps geometry focused on box resolution and lets callers read the precomputed outset directly from Elevation. --- .../totalcross/ui/style/geom/BoxGeometry.java | 33 ------------------- .../totalcross/ui/style/model/Elevation.java | 18 ++++++++++ 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java index c9f758bfe..d4313c31b 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java @@ -4,7 +4,6 @@ package totalcross.ui.style.geom; -import totalcross.ui.Control; import totalcross.ui.Insets; import totalcross.ui.gfx.RRect; import totalcross.ui.gfx.Rect; @@ -15,9 +14,6 @@ import totalcross.ui.style.model.BoxShape; import totalcross.ui.style.model.BoxStyle; import totalcross.ui.style.model.CornerRadii; -import totalcross.ui.style.model.Elevation; -import totalcross.ui.style.model.Shadow; -import totalcross.util.UnitsConverter; /** * Resolves the concrete rectangles and corner radii used to paint a box. @@ -82,31 +78,6 @@ public RRect contentRRect() { }); } - /** - * Computes the extra paint area required by the style, such as shadows. - */ - public static int paintExpand(BoxStyle style) { - Elevation elevation = style == null ? null : style.elevation; - Shadow[] shadows = elevation == null ? null : elevation.shadows; - if (shadows == null) { - return 0; - } - - int expand = 0; - for (int i = 0; i < shadows.length; i++) { - Shadow shadow = shadows[i]; - if (shadow == null || shadow == Shadow.NONE || shadow.alpha <= 0) { - continue; - } - expand = Math.max( - expand, - Math.max(Math.abs(toPixels(shadow.dx)), Math.abs(toPixels(shadow.dy))) - + Math.max(0, toPixels(shadow.spread)) - + Math.max(0, toPixels(shadow.blurRadius))); - } - return expand; - } - /** * Computes box geometry for the given bounds and style. */ @@ -217,8 +188,4 @@ private static int resolveStrokeAlign(BoxBorder border) { } return border.top.align; } - - private static int toPixels(double value) { - return (int) Math.round(UnitsConverter.toPixels(Control.DP + value)); - } } diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Elevation.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Elevation.java index 2413c655e..f1665a5c6 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Elevation.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/Elevation.java @@ -15,9 +15,11 @@ public final class Elevation { public static final Elevation NONE = new Elevation(new Shadow[] {Shadow.NONE}); public final Shadow[] shadows; + public final double outset; private Elevation(Shadow[] shadows) { this.shadows = copyShadows(shadows); + this.outset = computeOutset(this.shadows); } /** @@ -84,4 +86,20 @@ private static Shadow[] copyShadows(Shadow[] shadows) { } return copy; } + + private static double computeOutset(Shadow[] shadows) { + double expand = 0; + for (int i = 0; i < shadows.length; i++) { + Shadow shadow = shadows[i]; + if (shadow == null || shadow == Shadow.NONE || shadow.alpha <= 0) { + continue; + } + expand = Math.max( + expand, + Math.max(Math.abs(shadow.dx), Math.abs(shadow.dy)) + + Math.max(0d, shadow.spread) + + Math.max(0d, shadow.blurRadius)); + } + return expand; + } } From 57f3a813b1ea22edc0503528d9b830ccf99e1615 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Thu, 26 Mar 2026 17:42:29 -0300 Subject: [PATCH 09/18] refactor(style): move corner radii helpers into model Move corner-radii inset and clamp operations from BoxGeometry into CornerRadii so the shape math lives with the radii model itself. Update BoxGeometry and BoxPainter to use the new CornerRadii API. --- .../totalcross/ui/style/geom/BoxGeometry.java | 58 +++---------------- .../ui/style/model/CornerRadii.java | 36 ++++++++++++ .../totalcross/ui/style/paint/BoxPainter.java | 8 +-- 3 files changed, 47 insertions(+), 55 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java index d4313c31b..9fa09e530 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/geom/BoxGeometry.java @@ -112,23 +112,19 @@ public static BoxGeometry compute(int x, int y, int w, int h, BoxStyle style) { g.borderBox.set(borderBoxX, borderBoxY, Math.max(0, borderBoxW), Math.max(0, borderBoxH)); - g.borderRadii = clampRadii( - shape == null ? CornerRadii.ZERO : shape.radii, - g.borderBox.width, - g.borderBox.height); + g.borderRadii = (shape == null ? CornerRadii.ZERO : shape.radii) + .clamp(g.borderBox.width, g.borderBox.height); insetRect(g.borderBox, borderInsets, g.paddingBox); - g.paddingRadii = clampRadii( - insetRadii(g.borderRadii, borderInsets), - g.paddingBox.width, - g.paddingBox.height); + g.paddingRadii = g.borderRadii + .inset(borderInsets) + .clamp(g.paddingBox.width, g.paddingBox.height); Insets safePadding = padding == null ? new Insets() : padding; insetRect(g.paddingBox, safePadding, g.contentBox); - g.contentRadii = clampRadii( - insetRadii(g.paddingRadii, safePadding), - g.contentBox.width, - g.contentBox.height); + g.contentRadii = g.paddingRadii + .inset(safePadding) + .clamp(g.contentBox.width, g.contentBox.height); return g; } @@ -144,44 +140,6 @@ public static void insetRect(Rect src, Insets insets, Rect dst) { dst.set(x, y, Math.max(0, w), Math.max(0, h)); } - /** - * Insets corner radii according to the provided insets. - */ - public static CornerRadii insetRadii(CornerRadii src, Insets insets) { - if (src == null) { - return CornerRadii.ZERO; - } - return CornerRadii.of( - Math.max(0, src.topLeftX - insets.left), - Math.max(0, src.topLeftY - insets.top), - Math.max(0, src.topRightX - insets.right), - Math.max(0, src.topRightY - insets.top), - Math.max(0, src.bottomRightX - insets.right), - Math.max(0, src.bottomRightY - insets.bottom), - Math.max(0, src.bottomLeftX - insets.left), - Math.max(0, src.bottomLeftY - insets.bottom)); - } - - /** - * Clamps corner radii so they fit inside the given dimensions. - */ - public static CornerRadii clampRadii(CornerRadii radii, int w, int h) { - if (radii == null) { - return CornerRadii.ZERO; - } - double maxX = Math.max(0, w / 2.0); - double maxY = Math.max(0, h / 2.0); - return CornerRadii.of( - Math.min(radii.topLeftX, maxX), - Math.min(radii.topLeftY, maxY), - Math.min(radii.topRightX, maxX), - Math.min(radii.topRightY, maxY), - Math.min(radii.bottomRightX, maxX), - Math.min(radii.bottomRightY, maxY), - Math.min(radii.bottomLeftX, maxX), - Math.min(radii.bottomLeftY, maxY)); - } - private static int resolveStrokeAlign(BoxBorder border) { if (border == null || border.top == null) { return BorderSide.Align.INSIDE; diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/CornerRadii.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/CornerRadii.java index 54d27f981..483bdd684 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/model/CornerRadii.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/model/CornerRadii.java @@ -4,6 +4,8 @@ package totalcross.ui.style.model; +import totalcross.ui.Insets; + /** * Represents the x and y radii for each corner of a box. */ @@ -121,6 +123,40 @@ public CornerRadii offset(double delta) { ); } + /** + * Returns a copy inset by the given edge insets. + */ + public CornerRadii inset(Insets insets) { + return of( + Math.max(0, topLeftX - insets.left), + Math.max(0, topLeftY - insets.top), + Math.max(0, topRightX - insets.right), + Math.max(0, topRightY - insets.top), + Math.max(0, bottomRightX - insets.right), + Math.max(0, bottomRightY - insets.bottom), + Math.max(0, bottomLeftX - insets.left), + Math.max(0, bottomLeftY - insets.bottom) + ); + } + + /** + * Returns a copy clamped to fit inside the given dimensions. + */ + public CornerRadii clamp(int w, int h) { + double maxX = Math.max(0, w / 2.0); + double maxY = Math.max(0, h / 2.0); + return of( + Math.min(topLeftX, maxX), + Math.min(topLeftY, maxY), + Math.min(topRightX, maxX), + Math.min(topRightY, maxY), + Math.min(bottomRightX, maxX), + Math.min(bottomRightY, maxY), + Math.min(bottomLeftX, maxX), + Math.min(bottomLeftY, maxY) + ); + } + private static void validateNonNegative(String name, double value) { if (value < 0) { throw new IllegalArgumentException("CornerRadii " + name + " must be >= 0: " + value); diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java index 5a7ba3cc3..8accc5302 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java @@ -138,11 +138,9 @@ private static void drawShadow(Graphics g, BoxGeometry geom, Shadow shadow) { int weight = layers - layer + 1; int layerAlpha = Math.max(1, Math.min(255, (int) Math.round(((shadow.alpha * weight) / (double) totalWeight) * SHADOW_ALPHA_GAIN))); - CornerRadii shadowRadii = BoxGeometry.clampRadii( - geom.borderRadii.offset(spread + blurOffset), - shadowW, - shadowH - ); + CornerRadii shadowRadii = geom.borderRadii + .offset(spread + blurOffset) + .clamp(shadowW, shadowH); g.alpha = (layerAlpha & 0xFF) << 24; g.foreColor = shadow.color; From e93eb83db98f2526efa3bd2a35246f524f83abbf Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Thu, 26 Mar 2026 18:33:36 -0300 Subject: [PATCH 10/18] feat(style): introduce control renderer abstraction Add the ControlRenderer abstraction for control-level visual rendering and rename BoxPainter to BoxRenderer to match its broader role. This establishes the renderer contract that future control renderers can implement. --- .../BoxRenderer.java} | 61 +++++++++++++++++-- .../ui/style/render/ControlRenderer.java | 39 ++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) rename TotalCrossSDK/src/main/java/totalcross/ui/style/{paint/BoxPainter.java => render/BoxRenderer.java} (81%) create mode 100644 TotalCrossSDK/src/main/java/totalcross/ui/style/render/ControlRenderer.java diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java similarity index 81% rename from TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java rename to TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java index 8accc5302..2c1d86050 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/paint/BoxPainter.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java @@ -2,12 +2,14 @@ // // SPDX-License-Identifier: LGPL-2.1-only -package totalcross.ui.style.paint; +package totalcross.ui.style.render; import totalcross.ui.Control; +import totalcross.ui.Insets; import totalcross.ui.gfx.Color; import totalcross.ui.gfx.Graphics; import totalcross.ui.gfx.RRect; +import totalcross.ui.gfx.Rect; import totalcross.ui.gfx.Span; import totalcross.ui.style.geom.BoxGeometry; import totalcross.ui.style.model.BorderSide; @@ -21,9 +23,9 @@ import totalcross.util.UnitsConverter; /** - * Paints the visual representation of a {@link BoxStyle}. + * Renders the visual representation of a {@link BoxStyle}. */ -public final class BoxPainter { +public final class BoxRenderer implements ControlRenderer { private static final double SHADOW_ALPHA_GAIN = 1.8d; public final BoxStyle style; @@ -36,7 +38,7 @@ public final class BoxPainter { /** * Creates a painter for the given style. */ - public BoxPainter(BoxStyle style) { + public BoxRenderer(BoxStyle style) { this.style = style; BoxPaint paint = style == null ? null : style.paint; BoxShape shape = style == null ? null : style.shape; @@ -50,6 +52,7 @@ public BoxPainter(BoxStyle style) { /** * Paints the box for the given bounds. */ + @Override public void draw(Graphics g, int x, int y, int w, int h, boolean pressed) { BoxGeometry geom = BoxGeometry.compute(x, y, w, h, style); drawElevation(g, geom, elevation); @@ -58,6 +61,56 @@ public void draw(Graphics g, int x, int y, int w, int h, boolean pressed) { drawBorderStrip(g, geom, spec); } + /** + * Returns how much the renderer extends beyond the control bounds. + */ + @Override + public double getOutset() { + return elevation == null ? 0d : elevation.outset; + } + + /** + * Returns the effective renderer insets, including layout padding and border widths. + */ + @Override + public Insets getInsets() { + Insets insets = new Insets(); + if (style != null && style.layout != null) { + insets.left += style.layout.padding.left; + insets.right += style.layout.padding.right; + insets.top += style.layout.padding.top; + insets.bottom += style.layout.padding.bottom; + } + if (spec != null) { + Insets borderInsets = spec.getInsets(); + insets.left += borderInsets.left; + insets.right += borderInsets.right; + insets.top += borderInsets.top; + insets.bottom += borderInsets.bottom; + } + return insets; + } + + /** + * Returns whether child painting should be clipped. + */ + @Override + public boolean shouldClipChildren() { + return style != null && style.clip != null && style.clip.shouldClip(); + } + + /** + * Returns the clip used for children for the given control size. + */ + @Override + public Rect getChildrenClip(int width, int height) { + if (!shouldClipChildren()) { + return null; + } + BoxGeometry geom = BoxGeometry.compute(0, 0, width, height, style); + return geom.paddingRadii.isZero() ? geom.paddingBox : geom.paddingRRect(); + } + /** * Paints only the border strip for an already computed geometry. */ diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/ControlRenderer.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/ControlRenderer.java new file mode 100644 index 000000000..d9727bf5e --- /dev/null +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/ControlRenderer.java @@ -0,0 +1,39 @@ +// Copyright (C) 2026 Amalgam Solucoes em TI Ltda +// +// SPDX-License-Identifier: LGPL-2.1-only + +package totalcross.ui.style.render; + +import totalcross.ui.Insets; +import totalcross.ui.gfx.Graphics; +import totalcross.ui.gfx.Rect; + +/** + * Describes the visual rendering contract used by a control. + */ +public interface ControlRenderer { + /** + * Paints the control for the given bounds. + */ + void draw(Graphics g, int x, int y, int w, int h, boolean pressed); + + /** + * Returns how much the renderer extends beyond the control bounds. + */ + double getOutset(); + + /** + * Returns the effective insets contributed by the renderer. + */ + Insets getInsets(); + + /** + * Returns whether child painting should be clipped. + */ + boolean shouldClipChildren(); + + /** + * Returns the clip used for children at the given control size, or {@code null} when child clipping is disabled. + */ + Rect getChildrenClip(int width, int height); +} From 5dfe8e01d77bf0a1e84addf05ebe8e4fb4af8506 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Thu, 26 Mar 2026 19:07:50 -0300 Subject: [PATCH 11/18] feat(style): use RRect for renderer child clip Change the control renderer child clip contract to return RRect instead of Rect, allowing renderers to expose rounded and rectangular clip shapes through a single type. --- .../main/java/totalcross/ui/style/render/BoxRenderer.java | 5 ++--- .../java/totalcross/ui/style/render/ControlRenderer.java | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java index 2c1d86050..e95b9f3f8 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java @@ -9,7 +9,6 @@ import totalcross.ui.gfx.Color; import totalcross.ui.gfx.Graphics; import totalcross.ui.gfx.RRect; -import totalcross.ui.gfx.Rect; import totalcross.ui.gfx.Span; import totalcross.ui.style.geom.BoxGeometry; import totalcross.ui.style.model.BorderSide; @@ -103,12 +102,12 @@ public boolean shouldClipChildren() { * Returns the clip used for children for the given control size. */ @Override - public Rect getChildrenClip(int width, int height) { + public RRect getChildrenClip(int width, int height) { if (!shouldClipChildren()) { return null; } BoxGeometry geom = BoxGeometry.compute(0, 0, width, height, style); - return geom.paddingRadii.isZero() ? geom.paddingBox : geom.paddingRRect(); + return geom.paddingRRect(); } /** diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/ControlRenderer.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/ControlRenderer.java index d9727bf5e..d213e48c4 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/ControlRenderer.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/ControlRenderer.java @@ -6,7 +6,7 @@ import totalcross.ui.Insets; import totalcross.ui.gfx.Graphics; -import totalcross.ui.gfx.Rect; +import totalcross.ui.gfx.RRect; /** * Describes the visual rendering contract used by a control. @@ -35,5 +35,5 @@ public interface ControlRenderer { /** * Returns the clip used for children at the given control size, or {@code null} when child clipping is disabled. */ - Rect getChildrenClip(int width, int height); + RRect getChildrenClip(int width, int height); } From 41ad1ca350c8f78703110a8070ca720f294e0273 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Fri, 17 Apr 2026 14:24:31 -0300 Subject: [PATCH 12/18] feat(ui): preserve child clip during container painting Propagate inherited graphics clips when painting child controls and restore container clips after painting. Also cache user insets separately from border-derived insets so container layout can recompute them consistently. --- .../main/java/totalcross/ui/Container.java | 76 ++++++++++++++++--- .../src/main/java/totalcross/ui/Control.java | 43 +++++++++-- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/Container.java b/TotalCrossSDK/src/main/java/totalcross/ui/Container.java index 03eca01e9..e5800542b 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/Container.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/Container.java @@ -15,6 +15,7 @@ import totalcross.ui.event.PenEvent; import totalcross.ui.event.PenListener; import totalcross.ui.gfx.Graphics; +import totalcross.ui.gfx.RRect; import totalcross.ui.gfx.Rect; import totalcross.ui.image.Image; import totalcross.ui.image.ImageException; @@ -115,6 +116,8 @@ public class Container extends Control { * @see #setInsets */ protected Insets insets = new Insets(); // guich@tc110_87 + protected Insets userInsets = new Insets(); + protected RRect childrenClip; /** Set to true to always erase the background when repainting this container. * @since TotalCross 1.0 @@ -196,11 +199,11 @@ public boolean isPressed() { */ public void setInsets(int left, int right, int top, int bottom) // guich@tc110_87 { - int gap = borderStyle == BORDER_NONE || borderStyle == BORDER_TOP ? 0 : borderStyle == BORDER_SIMPLE ? 1 : 2; - insets.left = left + gap; - insets.right = right + gap; - insets.top = top + gap; - insets.bottom = bottom + gap; + userInsets.left = left; + userInsets.right = right; + userInsets.top = top; + userInsets.bottom = bottom; + recomputeInsets(); } /** Copy the current insets values into the given insets. If you call this method often, @@ -504,22 +507,44 @@ public void broadcastEvent(Event e) { /** Called by the system to draw the children of the container. */ public void paintChildren() { + paintChildren(getGraphics()); + } + + private void paintChildren(Graphics g) { + if (g == null) { + return; + } + + RRect oldClip = null; + if (childrenClip != null) { + oldClip = g.getClip(); + if (childrenClip.width <= 0 || childrenClip.height <= 0) { + return; + } + g.setClip(childrenClip); + } + try { for (Control child = children; child != null; child = child.next) { if (child.visible) // guich@200: ignore hidden controls - note: a window added to a container may not be painted correctly { if (child.offscreen != null) { - Graphics g = getGraphics(); g.drawImage(child.offscreen, child.x, child.y); if (child.offscreen0 != null) { g.drawImage(child.offscreen0, child.x, child.y); } } else { - child.onPaint(child.getGraphics()); + Graphics childGraphics = child.getGraphics(g); + child.onPaint(childGraphics); if (child.asContainer != null) { - child.asContainer.paintChildren(); + child.asContainer.paintChildren(childGraphics); + } } } } + } finally { + if (oldClip != null) { + g.setClip(oldClip); + } } } @@ -534,9 +559,8 @@ public void paintChildren() { */ public void setBorderStyle(byte border) // guich@200final_16 { - int gap = border == BORDER_NONE || borderStyle == BORDER_TOP ? 0 : borderStyle == BORDER_SIMPLE ? 1 : 2; - setInsets(gap, gap, gap, gap); this.borderStyle = border; + recomputeInsets(); if (border == BORDER_ROUNDED) { transparentBackground = true; } @@ -1108,4 +1132,36 @@ public void setBorderRadius(int borderRadius) { this.borderRadius = borderRadius; } + @Override + protected void onBoundsChanged(boolean screenChanged) { + super.onBoundsChanged(screenChanged); + updateChildrenClip(); + } + + private void recomputeInsets() { + int left = userInsets.left; + int right = userInsets.right; + int top = userInsets.top; + int bottom = userInsets.bottom; + + int gap = borderStyle == BORDER_NONE || borderStyle == BORDER_TOP ? 0 : borderStyle == BORDER_SIMPLE ? 1 : 2; + left += gap; + right += gap; + top += gap; + bottom += gap; + + insets.left = left; + insets.right = right; + insets.top = top; + insets.bottom = bottom; + } + + private void updateChildrenClip() { + childrenClip = computeChildrenClip(); + } + + private RRect computeChildrenClip() { + return null; + } + } diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/Control.java b/TotalCrossSDK/src/main/java/totalcross/ui/Control.java index c13a03d18..1add2fbbc 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/Control.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/Control.java @@ -54,8 +54,10 @@ import totalcross.ui.gfx.Coord; import totalcross.ui.gfx.GfxSurface; import totalcross.ui.gfx.Graphics; +import totalcross.ui.gfx.RRect; import totalcross.ui.gfx.Rect; import totalcross.ui.image.Image; +import totalcross.util.UnitsConverter; import totalcross.util.Vector; /** @@ -469,7 +471,7 @@ void paint2shot(Graphics g, Control top, boolean shift) { if (child.visible) { Rect r = child.getAbsoluteRect(); if (rtop.intersects(r)) { - child.refreshGraphics(g, 0, top, x0, y0); + child.refreshGraphics(g, child.getPaintExpand(), top, x0, y0); child.onPaint(g); if (child.asContainer != null) { child.asContainer.paint2shot(g, top, shift); @@ -1589,7 +1591,7 @@ public void repaintNow() { } else if (transparentBackground) { parent.repaintNow(); // guich@tc100: for transparent backgrounds we have to force paint everything } else { - Graphics g = refreshGraphics(gfx, 0, null, 0, 0); + Graphics g = refreshGraphics(gfx, getPaintExpand(), null, 0, 0); if (g != null) { onPaint(g); if (asContainer != null) { @@ -1625,13 +1627,22 @@ public void translateFromOrigin(Coord z) // guich@550_31 * It sets a clipping rectangle on the graphics, clipping it against all parent areas. */ public Graphics getGraphics() { - return refreshGraphics(gfx, 0, null, 0, 0); + return getGraphics(null); } - Graphics refreshGraphics(Graphics g, int expand, Control topParent, int tx0, int ty0) { + public Graphics getGraphics(Graphics sourceGraphics) { + return refreshGraphics(gfx, getPaintExpand(), null, 0, 0, sourceGraphics); + } + + Graphics refreshGraphics(Graphics g, double expand, Control topParent, int tx0, int ty0) { + return refreshGraphics(g, expand, topParent, tx0, ty0, null); + } + + Graphics refreshGraphics(Graphics g, double expand, Control topParent, int tx0, int ty0, Graphics sourceGraphics) { if (asWindow == null && parent == null) { return null; } + int roundedExpand = (int) Math.round(UnitsConverter.toPixels(Control.DP + expand)); int sw = this.width; int sh = this.height; int sx = this.x, sy = this.y, cx, cy, delta, tx = sx, ty = sy; @@ -1667,11 +1678,31 @@ Graphics refreshGraphics(Graphics g, int expand, Control topParent, int tx0, int } } } - g.refresh(sx + tx0 - expand, sy - expand + ty0, sw + expand + expand, sh + expand + expand, tx + tx0, ty + ty0, + g.refresh(sx + tx0 - roundedExpand, sy - roundedExpand + ty0, sw + roundedExpand + roundedExpand, sh + roundedExpand + roundedExpand, tx + tx0, ty + ty0, font); + if (sourceGraphics != null) { + RRect inheritedClip = sourceGraphics.getClip(); + inheritedClip.x -= this.x; + inheritedClip.y -= this.y; + g.setClip(inheritedClip); + } return g; } + /** + * Returns how far this control may paint outside its bounds, in density-independent units. + *

+ * The value is used to expand the refreshed graphics area so visual effects such as shadows + * are not clipped by the control bounds. The refresh pipeline converts this value to pixels + * at the point where the repaint rectangle is computed. + * + * @return the visual outset required by this control, or {@code 0} when no extra paint area is + * needed. + */ + protected double getPaintExpand() { + return 0d; + } + /** * Posts an event. The event pass will be posted to this control * and all the parent controls of this control (all the containers @@ -2098,7 +2129,7 @@ protected void reposition(boolean recursive) { font = current; fm = font.fm; fmH = font.fm.height; - refreshGraphics(gfx, 0, null, 0, 0); + refreshGraphics(gfx, getPaintExpand(), null, 0, 0); } if (recursive) { repositionChildren(); From ba33559c3018a560103f2e849362ce141e285057 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Fri, 17 Apr 2026 14:35:48 -0300 Subject: [PATCH 13/18] feat(ui): add control renderer accessors Store the control renderer on Control and expose setter and getter methods for it. Use the renderer outset when expanding the repaint area and add hooks for subclasses to react to renderer-driven style changes. --- .../src/main/java/totalcross/ui/Control.java | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/Control.java b/TotalCrossSDK/src/main/java/totalcross/ui/Control.java index 1add2fbbc..fd6f7cb1e 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/Control.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/Control.java @@ -57,6 +57,7 @@ import totalcross.ui.gfx.RRect; import totalcross.ui.gfx.Rect; import totalcross.ui.image.Image; +import totalcross.ui.style.render.ControlRenderer; import totalcross.util.UnitsConverter; import totalcross.util.Vector; @@ -108,6 +109,7 @@ public static enum TranslucentShape { public int clearValueInt; // guich@572_19 /** The next control that will receive focus when tab is hit. */ public Control nextTabControl; + protected ControlRenderer controlRenderer; public static final int RANGE = 10000000; public static final int UICONST = RANGE * 2 + 1000000; /** Constant used in params width and height in setRect. You can use this constant added to a number to specify a increment/decrement to the calculated size. EG: PREFERRED+2 or PREFERRED-1. */ @@ -1700,7 +1702,7 @@ Graphics refreshGraphics(Graphics g, double expand, Control topParent, int tx0, * needed. */ protected double getPaintExpand() { - return 0d; + return controlRenderer == null ? 0d : controlRenderer.getOutset(); } /** @@ -1962,6 +1964,39 @@ public void setFocusLess(boolean on) { public static void resetStyle() { uiStyleAlreadyChanged = false; } + + /** + * Sets the renderer responsible for drawing this control and providing visual metrics such as + * paint outset. + *

+ * Passing {@code null} removes the renderer and makes the control fall back to its normal + * painting behavior. This method calls {@link #onStyleChanged()} after updating the renderer. + * + * @param renderer the renderer to associate with this control, or {@code null} to clear it. + */ + public void setControlRenderer(ControlRenderer renderer) { + this.controlRenderer = renderer; + onStyleChanged(); + } + + /** + * Returns the renderer currently associated with this control. + * + * @return the current renderer, or {@code null} when this control uses its normal painting + * behavior. + */ + public ControlRenderer getControlRenderer() { + return controlRenderer; + } + + /** + * Called after the control renderer or visual style associated with this control changes. + *

+ * Subclasses may override this hook to update cached geometry, insets, clips, or any other + * derived state that depends on the current style. The default implementation does nothing. + */ + protected void onStyleChanged() { + } /** Internal use only */ public static void uiStyleChanged() { From 8fd06cbdb4ed9adb1cac979d9ee764b9b4013ee8 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Fri, 17 Apr 2026 14:37:20 -0300 Subject: [PATCH 14/18] feat(ui): let container use control renderer metrics Use the control renderer to draw containers, compute insets, and provide the cached children clip. Refresh renderer-derived container state when the style changes. --- .../main/java/totalcross/ui/Container.java | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/Container.java b/TotalCrossSDK/src/main/java/totalcross/ui/Container.java index e5800542b..f2397e175 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/Container.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/Container.java @@ -524,18 +524,18 @@ private void paintChildren(Graphics g) { g.setClip(childrenClip); } try { - for (Control child = children; child != null; child = child.next) { - if (child.visible) // guich@200: ignore hidden controls - note: a window added to a container may not be painted correctly - { - if (child.offscreen != null) { - g.drawImage(child.offscreen, child.x, child.y); - if (child.offscreen0 != null) { - g.drawImage(child.offscreen0, child.x, child.y); - } - } else { + for (Control child = children; child != null; child = child.next) { + if (child.visible) // guich@200: ignore hidden controls - note: a window added to a container may not be painted correctly + { + if (child.offscreen != null) { + g.drawImage(child.offscreen, child.x, child.y); + if (child.offscreen0 != null) { + g.drawImage(child.offscreen0, child.x, child.y); + } + } else { Graphics childGraphics = child.getGraphics(g); child.onPaint(childGraphics); - if (child.asContainer != null) { + if (child.asContainer != null) { child.asContainer.paintChildren(childGraphics); } } @@ -635,6 +635,12 @@ protected void fillBackground(Graphics g, int backColor, int foreColor, int x, i @Override public void onPaint(Graphics g) { int b = pressColor != -1 && cpressed ? pressColor : backColor; + + if (controlRenderer != null) { + controlRenderer.draw(g, 0, 0, width, height, cpressed); + return; + } + if (drawTranslucentBackground(g, alphaValue)) { } else if (!transparentBackground && (parent != null && (b != parent.backColor || parent.asWindow != null || alwaysEraseBackground))) { @@ -1138,17 +1144,31 @@ protected void onBoundsChanged(boolean screenChanged) { updateChildrenClip(); } + @Override + protected void onStyleChanged() { + recomputeInsets(); + updateChildrenClip(); + } + private void recomputeInsets() { int left = userInsets.left; int right = userInsets.right; int top = userInsets.top; int bottom = userInsets.bottom; - int gap = borderStyle == BORDER_NONE || borderStyle == BORDER_TOP ? 0 : borderStyle == BORDER_SIMPLE ? 1 : 2; - left += gap; - right += gap; - top += gap; - bottom += gap; + if (controlRenderer != null) { + Insets rendererInsets = controlRenderer.getInsets(); + left += rendererInsets.left; + right += rendererInsets.right; + top += rendererInsets.top; + bottom += rendererInsets.bottom; + } else { + int gap = borderStyle == BORDER_NONE || borderStyle == BORDER_TOP ? 0 : borderStyle == BORDER_SIMPLE ? 1 : 2; + left += gap; + right += gap; + top += gap; + bottom += gap; + } insets.left = left; insets.right = right; @@ -1161,6 +1181,9 @@ private void updateChildrenClip() { } private RRect computeChildrenClip() { + if (controlRenderer != null) { + return controlRenderer.getChildrenClip(width, height); + } return null; } From 06e832465c033be6c1a0cc4221ca38b5ee04a4e7 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Fri, 17 Apr 2026 15:34:52 -0300 Subject: [PATCH 15/18] chore(style): scale css box lengths to screen pixels Convert parsed CSS padding and border radius lengths through the unit converter so browser-style px values match controls sized with dp. --- .../ui/style/css/BoxStyleCssParser.java | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java index 74902767f..6383de1b1 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java @@ -10,6 +10,7 @@ import java.util.Locale; import java.util.Map; +import totalcross.ui.Control; import totalcross.ui.Insets; import totalcross.ui.gfx.Color; import totalcross.ui.style.model.BorderSide; @@ -22,6 +23,7 @@ import totalcross.ui.style.model.CornerRadii; import totalcross.ui.style.model.Elevation; import totalcross.ui.style.model.Shadow; +import totalcross.util.UnitsConverter; /** * Parses a CSS-like subset into a {@link BoxStyle}. @@ -127,8 +129,8 @@ public static BoxStyle parse(String css) { break; case "border-top-left-radius": radii = CornerRadii.of( - parseLengthDouble(firstToken(value)), - parseLengthDouble(firstToken(value)), + parseLengthPixelsDouble(firstToken(value)), + parseLengthPixelsDouble(firstToken(value)), radii.topRightX, radii.topRightY, radii.bottomRightX, @@ -141,8 +143,8 @@ public static BoxStyle parse(String css) { radii = CornerRadii.of( radii.topLeftX, radii.topLeftY, - parseLengthDouble(firstToken(value)), - parseLengthDouble(firstToken(value)), + parseLengthPixelsDouble(firstToken(value)), + parseLengthPixelsDouble(firstToken(value)), radii.bottomRightX, radii.bottomRightY, radii.bottomLeftX, @@ -155,8 +157,8 @@ public static BoxStyle parse(String css) { radii.topLeftY, radii.topRightX, radii.topRightY, - parseLengthDouble(firstToken(value)), - parseLengthDouble(firstToken(value)), + parseLengthPixelsDouble(firstToken(value)), + parseLengthPixelsDouble(firstToken(value)), radii.bottomLeftX, radii.bottomLeftY ); @@ -169,8 +171,8 @@ public static BoxStyle parse(String css) { radii.topRightY, radii.bottomRightX, radii.bottomRightY, - parseLengthDouble(firstToken(value)), - parseLengthDouble(firstToken(value)) + parseLengthPixelsDouble(firstToken(value)), + parseLengthPixelsDouble(firstToken(value)) ); break; case "background": @@ -354,11 +356,11 @@ private static void applyColors(int[] values, BorderDecl top, BorderDecl right, private static CornerRadii applyBorderRadius(String value) { String[] parts = value.split("/"); - double[] horizontal = normalize1To4Doubles(parseLengthsAsDouble(parts[0])); + double[] horizontal = normalize1To4Doubles(parseLengthsAsPixelsDouble(parts[0])); if (parts.length == 1) { return CornerRadii.of(horizontal[0], horizontal[1], horizontal[2], horizontal[3]); } - double[] vertical = normalize1To4Doubles(parseLengthsAsDouble(parts[1])); + double[] vertical = normalize1To4Doubles(parseLengthsAsPixelsDouble(parts[1])); return CornerRadii.of( horizontal[0], vertical[0], horizontal[1], vertical[1], @@ -376,6 +378,15 @@ private static double[] parseLengthsAsDouble(String value) { return values; } + private static double[] parseLengthsAsPixelsDouble(String value) { + String[] tokens = tokenize(value); + double[] values = new double[tokens.length]; + for (int i = 0; i < tokens.length; i++) { + values[i] = parseLengthPixelsDouble(tokens[i]); + } + return values; + } + private static int[] parseLengths(String value) { String[] tokens = tokenize(value); int[] values = new int[tokens.length]; @@ -481,18 +492,7 @@ private static boolean looksLikeSignedLength(String s) { } private static int parseLength(String s) { - s = normalize(s); - if (s.endsWith("px")) { - s = s.substring(0, s.length() - 2).trim(); - } - if (s.isEmpty()) { - return 0; - } - try { - return Math.max(0, (int) Math.round(Double.parseDouble(s))); - } catch (NumberFormatException e) { - return 0; - } + return (int) Math.round(parseLengthPixelsDouble(s)); } private static double parseLengthDouble(String s) { @@ -510,6 +510,10 @@ private static double parseLengthDouble(String s) { } } + private static double parseLengthPixelsDouble(String s) { + return UnitsConverter.toPixels(Control.DP + parseLengthDouble(s)); + } + private static int parseSignedLength(String s) { s = normalize(s); if (s.endsWith("px")) { From 96f253ba1a03335d56a2cbc313a3877bc2fdaba9 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Fri, 17 Apr 2026 16:17:03 -0300 Subject: [PATCH 16/18] fix(style): render box backgrounds as opaque colors Ensure BoxRenderer passes an opaque ARGB color when drawing box backgrounds so CSS colors are not treated as fully transparent. --- .../src/main/java/totalcross/ui/style/render/BoxRenderer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java index e95b9f3f8..537bb0b78 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java @@ -56,7 +56,7 @@ public void draw(Graphics g, int x, int y, int w, int h, boolean pressed) { BoxGeometry geom = BoxGeometry.compute(x, y, w, h, style); drawElevation(g, geom, elevation); int bg = pressed ? pressedColor : backgroundColor; - g.drawRRect(geom.borderRRect(), bg, true); + g.drawRRect(geom.borderRRect(), 0xFF000000 | bg, true); drawBorderStrip(g, geom, spec); } From 00c55b5556d260ed76028db4e073e399028d2d9b Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Fri, 17 Apr 2026 16:22:39 -0300 Subject: [PATCH 17/18] feat(style): support active box renderer styles Add CSS parsing for a base selector and its :active rule, merging the active declarations over the base box style. Let BoxRenderer use the active style while a control is pressed and keep the largest style outset available for repaint bounds. --- .../ui/style/css/BoxStyleCssParser.java | 119 ++++++++++++++++-- .../ui/style/render/BoxRenderer.java | 41 +++++- 2 files changed, 145 insertions(+), 15 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java index 6383de1b1..9101fec49 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/css/BoxStyleCssParser.java @@ -10,9 +10,11 @@ import java.util.Locale; import java.util.Map; +import totalcross.sys.Settings; import totalcross.ui.Control; import totalcross.ui.Insets; import totalcross.ui.gfx.Color; +import totalcross.ui.style.render.BoxRenderer; import totalcross.ui.style.model.BorderSide; import totalcross.ui.style.model.BoxBorder; import totalcross.ui.style.model.BoxClip; @@ -37,19 +39,44 @@ private BoxStyleCssParser() { */ public static BoxStyle parse(String css) { String body = extractRuleBody(css); + return parseBody(body, new BoxStyle()); + } + + /** + * Parses normal and {@code :active} rules for the given selector into a box renderer. + */ + public static BoxRenderer parseRenderer(String css, String selector) { + String baseBody = findRuleBody(css, selector); + if (baseBody == null) { + baseBody = extractRuleBody(css); + } + + BoxStyle baseStyle = parseBody(baseBody, new BoxStyle()); + String activeBody = findRuleBody(css, selector + ":active"); + if (activeBody == null) { + return new BoxRenderer(baseStyle); + } + + return new BoxRenderer(baseStyle, parseBody(activeBody, baseStyle)); + } - BorderDecl top = new BorderDecl(); - BorderDecl right = new BorderDecl(); - BorderDecl bottom = new BorderDecl(); - BorderDecl left = new BorderDecl(); + private static BoxStyle parseBody(String body, BoxStyle base) { + BorderDecl top = BorderDecl.from(base.paint.border.top); + BorderDecl right = BorderDecl.from(base.paint.border.right); + BorderDecl bottom = BorderDecl.from(base.paint.border.bottom); + BorderDecl left = BorderDecl.from(base.paint.border.left); - BoxStyle defaults = new BoxStyle(); - CornerRadii radii = defaults.shape.radii; - Insets padding = new Insets(); - int backgroundColor = defaults.paint.backgroundColor; - int pressedColor = defaults.paint.pressedColor; - int overflow = defaults.clip.overflow; - Elevation elevation = defaults.elevation; + CornerRadii radii = base.shape.radii; + Insets padding = new Insets( + base.layout.padding.top, + base.layout.padding.left, + base.layout.padding.bottom, + base.layout.padding.right + ); + int backgroundColor = base.paint.backgroundColor; + int pressedColor = base.paint.pressedColor; + int overflow = base.clip.overflow; + Elevation elevation = base.elevation; for (String decl : splitDeclarations(body)) { int colon = decl.indexOf(':'); @@ -251,13 +278,70 @@ private static String extractRuleBody(String css) { } String s = removeComments(css).trim(); int open = s.indexOf('{'); - int close = s.lastIndexOf('}'); + int close = findMatchingBrace(s, open); if (open >= 0 && close > open) { return s.substring(open + 1, close).trim(); } return s; } + private static String findRuleBody(String css, String selector) { + if (css == null || selector == null) { + return null; + } + + String s = removeComments(css).trim(); + int searchFrom = 0; + while (searchFrom < s.length()) { + int open = s.indexOf('{', searchFrom); + if (open < 0) { + return null; + } + + int close = findMatchingBrace(s, open); + if (close < 0) { + return null; + } + + String selectorList = s.substring(searchFrom, open).trim(); + if (selectorListContains(selectorList, selector)) { + return s.substring(open + 1, close).trim(); + } + searchFrom = close + 1; + } + return null; + } + + private static int findMatchingBrace(String s, int open) { + if (open < 0) { + return -1; + } + + int depth = 0; + for (int i = open; i < s.length(); i++) { + char ch = s.charAt(i); + if (ch == '{') { + depth++; + } else if (ch == '}') { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; + } + + private static boolean selectorListContains(String selectorList, String selector) { + List selectors = splitTopLevelCommaSeparated(selectorList); + for (int i = 0; i < selectors.size(); i++) { + if (normalize(selectors.get(i)).equals(normalize(selector))) { + return true; + } + } + return false; + } + private static String removeComments(String s) { StringBuilder out = new StringBuilder(s.length()); int i = 0; @@ -780,6 +864,17 @@ private static final class BorderDecl { int color = 0; int align = BorderSide.Align.INSIDE; + static BorderDecl from(BorderSide side) { + BorderDecl decl = new BorderDecl(); + if (side != null) { + decl.width = Settings.screenDensity == 0 ? side.width : side.width / Settings.screenDensity; + decl.style = side.style; + decl.color = side.color; + decl.align = side.align; + } + return decl; + } + BorderSide toBorderSide() { return BorderSide.with(width, style, color, align); } diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java index 537bb0b78..430ba05f9 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/style/render/BoxRenderer.java @@ -28,6 +28,7 @@ public final class BoxRenderer implements ControlRenderer { private static final double SHADOW_ALPHA_GAIN = 1.8d; public final BoxStyle style; + public final BoxStyle activeStyle; public final BoxBorder spec; public final CornerRadii radii; public final int backgroundColor; @@ -38,14 +39,23 @@ public final class BoxRenderer implements ControlRenderer { * Creates a painter for the given style. */ public BoxRenderer(BoxStyle style) { + this(style, null); + } + + /** + * Creates a painter for the given normal and pressed styles. + */ + public BoxRenderer(BoxStyle style, BoxStyle activeStyle) { this.style = style; + this.activeStyle = activeStyle; BoxPaint paint = style == null ? null : style.paint; BoxShape shape = style == null ? null : style.shape; this.spec = paint == null ? null : paint.border; this.radii = shape == null || shape.radii == null ? CornerRadii.ZERO : shape.radii; this.backgroundColor = paint == null ? 0 : paint.backgroundColor; this.pressedColor = paint == null ? Color.WHITE : paint.pressedColor; - this.elevation = style == null ? Elevation.NONE : style.elevation; + this.elevation = maxOutset(style == null ? Elevation.NONE : style.elevation, + activeStyle == null ? Elevation.NONE : activeStyle.elevation); } /** @@ -53,8 +63,13 @@ public BoxRenderer(BoxStyle style) { */ @Override public void draw(Graphics g, int x, int y, int w, int h, boolean pressed) { + if (pressed && activeStyle != null) { + drawStyle(g, x, y, w, h, activeStyle); + return; + } + BoxGeometry geom = BoxGeometry.compute(x, y, w, h, style); - drawElevation(g, geom, elevation); + drawElevation(g, geom, style == null ? Elevation.NONE : style.elevation); int bg = pressed ? pressedColor : backgroundColor; g.drawRRect(geom.borderRRect(), 0xFF000000 | bg, true); drawBorderStrip(g, geom, spec); @@ -114,7 +129,7 @@ public RRect getChildrenClip(int width, int height) { * Paints only the border strip for an already computed geometry. */ public static void drawBorderStrip(Graphics g, BoxGeometry geom, BoxBorder spec) { - if (!spec.isVisible()) { + if (spec == null || !spec.isVisible()) { return; } @@ -159,6 +174,26 @@ private static void drawElevation(Graphics g, BoxGeometry geom, Elevation elevat } } + private static void drawStyle(Graphics g, int x, int y, int w, int h, BoxStyle style) { + BoxGeometry geom = BoxGeometry.compute(x, y, w, h, style); + drawElevation(g, geom, style == null ? Elevation.NONE : style.elevation); + BoxPaint paint = style == null ? null : style.paint; + BoxBorder border = paint == null ? null : paint.border; + int background = paint == null ? 0 : paint.backgroundColor; + g.drawRRect(geom.borderRRect(), 0xFF000000 | background, true); + drawBorderStrip(g, geom, border); + } + + private static Elevation maxOutset(Elevation first, Elevation second) { + if (first == null) { + return second == null ? Elevation.NONE : second; + } + if (second == null || first.outset >= second.outset) { + return first; + } + return second; + } + private static void drawShadow(Graphics g, BoxGeometry geom, Shadow shadow) { if (shadow == null || shadow.alpha <= 0) { return; From 123fb56423574f47d2d3085d6710a0c46f3bb594 Mon Sep 17 00:00:00 2001 From: Fabio Sobral Date: Fri, 17 Apr 2026 16:27:01 -0300 Subject: [PATCH 18/18] feat(button): add support for drawing with control renderers Draw the configured control renderer as the button background without short-circuiting the normal text and image painting flow. Skip legacy button background and press effect painting when a renderer is present so the rendered style is not overwritten. --- .../src/main/java/totalcross/ui/Button.java | 161 +++++++++--------- 1 file changed, 83 insertions(+), 78 deletions(-) diff --git a/TotalCrossSDK/src/main/java/totalcross/ui/Button.java b/TotalCrossSDK/src/main/java/totalcross/ui/Button.java index d3fda61a6..b1abd59f1 100644 --- a/TotalCrossSDK/src/main/java/totalcross/ui/Button.java +++ b/TotalCrossSDK/src/main/java/totalcross/ui/Button.java @@ -788,97 +788,102 @@ public void onPaint(Graphics g) { if (skipPaint) { return; } - + int tx = tx0; int ty = ty0; int ix = ix0; int iy = iy0; - - if ((uiMaterial || uiAndroid) && this.border == BORDER_3D) { - int bbackColor = isEnabled() ? - backColor : (uiMaterial ? disabledColor : Color.interpolate(parent.backColor, backColor)); - if(!transparentBackground) { - g.backColor = bbackColor; - if(!drawNinePatch) { - g.fillRect(4, 4, width - 6, height - 6); + + boolean rendererPainted = controlRenderer != null; + if (rendererPainted) { + controlRenderer.draw(g, 0, 0, width, height, armed); + } else { + if ((uiMaterial || uiAndroid) && this.border == BORDER_3D) { + int bbackColor = isEnabled() ? + backColor : (uiMaterial ? disabledColor : Color.interpolate(parent.backColor, backColor)); + if(!transparentBackground) { + g.backColor = bbackColor; + if(!drawNinePatch) { + g.fillRect(4, 4, width - 6, height - 6); + } } - } - if(npParts != null) { - try { - if (!drawTranslucentBackground(g, armed ? alphaValue >= 80 ? alphaValue / 2 : alphaValue * 2 : alphaValue)) { - if(npback == null) { - npback = NinePatch.getInstance().getNormalInstance(npParts, width, height, - isEnabled() ? transparentBackground ? borderColor : bbackColor : bbackColor, false); + if(npParts != null) { + try { + if (!drawTranslucentBackground(g, armed ? alphaValue >= 80 ? alphaValue / 2 : alphaValue * 2 : alphaValue)) { + if(npback == null) { + npback = NinePatch.getInstance().getNormalInstance(npParts, width, height, + isEnabled() ? transparentBackground ? borderColor : bbackColor : bbackColor, false); + } + this.originalForeColor = this.originalForeColor == -1 ? this.foreColor : this.originalForeColor; + this.foreColor = this.isEnabled() ? this.originalForeColor : Color.BLACK; } - this.originalForeColor = this.originalForeColor == -1 ? this.foreColor : this.originalForeColor; - this.foreColor = this.isEnabled() ? this.originalForeColor : Color.BLACK; - } - if (npback != null) { - NinePatch.tryDrawImage(g, armed && (isSticky || effect == null) ? - NinePatch.getInstance().getPressedInstance(npback, backColor,pressColor) : npback, - 0, 0); + if (npback != null) { + NinePatch.tryDrawImage(g, armed && (isSticky || effect == null) ? + NinePatch.getInstance().getPressedInstance(npback, backColor,pressColor) : npback, + 0, 0); + } + } catch (ImageException ie) { + ie.printStackTrace(); } - } catch (ImageException ie) { - ie.printStackTrace(); } - } - } else { - boolean isBorderRound = border == BORDER_ROUND; - if (uiMaterial && effect != null) { - effect.darkSideOnPress = border != BORDER_NONE; - } - if (isAndroidStyle) { - if (translucentShape == TranslucentShape.NONE && !uiMaterial && !Settings.isOpenGL) { - g.getClip(clip); - g.backColor = Settings.isOpenGL ? parent.backColor : g.getPixel(clip.x, clip.y); - g.fillRect(0, 0, width, height); + } else { + boolean isBorderRound = border == BORDER_ROUND; + if (uiMaterial && effect != null) { + effect.darkSideOnPress = border != BORDER_NONE; } - } else if (!isBorderRound && (!transparentBackground || (armed && fillWhenPressedOnTransparentBackground) - || drawBordersIfTransparentBackground)) { - paintBackground(g); - } - - if (isBorderRound) { - g.backColor = backColor; - g.fillRoundRect(0, 0, width, height, + if (isAndroidStyle) { + if (translucentShape == TranslucentShape.NONE && !uiMaterial && !Settings.isOpenGL) { + g.getClip(clip); + g.backColor = Settings.isOpenGL ? parent.backColor : g.getPixel(clip.x, clip.y); + g.fillRect(0, 0, width, height); + } + } else if (!isBorderRound && (!transparentBackground || (armed && fillWhenPressedOnTransparentBackground) + || drawBordersIfTransparentBackground)) { + paintBackground(g); + } + + if (isBorderRound) { + g.backColor = backColor; + g.fillRoundRect(0, 0, width, height, + uiMaterial ? UnitsConverter.toPixels(DP + 4) : height / roundBorderFactor); + } else if (this.border == BORDER_OUTLINED) { + g.foreColor = transparentBackground ? this.borderColor : this.backColor; + g.drawRoundRect(0, 0, width, height, uiMaterial ? UnitsConverter.toPixels(DP + 4) : height / roundBorderFactor); - } else if (this.border == BORDER_OUTLINED) { - g.foreColor = transparentBackground ? this.borderColor : this.backColor; - g.drawRoundRect(0, 0, width, height, - uiMaterial ? UnitsConverter.toPixels(DP + 4) : height / roundBorderFactor); - - } else if (isAndroidStyle || (uiMaterial && border != BORDER_NONE)) { - paintImage(g, true, 0, 0); - } + + } else if (isAndroidStyle || (uiMaterial && border != BORDER_NONE)) { + paintImage(g, true, 0, 0); + } - int border = txtPos == CENTER ? 0 : Math.min(2, this.border); // guich@tc112_31 - g.setClip(border, border, width - (border << 1), height - (border << 1)); // guich@101: cut text if button is - // too small - guich@510_4 - - boolean isBorder3D = border == BORDER_3D_HORIZONTAL_GRADIENT || border == BORDER_3D_VERTICAL_GRADIENT; - if (armed && !isAndroidStyle && shiftOnPress && (isBorder3D || uiVista || (img != null && text == null))) { // guich@tc100: if this is an image-only button, let the button be pressed - int inc = isBorder3D ? borderWidth3DG : 1; - tx += inc; - ix += inc; - ty += inc; - iy += inc; - } - if(this.border != BORDER_OUTLINED) - { - g.foreColor = fColor; - } - } + int border = txtPos == CENTER ? 0 : Math.min(2, this.border); // guich@tc112_31 + g.setClip(border, border, width - (border << 1), height - (border << 1)); // guich@101: cut text if button is + // too small - guich@510_4 - if (getDoEffect() && effect != null) { - if(this.border == BORDER_OUTLINED) { - effect.color = Color.getBrightness(fColor) < 127 ? Color.brighter(fColor, 64) : Color.darker(fColor, 64); - int backup = this.backColor; - this.backColor = this.foreColor; - effect.paintEffect(g); - this.backColor = backup; - } else { - effect.paintEffect(g); + boolean isBorder3D = border == BORDER_3D_HORIZONTAL_GRADIENT || border == BORDER_3D_VERTICAL_GRADIENT; + if (armed && !isAndroidStyle && shiftOnPress && (isBorder3D || uiVista || (img != null && text == null))) { // guich@tc100: if this is an image-only button, let the button be pressed + int inc = isBorder3D ? borderWidth3DG : 1; + tx += inc; + ix += inc; + ty += inc; + iy += inc; + } + if(this.border != BORDER_OUTLINED) + { + g.foreColor = fColor; + } + } + + if (getDoEffect() && effect != null) { + if(this.border == BORDER_OUTLINED) { + effect.color = Color.getBrightness(fColor) < 127 ? Color.brighter(fColor, 64) : Color.darker(fColor, 64); + int backup = this.backColor; + this.backColor = this.foreColor; + effect.paintEffect(g); + this.backColor = backup; + } else { + effect.paintEffect(g); + } } }