From 63a6e3843d844655659f420eac49e53fa1b4fd4a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:05:33 +0300 Subject: [PATCH 01/59] CSSThemeCompiler: #Constants, font-size and box semantics matching the build-time compiler Fixes #6024. - #Constants is a constants block like @constants, wherever it sits; constant and custom-property names keep their case and quoted values are unquoted. - font-family/font-size build a Font with the build-time compiler's unit conversions, per UIID state, even when declared in separate rules. - padding/margin expand 1-4 values into top,bottom,left,right storage order and emit padUnit/marUnit per side (px/pt pixels, mm/cm, %, rem, vw...). Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 395 +++++++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 164 +++++++- .../css/RuntimeCompilerParityTest.java | 144 +++++++ 3 files changed, 651 insertions(+), 52 deletions(-) create mode 100644 maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 89286bb4573..e93d7c8d452 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -22,10 +22,14 @@ */ package com.codename1.ui.css; +import com.codename1.ui.CN; import com.codename1.ui.Component; +import com.codename1.ui.Display; import com.codename1.ui.EncodedImage; +import com.codename1.ui.Font; import com.codename1.ui.Image; import com.codename1.ui.plaf.CSSBorder; +import com.codename1.ui.plaf.Style; import com.codename1.ui.util.MutableResource; import java.util.ArrayList; import java.util.Hashtable; @@ -46,9 +50,15 @@ /// /// - `color` /// - `background-color` -/// - `padding` -/// - `margin` -/// - `font-family` (mapped to `font` string for later resolution) +/// - `padding`, `margin`: one to four values in CSS top/right/bottom/left order, stored in the +/// theme's top/bottom/left/right order together with per-side `padUnit`/`marUnit` metadata. +/// `px`, `pt` and unitless values are pixels (rounded, as the build-time compiler does), `mm` +/// and `cm` are millimetres, `%` is a percentage of the screen, and `rem`, `vw`, `vh`, +/// `vmin`, `vmax` keep their own unit. +/// - `font-family` and `font-size`: resolved to a {@link Font} with the build-time compiler's +/// semantics. The family defaults to `native:MainRegular` and the size to the medium system +/// font; `font-size` accepts `px`, `pt`, `mm`, `cm`, `in`, `rem`, `vw`, `vh`, `vmin`, `vmax`, +/// `%` and the `small`/`medium`/`large` keywords. /// - `cn1-derive` /// - `cn1-image-id` /// - `cn1-mutable-image` @@ -57,8 +67,12 @@ /// ## Theme constants /// /// - CSS custom property definitions in `:root`, e.g. `--primary: #ff00ff;` -/// - `@constants { name: value; other: value; }` +/// - `#Constants { name: value; }`, the block the build-time compiler reads, and the older +/// `@constants { name: value; other: value; }` spelling. /// - `var(--name)` dereferencing in declaration values. +/// +/// A constant keeps the case of its name (`includeNativeBool` becomes `@includeNativeBool`) and +/// a quoted value is stored without its quotes, so `'true'` and `"true"` both become `true`. public class CSSThemeCompiler { public static class CSSSyntaxException extends IllegalArgumentException { @@ -78,10 +92,18 @@ public void compile(String css, MutableResource resources, String themeName) { } String strippedCss = stripComments(css); - compileConstants(strippedCss, theme); Rule[] rules = parseRulesWithMedia(strippedCss); + // Constants first, wherever their block sits, so a var() in any rule can see them. + for (Rule rule : rules) { + if (rule.constants) { + compileConstants(rule.declarations, theme); + } + } + Hashtable fonts = new Hashtable(); for (Rule rule : rules) { - applyRule(theme, resources, rule); + if (!rule.constants) { + applyRule(theme, resources, rule, fonts); + } } inheritHoverDerivations(theme); resolveThemeConstantVars(theme); @@ -193,26 +215,47 @@ private void resolveThemeConstantVars(Hashtable theme) { } } - private void compileConstants(String css, Hashtable theme) { - int constantsStart = css.indexOf("@constants"); - if (constantsStart < 0) { - return; - } - int open = css.indexOf('{', constantsStart); - if (open < 0) { - return; - } - int close = css.indexOf('}', open + 1); - if (close <= open) { - throw new CSSSyntaxException("Unterminated @constants block"); - } - Declaration[] declarations = parseDeclarations(css.substring(open + 1, close)); + private void compileConstants(Declaration[] declarations, Hashtable theme) { for (Declaration declaration : declarations) { - theme.put("@" + declaration.property, declaration.value); + String name = declaration.name; + // `#Constants { --accent: ...; }` is how the build-time compiler exports a + // palette variable as the constant `@accent`. + if (name.startsWith("--")) { + name = name.substring(2); + } + theme.put("@" + name, unquote(declaration.value)); + } + } + + private static boolean isConstantsSelector(String selector) { + return "@constants".equalsIgnoreCase(selector) || "#Constants".equalsIgnoreCase(selector); + } + + /// Strips one pair of matching quotes, decoding backslash escapes inside them. Anything + /// else is returned as it is. + private static String unquote(String value) { + String v = value.trim(); + int len = v.length(); + if (len < 2) { + return v; + } + char quote = v.charAt(0); + if ((quote != '"' && quote != '\'') || v.charAt(len - 1) != quote) { + return v; + } + StringBuilder out = new StringBuilder(len - 2); + for (int i = 1; i < len - 1; i++) { + char c = v.charAt(i); + if (c == '\\' && i + 1 < len - 1) { + i++; + c = v.charAt(i); + } + out.append(c); } + return out.toString(); } - private void applyRule(Hashtable theme, MutableResource resources, Rule rule) { + private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts) { if (":root".equals(rule.selector)) { applyRootDeclarations(theme, rule.declarations); return; @@ -228,7 +271,10 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule) { String property = declaration.property; String value = resolveVars(theme, declaration.value); - if (applyThemeConstantProperty(theme, property, value)) { + if (applyThemeConstantProperty(theme, declaration.name, value)) { + continue; + } + if (applyFontProperty(theme, fonts, uiid + "." + statePrefix, property, value)) { continue; } if (applySimpleThemeProperty(theme, uiid, statePrefix, property, value)) { @@ -250,10 +296,10 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule) { private void applyRootDeclarations(Hashtable theme, Declaration[] declarations) { for (Declaration declaration : declarations) { - if (!declaration.property.startsWith("--")) { + if (!declaration.name.startsWith("--")) { continue; } - theme.put("@" + declaration.property.substring(2), resolveVars(theme, declaration.value)); + theme.put("@" + declaration.name.substring(2), unquote(resolveVars(theme, declaration.value))); } } @@ -261,10 +307,197 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str if (!property.startsWith("--")) { return false; } - theme.put("@" + property.substring(2), value); + theme.put("@" + property.substring(2), unquote(value)); + return true; + } + + /// Records a `font-family` or `font-size` for one UIID state and rebuilds that state's font + /// from everything declared for it so far, so the two may come from different rules. + private boolean applyFontProperty(Hashtable theme, Hashtable fonts, String keyPrefix, + String property, String value) { + int slot; + if ("font-family".equals(property)) { + slot = 0; + } else if ("font-size".equals(property)) { + slot = 1; + } else { + return false; + } + String[] spec = fonts.get(keyPrefix); + if (spec == null) { + spec = new String[2]; + fonts.put(keyPrefix, spec); + } + spec[slot] = value; + FontSize size = spec[1] == null ? null : parseFontSize(spec[1]); + // A font is a live platform object, so it can only be made with a running Display. + // Without one -- a tool that only checks that a stylesheet compiles -- the declaration + // has still been validated above. + if (Display.isInitialized()) { + theme.put(keyPrefix + Style.FONT, createFont(spec[0], size)); + } return true; } + private Font createFont(String familyList, FontSize size) { + String ttfName = "native:MainRegular"; + boolean monospace = false; + if (familyList != null) { + String[] families = splitOnComma(familyList); + for (String entry : families) { + String family = unquote(entry); + if (family.startsWith("native:")) { + ttfName = family; + break; + } + if ("monospace".equalsIgnoreCase(family)) { + monospace = true; + break; + } + if (isGenericFamily(family)) { + break; + } + // Any other name has no @font-face here to resolve it, so it is passed over + // the way the build-time compiler passes over a family it cannot find. + } + } + int systemSize = size == null ? Font.SIZE_MEDIUM : size.systemSize; + if (monospace) { + return Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_PLAIN, systemSize); + } + Font fallback = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, systemSize); + float pixels = size == null || size.unit < 0 ? fallback.getHeight() : fontSizeInPixels(size); + Font ttf; + try { + ttf = Font.createTrueTypeFont(ttfName, ttfName); + } catch (RuntimeException err) { + ttf = null; + } + // The same fallback a resource file takes for a truetype font the platform cannot load. + return ttf == null ? fallback : ttf.derive(pixels, Font.STYLE_PLAIN); + } + + private static boolean isGenericFamily(String family) { + String[] generic = {"sans-serif", "serif", "times", "courier", "arial", "cursive", "fantasy", "system-ui"}; + for (String g : generic) { + if (g.equalsIgnoreCase(family)) { + return true; + } + } + return false; + } + + /// Converts the way `Resources` converts a truetype size read from a resource file. + private float fontSizeInPixels(FontSize size) { + switch (size.unit) { + case Style.UNIT_TYPE_DIPS: + return Display.getInstance().convertToPixels((int) (size.value * 10), true) / 10.0f; + case Style.UNIT_TYPE_REM: + return Font.getDefaultFont().getHeight() * size.value; + case Style.UNIT_TYPE_VW: + return CN.getDisplayWidth() * size.value / 100f; + case Style.UNIT_TYPE_VH: + return CN.getDisplayHeight() * size.value / 100f; + case Style.UNIT_TYPE_VMIN: + return Math.min(CN.getDisplayWidth(), CN.getDisplayHeight()) * size.value / 100f; + case Style.UNIT_TYPE_VMAX: + return Math.max(CN.getDisplayWidth(), CN.getDisplayHeight()) * size.value / 100f; + default: + return size.value; + } + } + + /// Parses a `font-size` with the build-time compiler's conversions: `pt`, `cm` and `in` are + /// millimetres, a percentage scales the medium (3mm), small (2mm) or large (4mm) size, and + /// a keyword selects that system font size. + private FontSize parseFontSize(String value) { + String v = value.trim(); + FontSize out = new FontSize(); + if (endsWithIgnoreCase(v, "small") || "smaller".equalsIgnoreCase(v)) { + out.systemSize = Font.SIZE_SMALL; + return out; + } + if (endsWithIgnoreCase(v, "large") || "larger".equalsIgnoreCase(v)) { + out.systemSize = Font.SIZE_LARGE; + return out; + } + if ("medium".equalsIgnoreCase(v)) { + return out; + } + if (v.endsWith("%")) { + out.value = 3f * number(v.substring(0, v.length() - 1), value) / 100f; + out.unit = Style.UNIT_TYPE_DIPS; + return out; + } + // vmin before in, so the longer suffix wins. + String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "vmin", "in", "rem", "vw", "vh", "vmax"}); + if (unit == null) { + throw new CSSSyntaxException("Unsupported font-size value: " + value); + } + float n = number(v.substring(0, v.length() - unit.length()), value); + out.value = n; + if ("px".equals(unit)) { + out.unit = Style.UNIT_TYPE_PIXELS; + } else if ("pt".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + out.value = n / 72f * 25.4f; + } else if ("mm".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + } else if ("cm".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + out.value = n * 10f; + } else if ("in".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + out.value = n * 25.4f; + } else { + out.unit = viewportUnit(unit); + } + return out; + } + + /// The first of `units` that `value` ends with, ignoring case, or null. + private static String unitSuffix(String value, String[] units) { + for (String unit : units) { + if (endsWithIgnoreCase(value, unit)) { + return unit; + } + } + return null; + } + + private static byte viewportUnit(String unit) { + if ("rem".equals(unit)) { + return Style.UNIT_TYPE_REM; + } + if ("vw".equals(unit)) { + return Style.UNIT_TYPE_VW; + } + if ("vh".equals(unit)) { + return Style.UNIT_TYPE_VH; + } + if ("vmin".equals(unit)) { + return Style.UNIT_TYPE_VMIN; + } + return Style.UNIT_TYPE_VMAX; + } + + private static float number(String text, String original) { + String t = text.trim(); + if (t.length() == 0) { + throw new CSSSyntaxException("Missing number in: " + original); + } + try { + return Float.parseFloat(t); + } catch (NumberFormatException err) { + throw new CSSSyntaxException("Invalid number in: " + original, err); + } + } + + private static boolean endsWithIgnoreCase(String value, String suffix) { + int start = value.length() - suffix.length(); + return start >= 0 && value.regionMatches(true, start, suffix, 0, suffix.length()); + } + private boolean applySimpleThemeProperty(Hashtable theme, String uiid, String statePrefix, String property, String value) { if ("color".equals(property)) { theme.put(uiid + "." + statePrefix + "fgColor", normalizeHexColor(value)); @@ -278,7 +511,7 @@ private boolean applySimpleThemeProperty(Hashtable theme, String uiid, String st return true; } if ("padding".equals(property) || "margin".equals(property)) { - theme.put(uiid + "." + statePrefix + property, normalizeBox(value)); + applyBox(theme, uiid + "." + statePrefix, property, value); return true; } if ("cn1-derive".equals(property)) { @@ -597,28 +830,46 @@ private String toHexColor(int color) { return hex; } - private String normalizeBox(String cssValue) { + /// Expands a one to four value `padding`/`margin` from CSS top/right/bottom/left order into + /// the theme's top/bottom/left/right value order, and its unit array, which `Style` indexes + /// by `Component.TOP`, `LEFT`, `BOTTOM`, `RIGHT`. + private void applyBox(Hashtable theme, String keyPrefix, String property, String cssValue) { String[] parts = splitOnWhitespace(cssValue.trim()); - if (parts.length == 1) { - return scalar(parts[0]) + "," + scalar(parts[0]) + "," + scalar(parts[0]) + "," + scalar(parts[0]); - } - if (parts.length == 2) { - return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[0]) + "," + scalar(parts[1]); - } - if (parts.length == 3) { - return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[2]) + "," + scalar(parts[1]); - } - if (parts.length >= 4) { - return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[2]) + "," + scalar(parts[3]); - } - return "0,0,0,0"; + if (parts.length == 0 || parts.length > 4) { + throw new CSSSyntaxException(property + " takes one to four values: " + cssValue); + } + Length top = length(parts[0]); + Length right = parts.length > 1 ? length(parts[1]) : top; + Length bottom = parts.length > 2 ? length(parts[2]) : top; + Length left = parts.length > 3 ? length(parts[3]) : right; + theme.put(keyPrefix + property, top.text + "," + bottom.text + "," + left.text + "," + right.text); + String unitKey = "padding".equals(property) ? Style.PADDING_UNIT : Style.MARGIN_UNIT; + theme.put(keyPrefix + unitKey, new byte[]{top.unit, left.unit, bottom.unit, right.unit}); } - private String scalar(String value) { - String out = value.trim(); - if (out.endsWith("px")) { - out = out.substring(0, out.length() - 2); - } + /// One side of a box. `px`, `pt` and a bare number are pixels rounded to a whole number, + /// which is what the build-time compiler stores: its `pt` is a density independent pixel, + /// not a CSS point. + private Length length(String value) { + String v = value.trim(); + Length out = new Length(); + String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "%", "rem", "vw", "vh", "vmin", "vmax"}); + float n = number(unit == null ? v : v.substring(0, v.length() - unit.length()), value); + if (unit == null || "px".equals(unit) || "pt".equals(unit)) { + out.unit = Style.UNIT_TYPE_PIXELS; + n = Math.round(n); + } else if ("mm".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + } else if ("cm".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + n = n * 10f; + } else if ("%".equals(unit)) { + out.unit = Style.UNIT_TYPE_SCREEN_PERCENTAGE; + } else { + out.unit = viewportUnit(unit); + } + String text = String.valueOf(n); + out.text = text.endsWith(".0") ? text.substring(0, text.length() - 2) : text; return out; } @@ -649,7 +900,12 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext throw new CSSSyntaxException("Missing '}' for CSS rule: " + selectors); } - if (selectors.startsWith("@constants")) { + if (isConstantsSelector(selectors)) { + Rule constants = new Rule(); + constants.selector = selectors; + constants.constants = true; + constants.declarations = parseDeclarations(css.substring(open + 1, close)); + out.add(constants); pos = close + 1; continue; } @@ -760,7 +1016,7 @@ private String stripComments(String css) { private Declaration[] parseDeclarations(String body) { ArrayList out = new ArrayList(); - String[] segments = splitOnChar(body, ';'); + String[] segments = splitDeclarations(body); for (String line : segments) { String trimmed = line.trim(); if (trimmed.length() == 0) { @@ -771,7 +1027,8 @@ private Declaration[] parseDeclarations(String body) { throw new CSSSyntaxException("Malformed declaration: " + trimmed); } Declaration dec = new Declaration(); - dec.property = trimmed.substring(0, colon).trim().toLowerCase(); + dec.name = trimmed.substring(0, colon).trim(); + dec.property = dec.name.toLowerCase(); dec.value = trimmed.substring(colon + 1).trim(); if (dec.property.length() == 0 || dec.value.length() == 0) { throw new CSSSyntaxException("Malformed declaration: " + trimmed); @@ -781,6 +1038,30 @@ private Declaration[] parseDeclarations(String body) { return out.toArray(new Declaration[out.size()]); } + /// Splits a rule body on `;`, except inside a quoted value such as `"a;b"`. + private String[] splitDeclarations(String input) { + ArrayList out = new ArrayList(); + int start = 0; + char quote = 0; + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == ';') { + out.add(input.substring(start, i)); + start = i + 1; + } + } + out.add(input.substring(start)); + return out.toArray(new String[out.size()]); + } + private String[] splitOnChar(String input, char delimiter) { ArrayList out = new ArrayList(); int start = 0; @@ -837,10 +1118,26 @@ private String[] splitOnWhitespace(String input) { private static class Rule { String selector; Declaration[] declarations; + boolean constants; } private static class Declaration { + /// The name as written; constants and custom properties are case sensitive. + String name; + /// The lower case name ordinary CSS properties are matched by. String property; String value; } + + private static class Length { + String text; + byte unit; + } + + private static class FontSize { + /// A `Style.UNIT_TYPE_*`, or -1 for a size given only as a system size. + byte unit = -1; + float value; + int systemSize = Font.SIZE_MEDIUM; + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 8161c0a0f8c..8a42b701fb2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -25,14 +25,20 @@ import com.codename1.junit.UITestBase; import com.codename1.ui.Button; import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Font; import com.codename1.ui.Image; +import com.codename1.ui.Label; import com.codename1.ui.plaf.CSSBorder; +import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.MutableResource; import java.util.Hashtable; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -108,12 +114,13 @@ public void testCompilesThemeConstantsDeriveAndMutableImages() { assertEquals("ffc0cb", theme.get("Button.fgColor")); assertEquals("112233", theme.get("Button.bgColor")); assertEquals("255", theme.get("Button.transparency")); - assertEquals("1,2,1,2", theme.get("Button.padding")); - assertEquals("2,4,6,8", theme.get("Label.margin")); + // Theme storage order is top,bottom,left,right; CSS writes top,right,bottom,left. + assertEquals("1,1,2,2", theme.get("Button.padding")); + assertEquals("2,6,8,4", theme.get("Label.margin")); assertEquals("Label", theme.get("Button.derive")); assertEquals("#abc", theme.get("@primary")); assertEquals("4px", theme.get("@spacing")); - assertEquals("#abc", theme.get("@primarycolor")); + assertEquals("#abc", theme.get("@primaryColor")); assertEquals(Integer.valueOf(Component.CENTER), theme.get("Button.align")); assertTrue(theme.get("Button.border") instanceof CSSBorder); assertEquals("00ff00", theme.get("Button.press#fgColor")); @@ -239,4 +246,155 @@ public void testCompilesSideBorderLonghands() { assertTrue(css.contains("#ff0000ff")); } + @Test + public void testHashConstantsBlockKeepsCaseAndDecodesQuotes() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants {\n" + + " includeNativeBool: true;\n" + + " customMessage: \"Hello\";\n" + + " singleQuotedBool: 'true';\n" + + " withSemicolon: \"a;b\";\n" + + " --accentColor: #ff2d95;\n" + + "}\n" + + "Label{color:var(--accentColor);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("true", theme.get("@includeNativeBool")); + assertFalse(theme.containsKey("@includenativebool")); + assertEquals("Hello", theme.get("@customMessage")); + assertEquals("true", theme.get("@singleQuotedBool")); + assertEquals("a;b", theme.get("@withSemicolon")); + assertEquals("#ff2d95", theme.get("@accentColor")); + assertEquals("ff2d95", theme.get("Label.fgColor")); + // The block is constants only, never a UIID called "#Constants". + for (Object key : theme.keySet()) { + assertFalse(String.valueOf(key).indexOf("Constants") > -1, String.valueOf(key)); + } + } + + @Test + public void testLegacyConstantsBlockStillWorksAndMayFollowItsUse() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{color:var(--tint);}" + + "@constants{ tint: #102030; quoted: \"x y\"; plain: 12; }" + + "@constants{ second: 'two'; }", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("#102030", theme.get("@tint")); + assertEquals("x y", theme.get("@quoted")); + assertEquals("12", theme.get("@plain")); + assertEquals("two", theme.get("@second")); + assertEquals("102030", theme.get("Label.fgColor")); + } + + @Test + public void testRootCustomPropertiesKeepCase() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--mainColor:#123456;}Label{color:var(--mainColor);}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("#123456", theme.get("@mainColor")); + assertEquals("123456", theme.get("Label.fgColor")); + } + + @Test + public void testBoxShorthandForEveryLength() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("One{padding:5px;}" + + "Two{padding:1px 2px;}" + + "Three{padding:1px 2px 3px;}" + + "Four{padding:1px 2px 3px 4px;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("5,5,5,5", theme.get("One.padding")); + assertEquals("1,1,2,2", theme.get("Two.padding")); + assertEquals("1,3,2,2", theme.get("Three.padding")); + assertEquals("1,3,4,2", theme.get("Four.padding")); + byte px = Style.UNIT_TYPE_PIXELS; + assertArrayEquals(new byte[]{px, px, px, px}, (byte[]) theme.get("Four.padUnit")); + } + + @Test + public void testIssueExampleResolvesToTheRightSides() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label {\n padding: 1px 2px 3px 4px;\n margin: 1mm 2mm;\n}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("1,3,4,2", theme.get("Label.padding")); + assertEquals("1,1,2,2", theme.get("Label.margin")); + byte mm = Style.UNIT_TYPE_DIPS; + assertArrayEquals(new byte[]{mm, mm, mm, mm}, (byte[]) theme.get("Label.marUnit")); + + UIManager.getInstance().addThemeProps(theme); + Style s = new Label("x").getUnselectedStyle(); + assertEquals(1f, s.getPaddingFloatValue(false, Component.TOP), 0f); + assertEquals(2f, s.getPaddingFloatValue(false, Component.RIGHT), 0f); + assertEquals(3f, s.getPaddingFloatValue(false, Component.BOTTOM), 0f); + assertEquals(4f, s.getPaddingFloatValue(false, Component.LEFT), 0f); + assertEquals(1f, s.getMarginFloatValue(false, Component.TOP), 0f); + assertEquals(2f, s.getMarginFloatValue(false, Component.RIGHT), 0f); + assertEquals(1f, s.getMarginFloatValue(false, Component.BOTTOM), 0f); + assertEquals(2f, s.getMarginFloatValue(false, Component.LEFT), 0f); + assertEquals(mm, s.getMarginUnit()[Component.LEFT]); + assertEquals(Style.UNIT_TYPE_PIXELS, s.getPaddingUnit()[Component.LEFT]); + } + + @Test + public void testMixedUnitsKeepEachSidesUnit() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Mixed{padding:1mm 10% 2.5pt 3px;}" + + "Mixed:pressed{margin:1.5mm 2cm;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + // top=1mm, bottom=2.5pt (a density independent pixel, rounded), left=3px, right=10%. + assertEquals("1,3,3,10", theme.get("Mixed.padding")); + assertArrayEquals(new byte[]{Style.UNIT_TYPE_DIPS, Style.UNIT_TYPE_PIXELS, + Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_SCREEN_PERCENTAGE}, + (byte[]) theme.get("Mixed.padUnit")); + assertEquals("1.5,1.5,20,20", theme.get("Mixed.press#margin")); + byte mm = Style.UNIT_TYPE_DIPS; + assertArrayEquals(new byte[]{mm, mm, mm, mm}, (byte[]) theme.get("Mixed.press#marUnit")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("Mixed.marUnit")); + } + + @Test + public void testRejectsMalformedBoxValues() { + CSSThemeCompiler compiler = new CSSThemeCompiler(); + MutableResource resource = new MutableResource(); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{padding:1px 2px 3px 4px 5px;}", resource, "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{margin:wide;}", resource, "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{font-size:huge;}", resource, "Theme")); + } + + @Test + public void testFontSizeWithFamilyResolvesToTheRequestedSize() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label {\n font-family: \"native:MainRegular\";\n font-size: 3mm;\n}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + Font font = (Font) theme.get("Label.font"); + assertNotNull(font); + assertTrue(font.isTTFNativeFont()); + assertEquals(Display.getInstance().convertToPixels(30, true) / 10f, font.getPixelSize(), 0.001f); + + UIManager.getInstance().addThemeProps(theme); + assertEquals(font, new Label("x").getUnselectedStyle().getFont()); + } + + @Test + public void testFontSizeUnitsAndStateWithoutFamily() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Sized{font-size:12px;}" + + "Sized:selected{font-size:18pt;}" + + "Split{font-family:'native:MainBold';}" + + "Split{font-size:2mm;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals(12f, ((Font) theme.get("Sized.font")).getPixelSize(), 0.001f); + // 18pt is 6.35mm, the build-time compiler's conversion. + float pt = Display.getInstance().convertToPixels((int) (18f / 72f * 25.4f * 10), true) / 10f; + assertEquals(pt, ((Font) theme.get("Sized.sel#font")).getPixelSize(), 0.001f); + // Family and size declared in two rules for the same UIID still meet. + Font split = (Font) theme.get("Split.font"); + assertEquals(Display.getInstance().convertToPixels(20, true) / 10f, split.getPixelSize(), 0.001f); + } + } diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java new file mode 100644 index 00000000000..f22a3380b79 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import com.codename1.ui.css.CSSThemeCompiler; +import com.codename1.ui.util.MutableResource; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Hashtable; + +/** + * The runtime compiler in core ({@link CSSThemeCompiler}, used for live reload and by + * applications that build a theme on the device) against this one, on the declarations both + * support: the same sheet has to store the same constants, padding, margin and unit metadata. + */ +public class RuntimeCompilerParityTest { + + private static final String CSS = "#Constants {\n" + + " includeNativeBool: true;\n" + + " customMessage: \"Hello\";\n" + + " quotedFlagBool: 'false';\n" + + "}\n" + + "Label { padding: 1px 2px 3px 4px; margin: 1mm 2mm; }\n" + + "One { padding: 5px; margin: 0.5mm; }\n" + + "Three { padding: 1mm 2px 3mm; }\n" + + "Mixed { padding: 1mm 2px 3pt 4mm; margin: 1px 2mm 3px 4mm; }\n" + + "Mixed.pressed { padding: 2mm 1mm; }\n" + + "Mixed.selected { margin: 3pt; }\n"; + + @BeforeAll + static void installHeadlessImplementation() throws Exception { + HeadlessTestSupport.installHeadlessImplementation(); + } + + @Test + void constantsMatch() throws Exception { + Hashtable standard = compileStandard(CSS); + Hashtable runtime = compileRuntime(CSS); + for (String key : new String[]{"@includeNativeBool", "@customMessage", "@quotedFlagBool"}) { + assertEquals(standard.get(key), runtime.get(key), key); + } + assertEquals("true", runtime.get("@includeNativeBool"), "@includeNativeBool"); + assertEquals("Hello", runtime.get("@customMessage"), "@customMessage"); + } + + @Test + void spacingMatches() throws Exception { + Hashtable standard = compileStandard(CSS); + Hashtable runtime = compileRuntime(CSS); + String[] keys = { + "Label.padding", "Label.margin", "Label.padUnit", "Label.marUnit", + "One.padding", "One.margin", "One.padUnit", "One.marUnit", + "Three.padding", "Three.padUnit", + "Mixed.padding", "Mixed.margin", "Mixed.padUnit", "Mixed.marUnit", + "Mixed.press#padding", "Mixed.press#padUnit", + "Mixed.sel#margin", "Mixed.sel#marUnit"}; + for (String key : keys) { + Object expected = standard.get(key); + Object actual = runtime.get(key); + if (expected == null || actual == null) { + throw new AssertionError(key + " standard=" + expected + " runtime=" + actual); + } + if (expected instanceof byte[]) { + if (!Arrays.equals((byte[]) expected, (byte[]) actual)) { + throw new AssertionError(key + " units standard=" + Arrays.toString((byte[]) expected) + + " runtime=" + Arrays.toString((byte[]) actual)); + } + } else if (!Arrays.equals(floats((String) expected), floats((String) actual))) { + throw new AssertionError(key + " standard=" + expected + " runtime=" + actual); + } + } + } + + private static float[] floats(String box) { + String[] parts = box.split(","); + float[] out = new float[parts.length]; + for (int i = 0; i < parts.length; i++) { + out[i] = Float.parseFloat(parts[i].trim()); + } + return out; + } + + private static Hashtable compileRuntime(String css) { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(css, resource, "Theme"); + return resource.getTheme("Theme"); + } + + private static Hashtable compileStandard(String css) throws Exception { + Path cssFile = Files.createTempFile("cn1-parity", ".css"); + Path resFile = Files.createTempFile("cn1-parity", ".res"); + try { + Files.write(cssFile, css.getBytes(StandardCharsets.UTF_8)); + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = resFile.toFile(); + theme.res = new com.codename1.ui.util.EditableResourcesForCSS(resFile.toFile()); + theme.res.setTheme("Theme", new Hashtable()); + theme.updateResources(); + return theme.res.getTheme("Theme"); + } finally { + deleteIfExists(cssFile); + deleteIfExists(resFile); + } + } + + private static void deleteIfExists(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + } + + private static void assertEquals(Object expected, Object actual, String message) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw new AssertionError(message + " expected=" + expected + " actual=" + actual); + } + } +} From 821dc2cb0588589c99b635d1b2a9bc3be6a02aa9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:38:12 +0300 Subject: [PATCH 02/59] CSSThemeCompiler: color and image constants, state fonts inherit the base - Unquoted color literals in a constants block are stored as plain hex, as the build-time compiler stores them; consumers parse them with parseInt(v, 16). - A constant whose name ends in Image resolves to the resource image and a missing one fails the compile; getThemeImageConstant casts to Image, which is unchecked on iOS. - Fonts are built after all rules: a state that sets only family or size takes the other from the base style (and a dark style from the light one). Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 82 ++++++++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 64 ++++++++++++++- .../css/RuntimeCompilerParityTest.java | 8 ++ 3 files changed, 139 insertions(+), 15 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index e93d7c8d452..10fd6a5e51a 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -33,6 +33,7 @@ import com.codename1.ui.util.MutableResource; import java.util.ArrayList; import java.util.Hashtable; +import java.util.Map; /// Compiles a subset of Codename One CSS into theme properties stored in a {@link MutableResource}. /// @@ -96,7 +97,7 @@ public void compile(String css, MutableResource resources, String themeName) { // Constants first, wherever their block sits, so a var() in any rule can see them. for (Rule rule : rules) { if (rule.constants) { - compileConstants(rule.declarations, theme); + compileConstants(rule.declarations, theme, resources); } } Hashtable fonts = new Hashtable(); @@ -105,6 +106,7 @@ public void compile(String css, MutableResource resources, String themeName) { applyRule(theme, resources, rule, fonts); } } + compileFonts(theme, fonts); inheritHoverDerivations(theme); resolveThemeConstantVars(theme); resources.setTheme(themeName, theme); @@ -215,7 +217,7 @@ private void resolveThemeConstantVars(Hashtable theme) { } } - private void compileConstants(Declaration[] declarations, Hashtable theme) { + private void compileConstants(Declaration[] declarations, Hashtable theme, MutableResource resources) { for (Declaration declaration : declarations) { String name = declaration.name; // `#Constants { --accent: ...; }` is how the build-time compiler exports a @@ -223,8 +225,34 @@ private void compileConstants(Declaration[] declarations, Hashtable theme) { if (name.startsWith("--")) { name = name.substring(2); } - theme.put("@" + name, unquote(declaration.value)); + String raw = declaration.value.trim(); + String value = unquote(raw); + if (name.endsWith("Image")) { + // UIManager.getThemeImageConstant casts the constant to Image, and on iOS that + // cast is unchecked: a String left here would be read as an Image and crash + // natively. The build-time compiler fails the build on a missing image too. + Image image = resources.getImage(value); + if (image == null) { + throw new CSSSyntaxException("Constant " + name + " names no image in the resource: " + value); + } + theme.put("@" + name, image); + continue; + } + if (raw.equals(value) && isColorLiteral(value)) { + // Stored as plain hex, as the build-time compiler stores it: color constants are + // read with Integer.parseInt(value, 16), which a leading '#' would break. + value = normalizeHexColor(value); + } + theme.put("@" + name, value); + } + } + + private boolean isColorLiteral(String value) { + if (value.regionMatches(true, 0, "rgb(", 0, 4)) { + return true; } + int len = value.length(); + return value.startsWith("#") && (len == 4 || len == 7) && isHexColor(value.substring(1)); } private static boolean isConstantsSelector(String selector) { @@ -274,7 +302,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha if (applyThemeConstantProperty(theme, declaration.name, value)) { continue; } - if (applyFontProperty(theme, fonts, uiid + "." + statePrefix, property, value)) { + if (applyFontProperty(fonts, uiid + "." + statePrefix, property, value)) { continue; } if (applySimpleThemeProperty(theme, uiid, statePrefix, property, value)) { @@ -311,15 +339,17 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str return true; } - /// Records a `font-family` or `font-size` for one UIID state and rebuilds that state's font - /// from everything declared for it so far, so the two may come from different rules. - private boolean applyFontProperty(Hashtable theme, Hashtable fonts, String keyPrefix, + /// Records a `font-family` or `font-size` for one UIID state; `compileFonts` builds the fonts + /// once every rule has been read, so the two may come from different rules. + private boolean applyFontProperty(Hashtable fonts, String keyPrefix, String property, String value) { int slot; if ("font-family".equals(property)) { slot = 0; } else if ("font-size".equals(property)) { slot = 1; + // Validated here so the error is reported while the rule is being read. + parseFontSize(value); } else { return false; } @@ -329,14 +359,40 @@ private boolean applyFontProperty(Hashtable theme, Hashtable f fonts.put(keyPrefix, spec); } spec[slot] = value; - FontSize size = spec[1] == null ? null : parseFontSize(spec[1]); + return true; + } + + /// Builds the font of every UIID state that declared a font property. A state that sets only + /// one of family and size takes the other from the unselected style, since both rules apply + /// in that state -- the build-time compiler flattens the base style into each state the + /// same way. A dark style falls back to the light style of the same UIID after that. + private void compileFonts(Hashtable theme, Hashtable fonts) { // A font is a live platform object, so it can only be made with a running Display. - // Without one -- a tool that only checks that a stylesheet compiles -- the declaration - // has still been validated above. - if (Display.isInitialized()) { - theme.put(keyPrefix + Style.FONT, createFont(spec[0], size)); + // Without one -- a tool that only checks that a stylesheet compiles -- every + // declaration has still been validated as it was read. + if (!Display.isInitialized()) { + return; + } + for (Map.Entry entry : fonts.entrySet()) { + String keyPrefix = entry.getKey(); + String[] spec = entry.getValue(); + String family = spec[0]; + String size = spec[1]; + int dot = keyPrefix.indexOf('.'); + String uiid = keyPrefix.substring(0, dot); + String state = keyPrefix.substring(dot + 1); + String[] fallbacks = uiid.startsWith("$Dark") + ? new String[]{uiid + ".", uiid.substring(5) + "." + state, uiid.substring(5) + "."} + : new String[]{uiid + "."}; + for (String fallback : fallbacks) { + String[] other = fonts.get(fallback); + if (other != null) { + family = family == null ? other[0] : family; + size = size == null ? other[1] : size; + } + } + theme.put(keyPrefix + Style.FONT, createFont(family, size == null ? null : parseFontSize(size))); } - return true; } private Font createFont(String familyList, FontSize size) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 8a42b701fb2..d4fa4c09f59 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -263,7 +263,8 @@ public void testHashConstantsBlockKeepsCaseAndDecodesQuotes() { assertEquals("Hello", theme.get("@customMessage")); assertEquals("true", theme.get("@singleQuotedBool")); assertEquals("a;b", theme.get("@withSemicolon")); - assertEquals("#ff2d95", theme.get("@accentColor")); + // Plain hex, as the build-time compiler stores a color constant. + assertEquals("ff2d95", theme.get("@accentColor")); assertEquals("ff2d95", theme.get("Label.fgColor")); // The block is constants only, never a UIID called "#Constants". for (Object key : theme.keySet()) { @@ -278,7 +279,7 @@ public void testLegacyConstantsBlockStillWorksAndMayFollowItsUse() { + "@constants{ tint: #102030; quoted: \"x y\"; plain: 12; }" + "@constants{ second: 'two'; }", resource, "Theme"); Hashtable theme = resource.getTheme("Theme"); - assertEquals("#102030", theme.get("@tint")); + assertEquals("102030", theme.get("@tint")); assertEquals("x y", theme.get("@quoted")); assertEquals("12", theme.get("@plain")); assertEquals("two", theme.get("@second")); @@ -397,4 +398,63 @@ public void testFontSizeUnitsAndStateWithoutFamily() { assertEquals(Display.getInstance().convertToPixels(20, true) / 10f, split.getPixelSize(), 0.001f); } + @Test + public void testColorConstantsAreStoredAsPlainHex() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ infiniteDefaultColor: #f00; rgbColor: rgb(1, 2, 3);" + + " quotedHash: \"#abc\"; word: red; }", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@infiniteDefaultColor")); + assertEquals(0xff0000, Integer.parseInt((String) theme.get("@infiniteDefaultColor"), 16)); + assertEquals("010203", theme.get("@rgbColor")); + // A quoted value is a string, not a color. + assertEquals("#abc", theme.get("@quotedHash")); + assertEquals("red", theme.get("@word")); + } + + @Test + public void testImageConstantsResolveToTheResourceImage() { + MutableResource resource = new MutableResource(); + Image spinner = Image.createImage(4, 4, 0xff00ff00); + resource.setImage("spinner.png", spinner); + new CSSThemeCompiler().compile("#Constants{ infiniteImage: spinner.png; }", resource, "Theme"); + assertTrue(resource.getTheme("Theme").get("@infiniteImage") instanceof Image); + UIManager.getInstance().addThemeProps(resource.getTheme("Theme")); + assertNotNull(UIManager.getInstance().getThemeImageConstant("infiniteImage")); + + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ missingImage: nothing.png; }", new MutableResource(), "Theme")); + } + + @Test + public void testStateFontInheritsTheUndeclaredHalfFromTheBase() { + MutableResource resource = new MutableResource(); + // The state rule comes first on purpose: the merge must not depend on rule order. + new CSSThemeCompiler().compile("Label:selected{font-size:20px;}" + + "Label{font-family:'native:MainBold';font-size:2mm;}" + + "Label:pressed{font-family:'native:MainLight';}" + + "@media (prefers-color-scheme: dark){ Label:disabled{color:#ffffff;font-size:9px;} }", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + Font selected = (Font) theme.get("Label.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + assertEquals("native:MainBold", fontName(selected)); + Font pressed = (Font) theme.get("Label.press#font"); + assertEquals(Display.getInstance().convertToPixels(20, true) / 10f, pressed.getPixelSize(), 0.001f); + assertEquals("native:MainLight", fontName(pressed)); + Font dark = (Font) theme.get("$DarkLabel.dis#font"); + assertEquals(9f, dark.getPixelSize(), 0.001f); + assertEquals("native:MainBold", fontName(dark)); + } + + private static String fontName(Font font) { + try { + java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); + id.setAccessible(true); + return (String) id.get(font); + } catch (ReflectiveOperationException err) { + throw new AssertionError(err); + } + } + } diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java index f22a3380b79..ec5e80f7474 100644 --- a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java @@ -45,6 +45,8 @@ public class RuntimeCompilerParityTest { + " includeNativeBool: true;\n" + " customMessage: \"Hello\";\n" + " quotedFlagBool: 'false';\n" + + " accentColor: #ff2d95;\n" + + " shortColor: #abc;\n" + "}\n" + "Label { padding: 1px 2px 3px 4px; margin: 1mm 2mm; }\n" + "One { padding: 5px; margin: 0.5mm; }\n" @@ -65,6 +67,12 @@ void constantsMatch() throws Exception { for (String key : new String[]{"@includeNativeBool", "@customMessage", "@quotedFlagBool"}) { assertEquals(standard.get(key), runtime.get(key), key); } + // Hex case differs between the compilers for every color, not just constants (the + // build-time one writes upper case); both are read with Integer.parseInt(value, 16). + for (String key : new String[]{"@accentColor", "@shortColor"}) { + assertEquals(Integer.valueOf(Integer.parseInt((String) standard.get(key), 16)), + Integer.valueOf(Integer.parseInt((String) runtime.get(key), 16)), key); + } assertEquals("true", runtime.get("@includeNativeBool"), "@includeNativeBool"); assertEquals("Hello", runtime.get("@customMessage"), "@customMessage"); } From bc3cbf4a2dca0c3aef28140c63968008431c8d3f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:01:25 +0300 Subject: [PATCH 03/59] CSSThemeCompiler: constants in source order, var() color constants, derive-aware state fonts - Constants blocks are applied again in source order, so a later declaration wins against :root and in-rule custom properties; the first pass still makes them visible to rules above the block. - A constant written as var(--x) is normalized to plain hex once resolved if it is a color. - A state font missing family or size completes it from the UIID's base style and then each cn1-derive parent's matching state and base, as the build-time compiler's flattening does. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 63 +++++++++++++------ .../ui/css/CSSThemeCompilerTest.java | 52 ++++++++++++++- 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 10fd6a5e51a..3be8ea2ea9f 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -94,21 +94,33 @@ public void compile(String css, MutableResource resources, String themeName) { String strippedCss = stripComments(css); Rule[] rules = parseRulesWithMedia(strippedCss); - // Constants first, wherever their block sits, so a var() in any rule can see them. + ArrayList varConstants = new ArrayList(); + // Constants first, wherever their block sits, so a var() in any rule can see them... for (Rule rule : rules) { if (rule.constants) { - compileConstants(rule.declarations, theme, resources); + compileConstants(rule.declarations, theme, resources, varConstants); } } + // ...and then again in source order, so the last declaration of a name still wins + // against a `:root` or in-rule custom property, as it does in the build-time compiler. Hashtable fonts = new Hashtable(); for (Rule rule : rules) { - if (!rule.constants) { + if (rule.constants) { + compileConstants(rule.declarations, theme, resources, varConstants); + } else { applyRule(theme, resources, rule, fonts); } } compileFonts(theme, fonts); inheritHoverDerivations(theme); resolveThemeConstantVars(theme); + // A constant written as var(--x) is only a color once x is resolved. + for (String key : varConstants) { + Object value = theme.get(key); + if (value instanceof String && isColorLiteral((String) value)) { + theme.put(key, normalizeHexColor((String) value)); + } + } resources.setTheme(themeName, theme); } @@ -217,7 +229,8 @@ private void resolveThemeConstantVars(Hashtable theme) { } } - private void compileConstants(Declaration[] declarations, Hashtable theme, MutableResource resources) { + private void compileConstants(Declaration[] declarations, Hashtable theme, MutableResource resources, + ArrayList varConstants) { for (Declaration declaration : declarations) { String name = declaration.name; // `#Constants { --accent: ...; }` is how the build-time compiler exports a @@ -238,10 +251,14 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, Mutab theme.put("@" + name, image); continue; } - if (raw.equals(value) && isColorLiteral(value)) { - // Stored as plain hex, as the build-time compiler stores it: color constants are - // read with Integer.parseInt(value, 16), which a leading '#' would break. - value = normalizeHexColor(value); + if (raw.equals(value)) { + if (isColorLiteral(value)) { + // Stored as plain hex, as the build-time compiler stores it: color constants + // are read with Integer.parseInt(value, 16), which a leading '#' would break. + value = normalizeHexColor(value); + } else if (value.indexOf("var(--") > -1) { + varConstants.add("@" + name); + } } theme.put("@" + name, value); } @@ -363,9 +380,10 @@ private boolean applyFontProperty(Hashtable fonts, String keyP } /// Builds the font of every UIID state that declared a font property. A state that sets only - /// one of family and size takes the other from the unselected style, since both rules apply - /// in that state -- the build-time compiler flattens the base style into each state the - /// same way. A dark style falls back to the light style of the same UIID after that. + /// one of family and size takes the other from what applies in that state, as the build-time + /// compiler's flattening does: the UIID's unselected style, then each `cn1-derive` parent's + /// matching state and unselected style in turn. A dark style looks at the dark keys of each + /// UIID before the light ones. private void compileFonts(Hashtable theme, Hashtable fonts) { // A font is a live platform object, so it can only be made with a running Display. // Without one -- a tool that only checks that a stylesheet compiles -- every @@ -381,15 +399,22 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { int dot = keyPrefix.indexOf('.'); String uiid = keyPrefix.substring(0, dot); String state = keyPrefix.substring(dot + 1); - String[] fallbacks = uiid.startsWith("$Dark") - ? new String[]{uiid + ".", uiid.substring(5) + "." + state, uiid.substring(5) + "."} - : new String[]{uiid + "."}; - for (String fallback : fallbacks) { - String[] other = fonts.get(fallback); - if (other != null) { - family = family == null ? other[0] : family; - size = size == null ? other[1] : size; + boolean dark = uiid.startsWith("$Dark"); + String id = dark ? uiid.substring(5) : uiid; + ArrayList seen = new ArrayList(); + while (id != null && !seen.contains(id) && (family == null || size == null)) { + seen.add(id); + String[] fallbacks = dark + ? new String[]{"$Dark" + id + "." + state, "$Dark" + id + ".", id + "." + state, id + "."} + : new String[]{id + "." + state, id + "."}; + for (String fallback : fallbacks) { + String[] other = fonts.get(fallback); + if (other != null) { + family = family == null ? other[0] : family; + size = size == null ? other[1] : size; + } } + id = hoverBase(theme, id, dark); } theme.put(keyPrefix + Style.FONT, createFont(family, size == null ? null : parseFontSize(size))); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index d4fa4c09f59..e5a1254111c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -120,7 +120,8 @@ public void testCompilesThemeConstantsDeriveAndMutableImages() { assertEquals("Label", theme.get("Button.derive")); assertEquals("#abc", theme.get("@primary")); assertEquals("4px", theme.get("@spacing")); - assertEquals("#abc", theme.get("@primaryColor")); + // A constant resolving to a color is stored as plain hex; :root's own stays as written. + assertEquals("aabbcc", theme.get("@primaryColor")); assertEquals(Integer.valueOf(Component.CENTER), theme.get("Button.align")); assertTrue(theme.get("Button.border") instanceof CSSBorder); assertEquals("00ff00", theme.get("Button.press#fgColor")); @@ -447,6 +448,55 @@ public void testStateFontInheritsTheUndeclaredHalfFromTheBase() { assertEquals("native:MainBold", fontName(dark)); } + @Test + public void testColorConstantFromVarIsStoredAsPlainHex() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--c:#f00;}" + + "#Constants{infiniteDefaultColor:var(--c); label:var(--name);}" + + ":root{--name:hello;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@infiniteDefaultColor")); + assertEquals("hello", theme.get("@label")); + } + + @Test + public void testLaterDeclarationWinsBetweenRootAndConstants() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--accent:#00ff00;} #Constants{--accent:#ff0000;}" + + "Label{color:var(--accent);}", resource, "Theme"); + assertEquals("ff0000", resource.getTheme("Theme").get("@accent")); + assertEquals("ff0000", resource.getTheme("Theme").get("Label.fgColor")); + + resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#ff0000;} :root{--accent:#00ff00;}" + + "Label{color:var(--accent);}", resource, "Theme"); + assertEquals("#00ff00", resource.getTheme("Theme").get("@accent")); + // A constant declared after the rule that uses it is still seen by that rule. + resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{color:var(--late);} #Constants{late:#0000ff;}", resource, "Theme"); + assertEquals("0000ff", resource.getTheme("Theme").get("Label.fgColor")); + } + + @Test + public void testStateFontFollowsTheDeriveChain() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Child:selected{font-size:20px;}" + + "Child{cn1-derive:Middle;}" + + "Middle{cn1-derive:Base;}" + + "Base:selected{font-family:'native:MainBold';}" + + "Base{font-family:'native:MainLight';font-size:2mm;}" + + "Cyc1{cn1-derive:Cyc2;} Cyc2{cn1-derive:Cyc1;} Cyc1:pressed{font-size:7px;}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + Font selected = (Font) theme.get("Child.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + // Base's selected family wins over Base's unselected one: same state first. + assertEquals("native:MainBold", fontName(selected)); + Font cyclic = (Font) theme.get("Cyc1.press#font"); + assertEquals(7f, cyclic.getPixelSize(), 0.001f); + assertEquals("native:MainRegular", fontName(cyclic)); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); From 51a606ec92a620cfe8569830ef4c5c2bc896d701 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:20:34 +0300 Subject: [PATCH 04/59] CSSThemeCompiler: resolve var() before looking up an image constant An image constant written as var(--x) is looked up once variables are resolved, so a custom property declared after the constants block (or in :root) no longer fails the compile with an unresolved image name. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 34 ++++++++++++------- .../ui/css/CSSThemeCompilerTest.java | 11 ++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 3be8ea2ea9f..3f6c75d44b2 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -114,11 +114,17 @@ public void compile(String css, MutableResource resources, String themeName) { compileFonts(theme, fonts); inheritHoverDerivations(theme); resolveThemeConstantVars(theme); - // A constant written as var(--x) is only a color once x is resolved. + // A constant written as var(--x) is only an image or a color once x is resolved. for (String key : varConstants) { Object value = theme.get(key); - if (value instanceof String && isColorLiteral((String) value)) { - theme.put(key, normalizeHexColor((String) value)); + if (!(value instanceof String)) { + continue; + } + String resolved = unquote((String) value); + if (key.endsWith("Image")) { + theme.put(key, imageConstant(resources, key.substring(1), resolved)); + } else if (isColorLiteral(resolved)) { + theme.put(key, normalizeHexColor(resolved)); } } resources.setTheme(themeName, theme); @@ -240,15 +246,8 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, Mutab } String raw = declaration.value.trim(); String value = unquote(raw); - if (name.endsWith("Image")) { - // UIManager.getThemeImageConstant casts the constant to Image, and on iOS that - // cast is unchecked: a String left here would be read as an Image and crash - // natively. The build-time compiler fails the build on a missing image too. - Image image = resources.getImage(value); - if (image == null) { - throw new CSSSyntaxException("Constant " + name + " names no image in the resource: " + value); - } - theme.put("@" + name, image); + if (name.endsWith("Image") && value.indexOf("var(--") < 0) { + theme.put("@" + name, imageConstant(resources, name, value)); continue; } if (raw.equals(value)) { @@ -264,6 +263,17 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, Mutab } } + /// UIManager.getThemeImageConstant casts an image constant to Image, and on iOS that cast is + /// unchecked: a String left in its place would be read as an Image and crash natively. The + /// build-time compiler fails the build on a missing image too. + private static Image imageConstant(MutableResource resources, String name, String imageName) { + Image image = resources.getImage(imageName); + if (image == null) { + throw new CSSSyntaxException("Constant " + name + " names no image in the resource: " + imageName); + } + return image; + } + private boolean isColorLiteral(String value) { if (value.regionMatches(true, 0, "rgb(", 0, 4)) { return true; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index e5a1254111c..f47da0d3b29 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -425,6 +425,17 @@ public void testImageConstantsResolveToTheResourceImage() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() .compile("#Constants{ missingImage: nothing.png; }", new MutableResource(), "Theme")); + + // Through a custom property declared after the constants block, quoted or not. + MutableResource viaVar = new MutableResource(); + viaVar.setImage("spinner.png", spinner); + new CSSThemeCompiler().compile("#Constants{ infiniteImage: var(--spinner); quotedImage: var(--q); }" + + ":root{ --spinner: spinner.png; --q: \"spinner.png\"; }", viaVar, "Theme"); + assertTrue(viaVar.getTheme("Theme").get("@infiniteImage") == spinner); + assertTrue(viaVar.getTheme("Theme").get("@quotedImage") == spinner); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ goneImage: var(--x); } :root{ --x: nothing.png; }", + new MutableResource(), "Theme")); } @Test From 3435ab5e94c67d1308d961ff36132b70d45361e0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:58:52 +0300 Subject: [PATCH 05/59] CSSThemeCompiler: defer image constants past rules, quote-aware rule braces, state-first font fallback Image constants are looked up after every rule ran, so a cn1-mutable-image can supply one. Only the winning declaration of a constant decides whether it is reinterpreted as an image or a var() color. A quoted brace no longer ends a rule. A state font searches the matching state along the derive chain before any unselected style. The GUI builder normalizer keeps the units the compiler already stored instead of resetting them to px. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 114 +++++++++++------- .../ui/css/CSSThemeCompilerTest.java | 38 ++++++ .../guibuilder/CodenameOneGUIBuilder.java | 6 +- 3 files changed, 114 insertions(+), 44 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 3f6c75d44b2..dc44b002b81 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -86,6 +86,9 @@ public CSSSyntaxException(String message, Throwable cause) { } } + private static final String DEFERRED_IMAGE = "image"; + private static final String DEFERRED_VAR = "var"; + public void compile(String css, MutableResource resources, String themeName) { Hashtable theme = resources.getTheme(themeName); if (theme == null) { @@ -94,11 +97,14 @@ public void compile(String css, MutableResource resources, String themeName) { String strippedCss = stripComments(css); Rule[] rules = parseRulesWithMedia(strippedCss); - ArrayList varConstants = new ArrayList(); + // Constants whose final value is only known after every rule ran: an image constant, + // whose image a rule may still create (cn1-mutable-image), and one written as var(--x). + // Keyed by "@name"; only the declaration that wins for a name decides its entry. + Hashtable deferred = new Hashtable(); // Constants first, wherever their block sits, so a var() in any rule can see them... for (Rule rule : rules) { if (rule.constants) { - compileConstants(rule.declarations, theme, resources, varConstants); + compileConstants(rule.declarations, theme, deferred); } } // ...and then again in source order, so the last declaration of a name still wins @@ -106,24 +112,25 @@ public void compile(String css, MutableResource resources, String themeName) { Hashtable fonts = new Hashtable(); for (Rule rule : rules) { if (rule.constants) { - compileConstants(rule.declarations, theme, resources, varConstants); + compileConstants(rule.declarations, theme, deferred); } else { - applyRule(theme, resources, rule, fonts); + applyRule(theme, resources, rule, fonts, deferred); } } compileFonts(theme, fonts); inheritHoverDerivations(theme); resolveThemeConstantVars(theme); - // A constant written as var(--x) is only an image or a color once x is resolved. - for (String key : varConstants) { + for (Map.Entry entry : deferred.entrySet()) { + String key = entry.getKey(); Object value = theme.get(key); if (!(value instanceof String)) { continue; } String resolved = unquote((String) value); - if (key.endsWith("Image")) { + if (DEFERRED_IMAGE.equals(entry.getValue())) { theme.put(key, imageConstant(resources, key.substring(1), resolved)); } else if (isColorLiteral(resolved)) { + // A constant written as var(--x) is only a color once x is resolved. theme.put(key, normalizeHexColor(resolved)); } } @@ -235,8 +242,8 @@ private void resolveThemeConstantVars(Hashtable theme) { } } - private void compileConstants(Declaration[] declarations, Hashtable theme, MutableResource resources, - ArrayList varConstants) { + private void compileConstants(Declaration[] declarations, Hashtable theme, + Hashtable deferred) { for (Declaration declaration : declarations) { String name = declaration.name; // `#Constants { --accent: ...; }` is how the build-time compiler exports a @@ -244,22 +251,22 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, Mutab if (name.startsWith("--")) { name = name.substring(2); } + String key = "@" + name; String raw = declaration.value.trim(); String value = unquote(raw); - if (name.endsWith("Image") && value.indexOf("var(--") < 0) { - theme.put("@" + name, imageConstant(resources, name, value)); - continue; - } - if (raw.equals(value)) { + deferred.remove(key); + if (name.endsWith("Image")) { + deferred.put(key, DEFERRED_IMAGE); + } else if (raw.equals(value)) { if (isColorLiteral(value)) { // Stored as plain hex, as the build-time compiler stores it: color constants // are read with Integer.parseInt(value, 16), which a leading '#' would break. value = normalizeHexColor(value); } else if (value.indexOf("var(--") > -1) { - varConstants.add("@" + name); + deferred.put(key, DEFERRED_VAR); } } - theme.put("@" + name, value); + theme.put(key, value); } } @@ -310,9 +317,10 @@ private static String unquote(String value) { return out.toString(); } - private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts) { + private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts, + Hashtable deferred) { if (":root".equals(rule.selector)) { - applyRootDeclarations(theme, rule.declarations); + applyRootDeclarations(theme, rule.declarations, deferred); return; } @@ -326,7 +334,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha String property = declaration.property; String value = resolveVars(theme, declaration.value); - if (applyThemeConstantProperty(theme, declaration.name, value)) { + if (applyThemeConstantProperty(theme, declaration.name, value, deferred)) { continue; } if (applyFontProperty(fonts, uiid + "." + statePrefix, property, value)) { @@ -349,20 +357,21 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha theme.put(uiid + "." + statePrefix + "border", new CSSBorder(null, borderCss.toString())); } - private void applyRootDeclarations(Hashtable theme, Declaration[] declarations) { + private void applyRootDeclarations(Hashtable theme, Declaration[] declarations, Hashtable deferred) { for (Declaration declaration : declarations) { - if (!declaration.name.startsWith("--")) { - continue; - } - theme.put("@" + declaration.name.substring(2), unquote(resolveVars(theme, declaration.value))); + applyThemeConstantProperty(theme, declaration.name, resolveVars(theme, declaration.value), deferred); } } - private boolean applyThemeConstantProperty(Hashtable theme, String property, String value) { + private boolean applyThemeConstantProperty(Hashtable theme, String property, String value, + Hashtable deferred) { if (!property.startsWith("--")) { return false; } - theme.put("@" + property.substring(2), unquote(value)); + String key = "@" + property.substring(2); + // A later custom property replaces a #Constants entry outright, deferred handling included. + deferred.remove(key); + theme.put(key, unquote(value)); return true; } @@ -391,9 +400,9 @@ private boolean applyFontProperty(Hashtable fonts, String keyP /// Builds the font of every UIID state that declared a font property. A state that sets only /// one of family and size takes the other from what applies in that state, as the build-time - /// compiler's flattening does: the UIID's unselected style, then each `cn1-derive` parent's - /// matching state and unselected style in turn. A dark style looks at the dark keys of each - /// UIID before the light ones. + /// compiler's flattening does: the matching state of the UIID and each `cn1-derive` parent in + /// turn, then their unselected styles in the same order. A dark style looks at the dark keys + /// of each UIID before the light ones. private void compileFonts(Hashtable theme, Hashtable fonts) { // A font is a live platform object, so it can only be made with a running Display. // Without one -- a tool that only checks that a stylesheet compiles -- every @@ -410,21 +419,30 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { String uiid = keyPrefix.substring(0, dot); String state = keyPrefix.substring(dot + 1); boolean dark = uiid.startsWith("$Dark"); - String id = dark ? uiid.substring(5) : uiid; - ArrayList seen = new ArrayList(); - while (id != null && !seen.contains(id) && (family == null || size == null)) { - seen.add(id); - String[] fallbacks = dark - ? new String[]{"$Dark" + id + "." + state, "$Dark" + id + ".", id + "." + state, id + "."} - : new String[]{id + "." + state, id + "."}; - for (String fallback : fallbacks) { - String[] other = fonts.get(fallback); - if (other != null) { - family = family == null ? other[0] : family; - size = size == null ? other[1] : size; + // The matching state along the whole derive chain comes before any unselected + // style: the build-time flattener lays a parent's selected style over the child's + // unselected one, so `Base:selected` beats `Child`. + for (int pass = 0; pass < 2 && (family == null || size == null); pass++) { + String lookup = pass == 0 ? state : ""; + if (pass == 1 && state.length() == 0) { + break; + } + String id = dark ? uiid.substring(5) : uiid; + ArrayList seen = new ArrayList(); + while (id != null && !seen.contains(id) && (family == null || size == null)) { + seen.add(id); + String[] fallbacks = dark + ? new String[]{"$Dark" + id + "." + lookup, id + "." + lookup} + : new String[]{id + "." + lookup}; + for (String fallback : fallbacks) { + String[] other = fonts.get(fallback); + if (other != null) { + family = family == null ? other[0] : family; + size = size == null ? other[1] : size; + } } + id = hoverBase(theme, id, dark); } - id = hoverBase(theme, id, dark); } theme.put(keyPrefix + Style.FONT, createFont(family, size == null ? null : parseFontSize(size))); } @@ -1032,9 +1050,19 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext private int findMatchingBrace(String css, int openPos) { int depth = 0; int len = css.length(); + char quote = 0; for (int i = openPos; i < len; i++) { char c = css.charAt(i); - if (c == '{') { + // A brace inside a quoted value, such as a #Constants string, is text. + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '{') { depth++; } else if (c == '}') { depth--; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index f47da0d3b29..407f3f9f876 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -508,6 +508,44 @@ public void testStateFontFollowsTheDeriveChain() { assertEquals("native:MainRegular", fontName(cyclic)); } + @Test + public void testImageConstantMaySeeAnImageARuleCreates() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Maker{cn1-mutable-image:spinner #ffffff;}" + + "#Constants{infiniteImage:spinner;}", resource, "Theme"); + assertTrue(resource.getTheme("Theme").get("@infiniteImage") == resource.getImage("spinner")); + } + + @Test + public void testOnlyTheWinningConstantDeclarationIsReinterpreted() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--x:#00ff00;}" + + "#Constants{token:var(--x);} #Constants{token:\"#abc\";}", resource, "Theme"); + assertEquals("#abc", resource.getTheme("Theme").get("@token")); + } + + @Test + public void testQuotedBraceDoesNotEndTheConstantsBlock() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{customMessage:\"a}b\"; enabled:true;}" + + "Label{color:#ff0000;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("a}b", theme.get("@customMessage")); + assertEquals("true", theme.get("@enabled")); + assertEquals("ff0000", theme.get("Label.fgColor")); + } + + @Test + public void testDerivedStateFontBeatsTheChildsUnselectedFont() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Child{cn1-derive:Base;font-family:'native:MainLight';}" + + "Base:selected{font-family:'native:MainBold';}" + + "Child:selected{font-size:20px;}", resource, "Theme"); + Font selected = (Font) resource.getTheme("Theme").get("Child.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + assertEquals("native:MainBold", fontName(selected)); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index a4638953f1d..c7cf0135709 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5783,6 +5783,11 @@ private void normalizeCompiledTheme(Hashtable theme) { boolean padding = key.endsWith("." + Style.PADDING); boolean margin = key.endsWith("." + Style.MARGIN); if ((!padding && !margin) || !(raw instanceof String)) continue; + String unitKey = key.substring(0, key.length() - (padding ? Style.PADDING : Style.MARGIN).length()) + + (padding ? Style.PADDING_UNIT : Style.MARGIN_UNIT); + // CSSThemeCompiler already stores plain numbers with their units beside them, per + // state; re-deriving the units from the now suffix-free text would reset them to px. + if (theme.get(unitKey) instanceof byte[]) continue; String[] values = ((String) raw).split(","); if (values.length != 4) continue; byte[] units = new byte[4]; @@ -5798,7 +5803,6 @@ private void normalizeCompiledTheme(Hashtable theme) { normalized.append(item.length() == 0 ? "0" : item); } theme.put(keyObject, normalized.toString()); - String unitKey = key.substring(0, key.lastIndexOf('.') + 1) + (padding ? Style.PADDING_UNIT : Style.MARGIN_UNIT); theme.put(unitKey, units); } } From 6df549f64ac1f1ecef7a961ee5c05b94a0f7fe96 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:04:14 +0300 Subject: [PATCH 06/59] CSSThemeCompiler: rgba() and quoted constants, quote-aware comments, cyclic vars, * font fallback rgba() color constants are stored as their RGB hex. A quoted constant is a string: its var() text is not resolved, and comment delimiters inside quotes are text. A cyclic var() reference is a syntax error instead of a compile that never returns. A state font falls back to the * style after the derive chain. The GUI builder test that used font-size as its example of an unsupported declaration now uses font-style, since the canvas compiler supports font-size. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 133 +++++++++++++----- .../ui/css/CSSThemeCompilerTest.java | 58 ++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 2 +- .../guibuilder/GeneratedSourceTest.java | 10 +- 4 files changed, 162 insertions(+), 41 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index dc44b002b81..d4d80e50d8e 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -88,6 +88,7 @@ public CSSSyntaxException(String message, Throwable cause) { private static final String DEFERRED_IMAGE = "image"; private static final String DEFERRED_VAR = "var"; + private static final String DEFERRED_LITERAL = "literal"; public void compile(String css, MutableResource resources, String themeName) { Hashtable theme = resources.getTheme(themeName); @@ -119,7 +120,7 @@ public void compile(String css, MutableResource resources, String themeName) { } compileFonts(theme, fonts); inheritHoverDerivations(theme); - resolveThemeConstantVars(theme); + resolveThemeConstantVars(theme, deferred); for (Map.Entry entry : deferred.entrySet()) { String key = entry.getKey(); Object value = theme.get(key); @@ -129,9 +130,9 @@ public void compile(String css, MutableResource resources, String themeName) { String resolved = unquote((String) value); if (DEFERRED_IMAGE.equals(entry.getValue())) { theme.put(key, imageConstant(resources, key.substring(1), resolved)); - } else if (isColorLiteral(resolved)) { + } else if (DEFERRED_VAR.equals(entry.getValue()) && isColorLiteral(resolved)) { // A constant written as var(--x) is only a color once x is resolved. - theme.put(key, normalizeHexColor(resolved)); + theme.put(key, constantColor(resolved)); } } resources.setTheme(themeName, theme); @@ -228,10 +229,11 @@ private boolean hasHoverDefinition(Hashtable theme, String id) { return false; } - private void resolveThemeConstantVars(Hashtable theme) { + private void resolveThemeConstantVars(Hashtable theme, Hashtable deferred) { for (Object keyObj : theme.keySet()) { String key = String.valueOf(keyObj); - if (!key.startsWith("@")) { + // A quoted value is a string: `"var(--name)"` is text, not a reference. + if (!key.startsWith("@") || DEFERRED_LITERAL.equals(deferred.get(key))) { continue; } Object value = theme.get(key); @@ -257,14 +259,14 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, deferred.remove(key); if (name.endsWith("Image")) { deferred.put(key, DEFERRED_IMAGE); - } else if (raw.equals(value)) { - if (isColorLiteral(value)) { - // Stored as plain hex, as the build-time compiler stores it: color constants - // are read with Integer.parseInt(value, 16), which a leading '#' would break. - value = normalizeHexColor(value); - } else if (value.indexOf("var(--") > -1) { - deferred.put(key, DEFERRED_VAR); - } + } else if (!raw.equals(value)) { + deferred.put(key, DEFERRED_LITERAL); + } else if (isColorLiteral(value)) { + // Stored as plain hex, as the build-time compiler stores it: color constants + // are read with Integer.parseInt(value, 16), which a leading '#' would break. + value = constantColor(value); + } else if (value.indexOf("var(--") > -1) { + deferred.put(key, DEFERRED_VAR); } theme.put(key, value); } @@ -282,13 +284,26 @@ private static Image imageConstant(MutableResource resources, String name, Strin } private boolean isColorLiteral(String value) { - if (value.regionMatches(true, 0, "rgb(", 0, 4)) { + if (value.regionMatches(true, 0, "rgb(", 0, 4) || value.regionMatches(true, 0, "rgba(", 0, 5)) { return true; } int len = value.length(); return value.startsWith("#") && (len == 4 || len == 7) && isHexColor(value.substring(1)); } + /// A color constant as plain hex. A color constant has no alpha, so `rgba()` keeps its RGB + /// part, as the build-time compiler stores it. + private String constantColor(String value) { + if (value.regionMatches(true, 0, "rgba(", 0, 5) && value.endsWith(")")) { + String[] parts = splitOnComma(value.substring(5, value.length() - 1)); + if (parts.length != 4) { + throw new CSSSyntaxException("rgba() must have exactly 4 components: " + value); + } + return normalizeHexColor("rgb(" + parts[0] + "," + parts[1] + "," + parts[2] + ")"); + } + return normalizeHexColor(value); + } + private static boolean isConstantsSelector(String selector) { return "@constants".equalsIgnoreCase(selector) || "#Constants".equalsIgnoreCase(selector); } @@ -332,11 +347,10 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha for (int i = 0; i < rule.declarations.length; i++) { Declaration declaration = rule.declarations[i]; String property = declaration.property; - String value = resolveVars(theme, declaration.value); - - if (applyThemeConstantProperty(theme, declaration.name, value, deferred)) { + if (applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred)) { continue; } + String value = resolveVars(theme, declaration.value); if (applyFontProperty(fonts, uiid + "." + statePrefix, property, value)) { continue; } @@ -359,7 +373,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha private void applyRootDeclarations(Hashtable theme, Declaration[] declarations, Hashtable deferred) { for (Declaration declaration : declarations) { - applyThemeConstantProperty(theme, declaration.name, resolveVars(theme, declaration.value), deferred); + applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); } } @@ -369,9 +383,16 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str return false; } String key = "@" + property.substring(2); + String raw = value.trim(); + String unquoted = unquote(raw); // A later custom property replaces a #Constants entry outright, deferred handling included. deferred.remove(key); - theme.put(key, unquote(value)); + if (raw.equals(unquoted)) { + unquoted = resolveVars(theme, unquoted); + } else { + deferred.put(key, DEFERRED_LITERAL); + } + theme.put(key, unquoted); return true; } @@ -400,8 +421,8 @@ private boolean applyFontProperty(Hashtable fonts, String keyP /// Builds the font of every UIID state that declared a font property. A state that sets only /// one of family and size takes the other from what applies in that state, as the build-time - /// compiler's flattening does: the matching state of the UIID and each `cn1-derive` parent in - /// turn, then their unselected styles in the same order. A dark style looks at the dark keys + /// compiler's flattening does: the matching state of the UIID, each `cn1-derive` parent and + /// finally `*` in turn, then their unselected styles in the same order. A dark style looks at the dark keys /// of each UIID before the light ones. private void compileFonts(Hashtable theme, Hashtable fonts) { // A font is a live platform object, so it can only be made with a running Display. @@ -422,15 +443,24 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { // The matching state along the whole derive chain comes before any unselected // style: the build-time flattener lays a parent's selected style over the child's // unselected one, so `Base:selected` beats `Child`. + // `*` compiles to Component, the implicit parent of every UIID in the build-time + // compiler, so it comes after the explicit derive chain. + ArrayList chain = new ArrayList(); + String id = dark ? uiid.substring(5) : uiid; + while (id != null && !chain.contains(id)) { + chain.add(id); + id = hoverBase(theme, id, dark); + } + if (!chain.contains("Component")) { + chain.add("Component"); + } for (int pass = 0; pass < 2 && (family == null || size == null); pass++) { String lookup = pass == 0 ? state : ""; if (pass == 1 && state.length() == 0) { break; } - String id = dark ? uiid.substring(5) : uiid; - ArrayList seen = new ArrayList(); - while (id != null && !seen.contains(id) && (family == null || size == null)) { - seen.add(id); + for (int i = 0; i < chain.size() && (family == null || size == null); i++) { + id = chain.get(i); String[] fallbacks = dark ? new String[]{"$Dark" + id + "." + lookup, id + "." + lookup} : new String[]{id + "." + lookup}; @@ -441,7 +471,6 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { size = size == null ? other[1] : size; } } - id = hoverBase(theme, id, dark); } } theme.put(keyPrefix + Style.FONT, createFont(family, size == null ? null : parseFontSize(size))); @@ -732,20 +761,39 @@ private String expandBorderShorthand(String value) { } private String resolveVars(Hashtable theme, String value) { - String out = value; - int varPos = out.indexOf("var(--"); + return resolveVars(theme, value, new ArrayList()); + } + + /// Expands every `var(--x)`, including those inside the values it substitutes. `resolving` + /// holds the names being expanded, so `a: var(--b); b: var(--a)` is an error instead of a + /// compile that never returns. + private String resolveVars(Hashtable theme, String value, ArrayList resolving) { + int varPos = value.indexOf("var(--"); + if (varPos < 0) { + return value; + } + StringBuilder out = new StringBuilder(); + int pos = 0; while (varPos > -1) { - int end = out.indexOf(')', varPos); + int end = value.indexOf(')', varPos); if (end < 0) { break; } - String key = out.substring(varPos + "var(--".length(), end).trim(); + String key = value.substring(varPos + "var(--".length(), end).trim(); + if (resolving.contains(key)) { + throw new CSSSyntaxException("Cyclic variable reference: var(--" + key + ")"); + } Object replacement = theme.get("@" + key); String replaceValue = replacement == null ? "" : replacement.toString(); - out = out.substring(0, varPos) + replaceValue + out.substring(end + 1); - varPos = out.indexOf("var(--"); - } - return out; + resolving.add(key); + replaceValue = resolveVars(theme, replaceValue, resolving); + resolving.remove(resolving.size() - 1); + out.append(value.substring(pos, varPos)).append(replaceValue); + pos = end + 1; + varPos = value.indexOf("var(--", pos); + } + out.append(value.substring(pos)); + return out.toString(); } private String[] selector(String selector) { @@ -1109,9 +1157,24 @@ private String toDarkSelector(String selector) { private String stripComments(String css) { StringBuilder out = new StringBuilder(); int i = 0; + char quote = 0; while (i < css.length()) { char c = css.charAt(i); - if (c == '/' && i + 1 < css.length() && css.charAt(i + 1) == '*') { + // Comment delimiters inside a quoted string are text. + if (quote != 0) { + out.append(c); + if (c == '\\' && i + 1 < css.length()) { + out.append(css.charAt(i + 1)); + i++; + } else if (c == quote) { + quote = 0; + } + i++; + continue; + } + if (c == '"' || c == '\'') { + quote = c; + } else if (c == '/' && i + 1 < css.length() && css.charAt(i + 1) == '*') { i += 2; boolean closed = false; while (i + 1 < css.length()) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 407f3f9f876..2a3ea5ada32 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -546,6 +546,64 @@ public void testDerivedStateFontBeatsTheChildsUnselectedFont() { assertEquals("native:MainBold", fontName(selected)); } + @Test + public void testRgbaColorConstantKeepsItsRgb() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--half:rgba(0, 0, 255, 0.25);}" + + "#Constants{infiniteDefaultColor: rgba(255, 0, 0, 0.5); viaVar: var(--half);}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@infiniteDefaultColor")); + assertEquals("0000ff", theme.get("@viaVar")); + } + + @Test + public void testCommentMarkersInsideQuotesAreText() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("/* a real comment */ #Constants{ message: \"a/*b*/c\"; open: '/*'; }" + + "Label{color:#ff0000;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("a/*b*/c", theme.get("@message")); + assertEquals("/*", theme.get("@open")); + assertEquals("ff0000", theme.get("Label.fgColor")); + } + + @Test + public void testStateFontFallsBackToTheUniversalStyle() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("* { font-family: 'native:MainBold'; } Label:selected { font-size: 20px; }", + resource, "Theme"); + Font selected = (Font) resource.getTheme("Theme").get("Label.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + assertEquals("native:MainBold", fontName(selected)); + } + + @Test + public void testCyclicConstantsAreRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ a: var(--a); }", new MutableResource(), "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ a: var(--b); b: var(--a); }", new MutableResource(), "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile(":root{ --a: var(--b); } :root{ --b: var(--a); } Label{ color: var(--a); }", + new MutableResource(), "Theme")); + // Two uses of one constant are not a cycle. + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ c: #ff0000; pair: var(--c) var(--c); }", resource, "Theme"); + assertEquals("ff0000 ff0000", resource.getTheme("Theme").get("@pair")); + } + + @Test + public void testQuotedVarTextIsNotResolved() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{ --name: world; --rootQuoted: \"var(--name)\"; }" + + "#Constants{ message: \"var(--name)\"; plain: var(--name); }", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("var(--name)", theme.get("@message")); + assertEquals("var(--name)", theme.get("@rootQuoted")); + assertEquals("world", theme.get("@plain")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index c7cf0135709..dbd8b719063 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5275,7 +5275,7 @@ private boolean applyProjectCss(String css, CodeEditor editor) { normalizeCompiledTheme(projectTheme); // Declarations this compiler does not implement are reported rather than passed over. // The canvas runs the Codename One CSS compiler that ships in core, while the project - // is built by the Maven one, which understands more -- font-size and font-style among + // is built by the Maven one, which understands more -- font-style and font-weight among // them. Silently dropping those and announcing success let the canvas differ from the // built application with nothing to say so. List ignored = unsupportedDeclarations(css); diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 83012914170..c942465aeb1 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1773,21 +1773,21 @@ void theCanvasSaysWhichCssDeclarationsItCannotShow() throws Exception { @SuppressWarnings("unchecked") java.util.List reported = (java.util.List) unsupported.invoke(builder, - "Label {\n color: #ff0000;\n font-size: 3mm;\n}\n"); + "Label {\n color: #ff0000;\n font-style: italic;\n}\n"); assertEquals(1, reported.size(), "one declaration the canvas drops: " + reported); - assertTrue(reported.get(0).toString().contains("font-size") - || describe(reported.get(0)).contains("font-size"), + assertTrue(reported.get(0).toString().contains("font-style") + || describe(reported.get(0)).contains("font-style"), "and it names the property: " + describe(reported.get(0))); @SuppressWarnings("unchecked") java.util.List clean = (java.util.List) unsupported.invoke(builder, - "Label { color: #ff0000; background-color: #00ff00; padding: 2mm; margin: 1mm; }\n"); + "Label { color: #ff0000; background-color: #00ff00; padding: 2mm; margin: 1mm; font-size: 3mm; }\n"); assertTrue(clean.isEmpty(), "everything here reaches the canvas: " + clean); // A comment is not a declaration. @SuppressWarnings("unchecked") java.util.List commented = (java.util.List) unsupported.invoke(builder, - "Label {\n /* font-size: 3mm; */\n color: #ff0000;\n}\n"); + "Label {\n /* font-style: italic; */\n color: #ff0000;\n}\n"); assertTrue(commented.isEmpty(), "a commented-out declaration is not reported: " + commented); } From 5a719cfecf6cdeaae4aa25866c21ba6b198137df Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:01:19 +0300 Subject: [PATCH 07/59] CSSThemeCompiler: every rule resolves var() against the final constants All constants -- #Constants entries, :root and in-rule custom properties -- are stored in source order before any rule runs, so a rule between two declarations of a constant sees the last one, as the build-time compiler does. Unquoted custom properties keep their var() text and resolve where they are used. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 35 ++++++++----------- .../ui/css/CSSThemeCompilerTest.java | 13 +++++++ 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index d4d80e50d8e..d8e50e95c12 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -102,20 +102,23 @@ public void compile(String css, MutableResource resources, String themeName) { // whose image a rule may still create (cn1-mutable-image), and one written as var(--x). // Keyed by "@name"; only the declaration that wins for a name decides its entry. Hashtable deferred = new Hashtable(); - // Constants first, wherever their block sits, so a var() in any rule can see them... + // Every constant first, in source order, so the last declaration of a name wins -- a + // #Constants entry, a `:root` or an in-rule custom property alike -- and every rule then + // resolves var() against those final values, wherever it sits, as the build-time + // compiler does. for (Rule rule : rules) { if (rule.constants) { compileConstants(rule.declarations, theme, deferred); + } else { + for (Declaration declaration : rule.declarations) { + applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); + } } } - // ...and then again in source order, so the last declaration of a name still wins - // against a `:root` or in-rule custom property, as it does in the build-time compiler. Hashtable fonts = new Hashtable(); for (Rule rule : rules) { - if (rule.constants) { - compileConstants(rule.declarations, theme, deferred); - } else { - applyRule(theme, resources, rule, fonts, deferred); + if (!rule.constants) { + applyRule(theme, resources, rule, fonts); } } compileFonts(theme, fonts); @@ -332,10 +335,9 @@ private static String unquote(String value) { return out.toString(); } - private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts, - Hashtable deferred) { + private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts) { if (":root".equals(rule.selector)) { - applyRootDeclarations(theme, rule.declarations, deferred); + // Its custom properties were all stored before any rule ran. return; } @@ -347,7 +349,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha for (int i = 0; i < rule.declarations.length; i++) { Declaration declaration = rule.declarations[i]; String property = declaration.property; - if (applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred)) { + if (declaration.name.startsWith("--")) { continue; } String value = resolveVars(theme, declaration.value); @@ -371,12 +373,6 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha theme.put(uiid + "." + statePrefix + "border", new CSSBorder(null, borderCss.toString())); } - private void applyRootDeclarations(Hashtable theme, Declaration[] declarations, Hashtable deferred) { - for (Declaration declaration : declarations) { - applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); - } - } - private boolean applyThemeConstantProperty(Hashtable theme, String property, String value, Hashtable deferred) { if (!property.startsWith("--")) { @@ -386,10 +382,9 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str String raw = value.trim(); String unquoted = unquote(raw); // A later custom property replaces a #Constants entry outright, deferred handling included. + // An unquoted var() in it is resolved where it is used, against the final values. deferred.remove(key); - if (raw.equals(unquoted)) { - unquoted = resolveVars(theme, unquoted); - } else { + if (!raw.equals(unquoted)) { deferred.put(key, DEFERRED_LITERAL); } theme.put(key, unquoted); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 2a3ea5ada32..58e90bbb443 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -604,6 +604,19 @@ public void testQuotedVarTextIsNotResolved() { assertEquals("world", theme.get("@plain")); } + @Test + public void testRulesSeeTheFinalValueOfARedeclaredConstant() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#ff0000;} Label{color:var(--accent);}" + + " #Constants{--accent:#0000ff;}" + + ":root{--bg:#ff0000;} Button{background-color:var(--bg);} :root{--bg:#00ff00;}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("0000ff", theme.get("@accent")); + assertEquals("0000ff", theme.get("Label.fgColor")); + assertEquals("00ff00", theme.get("Button.bgColor")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); From 2abd19cd89fddcede7482c4aa690516c392b7728 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:16:34 +0300 Subject: [PATCH 08/59] CSSThemeCompiler: cn1rgb constants, literal aliases, state derives in fonts, CSS hex escapes cn1rgb()/cn1rgba() color constants are stored as hex like rgb()/rgba(). A quoted constant stays text when reached through another constant. A state font follows the state's own cn1-derive. Quoted values decode CSS hexadecimal escapes. The GUI builder no longer reports #Constants entries as declarations the canvas drops. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 102 ++++++++++++++---- .../ui/css/CSSThemeCompilerTest.java | 39 +++++++ .../guibuilder/CodenameOneGUIBuilder.java | 11 ++ .../guibuilder/GeneratedSourceTest.java | 5 + 4 files changed, 139 insertions(+), 18 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index d8e50e95c12..b8b130a22b7 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -90,6 +90,9 @@ public CSSSyntaxException(String message, Throwable cause) { private static final String DEFERRED_VAR = "var"; private static final String DEFERRED_LITERAL = "literal"; + /// The `deferred` table of the compile in progress; resolveVars reads which constants are literal. + private Hashtable deferredConstants = new Hashtable(); + public void compile(String css, MutableResource resources, String themeName) { Hashtable theme = resources.getTheme(themeName); if (theme == null) { @@ -102,6 +105,7 @@ public void compile(String css, MutableResource resources, String themeName) { // whose image a rule may still create (cn1-mutable-image), and one written as var(--x). // Keyed by "@name"; only the declaration that wins for a name decides its entry. Hashtable deferred = new Hashtable(); + deferredConstants = deferred; // Every constant first, in source order, so the last declaration of a name wins -- a // #Constants entry, a `:root` or an in-rule custom property alike -- and every rule then // resolves var() against those final values, wherever it sits, as the build-time @@ -287,7 +291,8 @@ private static Image imageConstant(MutableResource resources, String name, Strin } private boolean isColorLiteral(String value) { - if (value.regionMatches(true, 0, "rgb(", 0, 4) || value.regionMatches(true, 0, "rgba(", 0, 5)) { + String v = cssColorFunction(value); + if (v.regionMatches(true, 0, "rgb(", 0, 4) || v.regionMatches(true, 0, "rgba(", 0, 5)) { return true; } int len = value.length(); @@ -297,6 +302,7 @@ private boolean isColorLiteral(String value) { /// A color constant as plain hex. A color constant has no alpha, so `rgba()` keeps its RGB /// part, as the build-time compiler stores it. private String constantColor(String value) { + value = cssColorFunction(value); if (value.regionMatches(true, 0, "rgba(", 0, 5) && value.endsWith(")")) { String[] parts = splitOnComma(value.substring(5, value.length() - 1)); if (parts.length != 4) { @@ -307,6 +313,11 @@ private String constantColor(String value) { return normalizeHexColor(value); } + /// `cn1rgb()` and `cn1rgba()` are the build-time compiler's spellings of `rgb()` and `rgba()`. + private static String cssColorFunction(String value) { + return value.regionMatches(true, 0, "cn1rgb", 0, 6) ? value.substring(3) : value; + } + private static boolean isConstantsSelector(String selector) { return "@constants".equalsIgnoreCase(selector) || "#Constants".equalsIgnoreCase(selector); } @@ -324,13 +335,43 @@ private static String unquote(String value) { return v; } StringBuilder out = new StringBuilder(len - 2); - for (int i = 1; i < len - 1; i++) { + int end = len - 1; + for (int i = 1; i < end; i++) { char c = v.charAt(i); - if (c == '\\' && i + 1 < len - 1) { + if (c != '\\' || i + 1 >= end) { + out.append(c); + continue; + } + i++; + c = v.charAt(i); + if (c == '\n') { + // An escaped newline continues the string. + continue; + } + int digits = 0; + int code = 0; + while (digits < 6 && i + digits < end && Character.digit(v.charAt(i + digits), 16) > -1) { + code = code * 16 + Character.digit(v.charAt(i + digits), 16); + digits++; + } + if (digits == 0) { + out.append(c); + continue; + } + // CSS: one to six hex digits name a code point, and one whitespace after them ends it. + i += digits - 1; + if (i + 1 < end && Character.isWhitespace(v.charAt(i + 1))) { i++; - c = v.charAt(i); } - out.append(c); + if (code == 0 || code > 0x10FFFF || code >= 0xD800 && code <= 0xDFFF) { + code = 0xFFFD; + } + if (code >= 0x10000) { + code -= 0x10000; + out.append((char) (0xD800 + (code >> 10))).append((char) (0xDC00 + (code & 0x3FF))); + } else { + out.append((char) code); + } } return out.toString(); } @@ -440,22 +481,17 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { // unselected one, so `Base:selected` beats `Child`. // `*` compiles to Component, the implicit parent of every UIID in the build-time // compiler, so it comes after the explicit derive chain. - ArrayList chain = new ArrayList(); - String id = dark ? uiid.substring(5) : uiid; - while (id != null && !chain.contains(id)) { - chain.add(id); - id = hoverBase(theme, id, dark); - } - if (!chain.contains("Component")) { - chain.add("Component"); - } + String own = dark ? uiid.substring(5) : uiid; for (int pass = 0; pass < 2 && (family == null || size == null); pass++) { String lookup = pass == 0 ? state : ""; if (pass == 1 && state.length() == 0) { break; } + // The state's own derive applies in both passes: its parent's unselected style + // is what a state derived from it falls back to. + ArrayList chain = fontChain(theme, own, state, dark); for (int i = 0; i < chain.size() && (family == null || size == null); i++) { - id = chain.get(i); + String id = chain.get(i); String[] fallbacks = dark ? new String[]{"$Dark" + id + "." + lookup, id + "." + lookup} : new String[]{id + "." + lookup}; @@ -472,6 +508,33 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { } } + /// The UIIDs a font in `state` inherits from, nearest first. A state that declares its own + /// `cn1-derive` (`Child:selected{cn1-derive:Base}`) follows that instead of the UIID's. + private ArrayList fontChain(Hashtable theme, String id, String state, boolean dark) { + Object stateDerive = null; + if (state.length() > 0) { + stateDerive = dark ? theme.get("$Dark" + id + "." + state + "derive") : null; + if (stateDerive == null) { + stateDerive = theme.get(id + "." + state + "derive"); + } + } + String next = stateDerive instanceof String ? (String) stateDerive : null; + ArrayList chain = new ArrayList(); + while (id != null && !chain.contains(id)) { + chain.add(id); + if (next != null) { + id = next; + next = null; + } else { + id = hoverBase(theme, id, dark); + } + } + if (!chain.contains("Component")) { + chain.add("Component"); + } + return chain; + } + private Font createFont(String familyList, FontSize size) { String ttfName = "native:MainRegular"; boolean monospace = false; @@ -780,9 +843,12 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso } Object replacement = theme.get("@" + key); String replaceValue = replacement == null ? "" : replacement.toString(); - resolving.add(key); - replaceValue = resolveVars(theme, replaceValue, resolving); - resolving.remove(resolving.size() - 1); + // A quoted constant is text, also when it is reached through another constant. + if (!DEFERRED_LITERAL.equals(deferredConstants.get("@" + key))) { + resolving.add(key); + replaceValue = resolveVars(theme, replaceValue, resolving); + resolving.remove(resolving.size() - 1); + } out.append(value.substring(pos, varPos)).append(replaceValue); pos = end + 1; varPos = value.indexOf("var(--", pos); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 58e90bbb443..3b65d1a92d8 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -617,6 +617,45 @@ public void testRulesSeeTheFinalValueOfARedeclaredConstant() { assertEquals("00ff00", theme.get("Button.bgColor")); } + @Test + public void testCn1ColorFunctionsInConstants() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ a: cn1rgb(255,0,0); b: cn1rgba(0,255,0,0.5); }", + resource, "Theme"); + assertEquals("ff0000", resource.getTheme("Theme").get("@a")); + assertEquals("00ff00", resource.getTheme("Theme").get("@b")); + } + + @Test + public void testQuotedConstantStaysTextThroughAnAlias() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--name:world;--message:\"var(--name)\";alias:var(--message);}", + resource, "Theme"); + assertEquals("var(--name)", resource.getTheme("Theme").get("@alias")); + } + + @Test + public void testStateFontFollowsTheStatesOwnDerive() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:'native:MainBold';}" + + "Child:selected{cn1-derive:Base;font-size:20px;}", resource, "Theme"); + Font selected = (Font) resource.getTheme("Theme").get("Child.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + assertEquals("native:MainBold", fontName(selected)); + } + + @Test + public void testQuotedConstantsDecodeHexEscapes() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ a: \"\\41\"; b: \"\\41 B\"; c: \"\\1F600\"; d: \"x\\\"y\"; }", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("A", theme.get("@a")); + assertEquals("AB", theme.get("@b")); + assertEquals(new String(Character.toChars(0x1F600)), theme.get("@c")); + assertEquals("x\"y", theme.get("@d")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index dbd8b719063..fe02175b6ab 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5330,6 +5330,8 @@ private List unsupportedDeclarations(String css) { String property = css.substring(nameStart, nameEnd).trim(); String value = css.substring(at + 1, valueEnd).trim(); if (property.length() == 0 || value.length() == 0 || property.startsWith("@")) continue; + // A #Constants entry is a theme constant, not a style property a probe rule could show. + if (isInConstantsBlock(masked, at)) continue; Boolean supported = judged.get(property); if (supported == null) { Set withProperty = compiledKeys(property + ": " + value + ";"); @@ -5345,6 +5347,15 @@ private List unsupportedDeclarations(String css) { return out; } + /** True when {@code at} sits inside a {@code #Constants} or {@code @constants} block. */ + private static boolean isInConstantsBlock(String masked, int at) { + int open = masked.lastIndexOf('{', at); + if (open < 0 || masked.lastIndexOf('}', at) > open) return false; + int selectorStart = Math.max(masked.lastIndexOf('}', open), masked.lastIndexOf('{', open - 1)) + 1; + String selector = masked.substring(selectorStart, open).trim(); + return "#Constants".equalsIgnoreCase(selector) || "@constants".equalsIgnoreCase(selector); + } + /** True for the characters a CSS property name is made of. */ private static boolean isCssNameChar(char c) { return c == '-' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'; diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index c942465aeb1..58cbd853cf7 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1789,6 +1789,11 @@ void theCanvasSaysWhichCssDeclarationsItCannotShow() throws Exception { java.util.List commented = (java.util.List) unsupported.invoke(builder, "Label {\n /* font-style: italic; */\n color: #ff0000;\n}\n"); assertTrue(commented.isEmpty(), "a commented-out declaration is not reported: " + commented); + + @SuppressWarnings("unchecked") + java.util.List constants = (java.util.List) unsupported.invoke(builder, + "#Constants {\n includeNativeBool: true;\n}\nLabel { color: #ff0000; }\n"); + assertTrue(constants.isEmpty(), "a #Constants entry is not a dropped declaration: " + constants); } private static String describe(Object diagnostic) throws Exception { From 8f097f33a86f78549b6ac1d9002adc79d710f803 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:48:09 +0300 Subject: [PATCH 09/59] CSSThemeCompiler: match the build-time compiler on ordering, * fonts, % insets and size keywords Custom properties take effect where they are declared, as the build-time parser's callbacks do; #Constants stay visible to every rule, and only the winning declaration of a name is re-applied in source order. The * style no longer feeds fonts, since the build-time compiler ignores universal rules. A state's own cn1-derive is followed at every hop. Font-size keywords match exactly, and percentage padding/margin is rejected like the build-time compiler rejects it. An alias of a quoted constant stays text. The GUI builder's constants-block check ignores quoted braces. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 109 ++++++++++++------ .../ui/css/CSSThemeCompilerTest.java | 44 +++++-- .../guibuilder/CodenameOneGUIBuilder.java | 31 ++++- .../guibuilder/GeneratedSourceTest.java | 2 +- 4 files changed, 139 insertions(+), 47 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index b8b130a22b7..31e07f6d1fa 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -54,8 +54,8 @@ /// - `padding`, `margin`: one to four values in CSS top/right/bottom/left order, stored in the /// theme's top/bottom/left/right order together with per-side `padUnit`/`marUnit` metadata. /// `px`, `pt` and unitless values are pixels (rounded, as the build-time compiler does), `mm` -/// and `cm` are millimetres, `%` is a percentage of the screen, and `rem`, `vw`, `vh`, -/// `vmin`, `vmax` keep their own unit. +/// and `cm` are millimetres, and `rem`, `vw`, `vh`, `vmin`, `vmax` keep their own unit. A +/// percentage is rejected, as the build-time compiler rejects it. /// - `font-family` and `font-size`: resolved to a {@link Font} with the build-time compiler's /// semantics. The family defaults to `native:MainRegular` and the size to the medium system /// font; `font-size` accepts `px`, `pt`, `mm`, `cm`, `in`, `rem`, `vw`, `vh`, `vmin`, `vmax`, @@ -89,6 +89,8 @@ public CSSSyntaxException(String message, Throwable cause) { private static final String DEFERRED_IMAGE = "image"; private static final String DEFERRED_VAR = "var"; private static final String DEFERRED_LITERAL = "literal"; + /// Resolved like any var() constant, but kept as text: it aliases a quoted constant. + private static final String DEFERRED_TEXT = "text"; /// The `deferred` table of the compile in progress; resolveVars reads which constants are literal. private Hashtable deferredConstants = new Hashtable(); @@ -106,25 +108,39 @@ public void compile(String css, MutableResource resources, String themeName) { // Keyed by "@name"; only the declaration that wins for a name decides its entry. Hashtable deferred = new Hashtable(); deferredConstants = deferred; - // Every constant first, in source order, so the last declaration of a name wins -- a - // #Constants entry, a `:root` or an in-rule custom property alike -- and every rule then - // resolves var() against those final values, wherever it sits, as the build-time - // compiler does. + // #Constants entries first, wherever their block sits, so every rule sees their final + // values -- the build-time compiler reads that block before it compiles any rule. + Hashtable finalConstants = new Hashtable(); for (Rule rule : rules) { if (rule.constants) { compileConstants(rule.declarations, theme, deferred); - } else { for (Declaration declaration : rule.declarations) { - applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); + finalConstants.put(declaration.name, declaration); } } } + // Then source order. A `:root` or in-rule custom property takes effect where it is + // declared, as the build-time parser's callbacks do: a rule above it does not see it. + // Only the winning declaration of a #Constants name is applied again here, so it still + // overrides a custom property declared before it. Hashtable fonts = new Hashtable(); for (Rule rule : rules) { - if (!rule.constants) { - applyRule(theme, resources, rule, fonts); + if (rule.constants) { + ArrayList winners = new ArrayList(); + for (Declaration declaration : rule.declarations) { + if (finalConstants.get(declaration.name) == declaration) { + winners.add(declaration); + } + } + compileConstants(winners.toArray(new Declaration[winners.size()]), theme, deferred); + continue; + } + for (Declaration declaration : rule.declarations) { + applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); } + applyRule(theme, resources, rule, fonts); } + markLiteralAliases(theme, deferred); compileFonts(theme, fonts); inheritHoverDerivations(theme); resolveThemeConstantVars(theme, deferred); @@ -145,6 +161,35 @@ public void compile(String css, MutableResource resources, String themeName) { resources.setTheme(themeName, theme); } + /// `alias: var(--literal)` where `literal` is quoted resolves to that text, not to a color: + /// such an alias, followed through any chain of plain aliases, is not reclassified. + private void markLiteralAliases(Hashtable theme, Hashtable deferred) { + ArrayList aliases = new ArrayList(); + for (Map.Entry entry : deferred.entrySet()) { + if (DEFERRED_VAR.equals(entry.getValue())) { + aliases.add(entry.getKey()); + } + } + for (String key : aliases) { + ArrayList seen = new ArrayList(); + Object value = theme.get(key); + String target = key; + while (value instanceof String && !seen.contains(target)) { + seen.add(target); + String v = ((String) value).trim(); + if (!v.startsWith("var(--") || v.indexOf(')') != v.length() - 1) { + break; + } + target = "@" + v.substring("var(--".length(), v.length() - 1).trim(); + if (DEFERRED_LITERAL.equals(deferred.get(target))) { + deferred.put(key, DEFERRED_TEXT); + break; + } + value = theme.get(target); + } + } + } + private void inheritHoverDerivations(Hashtable theme) { ArrayList ids = new ArrayList(); for (Object keyObj : theme.keySet()) { @@ -378,7 +423,7 @@ private static String unquote(String value) { private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts) { if (":root".equals(rule.selector)) { - // Its custom properties were all stored before any rule ran. + // Its custom properties were stored just before this call. return; } @@ -508,29 +553,21 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { } } - /// The UIIDs a font in `state` inherits from, nearest first. A state that declares its own - /// `cn1-derive` (`Child:selected{cn1-derive:Base}`) follows that instead of the UIID's. + /// The UIIDs a font in `state` inherits from, nearest first. At every hop a state that + /// declares its own `cn1-derive` (`Child:selected{cn1-derive:Base}`) follows that instead of + /// the UIID's. `*` is not part of it: the build-time compiler ignores universal rules. private ArrayList fontChain(Hashtable theme, String id, String state, boolean dark) { - Object stateDerive = null; - if (state.length() > 0) { - stateDerive = dark ? theme.get("$Dark" + id + "." + state + "derive") : null; - if (stateDerive == null) { - stateDerive = theme.get(id + "." + state + "derive"); - } - } - String next = stateDerive instanceof String ? (String) stateDerive : null; ArrayList chain = new ArrayList(); while (id != null && !chain.contains(id)) { chain.add(id); - if (next != null) { - id = next; - next = null; - } else { - id = hoverBase(theme, id, dark); + Object stateDerive = null; + if (state.length() > 0) { + stateDerive = dark ? theme.get("$Dark" + id + "." + state + "derive") : null; + if (stateDerive == null) { + stateDerive = theme.get(id + "." + state + "derive"); + } } - } - if (!chain.contains("Component")) { - chain.add("Component"); + id = stateDerive instanceof String ? (String) stateDerive : hoverBase(theme, id, dark); } return chain; } @@ -609,11 +646,13 @@ private float fontSizeInPixels(FontSize size) { private FontSize parseFontSize(String value) { String v = value.trim(); FontSize out = new FontSize(); - if (endsWithIgnoreCase(v, "small") || "smaller".equalsIgnoreCase(v)) { + if ("small".equalsIgnoreCase(v) || "x-small".equalsIgnoreCase(v) || "xx-small".equalsIgnoreCase(v) + || "smaller".equalsIgnoreCase(v)) { out.systemSize = Font.SIZE_SMALL; return out; } - if (endsWithIgnoreCase(v, "large") || "larger".equalsIgnoreCase(v)) { + if ("large".equalsIgnoreCase(v) || "x-large".equalsIgnoreCase(v) || "xx-large".equalsIgnoreCase(v) + || "larger".equalsIgnoreCase(v)) { out.systemSize = Font.SIZE_LARGE; return out; } @@ -1071,7 +1110,11 @@ private void applyBox(Hashtable theme, String keyPrefix, String property, String private Length length(String value) { String v = value.trim(); Length out = new Length(); - String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "%", "rem", "vw", "vh", "vmin", "vmax"}); + // No `%`: the build-time compiler rejects a percentage inset, so the preview must too. + if (v.endsWith("%")) { + throw new CSSSyntaxException("Unsupported unit for padding or margin: " + value); + } + String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "rem", "vw", "vh", "vmin", "vmax"}); float n = number(unit == null ? v : v.substring(0, v.length() - unit.length()), value); if (unit == null || "px".equals(unit) || "pt".equals(unit)) { out.unit = Style.UNIT_TYPE_PIXELS; @@ -1081,8 +1124,6 @@ private Length length(String value) { } else if ("cm".equals(unit)) { out.unit = Style.UNIT_TYPE_DIPS; n = n * 10f; - } else if ("%".equals(unit)) { - out.unit = Style.UNIT_TYPE_SCREEN_PERCENTAGE; } else { out.unit = viewportUnit(unit); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 3b65d1a92d8..1e47827a173 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -341,13 +341,13 @@ public void testIssueExampleResolvesToTheRightSides() { @Test public void testMixedUnitsKeepEachSidesUnit() { MutableResource resource = new MutableResource(); - new CSSThemeCompiler().compile("Mixed{padding:1mm 10% 2.5pt 3px;}" + new CSSThemeCompiler().compile("Mixed{padding:1mm 2vw 2.5pt 3px;}" + "Mixed:pressed{margin:1.5mm 2cm;}", resource, "Theme"); Hashtable theme = resource.getTheme("Theme"); - // top=1mm, bottom=2.5pt (a density independent pixel, rounded), left=3px, right=10%. - assertEquals("1,3,3,10", theme.get("Mixed.padding")); + // top=1mm, bottom=2.5pt (a density independent pixel, rounded), left=3px, right=2vw. + assertEquals("1,3,3,2", theme.get("Mixed.padding")); assertArrayEquals(new byte[]{Style.UNIT_TYPE_DIPS, Style.UNIT_TYPE_PIXELS, - Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_SCREEN_PERCENTAGE}, + Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_VW}, (byte[]) theme.get("Mixed.padUnit")); assertEquals("1.5,1.5,20,20", theme.get("Mixed.press#margin")); byte mm = Style.UNIT_TYPE_DIPS; @@ -365,6 +365,11 @@ public void testRejectsMalformedBoxValues() { compiler.compile("Label{margin:wide;}", resource, "Theme")); assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> compiler.compile("Label{font-size:huge;}", resource, "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{font-size:not-small;}", resource, "Theme")); + // The build-time compiler has no percentage inset. + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{padding:10%;}", resource, "Theme")); } @Test @@ -569,13 +574,14 @@ public void testCommentMarkersInsideQuotesAreText() { } @Test - public void testStateFontFallsBackToTheUniversalStyle() { + public void testStateFontIgnoresTheUniversalStyle() { MutableResource resource = new MutableResource(); new CSSThemeCompiler().compile("* { font-family: 'native:MainBold'; } Label:selected { font-size: 20px; }", resource, "Theme"); Font selected = (Font) resource.getTheme("Theme").get("Label.sel#font"); assertEquals(20f, selected.getPixelSize(), 0.001f); - assertEquals("native:MainBold", fontName(selected)); + // The build-time compiler ignores `*` rules, so nothing is inherited from it. + assertEquals("native:MainRegular", fontName(selected)); } @Test @@ -614,7 +620,31 @@ public void testRulesSeeTheFinalValueOfARedeclaredConstant() { Hashtable theme = resource.getTheme("Theme"); assertEquals("0000ff", theme.get("@accent")); assertEquals("0000ff", theme.get("Label.fgColor")); - assertEquals("00ff00", theme.get("Button.bgColor")); + // A :root property takes effect where it is declared, as in the build-time parser. + assertEquals("ff0000", theme.get("Button.bgColor")); + assertEquals("#00ff00", theme.get("@bg")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{color:var(--late);} :root{--late:#ff0000;}", new MutableResource(), "Theme")); + } + + @Test + public void testStateDeriveIsFollowedAtEveryHop() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:'native:MainBold';}" + + "Middle:selected{cn1-derive:Base;}" + + "Child:selected{cn1-derive:Middle;font-size:20px;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Child.sel#font"))); + } + + @Test + public void testAliasOfAQuotedConstantStaysText() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{literal:\"#abc\"; alias:var(--literal); twice:var(--alias);" + + " color:#abc; colorAlias:var(--color);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("#abc", theme.get("@alias")); + assertEquals("#abc", theme.get("@twice")); + assertEquals("aabbcc", theme.get("@colorAlias")); } @Test diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index fe02175b6ab..475725713eb 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5347,12 +5347,33 @@ private List unsupportedDeclarations(String css) { return out; } - /** True when {@code at} sits inside a {@code #Constants} or {@code @constants} block. */ + /** + * True when {@code at} sits inside a {@code #Constants} or {@code @constants} block. Braces + * inside a quoted value are text, so the scan tracks quotes and escapes. + */ private static boolean isInConstantsBlock(String masked, int at) { - int open = masked.lastIndexOf('{', at); - if (open < 0 || masked.lastIndexOf('}', at) > open) return false; - int selectorStart = Math.max(masked.lastIndexOf('}', open), masked.lastIndexOf('{', open - 1)) + 1; - String selector = masked.substring(selectorStart, open).trim(); + List open = new ArrayList<>(); + int selectorStart = 0; + char quote = 0; + for (int i = 0; i < at && i < masked.length(); i++) { + char c = masked.charAt(i); + if (quote != 0) { + if (c == '\\') i++; + else if (c == quote) quote = 0; + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '{') { + open.add(masked.substring(selectorStart, i).trim()); + selectorStart = i + 1; + } else if (c == '}') { + if (!open.isEmpty()) open.remove(open.size() - 1); + selectorStart = i + 1; + } else if (c == ';') { + selectorStart = i + 1; + } + } + if (open.isEmpty()) return false; + String selector = open.get(open.size() - 1); return "#Constants".equalsIgnoreCase(selector) || "@constants".equalsIgnoreCase(selector); } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 58cbd853cf7..b94c7e90365 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1792,7 +1792,7 @@ void theCanvasSaysWhichCssDeclarationsItCannotShow() throws Exception { @SuppressWarnings("unchecked") java.util.List constants = (java.util.List) unsupported.invoke(builder, - "#Constants {\n includeNativeBool: true;\n}\nLabel { color: #ff0000; }\n"); + "#Constants {\n message: \"a}b\";\n includeNativeBool: true;\n}\nLabel { color: #ff0000; }\n"); assertTrue(constants.isEmpty(), "a #Constants entry is not a dropped declaration: " + constants); } From 24a44eb44a0478410a8691326be864aa5eb7493d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:56:38 +0300 Subject: [PATCH 10/59] CSSThemeCompiler: per-compile constant typing, sibling-state derives, no relative size keywords Only constants declared by this compile are resolved, so a literal from an earlier compile into the same theme keeps its text. A cn1-derive declared in any state applies to the UIID's other states for fonts, as the build-time compiler does. smaller/larger are rejected, since neither compiler resolves them against the inherited size. The build-time compiler now emits decimal constants instead of dropping them. The GUI builder resolves image constants from the project's res folder. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 49 +++++++++--- .../ui/css/CSSThemeCompilerTest.java | 25 ++++++ .../com/codename1/designer/css/CSSTheme.java | 6 ++ .../css/RuntimeCompilerParityTest.java | 1 + .../guibuilder/CodenameOneGUIBuilder.java | 76 ++++++++++++++++++- .../guibuilder/GeneratedSourceTest.java | 17 +++++ 6 files changed, 161 insertions(+), 13 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 31e07f6d1fa..023a3eb6003 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -89,6 +89,8 @@ public CSSSyntaxException(String message, Throwable cause) { private static final String DEFERRED_IMAGE = "image"; private static final String DEFERRED_VAR = "var"; private static final String DEFERRED_LITERAL = "literal"; + /// Declared by this compile and needing no further handling beyond var() resolution. + private static final String DEFERRED_PLAIN = "plain"; /// Resolved like any var() constant, but kept as text: it aliases a quoted constant. private static final String DEFERRED_TEXT = "text"; @@ -105,7 +107,9 @@ public void compile(String css, MutableResource resources, String themeName) { Rule[] rules = parseRulesWithMedia(strippedCss); // Constants whose final value is only known after every rule ran: an image constant, // whose image a rule may still create (cn1-mutable-image), and one written as var(--x). - // Keyed by "@name"; only the declaration that wins for a name decides its entry. + // Keyed by "@name"; only the declaration that wins for a name decides its entry. Every + // constant this compile declares has an entry, so constants a previous compile left in + // the same theme are never re-resolved without the typing they were declared with. Hashtable deferred = new Hashtable(); deferredConstants = deferred; // #Constants entries first, wherever their block sits, so every rule sees their final @@ -282,10 +286,10 @@ private boolean hasHoverDefinition(Hashtable theme, String id) { } private void resolveThemeConstantVars(Hashtable theme, Hashtable deferred) { - for (Object keyObj : theme.keySet()) { - String key = String.valueOf(keyObj); + for (Map.Entry entry : deferred.entrySet()) { + String key = entry.getKey(); // A quoted value is a string: `"var(--name)"` is text, not a reference. - if (!key.startsWith("@") || DEFERRED_LITERAL.equals(deferred.get(key))) { + if (DEFERRED_LITERAL.equals(entry.getValue())) { continue; } Object value = theme.get(key); @@ -308,7 +312,7 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, String key = "@" + name; String raw = declaration.value.trim(); String value = unquote(raw); - deferred.remove(key); + deferred.put(key, DEFERRED_PLAIN); if (name.endsWith("Image")) { deferred.put(key, DEFERRED_IMAGE); } else if (!raw.equals(value)) { @@ -469,7 +473,7 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str String unquoted = unquote(raw); // A later custom property replaces a #Constants entry outright, deferred handling included. // An unquoted var() in it is resolved where it is used, against the final values. - deferred.remove(key); + deferred.put(key, DEFERRED_PLAIN); if (!raw.equals(unquoted)) { deferred.put(key, DEFERRED_LITERAL); } @@ -567,11 +571,34 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo stateDerive = theme.get(id + "." + state + "derive"); } } - id = stateDerive instanceof String ? (String) stateDerive : hoverBase(theme, id, dark); + if (stateDerive instanceof String) { + id = (String) stateDerive; + } else { + id = hoverBase(theme, id, dark); + if (id == null) { + id = anyStateDerive(theme, chain.get(chain.size() - 1), dark); + } + } } return chain; } + /// The build-time compiler applies a `cn1-derive` declared in any state to the owning UIID, + /// so `Child:selected{cn1-derive:Base}` is also where `Child:pressed` inherits from. + private String anyStateDerive(Hashtable theme, String id, boolean dark) { + String[] states = {"sel#", "press#", "dis#", "hover#"}; + for (int pass = dark ? 0 : 1; pass < 2; pass++) { + String prefix = pass == 0 ? "$Dark" + id + "." : id + "."; + for (String state : states) { + Object derive = theme.get(prefix + state + "derive"); + if (derive instanceof String) { + return (String) derive; + } + } + } + return null; + } + private Font createFont(String familyList, FontSize size) { String ttfName = "native:MainRegular"; boolean monospace = false; @@ -646,13 +673,13 @@ private float fontSizeInPixels(FontSize size) { private FontSize parseFontSize(String value) { String v = value.trim(); FontSize out = new FontSize(); - if ("small".equalsIgnoreCase(v) || "x-small".equalsIgnoreCase(v) || "xx-small".equalsIgnoreCase(v) - || "smaller".equalsIgnoreCase(v)) { + // `smaller` and `larger` are relative to the inherited size, which the build-time + // compiler does not implement; they fall through to the unsupported-size error. + if ("small".equalsIgnoreCase(v) || "x-small".equalsIgnoreCase(v) || "xx-small".equalsIgnoreCase(v)) { out.systemSize = Font.SIZE_SMALL; return out; } - if ("large".equalsIgnoreCase(v) || "x-large".equalsIgnoreCase(v) || "xx-large".equalsIgnoreCase(v) - || "larger".equalsIgnoreCase(v)) { + if ("large".equalsIgnoreCase(v) || "x-large".equalsIgnoreCase(v) || "xx-large".equalsIgnoreCase(v)) { out.systemSize = Font.SIZE_LARGE; return out; } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 1e47827a173..10271451b31 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -686,6 +686,31 @@ public void testQuotedConstantsDecodeHexEscapes() { assertEquals("x\"y", theme.get("@d")); } + @Test + public void testSecondCompileKeepsEarlierLiteralConstants() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{message:\"var(--name)\";}", resource, "Theme"); + new CSSThemeCompiler().compile("Label{color:#ff0000;}", resource, "Theme"); + assertEquals("var(--name)", resource.getTheme("Theme").get("@message")); + } + + @Test + public void testSiblingStateFontUsesADeriveDeclaredInAnotherState() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:'native:MainBold';}" + + "Child:selected{cn1-derive:Base;}" + + "Child:pressed{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Child.press#font"))); + } + + @Test + public void testRelativeFontSizeKeywordsAreRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{font-size:smaller;}", new MutableResource(), "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{font-size:larger;}", new MutableResource(), "Theme")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 5b04803eaf0..ef03d5ecf6c 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -3246,6 +3246,12 @@ public void updateResources() { } } else if (lu.getLexicalUnitType() == LexicalUnit.SAC_INTEGER) { res.setThemeProperty(themeName, "@"+constantKey, String.valueOf(((ScaledUnit)lu).getIntegerValue())); + } else if (lu.getLexicalUnitType() == LexicalUnit.SAC_REAL) { + // A decimal such as `switchTrackScaleY: 1.5` was silently dropped, so the + // built theme fell back to the default the runtime compiler did not. + String real = String.valueOf(lu.getFloatValue()); + res.setThemeProperty(themeName, "@"+constantKey, + real.endsWith(".0") ? real.substring(0, real.length() - 2) : real); } else if (lu.getLexicalUnitType() == LexicalUnit.SAC_RGBCOLOR || (lu.getLexicalUnitType() == LexicalUnit.SAC_FUNCTION && ("rgb".equals(lu.getFunctionName()) diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java index ec5e80f7474..0d3484f403e 100644 --- a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java @@ -47,6 +47,7 @@ public class RuntimeCompilerParityTest { + " quotedFlagBool: 'false';\n" + " accentColor: #ff2d95;\n" + " shortColor: #abc;\n" + + " switchTrackScaleY: 1.5;\n" + "}\n" + "Label { padding: 1px 2px 3px 4px; margin: 1mm 2mm; }\n" + "One { padding: 5px; margin: 0.5mm; }\n" diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 475725713eb..a730d58fcad 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5269,7 +5269,7 @@ private void saveCss(String css, CodeEditor editor) { private boolean applyProjectCss(String css, CodeEditor editor) { try { - MutableResource resources = new MutableResource(); + MutableResource resources = new ProjectImageResource(binding == null ? null : binding.cssFile()); new CSSThemeCompiler().compile(css, resources, "ProjectTheme"); projectTheme = resources.getTheme("ProjectTheme"); normalizeCompiledTheme(projectTheme); @@ -5464,7 +5464,7 @@ private void refreshProjectThemeOnPreview() { private void loadProjectTheme() { if (binding == null || binding.cssFile() == null) return; try { - MutableResource resources = new MutableResource(); + MutableResource resources = new ProjectImageResource(binding.cssFile()); new CSSThemeCompiler().compile(ProjectIO.read(binding.cssFile()), resources, "ProjectTheme"); projectTheme = resources.getTheme("ProjectTheme"); normalizeCompiledTheme(projectTheme); @@ -5474,6 +5474,78 @@ private void loadProjectTheme() { } } + /** + * The canvas compiles into an empty resource, so an image constant such as + * {@code #Constants{mediaPlayImage:play.png}} found nothing and the stylesheet was rejected, + * although the project build resolves it. This looks an image up where the build-time + * compiler does: {@code res///.png} beside the stylesheet, or a file of + * that name there. Images added to the project's own resource file in the designer are not + * visible here. + */ + static final class ProjectImageResource extends MutableResource { + private final String cssDir; + + ProjectImageResource(String cssFile) { + int slash = cssFile == null ? -1 : cssFile.replace('\\', '/').lastIndexOf('/'); + cssDir = slash < 0 ? null : cssFile.substring(0, slash); + } + + @Override + public com.codename1.ui.Image getImage(String id) { + com.codename1.ui.Image image = super.getImage(id); + if (image != null || cssDir == null || id == null) return image; + image = load(cssDir + "/" + id); + if (image == null) { + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + String res = ProjectIO.fsUrl(cssDir + "/res"); + try { + if (fs.isDirectory(res)) { + for (String theme : fs.listFiles(res)) { + String folder = res + "/" + stripSlash(theme) + "/" + id; + if (!fs.isDirectory(folder)) continue; + for (String density : new String[]{"veryhigh.png", "high.png", "medium.png"}) { + image = load(folder + "/" + density); + if (image != null) break; + } + if (image == null) { + for (String file : fs.listFiles(folder)) { + if (file.endsWith(".png")) { + image = load(folder + "/" + file); + if (image != null) break; + } + } + } + if (image != null) break; + } + } + } catch (IOException ignored) { + // An unreadable folder is an image that is not there. + } + } + if (image != null) setImage(id, image); + return image; + } + + private static String stripSlash(String name) { + return name.endsWith("/") ? name.substring(0, name.length() - 1) : name; + } + + private static com.codename1.ui.Image load(String path) { + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + String url = ProjectIO.fsUrl(path); + if (!fs.exists(url) || fs.isDirectory(url)) return null; + java.io.InputStream in = null; + try { + in = fs.openInputStream(url); + return com.codename1.ui.Image.createImage(in); + } catch (IOException ex) { + return null; + } finally { + com.codename1.io.Util.cleanup(in); + } + } + } + /** Why the companion cannot be regenerated, or null when it can. */ private String companionRefusal; diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index b94c7e90365..5f47572e12d 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1796,6 +1796,23 @@ void theCanvasSaysWhichCssDeclarationsItCannotShow() throws Exception { assertTrue(constants.isEmpty(), "a #Constants entry is not a dropped declaration: " + constants); } + @Test + void anImageConstantResolvesFromTheProjectsResFolder() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + java.awt.image.BufferedImage png = new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB); + javax.imageio.ImageIO.write(png, "png", folder.resolve("medium.png").toFile()); + String css = dir.resolve("theme.css").toString(); + com.codename1.ui.util.MutableResource resources = + new CodenameOneGUIBuilder.ProjectImageResource(css); + new com.codename1.ui.css.CSSThemeCompiler().compile("#Constants{mediaPlayImage:play.png;}", + resources, "ProjectTheme"); + assertTrue(resources.getTheme("ProjectTheme").get("@mediaPlayImage") instanceof com.codename1.ui.Image, + "the image comes from res//play.png/medium.png"); + } + private static String describe(Object diagnostic) throws Exception { for (String getter : new String[]{"getMessage", "getDescription", "getText"}) { try { From 8e5538495977592d6c4343259e71711155408efc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:42:35 +0300 Subject: [PATCH 11/59] CSSThemeCompiler: in-rule custom property order, last state derive wins, incremental fonts, CRLF continuations Also rejects unitless decimal insets, picks the GUI builder's image variant for the display density and drops the identity comparison PMD flagged. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 76 ++++++++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 41 ++++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 26 ++++++- .../guibuilder/GeneratedSourceTest.java | 10 +++ 4 files changed, 140 insertions(+), 13 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 023a3eb6003..0e539f8d441 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -97,6 +97,10 @@ public CSSSyntaxException(String message, Throwable cause) { /// The `deferred` table of the compile in progress; resolveVars reads which constants are literal. private Hashtable deferredConstants = new Hashtable(); + /// The `cn1-derive` each UIID (with its `$Dark` prefix) declared last in any state, in source + /// order: the build-time compiler sets every such derive on the owning UIID, so the last one wins. + private Hashtable ownerDerives = new Hashtable(); + public void compile(String css, MutableResource resources, String themeName) { Hashtable theme = resources.getTheme(themeName); if (theme == null) { @@ -119,7 +123,11 @@ public void compile(String css, MutableResource resources, String themeName) { if (rule.constants) { compileConstants(rule.declarations, theme, deferred); for (Declaration declaration : rule.declarations) { - finalConstants.put(declaration.name, declaration); + Declaration previous = finalConstants.put(declaration.name, declaration); + if (previous != null) { + previous.winner = false; + } + declaration.winner = true; } } } @@ -128,21 +136,19 @@ public void compile(String css, MutableResource resources, String themeName) { // Only the winning declaration of a #Constants name is applied again here, so it still // overrides a custom property declared before it. Hashtable fonts = new Hashtable(); + ownerDerives = new Hashtable(); for (Rule rule : rules) { if (rule.constants) { ArrayList winners = new ArrayList(); for (Declaration declaration : rule.declarations) { - if (finalConstants.get(declaration.name) == declaration) { + if (declaration.winner) { winners.add(declaration); } } compileConstants(winners.toArray(new Declaration[winners.size()]), theme, deferred); continue; } - for (Declaration declaration : rule.declarations) { - applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); - } - applyRule(theme, resources, rule, fonts); + applyRule(theme, resources, rule, fonts, deferred); } markLiteralAliases(theme, deferred); compileFonts(theme, fonts); @@ -393,8 +399,11 @@ private static String unquote(String value) { } i++; c = v.charAt(i); - if (c == '\n') { - // An escaped newline continues the string. + if (c == '\n' || c == '\r' || c == '\f') { + // An escaped newline (LF, CRLF, CR or FF) continues the string. + if (c == '\r' && i + 1 < end && v.charAt(i + 1) == '\n') { + i++; + } continue; } int digits = 0; @@ -425,9 +434,12 @@ private static String unquote(String value) { return out.toString(); } - private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts) { + private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts, + Hashtable deferred) { if (":root".equals(rule.selector)) { - // Its custom properties were stored just before this call. + for (Declaration declaration : rule.declarations) { + applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); + } return; } @@ -439,10 +451,15 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha for (int i = 0; i < rule.declarations.length; i++) { Declaration declaration = rule.declarations[i]; String property = declaration.property; - if (declaration.name.startsWith("--")) { + // A custom property takes effect where it is declared, also within a rule: the + // build-time parser resolves each declaration as it reads it. + if (applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred)) { continue; } String value = resolveVars(theme, declaration.value); + if ("cn1-derive".equals(property) && statePrefix.length() > 0) { + ownerDerives.put(uiid, value.trim()); + } if (applyFontProperty(fonts, uiid + "." + statePrefix, property, value)) { continue; } @@ -531,6 +548,9 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { // `*` compiles to Component, the implicit parent of every UIID in the build-time // compiler, so it comes after the explicit derive chain. String own = dark ? uiid.substring(5) : uiid; + // A font a previous compile into the same theme built, when this compile has no + // spec for that key: its family and size stand in for the missing half. + Font inherited = null; for (int pass = 0; pass < 2 && (family == null || size == null); pass++) { String lookup = pass == 0 ? state : ""; if (pass == 1 && state.length() == 0) { @@ -549,11 +569,28 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { if (other != null) { family = family == null ? other[0] : family; size = size == null ? other[1] : size; + } else if (inherited == null) { + Object existing = theme.get(fallback + Style.FONT); + if (existing instanceof Font && ((Font) existing).isTTFNativeFont()) { + inherited = (Font) existing; + } } } } } - theme.put(keyPrefix + Style.FONT, createFont(family, size == null ? null : parseFontSize(size))); + FontSize parsed = size == null ? null : parseFontSize(size); + Font font; + if (inherited != null && family == null && parsed != null && parsed.unit >= 0) { + font = inherited.derive(fontSizeInPixels(parsed), inherited.getStyle()); + } else { + if (inherited != null && family != null && parsed == null) { + parsed = new FontSize(); + parsed.unit = Style.UNIT_TYPE_PIXELS; + parsed.value = inherited.getPixelSize(); + } + font = createFont(family, parsed); + } + theme.put(keyPrefix + Style.FONT, font); } } @@ -586,6 +623,14 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo /// The build-time compiler applies a `cn1-derive` declared in any state to the owning UIID, /// so `Child:selected{cn1-derive:Base}` is also where `Child:pressed` inherits from. private String anyStateDerive(Hashtable theme, String id, boolean dark) { + String recorded = dark ? ownerDerives.get("$Dark" + id) : null; + if (recorded == null) { + recorded = ownerDerives.get(id); + } + if (recorded != null) { + return recorded; + } + // A derive a previous compile into the same theme stored: its order is gone. String[] states = {"sel#", "press#", "dis#", "hover#"}; for (int pass = dark ? 0 : 1; pass < 2; pass++) { String prefix = pass == 0 ? "$Dark" + id + "." : id + "."; @@ -1142,6 +1187,11 @@ private Length length(String value) { throw new CSSSyntaxException("Unsupported unit for padding or margin: " + value); } String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "rem", "vw", "vh", "vmin", "vmax"}); + // A bare number must be whole: the build-time compiler takes `1.5` for a dimension + // missing its unit and fails the build. + if (unit == null && v.indexOf('.') > -1) { + throw new CSSSyntaxException("A unitless padding or margin must be a whole number: " + value); + } float n = number(unit == null ? v : v.substring(0, v.length() - unit.length()), value); if (unit == null || "px".equals(unit) || "pt".equals(unit)) { out.unit = Style.UNIT_TYPE_PIXELS; @@ -1438,6 +1488,8 @@ private static class Declaration { /// The lower case name ordinary CSS properties are matched by. String property; String value; + /// The last declaration of its name across every #Constants block. + boolean winner; } private static class Length { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 10271451b31..39a4a746344 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -711,6 +711,47 @@ public void testRelativeFontSizeKeywordsAreRejected() { .compile("Label{font-size:larger;}", new MutableResource(), "Theme")); } + @Test + public void testCrlfLineContinuationInAQuotedConstant() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{a:\"first\\\r\nsecond\";b:\"x\\\ry\";}", resource, "Theme"); + assertEquals("firstsecond", resource.getTheme("Theme").get("@a")); + assertEquals("xy", resource.getTheme("Theme").get("@b")); + } + + @Test + public void testCustomPropertyInARuleAppliesInDeclarationOrder() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{--accent:#ff0000;color:var(--accent);--accent:#0000ff;}", + resource, "Theme"); + assertEquals("ff0000", resource.getTheme("Theme").get("Label.fgColor")); + } + + @Test + public void testUnitlessDecimalInsetIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{padding:1.5;}", new MutableResource(), "Theme")); + } + + @Test + public void testLastStateDeriveWinsForTheOwningUiid() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("BaseA{font-family:'native:MainBold';}" + + "BaseB{font-family:'native:MainLight';}" + + "Child:selected{cn1-derive:BaseA;}" + + "Child:disabled{cn1-derive:BaseB;}" + + "Child:pressed{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainLight", fontName((Font) resource.getTheme("Theme").get("Child.press#font"))); + } + + @Test + public void testSecondCompileKeepsTheFamilyOfAnEarlierFont() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:'native:MainBold';font-size:10px;}", resource, "Theme"); + new CSSThemeCompiler().compile("Label:selected{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Label.sel#font"))); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index a730d58fcad..a644b6cab55 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5503,7 +5503,7 @@ public com.codename1.ui.Image getImage(String id) { for (String theme : fs.listFiles(res)) { String folder = res + "/" + stripSlash(theme) + "/" + id; if (!fs.isDirectory(folder)) continue; - for (String density : new String[]{"veryhigh.png", "high.png", "medium.png"}) { + for (String density : densityOrder(com.codename1.ui.Display.getInstance().getDeviceDensity())) { image = load(folder + "/" + density); if (image != null) break; } @@ -5526,6 +5526,30 @@ public com.codename1.ui.Image getImage(String id) { return image; } + private static final int[] DENSITIES = { + com.codename1.ui.Display.DENSITY_VERY_LOW, com.codename1.ui.Display.DENSITY_LOW, + com.codename1.ui.Display.DENSITY_MEDIUM, com.codename1.ui.Display.DENSITY_HIGH, + com.codename1.ui.Display.DENSITY_VERY_HIGH, com.codename1.ui.Display.DENSITY_HD, + com.codename1.ui.Display.DENSITY_560, com.codename1.ui.Display.DENSITY_2HD, + com.codename1.ui.Display.DENSITY_4K}; + private static final String[] DENSITY_FILES = { + "verylow.png", "low.png", "medium.png", "high.png", "veryhigh.png", "hd.png", "560.png", "2hd.png", "4k.png"}; + + /** + * The variant files of a multi-image, best first for a display of {@code density}: the + * project build stores every variant and the device picks its own, so the canvas shows + * the one the running density would get -- that density, then the nearest larger one + * (scaled down), then the nearest smaller. + */ + static String[] densityOrder(int density) { + int at = 0; + while (at < DENSITIES.length - 1 && DENSITIES[at] < density) at++; + java.util.List out = new java.util.ArrayList(); + for (int i = at; i < DENSITIES.length; i++) out.add(DENSITY_FILES[i]); + for (int i = at - 1; i >= 0; i--) out.add(DENSITY_FILES[i]); + return out.toArray(new String[out.size()]); + } + private static String stripSlash(String name) { return name.endsWith("/") ? name.substring(0, name.length() - 1) : name; } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 5f47572e12d..e02976a53a4 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1813,6 +1813,16 @@ void anImageConstantResolvesFromTheProjectsResFolder() throws Exception { "the image comes from res//play.png/medium.png"); } + @Test + void anImageVariantIsChosenForTheDisplayDensity() { + String[] high = CodenameOneGUIBuilder.ProjectImageResource.densityOrder(com.codename1.ui.Display.DENSITY_HIGH); + assertEquals("high.png", high[0]); + assertEquals("veryhigh.png", high[1]); + assertEquals("medium.png", high[high.length - 3]); + assertEquals("verylow.png", high[high.length - 1]); + assertEquals("4k.png", CodenameOneGUIBuilder.ProjectImageResource.densityOrder(1000)[0]); + } + private static String describe(Object diagnostic) throws Exception { for (String getter : new String[]{"getMessage", "getDescription", "getText"}) { try { From 6c955f382e1ab1ca428a152b7872e9c5ef64ac7a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:22:10 +0300 Subject: [PATCH 12/59] CSSThemeCompiler: earlier compiles' constants stay text, same-state recompiles keep the font GUI builder: pick image variants the way MultiImage.getBest() does and stop accepting a loose image file the build-time compiler would not find. Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/ui/css/CSSThemeCompiler.java | 17 +++++++++++++---- .../codename1/ui/css/CSSThemeCompilerTest.java | 16 ++++++++++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 18 ++++++++++-------- .../guibuilder/GeneratedSourceTest.java | 17 ++++++++++++++--- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 0e539f8d441..7930009685e 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -191,7 +191,10 @@ private void markLiteralAliases(Hashtable theme, Hashtable defer break; } target = "@" + v.substring("var(--".length(), v.length() - 1).trim(); - if (DEFERRED_LITERAL.equals(deferred.get(target))) { + // A constant a previous compile stored is a final value; when it is text the + // alias must stay text as well. + boolean earlier = deferred.get(target) == null && theme.get(target) instanceof String; + if (DEFERRED_LITERAL.equals(deferred.get(target)) || earlier) { deferred.put(key, DEFERRED_TEXT); break; } @@ -569,7 +572,10 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { if (other != null) { family = family == null ? other[0] : family; size = size == null ? other[1] : size; - } else if (inherited == null) { + } + // Also when this compile has a spec for the key: a recompile of the same + // state that sets only one field keeps the other from the earlier font. + if (inherited == null) { Object existing = theme.get(fallback + Style.FONT); if (existing instanceof Font && ((Font) existing).isTTFNativeFont()) { inherited = (Font) existing; @@ -954,8 +960,11 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso } Object replacement = theme.get("@" + key); String replaceValue = replacement == null ? "" : replacement.toString(); - // A quoted constant is text, also when it is reached through another constant. - if (!DEFERRED_LITERAL.equals(deferredConstants.get("@" + key))) { + // A quoted constant is text, also when it is reached through another constant. So + // is one a previous compile into the same theme left: it is already resolved, and + // any var() still in it was quoted text there. + String kind = deferredConstants.get("@" + key); + if (kind != null && !DEFERRED_LITERAL.equals(kind)) { resolving.add(key); replaceValue = resolveVars(theme, replaceValue, resolving); resolving.remove(resolving.size() - 1); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 39a4a746344..0a23eb91407 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -752,6 +752,22 @@ public void testSecondCompileKeepsTheFamilyOfAnEarlierFont() { assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Label.sel#font"))); } + @Test + public void testSameStateRecompileKeepsTheEarlierFamily() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:'native:MainBold';font-size:10px;}", resource, "Theme"); + new CSSThemeCompiler().compile("Label{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Label.font"))); + } + + @Test + public void testAliasOfAnEarlierCompilesLiteralStaysText() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{message:\"var(--name)\";}", resource, "Theme"); + new CSSThemeCompiler().compile("#Constants{alias:var(--message);}", resource, "Theme"); + assertEquals("var(--name)", resource.getTheme("Theme").get("@alias")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index a644b6cab55..d37967ec2a5 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5478,8 +5478,7 @@ private void loadProjectTheme() { * The canvas compiles into an empty resource, so an image constant such as * {@code #Constants{mediaPlayImage:play.png}} found nothing and the stylesheet was rejected, * although the project build resolves it. This looks an image up where the build-time - * compiler does: {@code res///.png} beside the stylesheet, or a file of - * that name there. Images added to the project's own resource file in the designer are not + * compiler does: {@code res///.png} beside the stylesheet. Images added to the project's own resource file in the designer are not * visible here. */ static final class ProjectImageResource extends MutableResource { @@ -5494,8 +5493,9 @@ static final class ProjectImageResource extends MutableResource { public com.codename1.ui.Image getImage(String id) { com.codename1.ui.Image image = super.getImage(id); if (image != null || cssDir == null || id == null) return image; - image = load(cssDir + "/" + id); - if (image == null) { + // No file of that name beside the stylesheet: the build-time compiler only reads the + // multi-image folder, so the canvas must not accept what the build would reject. + { com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); String res = ProjectIO.fsUrl(cssDir + "/res"); try { @@ -5538,15 +5538,17 @@ public com.codename1.ui.Image getImage(String id) { /** * The variant files of a multi-image, best first for a display of {@code density}: the * project build stores every variant and the device picks its own, so the canvas shows - * the one the running density would get -- that density, then the nearest larger one - * (scaled down), then the nearest smaller. + * the one the running density would get. That is the rule of + * {@code EditableResources.MultiImage.getBest()}: the largest variant not above the + * density, and only when there is none, the nearest larger one. */ static String[] densityOrder(int density) { int at = 0; while (at < DENSITIES.length - 1 && DENSITIES[at] < density) at++; java.util.List out = new java.util.ArrayList(); - for (int i = at; i < DENSITIES.length; i++) out.add(DENSITY_FILES[i]); - for (int i = at - 1; i >= 0; i--) out.add(DENSITY_FILES[i]); + if (DENSITIES[at] > density && at > 0) at--; + for (int i = at; i >= 0; i--) out.add(DENSITY_FILES[i]); + for (int i = at + 1; i < DENSITIES.length; i++) out.add(DENSITY_FILES[i]); return out.toArray(new String[out.size()]); } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index e02976a53a4..8f1c9b3bec4 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1813,13 +1813,24 @@ void anImageConstantResolvesFromTheProjectsResFolder() throws Exception { "the image comes from res//play.png/medium.png"); } + @Test + void anImageBesideTheStylesheetIsNotAccepted() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", dir.resolve("play.png").toFile()); + assertNull(new CodenameOneGUIBuilder.ProjectImageResource(dir.resolve("theme.css").toString()) + .getImage("play.png"), "the build-time compiler reads only res///"); + } + @Test void anImageVariantIsChosenForTheDisplayDensity() { String[] high = CodenameOneGUIBuilder.ProjectImageResource.densityOrder(com.codename1.ui.Display.DENSITY_HIGH); assertEquals("high.png", high[0]); - assertEquals("veryhigh.png", high[1]); - assertEquals("medium.png", high[high.length - 3]); - assertEquals("verylow.png", high[high.length - 1]); + assertEquals("medium.png", high[1]); + assertEquals("verylow.png", high[3]); + assertEquals("veryhigh.png", high[4]); + assertEquals("4k.png", high[high.length - 1]); + assertEquals("verylow.png", CodenameOneGUIBuilder.ProjectImageResource.densityOrder(0)[0]); assertEquals("4k.png", CodenameOneGUIBuilder.ProjectImageResource.densityOrder(1000)[0]); } From a58fdb450627bc47f76b33a3bf2686963f446df8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:36:01 +0300 Subject: [PATCH 13/59] CSSThemeCompiler: strip !important, keep system font families and earlier color aliases GUI builder: search res/Theme before other theme folders. The test implementation now reports the face and size a system font was created with. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 48 +++++++++++++++++-- .../TestCodenameOneImplementation.java | 18 ++++++- .../ui/css/CSSThemeCompilerTest.java | 25 ++++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 8 +++- .../guibuilder/GeneratedSourceTest.java | 15 ++++++ 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 7930009685e..8dfdf45f814 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -191,9 +191,11 @@ private void markLiteralAliases(Hashtable theme, Hashtable defer break; } target = "@" + v.substring("var(--".length(), v.length() - 1).trim(); - // A constant a previous compile stored is a final value; when it is text the - // alias must stay text as well. - boolean earlier = deferred.get(target) == null && theme.get(target) instanceof String; + // A constant a previous compile stored is a final value: a var() still in it was + // quoted text there, and must not be expanded through the alias. Any other earlier + // value is classified like a fresh one, so `#abc` still becomes a color. + Object earlierValue = deferred.get(target) == null ? theme.get(target) : null; + boolean earlier = earlierValue instanceof String && ((String) earlierValue).indexOf("var(--") > -1; if (DEFERRED_LITERAL.equals(deferred.get(target)) || earlier) { deferred.put(key, DEFERRED_TEXT); break; @@ -577,13 +579,24 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { // state that sets only one field keeps the other from the earlier font. if (inherited == null) { Object existing = theme.get(fallback + Style.FONT); - if (existing instanceof Font && ((Font) existing).isTTFNativeFont()) { + if (existing instanceof Font) { inherited = (Font) existing; } } } } } + // A system font keeps only what it can say: monospace, and its system size. + if (inherited != null && !inherited.isTTFNativeFont()) { + if (family == null && inherited.getFace() == Font.FACE_MONOSPACE) { + family = "monospace"; + } + if (size == null) { + size = inherited.getSize() == Font.SIZE_SMALL ? "small" + : inherited.getSize() == Font.SIZE_LARGE ? "large" : "medium"; + } + inherited = null; + } FontSize parsed = size == null ? null : parseFontSize(size); Font font; if (inherited != null && family == null && parsed != null && parsed.unit >= 0) { @@ -1384,6 +1397,31 @@ private String stripComments(String css) { return out.toString(); } + /// Drops a trailing `!important`: the build-time SAC parser reports priority apart from the + /// value and the compiler ignores it, so `includeNativeBool: true !important` stores `true`. + private static String stripPriority(String value) { + String marker = "!important"; + int at = value.length() - marker.length(); + if (at < 0 || !value.regionMatches(true, at, marker, 0, marker.length())) { + return value; + } + int bang = at; + while (bang > 0 && value.charAt(bang - 1) <= ' ') { + bang--; + } + // Inside an unterminated quote the marker is text, not a priority. + int quotes = 0; + for (int i = 0; i < bang; i++) { + char c = value.charAt(i); + if (c == '\\') { + i++; + } else if (c == '"' || c == '\'') { + quotes++; + } + } + return quotes % 2 == 0 ? value.substring(0, bang).trim() : value; + } + private Declaration[] parseDeclarations(String body) { ArrayList out = new ArrayList(); String[] segments = splitDeclarations(body); @@ -1399,7 +1437,7 @@ private Declaration[] parseDeclarations(String body) { Declaration dec = new Declaration(); dec.name = trimmed.substring(0, colon).trim(); dec.property = dec.name.toLowerCase(); - dec.value = trimmed.substring(colon + 1).trim(); + dec.value = stripPriority(trimmed.substring(colon + 1).trim()); if (dec.property.length() == 0 || dec.value.length() == 0) { throw new CSSSyntaxException("Malformed declaration: " + trimmed); } diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index 32a47daa6a4..6b21b7b5f7d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -3233,7 +3233,20 @@ public Object createTestFont(int charWidth, int height) { @Override public Object createFont(int face, int style, int size) { - return new TestFont(defaultFont.charWidth, defaultFont.height); + TestFont font = new TestFont(defaultFont.charWidth, defaultFont.height); + font.face = face; + font.size = size; + return font; + } + + @Override + public int getFace(Object nativeFont) { + return nativeFont instanceof TestFont ? ((TestFont) nativeFont).face : 0; + } + + @Override + public int getSize(Object nativeFont) { + return nativeFont instanceof TestFont ? ((TestFont) nativeFont).size : 0; } @Override @@ -5627,6 +5640,9 @@ public String toString() { public static final class TestFont { final int charWidth; final int height; + /// What a system font was created with; a real port reports these from its native font. + int face; + int size = com.codename1.ui.Font.SIZE_MEDIUM; TestFont(int charWidth, int height) { this.charWidth = charWidth; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 0a23eb91407..fe7117c7e43 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -768,6 +768,31 @@ public void testAliasOfAnEarlierCompilesLiteralStaysText() { assertEquals("var(--name)", resource.getTheme("Theme").get("@alias")); } + @Test + public void testAliasOfAnEarlierUnquotedColorBecomesAColor() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--accent:#abc;}", resource, "Theme"); + new CSSThemeCompiler().compile("#Constants{alias:var(--accent);}", resource, "Theme"); + assertEquals("aabbcc", String.valueOf(resource.getTheme("Theme").get("@alias")).toLowerCase()); + } + + @Test + public void testRecompileKeepsAMonospaceFamily() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:monospace;}", resource, "Theme"); + new CSSThemeCompiler().compile("Label{font-size:20px;}", resource, "Theme"); + assertEquals(Font.FACE_MONOSPACE, ((Font) resource.getTheme("Theme").get("Label.font")).getFace()); + } + + @Test + public void testDeclarationPriorityIsNotPartOfTheValue() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{includeNativeBool:true !important;msg:\"a !important\";}", + resource, "Theme"); + assertEquals("true", resource.getTheme("Theme").get("@includeNativeBool")); + assertEquals("a !important", resource.getTheme("Theme").get("@msg")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index d37967ec2a5..158eeefc326 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5500,7 +5500,13 @@ public com.codename1.ui.Image getImage(String id) { String res = ProjectIO.fsUrl(cssDir + "/res"); try { if (fs.isDirectory(res)) { - for (String theme : fs.listFiles(res)) { + // res/Theme first, as CSSTheme.getResourceImage() does, then the others. + java.util.List themes = new java.util.ArrayList(); + themes.add("Theme"); + for (String listed : fs.listFiles(res)) { + if (!"Theme".equals(stripSlash(listed))) themes.add(listed); + } + for (String theme : themes) { String folder = res + "/" + stripSlash(theme) + "/" + id; if (!fs.isDirectory(folder)) continue; for (String density : densityOrder(com.codename1.ui.Display.getInstance().getDeviceDensity())) { diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 8f1c9b3bec4..367ff9d559d 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1813,6 +1813,21 @@ void anImageConstantResolvesFromTheProjectsResFolder() throws Exception { "the image comes from res//play.png/medium.png"); } + @Test + void theThemeFolderNamedThemeIsSearchedFirst() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + for (String theme : new String[]{"Aaa", "Theme"}) { + java.nio.file.Path folder = dir.resolve("res").resolve(theme).resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + int size = "Theme".equals(theme) ? 3 : 2; + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(size, size, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); + } + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertEquals(3, image.getWidth()); + } + @Test void anImageBesideTheStylesheetIsNotAccepted() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); From 0fd2f0df4f7ad404a2e976f9b398b5ccd666c4f0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:11:51 +0300 Subject: [PATCH 14/59] CSSThemeCompiler: a quoted custom property stays a string token in styles GUI builder: accept upper-case .PNG variants; document why the denser-only fallback stays deterministic. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 22 +++++++++++++++---- .../ui/css/CSSThemeCompilerTest.java | 9 ++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 7 ++++-- .../guibuilder/GeneratedSourceTest.java | 11 ++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 8dfdf45f814..c2a7375ee35 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -101,6 +101,11 @@ public CSSSyntaxException(String message, Throwable cause) { /// order: the build-time compiler sets every such derive on the owning UIID, so the last one wins. private Hashtable ownerDerives = new Hashtable(); + /// The quoted source of each string constant this compile declared. A style property that + /// uses one gets the string token, not its text: the build-time compiler keeps the token's + /// type, so `color: var(--accent)` with `--accent: "#f00"` is not a color there either. + private Hashtable literalTokens = new Hashtable(); + public void compile(String css, MutableResource resources, String themeName) { Hashtable theme = resources.getTheme(themeName); if (theme == null) { @@ -116,6 +121,7 @@ public void compile(String css, MutableResource resources, String themeName) { // the same theme are never re-resolved without the typing they were declared with. Hashtable deferred = new Hashtable(); deferredConstants = deferred; + literalTokens = new Hashtable(); // #Constants entries first, wherever their block sits, so every rule sees their final // values -- the build-time compiler reads that block before it compiles any rule. Hashtable finalConstants = new Hashtable(); @@ -324,10 +330,12 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, String raw = declaration.value.trim(); String value = unquote(raw); deferred.put(key, DEFERRED_PLAIN); + literalTokens.remove(key); if (name.endsWith("Image")) { deferred.put(key, DEFERRED_IMAGE); } else if (!raw.equals(value)) { deferred.put(key, DEFERRED_LITERAL); + literalTokens.put(key, raw); } else if (isColorLiteral(value)) { // Stored as plain hex, as the build-time compiler stores it: color constants // are read with Integer.parseInt(value, 16), which a leading '#' would break. @@ -461,7 +469,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha if (applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred)) { continue; } - String value = resolveVars(theme, declaration.value); + String value = resolveVars(theme, declaration.value, new ArrayList(), true); if ("cn1-derive".equals(property) && statePrefix.length() > 0) { ownerDerives.put(uiid, value.trim()); } @@ -496,8 +504,10 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str // A later custom property replaces a #Constants entry outright, deferred handling included. // An unquoted var() in it is resolved where it is used, against the final values. deferred.put(key, DEFERRED_PLAIN); + literalTokens.remove(key); if (!raw.equals(unquoted)) { deferred.put(key, DEFERRED_LITERAL); + literalTokens.put(key, raw); } theme.put(key, unquoted); return true; @@ -949,13 +959,13 @@ private String expandBorderShorthand(String value) { } private String resolveVars(Hashtable theme, String value) { - return resolveVars(theme, value, new ArrayList()); + return resolveVars(theme, value, new ArrayList(), false); } /// Expands every `var(--x)`, including those inside the values it substitutes. `resolving` /// holds the names being expanded, so `a: var(--b); b: var(--a)` is an error instead of a /// compile that never returns. - private String resolveVars(Hashtable theme, String value, ArrayList resolving) { + private String resolveVars(Hashtable theme, String value, ArrayList resolving, boolean tokens) { int varPos = value.indexOf("var(--"); if (varPos < 0) { return value; @@ -973,13 +983,17 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso } Object replacement = theme.get("@" + key); String replaceValue = replacement == null ? "" : replacement.toString(); + String token = tokens ? literalTokens.get("@" + key) : null; + if (token != null) { + replaceValue = token; + } // A quoted constant is text, also when it is reached through another constant. So // is one a previous compile into the same theme left: it is already resolved, and // any var() still in it was quoted text there. String kind = deferredConstants.get("@" + key); if (kind != null && !DEFERRED_LITERAL.equals(kind)) { resolving.add(key); - replaceValue = resolveVars(theme, replaceValue, resolving); + replaceValue = resolveVars(theme, replaceValue, resolving, tokens); resolving.remove(resolving.size() - 1); } out.append(value.substring(pos, varPos)).append(replaceValue); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index fe7117c7e43..5bdaaa4c88b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -793,6 +793,15 @@ public void testDeclarationPriorityIsNotPartOfTheValue() { assertEquals("a !important", resource.getTheme("Theme").get("@msg")); } + @Test + public void testQuotedCustomPropertyIsNotAColorInAStyle() { + MutableResource resource = new MutableResource(); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{--accent:\"#f00\";} Label{color:var(--accent);}", resource, "Theme")); + new CSSThemeCompiler().compile("#Constants{--accent:\"#f00\";}", resource, "Theme"); + assertEquals("#f00", resource.getTheme("Theme").get("@accent")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 158eeefc326..60433e9d7c6 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5515,7 +5515,8 @@ public com.codename1.ui.Image getImage(String id) { } if (image == null) { for (String file : fs.listFiles(folder)) { - if (file.endsWith(".png")) { + // Any case, as CSSTheme.getResourceImage() accepts medium.PNG. + if (file.regionMatches(true, file.length() - 4, ".png", 0, 4)) { image = load(folder + "/" + file); if (image != null) break; } @@ -5546,7 +5547,9 @@ public com.codename1.ui.Image getImage(String id) { * project build stores every variant and the device picks its own, so the canvas shows * the one the running density would get. That is the rule of * {@code EditableResources.MultiImage.getBest()}: the largest variant not above the - * density, and only when there is none, the nearest larger one. + * density. When every variant is denser, getBest() takes whichever file + * {@code File.listFiles()} happened to return first, an order the file system does not + * define; that is not reproducible, so the canvas takes the nearest larger one. */ static String[] densityOrder(int density) { int at = 0; diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 367ff9d559d..4560a5394c5 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1828,6 +1828,17 @@ void theThemeFolderNamedThemeIsSearchedFirst() throws Exception { assertEquals(3, image.getWidth()); } + @Test + void anUpperCaseVariantNameIsAccepted() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("custom.PNG").toFile()); + assertNotNull(new CodenameOneGUIBuilder.ProjectImageResource(dir.resolve("theme.css").toString()) + .getImage("play.png")); + } + @Test void anImageBesideTheStylesheetIsNotAccepted() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); From b70aedb542b0b1918a8161ac12a360aa46317028 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:29:27 +0300 Subject: [PATCH 15/59] CSSThemeCompiler: last derive wins for every state, build-time family walk, CSS number syntax GUI builder: density variants match in any case. Documents why quoted typing is not recovered across separate compile() calls. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 69 ++++++++++++++++++- .../ui/css/CSSThemeCompilerTest.java | 31 +++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 12 +++- .../guibuilder/GeneratedSourceTest.java | 14 ++++ 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index c2a7375ee35..770effdbae8 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -197,6 +197,10 @@ private void markLiteralAliases(Hashtable theme, Hashtable defer break; } target = "@" + v.substring("var(--".length(), v.length() - 1).trim(); + // Across separate compile() calls the theme keeps only final values, and a quoted + // "#abc" is stored exactly like an unquoted #abc custom property, so whether an + // earlier value was quoted is not recoverable from it. Compiling the stylesheet + // whole, as live reload and the GUI builder do, keeps the exact typing. // A constant a previous compile stored is a final value: a var() still in it was // quoted text there, and must not be expanded through the alias. Any other earlier // value is classified like a fresh one, so `#abc` still becomes a color. @@ -470,7 +474,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha continue; } String value = resolveVars(theme, declaration.value, new ArrayList(), true); - if ("cn1-derive".equals(property) && statePrefix.length() > 0) { + if ("cn1-derive".equals(property)) { ownerDerives.put(uiid, value.trim()); } if (applyFontProperty(fonts, uiid + "." + statePrefix, property, value)) { @@ -630,6 +634,17 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo ArrayList chain = new ArrayList(); while (id != null && !chain.contains(id)) { chain.add(id); + // A derive this compile declared, in any state: the last one in source order is the + // owning UIID's only parent in the build-time compiler, for every state. + String owner = dark ? ownerDerives.get("$Dark" + id) : null; + if (owner == null) { + owner = ownerDerives.get(id); + } + if (owner != null) { + id = owner; + continue; + } + // Otherwise a derive a previous compile into the same theme stored. Object stateDerive = null; if (state.length() > 0) { stateDerive = dark ? theme.get("$Dark" + id + "." + state + "derive") : null; @@ -680,9 +695,11 @@ private Font createFont(String familyList, FontSize size) { String[] families = splitOnComma(familyList); for (String entry : families) { String family = unquote(entry); + // The build-time compiler walks on past a native font, so a later one, or a later + // monospace, wins; only a generic family or monospace ends the walk. if (family.startsWith("native:")) { ttfName = family; - break; + continue; } if ("monospace".equalsIgnoreCase(family)) { monospace = true; @@ -822,6 +839,11 @@ private static float number(String text, String original) { if (t.length() == 0) { throw new CSSSyntaxException("Missing number in: " + original); } + // CSS number syntax only: Float.parseFloat would also take 1f, 1d, 0x1p3, NaN and + // Infinity, which the build-time parser reads as a number followed by a unit. + if (!isCssNumber(t)) { + throw new CSSSyntaxException("Invalid number in: " + original); + } try { return Float.parseFloat(t); } catch (NumberFormatException err) { @@ -829,6 +851,49 @@ private static float number(String text, String original) { } } + /// `[+-]? (digits | digits? . digits) ([eE] [+-]? digits)?`, the CSS number token. + private static boolean isCssNumber(String t) { + int i = 0; + int n = t.length(); + if (i < n && (t.charAt(i) == '+' || t.charAt(i) == '-')) { + i++; + } + int intDigits = 0; + while (i < n && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i++; + intDigits++; + } + int fracDigits = 0; + if (i < n && t.charAt(i) == '.') { + i++; + while (i < n && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i++; + fracDigits++; + } + if (fracDigits == 0) { + return false; + } + } + if (intDigits + fracDigits == 0) { + return false; + } + if (i < n && (t.charAt(i) == 'e' || t.charAt(i) == 'E')) { + i++; + if (i < n && (t.charAt(i) == '+' || t.charAt(i) == '-')) { + i++; + } + int expDigits = 0; + while (i < n && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i++; + expDigits++; + } + if (expDigits == 0) { + return false; + } + } + return i == n; + } + private static boolean endsWithIgnoreCase(String value, String suffix) { int start = value.length() - suffix.length(); return start >= 0 && value.regionMatches(true, start, suffix, 0, suffix.length()); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 5bdaaa4c88b..00d4a1ae923 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -802,6 +802,37 @@ public void testQuotedCustomPropertyIsNotAColorInAStyle() { assertEquals("#f00", resource.getTheme("Theme").get("@accent")); } + @Test + public void testTheLastDeriveWinsAlsoForAStateWithItsOwn() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("BaseA{font-family:'native:MainBold';}" + + "BaseB{font-family:'native:MainLight';}" + + "Child:selected{cn1-derive:BaseA;}" + + "Child:disabled{cn1-derive:BaseB;}" + + "Child:selected{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainLight", fontName((Font) resource.getTheme("Theme").get("Child.sel#font"))); + } + + @Test + public void testFamilyWalkContinuesPastANativeFont() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("A{font-family:'native:MainBold',monospace;}" + + "B{font-family:'native:MainBold','native:MainLight';}", resource, "Theme"); + assertEquals(Font.FACE_MONOSPACE, ((Font) resource.getTheme("Theme").get("A.font")).getFace()); + assertEquals("native:MainLight", fontName((Font) resource.getTheme("Theme").get("B.font"))); + } + + @Test + public void testJavaOnlyNumberSpellingsAreRejected() { + for (String bad : new String[]{"1fpx", "NaNpx", "Infinitypx", "1dpx", "0x1p3px", "1.px"}) { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{padding:" + bad + ";}", new MutableResource(), "Theme"), bad); + } + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{padding:.5mm 1e1px +2px -0px;}", resource, "Theme"); + assertEquals("0.5,2,0,10", resource.getTheme("Theme").get("Label.padding")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 60433e9d7c6..20c335d0c76 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5509,12 +5509,20 @@ public com.codename1.ui.Image getImage(String id) { for (String theme : themes) { String folder = res + "/" + stripSlash(theme) + "/" + id; if (!fs.isDirectory(folder)) continue; + // Variant names match in any case, as the build-time compiler's do, so + // medium.PNG is still the medium variant and wins over veryhigh.png. + String[] files = fs.listFiles(folder); for (String density : densityOrder(com.codename1.ui.Display.getInstance().getDeviceDensity())) { - image = load(folder + "/" + density); + for (String file : files) { + if (density.equalsIgnoreCase(stripSlash(file))) { + image = load(folder + "/" + file); + break; + } + } if (image != null) break; } if (image == null) { - for (String file : fs.listFiles(folder)) { + for (String file : files) { // Any case, as CSSTheme.getResourceImage() accepts medium.PNG. if (file.regionMatches(true, file.length() - 4, ".png", 0, 4)) { image = load(folder + "/" + file); diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 4560a5394c5..2e4a091131d 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1839,6 +1839,20 @@ void anUpperCaseVariantNameIsAccepted() throws Exception { .getImage("play.png")); } + @Test + void anUpperCaseDensityVariantBeatsADenserOne() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("verylow.PNG").toFile()); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("4k.png").toFile()); + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertEquals(3, image.getWidth(), "every display density is at least very low"); + } + @Test void anImageBesideTheStylesheetIsNotAccepted() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); From 25236ab53e7e00645c32a2160a92555ef4212fe7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:27:19 +0300 Subject: [PATCH 16/59] CSSThemeCompiler: #Constants aliases resolve where declared GUI builder: unnamed PNG variants count as medium density and the parent folder's res is searched, as the build-time compiler does. Documents why an incremental derive change does not rebuild earlier fonts. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 60 +++++++++- .../ui/css/CSSThemeCompilerTest.java | 12 ++ .../guibuilder/CodenameOneGUIBuilder.java | 103 +++++++++++------- .../guibuilder/GeneratedSourceTest.java | 19 ++++ 4 files changed, 151 insertions(+), 43 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 770effdbae8..a17b7fb0d90 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -345,12 +345,59 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, // are read with Integer.parseInt(value, 16), which a leading '#' would break. value = constantColor(value); } else if (value.indexOf("var(--") > -1) { - deferred.put(key, DEFERRED_VAR); + String kind = DEFERRED_VAR; + if (declaration.snapshot == null && referencesDeclaredOnly(theme, key, value, deferred)) { + // Every name it uses is already declared: the build-time compiler resolves it + // right here, so a later redefinition of a target does not reach it. Kept on + // the declaration, because the second pass sees the final values. + String target = aliasTarget(value); + String targetKind = target == null ? null : deferred.get(target); + declaration.snapshotKind = DEFERRED_LITERAL.equals(targetKind) || DEFERRED_TEXT.equals(targetKind) + ? DEFERRED_LITERAL : DEFERRED_VAR; + declaration.snapshot = resolveVars(theme, value); + } + if (declaration.snapshot != null) { + value = declaration.snapshot; + kind = declaration.snapshotKind; + } + deferred.put(key, kind); } theme.put(key, value); } } + /// True when every `var(--x)` in `value` names a constant this compile already declared and + /// that is itself settled: not an alias still waiting for a name declared later. + private static boolean referencesDeclaredOnly(Hashtable theme, String key, String value, + Hashtable deferred) { + int at = value.indexOf("var(--"); + while (at > -1) { + int end = value.indexOf(')', at); + if (end < 0) { + return false; + } + String target = "@" + value.substring(at + "var(--".length(), end).trim(); + String kind = deferred.get(target); + Object current = theme.get(target); + // Its own name is never settled: that is a cycle, reported when it is resolved. + if (kind == null || target.equals(key) || (DEFERRED_VAR.equals(kind) && current instanceof String + && ((String) current).indexOf("var(--") > -1)) { + return false; + } + at = value.indexOf("var(--", end); + } + return true; + } + + /// `@x` for a value that is exactly `var(--x)`, else null. + private static String aliasTarget(String value) { + String v = value.trim(); + if (!v.startsWith("var(--") || v.indexOf(')') != v.length() - 1) { + return null; + } + return "@" + v.substring("var(--".length(), v.length() - 1).trim(); + } + /// UIManager.getThemeImageConstant casts an image constant to Image, and on iOS that cast is /// unchecked: a String left in its place would be read as an Image and crash natively. The /// build-time compiler fails the build on a missing image too. @@ -545,6 +592,12 @@ private boolean applyFontProperty(Hashtable fonts, String keyP /// compiler's flattening does: the matching state of the UIID, each `cn1-derive` parent and /// finally `*` in turn, then their unselected styles in the same order. A dark style looks at the dark keys /// of each UIID before the light ones. + /// + /// Only the states this compile declares a font property for are built. A font an earlier + /// compile into the same theme built is a finished Font, and which parent its family came + /// from is not stored, so a later compile that only changes a `cn1-derive` cannot tell which + /// earlier fonts depended on it. Compiling the stylesheet whole, as live reload and the GUI + /// builder do, rebuilds every font. private void compileFonts(Hashtable theme, Hashtable fonts) { // A font is a live platform object, so it can only be made with a running Display. // Without one -- a tool that only checks that a stylesheet compiles -- every @@ -1616,6 +1669,11 @@ private static class Declaration { String value; /// The last declaration of its name across every #Constants block. boolean winner; + /// A #Constants alias resolved where it was declared, and how to type it (an alias of + /// text is final text, so it is typed like the quoted constant itself); null when it + /// names something declared later and is resolved against the final values instead. + String snapshot; + String snapshotKind; } private static class Length { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 00d4a1ae923..80a97aa1a4e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -833,6 +833,18 @@ public void testJavaOnlyNumberSpellingsAreRejected() { assertEquals("0.5,2,0,10", resource.getTheme("Theme").get("Label.padding")); } + @Test + public void testConstantsAliasKeepsTheValueAtItsDeclaration() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#f00;alias:var(--accent);--accent:#00f;" + + "early:var(--later);later:#0f0;lit:\"#abc\";litAlias:var(--lit);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", String.valueOf(theme.get("@alias")).toLowerCase()); + assertEquals("0000ff", String.valueOf(theme.get("@accent")).toLowerCase()); + assertEquals("00ff00", String.valueOf(theme.get("@early")).toLowerCase()); + assertEquals("#abc", theme.get("@litAlias")); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 20c335d0c76..602e2a6094a 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5485,8 +5485,9 @@ static final class ProjectImageResource extends MutableResource { private final String cssDir; ProjectImageResource(String cssFile) { - int slash = cssFile == null ? -1 : cssFile.replace('\\', '/').lastIndexOf('/'); - cssDir = slash < 0 ? null : cssFile.substring(0, slash); + String path = cssFile == null ? null : cssFile.replace('\\', '/'); + int slash = path == null ? -1 : path.lastIndexOf('/'); + cssDir = slash < 0 ? null : path.substring(0, slash); } @Override @@ -5494,53 +5495,71 @@ public com.codename1.ui.Image getImage(String id) { com.codename1.ui.Image image = super.getImage(id); if (image != null || cssDir == null || id == null) return image; // No file of that name beside the stylesheet: the build-time compiler only reads the - // multi-image folder, so the canvas must not accept what the build would reject. - { - com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); - String res = ProjectIO.fsUrl(cssDir + "/res"); - try { - if (fs.isDirectory(res)) { - // res/Theme first, as CSSTheme.getResourceImage() does, then the others. - java.util.List themes = new java.util.ArrayList(); - themes.add("Theme"); - for (String listed : fs.listFiles(res)) { - if (!"Theme".equals(stripSlash(listed))) themes.add(listed); - } - for (String theme : themes) { - String folder = res + "/" + stripSlash(theme) + "/" + id; - if (!fs.isDirectory(folder)) continue; - // Variant names match in any case, as the build-time compiler's do, so - // medium.PNG is still the medium variant and wins over veryhigh.png. - String[] files = fs.listFiles(folder); - for (String density : densityOrder(com.codename1.ui.Display.getInstance().getDeviceDensity())) { - for (String file : files) { - if (density.equalsIgnoreCase(stripSlash(file))) { - image = load(folder + "/" + file); - break; - } - } - if (image != null) break; - } - if (image == null) { - for (String file : files) { - // Any case, as CSSTheme.getResourceImage() accepts medium.PNG. - if (file.regionMatches(true, file.length() - 4, ".png", 0, 4)) { - image = load(folder + "/" + file); - if (image != null) break; - } - } - } - if (image != null) break; - } + // multi-image folder, so the canvas must not accept what the build would reject. It + // looks in the res folder beside the stylesheet, then the one a level up (a Maven + // project's src/main/res, an Ant project's own res). + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + int slash = cssDir.lastIndexOf('/'); + String[] roots = slash < 0 ? new String[]{cssDir + "/res"} + : new String[]{cssDir + "/res", cssDir.substring(0, slash) + "/res"}; + try { + for (int r = 0; r < roots.length && image == null; r++) { + String res = ProjectIO.fsUrl(roots[r]); + if (!fs.isDirectory(res)) continue; + // res/Theme first, as CSSTheme.getResourceImage() does, then the others. + java.util.List themes = new java.util.ArrayList(); + themes.add("Theme"); + for (String listed : fs.listFiles(res)) { + if (!"Theme".equals(stripSlash(listed))) themes.add(listed); + } + for (String theme : themes) { + String folder = res + "/" + stripSlash(theme) + "/" + id; + if (!fs.isDirectory(folder)) continue; + image = bestVariant(folder, fs.listFiles(folder)); + if (image != null) break; } - } catch (IOException ignored) { - // An unreadable folder is an image that is not there. } + } catch (IOException ignored) { + // An unreadable folder is an image that is not there. } if (image != null) setImage(id, image); return image; } + /** + * Classifies every PNG of a multi-image folder the way the build-time compiler does -- + * a density by its name in any case, any other name as medium -- and loads the one the + * display density gets. A named medium file wins over another medium-classified one. + */ + private static com.codename1.ui.Image bestVariant(String folder, String[] files) { + for (String density : densityOrder(com.codename1.ui.Display.getInstance().getDeviceDensity())) { + String match = null; + for (String listed : files) { + String file = stripSlash(listed); + if (!file.regionMatches(true, file.length() - 4, ".png", 0, 4)) continue; + if (density.equalsIgnoreCase(file)) { + match = file; + break; + } + if ("medium.png".equals(density) && match == null && !isDensityName(file)) { + match = file; + } + } + if (match != null) { + com.codename1.ui.Image image = load(folder + "/" + match); + if (image != null) return image; + } + } + return null; + } + + private static boolean isDensityName(String file) { + for (String name : DENSITY_FILES) { + if (name.equalsIgnoreCase(file)) return true; + } + return false; + } + private static final int[] DENSITIES = { com.codename1.ui.Display.DENSITY_VERY_LOW, com.codename1.ui.Display.DENSITY_LOW, com.codename1.ui.Display.DENSITY_MEDIUM, com.codename1.ui.Display.DENSITY_HIGH, diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 2e4a091131d..061dec5257c 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1853,6 +1853,25 @@ void anUpperCaseDensityVariantBeatsADenserOne() throws Exception { assertEquals(3, image.getWidth(), "every display density is at least very low"); } + @Test + void anUnnamedVariantCountsAsMediumAndTheParentResIsSearched() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("custom.png").toFile()); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("verylow.png").toFile()); + java.nio.file.Path css = dir.resolve("css"); + java.nio.file.Files.createDirectories(css); + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + css.resolve("theme.css").toString()).getImage("play.png"); + assertNotNull(image, "src/main/res beside src/main/css is searched"); + int expected = com.codename1.ui.Display.getInstance().getDeviceDensity() + >= com.codename1.ui.Display.DENSITY_MEDIUM ? 3 : 2; + assertEquals(expected, image.getWidth()); + } + @Test void anImageBesideTheStylesheetIsNotAccepted() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); From 81cdb2cfa843eafe1d74b889f35cc38a11ed7103 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 08:36:55 +0300 Subject: [PATCH 17/59] CSSThemeCompiler: #Constants in source order, var() fallbacks; CSSTheme: font-size medium The build-time parser resolves var() as it reads each declaration, so a rule above a #Constants block no longer sees it; the runtime-only @constants block keeps applying first. The build-time compiler stored font-size: medium as a zero-size pixel font. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 127 +++++++++++++----- .../ui/css/CSSThemeCompilerTest.java | 22 ++- .../com/codename1/designer/css/CSSTheme.java | 7 + 3 files changed, 116 insertions(+), 40 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index a17b7fb0d90..9d73270f367 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -122,36 +122,25 @@ public void compile(String css, MutableResource resources, String themeName) { Hashtable deferred = new Hashtable(); deferredConstants = deferred; literalTokens = new Hashtable(); - // #Constants entries first, wherever their block sits, so every rule sees their final - // values -- the build-time compiler reads that block before it compiles any rule. - Hashtable finalConstants = new Hashtable(); + // Everything in source order, #Constants blocks included: the build-time parser resolves + // var() in each declaration as it reads it, and a `--name` inside #Constants enters its + // variable map only when that block is reached. A rule above the block does not see it. + // The legacy `@constants` block is this compiler's own syntax, which the build-time + // compiler does not read; it has always been applied before every rule, so a rule may + // use it from above. for (Rule rule : rules) { - if (rule.constants) { + if (rule.legacyConstants) { compileConstants(rule.declarations, theme, deferred); - for (Declaration declaration : rule.declarations) { - Declaration previous = finalConstants.put(declaration.name, declaration); - if (previous != null) { - previous.winner = false; - } - declaration.winner = true; - } } } - // Then source order. A `:root` or in-rule custom property takes effect where it is - // declared, as the build-time parser's callbacks do: a rule above it does not see it. - // Only the winning declaration of a #Constants name is applied again here, so it still - // overrides a custom property declared before it. Hashtable fonts = new Hashtable(); ownerDerives = new Hashtable(); for (Rule rule : rules) { + if (rule.legacyConstants) { + continue; + } if (rule.constants) { - ArrayList winners = new ArrayList(); - for (Declaration declaration : rule.declarations) { - if (declaration.winner) { - winners.add(declaration); - } - } - compileConstants(winners.toArray(new Declaration[winners.size()]), theme, deferred); + compileConstants(rule.declarations, theme, deferred); continue; } applyRule(theme, resources, rule, fonts, deferred); @@ -192,11 +181,11 @@ private void markLiteralAliases(Hashtable theme, Hashtable defer String target = key; while (value instanceof String && !seen.contains(target)) { seen.add(target); - String v = ((String) value).trim(); - if (!v.startsWith("var(--") || v.indexOf(')') != v.length() - 1) { + String next = aliasTarget((String) value); + if (next == null) { break; } - target = "@" + v.substring("var(--".length(), v.length() - 1).trim(); + target = next; // Across separate compile() calls the theme keeps only final values, and a quoted // "#abc" is stored exactly like an unquoted #abc custom property, so whether an // earlier value was quoted is not recoverable from it. Compiling the stylesheet @@ -372,11 +361,13 @@ private static boolean referencesDeclaredOnly(Hashtable theme, String key, Strin Hashtable deferred) { int at = value.indexOf("var(--"); while (at > -1) { - int end = value.indexOf(')', at); + int end = closingParen(value, at + "var(".length()); if (end < 0) { return false; } - String target = "@" + value.substring(at + "var(--".length(), end).trim(); + String inner = value.substring(at + "var(--".length(), end); + int comma = topLevelComma(inner); + String target = "@" + (comma < 0 ? inner : inner.substring(0, comma)).trim(); String kind = deferred.get(target); Object current = theme.get(target); // Its own name is never settled: that is a cycle, reported when it is resolved. @@ -389,13 +380,15 @@ private static boolean referencesDeclaredOnly(Hashtable theme, String key, Strin return true; } - /// `@x` for a value that is exactly `var(--x)`, else null. + /// `@x` for a value that is exactly `var(--x)` or `var(--x, fallback)`, else null. private static String aliasTarget(String value) { String v = value.trim(); - if (!v.startsWith("var(--") || v.indexOf(')') != v.length() - 1) { + if (!v.startsWith("var(--") || closingParen(v, "var(".length()) != v.length() - 1) { return null; } - return "@" + v.substring("var(--".length(), v.length() - 1).trim(); + String inner = v.substring("var(--".length(), v.length() - 1); + int comma = topLevelComma(inner); + return "@" + (comma < 0 ? inner : inner.substring(0, comma)).trim(); } /// UIManager.getThemeImageConstant casts an image constant to Image, and on iOS that cast is @@ -1091,15 +1084,26 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso StringBuilder out = new StringBuilder(); int pos = 0; while (varPos > -1) { - int end = value.indexOf(')', varPos); + int end = closingParen(value, varPos + "var(".length()); if (end < 0) { break; } - String key = value.substring(varPos + "var(--".length(), end).trim(); + // `var(--name, fallback)`: the build-time compiler takes the fallback when the + // variable is not defined. + String inner = value.substring(varPos + "var(--".length(), end); + int comma = topLevelComma(inner); + String key = (comma < 0 ? inner : inner.substring(0, comma)).trim(); + String fallback = comma < 0 ? null : inner.substring(comma + 1).trim(); if (resolving.contains(key)) { throw new CSSSyntaxException("Cyclic variable reference: var(--" + key + ")"); } Object replacement = theme.get("@" + key); + if (replacement == null && fallback != null) { + out.append(value.substring(pos, varPos)).append(resolveVars(theme, fallback, resolving, tokens)); + pos = end + 1; + varPos = value.indexOf("var(--", pos); + continue; + } String replaceValue = replacement == null ? "" : replacement.toString(); String token = tokens ? literalTokens.get("@" + key) : null; if (token != null) { @@ -1122,6 +1126,58 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso return out.toString(); } + /// The index of the `)` closing the parenthesis opened just before `from`, skipping nested + /// parentheses and quoted text; -1 when it is not closed. + private static int closingParen(String value, int from) { + int depth = 0; + char quote = 0; + for (int i = from; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + if (depth == 0) { + return i; + } + depth--; + } + } + return -1; + } + + /// The first comma outside parentheses and quotes, or -1. + private static int topLevelComma(String value) { + int depth = 0; + char quote = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (c == ',' && depth == 0) { + return i; + } + } + return -1; + } + private String[] selector(String selector) { String statePrefix = ""; String uiid = selector.trim(); @@ -1394,6 +1450,7 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext Rule constants = new Rule(); constants.selector = selectors; constants.constants = true; + constants.legacyConstants = selectors.startsWith("@"); constants.declarations = parseDeclarations(css.substring(open + 1, close)); out.add(constants); pos = close + 1; @@ -1659,6 +1716,8 @@ private static class Rule { String selector; Declaration[] declarations; boolean constants; + /// An `@constants` block rather than `#Constants`. + boolean legacyConstants; } private static class Declaration { @@ -1667,11 +1726,9 @@ private static class Declaration { /// The lower case name ordinary CSS properties are matched by. String property; String value; - /// The last declaration of its name across every #Constants block. - boolean winner; /// A #Constants alias resolved where it was declared, and how to type it (an alias of /// text is final text, so it is typed like the quoted constant itself); null when it - /// names something declared later and is resolved against the final values instead. + /// names something not declared yet and is resolved against the final values instead. String snapshot; String snapshotKind; } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 80a97aa1a4e..b15997776d6 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -487,10 +487,10 @@ public void testLaterDeclarationWinsBetweenRootAndConstants() { new CSSThemeCompiler().compile("#Constants{--accent:#ff0000;} :root{--accent:#00ff00;}" + "Label{color:var(--accent);}", resource, "Theme"); assertEquals("#00ff00", resource.getTheme("Theme").get("@accent")); - // A constant declared after the rule that uses it is still seen by that rule. - resource = new MutableResource(); - new CSSThemeCompiler().compile("Label{color:var(--late);} #Constants{late:#0000ff;}", resource, "Theme"); - assertEquals("0000ff", resource.getTheme("Theme").get("Label.fgColor")); + // A constant declared after the rule that uses it is not seen by that rule: the build-time + // parser resolves var() in source order, #Constants blocks included. + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{color:var(--late);} #Constants{--late:#0000ff;}", new MutableResource(), "Theme")); } @Test @@ -619,7 +619,8 @@ public void testRulesSeeTheFinalValueOfARedeclaredConstant() { resource, "Theme"); Hashtable theme = resource.getTheme("Theme"); assertEquals("0000ff", theme.get("@accent")); - assertEquals("0000ff", theme.get("Label.fgColor")); + // The rule sits between the two blocks, so it sees the first value. + assertEquals("ff0000", theme.get("Label.fgColor")); // A :root property takes effect where it is declared, as in the build-time parser. assertEquals("ff0000", theme.get("Button.bgColor")); assertEquals("#00ff00", theme.get("@bg")); @@ -845,6 +846,17 @@ public void testConstantsAliasKeepsTheValueAtItsDeclaration() { assertEquals("#abc", theme.get("@litAlias")); } + @Test + public void testVarFallbackIsUsedForAnUndefinedVariable() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{accentColor:var(--missing, #f00);--set:#00f;" + + "setColor:var(--set, #f00);} Label{color:var(--nope, rgb(0, 255, 0));}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", String.valueOf(theme.get("@accentColor")).toLowerCase()); + assertEquals("0000ff", String.valueOf(theme.get("@setColor")).toLowerCase()); + assertEquals("00ff00", String.valueOf(theme.get("Label.fgColor")).toLowerCase()); + } + private static String fontName(Font font) { try { java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index ef03d5ecf6c..7e14161c15e 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -6831,6 +6831,13 @@ public com.codename1.ui.Font getThemeFont(Map styles) { ttfSizeType = TTF_SIZE_TYPE_LARGE; sysSize = Font.SIZE_LARGE; break outer; + case "medium": + // Without this case `medium` fell through to the pixel branch + // and stored a zero-size font. + iFontSizeType = Font.SIZE_MEDIUM; + ttfSizeType = TTF_SIZE_TYPE_MEDIUM; + sysSize = Font.SIZE_MEDIUM; + break outer; } } From 42597d53ed3a4d48e556f113c0a1530e3063ca7f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:08:19 +0300 Subject: [PATCH 18/59] CSSThemeCompiler: track the open quote for !important, reject #Constants lengths; GUI builder: case-sensitive density names Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 40 +++++++++++++++---- .../ui/css/CSSThemeCompilerTest.java | 18 +++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 7 ++-- .../guibuilder/GeneratedSourceTest.java | 10 +++-- 4 files changed, 60 insertions(+), 15 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 9d73270f367..3c14da0199c 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -130,7 +130,7 @@ public void compile(String css, MutableResource resources, String themeName) { // use it from above. for (Rule rule : rules) { if (rule.legacyConstants) { - compileConstants(rule.declarations, theme, deferred); + compileConstants(rule.declarations, theme, deferred, true); } } Hashtable fonts = new Hashtable(); @@ -140,7 +140,7 @@ public void compile(String css, MutableResource resources, String themeName) { continue; } if (rule.constants) { - compileConstants(rule.declarations, theme, deferred); + compileConstants(rule.declarations, theme, deferred, false); continue; } applyRule(theme, resources, rule, fonts, deferred); @@ -311,7 +311,7 @@ private void resolveThemeConstantVars(Hashtable theme, Hashtable } private void compileConstants(Declaration[] declarations, Hashtable theme, - Hashtable deferred) { + Hashtable deferred, boolean legacy) { for (Declaration declaration : declarations) { String name = declaration.name; // `#Constants { --accent: ...; }` is how the build-time compiler exports a @@ -329,6 +329,13 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, } else if (!raw.equals(value)) { deferred.put(key, DEFERRED_LITERAL); literalTokens.put(key, raw); + } else if (!legacy && isDimensionToken(value)) { + // The build-time compiler writes string, identifier, number and color constants + // only; `50%` or `2mm` is dropped from the built theme without a word, so a + // preview that showed it would show a constant the application never gets. The + // legacy `@constants` block never reaches the build-time compiler and keeps them. + throw new CSSSyntaxException("#Constants does not support a length or percentage: " + + name + ": " + raw + " (quote it to store it as text)"); } else if (isColorLiteral(value)) { // Stored as plain hex, as the build-time compiler stores it: color constants // are read with Integer.parseInt(value, 16), which a leading '#' would break. @@ -897,6 +904,20 @@ private static float number(String text, String original) { } } + /// A CSS number followed by a unit or `%`: a dimension or percentage token. + private static boolean isDimensionToken(String t) { + int end = t.length(); + while (end > 0) { + char c = t.charAt(end - 1); + if (c == '%' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + end--; + } else { + break; + } + } + return end < t.length() && end > 0 && isCssNumber(t.substring(0, end)); + } + /// `[+-]? (digits | digits? . digits) ([eE] [+-]? digits)?`, the CSS number token. private static boolean isCssNumber(String t) { int i = 0; @@ -1598,17 +1619,20 @@ private static String stripPriority(String value) { while (bang > 0 && value.charAt(bang - 1) <= ' ') { bang--; } - // Inside an unterminated quote the marker is text, not a priority. - int quotes = 0; + // Inside an unterminated quote the marker is text, not a priority. The open delimiter + // is tracked, since the other quote character inside a string (`"don't"`) is text. + char open = 0; for (int i = 0; i < bang; i++) { char c = value.charAt(i); if (c == '\\') { i++; - } else if (c == '"' || c == '\'') { - quotes++; + } else if (open == 0 && (c == '"' || c == '\'')) { + open = c; + } else if (c == open) { + open = 0; } } - return quotes % 2 == 0 ? value.substring(0, bang).trim() : value; + return open == 0 ? value.substring(0, bang).trim() : value; } private Declaration[] parseDeclarations(String body) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index b15997776d6..06f14cc55ce 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -867,4 +867,22 @@ private static String fontName(Font font) { } } + + @Test + void testPriorityAfterAStringHoldingTheOtherQuoteIsStripped() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { msg: \"don't\" !important; }", res, "Theme"); + assertEquals("don't", res.getTheme("Theme").get("@msg")); + } + + @Test + void testLengthAndPercentageConstantsAreRejected() { + for (String value : new String[]{"50%", "2mm", "1.5px"}) { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants { progress: " + value + "; }", new MutableResource(), "Theme")); + } + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { progress: \"50%\"; }", res, "Theme"); + assertEquals("50%", res.getTheme("Theme").get("@progress")); + } } diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 602e2a6094a..9da61cb80b4 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5528,7 +5528,8 @@ public com.codename1.ui.Image getImage(String id) { /** * Classifies every PNG of a multi-image folder the way the build-time compiler does -- - * a density by its name in any case, any other name as medium -- and loads the one the + * a density by its exact lower-case name, any other name (HIGH.PNG included, which the + * build's case-sensitive switch reads as medium) as medium -- and loads the one the * display density gets. A named medium file wins over another medium-classified one. */ private static com.codename1.ui.Image bestVariant(String folder, String[] files) { @@ -5537,7 +5538,7 @@ private static com.codename1.ui.Image bestVariant(String folder, String[] files) for (String listed : files) { String file = stripSlash(listed); if (!file.regionMatches(true, file.length() - 4, ".png", 0, 4)) continue; - if (density.equalsIgnoreCase(file)) { + if (density.equals(file)) { match = file; break; } @@ -5555,7 +5556,7 @@ private static com.codename1.ui.Image bestVariant(String folder, String[] files) private static boolean isDensityName(String file) { for (String name : DENSITY_FILES) { - if (name.equalsIgnoreCase(file)) return true; + if (name.equals(file)) return true; } return false; } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 061dec5257c..404e262e997 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1840,17 +1840,19 @@ void anUpperCaseVariantNameIsAccepted() throws Exception { } @Test - void anUpperCaseDensityVariantBeatsADenserOne() throws Exception { + void anUpperCaseDensityNameIsReadAsMediumLikeTheBuild() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); java.nio.file.Files.createDirectories(folder); javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, - java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("verylow.PNG").toFile()); + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("high.PNG").toFile()); javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, - java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("4k.png").toFile()); + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("low.png").toFile()); com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( dir.resolve("theme.css").toString()).getImage("play.png"); - assertEquals(3, image.getWidth(), "every display density is at least very low"); + int expected = com.codename1.ui.Display.getInstance().getDeviceDensity() + >= com.codename1.ui.Display.DENSITY_MEDIUM ? 3 : 2; + assertEquals(expected, image.getWidth(), "the build's case-sensitive switch reads high.PNG as medium"); } @Test From af0590d40976f7ab82c2a380a3d270dc74a2def5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:40:28 +0300 Subject: [PATCH 19/59] CSSThemeCompiler: #Constants stores numbers as the build does, rejects tokens it drops; CSSTheme: system-ui is generic Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 66 ++++++++++++++----- .../ui/css/CSSThemeCompilerTest.java | 13 +++- .../com/codename1/designer/css/CSSTheme.java | 2 + 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 3c14da0199c..7524044ec20 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -329,13 +329,6 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, } else if (!raw.equals(value)) { deferred.put(key, DEFERRED_LITERAL); literalTokens.put(key, raw); - } else if (!legacy && isDimensionToken(value)) { - // The build-time compiler writes string, identifier, number and color constants - // only; `50%` or `2mm` is dropped from the built theme without a word, so a - // preview that showed it would show a constant the application never gets. The - // legacy `@constants` block never reaches the build-time compiler and keeps them. - throw new CSSSyntaxException("#Constants does not support a length or percentage: " - + name + ": " + raw + " (quote it to store it as text)"); } else if (isColorLiteral(value)) { // Stored as plain hex, as the build-time compiler stores it: color constants // are read with Integer.parseInt(value, 16), which a leading '#' would break. @@ -357,6 +350,13 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, kind = declaration.snapshotKind; } deferred.put(key, kind); + } else if (!legacy) { + // The build-time compiler writes string, identifier, number and color constants + // only; anything else (`50%`, `2mm`, `url(a.png)`, a list of values) is dropped + // from the built theme without a word, so a preview that showed it would show a + // constant the application never gets. The legacy `@constants` block never + // reaches the build-time compiler and keeps such values. + value = buildConstant(name, raw, value); } theme.put(key, value); } @@ -904,18 +904,50 @@ private static float number(String text, String original) { } } - /// A CSS number followed by a unit or `%`: a dimension or percentage token. - private static boolean isDimensionToken(String t) { - int end = t.length(); - while (end > 0) { - char c = t.charAt(end - 1); - if (c == '%' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { - end--; - } else { - break; + /// The value the build-time compiler stores for an unquoted `#Constants` token: a number + /// as it serializes the parsed value (`1.50` is `1.5`, `001` is `1`), an identifier as + /// written. Every other token type is one it drops, so it is rejected here. + private static String buildConstant(String name, String raw, String value) { + if (isCssNumber(value)) { + boolean integer = value.indexOf('.') < 0 && value.indexOf('e') < 0 && value.indexOf('E') < 0; + try { + if (integer) { + return String.valueOf(Integer.parseInt(value.charAt(0) == '+' ? value.substring(1) : value)); + } + String real = String.valueOf(Float.parseFloat(value)); + return real.endsWith(".0") ? real.substring(0, real.length() - 2) : real; + } catch (NumberFormatException err) { + throw new CSSSyntaxException("Invalid number in #Constants: " + name + ": " + raw, err); } } - return end < t.length() && end > 0 && isCssNumber(t.substring(0, end)); + if (isIdentifier(value)) { + return value; + } + throw new CSSSyntaxException("#Constants supports strings, identifiers, numbers and colors only: " + + name + ": " + raw + " (quote it to store it as text)"); + } + + /// A CSS identifier: `-`? then a letter, `_` or `-`, then letters, digits, `_` or `-`. + private static boolean isIdentifier(String t) { + int n = t.length(); + int i = 0; + if (i < n && t.charAt(i) == '-') { + i++; + } + if (i >= n) { + return false; + } + char first = t.charAt(i); + if (!(first == '_' || first == '-' || (first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z'))) { + return false; + } + for (i++; i < n; i++) { + char c = t.charAt(i); + if (!(c == '_' || c == '-' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) { + return false; + } + } + return true; } /// `[+-]? (digits | digits? . digits) ([eE] [+-]? digits)?`, the CSS number token. diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 06f14cc55ce..0cbf0dae20c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -877,7 +877,7 @@ void testPriorityAfterAStringHoldingTheOtherQuoteIsStripped() { @Test void testLengthAndPercentageConstantsAreRejected() { - for (String value : new String[]{"50%", "2mm", "1.5px"}) { + for (String value : new String[]{"50%", "2mm", "1.5px", "url(foo.png)", "a b"}) { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler().compile("#Constants { progress: " + value + "; }", new MutableResource(), "Theme")); } @@ -885,4 +885,15 @@ void testLengthAndPercentageConstantsAreRejected() { new CSSThemeCompiler().compile("#Constants { progress: \"50%\"; }", res, "Theme"); assertEquals("50%", res.getTheme("Theme").get("@progress")); } + + @Test + void testNumericConstantsAreStoredAsTheBuildSerializesThem() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { scale: 1.50; count: 001; whole: 2.0; flag: true; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("1.5", theme.get("@scale")); + assertEquals("1", theme.get("@count")); + assertEquals("2", theme.get("@whole")); + assertEquals("true", theme.get("@flag")); + } } diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 7e14161c15e..0e5548311db 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -6966,6 +6966,8 @@ public com.codename1.ui.Font getThemeFont(Map styles) { case "arial" : case "cursive" : case "fantasy" : + // The platform's own UI font: a generic family, as in the runtime compiler. + case "system-ui" : iFontFace = Font.FACE_SYSTEM; break loop; case "monospace" : From 507d9751315f8aaa8e3684835652f21be7d17750 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:38:14 +0300 Subject: [PATCH 20/59] CSSThemeCompiler: keyword size keeps an inherited native family, font-size 0, escaped/non-ASCII identifiers, whitespace in var() Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 123 +++++++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 38 ++++++ 2 files changed, 142 insertions(+), 19 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 7524044ec20..6955aa6bd4f 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -668,6 +668,11 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { Font font; if (inherited != null && family == null && parsed != null && parsed.unit >= 0) { font = inherited.derive(fontSizeInPixels(parsed), inherited.getStyle()); + } else if (inherited != null && family == null && parsed != null) { + // A keyword size (`small`, `medium`, `large`) keeps the inherited family too, at + // the height of the system font of that size. + font = inherited.derive(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, + parsed.systemSize).getHeight(), inherited.getStyle()); } else { if (inherited != null && family != null && parsed == null) { parsed = new FontSize(); @@ -835,6 +840,11 @@ private FontSize parseFontSize(String value) { out.unit = Style.UNIT_TYPE_DIPS; return out; } + if (isCssNumber(v) && Float.parseFloat(v) == 0f) { + // `0` is a valid length; the build-time compiler reads the bare number as no size + // and keeps its default medium sizing. + return out; + } // vmin before in, so the longer suffix wins. String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "vmin", "in", "rem", "vw", "vh", "vmax"}); if (unit == null) { @@ -920,34 +930,75 @@ private static String buildConstant(String name, String raw, String value) { throw new CSSSyntaxException("Invalid number in #Constants: " + name + ": " + raw, err); } } - if (isIdentifier(value)) { - return value; + String ident = decodeIdentifier(value); + if (ident != null) { + return ident; } throw new CSSSyntaxException("#Constants supports strings, identifiers, numbers and colors only: " + name + ": " + raw + " (quote it to store it as text)"); } - /// A CSS identifier: `-`? then a letter, `_` or `-`, then letters, digits, `_` or `-`. - private static boolean isIdentifier(String t) { + /// The decoded value of a CSS identifier token, or null when `t` is not one: `-`? then a + /// name-start character (a letter, `_`, `-`, any non-ASCII character or an escape), then name + /// characters. An escape is a backslash and 1-6 hex digits (plus one optional whitespace) or + /// a backslash and any other character, decoded as the build-time parser stores it. + private static String decodeIdentifier(String t) { int n = t.length(); + StringBuilder out = new StringBuilder(n); int i = 0; - if (i < n && t.charAt(i) == '-') { - i++; - } - if (i >= n) { - return false; - } - char first = t.charAt(i); - if (!(first == '_' || first == '-' || (first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z'))) { - return false; - } - for (i++; i < n; i++) { + boolean first = true; + while (i < n) { char c = t.charAt(i); - if (!(c == '_' || c == '-' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) { - return false; + if (c == '\\') { + if (i + 1 >= n || t.charAt(i + 1) == '\n' || t.charAt(i + 1) == '\r' || t.charAt(i + 1) == '\f') { + return null; + } + int j = i + 1; + int code = 0; + while (j < n && j - i <= 6 && Character.digit(t.charAt(j), 16) >= 0) { + code = code * 16 + Character.digit(t.charAt(j), 16); + j++; + } + if (j == i + 1) { + out.append(t.charAt(j)); + i = j + 1; + } else { + if (code == 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) { + code = 0xfffd; + } + if (code > 0xffff) { + code -= 0x10000; + out.append((char) (0xd800 + (code >> 10))); + out.append((char) (0xdc00 + (code & 0x3ff))); + } else { + out.append((char) code); + } + if (j < n && (t.charAt(j) == ' ' || t.charAt(j) == '\t' || t.charAt(j) == '\n')) { + j++; + } + i = j; + } + first = false; + continue; } + boolean digit = c >= '0' && c <= '9'; + if (!isNameChar(c) || (first && digit)) { + return null; + } + if (first && c == '-' && out.length() == 0) { + // A leading '-' is followed by the real name start. + out.append(c); + i++; + if (i < n && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + return null; + } + continue; + } + out.append(c); + first = false; + i++; } - return true; + return first ? null : out.toString(); } /// `[+-]? (digits | digits? . digits) ([eE] [+-]? digits)?`, the CSS number token. @@ -1641,6 +1692,40 @@ private String stripComments(String css) { /// Drops a trailing `!important`: the build-time SAC parser reports priority apart from the /// value and the compiler ignores it, so `includeNativeBool: true !important` stores `true`. + /// `var( --x )` is `var(--x)`: the build-time parser tokenizes function arguments without + /// regard to whitespace, while every lookup here searches for `var(--`. Quoted text is left + /// as written. + private static String tightenVarCalls(String value) { + if (value.indexOf("var(") < 0) { + return value; + } + StringBuilder out = new StringBuilder(value.length()); + char open = 0; + int n = value.length(); + for (int i = 0; i < n; i++) { + char c = value.charAt(i); + out.append(c); + if (c == '\\' && i + 1 < n) { + out.append(value.charAt(++i)); + } else if (open == 0 && (c == '"' || c == '\'')) { + open = c; + } else if (c == open) { + open = 0; + } else if (open == 0 && c == '(' && i >= 3 && value.regionMatches(true, i - 3, "var", 0, 3) + && (i == 3 || !isNameChar(value.charAt(i - 4)))) { + while (i + 1 < n && value.charAt(i + 1) <= ' ') { + i++; + } + } + } + return out.toString(); + } + + private static boolean isNameChar(char c) { + return c == '-' || c == '_' || c > 127 || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9'); + } + private static String stripPriority(String value) { String marker = "!important"; int at = value.length() - marker.length(); @@ -1682,7 +1767,7 @@ private Declaration[] parseDeclarations(String body) { Declaration dec = new Declaration(); dec.name = trimmed.substring(0, colon).trim(); dec.property = dec.name.toLowerCase(); - dec.value = stripPriority(trimmed.substring(colon + 1).trim()); + dec.value = tightenVarCalls(stripPriority(trimmed.substring(colon + 1).trim())); if (dec.property.length() == 0 || dec.value.length() == 0) { throw new CSSSyntaxException("Malformed declaration: " + trimmed); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 0cbf0dae20c..9513b479893 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -896,4 +896,42 @@ void testNumericConstantsAreStoredAsTheBuildSerializesThem() { assertEquals("2", theme.get("@whole")); assertEquals("true", theme.get("@flag")); } + + @Test + void testAKeywordSizeKeepsTheInheritedNativeFamily() { + MutableResource resource = new MutableResource(); + CSSThemeCompiler compiler = new CSSThemeCompiler(); + compiler.compile("Label{font-family:'native:MainBold';font-size:2mm;}", resource, "Theme"); + compiler.compile("Label{font-size:large;}", resource, "Theme"); + Font font = (Font) resource.getTheme("Theme").get("Label.font"); + assertEquals("native:MainBold", fontName(font)); + // Font.derive hands back the cached base font for its own natural height, so the height + // is compared rather than the requested pixel size. + assertEquals(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE).getHeight(), + font.getHeight()); + } + + @Test + void testUnitlessZeroFontSizeIsAccepted() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-size:0;}", resource, "Theme"); + assertNotNull(resource.getTheme("Theme").get("Label.font")); + } + + @Test + void testEscapedAndNonAsciiIdentifierConstants() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { a: caf\u00e9; b: foo\\ bar; c: \\41 x; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("caf\u00e9", theme.get("@a")); + assertEquals("foo bar", theme.get("@b")); + assertEquals("Ax", theme.get("@c")); + } + + @Test + void testWhitespaceInsideVarIsAllowed() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile(":root{--accent:#f00} Label{color:var( --accent )}", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); + } } From 87578bd5719052a64e52df97370571524749a774 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:09:48 +0300 Subject: [PATCH 21/59] CSSThemeCompiler: drop constants with an undefined var(), reject unterminated var() and exponent insets, integer px font sizes Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 44 ++++++++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 32 ++++++++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 3 ++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 6955aa6bd4f..32494815a66 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -105,6 +105,8 @@ public CSSSyntaxException(String message, Throwable cause) { /// uses one gets the string token, not its text: the build-time compiler keeps the token's /// type, so `color: var(--accent)` with `--accent: "#f00"` is not a color there either. private Hashtable literalTokens = new Hashtable(); + /// Set by resolveVars when it met a var() naming an undefined variable with no fallback. + private boolean unresolvedVar; public void compile(String css, MutableResource resources, String themeName) { Hashtable theme = resources.getTheme(themeName); @@ -306,7 +308,16 @@ private void resolveThemeConstantVars(Hashtable theme, Hashtable if (!(value instanceof String)) { continue; } - theme.put(key, resolveVars(theme, (String) value)); + unresolvedVar = false; + String resolved = resolveVars(theme, (String) value); + if (unresolvedVar) { + // The build-time compiler keeps an undefined var() with no fallback as a function + // its constants emitter has no branch for, so the built theme has no such + // constant: the caller's own default applies, not an empty string. + theme.remove(key); + } else { + theme.put(key, resolved); + } } } @@ -854,6 +865,11 @@ private FontSize parseFontSize(String value) { out.value = n; if ("px".equals(unit)) { out.unit = Style.UNIT_TYPE_PIXELS; + // The build-time compiler takes the integer part of a pixel size (12.5px is 12), + // and the fraction only when the integer part is 0. + if ((int) n != 0) { + out.value = (int) n; + } } else if ("pt".equals(unit)) { out.unit = Style.UNIT_TYPE_DIPS; out.value = n / 72f * 25.4f; @@ -1001,6 +1017,20 @@ private static String decodeIdentifier(String t) { return first ? null : out.toString(); } + /// `[+-]? digits`. + private static boolean isIntegerSpelling(String t) { + int i = t.length() > 0 && (t.charAt(0) == '+' || t.charAt(0) == '-') ? 1 : 0; + if (i >= t.length()) { + return false; + } + for (; i < t.length(); i++) { + if (t.charAt(i) < '0' || t.charAt(i) > '9') { + return false; + } + } + return true; + } + /// `[+-]? (digits | digits? . digits) ([eE] [+-]? digits)?`, the CSS number token. private static boolean isCssNumber(String t) { int i = 0; @@ -1190,7 +1220,8 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso while (varPos > -1) { int end = closingParen(value, varPos + "var(".length()); if (end < 0) { - break; + // The build-time parser rejects the unmatched parenthesis. + throw new CSSSyntaxException("Unterminated var() in: " + value); } // `var(--name, fallback)`: the build-time compiler takes the fallback when the // variable is not defined. @@ -1208,6 +1239,9 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso varPos = value.indexOf("var(--", pos); continue; } + if (replacement == null) { + unresolvedVar = true; + } String replaceValue = replacement == null ? "" : replacement.toString(); String token = tokens ? literalTokens.get("@" + key) : null; if (token != null) { @@ -1501,9 +1535,9 @@ private Length length(String value) { throw new CSSSyntaxException("Unsupported unit for padding or margin: " + value); } String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "rem", "vw", "vh", "vmin", "vmax"}); - // A bare number must be whole: the build-time compiler takes `1.5` for a dimension - // missing its unit and fails the build. - if (unit == null && v.indexOf('.') > -1) { + // A bare number must be an integer spelling: the build-time compiler takes `1.5` or + // `1e2` for a dimension missing its unit and fails the build. + if (unit == null && !isIntegerSpelling(v)) { throw new CSSSyntaxException("A unitless padding or margin must be a whole number: " + value); } float n = number(unit == null ? v : v.substring(0, v.length() - unit.length()), value); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 9513b479893..487696e558e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -934,4 +934,36 @@ void testWhitespaceInsideVarIsAllowed() { new CSSThemeCompiler().compile(":root{--accent:#f00} Label{color:var( --accent )}", res, "Theme"); assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); } + + @Test + void testAConstantWithAnUndefinedVariableIsLeftOut() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { label: var(--missing); other: var(--label); kept: var(--missing, x); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertFalse(theme.containsKey("@label")); + assertFalse(theme.containsKey("@other")); + assertEquals("x", theme.get("@kept")); + } + + @Test + void testUnterminatedVarIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants { label: var(--name; }", new MutableResource(), "Theme")); + } + + @Test + void testExponentUnitlessInsetIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("Label{padding:1e2;}", new MutableResource(), "Theme")); + } + + @Test + void testFractionalPixelFontSizeTakesTheIntegerPart() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:'native:MainBold';font-size:12.5px;} Small{font-family:'native:MainBold';font-size:0.5px;}", + res, "Theme"); + assertEquals(12f, ((Font) res.getTheme("Theme").get("Label.font")).getPixelSize(), 0.001f); + assertEquals(0.5f, ((Font) res.getTheme("Theme").get("Small.font")).getPixelSize(), 0.001f); + } } diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 9da61cb80b4..cc5dae0b6d9 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5542,6 +5542,9 @@ private static com.codename1.ui.Image bestVariant(String folder, String[] files) match = file; break; } + // Several files classified medium: the build keeps whichever File.listFiles() + // returns first, an order the platform does not define, so no preview can + // promise to match it. The named medium.png is the stable choice. if ("medium.png".equals(density) && match == null && !isDensityName(file)) { match = file; } From c3768fbf880add2ed9939290d4a5fab61bdf451f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:44:19 +0300 Subject: [PATCH 22/59] CSSThemeCompiler: only --name declarations are variables, dark fonts stand alone, typed fallbacks, ! important Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 47 +++++++++++++++++-- .../ui/css/CSSThemeCompilerTest.java | 42 ++++++++++++++--- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 32494815a66..d67455a40ee 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -32,6 +32,7 @@ import com.codename1.ui.plaf.Style; import com.codename1.ui.util.MutableResource; import java.util.ArrayList; +import java.util.HashSet; import java.util.Hashtable; import java.util.Map; @@ -105,6 +106,10 @@ public CSSSyntaxException(String message, Throwable cause) { /// uses one gets the string token, not its text: the build-time compiler keeps the token's /// type, so `color: var(--accent)` with `--accent: "#f00"` is not a color there either. private Hashtable literalTokens = new Hashtable(); + /// `#Constants` entries this compile declared without the `--` prefix. The build-time + /// compiler adds only `--name` declarations to its variables, so `var(--name)` does not see + /// these. + private HashSet plainConstants = new HashSet(); /// Set by resolveVars when it met a var() naming an undefined variable with no fallback. private boolean unresolvedVar; @@ -124,6 +129,7 @@ public void compile(String css, MutableResource resources, String themeName) { Hashtable deferred = new Hashtable(); deferredConstants = deferred; literalTokens = new Hashtable(); + plainConstants = new HashSet(); // Everything in source order, #Constants blocks included: the build-time parser resolves // var() in each declaration as it reads it, and a `--name` inside #Constants enters its // variable map only when that block is reached. A rule above the block does not see it. @@ -316,6 +322,19 @@ private void resolveThemeConstantVars(Hashtable theme, Hashtable // constant: the caller's own default applies, not an empty string. theme.remove(key); } else { + String sole = ((String) value).trim(); + if (sole.startsWith("var(") && closingParen(sole, "var(".length()) == sole.length() - 1) { + // A constant that is one var() takes its value's token type, a fallback's + // too: the build-time compiler stores the string `"hello"` as hello and the + // number 1.50 as 1.5, as it would written directly. + String text = resolved.trim(); + String unquoted = unquote(text); + if (!unquoted.equals(text)) { + resolved = unquoted; + } else if (isCssNumber(text)) { + resolved = buildConstant(key.substring(1), text, text); + } + } theme.put(key, resolved); } } @@ -331,6 +350,11 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, name = name.substring(2); } String key = "@" + name; + if (legacy || declaration.name.startsWith("--")) { + plainConstants.remove(key); + } else { + plainConstants.add(key); + } String raw = declaration.value.trim(); String value = unquote(raw); deferred.put(key, DEFERRED_PLAIN); @@ -375,7 +399,7 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, /// True when every `var(--x)` in `value` names a constant this compile already declared and /// that is itself settled: not an alias still waiting for a name declared later. - private static boolean referencesDeclaredOnly(Hashtable theme, String key, String value, + private boolean referencesDeclaredOnly(Hashtable theme, String key, String value, Hashtable deferred) { int at = value.indexOf("var(--"); while (at > -1) { @@ -386,6 +410,9 @@ private static boolean referencesDeclaredOnly(Hashtable theme, String key, Strin String inner = value.substring(at + "var(--".length(), end); int comma = topLevelComma(inner); String target = "@" + (comma < 0 ? inner : inner.substring(0, comma)).trim(); + if (plainConstants.contains(target)) { + return false; + } String kind = deferred.get(target); Object current = theme.get(target); // Its own name is never settled: that is a cycle, reported when it is resolved. @@ -561,6 +588,7 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str return false; } String key = "@" + property.substring(2); + plainConstants.remove(key); String raw = value.trim(); String unquoted = unquote(raw); // A later custom property replaces a #Constants entry outright, deferred handling included. @@ -644,8 +672,10 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { ArrayList chain = fontChain(theme, own, state, dark); for (int i = 0; i < chain.size() && (family == null || size == null); i++) { String id = chain.get(i); + // A dark rule is its own `$Dark` element in the build-time compiler, with no + // link to the light UIID, so a dark font never takes the light one's half. String[] fallbacks = dark - ? new String[]{"$Dark" + id + "." + lookup, id + "." + lookup} + ? new String[]{"$Dark" + id + "." + lookup} : new String[]{id + "." + lookup}; for (String fallback : fallbacks) { String[] other = fonts.get(fallback); @@ -1232,7 +1262,7 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso if (resolving.contains(key)) { throw new CSSSyntaxException("Cyclic variable reference: var(--" + key + ")"); } - Object replacement = theme.get("@" + key); + Object replacement = plainConstants.contains("@" + key) ? null : theme.get("@" + key); if (replacement == null && fallback != null) { out.append(value.substring(pos, varPos)).append(resolveVars(theme, fallback, resolving, tokens)); pos = end + 1; @@ -1761,12 +1791,19 @@ private static boolean isNameChar(char c) { } private static String stripPriority(String value) { - String marker = "!important"; + // `!` then optional whitespace then `important`, as the CSS grammar has it. + String marker = "important"; int at = value.length() - marker.length(); if (at < 0 || !value.regionMatches(true, at, marker, 0, marker.length())) { return value; } - int bang = at; + while (at > 0 && value.charAt(at - 1) <= ' ') { + at--; + } + if (at == 0 || value.charAt(at - 1) != '!') { + return value; + } + int bang = at - 1; while (bang > 0 && value.charAt(bang - 1) <= ' ') { bang--; } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 487696e558e..c859a2e107e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -459,9 +459,10 @@ public void testStateFontInheritsTheUndeclaredHalfFromTheBase() { Font pressed = (Font) theme.get("Label.press#font"); assertEquals(Display.getInstance().convertToPixels(20, true) / 10f, pressed.getPixelSize(), 0.001f); assertEquals("native:MainLight", fontName(pressed)); + // A dark rule is its own element in the build-time compiler: no light family reaches it. Font dark = (Font) theme.get("$DarkLabel.dis#font"); assertEquals(9f, dark.getPixelSize(), 0.001f); - assertEquals("native:MainBold", fontName(dark)); + assertEquals("native:MainRegular", fontName(dark)); } @Test @@ -587,15 +588,15 @@ public void testStateFontIgnoresTheUniversalStyle() { @Test public void testCyclicConstantsAreRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() - .compile("#Constants{ a: var(--a); }", new MutableResource(), "Theme")); + .compile("#Constants{ --a: var(--a); }", new MutableResource(), "Theme")); assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() - .compile("#Constants{ a: var(--b); b: var(--a); }", new MutableResource(), "Theme")); + .compile("#Constants{ --a: var(--b); --b: var(--a); }", new MutableResource(), "Theme")); assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() .compile(":root{ --a: var(--b); } :root{ --b: var(--a); } Label{ color: var(--a); }", new MutableResource(), "Theme")); // Two uses of one constant are not a cycle. MutableResource resource = new MutableResource(); - new CSSThemeCompiler().compile("#Constants{ c: #ff0000; pair: var(--c) var(--c); }", resource, "Theme"); + new CSSThemeCompiler().compile("#Constants{ --c: #ff0000; pair: var(--c) var(--c); }", resource, "Theme"); assertEquals("ff0000 ff0000", resource.getTheme("Theme").get("@pair")); } @@ -640,8 +641,8 @@ public void testStateDeriveIsFollowedAtEveryHop() { @Test public void testAliasOfAQuotedConstantStaysText() { MutableResource resource = new MutableResource(); - new CSSThemeCompiler().compile("#Constants{literal:\"#abc\"; alias:var(--literal); twice:var(--alias);" - + " color:#abc; colorAlias:var(--color);}", resource, "Theme"); + new CSSThemeCompiler().compile("#Constants{--literal:\"#abc\"; --alias:var(--literal); twice:var(--alias);" + + " --color:#abc; colorAlias:var(--color);}", resource, "Theme"); Hashtable theme = resource.getTheme("Theme"); assertEquals("#abc", theme.get("@alias")); assertEquals("#abc", theme.get("@twice")); @@ -838,7 +839,7 @@ public void testJavaOnlyNumberSpellingsAreRejected() { public void testConstantsAliasKeepsTheValueAtItsDeclaration() { MutableResource resource = new MutableResource(); new CSSThemeCompiler().compile("#Constants{--accent:#f00;alias:var(--accent);--accent:#00f;" - + "early:var(--later);later:#0f0;lit:\"#abc\";litAlias:var(--lit);}", resource, "Theme"); + + "early:var(--later);--later:#0f0;--lit:\"#abc\";litAlias:var(--lit);}", resource, "Theme"); Hashtable theme = resource.getTheme("Theme"); assertEquals("ff0000", String.valueOf(theme.get("@alias")).toLowerCase()); assertEquals("0000ff", String.valueOf(theme.get("@accent")).toLowerCase()); @@ -966,4 +967,31 @@ void testFractionalPixelFontSizeTakesTheIntegerPart() { assertEquals(12f, ((Font) res.getTheme("Theme").get("Label.font")).getPixelSize(), 0.001f); assertEquals(0.5f, ((Font) res.getTheme("Theme").get("Small.font")).getPixelSize(), 0.001f); } + + @Test + void testAFallbackIsStoredByItsTokenType() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: var(--missing, \"hello\"); scale: var(--missing, 1.50); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("hello", theme.get("@message")); + assertEquals("1.5", theme.get("@scale")); + } + + @Test + void testPriorityWithWhitespaceAfterTheBang() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { includeNativeBool: true ! important; }", res, "Theme"); + assertEquals("true", res.getTheme("Theme").get("@includeNativeBool")); + } + + @Test + void testAnOrdinaryConstantIsNotACustomProperty() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: #f00; --real: #0f0; alias: var(--accent); other: var(--real); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertFalse(theme.containsKey("@alias")); + assertEquals("00ff00", theme.get("@other")); + } } From d9b8f3d01805f441a8f7af54fa066b7d1eb78e06 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:10:57 +0300 Subject: [PATCH 23/59] CSSThemeCompiler: a color or number named ...Image is not an image; image aliases resolve where declared Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/ui/css/CSSThemeCompiler.java | 14 +++++++++++++- .../codename1/ui/css/CSSThemeCompilerTest.java | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index d67455a40ee..d7468afc1d3 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -359,7 +359,19 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, String value = unquote(raw); deferred.put(key, DEFERRED_PLAIN); literalTokens.remove(key); - if (name.endsWith("Image")) { + boolean alias = raw.equals(value) && value.indexOf("var(--") > -1; + if (name.endsWith("Image") && (!raw.equals(value) || (!isColorLiteral(value) && !isCssNumber(value)))) { + // The build-time compiler looks an image up only for a string or identifier + // token; a color or number named `...Image` is stored as a color or number. An alias is + // resolved where it is declared, as below, so a later redefinition of its + // target does not change the image. + if (alias && declaration.snapshot == null && referencesDeclaredOnly(theme, key, value, deferred)) { + declaration.snapshot = resolveVars(theme, value); + declaration.snapshotKind = DEFERRED_IMAGE; + } + if (alias && declaration.snapshot != null) { + value = declaration.snapshot; + } deferred.put(key, DEFERRED_IMAGE); } else if (!raw.equals(value)) { deferred.put(key, DEFERRED_LITERAL); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index c859a2e107e..b8bacf9e0f5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -994,4 +994,22 @@ void testAnOrdinaryConstantIsNotACustomProperty() { assertFalse(theme.containsKey("@alias")); assertEquals("00ff00", theme.get("@other")); } + + @Test + void testAColorNamedImageIsStoredAsAColor() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { placeholderImage: #f00; }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("@placeholderImage")); + } + + @Test + void testAnImageAliasKeepsTheImageAtItsDeclaration() { + MutableResource res = new MutableResource(); + Image first = Image.createImage(3, 3); + res.setImage("first.png", first); + res.setImage("second.png", Image.createImage(4, 4)); + new CSSThemeCompiler().compile("#Constants { --icon: \"first.png\"; navImage: var(--icon); --icon: \"second.png\"; }", + res, "Theme"); + assertEquals(3, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } } From e831d78dd7b2fad167cbb049f558422c2aeec1ac Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:23:00 +0300 Subject: [PATCH 24/59] CSSThemeCompiler: #Constants aliases resolve against earlier declarations only, Default.* at the root, * sets no font, escaped image names and bangs; GUI builder: a corrupt variant fails the folder Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 145 ++++++++++++++++-- .../ui/css/CSSThemeCompilerTest.java | 53 +++++-- .../guibuilder/CodenameOneGUIBuilder.java | 21 +++ .../guibuilder/GeneratedSourceTest.java | 12 ++ 4 files changed, 203 insertions(+), 28 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index d7468afc1d3..767256f6015 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -110,6 +110,12 @@ public CSSSyntaxException(String message, Throwable cause) { /// compiler adds only `--name` declarations to its variables, so `var(--name)` does not see /// these. private HashSet plainConstants = new HashSet(); + /// While set, resolveVars does not see a constant this compile declares unless it holds it: + /// one declared further down is not defined yet. A constant only an earlier compile into the + /// same theme declared is visible. + private Hashtable declaredOnly; + /// Every constant and custom property this compile declares, as "@name". + private HashSet declaredThisCompile = new HashSet(); /// Set by resolveVars when it met a var() naming an undefined variable with no fallback. private boolean unresolvedVar; @@ -130,6 +136,17 @@ public void compile(String css, MutableResource resources, String themeName) { deferredConstants = deferred; literalTokens = new Hashtable(); plainConstants = new HashSet(); + declaredThisCompile = new HashSet(); + for (Rule rule : rules) { + for (Declaration declaration : rule.declarations) { + if (rule.constants || rule.legacyConstants) { + String n = declaration.name; + declaredThisCompile.add("@" + (n.startsWith("--") ? n.substring(2) : n)); + } else if (declaration.name.startsWith("--")) { + declaredThisCompile.add("@" + declaration.name.substring(2)); + } + } + } // Everything in source order, #Constants blocks included: the build-time parser resolves // var() in each declaration as it reads it, and a `--name` inside #Constants enters its // variable map only when that block is reached. A rule above the block does not see it. @@ -171,6 +188,17 @@ public void compile(String css, MutableResource resources, String themeName) { theme.put(key, constantColor(resolved)); } } + // The build-time compiler also stores every `Default.*` property unprefixed: the + // theme's root keys, which UIManager's default style reads. + ArrayList defaults = new ArrayList(); + for (Object k : theme.keySet()) { + if (k instanceof String && ((String) k).startsWith("Default.")) { + defaults.add((String) k); + } + } + for (String k : defaults) { + theme.put(k.substring("Default.".length()), theme.get(k)); + } resources.setTheme(themeName, theme); } @@ -322,20 +350,7 @@ private void resolveThemeConstantVars(Hashtable theme, Hashtable // constant: the caller's own default applies, not an empty string. theme.remove(key); } else { - String sole = ((String) value).trim(); - if (sole.startsWith("var(") && closingParen(sole, "var(".length()) == sole.length() - 1) { - // A constant that is one var() takes its value's token type, a fallback's - // too: the build-time compiler stores the string `"hello"` as hello and the - // number 1.50 as 1.5, as it would written directly. - String text = resolved.trim(); - String unquoted = unquote(text); - if (!unquoted.equals(text)) { - resolved = unquoted; - } else if (isCssNumber(text)) { - resolved = buildConstant(key.substring(1), text, text); - } - } - theme.put(key, resolved); + theme.put(key, typedAlias(key, (String) value, resolved)); } } } @@ -365,13 +380,30 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, // token; a color or number named `...Image` is stored as a color or number. An alias is // resolved where it is declared, as below, so a later redefinition of its // target does not change the image. - if (alias && declaration.snapshot == null && referencesDeclaredOnly(theme, key, value, deferred)) { + if (alias && !legacy && declaration.snapshot == null && !declaration.dropped) { + declaration.snapshot = resolveAtDeclaration(theme, key, value, deferred); + declaration.dropped = declaration.snapshot == null; + declaration.snapshotKind = DEFERRED_IMAGE; + } else if (alias && legacy && declaration.snapshot == null + && referencesDeclaredOnly(theme, key, value, deferred)) { declaration.snapshot = resolveVars(theme, value); declaration.snapshotKind = DEFERRED_IMAGE; } + if (declaration.dropped) { + theme.remove(key); + deferred.remove(key); + continue; + } if (alias && declaration.snapshot != null) { value = declaration.snapshot; } + if (!alias && raw.equals(value)) { + // An identifier image name is looked up decoded: `my\ icon` is `my icon`. + String decoded = decodeIdentifier(value); + if (decoded != null) { + value = decoded; + } + } deferred.put(key, DEFERRED_IMAGE); } else if (!raw.equals(value)) { deferred.put(key, DEFERRED_LITERAL); @@ -382,7 +414,24 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, value = constantColor(value); } else if (value.indexOf("var(--") > -1) { String kind = DEFERRED_VAR; - if (declaration.snapshot == null && referencesDeclaredOnly(theme, key, value, deferred)) { + if (!legacy && declaration.snapshot == null && !declaration.dropped) { + String target = aliasTarget(value); + String targetKind = target == null ? null : deferred.get(target); + declaration.snapshotKind = DEFERRED_LITERAL.equals(targetKind) || DEFERRED_TEXT.equals(targetKind) + ? DEFERRED_LITERAL : DEFERRED_VAR; + declaration.snapshot = resolveAtDeclaration(theme, key, value, deferred); + declaration.dropped = declaration.snapshot == null; + if (!declaration.dropped && declaration.snapshot.indexOf("var(--") > -1) { + // Fully resolved here, so a var() still in it came from text. + declaration.snapshotKind = DEFERRED_LITERAL; + } + } + if (declaration.dropped) { + theme.remove(key); + deferred.remove(key); + continue; + } + if (legacy && declaration.snapshot == null && referencesDeclaredOnly(theme, key, value, deferred)) { // Every name it uses is already declared: the build-time compiler resolves it // right here, so a later redefinition of a target does not reach it. Kept on // the declaration, because the second pass sees the final values. @@ -409,6 +458,44 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, } } + /// A constant that is one var() takes its value's token type, a fallback's too: the + /// build-time compiler stores the string `"hello"` as hello and the number 1.50 as 1.5, as + /// it would written directly. + private static String typedAlias(String key, String original, String resolved) { + String sole = original.trim(); + if (sole.startsWith("var(") && closingParen(sole, "var(".length()) == sole.length() - 1) { + String text = resolved.trim(); + String unquoted = unquote(text); + if (!unquoted.equals(text)) { + return unquoted; + } + if (isCssNumber(text)) { + return buildConstant(key.substring(1), text, text); + } + } + return resolved; + } + + /// Resolves a `#Constants` alias where it is declared, as the build-time compiler does: only + /// names declared above it are seen, so `early: var(--later)` is undefined however `later` is + /// declared afterwards. Returns null, and the constant is left out, when a reference stays + /// undefined without a fallback: the build-time compiler keeps such a var() as a function its + /// constants emitter drops. + private String resolveAtDeclaration(Hashtable theme, String key, String value, Hashtable deferred) { + String own = deferred.remove(key); + declaredOnly = deferred; + unresolvedVar = false; + try { + String resolved = resolveVars(theme, value); + return unresolvedVar ? null : typedAlias(key, value, resolved); + } finally { + declaredOnly = null; + if (own != null) { + deferred.put(key, own); + } + } + } + /// True when every `var(--x)` in `value` names a constant this compile already declared and /// that is itself settled: not an alias still waiting for a name declared later. private boolean referencesDeclaredOnly(Hashtable theme, String key, String value, @@ -558,6 +645,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha } String[] selectorParts = selector(rule.selector); + boolean universal = rule.universal; String uiid = selectorParts[0]; String statePrefix = selectorParts[1]; StringBuilder borderCss = new StringBuilder(); @@ -574,6 +662,13 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha if ("cn1-derive".equals(property)) { ownerDerives.put(uiid, value.trim()); } + if (universal && ("font-family".equals(property) || "font-size".equals(property))) { + // The build-time compiler ignores universal rules, so `*` sets no font there. + if ("font-size".equals(property)) { + parseFontSize(value); + } + continue; + } if (applyFontProperty(fonts, uiid + "." + statePrefix, property, value)) { continue; } @@ -1274,7 +1369,9 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso if (resolving.contains(key)) { throw new CSSSyntaxException("Cyclic variable reference: var(--" + key + ")"); } - Object replacement = plainConstants.contains("@" + key) ? null : theme.get("@" + key); + Object replacement = plainConstants.contains("@" + key) + || (declaredOnly != null && !declaredOnly.containsKey("@" + key) + && declaredThisCompile.contains("@" + key)) ? null : theme.get("@" + key); if (replacement == null && fallback != null) { out.append(value.substring(pos, varPos)).append(resolveVars(theme, fallback, resolving, tokens)); pos = end + 1; @@ -1657,6 +1754,8 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext } Rule rule = new Rule(); rule.selector = darkContext ? toDarkSelector(selector) : selector; + String base = selector.trim(); + rule.universal = base.startsWith("*") || base.startsWith(":") && !":root".equals(base); rule.declarations = declarations; out.add(rule); } @@ -1816,6 +1915,14 @@ private static String stripPriority(String value) { return value; } int bang = at - 1; + // `foo\!important` is an identifier with an escaped bang, not a priority. + int slashes = 0; + while (bang - 1 - slashes >= 0 && value.charAt(bang - 1 - slashes) == '\\') { + slashes++; + } + if ((slashes & 1) != 0) { + return value; + } while (bang > 0 && value.charAt(bang - 1) <= ' ') { bang--; } @@ -1942,9 +2049,13 @@ private static class Rule { boolean constants; /// An `@constants` block rather than `#Constants`. boolean legacyConstants; + /// A `*` selector (or a bare state), which the build-time compiler ignores. + boolean universal; } private static class Declaration { + /// A `#Constants` alias whose reference was undefined where it was declared. + boolean dropped; /// The name as written; constants and custom properties are case sensitive. String name; /// The lower case name ordinary CSS properties are matched by. diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index b8bacf9e0f5..aa172cc7062 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -431,15 +431,15 @@ public void testImageConstantsResolveToTheResourceImage() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() .compile("#Constants{ missingImage: nothing.png; }", new MutableResource(), "Theme")); - // Through a custom property declared after the constants block, quoted or not. + // Through a custom property, quoted or not. MutableResource viaVar = new MutableResource(); viaVar.setImage("spinner.png", spinner); - new CSSThemeCompiler().compile("#Constants{ infiniteImage: var(--spinner); quotedImage: var(--q); }" - + ":root{ --spinner: spinner.png; --q: \"spinner.png\"; }", viaVar, "Theme"); + new CSSThemeCompiler().compile(":root{ --spinner: spinner.png; --q: \"spinner.png\"; }" + + "#Constants{ infiniteImage: var(--spinner); quotedImage: var(--q); }", viaVar, "Theme"); assertTrue(viaVar.getTheme("Theme").get("@infiniteImage") == spinner); assertTrue(viaVar.getTheme("Theme").get("@quotedImage") == spinner); assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() - .compile("#Constants{ goneImage: var(--x); } :root{ --x: nothing.png; }", + .compile(":root{ --x: nothing.png; } #Constants{ goneImage: var(--x); }", new MutableResource(), "Theme")); } @@ -469,8 +469,8 @@ public void testStateFontInheritsTheUndeclaredHalfFromTheBase() { public void testColorConstantFromVarIsStoredAsPlainHex() { MutableResource resource = new MutableResource(); new CSSThemeCompiler().compile(":root{--c:#f00;}" - + "#Constants{infiniteDefaultColor:var(--c); label:var(--name);}" - + ":root{--name:hello;}", resource, "Theme"); + + ":root{--name:hello;}" + + "#Constants{infiniteDefaultColor:var(--c); label:var(--name);}", resource, "Theme"); Hashtable theme = resource.getTheme("Theme"); assertEquals("ff0000", theme.get("@infiniteDefaultColor")); assertEquals("hello", theme.get("@label")); @@ -587,10 +587,13 @@ public void testStateFontIgnoresTheUniversalStyle() { @Test public void testCyclicConstantsAreRejected() { - assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() - .compile("#Constants{ --a: var(--a); }", new MutableResource(), "Theme")); - assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() - .compile("#Constants{ --a: var(--b); --b: var(--a); }", new MutableResource(), "Theme")); + // In #Constants a reference sees only what is declared above it, so a self or mutual + // reference is undefined, as in the build-time compiler, and the constants are left out. + MutableResource cyclic = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ --a: var(--a); --b: var(--c); --c: var(--b); }", cyclic, "Theme"); + assertFalse(cyclic.getTheme("Theme").containsKey("@a")); + assertFalse(cyclic.getTheme("Theme").containsKey("@b")); + assertFalse(cyclic.getTheme("Theme").containsKey("@c")); assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() .compile(":root{ --a: var(--b); } :root{ --b: var(--a); } Label{ color: var(--a); }", new MutableResource(), "Theme")); @@ -843,7 +846,8 @@ public void testConstantsAliasKeepsTheValueAtItsDeclaration() { Hashtable theme = resource.getTheme("Theme"); assertEquals("ff0000", String.valueOf(theme.get("@alias")).toLowerCase()); assertEquals("0000ff", String.valueOf(theme.get("@accent")).toLowerCase()); - assertEquals("00ff00", String.valueOf(theme.get("@early")).toLowerCase()); + // A forward reference is undefined where it is declared. + assertFalse(theme.containsKey("@early")); assertEquals("#abc", theme.get("@litAlias")); } @@ -1012,4 +1016,31 @@ void testAnImageAliasKeepsTheImageAtItsDeclaration() { res, "Theme"); assertEquals(3, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); } + + @Test + void testDefaultPropertiesAreAlsoStoredAtTheRoot() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Default{color:#ff0000;font-size:20px;}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("ff0000", theme.get("fgColor")); + assertEquals(20f, ((Font) theme.get("font")).getPixelSize(), 0.001f); + } + + @Test + void testUniversalRulesSetNoFont() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("*{font-size:20px;}", res, "Theme"); + assertFalse(res.getTheme("Theme").containsKey("Component.font")); + } + + @Test + void testEscapedIdentifiers() { + MutableResource res = new MutableResource(); + Image icon = Image.createImage(5, 5); + res.setImage("my icon", icon); + new CSSThemeCompiler().compile("#Constants { emptyImage: my\\ icon; message: foo\\!important; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals(5, ((Image) theme.get("@emptyImage")).getWidth()); + assertEquals("foo!important", theme.get("@message")); + } } diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index cc5dae0b6d9..2156289c9de 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5533,6 +5533,14 @@ public com.codename1.ui.Image getImage(String id) { * display density gets. A named medium file wins over another medium-classified one. */ private static com.codename1.ui.Image bestVariant(String folder, String[] files) { + // The build decodes every PNG of the folder before it picks one, so a corrupt + // variant fails the image there even when the display would not select it. + for (String listed : files) { + String file = stripSlash(listed); + if (file.regionMatches(true, file.length() - 4, ".png", 0, 4) && !decodes(load(folder + "/" + file))) { + return null; + } + } for (String density : densityOrder(com.codename1.ui.Display.getInstance().getDeviceDensity())) { String match = null; for (String listed : files) { @@ -5596,6 +5604,16 @@ private static String stripSlash(String name) { return name.endsWith("/") ? name.substring(0, name.length() - 1) : name; } + /** An image can decode lazily; reading its pixels is what proves the bytes are a PNG. */ + private static boolean decodes(com.codename1.ui.Image image) { + if (image == null) return false; + try { + return image.getWidth() > 0 && image.getRGB() != null; + } catch (RuntimeException ex) { + return false; + } + } + private static com.codename1.ui.Image load(String path) { com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); String url = ProjectIO.fsUrl(path); @@ -5606,6 +5624,9 @@ private static com.codename1.ui.Image load(String path) { return com.codename1.ui.Image.createImage(in); } catch (IOException ex) { return null; + } catch (RuntimeException ex) { + // A PNG the decoder cannot read. + return null; } finally { com.codename1.io.Util.cleanup(in); } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 404e262e997..63dafb27e56 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1855,6 +1855,18 @@ void anUpperCaseDensityNameIsReadAsMediumLikeTheBuild() throws Exception { assertEquals(expected, image.getWidth(), "the build's case-sensitive switch reads high.PNG as medium"); } + @Test + void aCorruptVariantFailsTheWholeFolderLikeTheBuild() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); + java.nio.file.Files.write(folder.resolve("high.png"), new byte[]{1, 2, 3, 4}); + assertNull(new CodenameOneGUIBuilder.ProjectImageResource(dir.resolve("theme.css").toString()) + .getImage("play.png")); + } + @Test void anUnnamedVariantCountsAsMediumAndTheParentResIsSearched() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); From 16e04f985807ee7fdd48584475957ad55200f052 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:36:59 +0300 Subject: [PATCH 25/59] CSSThemeCompiler: a plain #Constants entry no longer hides a custom property of the same name from var() Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 27 ++++++++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 10 +++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 767256f6015..73fd01978f4 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -110,6 +110,12 @@ public CSSSyntaxException(String message, Throwable cause) { /// compiler adds only `--name` declarations to its variables, so `var(--name)` does not see /// these. private HashSet plainConstants = new HashSet(); + /// A custom property this compile declared whose "@name" a later plain `#Constants` entry + /// took over, keyed by "@name": its value, and in shadowedKinds its deferred kind. The + /// build-time compiler keeps variables and constants apart, so `var(--accent)` still + /// reaches the variable after `accent: blue` exported a different `@accent`. + private Hashtable shadowedVars = new Hashtable(); + private Hashtable shadowedKinds = new Hashtable(); /// While set, resolveVars does not see a constant this compile declares unless it holds it: /// one declared further down is not defined yet. A constant only an earlier compile into the /// same theme declared is visible. @@ -136,6 +142,8 @@ public void compile(String css, MutableResource resources, String themeName) { deferredConstants = deferred; literalTokens = new Hashtable(); plainConstants = new HashSet(); + shadowedVars = new Hashtable(); + shadowedKinds = new Hashtable(); declaredThisCompile = new HashSet(); for (Rule rule : rules) { for (Declaration declaration : rule.declarations) { @@ -367,7 +375,15 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, String key = "@" + name; if (legacy || declaration.name.startsWith("--")) { plainConstants.remove(key); + shadowedVars.remove(key); + shadowedKinds.remove(key); } else { + Object variable = theme.get(key); + String variableKind = deferred.get(key); + if (!plainConstants.contains(key) && variableKind != null && variable instanceof String) { + shadowedVars.put(key, (String) variable); + shadowedKinds.put(key, variableKind); + } plainConstants.add(key); } String raw = declaration.value.trim(); @@ -696,6 +712,8 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str } String key = "@" + property.substring(2); plainConstants.remove(key); + shadowedVars.remove(key); + shadowedKinds.remove(key); String raw = value.trim(); String unquoted = unquote(raw); // A later custom property replaces a #Constants entry outright, deferred handling included. @@ -1369,8 +1387,9 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso if (resolving.contains(key)) { throw new CSSSyntaxException("Cyclic variable reference: var(--" + key + ")"); } - Object replacement = plainConstants.contains("@" + key) - || (declaredOnly != null && !declaredOnly.containsKey("@" + key) + boolean shadowed = plainConstants.contains("@" + key); + Object replacement = shadowed ? shadowedVars.get("@" + key) + : (declaredOnly != null && !declaredOnly.containsKey("@" + key) && declaredThisCompile.contains("@" + key)) ? null : theme.get("@" + key); if (replacement == null && fallback != null) { out.append(value.substring(pos, varPos)).append(resolveVars(theme, fallback, resolving, tokens)); @@ -1382,14 +1401,14 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso unresolvedVar = true; } String replaceValue = replacement == null ? "" : replacement.toString(); - String token = tokens ? literalTokens.get("@" + key) : null; + String token = tokens && !shadowed ? literalTokens.get("@" + key) : null; if (token != null) { replaceValue = token; } // A quoted constant is text, also when it is reached through another constant. So // is one a previous compile into the same theme left: it is already resolved, and // any var() still in it was quoted text there. - String kind = deferredConstants.get("@" + key); + String kind = shadowed ? shadowedKinds.get("@" + key) : deferredConstants.get("@" + key); if (kind != null && !DEFERRED_LITERAL.equals(kind)) { resolving.add(key); replaceValue = resolveVars(theme, replaceValue, resolving, tokens); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index aa172cc7062..3440a309b50 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -603,6 +603,16 @@ public void testCyclicConstantsAreRejected() { assertEquals("ff0000 ff0000", resource.getTheme("Theme").get("@pair")); } + @Test + public void testPlainConstantDoesNotHideAVariableOfTheSameName() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#f00;} #Constants{accent:blue;}" + + " Label{color:var(--accent);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("blue", theme.get("@accent")); + assertEquals("ff0000", theme.get("Label.fgColor")); + } + @Test public void testQuotedVarTextIsNotResolved() { MutableResource resource = new MutableResource(); From df69ee6d72fb813150694c90add40ec955504257 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:49:55 +0300 Subject: [PATCH 26/59] CSSThemeCompiler: a later :root property does not change an exported constant, exponent constants are rejected, escaped font families are decoded Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 33 +++++++++++++++++-- .../ui/css/CSSThemeCompilerTest.java | 27 ++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 73fd01978f4..c81e0455631 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -116,6 +116,11 @@ public CSSSyntaxException(String message, Throwable cause) { /// reaches the variable after `accent: blue` exported a different `@accent`. private Hashtable shadowedVars = new Hashtable(); private Hashtable shadowedKinds = new Hashtable(); + /// "@name" of every entry a `#Constants` block of this compile exported. The build-time + /// compiler adds a custom property to its constants only inside `#Constants`, so a later + /// `:root { --name: ... }` changes what `var(--name)` reads (through shadowedVars) and + /// leaves the exported `@name` alone. + private HashSet exportedConstants = new HashSet(); /// While set, resolveVars does not see a constant this compile declares unless it holds it: /// one declared further down is not defined yet. A constant only an earlier compile into the /// same theme declared is visible. @@ -144,6 +149,7 @@ public void compile(String css, MutableResource resources, String themeName) { plainConstants = new HashSet(); shadowedVars = new Hashtable(); shadowedKinds = new Hashtable(); + exportedConstants = new HashSet(); declaredThisCompile = new HashSet(); for (Rule rule : rules) { for (Declaration declaration : rule.declarations) { @@ -373,6 +379,7 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, name = name.substring(2); } String key = "@" + name; + exportedConstants.add(key); if (legacy || declaration.name.startsWith("--")) { plainConstants.remove(key); shadowedVars.remove(key); @@ -711,12 +718,18 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str return false; } String key = "@" + property.substring(2); + String raw = value.trim(); + String unquoted = unquote(raw); + if (exportedConstants.contains(key)) { + // Only the variable changes; the constant #Constants exported keeps its value. + shadowedVars.put(key, unquoted); + shadowedKinds.put(key, raw.equals(unquoted) ? DEFERRED_PLAIN : DEFERRED_LITERAL); + plainConstants.add(key); + return true; + } plainConstants.remove(key); shadowedVars.remove(key); shadowedKinds.remove(key); - String raw = value.trim(); - String unquoted = unquote(raw); - // A later custom property replaces a #Constants entry outright, deferred handling included. // An unquoted var() in it is resolved where it is used, against the final values. deferred.put(key, DEFERRED_PLAIN); literalTokens.remove(key); @@ -919,6 +932,14 @@ private Font createFont(String familyList, FontSize size) { String[] families = splitOnComma(familyList); for (String entry : families) { String family = unquote(entry); + if (family.equals(entry.trim())) { + // An identifier is read decoded, as the build-time parser reads it: + // `native\:MainBold` is `native:MainBold`. + String decoded = decodeIdentifier(family); + if (decoded != null) { + family = decoded; + } + } // The build-time compiler walks on past a native font, so a later one, or a later // monospace, wins; only a generic family or monospace ends the walk. if (family.startsWith("native:")) { @@ -1089,6 +1110,12 @@ private static float number(String text, String original) { /// as it serializes the parsed value (`1.50` is `1.5`, `001` is `1`), an identifier as /// written. Every other token type is one it drops, so it is rejected here. private static String buildConstant(String name, String raw, String value) { + if (isCssNumber(value) && (value.indexOf('e') > -1 || value.indexOf('E') > -1)) { + // The build-time parser reads `1e2` as a dimension, a token its constants emitter + // drops, so the built theme has no such constant. + throw new CSSSyntaxException("#Constants does not support exponent notation: " + + name + ": " + raw + " (write the number out, or quote it to store it as text)"); + } if (isCssNumber(value)) { boolean integer = value.indexOf('.') < 0 && value.indexOf('e') < 0 && value.indexOf('E') < 0; try { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 3440a309b50..bd6e17b362e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -487,7 +487,9 @@ public void testLaterDeclarationWinsBetweenRootAndConstants() { resource = new MutableResource(); new CSSThemeCompiler().compile("#Constants{--accent:#ff0000;} :root{--accent:#00ff00;}" + "Label{color:var(--accent);}", resource, "Theme"); - assertEquals("#00ff00", resource.getTheme("Theme").get("@accent")); + // Only #Constants exports: the later :root changes var(--accent), not @accent. + assertEquals("ff0000", resource.getTheme("Theme").get("@accent")); + assertEquals("00ff00", resource.getTheme("Theme").get("Label.fgColor")); // A constant declared after the rule that uses it is not seen by that rule: the build-time // parser resolves var() in source order, #Constants blocks included. assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() @@ -613,6 +615,29 @@ public void testPlainConstantDoesNotHideAVariableOfTheSameName() { assertEquals("ff0000", theme.get("Label.fgColor")); } + @Test + public void testEscapedFontFamilyIdentifierIsDecoded() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:native\\:MainBold;font-size:2mm;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Label.font"))); + } + + @Test + public void testExponentConstantIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants{ scale: 1e2; }", new MutableResource(), "Theme")); + } + + @Test + public void testLaterRootPropertyChangesTheVariableNotTheConstant() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#f00;} :root{--accent:#0f0;}" + + " Label{color:var(--accent);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@accent")); + assertEquals("00ff00", theme.get("Label.fgColor")); + } + @Test public void testQuotedVarTextIsNotResolved() { MutableResource resource = new MutableResource(); From e1200fbe8b79a56d577c3e25f88180cd8dfec477 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:08:53 +0300 Subject: [PATCH 27/59] CSSThemeCompiler: variables kept apart from exported constants, multi-token aliases export the leading token, exponent insets and raw newlines in strings rejected Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 126 ++++++++++++++---- .../ui/css/CSSThemeCompilerTest.java | 51 ++++++- 2 files changed, 145 insertions(+), 32 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index c81e0455631..c04417268ce 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -110,16 +110,16 @@ public CSSSyntaxException(String message, Throwable cause) { /// compiler adds only `--name` declarations to its variables, so `var(--name)` does not see /// these. private HashSet plainConstants = new HashSet(); - /// A custom property this compile declared whose "@name" a later plain `#Constants` entry - /// took over, keyed by "@name": its value, and in shadowedKinds its deferred kind. The - /// build-time compiler keeps variables and constants apart, so `var(--accent)` still - /// reaches the variable after `accent: blue` exported a different `@accent`. - private Hashtable shadowedVars = new Hashtable(); - private Hashtable shadowedKinds = new Hashtable(); + /// Every custom property this compile has read so far, keyed by "@name", as written: the + /// build-time compiler keeps its variables apart from the constants it exports. A variable + /// holds its CSS spelling (`#f00`, not the exported `ff0000`, so a border can use it) and an + /// alias holds `var(--base)` itself, resolved where it is used, so a later `--base` changes + /// it while the exported `@alias` keeps the value it had where it was declared. A later + /// plain `#Constants` entry or `:root` property of the same name changes only its own side. + private Hashtable cssVars = new Hashtable(); /// "@name" of every entry a `#Constants` block of this compile exported. The build-time /// compiler adds a custom property to its constants only inside `#Constants`, so a later - /// `:root { --name: ... }` changes what `var(--name)` reads (through shadowedVars) and - /// leaves the exported `@name` alone. + /// `:root { --name: ... }` changes what `var(--name)` reads and leaves `@name` alone. private HashSet exportedConstants = new HashSet(); /// While set, resolveVars does not see a constant this compile declares unless it holds it: /// one declared further down is not defined yet. A constant only an earlier compile into the @@ -147,8 +147,7 @@ public void compile(String css, MutableResource resources, String themeName) { deferredConstants = deferred; literalTokens = new Hashtable(); plainConstants = new HashSet(); - shadowedVars = new Hashtable(); - shadowedKinds = new Hashtable(); + cssVars = new Hashtable(); exportedConstants = new HashSet(); declaredThisCompile = new HashSet(); for (Rule rule : rules) { @@ -371,7 +370,19 @@ private void resolveThemeConstantVars(Hashtable theme, Hashtable private void compileConstants(Declaration[] declarations, Hashtable theme, Hashtable deferred, boolean legacy) { + // A `--name` becomes a variable once its own declaration is read, so it cannot + // reference itself: it enters cssVars at the start of the next one. + String pendingVar = null; + String pendingValue = null; for (Declaration declaration : declarations) { + if (pendingVar != null) { + cssVars.put(pendingVar, pendingValue); + pendingVar = null; + } + if (!legacy && declaration.name.startsWith("--")) { + pendingVar = "@" + declaration.name.substring(2); + pendingValue = declaration.value.trim(); + } String name = declaration.name; // `#Constants { --accent: ...; }` is how the build-time compiler exports a // palette variable as the constant `@accent`. @@ -382,15 +393,7 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, exportedConstants.add(key); if (legacy || declaration.name.startsWith("--")) { plainConstants.remove(key); - shadowedVars.remove(key); - shadowedKinds.remove(key); } else { - Object variable = theme.get(key); - String variableKind = deferred.get(key); - if (!plainConstants.contains(key) && variableKind != null && variable instanceof String) { - shadowedVars.put(key, (String) variable); - shadowedKinds.put(key, variableKind); - } plainConstants.add(key); } String raw = declaration.value.trim(); @@ -479,6 +482,35 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { } theme.put(key, value); } + if (pendingVar != null) { + cssVars.put(pendingVar, pendingValue); + } + } + + /// The first whitespace- or comma-separated token of `value`, skipping quoted text and + /// parentheses: `foo bar` is `foo`, `rgb(1, 2, 3) x` is `rgb(1, 2, 3)`. + private static String leadingToken(String value) { + int depth = 0; + char quote = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (depth == 0 && (c == ',' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f')) { + return value.substring(0, i); + } + } + return value; } /// A constant that is one var() takes its value's token type, a fallback's too: the @@ -510,7 +542,21 @@ private String resolveAtDeclaration(Hashtable theme, String key, String value, H unresolvedVar = false; try { String resolved = resolveVars(theme, value); - return unresolvedVar ? null : typedAlias(key, value, resolved); + if (unresolvedVar) { + return null; + } + String text = resolved.trim(); + String first = leadingToken(text); + if (first.length() < text.length()) { + // `pair: var(--x) bar` is a chain of lexical units, and the build-time + // constants emitter writes only the leading one: `@pair` is `foo`. + String unquoted = unquote(first); + if (!unquoted.equals(first)) { + return unquoted; + } + return isCssNumber(first) ? buildConstant(key.substring(1), first, first) : first; + } + return typedAlias(key, value, resolved); } finally { declaredOnly = null; if (own != null) { @@ -617,6 +663,11 @@ private static String unquote(String value) { int end = len - 1; for (int i = 1; i < end; i++) { char c = v.charAt(i); + if (c == '\n' || c == '\r' || c == '\f') { + // A CSS string cannot hold an unescaped newline: the build-time parser rejects + // the declaration, so the built theme would not have it. + throw new CSSSyntaxException("Unescaped newline in string: " + value); + } if (c != '\\' || i + 1 >= end) { out.append(c); continue; @@ -720,16 +771,12 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str String key = "@" + property.substring(2); String raw = value.trim(); String unquoted = unquote(raw); + cssVars.put(key, raw); if (exportedConstants.contains(key)) { // Only the variable changes; the constant #Constants exported keeps its value. - shadowedVars.put(key, unquoted); - shadowedKinds.put(key, raw.equals(unquoted) ? DEFERRED_PLAIN : DEFERRED_LITERAL); - plainConstants.add(key); return true; } plainConstants.remove(key); - shadowedVars.remove(key); - shadowedKinds.remove(key); // An unquoted var() in it is resolved where it is used, against the final values. deferred.put(key, DEFERRED_PLAIN); literalTokens.remove(key); @@ -1414,8 +1461,25 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso if (resolving.contains(key)) { throw new CSSSyntaxException("Cyclic variable reference: var(--" + key + ")"); } + String variable = cssVars.get("@" + key); + boolean own = variable != null; + if (own) { + // A quoted variable is text: its token in a style property, its text elsewhere. + String text = unquote(variable); + String spelling = tokens || text.equals(variable) ? variable : text; + if (!text.equals(variable)) { + out.append(value.substring(pos, varPos)).append(spelling); + } else { + resolving.add(key); + out.append(value.substring(pos, varPos)).append(resolveVars(theme, variable, resolving, tokens)); + resolving.remove(resolving.size() - 1); + } + pos = end + 1; + varPos = value.indexOf("var(--", pos); + continue; + } boolean shadowed = plainConstants.contains("@" + key); - Object replacement = shadowed ? shadowedVars.get("@" + key) + Object replacement = shadowed ? null : (declaredOnly != null && !declaredOnly.containsKey("@" + key) && declaredThisCompile.contains("@" + key)) ? null : theme.get("@" + key); if (replacement == null && fallback != null) { @@ -1428,14 +1492,14 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso unresolvedVar = true; } String replaceValue = replacement == null ? "" : replacement.toString(); - String token = tokens && !shadowed ? literalTokens.get("@" + key) : null; + String token = tokens ? literalTokens.get("@" + key) : null; if (token != null) { replaceValue = token; } // A quoted constant is text, also when it is reached through another constant. So // is one a previous compile into the same theme left: it is already resolved, and // any var() still in it was quoted text there. - String kind = shadowed ? shadowedKinds.get("@" + key) : deferredConstants.get("@" + key); + String kind = deferredConstants.get("@" + key); if (kind != null && !DEFERRED_LITERAL.equals(kind)) { resolving.add(key); replaceValue = resolveVars(theme, replaceValue, resolving, tokens); @@ -1725,7 +1789,13 @@ private Length length(String value) { if (unit == null && !isIntegerSpelling(v)) { throw new CSSSyntaxException("A unitless padding or margin must be a whole number: " + value); } - float n = number(unit == null ? v : v.substring(0, v.length() - unit.length()), value); + String digits = unit == null ? v : v.substring(0, v.length() - unit.length()); + if (digits.indexOf('e') > -1 || digits.indexOf('E') > -1) { + // `1e2px` is a dimension with the unit `e2px` to the build-time parser, which the + // inset conversion rejects. + throw new CSSSyntaxException("Exponent notation is not supported for padding or margin: " + value); + } + float n = number(digits, value); if (unit == null || "px".equals(unit) || "pt".equals(unit)) { out.unit = Style.UNIT_TYPE_PIXELS; n = Math.round(n); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index bd6e17b362e..854cdffed69 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -599,10 +599,11 @@ public void testCyclicConstantsAreRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() .compile(":root{ --a: var(--b); } :root{ --b: var(--a); } Label{ color: var(--a); }", new MutableResource(), "Theme")); - // Two uses of one constant are not a cycle. + // Two uses of one constant are not a cycle. The build-time constants emitter writes only + // the leading unit of the chain. MutableResource resource = new MutableResource(); new CSSThemeCompiler().compile("#Constants{ --c: #ff0000; pair: var(--c) var(--c); }", resource, "Theme"); - assertEquals("ff0000 ff0000", resource.getTheme("Theme").get("@pair")); + assertEquals("ff0000", resource.getTheme("Theme").get("@pair")); } @Test @@ -638,6 +639,48 @@ public void testLaterRootPropertyChangesTheVariableNotTheConstant() { assertEquals("00ff00", theme.get("Label.fgColor")); } + @Test + public void testVariableAliasIsResolvedWhereItIsUsed() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--base:#f00;--alias:var(--base);--base:#00f;}" + + " Label{color:var(--alias);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@alias")); + assertEquals("0000ff", theme.get("Label.fgColor")); + } + + @Test + public void testColorVariableKeepsItsCssSpellingInABorder() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#f00;} Label{border:1px solid var(--accent);}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@accent")); + assertNotNull(theme.get("Label.border")); + } + + @Test + public void testMultiTokenAliasExportsItsLeadingToken() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--x:foo; pair:var(--x) bar;}", resource, "Theme"); + assertEquals("foo", resource.getTheme("Theme").get("@pair")); + } + + @Test + public void testExponentInsetWithUnitIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("Label{padding:1e2px;}", new MutableResource(), "Theme")); + } + + @Test + public void testUnescapedNewlineInStringIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants{message:\"first\nsecond\";}", new MutableResource(), "Theme")); + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{message:\"first\\\nsecond\";}", resource, "Theme"); + assertEquals("firstsecond", resource.getTheme("Theme").get("@message")); + } + @Test public void testQuotedVarTextIsNotResolved() { MutableResource resource = new MutableResource(); @@ -864,12 +907,12 @@ public void testFamilyWalkContinuesPastANativeFont() { @Test public void testJavaOnlyNumberSpellingsAreRejected() { - for (String bad : new String[]{"1fpx", "NaNpx", "Infinitypx", "1dpx", "0x1p3px", "1.px"}) { + for (String bad : new String[]{"1fpx", "NaNpx", "Infinitypx", "1dpx", "0x1p3px", "1.px", "1e1px"}) { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() .compile("Label{padding:" + bad + ";}", new MutableResource(), "Theme"), bad); } MutableResource resource = new MutableResource(); - new CSSThemeCompiler().compile("Label{padding:.5mm 1e1px +2px -0px;}", resource, "Theme"); + new CSSThemeCompiler().compile("Label{padding:.5mm 10px +2px -0px;}", resource, "Theme"); assertEquals("0.5,2,0,10", resource.getTheme("Theme").get("Label.padding")); } From e45c42d45407f8e0f3647f871c8d7ce9fc790cb6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:57:53 +0300 Subject: [PATCH 28/59] CSSThemeCompiler: constant names must be identifiers, image aliases decode escaped names, exponent font sizes rejected; GUI builder: support probe resolves project images Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 39 +++++++++++++++---- .../ui/css/CSSThemeCompilerTest.java | 21 ++++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 4 +- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index c04417268ce..a24800f23d2 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -379,11 +379,20 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, cssVars.put(pendingVar, pendingValue); pendingVar = null; } - if (!legacy && declaration.name.startsWith("--")) { - pendingVar = "@" + declaration.name.substring(2); + String declName = declaration.name; + if (!legacy) { + // The build-time parser takes a declaration name only as an identifier, and + // decoded: `1message` is not one, and `a\62 c` is `abc`. + declName = decodeIdentifier(declaration.name); + if (declName == null) { + throw new CSSSyntaxException("Invalid #Constants name: " + declaration.name); + } + } + if (!legacy && declName.startsWith("--")) { + pendingVar = "@" + declName.substring(2); pendingValue = declaration.value.trim(); } - String name = declaration.name; + String name = declName; // `#Constants { --accent: ...; }` is how the build-time compiler exports a // palette variable as the constant `@accent`. if (name.startsWith("--")) { @@ -391,7 +400,7 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, } String key = "@" + name; exportedConstants.add(key); - if (legacy || declaration.name.startsWith("--")) { + if (legacy || declName.startsWith("--")) { plainConstants.remove(key); } else { plainConstants.add(key); @@ -420,10 +429,14 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { deferred.remove(key); continue; } + boolean fromText = false; if (alias && declaration.snapshot != null) { value = declaration.snapshot; + // An alias of a quoted constant names the image by text, as written. + String target = aliasTarget(raw); + fromText = target != null && literalTokens.get(target) != null; } - if (!alias && raw.equals(value)) { + if (!fromText && raw.equals(value) || alias && !fromText) { // An identifier image name is looked up decoded: `my\ icon` is `my icon`. String decoded = decodeIdentifier(value); if (decoded != null) { @@ -500,6 +513,9 @@ private static String leadingToken(String value) { } else if (c == quote) { quote = 0; } + } else if (c == '\\') { + // An escaped character, `my\ icon`'s space among them, is part of the token. + i++; } else if (c == '"' || c == '\'') { quote = c; } else if (c == '(') { @@ -1053,6 +1069,15 @@ private float fontSizeInPixels(FontSize size) { /// Parses a `font-size` with the build-time compiler's conversions: `pt`, `cm` and `in` are /// millimetres, a percentage scales the medium (3mm), small (2mm) or large (4mm) size, and /// a keyword selects that system font size. + /// `digits` unchanged, unless it uses exponent notation: `1e2px` is a dimension with the + /// unit `e2px` to the build-time parser, which it does not recognise as a size. + private static String fontDigits(String digits, String value) { + if (digits.indexOf('e') > -1 || digits.indexOf('E') > -1) { + throw new CSSSyntaxException("Exponent notation is not supported for font-size: " + value); + } + return digits; + } + private FontSize parseFontSize(String value) { String v = value.trim(); FontSize out = new FontSize(); @@ -1070,7 +1095,7 @@ private FontSize parseFontSize(String value) { return out; } if (v.endsWith("%")) { - out.value = 3f * number(v.substring(0, v.length() - 1), value) / 100f; + out.value = 3f * number(fontDigits(v.substring(0, v.length() - 1), value), value) / 100f; out.unit = Style.UNIT_TYPE_DIPS; return out; } @@ -1084,7 +1109,7 @@ private FontSize parseFontSize(String value) { if (unit == null) { throw new CSSSyntaxException("Unsupported font-size value: " + value); } - float n = number(v.substring(0, v.length() - unit.length()), value); + float n = number(fontDigits(v.substring(0, v.length() - unit.length()), value), value); out.value = n; if ("px".equals(unit)) { out.unit = Style.UNIT_TYPE_PIXELS; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 854cdffed69..d11ae4c5e5e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1120,5 +1120,26 @@ void testEscapedIdentifiers() { Hashtable theme = res.getTheme("Theme"); assertEquals(5, ((Image) theme.get("@emptyImage")).getWidth()); assertEquals("foo!important", theme.get("@message")); + + res = new MutableResource(); + res.setImage("my icon", icon); + new CSSThemeCompiler().compile("#Constants { --icon: my\\ icon; navImage: var(--icon); }", res, "Theme"); + assertEquals(5, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testExponentFontSizeIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("Label{font-size:1e2px;}", new MutableResource(), "Theme")); + new CSSThemeCompiler().compile("Label{font-size:1rem;}", new MutableResource(), "Theme"); + } + + @Test + void testConstantNameMustBeAnIdentifier() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants{1message: hello;}", new MutableResource(), "Theme")); + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{a\\62 c: hello;}", res, "Theme"); + assertEquals("hello", res.getTheme("Theme").get("@abc")); } } diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 2156289c9de..80653c9f97c 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5395,7 +5395,9 @@ private static int lineStartBefore(String text, int at) { */ private Set compiledKeys(String declaration) { try { - MutableResource probe = new MutableResource(); + // Project-aware, as the real compile is: a cn1-image-id naming an image under + // res/Theme// applies there, so the probe must find it too. + MutableResource probe = new ProjectImageResource(binding == null ? null : binding.cssFile()); new CSSThemeCompiler().compile("CanvasCssProbe { color: #010203; " + declaration + " }", probe, "Probe"); Set keys = new LinkedHashSet<>(); From 43f32fdf090ce94ce232d968c7c34bbf82bb469f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:21:08 +0300 Subject: [PATCH 29/59] CSSThemeCompiler: a direct multi-token constant exports its leading token; GUI builder: a directory named x.png is not a variant Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 33 +++++++++++++++---- .../ui/css/CSSThemeCompilerTest.java | 14 +++++++- .../guibuilder/CodenameOneGUIBuilder.java | 11 +++++-- .../guibuilder/GeneratedSourceTest.java | 12 +++++++ 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index a24800f23d2..fa8b2e2a664 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -487,11 +487,20 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { deferred.put(key, kind); } else if (!legacy) { // The build-time compiler writes string, identifier, number and color constants - // only; anything else (`50%`, `2mm`, `url(a.png)`, a list of values) is dropped - // from the built theme without a word, so a preview that showed it would show a - // constant the application never gets. The legacy `@constants` block never - // reaches the build-time compiler and keeps such values. - value = buildConstant(name, raw, value); + // only; anything else (`50%`, `2mm`, `url(a.png)`) is dropped from the built + // theme without a word, so a preview that showed it would show a constant the + // application never gets. Of a chain such as `foo bar` it writes the leading unit + // alone. The legacy `@constants` block never reaches the build-time compiler and + // keeps such values. + String first = leadingToken(value); + String unquotedFirst = unquote(first); + if (first.length() < value.length() && !unquotedFirst.equals(first)) { + value = unquotedFirst; + } else if (first.length() < value.length() && isColorLiteral(first)) { + value = constantColor(first); + } else { + value = buildConstant(name, first, first); + } } theme.put(key, value); } @@ -514,8 +523,18 @@ private static String leadingToken(String value) { quote = 0; } } else if (c == '\\') { - // An escaped character, `my\ icon`'s space among them, is part of the token. - i++; + // An escaped character, `my\ icon`'s space among them, is part of the token, and + // so is the one whitespace ending a hex escape (`\41 x` is `Ax`). + int j = i + 1; + while (j < value.length() && j - i <= 6 && Character.digit(value.charAt(j), 16) >= 0) { + j++; + } + if (j > i + 1 && j < value.length() && (value.charAt(j) == ' ' || value.charAt(j) == '\t' + || value.charAt(j) == '\n')) { + i = j; + } else { + i = j > i + 1 ? j - 1 : i + 1; + } } else if (c == '"' || c == '\'') { quote = c; } else if (c == '(') { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index d11ae4c5e5e..7e6eedaeb68 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -960,7 +960,7 @@ void testPriorityAfterAStringHoldingTheOtherQuoteIsStripped() { @Test void testLengthAndPercentageConstantsAreRejected() { - for (String value : new String[]{"50%", "2mm", "1.5px", "url(foo.png)", "a b"}) { + for (String value : new String[]{"50%", "2mm", "1.5px", "url(foo.png)"}) { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler().compile("#Constants { progress: " + value + "; }", new MutableResource(), "Theme")); } @@ -1127,6 +1127,18 @@ void testEscapedIdentifiers() { assertEquals(5, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); } + @Test + void testDirectMultiTokenConstantExportsItsLeadingToken() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ pair: foo bar; num: 1.50 2; col: #f00 x; str: \"a b\" c; }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("foo", theme.get("@pair")); + assertEquals("1.5", theme.get("@num")); + assertEquals("ff0000", theme.get("@col")); + assertEquals("a b", theme.get("@str")); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 80653c9f97c..6dd21a128ad 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5534,12 +5534,18 @@ public com.codename1.ui.Image getImage(String id) { * build's case-sensitive switch reads as medium) as medium -- and loads the one the * display density gets. A named medium file wins over another medium-classified one. */ + private static boolean isDirectory(String path) { + return com.codename1.io.FileSystemStorage.getInstance().isDirectory(path); + } + private static com.codename1.ui.Image bestVariant(String folder, String[] files) { // The build decodes every PNG of the folder before it picks one, so a corrupt // variant fails the image there even when the display would not select it. for (String listed : files) { String file = stripSlash(listed); - if (file.regionMatches(true, file.length() - 4, ".png", 0, 4) && !decodes(load(folder + "/" + file))) { + // The build skips anything that is not a file, a directory named x.png included. + if (file.regionMatches(true, file.length() - 4, ".png", 0, 4) && !isDirectory(folder + "/" + file) + && !decodes(load(folder + "/" + file))) { return null; } } @@ -5547,7 +5553,8 @@ private static com.codename1.ui.Image bestVariant(String folder, String[] files) String match = null; for (String listed : files) { String file = stripSlash(listed); - if (!file.regionMatches(true, file.length() - 4, ".png", 0, 4)) continue; + if (!file.regionMatches(true, file.length() - 4, ".png", 0, 4) + || isDirectory(folder + "/" + file)) continue; if (density.equals(file)) { match = file; break; diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 63dafb27e56..76fbc90d38f 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1855,6 +1855,18 @@ void anUpperCaseDensityNameIsReadAsMediumLikeTheBuild() throws Exception { assertEquals(expected, image.getWidth(), "the build's case-sensitive switch reads high.PNG as medium"); } + @Test + void aDirectoryNamedLikeAPngIsSkippedLikeTheBuild() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder.resolve("high.png")); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertEquals(3, image.getWidth()); + } + @Test void aCorruptVariantFailsTheWholeFolderLikeTheBuild() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); From e5c2edaf7c30bdc960c6c7f793c3aaff7c110830 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:00:51 +0300 Subject: [PATCH 30/59] CSSThemeCompiler: an image alias of a color or number is typed as one; escaped colons in declaration names Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 22 ++++++++++++++++++- .../ui/css/CSSThemeCompilerTest.java | 13 +++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index fa8b2e2a664..fba499a5fe4 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -436,6 +436,13 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { String target = aliasTarget(raw); fromText = target != null && literalTokens.get(target) != null; } + if (alias && declaration.snapshot != null && !fromText + && (isColorLiteral(value) || isCssNumber(value))) { + // The alias resolved to a color or a number: the build-time compiler types + // the evaluated value, so it is that, not an image named `#f00`. + theme.put(key, isColorLiteral(value) ? constantColor(value) : buildConstant(name, value, value)); + continue; + } if (!fromText && raw.equals(value) || alias && !fromText) { // An identifier image name is looked up decoded: `my\ icon` is `my icon`. String decoded = decodeIdentifier(value); @@ -2102,6 +2109,19 @@ private static String stripPriority(String value) { return open == 0 ? value.substring(0, bang).trim() : value; } + /// The first `:` not escaped by a backslash: `a\:b: hello` names the constant `a:b`. + private static int unescapedColon(String declaration) { + for (int i = 0; i < declaration.length(); i++) { + char c = declaration.charAt(i); + if (c == '\\') { + i++; + } else if (c == ':') { + return i; + } + } + return -1; + } + private Declaration[] parseDeclarations(String body) { ArrayList out = new ArrayList(); String[] segments = splitDeclarations(body); @@ -2110,7 +2130,7 @@ private Declaration[] parseDeclarations(String body) { if (trimmed.length() == 0) { continue; } - int colon = trimmed.indexOf(':'); + int colon = unescapedColon(trimmed); if (colon <= 0 || colon == trimmed.length() - 1) { throw new CSSSyntaxException("Malformed declaration: " + trimmed); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 7e6eedaeb68..3d8a991a759 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1153,5 +1153,18 @@ void testConstantNameMustBeAnIdentifier() { MutableResource res = new MutableResource(); new CSSThemeCompiler().compile("#Constants{a\\62 c: hello;}", res, "Theme"); assertEquals("hello", res.getTheme("Theme").get("@abc")); + res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{a\\:b: hello;}", res, "Theme"); + assertEquals("hello", res.getTheme("Theme").get("@a:b")); + } + + @Test + void testImageAliasOfAColorOrNumberIsNotAnImage() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --tone: #f00; --n: 3; placeholderImage: var(--tone);" + + " countImage: var(--n); }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("ff0000", theme.get("@placeholderImage")); + assertEquals("3", theme.get("@countImage")); } } From 313a87da684e0482f9e27454507489147733a0d0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:24:20 +0300 Subject: [PATCH 31/59] GUI builder: cache canvas support per declaration value, not per property Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/guibuilder/CodenameOneGUIBuilder.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 6dd21a128ad..aaf4c5c46bc 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5332,11 +5332,14 @@ private List unsupportedDeclarations(String css) { if (property.length() == 0 || value.length() == 0 || property.startsWith("@")) continue; // A #Constants entry is a theme constant, not a style property a probe rule could show. if (isInConstantsBlock(masked, at)) continue; - Boolean supported = judged.get(property); + // Keyed by the value too: whether a declaration applies can depend on it, as a + // cn1-image-id naming a project image that exists or not does. + String judgedKey = property + ":" + value; + Boolean supported = judged.get(judgedKey); if (supported == null) { Set withProperty = compiledKeys(property + ": " + value + ";"); supported = Boolean.valueOf(withProperty == null || !withProperty.equals(baseline)); - judged.put(property, supported); + judged.put(judgedKey, supported); } if (!supported.booleanValue()) { out.add(new CodeDiagnostic(line, nameStart - lineStartBefore(masked, nameStart) + 1, From 63b9e91ae83556f4fb098d25fb4ebbc428b90fcd Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:54:43 +0300 Subject: [PATCH 32/59] CSSThemeCompiler: legacy @constants stay overridable, self-referencing custom properties are a cycle, pressed derives stay in their state, percentage rgb() channels; CSSTheme: percentage rgb() channels were read as 0 Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 38 ++++++++++--------- .../ui/css/CSSThemeCompilerTest.java | 34 ++++++++++++++--- .../com/codename1/designer/css/CSSTheme.java | 21 +++++++--- .../css/RuntimeCompilerParityTest.java | 3 +- 4 files changed, 66 insertions(+), 30 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index fba499a5fe4..9f20e8bbc5a 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -370,15 +370,7 @@ private void resolveThemeConstantVars(Hashtable theme, Hashtable private void compileConstants(Declaration[] declarations, Hashtable theme, Hashtable deferred, boolean legacy) { - // A `--name` becomes a variable once its own declaration is read, so it cannot - // reference itself: it enters cssVars at the start of the next one. - String pendingVar = null; - String pendingValue = null; for (Declaration declaration : declarations) { - if (pendingVar != null) { - cssVars.put(pendingVar, pendingValue); - pendingVar = null; - } String declName = declaration.name; if (!legacy) { // The build-time parser takes a declaration name only as an identifier, and @@ -389,8 +381,9 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, } } if (!legacy && declName.startsWith("--")) { - pendingVar = "@" + declName.substring(2); - pendingValue = declaration.value.trim(); + // The build-time compiler adds the raw variable before it evaluates it, so + // `--accent: var(--accent, #f00)` is a cycle there, which fails the build. + cssVars.put("@" + declName.substring(2), declaration.value.trim()); } String name = declName; // `#Constants { --accent: ...; }` is how the build-time compiler exports a @@ -399,7 +392,11 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, name = name.substring(2); } String key = "@" + name; - exportedConstants.add(key); + if (!legacy) { + // Only #Constants is what the build-time compiler exports; a later :root still + // replaces a legacy @constants entry, as it always has. + exportedConstants.add(key); + } if (legacy || declName.startsWith("--")) { plainConstants.remove(key); } else { @@ -511,9 +508,6 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { } theme.put(key, value); } - if (pendingVar != null) { - cssVars.put(pendingVar, pendingValue); - } } /// The first whitespace- or comma-separated token of `value`, skipping quoted text and @@ -775,7 +769,9 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha continue; } String value = resolveVars(theme, declaration.value, new ArrayList(), true); - if ("cn1-derive".equals(property)) { + if ("cn1-derive".equals(property) && !"press#".equals(statePrefix)) { + // The build-time compiler's Element.setParent() promotes a derive to the owning + // UIID from every state but pressed. ownerDerives.put(uiid, value.trim()); } if (universal && ("font-family".equals(property) || "font-size".equals(property))) { @@ -1000,8 +996,9 @@ private String anyStateDerive(Hashtable theme, String id, boolean dark) { if (recorded != null) { return recorded; } - // A derive a previous compile into the same theme stored: its order is gone. - String[] states = {"sel#", "press#", "dis#", "hover#"}; + // A derive a previous compile into the same theme stored: its order is gone. Pressed is + // not among them: the build-time compiler does not promote a pressed derive. + String[] states = {"sel#", "dis#", "hover#"}; for (int pass = dark ? 0 : 1; pass < 2; pass++) { String prefix = pass == 0 ? "$Dark" + id + "." : id + "."; for (String state : states) { @@ -1775,8 +1772,13 @@ private String cssColorKeyword(String value) { private int parseRgbChannel(String value, String originalColor) { int out; + String v = value.trim(); try { - out = Integer.parseInt(value.trim()); + if (v.endsWith("%")) { + out = Math.round(Float.parseFloat(v.substring(0, v.length() - 1)) * 255f / 100f); + } else { + out = Integer.parseInt(v); + } } catch (RuntimeException err) { throw new CSSSyntaxException("Invalid rgb() channel value in " + originalColor + ": " + value, err); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 3d8a991a759..ff1b9d05196 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -589,13 +589,16 @@ public void testStateFontIgnoresTheUniversalStyle() { @Test public void testCyclicConstantsAreRejected() { - // In #Constants a reference sees only what is declared above it, so a self or mutual - // reference is undefined, as in the build-time compiler, and the constants are left out. + // The build-time compiler adds a custom property before it evaluates it, so a self or + // mutual reference is a cycle that fails the build. A plain name is no variable, so a + // reference to it is undefined and the constant is left out instead. + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ --a: var(--a); }", new MutableResource(), "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ --b: var(--c); --c: var(--b); }", new MutableResource(), "Theme")); MutableResource cyclic = new MutableResource(); - new CSSThemeCompiler().compile("#Constants{ --a: var(--a); --b: var(--c); --c: var(--b); }", cyclic, "Theme"); + new CSSThemeCompiler().compile("#Constants{ a: var(--a); }", cyclic, "Theme"); assertFalse(cyclic.getTheme("Theme").containsKey("@a")); - assertFalse(cyclic.getTheme("Theme").containsKey("@b")); - assertFalse(cyclic.getTheme("Theme").containsKey("@c")); assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() .compile(":root{ --a: var(--b); } :root{ --b: var(--a); } Label{ color: var(--a); }", new MutableResource(), "Theme")); @@ -1139,6 +1142,27 @@ void testDirectMultiTokenConstantExportsItsLeadingToken() { assertEquals("a b", theme.get("@str")); } + @Test + void testLaterRootReplacesALegacyConstant() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("@constants { accent: red; } :root { --accent: blue; }", res, "Theme"); + assertEquals("blue", res.getTheme("Theme").get("@accent")); + } + + @Test + void testSelfReferencingCustomPropertyIsACycle() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants { --accent: var(--accent, #f00); }", new MutableResource(), "Theme")); + } + + @Test + void testPressedDeriveIsNotPromotedToTheOwner() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainBold;font-size:2mm;}" + + " Child:pressed{cn1-derive:Base;} Child:selected{font-size:20px;}", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("Child.sel#font"))); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 0e5548311db..e4acb402eca 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -9363,6 +9363,15 @@ static String getARGBHexString(LexicalUnit color) { return "#" + leftPad(Integer.toHexString(getColorInt(color)), 6) + leftPad(Integer.toHexString(getColorAlphaInt(color)), 2); } + /// One `rgb()` channel: `50%` is half of 255, which getIntegerValue() read as 0, so every + /// percentage color came out black. + private static int rgbChannel(ScaledUnit channel) { + if (channel.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { + return Math.round(channel.getFloatValue() * 255f / 100f); + } + return channel.getIntegerValue(); + } + static String getColorString(LexicalUnit color) { return getColorString(color, false); } @@ -9403,9 +9412,9 @@ static String getColorString(LexicalUnit color, boolean premultiplied) { blue = (ScaledUnit)blue.getNextLexicalUnit(); } return String.format( "%02X%02X%02X", - red.getIntegerValue(), - green.getIntegerValue(), - blue.getIntegerValue()); + rgbChannel(red), + rgbChannel(green), + rgbChannel(blue)); } case LexicalUnit.SAC_FUNCTION: { @@ -9450,9 +9459,9 @@ static String getColorString(LexicalUnit color, boolean premultiplied) { throw new RuntimeException("Failed to parse color " + color + ". Received null alpha parameter:" + color); } } - int r = (int)(red.getIntegerValue() * (premultiplied?(1-alpha.getNumericValue()):1)); - int g = (int)(green.getIntegerValue() * (premultiplied?(1-alpha.getNumericValue()):1)); - int b = (int)(blue.getIntegerValue() * (premultiplied?(1-alpha.getNumericValue()):1)); + int r = (int)(rgbChannel(red) * (premultiplied?(1-alpha.getNumericValue()):1)); + int g = (int)(rgbChannel(green) * (premultiplied?(1-alpha.getNumericValue()):1)); + int b = (int)(rgbChannel(blue) * (premultiplied?(1-alpha.getNumericValue()):1)); return String.format( "%02X%02X%02X", diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java index 0d3484f403e..212d2ae708f 100644 --- a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java @@ -48,6 +48,7 @@ public class RuntimeCompilerParityTest { + " accentColor: #ff2d95;\n" + " shortColor: #abc;\n" + " switchTrackScaleY: 1.5;\n" + + " pctColor: rgb(100%, 0%, 50%);\n" + "}\n" + "Label { padding: 1px 2px 3px 4px; margin: 1mm 2mm; }\n" + "One { padding: 5px; margin: 0.5mm; }\n" @@ -70,7 +71,7 @@ void constantsMatch() throws Exception { } // Hex case differs between the compilers for every color, not just constants (the // build-time one writes upper case); both are read with Integer.parseInt(value, 16). - for (String key : new String[]{"@accentColor", "@shortColor"}) { + for (String key : new String[]{"@accentColor", "@shortColor", "@pctColor"}) { assertEquals(Integer.valueOf(Integer.parseInt((String) standard.get(key), 16)), Integer.valueOf(Integer.parseInt((String) runtime.get(key), 16)), key); } From b0d26b291180ea4617f1bb089dccc7ca1536fdf7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:26:13 +0300 Subject: [PATCH 33/59] CSSThemeCompiler: image constants look up their leading token; escaped #Constants names count as redeclarations Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/ui/css/CSSThemeCompiler.java | 12 ++++++++++++ .../codename1/ui/css/CSSThemeCompilerTest.java | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 9f20e8bbc5a..ca791c0ee48 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -154,6 +154,13 @@ public void compile(String css, MutableResource resources, String themeName) { for (Declaration declaration : rule.declarations) { if (rule.constants || rule.legacyConstants) { String n = declaration.name; + if (rule.constants) { + // Decoded, as compileConstants keys it: `--a\62 c` declares `@abc`. + String decoded = decodeIdentifier(n); + if (decoded != null) { + n = decoded; + } + } declaredThisCompile.add("@" + (n.startsWith("--") ? n.substring(2) : n)); } else if (declaration.name.startsWith("--")) { declaredThisCompile.add("@" + declaration.name.substring(2)); @@ -441,6 +448,11 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { continue; } if (!fromText && raw.equals(value) || alias && !fromText) { + if (!legacy) { + // Of a chain such as `icon extra` the build-time compiler looks up the + // leading unit alone. + value = leadingToken(value); + } // An identifier image name is looked up decoded: `my\ icon` is `my icon`. String decoded = decodeIdentifier(value); if (decoded != null) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index ff1b9d05196..aa9a4b60c13 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1163,6 +1163,23 @@ void testPressedDeriveIsNotPromotedToTheOwner() { assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("Child.sel#font"))); } + @Test + void testImageConstantLooksUpItsLeadingToken() { + MutableResource res = new MutableResource(); + res.setImage("icon", Image.createImage(4, 4)); + new CSSThemeCompiler().compile("#Constants { navImage: icon extra; }", res, "Theme"); + assertEquals(4, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testEscapedRedeclarationHidesAnEarlierCompilesValue() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --abc: red; }", res, "Theme"); + new CSSThemeCompiler().compile("#Constants { early: var(--abc); --a\\62 c: blue; }", res, "Theme"); + assertFalse(res.getTheme("Theme").containsKey("@early")); + assertEquals("blue", res.getTheme("Theme").get("@abc")); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> From 02bfc37a9095b9aab8529c116e0bddf590920e2e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:07:50 +0300 Subject: [PATCH 34/59] CSSThemeCompiler: quoted image names survive alias chains, escaped semicolons stay in identifiers, dark fonts ignore light derives; both compilers clamp out-of-range rgb() channels Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 36 +++++++++++++------ .../ui/css/CSSThemeCompilerTest.java | 32 +++++++++++++++++ .../com/codename1/designer/css/CSSTheme.java | 10 ++++-- .../css/RuntimeCompilerParityTest.java | 3 +- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index ca791c0ee48..c8efa445e18 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -437,8 +437,7 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { if (alias && declaration.snapshot != null) { value = declaration.snapshot; // An alias of a quoted constant names the image by text, as written. - String target = aliasTarget(raw); - fromText = target != null && literalTokens.get(target) != null; + fromText = aliasesQuotedText(raw); } if (alias && declaration.snapshot != null && !fromText && (isColorLiteral(value) || isCssNumber(value))) { @@ -595,7 +594,7 @@ private String resolveAtDeclaration(Hashtable theme, String key, String value, H } String text = resolved.trim(); String first = leadingToken(text); - if (first.length() < text.length()) { + if (first.length() < text.length() && !aliasesQuotedText(value)) { // `pair: var(--x) bar` is a chain of lexical units, and the build-time // constants emitter writes only the leading one: `@pair` is `foo`. String unquoted = unquote(first); @@ -641,6 +640,20 @@ private boolean referencesDeclaredOnly(Hashtable theme, String key, String value return true; } + /// Whether `value` is an alias that ends, through every intermediate alias, at a quoted + /// string: `--a: var(--name)` keeps the string `--name` was, as the build-time compiler keeps + /// the lexical unit, so `navImage: var(--a)` names the image `my icon`, not `my`. + private boolean aliasesQuotedText(String value) { + String target = aliasTarget(value); + ArrayList seen = new ArrayList(); + while (target != null && literalTokens.get(target) == null && !seen.contains(target)) { + seen.add(target); + String next = cssVars.get(target); + target = next == null ? null : aliasTarget(next); + } + return target != null && literalTokens.get(target) != null; + } + /// `@x` for a value that is exactly `var(--x)` or `var(--x, fallback)`, else null. private static String aliasTarget(String value) { String v = value.trim(); @@ -970,9 +983,11 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo chain.add(id); // A derive this compile declared, in any state: the last one in source order is the // owning UIID's only parent in the build-time compiler, for every state. - String owner = dark ? ownerDerives.get("$Dark" + id) : null; - if (owner == null) { - owner = ownerDerives.get(id); + String owner = dark ? ownerDerives.get("$Dark" + id) : ownerDerives.get(id); + if (dark && owner == null && ownerDerives.get(id) != null) { + // The build-time compiler makes `$DarkChild` an element of its own with no link + // to the light `Child`'s derive, so only a dark derive extends the dark chain. + break; } if (owner != null) { id = owner; @@ -1794,10 +1809,8 @@ private int parseRgbChannel(String value, String originalColor) { } catch (RuntimeException err) { throw new CSSSyntaxException("Invalid rgb() channel value in " + originalColor + ": " + value, err); } - if (out < 0 || out > 255) { - throw new CSSSyntaxException("rgb() channel out of range in " + originalColor + ": " + value); - } - return out; + // Out of range is clamped, as CSS specifies and as the build-time compiler does. + return Math.max(0, Math.min(255, out)); } private boolean isHexColor(String value) { @@ -2173,6 +2186,9 @@ private String[] splitDeclarations(String input) { } else if (c == quote) { quote = 0; } + } else if (c == '\\') { + // `foo\;bar` is one identifier, as the build-time parser reads it. + i++; } else if (c == '"' || c == '\'') { quote = c; } else if (c == ';') { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index aa9a4b60c13..f2092a848f2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1180,6 +1180,38 @@ void testEscapedRedeclarationHidesAnEarlierCompilesValue() { assertEquals("blue", res.getTheme("Theme").get("@abc")); } + @Test + void testImageAliasChainKeepsItsQuotedName() { + MutableResource res = new MutableResource(); + res.setImage("my icon", Image.createImage(5, 5)); + new CSSThemeCompiler().compile("#Constants { --name: \"my icon\"; --alias: var(--name);" + + " navImage: var(--alias); }", res, "Theme"); + assertEquals(5, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testEscapedSemicolonStaysInTheIdentifier() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: foo\\;bar; }", res, "Theme"); + assertEquals("foo;bar", res.getTheme("Theme").get("@message")); + } + + @Test + void testDarkFontDoesNotFollowTheLightDerive() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Child{cn1-derive:Base;}" + + " @media (prefers-color-scheme: dark){ Base{font-family:native:MainBold;font-size:2mm;}" + + " Child{font-size:20px;} }", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("$DarkChild.font"))); + } + + @Test + void testOutOfRangeRgbChannelIsClamped() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: rgb(200%, 300, -5); }", res, "Theme"); + assertEquals(0xffff00, Integer.parseInt((String) res.getTheme("Theme").get("@accent"), 16)); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index e4acb402eca..8900cd9fb05 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -9367,9 +9367,15 @@ static String getARGBHexString(LexicalUnit color) { /// percentage color came out black. private static int rgbChannel(ScaledUnit channel) { if (channel.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { - return Math.round(channel.getFloatValue() * 255f / 100f); + return clampChannel(Math.round(channel.getFloatValue() * 255f / 100f)); } - return channel.getIntegerValue(); + return clampChannel(channel.getIntegerValue()); + } + + /// Out of range is clamped, as CSS specifies: `200%` or `300` is 255, not a value `%02X` + /// would write as three digits, breaking the six-digit color. + private static int clampChannel(int value) { + return Math.max(0, Math.min(255, value)); } static String getColorString(LexicalUnit color) { diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java index 212d2ae708f..d6a6834af66 100644 --- a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java @@ -49,6 +49,7 @@ public class RuntimeCompilerParityTest { + " shortColor: #abc;\n" + " switchTrackScaleY: 1.5;\n" + " pctColor: rgb(100%, 0%, 50%);\n" + + " clampColor: rgb(200%, 300, 0);\n" + "}\n" + "Label { padding: 1px 2px 3px 4px; margin: 1mm 2mm; }\n" + "One { padding: 5px; margin: 0.5mm; }\n" @@ -71,7 +72,7 @@ void constantsMatch() throws Exception { } // Hex case differs between the compilers for every color, not just constants (the // build-time one writes upper case); both are read with Integer.parseInt(value, 16). - for (String key : new String[]{"@accentColor", "@shortColor", "@pctColor"}) { + for (String key : new String[]{"@accentColor", "@shortColor", "@pctColor", "@clampColor"}) { assertEquals(Integer.valueOf(Integer.parseInt((String) standard.get(key), 16)), Integer.valueOf(Integer.parseInt((String) runtime.get(key), 16)), key); } From 450f1079e07350dcd7896d502ccdedacdbdffb3b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:20:10 +0300 Subject: [PATCH 35/59] CSSThemeCompiler: an escaped quote outside a string opens no string Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/ui/css/CSSThemeCompiler.java | 15 +++++++++++++++ .../codename1/ui/css/CSSThemeCompilerTest.java | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index c8efa445e18..8889ea337a9 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -1601,6 +1601,9 @@ private static int closingParen(String value, int from) { } else if (c == quote) { quote = 0; } + } else if (c == '\\') { + // An escape outside a string: `foo\"bar` is one identifier, not a string start. + i++; } else if (c == '"' || c == '\'') { quote = c; } else if (c == '(') { @@ -1627,6 +1630,9 @@ private static int topLevelComma(String value) { } else if (c == quote) { quote = 0; } + } else if (c == '\\') { + // An escape outside a string: `foo\"bar` is one identifier, not a string start. + i++; } else if (c == '"' || c == '\'') { quote = c; } else if (c == '(') { @@ -1971,6 +1977,9 @@ private int findMatchingBrace(String css, int openPos) { } else if (c == quote) { quote = 0; } + } else if (c == '\\') { + // An escape outside a string: `foo\"bar` is one identifier, not a string start. + i++; } else if (c == '"' || c == '\'') { quote = c; } else if (c == '{') { @@ -2035,6 +2044,12 @@ private String stripComments(String css) { i++; continue; } + if (c == '\\' && i + 1 < css.length()) { + // An escaped quote outside a string, as in `foo\"bar`, opens no string. + out.append(c).append(css.charAt(i + 1)); + i += 2; + continue; + } if (c == '"' || c == '\'') { quote = c; } else if (c == '/' && i + 1 < css.length() && css.charAt(i + 1) == '*') { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index f2092a848f2..71d31c703c6 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1212,6 +1212,13 @@ void testOutOfRangeRgbChannelIsClamped() { assertEquals(0xffff00, Integer.parseInt((String) res.getTheme("Theme").get("@accent"), 16)); } + @Test + void testEscapedQuoteOutsideAStringOpensNoString() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: foo\\\"bar; } Label{padding:1px;}", res, "Theme"); + assertEquals("foo\"bar", res.getTheme("Theme").get("@message")); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> From 1732c45231f02e39fb23c7ec6a6d4e253fd600b1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:07:41 +0300 Subject: [PATCH 36/59] CSSThemeCompiler: dark font chains read only dark derives, #Constants in a selector list, cn1-derive targets unquoted and decoded, hex escapes end at any CSS whitespace Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 76 ++++++++++++------- .../ui/css/CSSThemeCompilerTest.java | 36 +++++++++ 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 8889ea337a9..ad5798f2cf3 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -541,9 +541,8 @@ private static String leadingToken(String value) { while (j < value.length() && j - i <= 6 && Character.digit(value.charAt(j), 16) >= 0) { j++; } - if (j > i + 1 && j < value.length() && (value.charAt(j) == ' ' || value.charAt(j) == '\t' - || value.charAt(j) == '\n')) { - i = j; + if (j > i + 1 && skipEscapeTerminator(value, j) > j) { + i = skipEscapeTerminator(value, j) - 1; } else { i = j > i + 1 ? j - 1 : i + 1; } @@ -654,6 +653,18 @@ private boolean aliasesQuotedText(String value) { return target != null && literalTokens.get(target) != null; } + /// Past the one whitespace that may end a hex escape: any CSS whitespace, with CR LF as one. + private static int skipEscapeTerminator(String value, int j) { + if (j >= value.length()) { + return j; + } + char c = value.charAt(j); + if (c == '\r' && j + 1 < value.length() && value.charAt(j + 1) == '\n') { + return j + 2; + } + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' ? j + 1 : j; + } + /// `@x` for a value that is exactly `var(--x)` or `var(--x, fallback)`, else null. private static String aliasTarget(String value) { String v = value.trim(); @@ -704,6 +715,15 @@ private static String cssColorFunction(String value) { return value.regionMatches(true, 0, "cn1rgb", 0, 6) ? value.substring(3) : value; } + private static String derivedUiid(String value) { + String unquoted = unquote(value); + if (!unquoted.equals(value)) { + return unquoted; + } + String decoded = decodeIdentifier(value); + return decoded != null ? decoded : value; + } + private static boolean isConstantsSelector(String selector) { return "@constants".equalsIgnoreCase(selector) || "#Constants".equalsIgnoreCase(selector); } @@ -794,6 +814,11 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha continue; } String value = resolveVars(theme, declaration.value, new ArrayList(), true); + if ("cn1-derive".equals(property)) { + // The build-time compiler reads the target with getStringValue(): `"Base"` and + // `B\61se` both name the UIID `Base`. + value = derivedUiid(value.trim()); + } if ("cn1-derive".equals(property) && !"press#".equals(statePrefix)) { // The build-time compiler's Element.setParent() promotes a derive to the owning // UIID from every state but pressed. @@ -983,28 +1008,22 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo chain.add(id); // A derive this compile declared, in any state: the last one in source order is the // owning UIID's only parent in the build-time compiler, for every state. - String owner = dark ? ownerDerives.get("$Dark" + id) : ownerDerives.get(id); - if (dark && owner == null && ownerDerives.get(id) != null) { - // The build-time compiler makes `$DarkChild` an element of its own with no link - // to the light `Child`'s derive, so only a dark derive extends the dark chain. - break; - } + // The build-time compiler makes `$DarkChild` an element of its own with no link to + // the light `Child`'s derive, so only a dark derive extends the dark chain. + String owner = ownerDerives.get(dark ? "$Dark" + id : id); if (owner != null) { id = owner; continue; } - // Otherwise a derive a previous compile into the same theme stored. - Object stateDerive = null; - if (state.length() > 0) { - stateDerive = dark ? theme.get("$Dark" + id + "." + state + "derive") : null; - if (stateDerive == null) { - stateDerive = theme.get(id + "." + state + "derive"); - } - } + // Otherwise a derive a previous compile into the same theme stored. For a dark + // element only its own `$Dark` keys: a retained light derive is no link either. + String prefix = dark ? "$Dark" + id + "." : id + "."; + Object stateDerive = state.length() > 0 ? theme.get(prefix + state + "derive") : null; if (stateDerive instanceof String) { id = (String) stateDerive; } else { - id = hoverBase(theme, id, dark); + Object base = theme.get(prefix + "derive"); + id = base instanceof String ? (String) base : null; if (id == null) { id = anyStateDerive(theme, chain.get(chain.size() - 1), dark); } @@ -1016,17 +1035,14 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo /// The build-time compiler applies a `cn1-derive` declared in any state to the owning UIID, /// so `Child:selected{cn1-derive:Base}` is also where `Child:pressed` inherits from. private String anyStateDerive(Hashtable theme, String id, boolean dark) { - String recorded = dark ? ownerDerives.get("$Dark" + id) : null; - if (recorded == null) { - recorded = ownerDerives.get(id); - } + String recorded = ownerDerives.get(dark ? "$Dark" + id : id); if (recorded != null) { return recorded; } // A derive a previous compile into the same theme stored: its order is gone. Pressed is // not among them: the build-time compiler does not promote a pressed derive. String[] states = {"sel#", "dis#", "hover#"}; - for (int pass = dark ? 0 : 1; pass < 2; pass++) { + for (int pass = dark ? 0 : 1; pass < (dark ? 1 : 2); pass++) { String prefix = pass == 0 ? "$Dark" + id + "." : id + "."; for (String state : states) { Object derive = theme.get(prefix + state + "derive"); @@ -1293,9 +1309,7 @@ private static String decodeIdentifier(String t) { } else { out.append((char) code); } - if (j < n && (t.charAt(j) == ' ' || t.charAt(j) == '\t' || t.charAt(j) == '\n')) { - j++; - } + j = skipEscapeTerminator(t, j); i = j; } first = false; @@ -1952,6 +1966,16 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext if (selector.length() == 0) { throw new CSSSyntaxException("Empty selector in selector list: " + selectors); } + if (isConstantsSelector(selector)) { + // `#Constants, Other { ... }`: the build-time compiler still exports the + // constants, and applies the declarations to the other selectors as usual. + Rule constants = new Rule(); + constants.selector = selector; + constants.constants = true; + constants.declarations = parseDeclarations(body); + out.add(constants); + continue; + } Rule rule = new Rule(); rule.selector = darkContext ? toDarkSelector(selector) : selector; String base = selector.trim(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 71d31c703c6..29e25ebc0ef 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1219,6 +1219,42 @@ void testEscapedQuoteOutsideAStringOpensNoString() { assertEquals("foo\"bar", res.getTheme("Theme").get("@message")); } + @Test + void testDarkFontIgnoresALightDeriveFromAnEarlierCompile() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Child{cn1-derive:Base;}", res, "Theme"); + new CSSThemeCompiler().compile("@media (prefers-color-scheme: dark){" + + " Base{font-family:native:MainBold;font-size:2mm;} Child{font-size:20px;} }", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("$DarkChild.font"))); + } + + @Test + void testConstantsInASelectorList() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants, Other { accentColor: #ff0000; }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("@accentColor")); + } + + @Test + void testDeriveTargetIsUnquotedAndDecoded() { + for (String target : new String[]{"\"Base\"", "B\\61se"}) { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainBold;font-size:2mm;}" + + " Child{cn1-derive:" + target + ";font-size:20px;}", res, "Theme"); + assertEquals("Base", res.getTheme("Theme").get("Child.derive"), target); + assertEquals("native:MainBold", fontName((Font) res.getTheme("Theme").get("Child.font")), target); + } + } + + @Test + void testHexEscapeEndsAtAnyCssWhitespace() { + for (String ws : new String[]{"\r", "\f", "\r\n"}) { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{a\\62" + ws + "c: hello;}", res, "Theme"); + assertEquals("hello", res.getTheme("Theme").get("@abc")); + } + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> From 58543d783406a085db18f1b7f026c6a1f3948ac1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:31:38 +0300 Subject: [PATCH 37/59] CSSThemeCompiler: escaped var() names decoded, --digit custom properties, empty rgb() components rejected; GUI builder: #Constants in selector lists, corrupt unselected variants no longer fail the folder Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 33 +++++++++++++++++-- .../ui/css/CSSThemeCompilerTest.java | 22 +++++++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 26 ++++++++------- .../guibuilder/GeneratedSourceTest.java | 15 +++++++-- 4 files changed, 79 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index ad5798f2cf3..d798498d566 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -701,7 +701,7 @@ private boolean isColorLiteral(String value) { private String constantColor(String value) { value = cssColorFunction(value); if (value.regionMatches(true, 0, "rgba(", 0, 5) && value.endsWith(")")) { - String[] parts = splitOnComma(value.substring(5, value.length() - 1)); + String[] parts = colorArguments(value.substring(5, value.length() - 1), value); if (parts.length != 4) { throw new CSSSyntaxException("rgba() must have exactly 4 components: " + value); } @@ -1283,6 +1283,12 @@ private static String decodeIdentifier(String t) { StringBuilder out = new StringBuilder(n); int i = 0; boolean first = true; + if (t.startsWith("--")) { + // A custom property: anything may follow the double hyphen, `--1accent` included. + out.append("--"); + i = 2; + first = false; + } while (i < n) { char c = t.charAt(i); if (c == '\\') { @@ -1546,6 +1552,11 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso String inner = value.substring(varPos + "var(--".length(), end); int comma = topLevelComma(inner); String key = (comma < 0 ? inner : inner.substring(0, comma)).trim(); + // Decoded, as the declaration was: `var(--a\62 c)` is `--abc`. + String decodedKey = decodeIdentifier("--" + key); + if (decodedKey != null) { + key = decodedKey.substring(2); + } String fallback = comma < 0 ? null : inner.substring(comma + 1).trim(); if (resolving.contains(key)) { throw new CSSSyntaxException("Cyclic variable reference: var(--" + key + ")"); @@ -1740,7 +1751,7 @@ private String normalizeHexColor(String cssColor) { if (!value.endsWith(")")) { throw new CSSSyntaxException("Malformed rgb() color: " + cssColor); } - String[] parts = splitOnComma(value.substring(4, value.length() - 1)); + String[] parts = colorArguments(value.substring(4, value.length() - 1), cssColor); if (parts.length != 3) { throw new CSSSyntaxException("rgb() must have exactly 3 components: " + cssColor); } @@ -2253,6 +2264,24 @@ private String[] splitOnChar(String input, char delimiter) { return out.toArray(new String[out.size()]); } + /// The arguments of `rgb()` or `rgba()`. Unlike a font family list, an empty one is an error: + /// the build-time parser rejects `rgb(255,,0,0)` rather than reading it as red. + private static String[] colorArguments(String input, String color) { + ArrayList out = new ArrayList(); + int start = 0; + for (int i = 0; i <= input.length(); i++) { + if (i == input.length() || input.charAt(i) == ',') { + String part = input.substring(start, i).trim(); + if (part.length() == 0) { + throw new CSSSyntaxException("Empty component in color: " + color); + } + out.add(part); + start = i + 1; + } + } + return out.toArray(new String[out.size()]); + } + private String[] splitOnComma(String input) { ArrayList parts = new ArrayList(); int start = 0; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 29e25ebc0ef..9a7257b5832 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1255,6 +1255,28 @@ void testHexEscapeEndsAtAnyCssWhitespace() { } } + @Test + void testEscapedVarReferenceIsDecoded() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --a\\62 c: red; alias: var(--a\\62 c); }", res, "Theme"); + assertEquals("red", res.getTheme("Theme").get("@alias")); + } + + @Test + void testCustomPropertyMayStartWithADigit() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --1accent: red; }", res, "Theme"); + assertEquals("red", res.getTheme("Theme").get("@1accent")); + } + + @Test + void testEmptyColorComponentIsRejected() { + for (String color : new String[]{"rgb(255,,0)", "rgb(255,0,)", "rgba(255,,0,0)"}) { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler().compile( + "#Constants { accent: " + color + "; }", new MutableResource(), "Theme"), color); + } + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index aaf4c5c46bc..e16f504f7ba 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5377,7 +5377,16 @@ private static boolean isInConstantsBlock(String masked, int at) { } if (open.isEmpty()) return false; String selector = open.get(open.size() - 1); - return "#Constants".equalsIgnoreCase(selector) || "@constants".equalsIgnoreCase(selector); + if ("@constants".equalsIgnoreCase(selector)) return true; + // `#Constants, Other { ... }` exports its constants too, as the compiler reads it. + int start = 0; + for (int i = 0; i <= selector.length(); i++) { + if (i == selector.length() || selector.charAt(i) == ',') { + if ("#Constants".equalsIgnoreCase(selector.substring(start, i).trim())) return true; + start = i + 1; + } + } + return false; } /** True for the characters a CSS property name is made of. */ @@ -5542,16 +5551,9 @@ private static boolean isDirectory(String path) { } private static com.codename1.ui.Image bestVariant(String folder, String[] files) { - // The build decodes every PNG of the folder before it picks one, so a corrupt - // variant fails the image there even when the display would not select it. - for (String listed : files) { - String file = stripSlash(listed); - // The build skips anything that is not a file, a directory named x.png included. - if (file.regionMatches(true, file.length() - 4, ".png", 0, 4) && !isDirectory(folder + "/" + file) - && !decodes(load(folder + "/" + file))) { - return null; - } - } + // The build stores each variant's bytes with EncodedImage.create(), which does not + // decode them, so a corrupt variant fails nothing there. The canvas only has to + // show one: a selected variant that does not decode falls through to the next. for (String density : densityOrder(com.codename1.ui.Display.getInstance().getDeviceDensity())) { String match = null; for (String listed : files) { @@ -5571,7 +5573,7 @@ private static com.codename1.ui.Image bestVariant(String folder, String[] files) } if (match != null) { com.codename1.ui.Image image = load(folder + "/" + match); - if (image != null) return image; + if (decodes(image)) return image; } } return null; diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 76fbc90d38f..6a850922db3 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1794,6 +1794,11 @@ void theCanvasSaysWhichCssDeclarationsItCannotShow() throws Exception { java.util.List constants = (java.util.List) unsupported.invoke(builder, "#Constants {\n message: \"a}b\";\n includeNativeBool: true;\n}\nLabel { color: #ff0000; }\n"); assertTrue(constants.isEmpty(), "a #Constants entry is not a dropped declaration: " + constants); + + @SuppressWarnings("unchecked") + java.util.List listed = (java.util.List) unsupported.invoke(builder, + "#Constants, Other {\n accentColor: #ff0000;\n}\n"); + assertTrue(listed.isEmpty(), "#Constants in a selector list exports its constants: " + listed); } @Test @@ -1868,15 +1873,19 @@ void aDirectoryNamedLikeAPngIsSkippedLikeTheBuild() throws Exception { } @Test - void aCorruptVariantFailsTheWholeFolderLikeTheBuild() throws Exception { + void aCorruptVariantDoesNotFailTheFolderLikeTheBuild() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); java.nio.file.Files.createDirectories(folder); javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); java.nio.file.Files.write(folder.resolve("high.png"), new byte[]{1, 2, 3, 4}); - assertNull(new CodenameOneGUIBuilder.ProjectImageResource(dir.resolve("theme.css").toString()) - .getImage("play.png")); + // The build stores the variants undecoded, so the folder is an image; the canvas + // shows the variant that decodes. + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertNotNull(image); + assertEquals(3, image.getWidth()); } @Test From c70b2e17ff53fcd0de5447204281c7b99fe1e948 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:36:08 +0300 Subject: [PATCH 38/59] CSSThemeCompiler: a pressed-only derive inherits the target's normal font; GUI builder: a mixed #Constants selector list is still probed Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/ui/css/CSSThemeCompiler.java | 16 ++++++++++++---- .../codename1/ui/css/CSSThemeCompilerTest.java | 9 +++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 7 ++++--- .../guibuilder/GeneratedSourceTest.java | 9 +++++++-- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index d798498d566..3e6c3b728d5 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -942,14 +942,16 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { } // The state's own derive applies in both passes: its parent's unselected style // is what a state derived from it falls back to. - ArrayList chain = fontChain(theme, own, state, dark); + int[] normalFrom = {Integer.MAX_VALUE}; + ArrayList chain = fontChain(theme, own, state, dark, normalFrom); for (int i = 0; i < chain.size() && (family == null || size == null); i++) { String id = chain.get(i); + String lookupHere = i >= normalFrom[0] ? "" : lookup; // A dark rule is its own `$Dark` element in the build-time compiler, with no // link to the light UIID, so a dark font never takes the light one's half. String[] fallbacks = dark - ? new String[]{"$Dark" + id + "." + lookup} - : new String[]{id + "." + lookup}; + ? new String[]{"$Dark" + id + "." + lookupHere} + : new String[]{id + "." + lookupHere}; for (String fallback : fallbacks) { String[] other = fonts.get(fallback); if (other != null) { @@ -1002,7 +1004,10 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { /// The UIIDs a font in `state` inherits from, nearest first. At every hop a state that /// declares its own `cn1-derive` (`Child:selected{cn1-derive:Base}`) follows that instead of /// the UIID's. `*` is not part of it: the build-time compiler ignores universal rules. - private ArrayList fontChain(Hashtable theme, String id, String state, boolean dark) { + /// `normalFrom[0]` is set to the first index past a pressed-only derive: the build-time + /// compiler attaches `Child:pressed{cn1-derive:Base}` to `Base` itself, not `Base:pressed`, + /// so from there on the chain is read in the unselected state. + private ArrayList fontChain(Hashtable theme, String id, String state, boolean dark, int[] normalFrom) { ArrayList chain = new ArrayList(); while (id != null && !chain.contains(id)) { chain.add(id); @@ -1021,6 +1026,9 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo Object stateDerive = state.length() > 0 ? theme.get(prefix + state + "derive") : null; if (stateDerive instanceof String) { id = (String) stateDerive; + if ("press#".equals(state) && normalFrom[0] == Integer.MAX_VALUE) { + normalFrom[0] = chain.size(); + } } else { Object base = theme.get(prefix + "derive"); id = base instanceof String ? (String) base : null; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 9a7257b5832..b4e07dfaf8c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1277,6 +1277,15 @@ void testEmptyColorComponentIsRejected() { } } + @Test + void testPressedOnlyDeriveInheritsTheNormalFont() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainRegular;font-size:2mm;}" + + " Base:pressed{font-family:native:MainBold;}" + + " Child:pressed{cn1-derive:Base;font-size:20px;}", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("Child.press#font"))); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index e16f504f7ba..900f71ac02a 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5378,15 +5378,16 @@ private static boolean isInConstantsBlock(String masked, int at) { if (open.isEmpty()) return false; String selector = open.get(open.size() - 1); if ("@constants".equalsIgnoreCase(selector)) return true; - // `#Constants, Other { ... }` exports its constants too, as the compiler reads it. + // `#Constants, Other { ... }` exports its constants too, but also styles `Other`, whose + // declarations still need the probe: only a list of nothing but #Constants is skipped. int start = 0; for (int i = 0; i <= selector.length(); i++) { if (i == selector.length() || selector.charAt(i) == ',') { - if ("#Constants".equalsIgnoreCase(selector.substring(start, i).trim())) return true; + if (!"#Constants".equalsIgnoreCase(selector.substring(start, i).trim())) return false; start = i + 1; } } - return false; + return true; } /** True for the characters a CSS property name is made of. */ diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 6a850922db3..e952efb4e9a 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1797,8 +1797,13 @@ void theCanvasSaysWhichCssDeclarationsItCannotShow() throws Exception { @SuppressWarnings("unchecked") java.util.List listed = (java.util.List) unsupported.invoke(builder, - "#Constants, Other {\n accentColor: #ff0000;\n}\n"); - assertTrue(listed.isEmpty(), "#Constants in a selector list exports its constants: " + listed); + "#Constants, #Constants {\n accentColor: #ff0000;\n}\n"); + assertTrue(listed.isEmpty(), "a list of only #Constants exports its constants: " + listed); + + @SuppressWarnings("unchecked") + java.util.List mixed = (java.util.List) unsupported.invoke(builder, + "#Constants, Label {\n font-style: italic;\n}\n"); + assertEquals(1, mixed.size(), "Label still gets the probe in a mixed list: " + mixed); } @Test From 3f2dd3ea52b8afc8970d6552100c86cc7f1e3383 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:50:29 +0300 Subject: [PATCH 39/59] CSSThemeCompiler: escaped alias targets decoded for type lookup; a dark element's derive leads to the light target Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 20 ++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 3e6c3b728d5..124c6a8ceb2 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -465,6 +465,9 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { } else if (isColorLiteral(value)) { // Stored as plain hex, as the build-time compiler stores it: color constants // are read with Integer.parseInt(value, 16), which a leading '#' would break. + // Only `#hex` and `rgb()`/`rgba()` are color tokens here; a keyword such as `red` + // or `transparent` is an identifier and is stored as written, as in the build. + // testColorKeywordConstantStaysAnIdentifier holds that. value = constantColor(value); } else if (value.indexOf("var(--") > -1) { String kind = DEFERRED_VAR; @@ -673,7 +676,10 @@ private static String aliasTarget(String value) { } String inner = v.substring("var(--".length(), v.length() - 1); int comma = topLevelComma(inner); - return "@" + (comma < 0 ? inner : inner.substring(0, comma)).trim(); + String name = (comma < 0 ? inner : inner.substring(0, comma)).trim(); + // Decoded, as resolveVars looks it up: `var(--a\62 c)` is `@abc`. + String decoded = decodeIdentifier("--" + name); + return "@" + (decoded != null ? decoded.substring(2) : name); } /// UIManager.getThemeImageConstant casts an image constant to Image, and on iOS that cast is @@ -942,14 +948,14 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { } // The state's own derive applies in both passes: its parent's unselected style // is what a state derived from it falls back to. - int[] normalFrom = {Integer.MAX_VALUE}; + int[] normalFrom = {Integer.MAX_VALUE, Integer.MAX_VALUE}; ArrayList chain = fontChain(theme, own, state, dark, normalFrom); for (int i = 0; i < chain.size() && (family == null || size == null); i++) { String id = chain.get(i); String lookupHere = i >= normalFrom[0] ? "" : lookup; // A dark rule is its own `$Dark` element in the build-time compiler, with no // link to the light UIID, so a dark font never takes the light one's half. - String[] fallbacks = dark + String[] fallbacks = dark && i < normalFrom[1] ? new String[]{"$Dark" + id + "." + lookupHere} : new String[]{id + "." + lookupHere}; for (String fallback : fallbacks) { @@ -1006,11 +1012,17 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { /// the UIID's. `*` is not part of it: the build-time compiler ignores universal rules. /// `normalFrom[0]` is set to the first index past a pressed-only derive: the build-time /// compiler attaches `Child:pressed{cn1-derive:Base}` to `Base` itself, not `Base:pressed`, - /// so from there on the chain is read in the unselected state. + /// so from there on the chain is read in the unselected state. `normalFrom[1]` is the first + /// index past a dark element's derive: `$DarkChild{cn1-derive:Base}` is attached to the + /// light `Base` by name, so from there on the chain is light. private ArrayList fontChain(Hashtable theme, String id, String state, boolean dark, int[] normalFrom) { ArrayList chain = new ArrayList(); while (id != null && !chain.contains(id)) { chain.add(id); + if (dark && chain.size() > 1) { + dark = false; + normalFrom[1] = chain.size() - 1; + } // A derive this compile declared, in any state: the last one in source order is the // owning UIID's only parent in the build-time compiler, for every state. // The build-time compiler makes `$DarkChild` an element of its own with no link to diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index b4e07dfaf8c..c6e79f0fdcf 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1286,6 +1286,34 @@ void testPressedOnlyDeriveInheritsTheNormalFont() { assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("Child.press#font"))); } + @Test + void testColorKeywordConstantStaysAnIdentifier() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: red; clear: transparent; accentColor: red;" + + " --pal: red; alias: var(--pal); }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("red", theme.get("@accent")); + assertEquals("transparent", theme.get("@clear")); + assertEquals("red", theme.get("@accentColor")); + assertEquals("red", theme.get("@alias")); + } + + @Test + void testEscapedAliasKeepsTheQuotedType() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --a\\62 c: \"#f00\"; alias: var(--a\\62 c); }", res, "Theme"); + assertEquals("#f00", res.getTheme("Theme").get("@alias")); + } + + @Test + void testExplicitDarkDeriveFollowsTheLightTarget() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainRegular;font-size:2mm;}" + + " @media (prefers-color-scheme: dark){ Base{font-family:native:MainBold;}" + + " Child{cn1-derive:Base;font-size:20px;} }", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("$DarkChild.font"))); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> From 64d6855338940a3dbd1cf6715c8c815fd40ae0a6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:20:29 +0300 Subject: [PATCH 40/59] CSSThemeCompiler: legacy @constants aliases stay live until the final pass; an image constant of a url() or dimension is left out Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 57 ++++++------------- .../ui/css/CSSThemeCompilerTest.java | 17 ++++++ 2 files changed, 35 insertions(+), 39 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 124c6a8ceb2..aaa78937f8b 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -423,10 +423,6 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, declaration.snapshot = resolveAtDeclaration(theme, key, value, deferred); declaration.dropped = declaration.snapshot == null; declaration.snapshotKind = DEFERRED_IMAGE; - } else if (alias && legacy && declaration.snapshot == null - && referencesDeclaredOnly(theme, key, value, deferred)) { - declaration.snapshot = resolveVars(theme, value); - declaration.snapshotKind = DEFERRED_IMAGE; } if (declaration.dropped) { theme.remove(key); @@ -451,6 +447,13 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { // Of a chain such as `icon extra` the build-time compiler looks up the // leading unit alone. value = leadingToken(value); + if (isUriOrDimension(value)) { + // The build-time constants emitter looks an image up only for a + // string or identifier; `url(icon.png)` or `2mm` is silently left out. + theme.remove(key); + deferred.remove(key); + continue; + } } // An identifier image name is looked up decoded: `my\ icon` is `my icon`. String decoded = decodeIdentifier(value); @@ -488,16 +491,9 @@ && referencesDeclaredOnly(theme, key, value, deferred)) { deferred.remove(key); continue; } - if (legacy && declaration.snapshot == null && referencesDeclaredOnly(theme, key, value, deferred)) { - // Every name it uses is already declared: the build-time compiler resolves it - // right here, so a later redefinition of a target does not reach it. Kept on - // the declaration, because the second pass sees the final values. - String target = aliasTarget(value); - String targetKind = target == null ? null : deferred.get(target); - declaration.snapshotKind = DEFERRED_LITERAL.equals(targetKind) || DEFERRED_TEXT.equals(targetKind) - ? DEFERRED_LITERAL : DEFERRED_VAR; - declaration.snapshot = resolveVars(theme, value); - } + // A legacy `@constants` alias is not frozen here: the build-time compiler has no + // `@constants` block to match, so it keeps resolving after every rule, as it + // always has, and `:root { --base: blue; }` later still reaches `alias`. if (declaration.snapshot != null) { value = declaration.snapshot; kind = declaration.snapshotKind; @@ -614,32 +610,15 @@ private String resolveAtDeclaration(Hashtable theme, String key, String value, H } } - /// True when every `var(--x)` in `value` names a constant this compile already declared and - /// that is itself settled: not an alias still waiting for a name declared later. - private boolean referencesDeclaredOnly(Hashtable theme, String key, String value, - Hashtable deferred) { - int at = value.indexOf("var(--"); - while (at > -1) { - int end = closingParen(value, at + "var(".length()); - if (end < 0) { - return false; - } - String inner = value.substring(at + "var(--".length(), end); - int comma = topLevelComma(inner); - String target = "@" + (comma < 0 ? inner : inner.substring(0, comma)).trim(); - if (plainConstants.contains(target)) { - return false; - } - String kind = deferred.get(target); - Object current = theme.get(target); - // Its own name is never settled: that is a cycle, reported when it is resolved. - if (kind == null || target.equals(key) || (DEFERRED_VAR.equals(kind) && current instanceof String - && ((String) current).indexOf("var(--") > -1)) { - return false; - } - at = value.indexOf("var(--", end); + /// A `url()` token or a number with a unit: neither is a string or identifier token. + private static boolean isUriOrDimension(String token) { + if (token.regionMatches(true, 0, "url(", 0, 4)) { + return true; } - return true; + int i = token.startsWith("+") || token.startsWith("-") ? 1 : 0; + return i < token.length() && (token.charAt(i) >= '0' && token.charAt(i) <= '9' + || token.charAt(i) == '.' && i + 1 < token.length() + && token.charAt(i + 1) >= '0' && token.charAt(i + 1) <= '9'); } /// Whether `value` is an alias that ends, through every intermediate alias, at a quoted diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index c6e79f0fdcf..d016d94aaf8 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1314,6 +1314,23 @@ void testExplicitDarkDeriveFollowsTheLightTarget() { assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("$DarkChild.font"))); } + @Test + void testLegacyAliasFollowsALaterRootRedefinition() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("@constants { base: red; alias: var(--base); } :root { --base: blue; }", + res, "Theme"); + assertEquals("blue", res.getTheme("Theme").get("@base")); + assertEquals("blue", res.getTheme("Theme").get("@alias")); + } + + @Test + void testImageConstantOfAUrlOrDimensionIsLeftOut() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { navImage: url(icon.png); sizeImage: 2mm; }", res, "Theme"); + assertFalse(res.getTheme("Theme").containsKey("@navImage")); + assertFalse(res.getTheme("Theme").containsKey("@sizeImage")); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> From 259eb5181b38bfa01c118b49494df3502987d668 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:59:18 +0300 Subject: [PATCH 41/59] CSSThemeCompiler: quoted var() fallbacks stay text, escaped custom property names decoded everywhere, a redefined exported constant retypes later aliases Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 38 +++++++++++++++++-- .../ui/css/CSSThemeCompilerTest.java | 23 +++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index aaa78937f8b..8749a514834 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -163,7 +163,7 @@ public void compile(String css, MutableResource resources, String themeName) { } declaredThisCompile.add("@" + (n.startsWith("--") ? n.substring(2) : n)); } else if (declaration.name.startsWith("--")) { - declaredThisCompile.add("@" + declaration.name.substring(2)); + declaredThisCompile.add("@" + customPropertyName(declaration.name)); } } } @@ -477,7 +477,10 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, if (!legacy && declaration.snapshot == null && !declaration.dropped) { String target = aliasTarget(value); String targetKind = target == null ? null : deferred.get(target); + // Text when the target is, through aliases or a redefinition of an exported + // constant, or when an undefined target's fallback is a quoted string. declaration.snapshotKind = DEFERRED_LITERAL.equals(targetKind) || DEFERRED_TEXT.equals(targetKind) + || aliasesQuotedText(value) || quotedFallbackApplies(value) ? DEFERRED_LITERAL : DEFERRED_VAR; declaration.snapshot = resolveAtDeclaration(theme, key, value, deferred); declaration.dropped = declaration.snapshot == null; @@ -621,6 +624,29 @@ private static boolean isUriOrDimension(String token) { && token.charAt(i + 1) >= '0' && token.charAt(i + 1) <= '9'); } + /// `--a\62 c` as the name `abc`, decoded as the build-time parser reads it. + private static String customPropertyName(String property) { + String decoded = decodeIdentifier(property); + return (decoded != null ? decoded : property).substring(2); + } + + /// Whether `value` is `var(--x, "text")` with `x` not declared so far: the fallback is then + /// the value, and it keeps its string type. + private boolean quotedFallbackApplies(String value) { + String v = value.trim(); + String target = aliasTarget(v); + if (target == null || cssVars.get(target) != null) { + return false; + } + String inner = v.substring("var(".length(), v.length() - 1); + int comma = topLevelComma(inner); + if (comma < 0) { + return false; + } + String fallback = inner.substring(comma + 1).trim(); + return !unquote(fallback).equals(fallback); + } + /// Whether `value` is an alias that ends, through every intermediate alias, at a quoted /// string: `--a: var(--name)` keeps the string `--name` was, as the build-time compiler keeps /// the lexical unit, so `navImage: var(--a)` names the image `my icon`, not `my`. @@ -841,12 +867,18 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str if (!property.startsWith("--")) { return false; } - String key = "@" + property.substring(2); + String key = "@" + customPropertyName(property); String raw = value.trim(); String unquoted = unquote(raw); cssVars.put(key, raw); if (exportedConstants.contains(key)) { - // Only the variable changes; the constant #Constants exported keeps its value. + // Only the variable changes; the constant #Constants exported keeps its value. Its + // token type changes with it, so a later alias of a now quoted `--x` is text. + if (!raw.equals(unquoted)) { + literalTokens.put(key, raw); + } else { + literalTokens.remove(key); + } return true; } plainConstants.remove(key); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index d016d94aaf8..ee1c26c0472 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1331,6 +1331,29 @@ void testImageConstantOfAUrlOrDimensionIsLeftOut() { assertFalse(res.getTheme("Theme").containsKey("@sizeImage")); } + @Test + void testQuotedFallbackStaysText() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: var(--missing, \"#f00\"); }", res, "Theme"); + assertEquals("#f00", res.getTheme("Theme").get("@accent")); + } + + @Test + void testEscapedRootCustomPropertyIsDecoded() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile(":root { --a\\62 c: #f00; } Label { color: var(--a\\62 c); }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); + } + + @Test + void testRedefinedExportedConstantRetypesItsAliases() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--x:#0f0} :root{--x:\"#f00\"} #Constants{alias:var(--x)}", + res, "Theme"); + assertEquals("00ff00", res.getTheme("Theme").get("@x")); + assertEquals("#f00", res.getTheme("Theme").get("@alias")); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> From 91cc7f3933c646354359eca133c9adec5f461e05 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:46:06 +0300 Subject: [PATCH 42/59] CSSThemeCompiler: universal rules set no style property; a functional color constant takes its leading token Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 13 ++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 8749a514834..4c33c14d3de 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -465,7 +465,12 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, } else if (!raw.equals(value)) { deferred.put(key, DEFERRED_LITERAL); literalTokens.put(key, raw); - } else if (isColorLiteral(value)) { + } else if (isColorLiteral(value) || !legacy && isColorLiteral(leadingToken(value))) { + if (!legacy) { + // `rgb(255, 0, 0) extra` is a chain whose leading unit is the color, and the + // build-time constants emitter writes only that unit. + value = leadingToken(value); + } // Stored as plain hex, as the build-time compiler stores it: color constants // are read with Integer.parseInt(value, 16), which a leading '#' would break. // Only `#hex` and `rgb()`/`rgba()` are color tokens here; a keyword such as `red` @@ -835,8 +840,10 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha // UIID from every state but pressed. ownerDerives.put(uiid, value.trim()); } - if (universal && ("font-family".equals(property) || "font-size".equals(property))) { - // The build-time compiler ignores universal rules, so `*` sets no font there. + if (universal) { + // The build-time compiler ignores universal rules entirely (CSSTheme.apply() + // returns before any element), so `*` sets no style property there; its custom + // properties, handled above, still count. if ("font-size".equals(property)) { parseFontSize(value); } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index ee1c26c0472..c9afbfe9878 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1354,6 +1354,25 @@ void testRedefinedExportedConstantRetypesItsAliases() { assertEquals("#f00", res.getTheme("Theme").get("@alias")); } + @Test + void testUniversalRuleSetsNoStyleProperty() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("* { color: red; padding: 1px; --tone: #00f; } Label { color: var(--tone); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + for (Object key : theme.keySet()) { + assertFalse(String.valueOf(key).startsWith("Component."), String.valueOf(key)); + } + assertEquals("0000ff", theme.get("Label.fgColor")); + } + + @Test + void testFunctionalColorConstantTakesItsLeadingToken() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: rgb(255, 0, 0) extra; }", res, "Theme"); + assertEquals(0xff0000, Integer.parseInt((String) res.getTheme("Theme").get("@accent"), 16)); + } + @Test void testExponentFontSizeIsRejected() { assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> From 699f677a69010f931dabba3d2b5ce34fffb0e546 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:22:59 +0300 Subject: [PATCH 43/59] CSSThemeCompiler: a pressed derive wins over the owner's, a size-only rule keeps a system font's style, an unquoted url() keeps its semicolon Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 35 +++++++++++++++---- .../TestCodenameOneImplementation.java | 7 ++++ .../ui/css/CSSThemeCompilerTest.java | 28 +++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 4c33c14d3de..dcaedfce930 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -993,8 +993,11 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { } } } - // A system font keeps only what it can say: monospace, and its system size. + // A system font keeps only what it can say: monospace, its system size and its style, + // so a size-only rule over a bold system font stays bold. + int style = Font.STYLE_PLAIN; if (inherited != null && !inherited.isTTFNativeFont()) { + style = inherited.getStyle(); if (family == null && inherited.getFace() == Font.FACE_MONOSPACE) { family = "monospace"; } @@ -1019,7 +1022,7 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { parsed.unit = Style.UNIT_TYPE_PIXELS; parsed.value = inherited.getPixelSize(); } - font = createFont(family, parsed); + font = createFont(family, parsed, style); } theme.put(keyPrefix + Style.FONT, font); } @@ -1045,6 +1048,17 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo // owning UIID's only parent in the build-time compiler, for every state. // The build-time compiler makes `$DarkChild` an element of its own with no link to // the light `Child`'s derive, so only a dark derive extends the dark chain. + // Except a pressed state's own derive: `Child:pressed{cn1-derive:Base1}` is attached + // to `Base1` directly, whatever the owner's normal derive says. + String prefix = dark ? "$Dark" + id + "." : id + "."; + if ("press#".equals(state) && normalFrom[0] == Integer.MAX_VALUE) { + Object pressDerive = theme.get(prefix + "press#derive"); + if (pressDerive instanceof String) { + id = (String) pressDerive; + normalFrom[0] = chain.size(); + continue; + } + } String owner = ownerDerives.get(dark ? "$Dark" + id : id); if (owner != null) { id = owner; @@ -1052,7 +1066,6 @@ private ArrayList fontChain(Hashtable theme, String id, String state, bo } // Otherwise a derive a previous compile into the same theme stored. For a dark // element only its own `$Dark` keys: a retained light derive is no link either. - String prefix = dark ? "$Dark" + id + "." : id + "."; Object stateDerive = state.length() > 0 ? theme.get(prefix + state + "derive") : null; if (stateDerive instanceof String) { id = (String) stateDerive; @@ -1092,7 +1105,7 @@ private String anyStateDerive(Hashtable theme, String id, boolean dark) { return null; } - private Font createFont(String familyList, FontSize size) { + private Font createFont(String familyList, FontSize size, int style) { String ttfName = "native:MainRegular"; boolean monospace = false; if (familyList != null) { @@ -1126,9 +1139,9 @@ private Font createFont(String familyList, FontSize size) { } int systemSize = size == null ? Font.SIZE_MEDIUM : size.systemSize; if (monospace) { - return Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_PLAIN, systemSize); + return Font.createSystemFont(Font.FACE_MONOSPACE, style, systemSize); } - Font fallback = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, systemSize); + Font fallback = Font.createSystemFont(Font.FACE_SYSTEM, style, systemSize); float pixels = size == null || size.unit < 0 ? fallback.getHeight() : fontSizeInPixels(size); Font ttf; try { @@ -1137,7 +1150,7 @@ private Font createFont(String familyList, FontSize size) { ttf = null; } // The same fallback a resource file takes for a truetype font the platform cannot load. - return ttf == null ? fallback : ttf.derive(pixels, Font.STYLE_PLAIN); + return ttf == null ? fallback : ttf.derive(pixels, style); } private static boolean isGenericFamily(String family) { @@ -2279,6 +2292,14 @@ private String[] splitDeclarations(String input) { i++; } else if (c == '"' || c == '\'') { quote = c; + } else if ((c == 'u' || c == 'U') && input.regionMatches(true, i, "url(", 0, 4) + && (i == 0 || !isNameChar(input.charAt(i - 1)))) { + // An unquoted url() is one token, so `url(data:image/png;base64,AAAA)` keeps + // its semicolon, as the build-time parser reads it. + int close = input.indexOf(')', i + 4); + if (close > 0) { + i = close; + } } else if (c == ';') { out.add(input.substring(start, i)); start = i + 1; diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index 6b21b7b5f7d..a0bbd04ad8d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -3235,10 +3235,16 @@ public Object createTestFont(int charWidth, int height) { public Object createFont(int face, int style, int size) { TestFont font = new TestFont(defaultFont.charWidth, defaultFont.height); font.face = face; + font.style = style; font.size = size; return font; } + @Override + public int getStyle(Object nativeFont) { + return nativeFont instanceof TestFont ? ((TestFont) nativeFont).style : 0; + } + @Override public int getFace(Object nativeFont) { return nativeFont instanceof TestFont ? ((TestFont) nativeFont).face : 0; @@ -5642,6 +5648,7 @@ public static final class TestFont { final int height; /// What a system font was created with; a real port reports these from its native font. int face; + int style; int size = com.codename1.ui.Font.SIZE_MEDIUM; TestFont(int charWidth, int height) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index c9afbfe9878..02adb8931d3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1286,6 +1286,34 @@ void testPressedOnlyDeriveInheritsTheNormalFont() { assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("Child.press#font"))); } + @Test + void testPressedDeriveWinsOverTheOwnerDerive() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base1{font-family:native:MainBold;font-size:2mm;}" + + " Base2{font-family:native:MainLight;font-size:2mm;}" + + " Child{cn1-derive:Base2;} Child:pressed{cn1-derive:Base1;font-size:20px;}", res, "Theme"); + assertEquals("native:MainBold", fontName((Font) res.getTheme("Theme").get("Child.press#font"))); + } + + @Test + void testSizeOnlyRuleKeepsTheInheritedSystemFontStyle() { + MutableResource res = new MutableResource(); + res.setTheme("Theme", new Hashtable()); + res.getTheme("Theme").put("Label.font", + Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_BOLD, Font.SIZE_MEDIUM)); + new CSSThemeCompiler().compile("Label{font-size:large;}", res, "Theme"); + Font font = (Font) res.getTheme("Theme").get("Label.font"); + assertEquals(Font.STYLE_BOLD, font.getStyle() & Font.STYLE_BOLD); + } + + @Test + void testUnquotedUrlKeepsItsSemicolon() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { navImage: url(data:image/png;base64,AAAA); after: \"x\"; }", + res, "Theme"); + assertEquals("x", res.getTheme("Theme").get("@after")); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); From 82d95d82d92c73952f3fde5e0a91d0791c92c668 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:23:33 +0300 Subject: [PATCH 44/59] CSSThemeCompiler: only CSS whitespace ends a hex escape inside a string Co-Authored-By: Claude Opus 5.5 --- .../src/com/codename1/ui/css/CSSThemeCompiler.java | 7 ++++--- .../java/com/codename1/ui/css/CSSThemeCompilerTest.java | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index dcaedfce930..3066b555739 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -788,10 +788,11 @@ private static String unquote(String value) { out.append(c); continue; } - // CSS: one to six hex digits name a code point, and one whitespace after them ends it. + // CSS: one to six hex digits name a code point, and one CSS whitespace after them + // (space, tab, LF, FF, CR or CR LF) ends it; an em space (U+2003) after `\\41` is kept. i += digits - 1; - if (i + 1 < end && Character.isWhitespace(v.charAt(i + 1))) { - i++; + if (i + 1 < end) { + i = Math.min(end, skipEscapeTerminator(v, i + 1)) - 1; } if (code == 0 || code > 0x10FFFF || code >= 0xD800 && code <= 0xDFFF) { code = 0xFFFD; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 02adb8931d3..f2ae217fc2d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1314,6 +1314,14 @@ void testUnquotedUrlKeepsItsSemicolon() { assertEquals("x", res.getTheme("Theme").get("@after")); } + @Test + void testStringEscapeKeepsANonCssWhitespaceAfterIt() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: \"\\41\u2003B\"; plain: \"\\41 B\"; }", res, "Theme"); + assertEquals("A\u2003B", res.getTheme("Theme").get("@message")); + assertEquals("AB", res.getTheme("Theme").get("@plain")); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); From daee10eec50dedfe02846a1c32e80c6bab211eba Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:37:32 +0300 Subject: [PATCH 45/59] CSSThemeCompiler: a universal derive is ignored; a quoted image variable stays text for its aliases Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 22 ++++++++++++------- .../ui/css/CSSThemeCompilerTest.java | 21 ++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 3066b555739..f70e0ea8129 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -462,6 +462,11 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, } } deferred.put(key, DEFERRED_IMAGE); + if (!raw.equals(value)) { + // A quoted image name stays a string token, so an alias of it names the + // whole `"my icon"`, not the leading `my` of a chain. + literalTokens.put(key, raw); + } } else if (!raw.equals(value)) { deferred.put(key, DEFERRED_LITERAL); literalTokens.put(key, raw); @@ -836,20 +841,20 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha // `B\61se` both name the UIID `Base`. value = derivedUiid(value.trim()); } - if ("cn1-derive".equals(property) && !"press#".equals(statePrefix)) { - // The build-time compiler's Element.setParent() promotes a derive to the owning - // UIID from every state but pressed. - ownerDerives.put(uiid, value.trim()); - } if (universal) { // The build-time compiler ignores universal rules entirely (CSSTheme.apply() - // returns before any element), so `*` sets no style property there; its custom - // properties, handled above, still count. + // returns before any element), so `*` sets no style property and no derive + // there; its custom properties, handled above, still count. if ("font-size".equals(property)) { parseFontSize(value); } continue; } + if ("cn1-derive".equals(property) && !"press#".equals(statePrefix)) { + // The build-time compiler's Element.setParent() promotes a derive to the owning + // UIID from every state but pressed. + ownerDerives.put(uiid, value.trim()); + } if (applyFontProperty(fonts, uiid + "." + statePrefix, property, value)) { continue; } @@ -1652,7 +1657,8 @@ private String resolveVars(Hashtable theme, String value, ArrayList reso // is one a previous compile into the same theme left: it is already resolved, and // any var() still in it was quoted text there. String kind = deferredConstants.get("@" + key); - if (kind != null && !DEFERRED_LITERAL.equals(kind)) { + // A quoted token is text whatever its kind (a quoted image name is one). + if (kind != null && !DEFERRED_LITERAL.equals(kind) && token == null) { resolving.add(key); replaceValue = resolveVars(theme, replaceValue, resolving, tokens); resolving.remove(resolving.size() - 1); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index f2ae217fc2d..4d365d2d2ec 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1322,6 +1322,27 @@ void testStringEscapeKeepsANonCssWhitespaceAfterIt() { assertEquals("AB", res.getTheme("Theme").get("@plain")); } + @Test + void testUniversalDeriveIsIgnored() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainBold;font-size:2mm;}" + + " *{cn1-derive:Base;} Component{font-size:20px;}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("Component.derive")); + assertEquals("native:MainRegular", fontName((Font) theme.get("Component.font"))); + } + + @Test + void testAliasOfQuotedImageVariableKeepsTheWholeName() { + MutableResource res = new MutableResource(); + res.setImage("my icon", Image.createImage(5, 5)); + new CSSThemeCompiler().compile("#Constants { --baseImage: \"my icon\"; aliasImage: var(--baseImage); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals(5, ((Image) theme.get("@baseImage")).getWidth()); + assertEquals(5, ((Image) theme.get("@aliasImage")).getWidth()); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); From 07e0d66a90c451ec33c030eb37d109569cc56c6c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:37:06 +0300 Subject: [PATCH 46/59] CSSThemeCompiler: quoted image fallbacks keep the whole name, image-named chains typed by their leading token, universal font-size not validated, escaped #Constants selector recognised Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 27 +++++++++++----- .../ui/css/CSSThemeCompilerTest.java | 32 +++++++++++++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 5 +++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index f70e0ea8129..5d6c95c047d 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -414,7 +414,10 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, deferred.put(key, DEFERRED_PLAIN); literalTokens.remove(key); boolean alias = raw.equals(value) && value.indexOf("var(--") > -1; - if (name.endsWith("Image") && (!raw.equals(value) || (!isColorLiteral(value) && !isCssNumber(value)))) { + // Of a chain such as `#f00 extra` the build-time compiler types the leading unit, so + // that is what decides whether an `...Image` name is looked up as an image. + String typed = !legacy && raw.equals(value) && !alias ? leadingToken(value) : value; + if (name.endsWith("Image") && (!raw.equals(value) || (!isColorLiteral(typed) && !isCssNumber(typed)))) { // The build-time compiler looks an image up only for a string or identifier // token; a color or number named `...Image` is stored as a color or number. An alias is // resolved where it is declared, as below, so a later redefinition of its @@ -432,8 +435,9 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, boolean fromText = false; if (alias && declaration.snapshot != null) { value = declaration.snapshot; - // An alias of a quoted constant names the image by text, as written. - fromText = aliasesQuotedText(raw); + // An alias of a quoted constant names the image by text, as written, and so + // does a quoted fallback: `var(--missing, "my icon")` is `my icon`. + fromText = aliasesQuotedText(raw) || quotedFallbackApplies(raw); } if (alias && declaration.snapshot != null && !fromText && (isColorLiteral(value) || isCssNumber(value))) { @@ -746,7 +750,16 @@ private static String derivedUiid(String value) { } private static boolean isConstantsSelector(String selector) { - return "@constants".equalsIgnoreCase(selector) || "#Constants".equalsIgnoreCase(selector); + if ("@constants".equalsIgnoreCase(selector)) { + return true; + } + if (!selector.startsWith("#")) { + return false; + } + // The build-time parser decodes the ID before its special-selector check, so + // `#\\43 onstants` is `#Constants` too. + String decoded = decodeIdentifier(selector.substring(1)); + return "Constants".equalsIgnoreCase(decoded != null ? decoded : selector.substring(1)); } /// Strips one pair of matching quotes, decoding backslash escapes inside them. Anything @@ -844,10 +857,8 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha if (universal) { // The build-time compiler ignores universal rules entirely (CSSTheme.apply() // returns before any element), so `*` sets no style property and no derive - // there; its custom properties, handled above, still count. - if ("font-size".equals(property)) { - parseFontSize(value); - } + // there; its custom properties, handled above, still count. Nothing is validated + // either: `* { font-size: smaller; }` builds. continue; } if ("cn1-derive".equals(property) && !"press#".equals(statePrefix)) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 4d365d2d2ec..f21185944e7 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1343,6 +1343,38 @@ void testAliasOfQuotedImageVariableKeepsTheWholeName() { assertEquals(5, ((Image) theme.get("@aliasImage")).getWidth()); } + @Test + void testQuotedFallbackNamesTheWholeImage() { + MutableResource res = new MutableResource(); + res.setImage("my icon", Image.createImage(5, 5)); + new CSSThemeCompiler().compile("#Constants { navImage: var(--missing, \"my icon\"); }", res, "Theme"); + assertEquals(5, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testImageNamedChainIsTypedByItsLeadingToken() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { placeholderImage: #f00 extra; countImage: 2 extra; }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals(0xff0000, Integer.parseInt((String) theme.get("@placeholderImage"), 16)); + assertEquals("2", String.valueOf(theme.get("@countImage"))); + } + + @Test + void testUniversalFontSizeIsNotValidated() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("* { font-size: smaller; } Label { color: red; }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); + } + + @Test + void testEscapedConstantsSelector() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#\\43 onstants { accent: blue; }", res, "Theme"); + assertEquals("blue", res.getTheme("Theme").get("@accent")); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 900f71ac02a..02093ef325a 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5513,6 +5513,11 @@ public com.codename1.ui.Image getImage(String id) { // multi-image folder, so the canvas must not accept what the build would reject. It // looks in the res folder beside the stylesheet, then the one a level up (a Maven // project's src/main/res, an Ant project's own res). + // The theme.res the build writes is deliberately not consulted. The CSS CLI loads it + // only for an incremental rebuild (its selector cache present and no asset newer); + // a clean build -- CI, `mvn clean`, a fresh checkout -- starts without it, so an + // image found only there is one the next clean build rejects. Matching that build + // means rejecting it here too; the image belongs in a res// folder. com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); int slash = cssDir.lastIndexOf('/'); String[] roots = slash < 0 ? new String[]{cssDir + "/res"} From 617245d725b3b0e0a59bd24af4a777dae498e378 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:06:31 +0300 Subject: [PATCH 47/59] CSSThemeCompiler: escaped commas stay in a family name, an unsupported var() fallback is rejected like a direct one, hex escapes take ASCII digits only Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 52 ++++++++++++++++--- .../ui/css/CSSThemeCompilerTest.java | 22 ++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 5d6c95c047d..45dbb3e83f2 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -554,7 +554,7 @@ private static String leadingToken(String value) { // An escaped character, `my\ icon`'s space among them, is part of the token, and // so is the one whitespace ending a hex escape (`\41 x` is `Ax`). int j = i + 1; - while (j < value.length() && j - i <= 6 && Character.digit(value.charAt(j), 16) >= 0) { + while (j < value.length() && j - i <= 6 && hexDigit(value.charAt(j)) >= 0) { j++; } if (j > i + 1 && skipEscapeTerminator(value, j) > j) { @@ -618,7 +618,14 @@ private String resolveAtDeclaration(Hashtable theme, String key, String value, H } return isCssNumber(first) ? buildConstant(key.substring(1), first, first) : first; } - return typedAlias(key, value, resolved); + String typed = typedAlias(key, value, resolved); + if (typed.equals(text) && !isCssNumber(text) && !isColorLiteral(text) && isUriOrDimension(text)) { + // `var(--missing, 50%)` is the percentage token itself, which the build-time + // emitter leaves out; it is rejected here exactly as `progress: 50%` written + // directly is, so the preview never shows a constant the application lacks. + buildConstant(key.substring(1), text, text); + } + return typed; } finally { declaredOnly = null; if (own != null) { @@ -798,8 +805,8 @@ private static String unquote(String value) { } int digits = 0; int code = 0; - while (digits < 6 && i + digits < end && Character.digit(v.charAt(i + digits), 16) > -1) { - code = code * 16 + Character.digit(v.charAt(i + digits), 16); + while (digits < 6 && i + digits < end && hexDigit(v.charAt(i + digits)) > -1) { + code = code * 16 + hexDigit(v.charAt(i + digits)); digits++; } if (digits == 0) { @@ -1365,8 +1372,8 @@ private static String decodeIdentifier(String t) { } int j = i + 1; int code = 0; - while (j < n && j - i <= 6 && Character.digit(t.charAt(j), 16) >= 0) { - code = code * 16 + Character.digit(t.charAt(j), 16); + while (j < n && j - i <= 6 && hexDigit(t.charAt(j)) >= 0) { + code = code * 16 + hexDigit(t.charAt(j)); j++; } if (j == i + 1) { @@ -2359,11 +2366,42 @@ private static String[] colorArguments(String input, String color) { return out.toArray(new String[out.size()]); } + /// The value of an ASCII hex digit, or -1. CSS escapes take only `0-9a-fA-F`; + /// `Character.digit` would also accept fullwidth and other Unicode digits. + private static int hexDigit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + } + + /// Splits a family list on its commas, except an escaped one (`foo\,monospace` is one + /// identifier) or one inside a quoted name. private String[] splitOnComma(String input) { ArrayList parts = new ArrayList(); int start = 0; + char quote = 0; for (int i = 0; i < input.length(); i++) { - if (input.charAt(i) == ',') { + char c = input.charAt(i); + if (c == '\\') { + i++; + continue; + } + if (quote != 0) { + if (c == quote) { + quote = 0; + } + continue; + } + if (c == '"' || c == '\'') { + quote = c; + } else if (c == ',') { String token = input.substring(start, i).trim(); if (token.length() > 0) { parts.add(token); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index f21185944e7..9fe1dea4603 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1375,6 +1375,28 @@ void testEscapedConstantsSelector() { assertEquals("blue", res.getTheme("Theme").get("@accent")); } + @Test + void testEscapedCommaStaysInsideTheFamilyName() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:foo\\,monospace, native\\:MainBold;font-size:2mm;}", + res, "Theme"); + assertEquals("native:MainBold", fontName((Font) res.getTheme("Theme").get("Label.font"))); + } + + @Test + void testPercentageFallbackIsRejectedLikeADirectOne() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants { progress: var(--missing, 50%); }", + new MutableResource(), "Theme")); + } + + @Test + void testFullwidthLetterIsNotAHexEscape() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: \"\\\uff21\"; }", res, "Theme"); + assertEquals("\uff21", res.getTheme("Theme").get("@message")); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); From 1e4c9c39977ae3d10b9de2d8e81c83bd9d518177 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:51:09 +0300 Subject: [PATCH 48/59] CSSThemeCompiler: nested quoted fallbacks stay text, an image constant's url() fallback is left out, an undefined font-size variable is passed over; GUI builder keeps the last medium variant like the build Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 16 ++++++++++++- .../ui/css/CSSThemeCompilerTest.java | 24 +++++++++++++++++++ .../guibuilder/CodenameOneGUIBuilder.java | 11 +++++---- .../guibuilder/GeneratedSourceTest.java | 17 +++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 45dbb3e83f2..df9b4357561 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -623,6 +623,11 @@ private String resolveAtDeclaration(Hashtable theme, String key, String value, H // `var(--missing, 50%)` is the percentage token itself, which the build-time // emitter leaves out; it is rejected here exactly as `progress: 50%` written // directly is, so the preview never shows a constant the application lacks. + // An `...Image` constant is the exception: a direct `navImage: url(icon.png)` is + // left out silently, so its fallback is too. + if (key.endsWith("Image")) { + return null; + } buildConstant(key.substring(1), text, text); } return typed; @@ -665,7 +670,10 @@ private boolean quotedFallbackApplies(String value) { return false; } String fallback = inner.substring(comma + 1).trim(); - return !unquote(fallback).equals(fallback); + // The fallback that applies keeps its lexical type through any nesting: + // `var(--missing, var(--text))` is text when `--text` is. + return !unquote(fallback).equals(fallback) || aliasesQuotedText(fallback) + || quotedFallbackApplies(fallback); } /// Whether `value` is an alias that ends, through every intermediate alias, at a quoted @@ -933,6 +941,12 @@ private boolean applyFontProperty(Hashtable fonts, String keyP slot = 0; } else if ("font-size".equals(property)) { slot = 1; + if (value.trim().length() == 0) { + // `var(--optional-size)` left undefined: the build-time compiler keeps the + // unresolved function and its font falls back to the default size, so the + // declaration is passed over rather than rejecting the stylesheet. + return true; + } // Validated here so the error is reported while the rule is being read. parseFontSize(value); } else { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 9fe1dea4603..6ecdf0e7a9e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1397,6 +1397,30 @@ void testFullwidthLetterIsNotAHexEscape() { assertEquals("\uff21", res.getTheme("Theme").get("@message")); } + @Test + void testNestedQuotedFallbackStaysText() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --text: \"#f00\"; accent: var(--missing, var(--text)); }", + res, "Theme"); + assertEquals("#f00", res.getTheme("Theme").get("@accent")); + } + + @Test + void testUrlFallbackOfAnImageConstantIsLeftOut() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { navImage: var(--missing, url(icon.png)); other: x; }", + res, "Theme"); + org.junit.jupiter.api.Assertions.assertNull(res.getTheme("Theme").get("@navImage")); + assertEquals("x", res.getTheme("Theme").get("@other")); + } + + @Test + void testUndefinedFontSizeVariableIsPassedOver() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Label { font-size: var(--optional-size); color: red; }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index 02093ef325a..52c04cea64d 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5566,14 +5566,15 @@ private static com.codename1.ui.Image bestVariant(String folder, String[] files) String file = stripSlash(listed); if (!file.regionMatches(true, file.length() - 4, ".png", 0, 4) || isDirectory(folder + "/" + file)) continue; - if (density.equals(file)) { + // Several files classified medium (medium.png and any unrecognised name): + // EditableResources.MultiImage.getBest() replaces its pick on every equal + // DPI, so the build keeps the LAST one in its File.listFiles() order, and + // the canvas keeps the last in its own listing. + if (density.equals(file) && !"medium.png".equals(density)) { match = file; break; } - // Several files classified medium: the build keeps whichever File.listFiles() - // returns first, an order the platform does not define, so no preview can - // promise to match it. The named medium.png is the stable choice. - if ("medium.png".equals(density) && match == null && !isDensityName(file)) { + if ("medium.png".equals(density) && (density.equals(file) || !isDensityName(file))) { match = file; } } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index e952efb4e9a..692de1b1c64 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1865,6 +1865,23 @@ void anUpperCaseDensityNameIsReadAsMediumLikeTheBuild() throws Exception { assertEquals(expected, image.getWidth(), "the build's case-sensitive switch reads high.PNG as medium"); } + @Test + void severalMediumVariantsKeepTheLastListedLikeTheBuild() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(5, 5, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("custom.png").toFile()); + // EditableResources.MultiImage.getBest() replaces its pick on each equal DPI. + String[] listed = folder.toFile().list(); + int expected = "custom.png".equals(listed[listed.length - 1]) ? 5 : 3; + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertEquals(expected, image.getWidth()); + } + @Test void aDirectoryNamedLikeAPngIsSkippedLikeTheBuild() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); From 269adfb873219f886a702b89380f40c5dd1d4ad8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:20:52 +0300 Subject: [PATCH 49/59] CSSThemeCompiler: a UIID's font reaches its selected, pressed and disabled states, as in the build Co-Authored-By: Claude Opus 5.5 --- .../com/codename1/ui/css/CSSThemeCompiler.java | 15 +++++++++++++++ .../codename1/ui/css/CSSThemeCompilerTest.java | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index df9b4357561..a7afae4a2da 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -979,6 +979,21 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { if (!Display.isInitialized()) { return; } + // The build-time compiler writes a UIID's font into every state, and UIManager does not + // fall back from `sel#font` to `font`: `Label { font-family: monospace; }` alone would + // be monospace unselected and the system face once selected. A state with no font of + // its own gets an empty spec, which the unselected pass below fills from `Label`. + String[] states = {"sel#", "press#", "dis#"}; + for (String keyPrefix : new ArrayList(fonts.keySet())) { + if (!keyPrefix.endsWith(".") || keyPrefix.indexOf('.') != keyPrefix.length() - 1) { + continue; + } + for (String state : states) { + if (!fonts.containsKey(keyPrefix + state)) { + fonts.put(keyPrefix + state, new String[2]); + } + } + } for (Map.Entry entry : fonts.entrySet()) { String keyPrefix = entry.getKey(); String[] spec = entry.getValue(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 6ecdf0e7a9e..afbf5800bd0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1421,6 +1421,21 @@ void testUndefinedFontSizeVariableIsPassedOver() { assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); } + @Test + void testFontReachesEveryState() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("ReviewLabel { font-family: monospace; font-size: large; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + for (String state : new String[]{"", "sel#", "press#", "dis#"}) { + Font font = (Font) theme.get("ReviewLabel." + state + "font"); + org.junit.jupiter.api.Assertions.assertNotNull(font, state); + assertEquals(Font.FACE_MONOSPACE, font.getFace(), state); + } + com.codename1.ui.plaf.UIManager.getInstance().setThemeProps(theme); + assertEquals(Font.FACE_MONOSPACE, com.codename1.ui.plaf.UIManager.getInstance() + .getComponentSelectedStyle("ReviewLabel").getFont().getFace()); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); From acfbbe9321d5b19eb6b55d4d76a312e8531fe968 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:26:49 +0300 Subject: [PATCH 50/59] CSSThemeCompiler: an alias of a quoted constant keeps its text, not read again as a number Co-Authored-By: Claude Opus 5.5 --- .../src/com/codename1/ui/css/CSSThemeCompiler.java | 8 +++++++- .../com/codename1/ui/css/CSSThemeCompilerTest.java | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index a7afae4a2da..848766a32d2 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -578,9 +578,15 @@ private static String leadingToken(String value) { /// A constant that is one var() takes its value's token type, a fallback's too: the /// build-time compiler stores the string `"hello"` as hello and the number 1.50 as 1.5, as /// it would written directly. - private static String typedAlias(String key, String original, String resolved) { + private String typedAlias(String key, String original, String resolved) { String sole = original.trim(); if (sole.startsWith("var(") && closingParen(sole, "var(".length()) == sole.length() - 1) { + if (aliasesQuotedText(sole)) { + // The target is a string token whose text resolveVars() already decoded: + // `--code: "001"` is copied as 001, not read again as the number 1, and a + // quote character inside it is part of the text. + return resolved; + } String text = resolved.trim(); String unquoted = unquote(text); if (!unquoted.equals(text)) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index afbf5800bd0..1943f96dec8 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1436,6 +1436,18 @@ void testFontReachesEveryState() { .getComponentSelectedStyle("ReviewLabel").getFont().getFace()); } + @Test + void testAliasOfQuotedNumberStaysText() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --code: \"001\"; copy: var(--code);" + + " --q: '\"x\"'; quoteCopy: var(--q); }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("001", theme.get("@code")); + assertEquals("001", theme.get("@copy")); + assertEquals("\"x\"", theme.get("@q")); + assertEquals("\"x\"", theme.get("@quoteCopy")); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); From 121c849694f7d2efdfb2cced17d13fe7389f3ac1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:16:39 +0300 Subject: [PATCH 51/59] Baselines: take #6059's repair of #5976's merged overlays (rows master already moved are dropped or re-anchored) Co-Authored-By: Claude Opus 5.5 --- scripts/flutter-bench/baseline/pr/5976.json | 2 +- vm/selfhost/perf-baseline/pr/5976.json | 70 --------------------- 2 files changed, 1 insertion(+), 71 deletions(-) diff --git a/scripts/flutter-bench/baseline/pr/5976.json b/scripts/flutter-bench/baseline/pr/5976.json index 0cd73c825d2..5b507ff550f 100644 --- a/scripts/flutter-bench/baseline/pr/5976.json +++ b/scripts/flutter-bench/baseline/pr/5976.json @@ -8,7 +8,7 @@ "tolerance": { "value": 0.005 }, - "value": 13238731 + "value": 13304997 }, "reason": "JavaScript sizes on run 38943915580 (13,338,532 / 108,835,838 bytes) are above a baseline taken on the Flutter branch before later master merges; desktop-compat-layer's run 38946610873, which does not contain this PR, is further above it (13,356,239 / 108,853,545). The Flutter benchmark has no master run to separate this PR's share, which is ParparVM's JS output for float rounding (Math.fround) and the single-rounding long-to-float.", "runs": 1, diff --git a/vm/selfhost/perf-baseline/pr/5976.json b/vm/selfhost/perf-baseline/pr/5976.json index 02c3a50438f..4a238c017ad 100644 --- a/vm/selfhost/perf-baseline/pr/5976.json +++ b/vm/selfhost/perf-baseline/pr/5976.json @@ -42,24 +42,6 @@ "time": 0.5 } } - }, - "translator": { - "all": { - "from": { - "memory": 0.519, - "time": 0.626, - "tolerance": { - "time": 0.2 - } - }, - "memory": 0.519, - "reason": "Linux x64 EPYC 7763 translator time 0.49x on run 38943915665 (rounds 0.48-0.54); this PR's earlier runs on that model measured 0.51x and 0.55x against the 0.63x row", - "runs": 3, - "time": 0.508, - "tolerance": { - "time": 0.2 - } - } } }, "macos-arm64": { @@ -156,58 +138,6 @@ } } } - }, - "windows-arm64@armv8-64-bit-family-8-model-d49-microsoft-corporation": { - "hello": { - "all": { - "from": { - "memory": 1.277, - "time": 1.107, - "tolerance": { - "memory": 0.35 - } - }, - "memory": 0.773, - "reason": "First run after merging master with the Flutter runtime (ff8f935c20): hello RAM fell on macOS arm64 (1.17-1.47x before, 0.92x now) and on Windows arm64 d49 (1.28x to 0.77x) in the same run, so the move comes from master's changes, not this PR's", - "runs": 1, - "time": 1.107, - "tolerance": { - "memory": 0.35 - } - } - }, - "translator": { - "all": { - "from": { - "memory": 0.43, - "time": 0.838 - }, - "memory": 0.503, - "reason": "Windows arm64 d49 translator RAM 0.50x (rounds 0.48-0.55) is the 0.513x the row held before pr/5883.json moved it to 0.43x from one run", - "runs": 1, - "time": 0.838 - } - } - }, - "windows-x64@intel64-family-6-model-207-genuineintel": { - "recursion": { - "all": { - "from": { - "memory": 0.059, - "time": 0.945, - "tolerance": { - "time": 0.2 - } - }, - "memory": 0.059, - "reason": "Windows x64 Intel Family 6 Model 207 recursion time is bimodal: base folds moved it 1.204x -> 0.943x -> 1.212x -> 0.945x, each from one or two runs; this branch measured 1.207x across five rounds (1.197-1.217), the upper mode", - "runs": 1, - "time": 1.207, - "tolerance": { - "time": 0.2 - } - } - } } } } From bfd6654d6624086184fb076581af39ce6378ab98 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:17:51 +0300 Subject: [PATCH 52/59] CSSThemeCompiler: fold CSS keywords, property names, colors and media queries with an ASCII fold, not the device locale Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 848766a32d2..0c2f8bb6355 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -1599,7 +1599,7 @@ private String expandBorderShorthand(String value) { String style = null; String color = null; for (String part : parts) { - String token = part.trim().toLowerCase(); + String token = asciiLower(part.trim()); if (token.length() == 0) { continue; } @@ -1850,7 +1850,7 @@ private boolean isBorderProperty(String property) { } private String normalizeHexColor(String cssColor) { - String value = cssColor == null ? "" : cssColor.trim().toLowerCase(); + String value = cssColor == null ? "" : asciiLower(cssColor.trim()); if (value.length() == 0) { throw new CSSSyntaxException("Color value cannot be empty"); } @@ -1892,7 +1892,7 @@ private String normalizeHexColor(String cssColor) { } private Integer normalizeAlignment(String value) { - String v = value == null ? "" : value.trim().toLowerCase(); + String v = value == null ? "" : asciiLower(value.trim()); if ("left".equals(v) || "start".equals(v)) { return Integer.valueOf(Component.LEFT); } @@ -2141,7 +2141,7 @@ private int findMatchingBrace(String css, int openPos) { } private boolean isDarkModeMediaQuery(String mediaQuery) { - String normalized = mediaQuery == null ? "" : mediaQuery.toLowerCase(); + String normalized = mediaQuery == null ? "" : asciiLower(mediaQuery); return normalized.indexOf("prefers-color-scheme") > -1 && normalized.indexOf("dark") > -1; } @@ -2324,7 +2324,7 @@ private Declaration[] parseDeclarations(String body) { } Declaration dec = new Declaration(); dec.name = trimmed.substring(0, colon).trim(); - dec.property = dec.name.toLowerCase(); + dec.property = asciiLower(dec.name); dec.value = tightenVarCalls(stripPriority(trimmed.substring(colon + 1).trim())); if (dec.property.length() == 0 || dec.value.length() == 0) { throw new CSSSyntaxException("Malformed declaration: " + trimmed); @@ -2401,6 +2401,23 @@ private static String[] colorArguments(String input, String color) { return out.toArray(new String[out.size()]); } + /// Lower-cases ASCII letters only. CSS keywords, property names and colors are ASCII by + /// specification, and String.toLowerCase() follows the device locale: on a Turkish device + /// `I` would fold to a dotless i and `PADDING-LEFT`-style names would never match. + private static String asciiLower(String s) { + StringBuilder out = null; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= 'A' && c <= 'Z') { + if (out == null) { + out = new StringBuilder(s); + } + out.setCharAt(i, (char) (c + 32)); + } + } + return out == null ? s : out.toString(); + } + /// The value of an ASCII hex digit, or -1. CSS escapes take only `0-9a-fA-F`; /// `Character.digit` would also accept fullwidth and other Unicode digits. private static int hexDigit(char c) { From 588eee32a2882122dbb0223b3e41441952b0b1af Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:14:01 +0300 Subject: [PATCH 53/59] CSSThemeCompiler: carry unselected styles and derives into every state, per-side padding/margin, rgba/transparent/opacity, the full CSS named-color table Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 356 ++++++++++++++++-- .../ui/css/CSSThemeCompilerTest.java | 82 ++++ 2 files changed, 407 insertions(+), 31 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 0c2f8bb6355..e35ce24db4a 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -121,6 +121,20 @@ public CSSSyntaxException(String message, Throwable cause) { /// compiler adds a custom property to its constants only inside `#Constants`, so a later /// `:root { --name: ... }` changes what `var(--name)` reads and leaves `@name` alone. private HashSet exportedConstants = new HashSet(); + + /// `padding-left` and its kin, per `uiid.state#padding` key, top/right/bottom/left; null is + /// a side not declared. Applied once every rule is read, over the box shorthand. + private Hashtable boxSides = new Hashtable(); + + /// The UIIDs (`$Dark` ones included) a rule of this compile styled, in first-seen order. + private ArrayList styledUiids = new ArrayList(); + + /// The style keys the build-time compiler carries from a UIID's unselected style into its + /// selected, pressed and disabled ones. The font has its own pass in compileFonts. + private static final String[] STATE_INHERITED_KEYS = { + "fgColor", "fgAlpha", "bgColor", "transparency", "opacity", "padding", "padUnit", "margin", + "marUnit", "align", "border", "bgImage" + }; /// While set, resolveVars does not see a constant this compile declares unless it holds it: /// one declared further down is not defined yet. A constant only an earlier compile into the /// same theme declared is visible. @@ -180,6 +194,8 @@ public void compile(String css, MutableResource resources, String themeName) { } Hashtable fonts = new Hashtable(); ownerDerives = new Hashtable(); + styledUiids = new ArrayList(); + boxSides = new Hashtable(); for (Rule rule : rules) { if (rule.legacyConstants) { continue; @@ -192,6 +208,8 @@ public void compile(String css, MutableResource resources, String themeName) { } markLiteralAliases(theme, deferred); compileFonts(theme, fonts); + applyBoxSides(theme); + inheritUnselectedIntoStates(theme); inheritHoverDerivations(theme); resolveThemeConstantVars(theme, deferred); for (Map.Entry entry : deferred.entrySet()) { @@ -859,6 +877,9 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha boolean universal = rule.universal; String uiid = selectorParts[0]; String statePrefix = selectorParts[1]; + if (!universal) { + styledUiids.add(uiid); + } StringBuilder borderCss = new StringBuilder(); for (int i = 0; i < rule.declarations.length; i++) { @@ -1087,6 +1108,71 @@ private void compileFonts(Hashtable theme, Hashtable fonts) { } } + /// The build-time compiler's selected, pressed and disabled styles start from the UIID's + /// unselected one, and the built theme holds every state's value. UIManager does not fall + /// back from `sel#bgColor` to `bgColor`, so without this a selected `Label{color:#333}` + /// would take the default selected color. A state keeps its own value, and one a derive + /// target declares for that state: the build lays `Base:selected` over `Child`'s unselected + /// style, and UIManager reaches `Base.sel#` through the derive by itself. + private void inheritUnselectedIntoStates(Hashtable theme) { + String[] states = {"sel#", "press#", "dis#"}; + String[] baseStates = {"sel", "press", "dis"}; + // What the stylesheet itself declared; a value this pass copies into a state is not a + // declaration a derived UIID's state should stop at. + Hashtable declared = new Hashtable(theme); + // A derived UIID's states derive from the target's matching states, as the build-time + // compiler writes them (`Child.sel#derive=Base.sel`): UIManager reads only the state's + // own derive key, so without it a selected Child would start from the default style. + for (Map.Entry entry : ownerDerives.entrySet()) { + String uiid = entry.getKey(); + for (int i = 0; i < states.length; i++) { + if (theme.get(uiid + "." + states[i] + "derive") == null) { + theme.put(uiid + "." + states[i] + "derive", entry.getValue() + "." + baseStates[i]); + } + } + } + for (String uiid : styledUiids) { + for (String key : STATE_INHERITED_KEYS) { + Object value = theme.get(uiid + "." + key); + if (value == null) { + continue; + } + for (String state : states) { + if (theme.get(uiid + "." + state + key) != null + || declared.get(uiid + "." + state + "derive") != null + || derivedStateDeclares(declared, uiid, state, key)) { + continue; + } + theme.put(uiid + "." + state + key, value); + } + } + } + } + + /// Whether a UIID `uiid` derives from, at any depth, declares `key` for `state`. + private boolean derivedStateDeclares(Hashtable theme, String uiid, String state, String key) { + boolean dark = uiid.startsWith("$Dark"); + ArrayList seen = new ArrayList(); + String id = uiid; + while (!seen.contains(id)) { + seen.add(id); + String next = ownerDerives.get(id); + if (next == null) { + Object stored = theme.get(id + ".derive"); + next = stored instanceof String ? (String) stored : null; + } + if (next == null) { + return false; + } + id = dark && !next.startsWith("$Dark") && theme.get("$Dark" + next + "." + state + key) != null + ? "$Dark" + next : next; + if (theme.get(id + "." + state + key) != null) { + return true; + } + } + return false; + } + /// The UIIDs a font in `state` inherits from, nearest first. At every hop a state that /// declares its own `cn1-derive` (`Child:selected{cn1-derive:Base}`) follows that instead of /// the UIID's. `*` is not part of it: the build-time compiler ignores universal rules. @@ -1516,17 +1602,49 @@ private static boolean endsWithIgnoreCase(String value, String suffix) { private boolean applySimpleThemeProperty(Hashtable theme, String uiid, String statePrefix, String property, String value) { if ("color".equals(property)) { theme.put(uiid + "." + statePrefix + "fgColor", normalizeHexColor(value)); + // As the build-time compiler's getThemeFgAlpha(): `transparent` and an rgba() alpha + // become fgAlpha, any other color leaves it unset. + int alpha = colorAlpha(value); + if (alpha >= 0) { + theme.put(uiid + "." + statePrefix + "fgAlpha", Integer.valueOf(alpha)); + } return true; } if ("background-color".equals(property)) { theme.put(uiid + "." + statePrefix + "bgColor", normalizeHexColor(value)); - if (!"transparent".equalsIgnoreCase(value)) { - theme.put(uiid + "." + statePrefix + "transparency", "255"); + // As getThemeTransparency(): `transparent` is 0, an rgba() its alpha, anything else + // opaque. A transparent background has to say so: the UIID's default may be opaque. + int alpha = colorAlpha(value); + theme.put(uiid + "." + statePrefix + "transparency", String.valueOf(alpha >= 0 ? alpha : 255)); + return true; + } + if ("opacity".equals(property)) { + float opacity; + try { + opacity = Float.parseFloat(value.trim()); + } catch (NumberFormatException err) { + throw new CSSSyntaxException("Unsupported opacity value: " + value, err); } + // As getThemeOpacity(): the fraction scaled to 0-255 and truncated. + theme.put(uiid + "." + statePrefix + "opacity", String.valueOf((int) (opacity * 255.0))); return true; } if ("padding".equals(property) || "margin".equals(property)) { applyBox(theme, uiid + "." + statePrefix, property, value); + // A later shorthand replaces the sides declared before it. + boxSides.remove(uiid + "." + statePrefix + property); + return true; + } + int side = boxSide(property); + if (side >= 0) { + String box = property.startsWith("padding") ? "padding" : "margin"; + String key = uiid + "." + statePrefix + box; + Length[] sides = boxSides.get(key); + if (sides == null) { + sides = new Length[4]; + boxSides.put(key, sides); + } + sides[side] = length(value); return true; } if ("cn1-derive".equals(property)) { @@ -1572,6 +1690,13 @@ private boolean appendBorderProperty(StringBuilder borderCss, String property, S if (!isBorderProperty(property)) { return false; } + if ("background-image".equals(property) && "none".equalsIgnoreCase(value.trim())) { + // No image, as in the build-time compiler; CSSBorder has no `none` to parse. + return true; + } + // CSSBorder reads only hex and rgb() colors; a keyword such as `red` is written as the + // hex the build-time compiler resolves it to. + value = namedColorsAsHex(value); if ("border".equals(property)) { String expanded = expandBorderShorthand(value); if (expanded.length() == 0) { @@ -1858,13 +1983,15 @@ private String normalizeHexColor(String cssColor) { return "000000"; } - if (value.startsWith("rgb(")) { + if (value.startsWith("rgb(") || value.startsWith("rgba(")) { + boolean alpha = value.startsWith("rgba("); if (!value.endsWith(")")) { throw new CSSSyntaxException("Malformed rgb() color: " + cssColor); } - String[] parts = colorArguments(value.substring(4, value.length() - 1), cssColor); - if (parts.length != 3) { - throw new CSSSyntaxException("rgb() must have exactly 3 components: " + cssColor); + String[] parts = colorArguments(value.substring(alpha ? 5 : 4, value.length() - 1), cssColor); + if (parts.length != (alpha ? 4 : 3)) { + throw new CSSSyntaxException((alpha ? "rgba() must have exactly 4" : "rgb() must have exactly 3") + + " components: " + cssColor); } int r = parseRgbChannel(parts[0], cssColor); int g = parseRgbChannel(parts[1], cssColor); @@ -1905,38 +2032,116 @@ private Integer normalizeAlignment(String value) { throw new CSSSyntaxException("Unsupported text-align value: " + value); } + /// The CSS named colors as the build-time compiler's Color table resolves them, name then + /// hex. `transparent` is handled by the callers, which give it its alpha. + private static final String[] NAMED_COLORS = { + "aliceblue", "f0f8ff", "antiquewhite", "faebd7", "aqua", "00ffff", + "aquamarine", "7fffd4", "azure", "f0ffff", "beige", "f5f5dc", + "bisque", "ffe4c4", "black", "000000", "blanchedalmond", "ffebcd", + "blue", "0000ff", "blueviolet", "8a2be2", "brown", "a52a2a", + "burlywood", "deb887", "cadetblue", "5f9ea0", "chartreuse", "7fff00", + "chocolate", "d2691e", "coral", "ff7f50", "cornflowerblue", "6495ed", + "cornsilk", "fff8dc", "crimson", "dc143c", "cyan", "00ffff", + "darkblue", "00008b", "darkcyan", "008b8b", "darkgoldenrod", "b8860b", + "darkgray", "a9a9a9", "darkgreen", "006400", "darkgrey", "a9a9a9", + "darkkhaki", "bdb76b", "darkmagenta", "8b008b", "darkolivegreen", "556b2f", + "darkorange", "ff8c00", "darkorchid", "9932cc", "darkred", "8b0000", + "darksalmon", "e9967a", "darkseagreen", "8fbc8f", "darkslateblue", "483d8b", + "darkslategray", "2f4f4f", "darkslategrey", "2f4f4f", "darkturquoise", "00ced1", + "darkviolet", "9400d3", "deeppink", "ff1493", "deepskyblue", "00bfff", + "dimgray", "696969", "dimgrey", "696969", "dodgerblue", "1e90ff", + "firebrick", "b22222", "floralwhite", "fffaf0", "forestgreen", "228b22", + "fuchsia", "ff00ff", "gainsboro", "dcdcdc", "ghostwhite", "f8f8ff", + "gold", "ffd700", "goldenrod", "daa520", "gray", "808080", + "green", "008000", "greenyellow", "adff2f", "grey", "808080", + "honeydew", "f0fff0", "hotpink", "ff69b4", "indianred", "cd5c5c", + "indigo", "4b0082", "ivory", "fffff0", "khaki", "f0e68c", + "lavender", "e6e6fa", "lavenderblush", "fff0f5", "lawngreen", "7cfc00", + "lemonchiffon", "fffacd", "lightblue", "add8e6", "lightcoral", "f08080", + "lightcyan", "e0ffff", "lightgoldenrodyellow", "fafad2", "lightgray", "d3d3d3", + "lightgreen", "90ee90", "lightgrey", "d3d3d3", "lightpink", "ffb6c1", + "lightsalmon", "ffa07a", "lightseagreen", "20b2aa", "lightskyblue", "87cefa", + "lightslategray", "778899", "lightslategrey", "778899", "lightsteelblue", "b0c4de", + "lightyellow", "ffffe0", "lime", "00ff00", "limegreen", "32cd32", + "linen", "faf0e6", "magenta", "ff00ff", "maroon", "800000", + "mediumaquamarine", "66cdaa", "mediumblue", "0000cd", "mediumorchid", "ba55d3", + "mediumpurple", "9370db", "mediumseagreen", "3cb371", "mediumslateblue", "7b68ee", + "mediumspringgreen", "00fa9a", "mediumturquoise", "48d1cc", "mediumvioletred", "c71585", + "midnightblue", "191970", "mintcream", "f5fffa", "mistyrose", "ffe4e1", + "moccasin", "ffe4b5", "navajowhite", "ffdead", "navy", "000080", + "oldlace", "fdf5e6", "olive", "808000", "olivedrab", "6b8e23", + "orange", "ffa500", "orangered", "ff4500", "orchid", "da70d6", + "palegoldenrod", "eee8aa", "palegreen", "98fb98", "paleturquoise", "afeeee", + "palevioletred", "db7093", "papayawhip", "ffefd5", "peachpuff", "ffdab9", + "peru", "cd853f", "pink", "ffc0cb", "plum", "dda0dd", + "powderblue", "b0e0e6", "purple", "800080", "red", "ff0000", + "rosybrown", "bc8f8f", "royalblue", "4169e1", "saddlebrown", "8b4513", + "salmon", "fa8072", "sandybrown", "f4a460", "seagreen", "2e8b57", + "seashell", "fff5ee", "sienna", "a0522d", "silver", "c0c0c0", + "skyblue", "87ceeb", "slateblue", "6a5acd", "slategray", "708090", + "slategrey", "708090", "snow", "fffafa", "springgreen", "00ff7f", + "steelblue", "4682b4", "tan", "d2b48c", "teal", "008080", + "thistle", "d8bfd8", "tomato", "ff6347", "turquoise", "40e0d0", + "violet", "ee82ee", "wheat", "f5deb3", "white", "ffffff", + "whitesmoke", "f5f5f5", "yellow", "ffff00", "yellowgreen", "9acd32", + }; + private String cssColorKeyword(String value) { - if ("black".equals(value)) { - return "000000"; - } - if ("white".equals(value)) { - return "ffffff"; - } - if ("red".equals(value)) { - return "ff0000"; - } - if ("green".equals(value)) { - return "008000"; - } - if ("blue".equals(value)) { - return "0000ff"; + for (int i = 0; i < NAMED_COLORS.length; i += 2) { + if (NAMED_COLORS[i].equals(value)) { + return NAMED_COLORS[i + 1]; + } } - if ("pink".equals(value)) { - return "ffc0cb"; + return null; + } + + /// `1px solid red` as `1px solid #ff0000`: every whitespace-separated CSS color keyword + /// replaced by its hex. + private String namedColorsAsHex(String value) { + StringBuilder out = new StringBuilder(value.length()); + int i = 0; + int n = value.length(); + while (i < n) { + char c = value.charAt(i); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f') { + out.append(c); + i++; + continue; + } + int end = i; + while (end < n && " \t\n\r\f".indexOf(value.charAt(end)) < 0) { + end++; + } + String token = value.substring(i, end); + String hex = "transparent".equalsIgnoreCase(token) ? null : cssColorKeyword(asciiLower(token)); + out.append(hex != null ? "#" + hex : token); + i = end; } - if ("orange".equals(value)) { - return "ffa500"; + return out.toString(); + } + + /// The alpha a color carries, 0-255, or -1 for an opaque color that states none: + /// `transparent` is 0 and `rgba(0, 0, 0, .5)` is 127, as the build-time compiler truncates it. + private int colorAlpha(String cssColor) { + String value = asciiLower(cssColor.trim()); + if ("transparent".equals(value)) { + return 0; } - if ("yellow".equals(value)) { - return "ffff00"; + if (!value.startsWith("rgba(") || !value.endsWith(")")) { + return -1; } - if ("purple".equals(value)) { - return "800080"; + String[] parts = colorArguments(value.substring(5, value.length() - 1), cssColor); + if (parts.length != 4) { + throw new CSSSyntaxException("rgba() must have exactly 4 components: " + cssColor); } - if ("gray".equals(value) || "grey".equals(value)) { - return "808080"; + String a = parts[3].trim(); + float fraction; + try { + fraction = a.endsWith("%") ? Float.parseFloat(a.substring(0, a.length() - 1)) / 100f : Float.parseFloat(a); + } catch (NumberFormatException err) { + throw new CSSSyntaxException("Invalid rgba() alpha in " + cssColor + ": " + a, err); } - return null; + return Math.max(0, Math.min(255, (int) (fraction * 255.0))); } private int parseRgbChannel(String value, String originalColor) { @@ -1993,6 +2198,88 @@ private void applyBox(Hashtable theme, String keyPrefix, String property, String theme.put(keyPrefix + unitKey, new byte[]{top.unit, left.unit, bottom.unit, right.unit}); } + /// 0-3 for `padding-top`/`-right`/`-bottom`/`-left` (and `margin-`), else -1. + private static int boxSide(String property) { + String rest; + if (property.startsWith("padding-")) { + rest = property.substring(8); + } else if (property.startsWith("margin-")) { + rest = property.substring(7); + } else { + return -1; + } + if ("top".equals(rest)) { + return 0; + } + if ("right".equals(rest)) { + return 1; + } + if ("bottom".equals(rest)) { + return 2; + } + return "left".equals(rest) ? 3 : -1; + } + + /// Writes the declared sides over the box they belong to: the state's own box when one was + /// declared, else (for a state) the UIID's unselected box, which the build-time compiler's + /// state style starts from, else zero. Unselected keys go first so a state can start from + /// a box that itself had sides applied. + private void applyBoxSides(Hashtable theme) { + for (int pass = 0; pass < 2; pass++) { + for (Map.Entry entry : boxSides.entrySet()) { + String key = entry.getKey(); + int dot = key.indexOf('.'); + int hash = key.indexOf('#', dot); + if ((pass == 0) != (hash < 0)) { + continue; + } + String box = key.endsWith("padding") ? "padding" : "margin"; + String unitSuffix = "padding".equals(box) ? Style.PADDING_UNIT : Style.MARGIN_UNIT; + String prefix = key.substring(0, key.length() - box.length()); + String source = theme.get(key) != null || hash < 0 ? prefix : key.substring(0, dot + 1); + Length[] out = readBox(theme.get(source + box), theme.get(source + unitSuffix)); + Length[] sides = entry.getValue(); + for (int i = 0; i < 4; i++) { + if (sides[i] != null) { + out[i] = sides[i]; + } + } + theme.put(key, out[0].text + "," + out[2].text + "," + out[3].text + "," + out[1].text); + theme.put(prefix + unitSuffix, new byte[]{out[0].unit, out[3].unit, out[2].unit, out[1].unit}); + } + } + } + + /// A stored box (`top,bottom,left,right` with units `[top, left, bottom, right]`) as + /// top/right/bottom/left lengths; zero pixels where nothing is stored. + private static Length[] readBox(Object values, Object units) { + Length[] out = new Length[4]; + String[] parts = values instanceof String ? splitCommas((String) values) : null; + byte[] unit = units instanceof byte[] ? (byte[]) units : null; + // Stored order to top/right/bottom/left. + int[] textIndex = {0, 3, 1, 2}; + int[] unitIndex = {0, 3, 2, 1}; + for (int i = 0; i < 4; i++) { + Length l = new Length(); + l.text = parts != null && parts.length == 4 ? parts[textIndex[i]].trim() : "0"; + l.unit = unit != null && unit.length == 4 ? unit[unitIndex[i]] : Style.UNIT_TYPE_PIXELS; + out[i] = l; + } + return out; + } + + private static String[] splitCommas(String value) { + ArrayList out = new ArrayList(); + int start = 0; + for (int i = 0; i <= value.length(); i++) { + if (i == value.length() || value.charAt(i) == ',') { + out.add(value.substring(start, i)); + start = i + 1; + } + } + return out.toArray(new String[out.size()]); + } + /// One side of a box. `px`, `pt` and a bare number are pixels rounded to a whole number, /// which is what the build-time compiler stores: its `pt` is a density independent pixel, /// not a CSS point. @@ -2140,6 +2427,13 @@ private int findMatchingBrace(String css, int openPos) { return -1; } + /// A dark media block's rules land on the same `$Dark` UIID as a `$DarkFoo` rule and merge + /// with it in source order. The build-time compiler differs in one case on purpose left + /// unmatched: a `$DarkFoo` rule BEFORE a `@media (prefers-color-scheme: dark) { Foo {...} }` + /// block loses its other declarations there (the media rule replaces the element instead of + /// merging), so `background-color` set in `$DarkFoo` silently vanishes from the build. That + /// is a defect of the build, not a behaviour a stylesheet relies on, so the preview keeps + /// what the author wrote. private boolean isDarkModeMediaQuery(String mediaQuery) { String normalized = mediaQuery == null ? "" : asciiLower(mediaQuery); return normalized.indexOf("prefers-color-scheme") > -1 && normalized.indexOf("dark") > -1; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 1943f96dec8..99e857478e5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1448,6 +1448,88 @@ void testAliasOfQuotedNumberStaysText() { assertEquals("\"x\"", theme.get("@quoteCopy")); } + @Test + void testUnselectedStyleReachesEveryState() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Label{color:#333;background-color:#fff;padding:2px 3mm;margin:1mm}" + + " Label.selected{color:#f00}" + + " Base.pressed{background-color:#0f0} Child{cn1-derive:Base;background-color:#00f}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + for (String state : new String[]{"sel#", "press#", "dis#"}) { + assertEquals("ffffff", theme.get("Label." + state + "bgColor"), state); + assertEquals("255", theme.get("Label." + state + "transparency"), state); + assertEquals(theme.get("Label.padding"), theme.get("Label." + state + "padding"), state); + org.junit.jupiter.api.Assertions.assertNotNull(theme.get("Label." + state + "marUnit"), state); + } + assertEquals("ff0000", theme.get("Label.sel#fgColor")); + assertEquals("333333", theme.get("Label.press#fgColor")); + // Base:pressed is laid over Child's unselected style, through the derive. + org.junit.jupiter.api.Assertions.assertNull(theme.get("Child.press#bgColor")); + assertEquals("0000ff", theme.get("Child.sel#bgColor")); + } + + @Test + void testRgbaAndTransparentColors() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("T{background-color:rgba(0,0,0,0);color:rgba(255,0,0,.5)}" + + " N{background-color:transparent} O{opacity:0.5}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("000000", theme.get("T.bgColor")); + assertEquals("0", theme.get("T.transparency")); + assertEquals("ff0000", theme.get("T.fgColor")); + assertEquals(Integer.valueOf(127), theme.get("T.fgAlpha")); + assertEquals("0", theme.get("N.transparency")); + assertEquals("127", theme.get("O.opacity")); + } + + @Test + void testBorderWithNamedColorAndNoBackgroundImage() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Card{border:1px solid red} N{background-image:none;color:#000}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNotNull(theme.get("Card.border")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("N.border")); + assertEquals("000000", theme.get("N.fgColor")); + } + + @Test + void testDerivedUiidStatesDeriveFromTheTargetStates() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{color:#112233;background-color:#fff;padding:2mm}" + + " Base.pressed{color:#f00} Child{cn1-derive:Base} Child.selected{color:#0f0}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("Base.sel", theme.get("Child.sel#derive")); + assertEquals("Base.press", theme.get("Child.press#derive")); + assertEquals("Base.dis", theme.get("Child.dis#derive")); + assertEquals("00ff00", theme.get("Child.sel#fgColor")); + } + + @Test + void testPerSidePaddingAndMargin() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("S{padding-left:1mm;padding-top:2px;margin-right:3mm;margin-bottom:4pt}" + + " S.pressed{padding-right:5px} T{padding:1px;padding-left:9px}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("2,0,1,0", theme.get("S.padding")); + org.junit.jupiter.api.Assertions.assertArrayEquals(new byte[]{Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_DIPS, + Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS}, (byte[]) theme.get("S.padUnit")); + assertEquals("0,4,0,3", theme.get("S.margin")); + assertEquals("2,0,1,5", theme.get("S.press#padding")); + assertEquals("1,1,9,1", theme.get("T.padding")); + } + + @Test + void testEveryCssNamedColor() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("A{color:silver;background-color:navy} B{color:orchid}" + + " C{color:lightslategrey}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("c0c0c0", theme.get("A.fgColor")); + assertEquals("000080", theme.get("A.bgColor")); + assertEquals("da70d6", theme.get("B.fgColor")); + assertEquals("778899", theme.get("C.fgColor")); + } + @Test void testColorKeywordConstantStaysAnIdentifier() { MutableResource res = new MutableResource(); From eb6822e6c618558419e267e6eb56f46f9cdbc30c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:36:04 +0300 Subject: [PATCH 54/59] CSSThemeCompiler: prefix UIIDs for platform/density/device @media like the build, keep .unselected rules out of the other states Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 100 ++++++++++++++++-- .../ui/css/CSSThemeCompilerTest.java | 22 ++++ 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index e35ce24db4a..db2b13ea0b7 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -877,7 +877,9 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha boolean universal = rule.universal; String uiid = selectorParts[0]; String statePrefix = selectorParts[1]; - if (!universal) { + // Only a bare `A {}` rule reaches the other states in the build; `A.unselected {}` + // styles the unselected state alone. + if (!universal && !isUnselectedSelector(rule.selector)) { styledUiids.add(uiid); } StringBuilder borderCss = new StringBuilder(); @@ -1933,6 +1935,14 @@ private String[] selector(String selector) { return new String[]{uiid, statePrefix}; } + private static boolean isUnselectedSelector(String selector) { + String s = selector.trim(); + int dot = s.indexOf('.'); + int colon = s.indexOf(':'); + int pos = dot < 0 ? colon : colon < 0 ? dot : Math.min(dot, colon); + return pos > -1 && "unselected".equals(s.substring(pos + 1).trim()); + } + private String statePrefix(String pseudo) { if ("unselected".equals(pseudo)) { return ""; @@ -2123,6 +2133,9 @@ private String namedColorsAsHex(String value) { /// The alpha a color carries, 0-255, or -1 for an opaque color that states none: /// `transparent` is 0 and `rgba(0, 0, 0, .5)` is 127, as the build-time compiler truncates it. private int colorAlpha(String cssColor) { + // Folded on purpose: CSS keywords are case-insensitive, so `TRANSPARENT` is transparent. + // The build-time compiler matches only the lowercase spelling and paints such a value + // opaque, which is its bug, not a behaviour this compiler should copy. String value = asciiLower(cssColor.trim()); if ("transparent".equals(value)) { return 0; @@ -2321,11 +2334,11 @@ private Length length(String value) { private Rule[] parseRulesWithMedia(String css) { ArrayList out = new ArrayList(); - parseRulesInto(css, out, false); + parseRulesInto(css, out, false, null); return out.toArray(new Rule[out.size()]); } - private void parseRulesInto(String css, ArrayList out, boolean darkContext) { + private void parseRulesInto(String css, ArrayList out, boolean darkContext, String[] mediaPrefixes) { int pos = 0; int len = css.length(); while (pos < len) { @@ -2358,8 +2371,18 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext } if (selectors.startsWith("@media")) { String mediaQuery = selectors.substring("@media".length()).trim(); - boolean nextDarkContext = darkContext || isDarkModeMediaQuery(mediaQuery); - parseRulesInto(css.substring(open + 1, close), out, nextDarkContext); + boolean dark = isDarkModeMediaQuery(mediaQuery); + String[] nextPrefixes = mediaPrefixes; + if (!dark) { + if (mediaQuery.indexOf('(') >= 0) { + // The build-time parser rejects a feature query such as (min-width: 400px) + // and drops the whole block; a theme has no viewport to test it against. + pos = close + 1; + continue; + } + nextPrefixes = mediaPrefixes(mediaQuery); + } + parseRulesInto(css.substring(open + 1, close), out, darkContext || dark, nextPrefixes); pos = close + 1; continue; } @@ -2385,12 +2408,23 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext out.add(constants); continue; } - Rule rule = new Rule(); - rule.selector = darkContext ? toDarkSelector(selector) : selector; String base = selector.trim(); - rule.universal = base.startsWith("*") || base.startsWith(":") && !":root".equals(base); - rule.declarations = declarations; - out.add(rule); + boolean universal = base.startsWith("*") || base.startsWith(":") && !":root".equals(base); + String target = darkContext ? toDarkSelector(selector) : selector; + if (mediaPrefixes == null || universal) { + Rule rule = new Rule(); + rule.selector = target; + rule.universal = universal; + rule.declarations = declarations; + out.add(rule); + continue; + } + for (String prefix : mediaPrefixes) { + Rule rule = new Rule(); + rule.selector = prefix.length() == 0 ? target : prefix + "-" + target; + rule.declarations = declarations; + out.add(rule); + } } pos = close + 1; @@ -2434,6 +2468,52 @@ private int findMatchingBrace(String css, int openPos) { /// merging), so `background-color` set in `$DarkFoo` silently vanishes from the build. That /// is a defect of the build, not a behaviour a stylesheet relies on, so the preview keeps /// what the author wrote. + /// The UIID prefixes a `@media` list stands for, as the build-time compiler computes them: + /// `platform-*` crossed with `density-*`, then `device-*` in front, so + /// `@media platform-ios, density-high { A {} }` styles `platform-ios-density-high-A`. A list + /// naming none of the three applies to the plain UIIDs (prefix ""). + private String[] mediaPrefixes(String mediaQuery) { + ArrayList platforms = new ArrayList(); + ArrayList densities = new ArrayList(); + ArrayList types = new ArrayList(); + for (String entry : splitOnChar(mediaQuery, ',')) { + String key = entry.trim(); + if (key.startsWith("platform-")) { + platforms.add(key); + } else if (key.startsWith("density-")) { + densities.add(key); + } else if (key.startsWith("device-")) { + types.add(key); + } + } + ArrayList out = new ArrayList(); + if (platforms.isEmpty()) { + if (densities.isEmpty()) { + out.add(""); + } else { + out.addAll(densities); + } + } else { + for (String platform : platforms) { + if (densities.isEmpty()) { + out.add(platform); + } else { + for (String density : densities) { + out.add(platform + "-" + density); + } + } + } + } + int count = out.size(); + for (String type : types) { + for (int i = 0; i < count; i++) { + String curr = out.get(i); + out.set(i, curr.length() == 0 ? type : type + "-" + curr); + } + } + return out.toArray(new String[out.size()]); + } + private boolean isDarkModeMediaQuery(String mediaQuery) { String normalized = mediaQuery == null ? "" : asciiLower(mediaQuery); return normalized.indexOf("prefers-color-scheme") > -1 && normalized.indexOf("dark") > -1; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 99e857478e5..662a06dbfe9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1645,4 +1645,26 @@ void testImageAliasOfAColorOrNumberIsNotAnImage() { assertEquals("ff0000", theme.get("@placeholderImage")); assertEquals("3", theme.get("@countImage")); } + + @Test + void testPlatformMediaQueryPrefixesTheUiid() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("@media platform-ios { A { color: #111111; } }" + + "@media device-tablet, platform-and, density-high { B { color: #222222; } }" + + "@media (min-width: 400px) { C { color: #333333; } }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("111111", theme.get("platform-ios-A.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A.fgColor")); + assertEquals("222222", theme.get("device-tablet-platform-and-density-high-B.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("C.fgColor")); + } + + @Test + void testUnselectedRuleStaysInTheUnselectedState() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("A.unselected { color: #222222; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("222222", theme.get("A.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A.sel#fgColor")); + } } From 47fe4484f674dd412d53cf21cb19913936fb9758 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:52:14 +0300 Subject: [PATCH 55/59] CSSThemeCompiler: write @cn1-bind keys for var() colors, accept --var lengths in #Constants, drop combinator selectors Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 83 +++++++++++++++++++ .../ui/css/CSSThemeCompilerTest.java | 33 ++++++++ 2 files changed, 116 insertions(+) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index db2b13ea0b7..ff82dc330e8 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -547,6 +547,13 @@ private void compileConstants(Declaration[] declarations, Hashtable theme, value = unquotedFirst; } else if (first.length() < value.length() && isColorLiteral(first)) { value = constantColor(first); + } else if (declName.startsWith("--") && isDimensionToken(first)) { + // `#Constants { --gap: 5px; }` exists to be used as var(--gap) in a + // padding or margin; the build-time compiler resolves those and leaves the + // constant itself out of the theme, without failing the sheet. A plain + // `gap: 5px` has no such use and is still rejected below, so the author + // learns the constant does not exist rather than reading null at runtime. + continue; } else { value = buildConstant(name, first, first); } @@ -867,6 +874,9 @@ private static String unquote(String value) { private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts, Hashtable deferred) { if (":root".equals(rule.selector)) { + // Unlike the build-time compiler, `:root` custom properties are also kept as `@name` + // theme constants: that is this compiler's documented contract for `:root` (see the + // class comment), and applications already read them through getThemeConstant(). for (Declaration declaration : rule.declarations) { applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); } @@ -893,6 +903,19 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha continue; } String value = resolveVars(theme, declaration.value, new ArrayList(), true); + if (!universal && ("color".equals(property) || "background-color".equals(property))) { + // `@cn1-bind:Foo.fgColor=accent`, as the build-time compiler writes it, lets + // UIManager retune every color that names `var(--accent)` through one + // addThemeProps({"@accent": ...}). A literal drops an earlier binding. + String bindKey = "@cn1-bind:" + uiid + "." + statePrefix + + ("color".equals(property) ? "fgColor" : "bgColor"); + String bound = boundVariable(declaration.value); + if (bound != null) { + theme.put(bindKey, bound); + } else { + theme.remove(bindKey); + } + } if ("cn1-derive".equals(property)) { // The build-time compiler reads the target with getStringValue(): `"Base"` and // `B\61se` both name the UIID `Base`. @@ -1146,12 +1169,41 @@ private void inheritUnselectedIntoStates(Hashtable theme) { continue; } theme.put(uiid + "." + state + key, value); + Object binding = theme.get("@cn1-bind:" + uiid + "." + key); + if (binding != null) { + theme.put("@cn1-bind:" + uiid + "." + state + key, binding); + } } } } } /// Whether a UIID `uiid` derives from, at any depth, declares `key` for `state`. + /// The custom property `value` consists of, `accent` for `var(--accent)` or + /// `var(--accent, red)`, or null when the value is anything more than one var(). + private static String boundVariable(String value) { + String v = value.trim(); + if (!v.startsWith("var(") || !v.endsWith(")")) { + return null; + } + String inner = v.substring(4, v.length() - 1).trim(); + int comma = inner.indexOf(','); + String name = (comma < 0 ? inner : inner.substring(0, comma)).trim(); + if (!name.startsWith("--") || name.length() == 2) { + return null; + } + for (int i = 2; i < name.length(); i++) { + char c = name.charAt(i); + if (c == '(' || c == ')' || c <= ' ') { + return null; + } + } + if (comma < 0 && inner.indexOf('(') >= 0) { + return null; + } + return name.substring(2); + } + private boolean derivedStateDeclares(Hashtable theme, String uiid, String state, String key) { boolean dark = uiid.startsWith("$Dark"); ArrayList seen = new ArrayList(); @@ -1445,6 +1497,20 @@ private static float number(String text, String original) { /// The value the build-time compiler stores for an unquoted `#Constants` token: a number /// as it serializes the parsed value (`1.50` is `1.5`, `001` is `1`), an identifier as /// written. Every other token type is one it drops, so it is rejected here. + /// Whether `token` is a CSS length or percentage such as `5px`, `-1.5mm` or `50%`. + private static boolean isDimensionToken(String token) { + int end = token.length(); + while (end > 0) { + char c = token.charAt(end - 1); + if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '%') { + end--; + } else { + break; + } + } + return end > 0 && end < token.length() && isCssNumber(token.substring(0, end)); + } + private static String buildConstant(String name, String raw, String value) { if (isCssNumber(value) && (value.indexOf('e') > -1 || value.indexOf('E') > -1)) { // The build-time parser reads `1e2` as a dimension, a token its constants emitter @@ -1935,6 +2001,18 @@ private String[] selector(String selector) { return new String[]{uiid, statePrefix}; } + private static boolean isCombinatorSelector(String selector) { + for (int i = 0; i < selector.length(); i++) { + char c = selector.charAt(i); + if (c == '\\') { + i++; + } else if (c <= ' ' || c == '>' || c == '+' || c == '~') { + return true; + } + } + return false; + } + private static boolean isUnselectedSelector(String selector) { String s = selector.trim(); int dot = s.indexOf('.'); @@ -2398,6 +2476,11 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext if (selector.length() == 0) { throw new CSSSyntaxException("Empty selector in selector list: " + selectors); } + if (isCombinatorSelector(selector)) { + // `A B`, `A > B`, `A + B`, `A ~ B`: a theme has no element tree to match a + // combinator against, and the build-time compiler drops such a rule. + continue; + } if (isConstantsSelector(selector)) { // `#Constants, Other { ... }`: the build-time compiler still exports the // constants, and applies the declarations to the other selectors as usual. diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 662a06dbfe9..6996140a6cd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1667,4 +1667,37 @@ void testUnselectedRuleStaysInTheUnselectedState() { assertEquals("222222", theme.get("A.fgColor")); org.junit.jupiter.api.Assertions.assertNull(theme.get("A.sel#fgColor")); } + + @Test + void testVarColorWritesABinding() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile(":root{--a:#112233;--b:var(--a);} Foo{color:var(--b);background-color:var(--a)}" + + " Bar{color:var(--a)} Bar.disabled{color:#000000}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("b", theme.get("@cn1-bind:Foo.fgColor")); + assertEquals("b", theme.get("@cn1-bind:Foo.sel#fgColor")); + assertEquals("a", theme.get("@cn1-bind:Foo.bgColor")); + assertEquals("a", theme.get("@cn1-bind:Bar.press#fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("@cn1-bind:Bar.dis#fgColor")); + } + + @Test + void testLengthConstantIsUsableButNotStored() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --a: 5px; --b: 2mm; } Foo { padding: var(--a); }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("@a")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("@b")); + assertEquals("5,5,5,5", theme.get("Foo.padding")); + } + + @Test + void testCombinatorRulesAreDropped() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("A B{color:#ff0000} A > B{color:#00ff00} C{color:#0000ff}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A B.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A > B.fgColor")); + assertEquals("0000ff", theme.get("C.fgColor")); + } } From fd264e6b9e9de9935a6a70da490cf8704cf327cc Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:05:49 +0300 Subject: [PATCH 56/59] CSSThemeCompiler: carry only the bare rule's values into states, drop compound media queries, decode escaped UIIDs Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 57 ++++++++++++++++++- .../ui/css/CSSThemeCompilerTest.java | 35 ++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index ff82dc330e8..55de6088e92 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -128,6 +128,10 @@ public CSSSyntaxException(String message, Throwable cause) { /// The UIIDs (`$Dark` ones included) a rule of this compile styled, in first-seen order. private ArrayList styledUiids = new ArrayList(); + /// For a base key an `A.unselected {}` rule overwrote: the value a bare `A {}` rule had set, + /// or NO_BARE_VALUE. Only that value is carried into the other states. + private Hashtable bareValues = new Hashtable(); + private static final Object NO_BARE_VALUE = new Object(); /// The style keys the build-time compiler carries from a UIID's unselected style into its /// selected, pressed and disabled ones. The font has its own pass in compileFonts. @@ -196,6 +200,7 @@ public void compile(String css, MutableResource resources, String themeName) { ownerDerives = new Hashtable(); styledUiids = new ArrayList(); boxSides = new Hashtable(); + bareValues = new Hashtable(); for (Rule rule : rules) { if (rule.legacyConstants) { continue; @@ -204,7 +209,31 @@ public void compile(String css, MutableResource resources, String themeName) { compileConstants(rule.declarations, theme, deferred, false); continue; } + if (rule.universal || ":root".equals(rule.selector) || selector(rule.selector)[1].length() > 0) { + applyRule(theme, resources, rule, fonts, deferred); + continue; + } + // A base-state rule: remember, per key, what a bare `A {}` rule set before an + // `A.unselected {}` rule overwrote it, since only the bare value reaches the states. + String uiid = selector(rule.selector)[0]; + boolean unselected = isUnselectedSelector(rule.selector); + Object[] before = new Object[STATE_INHERITED_KEYS.length]; + for (int i = 0; i < before.length; i++) { + before[i] = theme.get(uiid + "." + STATE_INHERITED_KEYS[i]); + } applyRule(theme, resources, rule, fonts, deferred); + for (int i = 0; i < before.length; i++) { + String key = uiid + "." + STATE_INHERITED_KEYS[i]; + Object after = theme.get(key); + if (after == null ? before[i] == null : after.equals(before[i])) { + continue; + } + if (!unselected) { + bareValues.remove(key); + } else if (!bareValues.containsKey(key)) { + bareValues.put(key, before[i] == null ? NO_BARE_VALUE : before[i]); + } + } } markLiteralAliases(theme, deferred); compileFonts(theme, fonts); @@ -1158,8 +1187,9 @@ private void inheritUnselectedIntoStates(Hashtable theme) { } for (String uiid : styledUiids) { for (String key : STATE_INHERITED_KEYS) { - Object value = theme.get(uiid + "." + key); - if (value == null) { + Object bare = bareValues.get(uiid + "." + key); + Object value = bare == null ? theme.get(uiid + "." + key) : bare; + if (value == null || value == NO_BARE_VALUE) { continue; } for (String state : states) { @@ -1169,7 +1199,7 @@ private void inheritUnselectedIntoStates(Hashtable theme) { continue; } theme.put(uiid + "." + state + key, value); - Object binding = theme.get("@cn1-bind:" + uiid + "." + key); + Object binding = bare != null ? null : theme.get("@cn1-bind:" + uiid + "." + key); if (binding != null) { theme.put("@cn1-bind:" + uiid + "." + state + key, binding); } @@ -1975,6 +2005,9 @@ private static int topLevelComma(String value) { return -1; } + // A selector written as `$DarkA` is kept as the UIID `$DarkA`, the key this compiler has + // always stored dark styles under, although the build-time compiler drops such a selector: + // themes compiled here address their dark variants that way. private String[] selector(String selector) { String statePrefix = ""; String uiid = selector.trim(); @@ -1998,6 +2031,13 @@ private String[] selector(String selector) { if ("*".equals(uiid) || uiid.length() == 0) { uiid = "Component"; } + if (uiid.indexOf('\\') > -1) { + // `A\ B` names the UIID `A B`, as the build-time compiler decodes it. + String decoded = decodeIdentifier(uiid); + if (decoded != null) { + uiid = decoded; + } + } return new String[]{uiid, statePrefix}; } @@ -2459,6 +2499,10 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext continue; } nextPrefixes = mediaPrefixes(mediaQuery); + if (nextPrefixes == null) { + pos = close + 1; + continue; + } } parseRulesInto(css.substring(open + 1, close), out, darkContext || dark, nextPrefixes); pos = close + 1; @@ -2561,6 +2605,13 @@ private String[] mediaPrefixes(String mediaQuery) { ArrayList types = new ArrayList(); for (String entry : splitOnChar(mediaQuery, ',')) { String key = entry.trim(); + for (int i = 0; i < key.length(); i++) { + if (key.charAt(i) <= ' ') { + // `platform-ios and device-tablet`: the build-time compiler matches whole + // media names only and styles nothing for such a query. + return null; + } + } if (key.startsWith("platform-")) { platforms.add(key); } else if (key.startsWith("density-")) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index 6996140a6cd..afeb8916dc5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1700,4 +1700,39 @@ void testCombinatorRulesAreDropped() { org.junit.jupiter.api.Assertions.assertNull(theme.get("A > B.fgColor")); assertEquals("0000ff", theme.get("C.fgColor")); } + + @Test + void testUnselectedRuleBesideABareRuleStaysUnselected() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("A{color:#111111;background-color:#00ff00} A.unselected{color:#ff0000;padding:1px}" + + " B.unselected{color:#ff0000} B{background-color:#00ff00}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("ff0000", theme.get("A.fgColor")); + assertEquals("111111", theme.get("A.sel#fgColor")); + assertEquals("00ff00", theme.get("A.press#bgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A.dis#padding")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("B.sel#fgColor")); + assertEquals("00ff00", theme.get("B.sel#bgColor")); + } + + @Test + void testCompoundMediaQueryAndEscapedSpaceSelector() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("@media platform-ios and device-tablet { A { color: #ff0000; } }" + + " A\\ B { color: #00ff00; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A.fgColor")); + assertEquals(1, countKeysContaining(theme, "fgColor"), theme.toString()); + assertEquals("00ff00", theme.get("A B.fgColor")); + } + + private static int countKeysContaining(Hashtable theme, String part) { + int n = 0; + for (Object k : theme.keySet()) { + if (k.toString().indexOf(part) > -1 && k.toString().indexOf('#') < 0) { + n++; + } + } + return n; + } } From 5b88e8bc111993b39726f647ac700c58f9f15ae4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:18:49 +0300 Subject: [PATCH 57/59] CSSThemeCompiler: compile .unselected as a layer over the bare rule, keep hex escapes out of the combinator check Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 91 ++++++++++--------- .../ui/css/CSSThemeCompilerTest.java | 26 ++++++ 2 files changed, 72 insertions(+), 45 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 55de6088e92..5035069fa1e 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -128,10 +128,11 @@ public CSSSyntaxException(String message, Throwable cause) { /// The UIIDs (`$Dark` ones included) a rule of this compile styled, in first-seen order. private ArrayList styledUiids = new ArrayList(); - /// For a base key an `A.unselected {}` rule overwrote: the value a bare `A {}` rule had set, - /// or NO_BARE_VALUE. Only that value is carried into the other states. - private Hashtable bareValues = new Hashtable(); - private static final Object NO_BARE_VALUE = new Object(); + /// The state prefix an `A.unselected {}` rule compiles into until mergeUnselected(). + /// The build-time compiler keeps such a rule apart from a bare `A {}` rule: it wins over the + /// bare rule whatever the source order, yet only the bare rule's values reach the + /// selected, pressed and disabled states, and per-side insets start from the bare box. + private static final String UNSELECTED = "uns#"; /// The style keys the build-time compiler carries from a UIID's unselected style into its /// selected, pressed and disabled ones. The font has its own pass in compileFonts. @@ -200,7 +201,6 @@ public void compile(String css, MutableResource resources, String themeName) { ownerDerives = new Hashtable(); styledUiids = new ArrayList(); boxSides = new Hashtable(); - bareValues = new Hashtable(); for (Rule rule : rules) { if (rule.legacyConstants) { continue; @@ -209,31 +209,7 @@ public void compile(String css, MutableResource resources, String themeName) { compileConstants(rule.declarations, theme, deferred, false); continue; } - if (rule.universal || ":root".equals(rule.selector) || selector(rule.selector)[1].length() > 0) { - applyRule(theme, resources, rule, fonts, deferred); - continue; - } - // A base-state rule: remember, per key, what a bare `A {}` rule set before an - // `A.unselected {}` rule overwrote it, since only the bare value reaches the states. - String uiid = selector(rule.selector)[0]; - boolean unselected = isUnselectedSelector(rule.selector); - Object[] before = new Object[STATE_INHERITED_KEYS.length]; - for (int i = 0; i < before.length; i++) { - before[i] = theme.get(uiid + "." + STATE_INHERITED_KEYS[i]); - } applyRule(theme, resources, rule, fonts, deferred); - for (int i = 0; i < before.length; i++) { - String key = uiid + "." + STATE_INHERITED_KEYS[i]; - Object after = theme.get(key); - if (after == null ? before[i] == null : after.equals(before[i])) { - continue; - } - if (!unselected) { - bareValues.remove(key); - } else if (!bareValues.containsKey(key)) { - bareValues.put(key, before[i] == null ? NO_BARE_VALUE : before[i]); - } - } } markLiteralAliases(theme, deferred); compileFonts(theme, fonts); @@ -255,6 +231,7 @@ public void compile(String css, MutableResource resources, String themeName) { theme.put(key, constantColor(resolved)); } } + mergeUnselected(theme); // The build-time compiler also stores every `Default.*` property unprefixed: the // theme's root keys, which UIManager's default style reads. ArrayList defaults = new ArrayList(); @@ -917,8 +894,8 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha String uiid = selectorParts[0]; String statePrefix = selectorParts[1]; // Only a bare `A {}` rule reaches the other states in the build; `A.unselected {}` - // styles the unselected state alone. - if (!universal && !isUnselectedSelector(rule.selector)) { + // styles the unselected state alone (see UNSELECTED). + if (!universal && statePrefix.length() == 0) { styledUiids.add(uiid); } StringBuilder borderCss = new StringBuilder(); @@ -1187,9 +1164,8 @@ private void inheritUnselectedIntoStates(Hashtable theme) { } for (String uiid : styledUiids) { for (String key : STATE_INHERITED_KEYS) { - Object bare = bareValues.get(uiid + "." + key); - Object value = bare == null ? theme.get(uiid + "." + key) : bare; - if (value == null || value == NO_BARE_VALUE) { + Object value = theme.get(uiid + "." + key); + if (value == null) { continue; } for (String state : states) { @@ -1199,7 +1175,7 @@ private void inheritUnselectedIntoStates(Hashtable theme) { continue; } theme.put(uiid + "." + state + key, value); - Object binding = bare != null ? null : theme.get("@cn1-bind:" + uiid + "." + key); + Object binding = theme.get("@cn1-bind:" + uiid + "." + key); if (binding != null) { theme.put("@cn1-bind:" + uiid + "." + state + key, binding); } @@ -2045,7 +2021,18 @@ private static boolean isCombinatorSelector(String selector) { for (int i = 0; i < selector.length(); i++) { char c = selector.charAt(i); if (c == '\\') { - i++; + // `\41 B` is the UIID `AB`: up to six hex digits, then one whitespace + // character that belongs to the escape. + int digits = 0; + while (digits < 6 && i + 1 < selector.length() && hexDigit(selector.charAt(i + 1)) > -1) { + i++; + digits++; + } + if (digits == 0) { + i++; + } else if (i + 1 < selector.length() && selector.charAt(i + 1) <= ' ') { + i++; + } } else if (c <= ' ' || c == '>' || c == '+' || c == '~') { return true; } @@ -2053,17 +2040,9 @@ private static boolean isCombinatorSelector(String selector) { return false; } - private static boolean isUnselectedSelector(String selector) { - String s = selector.trim(); - int dot = s.indexOf('.'); - int colon = s.indexOf(':'); - int pos = dot < 0 ? colon : colon < 0 ? dot : Math.min(dot, colon); - return pos > -1 && "unselected".equals(s.substring(pos + 1).trim()); - } - private String statePrefix(String pseudo) { if ("unselected".equals(pseudo)) { - return ""; + return UNSELECTED; } if ("selected".equals(pseudo)) { return "sel#"; @@ -2355,6 +2334,28 @@ private static int boxSide(String property) { /// declared, else (for a state) the UIID's unselected box, which the build-time compiler's /// state style starts from, else zero. Unselected keys go first so a state can start from /// a box that itself had sides applied. + /// Lays every `A.uns#key` over `A.key`, with the binding that goes with it. + private static void mergeUnselected(Hashtable theme) { + ArrayList keys = new ArrayList(); + for (Object k : theme.keySet()) { + if (k instanceof String && ((String) k).indexOf("." + UNSELECTED) > -1 + && !((String) k).startsWith("@")) { + keys.add((String) k); + } + } + for (String key : keys) { + int at = key.indexOf("." + UNSELECTED); + String base = key.substring(0, at + 1) + key.substring(at + 1 + UNSELECTED.length()); + theme.put(base, theme.remove(key)); + Object binding = theme.remove("@cn1-bind:" + key); + if (binding != null) { + theme.put("@cn1-bind:" + base, binding); + } else { + theme.remove("@cn1-bind:" + base); + } + } + } + private void applyBoxSides(Hashtable theme) { for (int pass = 0; pass < 2; pass++) { for (Map.Entry entry : boxSides.entrySet()) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index afeb8916dc5..f02016adcb8 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1735,4 +1735,30 @@ private static int countKeysContaining(Hashtable theme, String part) { } return n; } + + @Test + void testUnselectedIsALayerOverTheBareRule() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile(":root{--c:#ff0000;}" + + " A{padding:1px} A.unselected{padding-top:5px}" + + " B{padding-top:2px} B.unselected{padding:5px} B{padding-left:3px}" + + " C{color:var(--c)} C.unselected{color:#00ff00}" + + " D.unselected{color:#222222} D{color:#333333}" + + " \\41 E{color:#123456}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("5,1,1,1", theme.get("A.padding")); + assertEquals("1,1,1,1", theme.get("A.sel#padding")); + assertEquals("5,5,5,5", theme.get("B.padding")); + assertEquals("2,0,3,0", theme.get("B.press#padding")); + assertEquals("00ff00", theme.get("C.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("@cn1-bind:C.fgColor")); + assertEquals("c", theme.get("@cn1-bind:C.sel#fgColor")); + assertEquals("ff0000", theme.get("C.dis#fgColor")); + assertEquals("222222", theme.get("D.fgColor")); + assertEquals("333333", theme.get("D.sel#fgColor")); + assertEquals("123456", theme.get("AE.fgColor")); + for (Object k : theme.keySet()) { + org.junit.jupiter.api.Assertions.assertTrue(k.toString().indexOf("uns#") < 0, k.toString()); + } + } } From f868e765140186f47b7b0bd332c35471ea4ba05c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:24:02 +0300 Subject: [PATCH 58/59] Baselines: calibrate linux binary size for the runtime CSS theme compiler's parity code Co-Authored-By: Claude Opus 5.5 --- scripts/flutter-bench/baseline/pr/6032.json | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 scripts/flutter-bench/baseline/pr/6032.json diff --git a/scripts/flutter-bench/baseline/pr/6032.json b/scripts/flutter-bench/baseline/pr/6032.json new file mode 100644 index 00000000000..2dceb5b43e4 --- /dev/null +++ b/scripts/flutter-bench/baseline/pr/6032.json @@ -0,0 +1,33 @@ +{ + "pr": 6032, + "rebaseline": { + "linux": { + "gallery": { + "code_bytes": { + "from": { + "value": 14523602 + }, + "reason": "CSSThemeCompiler (runtime CSS theme compiler in core) gained build-compiler parity: state inheritance, per-side insets, media prefixes, var() bindings, the CSS named-color table", + "runs": 1, + "value": 14592242 + }, + "install_bytes": { + "from": { + "value": 116342496 + }, + "reason": "CSSThemeCompiler (runtime CSS theme compiler in core) gained build-compiler parity: state inheritance, per-side insets, media prefixes, var() bindings, the CSS named-color table", + "runs": 1, + "value": 116468448 + }, + "wire_bytes": { + "from": { + "value": 84685068 + }, + "reason": "CSSThemeCompiler (runtime CSS theme compiler in core) gained build-compiler parity: state inheritance, per-side insets, media prefixes, var() bindings, the CSS named-color table", + "runs": 1, + "value": 84687971 + } + } + } + } +} From c53c970cb8062c541b2923227b8e4732d4b04c9a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:12:28 +0300 Subject: [PATCH 59/59] CSSThemeCompiler: use HashMap for the compile's own scratch maps The compiler runs on the EDT, so Hashtable's locking buys nothing. Only the theme map itself stays a Hashtable, since that is the Resources API type. Co-Authored-By: Claude Opus 5.5 --- .../codename1/ui/css/CSSThemeCompiler.java | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 5035069fa1e..a4938ba97dd 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -32,6 +32,7 @@ import com.codename1.ui.plaf.Style; import com.codename1.ui.util.MutableResource; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.Hashtable; import java.util.Map; @@ -96,16 +97,16 @@ public CSSSyntaxException(String message, Throwable cause) { private static final String DEFERRED_TEXT = "text"; /// The `deferred` table of the compile in progress; resolveVars reads which constants are literal. - private Hashtable deferredConstants = new Hashtable(); + private HashMap deferredConstants = new HashMap(); /// The `cn1-derive` each UIID (with its `$Dark` prefix) declared last in any state, in source /// order: the build-time compiler sets every such derive on the owning UIID, so the last one wins. - private Hashtable ownerDerives = new Hashtable(); + private HashMap ownerDerives = new HashMap(); /// The quoted source of each string constant this compile declared. A style property that /// uses one gets the string token, not its text: the build-time compiler keeps the token's /// type, so `color: var(--accent)` with `--accent: "#f00"` is not a color there either. - private Hashtable literalTokens = new Hashtable(); + private HashMap literalTokens = new HashMap(); /// `#Constants` entries this compile declared without the `--` prefix. The build-time /// compiler adds only `--name` declarations to its variables, so `var(--name)` does not see /// these. @@ -116,7 +117,7 @@ public CSSSyntaxException(String message, Throwable cause) { /// alias holds `var(--base)` itself, resolved where it is used, so a later `--base` changes /// it while the exported `@alias` keeps the value it had where it was declared. A later /// plain `#Constants` entry or `:root` property of the same name changes only its own side. - private Hashtable cssVars = new Hashtable(); + private HashMap cssVars = new HashMap(); /// "@name" of every entry a `#Constants` block of this compile exported. The build-time /// compiler adds a custom property to its constants only inside `#Constants`, so a later /// `:root { --name: ... }` changes what `var(--name)` reads and leaves `@name` alone. @@ -124,7 +125,7 @@ public CSSSyntaxException(String message, Throwable cause) { /// `padding-left` and its kin, per `uiid.state#padding` key, top/right/bottom/left; null is /// a side not declared. Applied once every rule is read, over the box shorthand. - private Hashtable boxSides = new Hashtable(); + private HashMap boxSides = new HashMap(); /// The UIIDs (`$Dark` ones included) a rule of this compile styled, in first-seen order. private ArrayList styledUiids = new ArrayList(); @@ -143,7 +144,7 @@ public CSSSyntaxException(String message, Throwable cause) { /// While set, resolveVars does not see a constant this compile declares unless it holds it: /// one declared further down is not defined yet. A constant only an earlier compile into the /// same theme declared is visible. - private Hashtable declaredOnly; + private HashMap declaredOnly; /// Every constant and custom property this compile declares, as "@name". private HashSet declaredThisCompile = new HashSet(); /// Set by resolveVars when it met a var() naming an undefined variable with no fallback. @@ -162,11 +163,11 @@ public void compile(String css, MutableResource resources, String themeName) { // Keyed by "@name"; only the declaration that wins for a name decides its entry. Every // constant this compile declares has an entry, so constants a previous compile left in // the same theme are never re-resolved without the typing they were declared with. - Hashtable deferred = new Hashtable(); + HashMap deferred = new HashMap(); deferredConstants = deferred; - literalTokens = new Hashtable(); + literalTokens = new HashMap(); plainConstants = new HashSet(); - cssVars = new Hashtable(); + cssVars = new HashMap(); exportedConstants = new HashSet(); declaredThisCompile = new HashSet(); for (Rule rule : rules) { @@ -197,10 +198,10 @@ public void compile(String css, MutableResource resources, String themeName) { compileConstants(rule.declarations, theme, deferred, true); } } - Hashtable fonts = new Hashtable(); - ownerDerives = new Hashtable(); + HashMap fonts = new HashMap(); + ownerDerives = new HashMap(); styledUiids = new ArrayList(); - boxSides = new Hashtable(); + boxSides = new HashMap(); for (Rule rule : rules) { if (rule.legacyConstants) { continue; @@ -248,7 +249,7 @@ public void compile(String css, MutableResource resources, String themeName) { /// `alias: var(--literal)` where `literal` is quoted resolves to that text, not to a color: /// such an alias, followed through any chain of plain aliases, is not reclassified. - private void markLiteralAliases(Hashtable theme, Hashtable deferred) { + private void markLiteralAliases(Hashtable theme, HashMap deferred) { ArrayList aliases = new ArrayList(); for (Map.Entry entry : deferred.entrySet()) { if (DEFERRED_VAR.equals(entry.getValue())) { @@ -375,7 +376,7 @@ private boolean hasHoverDefinition(Hashtable theme, String id) { return false; } - private void resolveThemeConstantVars(Hashtable theme, Hashtable deferred) { + private void resolveThemeConstantVars(Hashtable theme, HashMap deferred) { for (Map.Entry entry : deferred.entrySet()) { String key = entry.getKey(); // A quoted value is a string: `"var(--name)"` is text, not a reference. @@ -400,7 +401,7 @@ private void resolveThemeConstantVars(Hashtable theme, Hashtable } private void compileConstants(Declaration[] declarations, Hashtable theme, - Hashtable deferred, boolean legacy) { + HashMap deferred, boolean legacy) { for (Declaration declaration : declarations) { String declName = declaration.name; if (!legacy) { @@ -635,7 +636,7 @@ private String typedAlias(String key, String original, String resolved) { /// declared afterwards. Returns null, and the constant is left out, when a reference stays /// undefined without a fallback: the build-time compiler keeps such a var() as a function its /// constants emitter drops. - private String resolveAtDeclaration(Hashtable theme, String key, String value, Hashtable deferred) { + private String resolveAtDeclaration(Hashtable theme, String key, String value, HashMap deferred) { String own = deferred.remove(key); declaredOnly = deferred; unresolvedVar = false; @@ -877,8 +878,8 @@ private static String unquote(String value) { return out.toString(); } - private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Hashtable fonts, - Hashtable deferred) { + private void applyRule(Hashtable theme, MutableResource resources, Rule rule, HashMap fonts, + HashMap deferred) { if (":root".equals(rule.selector)) { // Unlike the build-time compiler, `:root` custom properties are also kept as `@name` // theme constants: that is this compiler's documented contract for `:root` (see the @@ -960,7 +961,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule, Ha } private boolean applyThemeConstantProperty(Hashtable theme, String property, String value, - Hashtable deferred) { + HashMap deferred) { if (!property.startsWith("--")) { return false; } @@ -992,7 +993,7 @@ private boolean applyThemeConstantProperty(Hashtable theme, String property, Str /// Records a `font-family` or `font-size` for one UIID state; `compileFonts` builds the fonts /// once every rule has been read, so the two may come from different rules. - private boolean applyFontProperty(Hashtable fonts, String keyPrefix, + private boolean applyFontProperty(HashMap fonts, String keyPrefix, String property, String value) { int slot; if ("font-family".equals(property)) { @@ -1030,7 +1031,7 @@ private boolean applyFontProperty(Hashtable fonts, String keyP /// from is not stored, so a later compile that only changes a `cn1-derive` cannot tell which /// earlier fonts depended on it. Compiling the stylesheet whole, as live reload and the GUI /// builder do, rebuilds every font. - private void compileFonts(Hashtable theme, Hashtable fonts) { + private void compileFonts(Hashtable theme, HashMap fonts) { // A font is a live platform object, so it can only be made with a running Display. // Without one -- a tool that only checks that a stylesheet compiles -- every // declaration has still been validated as it was read. @@ -1150,7 +1151,7 @@ private void inheritUnselectedIntoStates(Hashtable theme) { String[] baseStates = {"sel", "press", "dis"}; // What the stylesheet itself declared; a value this pass copies into a state is not a // declaration a derived UIID's state should stop at. - Hashtable declared = new Hashtable(theme); + HashMap declared = new HashMap(theme); // A derived UIID's states derive from the target's matching states, as the build-time // compiler writes them (`Child.sel#derive=Base.sel`): UIManager reads only the state's // own derive key, so without it a selected Child would start from the default style. @@ -1210,7 +1211,7 @@ private static String boundVariable(String value) { return name.substring(2); } - private boolean derivedStateDeclares(Hashtable theme, String uiid, String state, String key) { + private boolean derivedStateDeclares(Map theme, String uiid, String state, String key) { boolean dark = uiid.startsWith("$Dark"); ArrayList seen = new ArrayList(); String id = uiid;