From 89372ee9af439ff32f9df98cb7270e36cf925ef4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 28 Mar 2026 09:47:59 +0300 Subject: [PATCH 1/3] Add dark-mode UIID style fallback with inheritance --- .../src/com/codename1/ui/plaf/UIManager.java | 35 +++++++++ .../ui/plaf/UIManagerDarkModeStyleTest.java | 77 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerDarkModeStyleTest.java diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index f058d621a3..5f02b177ea 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -1942,7 +1942,15 @@ Style createStyle(String id, String prefix, boolean selected) { if (prefix != null && prefix.length() > 0) { id += prefix; } + String requestedId = id; + if (shouldUseDarkStyle(id)) { + id = "$Dark" + id; + } + String baseStyle = (String) themeProps.get(id + "derive"); + if (baseStyle == null && id.startsWith("$Dark")) { + baseStyle = toDeriveStyleName(requestedId); + } if (baseStyle != null) { if (baseStyle.indexOf('.') > -1 && baseStyle.indexOf('#') < 0) { baseStyle += "#"; @@ -2128,6 +2136,33 @@ Style createStyle(String id, String prefix, boolean selected) { return style; } + private boolean shouldUseDarkStyle(String id) { + if (themeProps == null || id == null || id.length() == 0 || id.startsWith("$Dark")) { + return false; + } + Boolean darkMode = CN.isDarkMode(); + return darkMode != null && darkMode.booleanValue() && hasStyleDefinition("$Dark" + id); + } + + private boolean hasStyleDefinition(String styleId) { + for (String key : themeProps.keySet()) { + if (key.startsWith(styleId)) { + return true; + } + } + return false; + } + + private String toDeriveStyleName(String styleId) { + if (styleId.endsWith(".")) { + return styleId.substring(0, styleId.length() - 1); + } + if (styleId.endsWith("#")) { + return styleId.substring(0, styleId.length() - 1); + } + return styleId; + } + /// This method is used to parse the margin and the padding /// /// #### Parameters diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerDarkModeStyleTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerDarkModeStyleTest.java new file mode 100644 index 0000000000..51268cc211 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerDarkModeStyleTest.java @@ -0,0 +1,77 @@ +package com.codename1.ui.plaf; + +import com.codename1.junit.UITestBase; +import java.util.Hashtable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class UIManagerDarkModeStyleTest extends UITestBase { + + @AfterEach + public void resetDarkMode() { + display.setDarkMode(null); + } + + @Test + public void testDarkStyleIsUsedAndInheritsBaseStyle() { + UIManager manager = UIManager.getInstance(); + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "112233"); + theme.put("Button.bgColor", "445566"); + theme.put("$DarkButton.bgColor", "000000"); + manager.setThemeProps(theme); + + display.setDarkMode(Boolean.TRUE); + Style style = manager.getComponentStyle("Button"); + + assertEquals(0x112233, style.getFgColor()); + assertEquals(0x000000, style.getBgColor()); + } + + @Test + public void testDarkStyleSelectedInheritsSelectedBaseStyle() { + UIManager manager = UIManager.getInstance(); + Hashtable theme = new Hashtable(); + theme.put("Button.sel#fgColor", "00ff00"); + theme.put("$DarkButton.sel#bgColor", "101010"); + manager.setThemeProps(theme); + + display.setDarkMode(Boolean.TRUE); + Style style = manager.getComponentSelectedStyle("Button"); + + assertEquals(0x00ff00, style.getFgColor()); + assertEquals(0x101010, style.getBgColor()); + } + + @Test + public void testDarkStyleCanOverrideImplicitInheritanceWithExplicitDerive() { + UIManager manager = UIManager.getInstance(); + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "ff0000"); + theme.put("Label.fgColor", "0000ff"); + theme.put("$DarkButton.derive", "Label"); + theme.put("$DarkButton.bgColor", "202020"); + manager.setThemeProps(theme); + + display.setDarkMode(Boolean.TRUE); + Style style = manager.getComponentStyle("Button"); + + assertEquals(0x0000ff, style.getFgColor()); + assertEquals(0x202020, style.getBgColor()); + } + + @Test + public void testFallsBackToRegularStyleWhenNoDarkStyleExists() { + UIManager manager = UIManager.getInstance(); + Hashtable theme = new Hashtable(); + theme.put("Button.fgColor", "aabbcc"); + manager.setThemeProps(theme); + + display.setDarkMode(Boolean.TRUE); + Style style = manager.getComponentStyle("Button"); + + assertEquals(0xaabbcc, style.getFgColor()); + } +} From 36bfc7a9ec93a67175f54af368f44960ec15c685 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:55:29 +0300 Subject: [PATCH 2/3] Fix dark style inheritance recursion in UIManager --- .../src/com/codename1/ui/plaf/UIManager.java | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index 5f02b177ea..9b2a6feb6f 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -1937,21 +1937,27 @@ Style parseStyle(Resources theme, String id, String prefix, String baseStyle, bo } Style createStyle(String id, String prefix, boolean selected) { + return createStyle(id, prefix, selected, true); + } + + private Style createStyle(String id, String prefix, boolean selected, boolean allowDarkStyle) { Style style; String originalId = id; if (prefix != null && prefix.length() > 0) { id += prefix; } - String requestedId = id; - if (shouldUseDarkStyle(id)) { + boolean useDarkStyle = allowDarkStyle && shouldUseDarkStyle(id); + if (useDarkStyle) { id = "$Dark" + id; } String baseStyle = (String) themeProps.get(id + "derive"); - if (baseStyle == null && id.startsWith("$Dark")) { - baseStyle = toDeriveStyleName(requestedId); + if (baseStyle == null && useDarkStyle) { + style = new Style(createStyle(originalId, prefix, selected, false)); + } else { + style = null; } - if (baseStyle != null) { + if (style == null && baseStyle != null) { if (baseStyle.indexOf('.') > -1 && baseStyle.indexOf('#') < 0) { baseStyle += "#"; } @@ -1972,7 +1978,7 @@ Style createStyle(String id, String prefix, boolean selected) { style = new Style(defaultStyle); } } - } else { + } else if (style == null) { if (selected) { style = new Style(defaultSelectedStyle); } else { @@ -2153,16 +2159,6 @@ private boolean hasStyleDefinition(String styleId) { return false; } - private String toDeriveStyleName(String styleId) { - if (styleId.endsWith(".")) { - return styleId.substring(0, styleId.length() - 1); - } - if (styleId.endsWith("#")) { - return styleId.substring(0, styleId.length() - 1); - } - return styleId; - } - /// This method is used to parse the margin and the padding /// /// #### Parameters From e84651d81b69c1aba243b26f3dd55119ff57a282 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:33:48 +0300 Subject: [PATCH 3/3] Compile prefers-color-scheme dark CSS into UIIDs --- .../codename1/ui/css/CSSThemeCompiler.java | 104 ++++++++++++---- .../com/codename1/designer/css/CSSTheme.java | 113 ++++++++++++++++++ .../css/CSSDarkModeMediaQueryTest.java | 57 +++++++++ docs/developer-guide/css.asciidoc | 22 +++- .../ui/css/CSSThemeCompilerTest.java | 23 ++++ 5 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 CodenameOneDesigner/test/com/codename1/designer/css/CSSDarkModeMediaQueryTest.java diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 5756f3a8c9..39d3289ae3 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -57,8 +57,9 @@ public void compile(String css, MutableResource resources, String themeName) { theme = new Hashtable(); } - compileConstants(css, theme); - Rule[] rules = parseRules(css); + String strippedCss = stripComments(css); + compileConstants(strippedCss, theme); + Rule[] rules = parseRulesWithMedia(strippedCss); for (Rule rule : rules) { applyRule(theme, resources, rule); } @@ -81,20 +82,19 @@ private void resolveThemeConstantVars(Hashtable theme) { } private void compileConstants(String css, Hashtable theme) { - String stripped = stripComments(css); - int constantsStart = stripped.indexOf("@constants"); + int constantsStart = css.indexOf("@constants"); if (constantsStart < 0) { return; } - int open = stripped.indexOf('{', constantsStart); + int open = css.indexOf('{', constantsStart); if (open < 0) { return; } - int close = stripped.indexOf('}', open + 1); + int close = css.indexOf('}', open + 1); if (close <= open) { throw new CSSSyntaxException("Unterminated @constants block"); } - Declaration[] declarations = parseDeclarations(stripped.substring(open + 1, close)); + Declaration[] declarations = parseDeclarations(css.substring(open + 1, close)); for (Declaration declaration : declarations) { theme.put("@" + declaration.property, declaration.value); } @@ -500,39 +500,49 @@ private String scalar(String value) { return out; } - private Rule[] parseRules(String css) { - String stripped = stripComments(css); + private Rule[] parseRulesWithMedia(String css) { ArrayList out = new ArrayList(); + parseRulesInto(css, out, false); + return out.toArray(new Rule[out.size()]); + } + + private void parseRulesInto(String css, ArrayList out, boolean darkContext) { int pos = 0; - while (pos < stripped.length()) { - while (pos < stripped.length() && Character.isWhitespace(stripped.charAt(pos))) { + int len = css.length(); + while (pos < len) { + while (pos < len && Character.isWhitespace(css.charAt(pos))) { pos++; } - if (pos >= stripped.length()) { + if (pos >= len) { break; } - int open = stripped.indexOf('{', pos); + int open = css.indexOf('{', pos); if (open < 0) { - throw new CSSSyntaxException("Missing '{' in CSS rule near: " + stripped.substring(pos)); + throw new CSSSyntaxException("Missing '{' in CSS rule near: " + css.substring(pos)); } - int close = stripped.indexOf('}', open + 1); + + String selectors = css.substring(pos, open).trim(); + int close = findMatchingBrace(css, open); if (close < 0) { - throw new CSSSyntaxException("Missing '}' for CSS rule: " + stripped.substring(pos, open).trim()); - } - if (stripped.indexOf('{', open + 1) > -1 && stripped.indexOf('{', open + 1) < close) { - throw new CSSSyntaxException("Nested '{' is not supported in CSS block: " + stripped.substring(pos, open).trim()); + throw new CSSSyntaxException("Missing '}' for CSS rule: " + selectors); } - String selectors = stripped.substring(pos, open).trim(); if (selectors.startsWith("@constants")) { pos = close + 1; continue; } + if (selectors.startsWith("@media")) { + String mediaQuery = selectors.substring("@media".length()).trim(); + boolean nextDarkContext = darkContext || isDarkModeMediaQuery(mediaQuery); + parseRulesInto(css.substring(open + 1, close), out, nextDarkContext); + pos = close + 1; + continue; + } if (selectors.length() == 0) { throw new CSSSyntaxException("Missing selector before '{'"); } - String body = stripped.substring(open + 1, close).trim(); + String body = css.substring(open + 1, close).trim(); Declaration[] declarations = parseDeclarations(body); String[] selectorsList = splitOnChar(selectors, ','); for (String selectorEntry : selectorsList) { @@ -541,14 +551,62 @@ private Rule[] parseRules(String css) { throw new CSSSyntaxException("Empty selector in selector list: " + selectors); } Rule rule = new Rule(); - rule.selector = selector; + rule.selector = darkContext ? toDarkSelector(selector) : selector; rule.declarations = declarations; out.add(rule); } pos = close + 1; } - return out.toArray(new Rule[out.size()]); + } + + private int findMatchingBrace(String css, int openPos) { + int depth = 0; + int len = css.length(); + for (int i = openPos; i < len; i++) { + char c = css.charAt(i); + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; + } + + private boolean isDarkModeMediaQuery(String mediaQuery) { + String normalized = mediaQuery == null ? "" : mediaQuery.toLowerCase(); + return normalized.indexOf("prefers-color-scheme") > -1 && normalized.indexOf("dark") > -1; + } + + private String toDarkSelector(String selector) { + String trimmed = selector == null ? "" : selector.trim(); + if (trimmed.length() == 0 || ":root".equals(trimmed)) { + return trimmed; + } + if (trimmed.startsWith("$Dark")) { + return trimmed; + } + + int pseudoPos = trimmed.indexOf(':'); + int classStatePos = trimmed.indexOf('.'); + int statePos = -1; + if (pseudoPos > -1 && classStatePos > -1) { + statePos = Math.min(pseudoPos, classStatePos); + } else if (pseudoPos > -1) { + statePos = pseudoPos; + } else if (classStatePos > -1) { + statePos = classStatePos; + } + String baseSelector = statePos > -1 ? trimmed.substring(0, statePos) : trimmed; + String stateSelector = statePos > -1 ? trimmed.substring(statePos) : ""; + if ("*".equals(baseSelector) || baseSelector.length() == 0) { + baseSelector = "Component"; + } + return "$Dark" + baseSelector + stateSelector; } private String stripComments(String css) { diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java b/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java index 4d868ae9ce..341e20e7ac 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java +++ b/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java @@ -6941,6 +6941,117 @@ private static List getMediaPrefixes(SACMediaList l) { } return out; } + + private static String transformDarkModeMediaQueries(String css) { + StringBuilder out = new StringBuilder(); + int len = css.length(); + int pos = 0; + while (pos < len) { + int mediaPos = css.indexOf("@media", pos); + if (mediaPos < 0) { + out.append(css.substring(pos)); + break; + } + out.append(css.substring(pos, mediaPos)); + int open = css.indexOf('{', mediaPos); + if (open < 0) { + out.append(css.substring(mediaPos)); + break; + } + String query = css.substring(mediaPos + "@media".length(), open).trim().toLowerCase(); + int close = findMatchingBrace(css, open); + if (close < 0) { + out.append(css.substring(mediaPos)); + break; + } + String block = css.substring(open + 1, close); + if (query.indexOf("prefers-color-scheme") > -1 && query.indexOf("dark") > -1) { + out.append(prefixSelectorsWithDark(block)); + } else { + out.append(css.substring(mediaPos, close + 1)); + } + pos = close + 1; + } + return out.toString(); + } + + private static String prefixSelectorsWithDark(String block) { + StringBuilder out = new StringBuilder(); + int len = block.length(); + int pos = 0; + while (pos < len) { + while (pos < len && Character.isWhitespace(block.charAt(pos))) { + out.append(block.charAt(pos)); + pos++; + } + if (pos >= len) { + break; + } + int open = block.indexOf('{', pos); + if (open < 0) { + out.append(block.substring(pos)); + break; + } + int close = findMatchingBrace(block, open); + if (close < 0) { + out.append(block.substring(pos)); + break; + } + String selectors = block.substring(pos, open); + out.append(toDarkSelectors(selectors)).append('{').append(block.substring(open + 1, close)).append('}'); + pos = close + 1; + } + return out.toString(); + } + + private static String toDarkSelectors(String selectors) { + StringBuilder out = new StringBuilder(); + String[] parts = selectors.split(","); + for (int i = 0; i < parts.length; i++) { + if (i > 0) { + out.append(','); + } + String selector = parts[i].trim(); + if (selector.length() == 0 || selector.startsWith("$Dark")) { + out.append(parts[i]); + continue; + } + int pseudoPos = selector.indexOf(':'); + int classPos = selector.indexOf('.'); + int statePos = -1; + if (pseudoPos > -1 && classPos > -1) { + statePos = Math.min(pseudoPos, classPos); + } else if (pseudoPos > -1) { + statePos = pseudoPos; + } else if (classPos > -1) { + statePos = classPos; + } + String base = statePos > -1 ? selector.substring(0, statePos) : selector; + String suffix = statePos > -1 ? selector.substring(statePos) : ""; + if ("*".equals(base) || base.length() == 0) { + base = "Component"; + } + out.append("$Dark").append(base).append(suffix); + } + return out.toString(); + } + + private static int findMatchingBrace(String css, int openPos) { + int depth = 0; + int len = css.length(); + for (int i = openPos; i < len; i++) { + char c = css.charAt(i); + if (c == '{') { + depth++; + } else if (c == '}') { + depth--; + if (depth == 0) { + return i; + } + } + } + return -1; + } public static CSSTheme load(URL uri) throws IOException { try { @@ -6949,6 +7060,8 @@ public static CSSTheme load(URL uri) throws IOException { InputStream stream = uri.openStream(); String stringContents = Util.readToString(stream); + stringContents = transformDarkModeMediaQueries(stringContents); + // The flute parser chokes on properties beginning with -- so we need to replace these with cn1 prefix // for CSS variable support. stringContents = stringContents.replaceAll("([\\(\\W])(--[a-zA-Z0-9\\-]+)", "$1cn1$2"); diff --git a/CodenameOneDesigner/test/com/codename1/designer/css/CSSDarkModeMediaQueryTest.java b/CodenameOneDesigner/test/com/codename1/designer/css/CSSDarkModeMediaQueryTest.java new file mode 100644 index 0000000000..cbc4b3ced0 --- /dev/null +++ b/CodenameOneDesigner/test/com/codename1/designer/css/CSSDarkModeMediaQueryTest.java @@ -0,0 +1,57 @@ +package com.codename1.designer.css; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Hashtable; + +/** + * Regression tests for dark-mode media query compilation into $Dark UIIDs. + */ +public class CSSDarkModeMediaQueryTest { + + public static void main(String[] args) throws Exception { + testDarkMediaCompilesToDarkUiids(); + } + + private static void testDarkMediaCompilesToDarkUiids() throws Exception { + Path cssFile = Files.createTempFile("cn1-dark-media", ".css"); + Path resFile = Files.createTempFile("cn1-dark-media", ".res"); + try { + String css = "Button { color: #111111; }" + + "@media (prefers-color-scheme: dark) {" + + " Button { color: #eeeeee; background-color: #000000; }" + + " Button.selected { color: #ff0000; }" + + "}"; + Files.write(cssFile, css.getBytes(StandardCharsets.UTF_8)); + + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = resFile.toFile(); + theme.updateResources(); + + Hashtable themeProps = theme.res.getTheme("Theme"); + assertEquals("111111", themeProps.get("Button.fgColor"), "Base style fgColor"); + assertEquals("eeeeee", themeProps.get("$DarkButton.fgColor"), "Dark style fgColor"); + assertEquals("000000", themeProps.get("$DarkButton.bgColor"), "Dark style bgColor"); + assertEquals("255", themeProps.get("$DarkButton.transparency"), "Dark style transparency"); + assertEquals("ff0000", themeProps.get("$DarkButton.sel#fgColor"), "Dark selected fgColor"); + } 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); + } + } +} diff --git a/docs/developer-guide/css.asciidoc b/docs/developer-guide/css.asciidoc index b96b986466..3959bba2c5 100644 --- a/docs/developer-guide/css.asciidoc +++ b/docs/developer-guide/css.asciidoc @@ -1174,6 +1174,27 @@ You can use media queries to target styles to specific platforms, devices, and d . `density-xxx` - Target a specific device density. E.g. `density-very-low`, `density-low`, `density-medium`, `density-high`, `density-very-high`, `density-hd`, `density-2hd`, and `density-560`. . `device-xxx` - Target a specific device type. E.g. `device-desktop`, `device-tablet`, `device-phone`. +In addition to the Codename One specific media tokens above, the CSS compilers also recognize standard dark-mode media queries using `prefers-color-scheme: dark`. Rules inside these blocks are compiled into `$Dark` UIIDs automatically (e.g. `Button` becomes `$DarkButton`, `Button.selected` becomes `$DarkButton.selected`). + +.Example: Dark-mode overrides with standard CSS media query syntax +[source,css] +---- +Button { + color: #222222; +} + +@media (prefers-color-scheme: dark) { + Button { + color: #f0f0f0; + background-color: #000000; + } + + Button.selected { + color: #ff0000; + } +} +---- + .Example: Different font colors on Android and iOS. On Android, labels will appear green. On iOS, they will appear red. On all other platforms, they will appear black. [source,css] ---- @@ -1334,4 +1355,3 @@ IMPORTANT: All matching `font-scale` constants will be applied to the styles. I - 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 f06d92ee1e..345c4c1b86 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 @@ -75,4 +75,27 @@ public void testThrowsOnMalformedCss() { ); } + @Test + public void testCompilesDarkModeMediaQueriesToDarkUiids() { + CSSThemeCompiler compiler = new CSSThemeCompiler(); + MutableResource resource = new MutableResource(); + + compiler.compile( + "Button{color:#111111;}" + + "@media (prefers-color-scheme: dark){" + + "Button{color:#eeeeee;background-color:#000000;}" + + "Button:pressed{color:#ff0000;}" + + "}", + resource, + "Theme" + ); + + Hashtable theme = resource.getTheme("Theme"); + assertEquals("111111", theme.get("Button.fgColor")); + assertEquals("eeeeee", theme.get("$DarkButton.fgColor")); + assertEquals("000000", theme.get("$DarkButton.bgColor")); + assertEquals("255", theme.get("$DarkButton.transparency")); + assertEquals("ff0000", theme.get("$DarkButton.press#fgColor")); + } + }