diff --git a/.github/workflows/developer-guide-docs.yml b/.github/workflows/developer-guide-docs.yml index ee9c8763416..8cefcec3378 100644 --- a/.github/workflows/developer-guide-docs.yml +++ b/.github/workflows/developer-guide-docs.yml @@ -427,7 +427,17 @@ jobs: - name: Sync Vale styles run: | set -euo pipefail - vale sync --config docs/developer-guide/.vale.ini + # Package downloads can return transient GitHub release HTTP 5xx errors. + # Keep the lint gate strict if all three sync attempts fail. + for attempt in 1 2 3; do + if vale sync --config docs/developer-guide/.vale.ini; then + break + fi + if [ "$attempt" -eq 3 ]; then + exit 1 + fi + sleep "$((attempt * 5))" + done - name: Run Vale style linter run: | diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 5188948a974..ffec2d464df 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -13182,6 +13182,19 @@ public boolean isAccessibilityTreeUpdateRequired() { return isAccessibilityTreeSupported(); } + /// Internal port hook. Return a fresh snapshot; unsupported ports keep bundled defaults. + public NativeThemeSettings getNativeThemeSettings() { + return new NativeThemeSettings(); + } + + /// Loads the selected OS family using the original native alias's weight/style. + /// A family of "native:" means the platform's existing native aliases. + public Object loadNativeThemeFont(String family, String template, float size, int style) { + String name = "native:".equals(family) ? template : family; + Object font = loadTrueTypeFont(name, null); + return font == null ? null : deriveTrueTypeFont(font, size, style); + } + /// Returns true if the user has selected larger type fonts in the system settings. /// Default implementation returns false. /// diff --git a/CodenameOne/src/com/codename1/impl/NativeThemeSettings.java b/CodenameOne/src/com/codename1/impl/NativeThemeSettings.java new file mode 100644 index 00000000000..ca1c40cab8a --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/NativeThemeSettings.java @@ -0,0 +1,127 @@ +/* + * 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.impl; + +import java.util.HashMap; +import java.util.Map; +import com.codename1.util.StringUtil; + +/// Internal port snapshot of OS theme defaults. Font size is in CN1 device pixels, +/// before accessibility scaling. Missing values mean unsupported, never a guessed value. +public final class NativeThemeSettings { + private final Map colors = new HashMap(); + private String fontFamily; + private float fontSize; + + public NativeThemeSettings color(String token, int rgb) { + colors.put(token, Integer.toHexString(0x1000000 | (rgb & 0xffffff)).substring(1)); + return this; + } + + /// Compares only the categories the application opted into. + public NativeThemeSettings forInheritance(boolean inheritColors, boolean inheritFonts) { + NativeThemeSettings result = new NativeThemeSettings(); + if (inheritColors) { + result.colors.putAll(colors); + } + if (inheritFonts) { + result.font(fontFamily, fontSize); + } + return result; + } + + public NativeThemeSettings copy() { + NativeThemeSettings result = new NativeThemeSettings(); + result.colors.putAll(colors); + result.fontFamily = fontFamily; + result.fontSize = fontSize; + return result; + } + + public String getColor(String token) { + return colors.get(token); + } + + public NativeThemeSettings font(String family, float pixels) { + if (family != null && family.length() > 0 && pixels > 0 && pixels < 10000) { + fontFamily = family; + fontSize = pixels; + } + return this; + } + + public String getFontFamily() { + return fontFamily; + } + + public float getFontSize() { + return fontSize; + } + + /// Parses the native bridge's newline-separated key=value snapshot. Unknown + /// and malformed entries are ignored so partially supported settings still work. + public static NativeThemeSettings parse(String encoded) { + NativeThemeSettings result = new NativeThemeSettings(); + if (encoded == null) { + return result; + } + String family = null; + float size = 0; + for (String line : StringUtil.tokenize(encoded, '\n')) { + int split = line.indexOf('='); + if (split <= 0) { + continue; + } + String key = line.substring(0, split); + String value = line.substring(split + 1); + try { + if ("fontFamily".equals(key)) { + family = value; + } else if ("fontSize".equals(key)) { + size = Float.parseFloat(value); + } else if (value.length() == 6) { + result.color(key, Integer.parseInt(value, 16)); + } + } catch (IllegalArgumentException ignored) { + // An unavailable or invalid setting keeps the bundled fallback. + } + } + return result.font(family, size); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof NativeThemeSettings)) { + return false; + } + NativeThemeSettings that = (NativeThemeSettings) other; + return colors.equals(that.colors) && Float.floatToIntBits(fontSize) == Float.floatToIntBits(that.fontSize) + && (fontFamily == null ? that.fontFamily == null : fontFamily.equals(that.fontFamily)); + } + + @Override + public int hashCode() { + return colors.hashCode() * 31 + (fontFamily == null ? 0 : fontFamily.hashCode()) + + Float.floatToIntBits(fontSize); + } +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 6a86336ed94..de239447359 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -1025,6 +1025,35 @@ public void setDarkMode(Boolean darkMode) { this.darkMode = darkMode; } + /// Internal platform snapshot used by native-theme inheritance. + public com.codename1.impl.NativeThemeSettings getNativeThemeSettings() { + return impl.getNativeThemeSettings(); + } + + private boolean nativeThemeRefreshPending; + + /// Port notification for OS theme changes and resume. Coalesces onto the CN1 EDT. + public void nativeThemeSettingsChanged() { + if (!isInitialized()) { + return; + } + synchronized (this) { + if (nativeThemeRefreshPending) { + return; + } + nativeThemeRefreshPending = true; + } + callSerially(new Runnable() { + @Override + public void run() { + synchronized (Display.this) { + nativeThemeRefreshPending = false; + } + UIManager.getInstance().refreshNativeThemeSettings(); + } + }); + } + /// Returns true if the user has selected larger type fonts in the system settings. /// /// #### Returns diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java index a9fcfbe75b7..e1edbae3219 100644 --- a/CodenameOne/src/com/codename1/ui/Font.java +++ b/CodenameOne/src/com/codename1/ui/Font.java @@ -154,6 +154,29 @@ public class Font extends CN { private static final Hashtable bitmapCache = new Hashtable(); private static final HashMap derivedFontCache = new HashMap(); + /// Internal theme hook: inherit only native UI fonts, never icon or bundled fonts. + public Font deriveNativeThemeFont(String family, float size) { + if (fontUniqueId == null || !fontUniqueId.startsWith("native:") || family == null) { + return this; + } + int style = getStyle(); + if (fontUniqueId.indexOf("Bold") >= 0 || fontUniqueId.indexOf("Black") >= 0) { + style |= STYLE_BOLD; + } + if (fontUniqueId.indexOf("Italic") >= 0) { + style |= STYLE_ITALIC; + } + Object nativeFont = Display.impl.loadNativeThemeFont(family, fontUniqueId, size, style); + if (nativeFont == null) { + return this; + } + Font result = new Font(nativeFont); + result.ttf = true; + result.pixelSize = size; + result.fontUniqueId = "os:" + family + ":" + fontUniqueId; + return result; + } + /// Clears the cache of derived TrueType fonts. Called when the theme changes so /// that fonts whose platform rendering depends on theme constants (e.g. a native /// theme's text letter spacing) are re-derived against the freshly-installed diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index e1bb400dd76..bd4f443c314 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -87,6 +87,22 @@ public class UIManager { /// This costs almost nothing in practice: no framework or port code calls /// those setters at all, they are purely an application-facing API. private boolean programmaticStyleInstalled; + private final Map programmaticStyles = new HashMap(); + private final Map programmaticSelectedStyles = new HashMap(); + private boolean refreshingNativeTheme; + + private void clearStyleCaches() { + styles.clear(); + selectedStyles.clear(); + prefixedStyles.clear(); + if (!refreshingNativeTheme) { + programmaticStyles.clear(); + programmaticSelectedStyles.clear(); + } + styles.putAll(programmaticStyles); + selectedStyles.putAll(programmaticSelectedStyles); + programmaticStyleInstalled = !programmaticStyles.isEmpty() || !programmaticSelectedStyles.isEmpty(); + } /// Style-lookup keys derived from a UIID, memoised. /// @@ -170,6 +186,208 @@ public static int getThemeGeneration() { private Style defaultStyle = new Style(); private Style defaultSelectedStyle = new Style(); private boolean useLargerTextScale; + private boolean useNativeColors; + private boolean useNativeFonts; + private boolean refreshingTheme; + private final java.util.Set nativeFontKeys = new java.util.HashSet(); + private final java.util.Set nativeColorKeys = new java.util.HashSet(); + private final java.util.Set nativeBorderKeys = new java.util.HashSet(); + private final java.util.Set applicationConstants = new java.util.HashSet(); + private final Map nativeOriginals = new HashMap(); + private final Map nativeDerived = new HashMap(); + private final Map nativeBorderOriginals = new HashMap(); + private final Map nativeBorderDerived = new HashMap(); + private float nativeBaseFontSize; + private com.codename1.impl.NativeThemeSettings nativeSettings; + private float nativeSettingsTextScale = 1f; + private Boolean nativeSettingsDark; + + /// Whether native-theme colors inherit available OS settings. Defaults to false. + public boolean isUseNativeColors() { + return useNativeColors; + } + + /// Opts into OS colors. Call refreshTheme() to apply to an already loaded theme; + /// an explicit useNativeColorsBool theme constant takes precedence on rebuild. + public void setUseNativeColors(boolean enabled) { + useNativeColors = enabled; + } + + /// Whether native-theme typography inherits the OS family and normal base size. + public boolean isUseNativeFonts() { + return useNativeFonts; + } + + /// Opts into OS typography, independently of larger-text accessibility scaling. + /// Call refreshTheme() to apply; useNativeFontsBool takes precedence on rebuild. + public void setUseNativeFonts(boolean enabled) { + useNativeFonts = enabled; + } + + /// Internal EDT entry point for coalesced platform settings notifications. + public void refreshNativeThemeSettings() { + Display display = Display.getInstance(); + com.codename1.impl.NativeThemeSettings next = getEffectiveNativeThemeSettings(display); + float scale = getEffectiveLargerTextScale(); + Boolean dark = display.isDarkMode(); + if (next.equals(nativeSettings) + && Float.floatToIntBits(scale) == Float.floatToIntBits(nativeSettingsTextScale) + && (dark == null ? nativeSettingsDark == null : dark.equals(nativeSettingsDark))) { + return; + } + refreshingNativeTheme = true; + try { + refreshTheme(); + } finally { + refreshingNativeTheme = false; + } + com.codename1.ui.Form form = display.getCurrent(); + if (form != null) { + // Invalidate the tree once. Form.refreshTheme performs its root layout. + form.setShouldCalcPreferredSize(true); + form.refreshTheme(true); + form.repaint(); + } + for (com.codename1.ui.Window window : com.codename1.ui.Desktop.getInstance().getWindows()) { + window.setShouldCalcPreferredSize(true); + window.refreshTheme(true); + window.revalidateLater(); + window.repaint(); + } + } + + private com.codename1.impl.NativeThemeSettings getEffectiveNativeThemeSettings(Display display) { + // Appearance follows the OS independently; do not query optional palette/font + // settings just to detect a light/dark change in a default-configured app. + if (useNativeColors || useNativeFonts) { + return display.getNativeThemeSettings().forInheritance(useNativeColors, useNativeFonts); + } + return new com.codename1.impl.NativeThemeSettings(); + } + + private void restoreNativeThemeValues() { + restoreNativeBorderColors(); + for (Map.Entry entry : nativeOriginals.entrySet()) { + // Ownership is by identity: an equal application replacement must survive. + if (themeProps.get(entry.getKey()) == nativeDerived.get(entry.getKey())) { //NOPMD CompareObjectsWithEquals + themeProps.put(entry.getKey(), entry.getValue()); + } + } + nativeOriginals.clear(); + nativeDerived.clear(); + } + + private void restoreNativeBorderColors() { + for (Map.Entry entry : nativeBorderOriginals.entrySet()) { + RoundBorder border = entry.getKey(); + // Preserve an application mutation made after the inherited tint. + if (border.getColor() == nativeBorderDerived.get(border).intValue()) { + border.color(entry.getValue().intValue()); + } + } + nativeBorderOriginals.clear(); + nativeBorderDerived.clear(); + } + + private void recordNativeThemeOwnership(Hashtable incoming) { + if (refreshingTheme) { + return; + } + boolean nativeLayer = "true".equals(incoming.get("@nativeThemeDefaultsBool")); + if (nativeLayer) { + Object base = incoming.get("Label.font"); + if (!(base instanceof Font)) { + base = incoming.get("font"); + } + if (base instanceof Font) { + nativeBaseFontSize = nativeFontSize((Font) base); + } + } + for (Object item : incoming.keySet()) { + String key = (String) item; + if (key.startsWith("@")) { + if (!nativeLayer) { + applicationConstants.add(key.substring(1)); + } + } else if (nativeLayer) { + if (incoming.get(key) instanceof Font) { + nativeFontKeys.add(key); + } + if (key.endsWith("border")) { + nativeBorderKeys.add(key); + } + if (incoming.containsKey("@cn1-bind:" + key)) { + nativeColorKeys.add(key); + } + } else { + nativeFontKeys.remove(key); + nativeColorKeys.remove(key); + nativeBorderKeys.remove(key); + } + } + } + + private void applyNativeThemeSettings() { + Boolean colors = isThemeConstant("useNativeColorsBool"); + Boolean fonts = isThemeConstant("useNativeFontsBool"); + if (colors != null) { + useNativeColors = colors.booleanValue(); + } + if (fonts != null) { + useNativeFonts = fonts.booleanValue(); + } + Display display = Display.getInstance(); + nativeSettings = getEffectiveNativeThemeSettings(display); + nativeSettingsTextScale = getEffectiveLargerTextScale(); + nativeSettingsDark = display.isDarkMode(); + if (useNativeColors) { + for (String key : nativeColorKeys) { + Object binding = themeConstants.get("cn1-bind:" + key); + if (!(binding instanceof String) || applicationConstants.contains((String) binding)) { + continue; + } + String value = nativeSettings.getColor((String) binding); + if (value != null && themeProps.containsKey(key)) { + rememberNativeValue(key, value); + syncBoundRoundBorderColor(key, value, true); + } + } + } + if (useNativeFonts && nativeBaseFontSize > 0 && nativeSettings.getFontFamily() != null) { + for (String key : nativeFontKeys) { + Object value = themeProps.get(key); + if (!(value instanceof Font)) { + continue; + } + Font original = (Font) value; + float size = nativeFontSize(original); + if (size <= 0) { + continue; + } + try { + Font inherited = original.deriveNativeThemeFont(nativeSettings.getFontFamily(), + size * nativeSettings.getFontSize() / nativeBaseFontSize); + // The original instance signals that the font is not eligible for inheritance. + if (inherited != original) { //NOPMD CompareObjectsWithEquals + rememberNativeValue(key, inherited); + } + } catch (RuntimeException ex) { + Log.e(ex); + } + } + } + } + + private float nativeFontSize(Font font) { + return font.getPixelSize() > 0 ? font.getPixelSize() : font.getHeight(); + } + + private void rememberNativeValue(String key, Object value) { + nativeOriginals.put(key, themeProps.get(key)); + nativeDerived.put(key, value); + themeProps.put(key, value); + } + /// Tracks the original (unscaled) Font we replaced in themeProps when /// [#applyLargerTextScaleToThemeFonts] last ran. Without this, each scale /// change derives from the previously-scaled font and compounds, so going @@ -456,6 +674,7 @@ public void setComponentStyle(String id, Style style) { } styles.put(id, style); + programmaticStyles.put(id, style); // A prefixed style can derive from this id: createStyle resolves // "derive" through getComponentStyle, which reads the very map written // here, and then the result is cached under prefix + id. Without this @@ -494,6 +713,7 @@ public void setComponentStyle(String id, Style style, String type) { } styles.put(id, style); + programmaticStyles.put(id, style); // A prefixed style can derive from this id: createStyle resolves // "derive" through getComponentStyle, which reads the very map written // here, and then the result is cached under prefix + id. Without this @@ -521,6 +741,7 @@ public void setComponentSelectedStyle(String id, Style style) { } selectedStyles.put(id, style); + programmaticSelectedStyles.put(id, style); // A prefixed style can derive from this id: createStyle resolves // "derive" through getComponentStyle, which reads the very map written // here, and then the result is cached under prefix + id. Without this @@ -1779,16 +2000,18 @@ private void resetThemeProps(Hashtable installedTheme) { /// - `themeProps`: the properties of the given theme public void addThemeProps(Hashtable themeProps) { if (accessible) { + restoreLargerTextFonts(); + restoreNativeThemeValues(); dropSupersededBindings(themeProps); buildTheme(themeProps); - styles.clear(); - selectedStyles.clear(); - prefixedStyles.clear(); - // styles.clear() above discarded the installed objects too. - programmaticStyleInstalled = false; + clearStyleCaches(); themeGeneration++; imageCache.clear(); - current.refreshTheme(false); + // Overlay resources merge inside the enclosing build. Refreshing here + // would cache control styles before OS colors/fonts have been applied. + if (buildThemeDepth == 0) { + current.refreshTheme(false); + } } } @@ -1858,6 +2081,10 @@ public void zoomFonts(float factor) { if (factor == 1f) { return; } + // Zoom the bundled originals, then reapply OS and accessibility settings. + // Otherwise zoom replaces the instances tracked by the restoration maps. + restoreLargerTextFonts(); + restoreNativeThemeValues(); for (Map.Entry entry : themeProps.entrySet()) { if (!entry.getKey().endsWith(Style.FONT)) { continue; @@ -1880,6 +2107,8 @@ public void zoomFonts(float factor) { entry.setValue(scaled); } } + applyNativeThemeSettings(); + applyLargerTextScaleToThemeFonts(); Font defFont = defaultStyle.getFont(); if (defFont != null && defFont.isTTFNativeFont()) { Font scaled = scaleFontByFactor(defFont, factor); @@ -1894,11 +2123,7 @@ public void zoomFonts(float factor) { defaultSelectedStyle.setFont(scaled); } } - styles.clear(); - selectedStyles.clear(); - prefixedStyles.clear(); - // styles.clear() above discarded the installed objects too. - programmaticStyleInstalled = false; + clearStyleCaches(); themeGeneration++; imageCache.clear(); current.refreshTheme(false); @@ -1935,6 +2160,8 @@ public void refreshTheme() { if (!accessible || themeProps == null) { return; } + restoreLargerTextFonts(); + restoreNativeThemeValues(); Hashtable props = new Hashtable(); for (Map.Entry e : themeProps.entrySet()) { props.put(e.getKey(), e.getValue()); @@ -1949,7 +2176,12 @@ public void refreshTheme() { props.put("@" + e.getKey(), e.getValue()); } } - setThemePropsImpl(props); + refreshingTheme = true; + try { + setThemePropsImpl(props); + } finally { + refreshingTheme = false; + } } /// Returns a theme constant defined in the resource editor @@ -2070,13 +2302,19 @@ public Object getThemeMaskConstant(String constantName) { } void setThemePropsImpl(Hashtable themeProps) { + if (!refreshingTheme && buildThemeDepth == 0) { + restoreNativeBorderColors(); + nativeFontKeys.clear(); + nativeColorKeys.clear(); + nativeBorderKeys.clear(); + applicationConstants.clear(); + nativeOriginals.clear(); + nativeDerived.clear(); + nativeBaseFontSize = 0; + } resetThemeProps(themeProps); - styles.clear(); + clearStyleCaches(); themeConstants.clear(); - selectedStyles.clear(); - prefixedStyles.clear(); - // styles.clear() above discarded the installed objects too. - programmaticStyleInstalled = false; themeGeneration++; imageCache.clear(); if (themelisteners != null) { @@ -2146,12 +2384,18 @@ private void buildThemeImpl(Hashtable themeProps) { // returning a paint derived under the previous (or no) theme. Font.clearDerivedFontCache(); String con = (String) themeProps.get("@includeNativeBool"); - if (con != null && "true".equalsIgnoreCase(con) && Display.getInstance().hasNativeTheme()) { + if (!refreshingTheme && con != null && "true".equalsIgnoreCase(con) && Display.getInstance().hasNativeTheme()) { boolean a = accessible; accessible = true; Display.getInstance().installNativeTheme(); accessible = a; } + recordNativeThemeOwnership(themeProps); + // A literal supplied by the app supersedes a native palette binding, including + // on initial @includeNativeBool composition (not only on addThemeProps). + if (!refreshingTheme && !"true".equals(themeProps.get("@nativeThemeDefaultsBool"))) { + dropSupersededBindings(themeProps); + } Enumeration e = themeProps.keys(); while (e.hasMoreElements()) { String key = (String) e.nextElement(); @@ -2164,6 +2408,27 @@ private void buildThemeImpl(Hashtable themeProps) { this.themeProps.put(key, themeProps.get(key)); } + String overlayThemes = (String) themeProps.get("@OverlayThemes"); + if (overlayThemes != null) { + java.util.List overlayThemesArr = StringUtil.tokenize(overlayThemes, ','); + for (String th : overlayThemesArr) { + th = th.trim(); + if (th.length() == 0) { + continue; + } + try { + Resources res = Resources.openLayered("/" + th); + boolean a = accessible; + accessible = true; + addThemeProps(res.getTheme(res.getThemeResourceNames()[0])); + accessible = a; + } catch (Exception ex) { + System.err.println("Failed to load overlay theme file specified by @overlayThemes theme constant: " + th); + Log.e(ex); + } + } + } + applyThemeBindings(); updateLargerTextScaleSettingFromTheme(); @@ -2194,6 +2459,8 @@ private void buildThemeImpl(Hashtable themeProps) { } if (buildThemeDepth == 1) { + restoreLargerTextFonts(); + applyNativeThemeSettings(); applyLargerTextScaleToThemeFonts(); } @@ -2208,27 +2475,6 @@ private void buildThemeImpl(Hashtable themeProps) { applyLargerTextScaleToDefaultStyles(); } - String overlayThemes = (String) themeProps.get("@OverlayThemes"); - if (overlayThemes != null) { - java.util.List overlayThemesArr = StringUtil.tokenize(overlayThemes, ','); - for (String th : overlayThemesArr) { - th = th.trim(); - if (th.length() == 0) { - continue; - } - try { - Resources res = Resources.openLayered("/" + th); - boolean a = accessible; - accessible = true; - addThemeProps(res.getTheme(res.getThemeResourceNames()[0])); - accessible = a; - } catch (Exception ex) { - System.err.println("Failed to load overlay theme file specified by @overlayThemes theme constant: " + th); - Log.e(ex); - } - } - } - // Everything above merged into themeProps, so the style-definition index // and the $Dark key list it carries describe the theme as it was BEFORE // this call and have to go. @@ -2300,7 +2546,7 @@ private void applyThemeBindings() { } } themeProps.put(themeKey, overrideValue); - syncBoundRoundBorderColor(themeKey, overrideValue); + syncBoundRoundBorderColor(themeKey, overrideValue, false); } } @@ -2309,15 +2555,29 @@ private void applyThemeBindings() { /// border when a compiler-emitted background-color binding is applied. /// This avoids switching the border into UIID painter mode, which is not /// supported consistently across ports and can change circle/pill geometry. - private void syncBoundRoundBorderColor(String themeKey, String colorValue) { + private void syncBoundRoundBorderColor(String themeKey, String colorValue, boolean nativeInheritance) { final String suffix = "bgColor"; if (!themeKey.endsWith(suffix)) { return; } String borderKey = themeKey.substring(0, themeKey.length() - suffix.length()) + "border"; + // An app may replace a native border without replacing its background-color + // property. The inherited fill must not recolor that explicitly supplied border. + if (nativeColorKeys.contains(themeKey) && !nativeBorderKeys.contains(borderKey)) { + return; + } Object border = themeProps.get(borderKey); if (border instanceof RoundBorder) { - ((RoundBorder) border).color(Integer.parseInt(colorValue, 16)); + RoundBorder round = (RoundBorder) border; + int color = Integer.parseInt(colorValue, 16); + if (nativeInheritance) { + // Multiple state keys can share the same serialized border instance. + if (!nativeBorderOriginals.containsKey(round)) { + nativeBorderOriginals.put(round, round.getColor()); + } + nativeBorderDerived.put(round, color); + } + round.color(color); } } @@ -2403,7 +2663,7 @@ private Font scaleFontForLargerText(Font font, float scale) { /// Only [#buildTheme] at depth 1 may call this: the bookkeeping below spans /// the whole merged theme, and a nested `@includeNativeBool` install sees /// only the native theme's half of it. See [#buildThemeDepth]. - private void applyLargerTextScaleToThemeFonts() { + private void restoreLargerTextFonts() { // Roll back any prior scaling we applied so this pass always derives // from the original installed font. Without the rollback, repeated // refreshes compound (each scale multiplies the previously-derived @@ -2424,6 +2684,10 @@ private void applyLargerTextScaleToThemeFonts() { scaledFontDerived.clear(); } + } + + private void applyLargerTextScaleToThemeFonts() { + restoreLargerTextFonts(); float scale = getEffectiveLargerTextScale(); if (scale <= 1f) { return; @@ -2608,8 +2872,10 @@ Style parseStyle(Resources theme, String id, String prefix, String baseStyle, bo if (selected) { selectedStyles.remove(id); + programmaticSelectedStyles.remove(id); } else { this.styles.remove(id); + programmaticStyles.remove(id); } // The prefixed cache too, or a re-parse of the same id is ignored. // diff --git a/Ports/Android/src/AndroidMaterialTheme.res b/Ports/Android/src/AndroidMaterialTheme.res index de3c0252c13..b500d0eee21 100644 Binary files a/Ports/Android/src/AndroidMaterialTheme.res and b/Ports/Android/src/AndroidMaterialTheme.res differ diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index fd5fb5b399b..5f2fa550db5 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -2038,6 +2038,63 @@ public Boolean isDarkMode() { } @Override + public com.codename1.impl.NativeThemeSettings getNativeThemeSettings() { + com.codename1.impl.NativeThemeSettings result = new com.codename1.impl.NativeThemeSettings(); + android.content.Context context = getContext(); + if (context == null) { return result; } + // Resolve normal-size platform text appearance in an unscaled configuration. + // Dividing scaledDensity is not sufficient with Android's nonlinear font scaling. + if (android.os.Build.VERSION.SDK_INT >= 17) { + Configuration config = new Configuration(context.getResources().getConfiguration()); + config.fontScale = 1f; + android.content.Context normal = context.createConfigurationContext(config); + android.content.res.TypedArray text = normal.obtainStyledAttributes( + android.R.style.TextAppearance_DeviceDefault, + new int[] {android.R.attr.textSize}); + try { + float size = text.getDimension(0, 0); + result.font("native:", size); + } finally { + text.recycle(); + } + } + // Public dynamic-color resources are available on Android 12+. Resolve by + // name so old SDK builds and vendors with partial support retain fallbacks. + if (android.os.Build.VERSION.SDK_INT >= 31) { + String[][] colors = { + {"accent-color", "system_accent1_600"}, + {"accent-color-dark", "system_accent1_200"}, + {"accent-on-color", "system_accent1_0"}, + {"accent-on-color-dark", "system_accent1_800"}, + {"accent-container-color", "system_accent1_100"}, + {"accent-container-color-dark", "system_accent1_700"}, + {"accent-on-container-color", "system_accent1_900"}, + {"accent-on-container-color-dark", "system_accent1_100"}, + {"accent-pressed-color", "system_accent1_200"}, + {"accent-pressed-color-dark", "system_accent1_700"}, + {"selection-color", "system_accent1_200"}, + {"selection-color-dark", "system_accent1_700"}, + {"window-bg-color", "system_neutral1_10"}, + {"window-bg-color-dark", "system_neutral1_900"}, + {"view-bg-color", "system_neutral1_10"}, + {"view-bg-color-dark", "system_neutral1_900"}, + {"text-color", "system_neutral1_900"}, + {"text-color-dark", "system_neutral1_100"}, + {"text-secondary-color", "system_neutral2_700"}, + {"text-secondary-color-dark", "system_neutral2_200"}, + {"separator-color", "system_neutral2_500"}, + {"separator-color-dark", "system_neutral2_600"} + }; + android.content.res.Resources resources = context.getResources(); + for (String[] color : colors) { + int id = resources.getIdentifier(color[1], "color", "android"); + if (id != 0) { result.color(color[0], resources.getColor(id)); } + } + } + return result; + } + + @Override public boolean isLargerTextEnabled() { return getLargerTextScale() > 1.0f; } diff --git a/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java b/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java index 9bda03998df..fb9530b303d 100644 --- a/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java +++ b/Ports/Android/src/com/codename1/impl/android/CodenameOneActivity.java @@ -127,11 +127,18 @@ private IBillingSupport getBillingSupport() { } + @Override + public void onConfigurationChanged(android.content.res.Configuration config) { + super.onConfigurationChanged(config); + com.codename1.ui.Display.getInstance().nativeThemeSettingsChanged(); + } + @Override protected void onResume() { super.onResume(); AndroidImplementation.setActivity(this); AndroidNativeUtil.onResume(); + com.codename1.ui.Display.getInstance().nativeThemeSettingsChanged(); if (isBillingEnabled() && getBillingSupport() != null) { billingSupport.consumeAndAcknowlegePurchases(); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 110aa3657d9..c1adb2a99ee 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -651,7 +651,7 @@ public Boolean isDarkMode() { private static boolean osDarkModeResolved; private static Boolean osDarkMode; - private static Boolean osDarkMode() { + private static synchronized Boolean osDarkMode() { if (!osDarkModeResolved) { osDarkModeResolved = true; try { @@ -672,6 +672,16 @@ private static Boolean osDarkMode() { return osDarkMode; } + // AWT settings listeners and activation run outside the CN1 EDT. Invalidate + // under the same lock as the query before queuing the EDT theme refresh. + static void desktopThemeSettingsChanged() { + synchronized (JavaSEPort.class) { + osDarkModeResolved = false; + osDarkMode = null; + } + Display.getInstance().nativeThemeSettingsChanged(); + } + /// Runs a short OS query and returns its standard output, or null when it failed or did /// not finish in time. A missing key is a non-zero exit with no output, which the parsers /// treat as the platform's default. @@ -8551,6 +8561,7 @@ public void actionPerformed(ActionEvent e) { }); final JMenu largerTextMenu = installLargerTextMenu(simulateMenu, pref, frm); + simulateMenu.add(installNativeThemeSettingsMenu()); final JMenu accessibilityPreferencesMenu = installAccessibilityPreferencesMenu(pref); final JMenu notificationBackgroundMenu = installNotificationBackgroundSimulationMenu(simulateMenu); @@ -9648,6 +9659,53 @@ private float zoomLevel() { return Math.min(h1, w1); } + private JMenu installNativeThemeSettingsMenu() { + JMenu menu = new JMenu("OS Theme Settings"); + JMenuItem reset = new JMenuItem("Reset simulated settings"); + reset.addActionListener(new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent event) { + setSimulatorNativeThemeSettings(null); + } + }); + menu.add(reset); + JMenuItem accent = new JMenuItem("Accent color..."); + accent.addActionListener(new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent event) { + java.awt.Color color = javax.swing.JColorChooser.showDialog(canvas, "Simulated OS accent", java.awt.Color.BLUE); + if (color != null) { + com.codename1.impl.NativeThemeSettings settings = simulatorNativeThemeSettings == null + ? new com.codename1.impl.NativeThemeSettings() : simulatorNativeThemeSettings.copy(); + settings.color("accent-color", color.getRGB()).color("accent-color-dark", color.getRGB()); + setSimulatorNativeThemeSettings(settings); + } + } + }); + menu.add(accent); + JMenuItem font = new JMenuItem("UI font..."); + font.addActionListener(new java.awt.event.ActionListener() { + @Override + public void actionPerformed(java.awt.event.ActionEvent event) { + String family = javax.swing.JOptionPane.showInputDialog(canvas, "OS UI font family", "Dialog"); + if (family == null || family.trim().length() == 0) { return; } + String size = javax.swing.JOptionPane.showInputDialog(canvas, "Normal size in CN1 pixels", "16"); + if (size == null) { return; } + try { + float pixels = Float.parseFloat(size); + if (!(pixels > 0 && pixels < 10000)) { throw new NumberFormatException(); } + com.codename1.impl.NativeThemeSettings settings = simulatorNativeThemeSettings == null + ? new com.codename1.impl.NativeThemeSettings() : simulatorNativeThemeSettings.copy(); + setSimulatorNativeThemeSettings(settings.font(family.trim(), pixels)); + } catch (NumberFormatException invalid) { + javax.swing.JOptionPane.showMessageDialog(canvas, "Enter a positive font size below 10000."); + } + } + }); + menu.add(font); + return menu; + } + private JMenu installLargerTextMenu(JMenu parent, final Preferences pref, final JFrame frm) { // Standard iOS Dynamic Type stops with their actual body-text point sizes. // The simulator returns ratio = bodyPt / 17pt, matching what iOS reports. @@ -10752,6 +10810,87 @@ private static String toHex(byte[] in) { return sb.toString().toUpperCase(); } + @Override + public com.codename1.impl.NativeThemeSettings getNativeThemeSettings() { + if (simulatorNativeThemeSettings != null) { return simulatorNativeThemeSettings.copy(); } + com.codename1.impl.NativeThemeSettings result = new com.codename1.impl.NativeThemeSettings(); + if (isSimulator()) { return result; } + final java.awt.Toolkit toolkit = java.awt.Toolkit.getDefaultToolkit(); + if (!nativeThemeDesktopListenersInstalled) { + nativeThemeDesktopListenersInstalled = true; + final String[] properties = {"win.messagebox.font", "win.itemHighlightColor", + "win.text.textColor", "win.frame.backgroundColor", "gnome.Gtk/FontName", "gnome.Xft/DPI"}; + final java.beans.PropertyChangeListener listener = new java.beans.PropertyChangeListener() { + public void propertyChange(java.beans.PropertyChangeEvent event) { + desktopThemeSettingsChanged(); + } + }; + for (String property : properties) { toolkit.addPropertyChangeListener(property, listener); } + addDeinitializeHook(new Runnable() { + public void run() { + for (String property : properties) { toolkit.removePropertyChangeListener(property, listener); } + nativeThemeDesktopListenersInstalled = false; + } + }); + } + readDesktopThemeFont(result, toolkit.getDesktopProperty("win.messagebox.font"), + toolkit.getDesktopProperty("gnome.Gtk/FontName"), toolkit.getDesktopProperty("gnome.Xft/DPI")); + String[][] properties = {{"selection-color", "win.itemHighlightColor"}, + {"text-color", "win.text.textColor"}, {"window-bg-color", "win.frame.backgroundColor"}}; + for (String[] pair : properties) { + Object color = toolkit.getDesktopProperty(pair[1]); + if (color instanceof java.awt.Color) { + result.color(pair[0], ((java.awt.Color) color).getRGB()); + } + } + return result; + } + + // Package visibility allows deterministic tests without a running desktop session. + static void readDesktopThemeFont(com.codename1.impl.NativeThemeSettings result, + Object windowsFont, Object gtkFontName, Object gtkDpi) { + if (windowsFont instanceof java.awt.Font) { + java.awt.Font font = (java.awt.Font) windowsFont; + result.font(font.getFamily(), font.getSize2D()); + } else if (gtkFontName instanceof String) { + // Pango descriptions end in a point size (or an absolute px size). + java.util.regex.Matcher match = java.util.regex.Pattern.compile( + "^(.+?)\\s+(\\d+(?:\\.\\d+)?)(px)?$").matcher(((String) gtkFontName).trim()); + if (!match.matches()) { + return; + } + String family = match.group(1).replaceFirst( + "(?i)(?:\\s+(?:bold|italic|oblique|regular|normal|light|medium|semibold|condensed))+$", ""); + float size = Float.parseFloat(match.group(2)); + if (match.group(3) == null) { + // XSettings publishes DPI as a fixed-point integer with 10 fractional bits. + float dpi = gtkDpi instanceof Number ? ((Number) gtkDpi).floatValue() / 1024f : 96f; + if (!(dpi > 0) || Float.isInfinite(dpi)) { + dpi = 96f; + } + size *= dpi / 72f; + } + result.font(family, size); + } + } + + private boolean nativeThemeDesktopListenersInstalled; + private com.codename1.impl.NativeThemeSettings simulatorNativeThemeSettings; + + /// Deterministic OS settings for simulator previews/tests; null restores host defaults. + public void setSimulatorNativeThemeSettings(com.codename1.impl.NativeThemeSettings settings) { + simulatorNativeThemeSettings = settings == null ? null : settings.copy(); + Display.getInstance().nativeThemeSettingsChanged(); + } + + @Override + public Object loadNativeThemeFont(String family, String template, float size, int style) { + if ("native:".equals(family)) { + return super.loadNativeThemeFont(family, template, size, style); + } + return deriveTrueTypeFont(new java.awt.Font(family, java.awt.Font.PLAIN, 1), size, style); + } + @Override public boolean isLargerTextEnabled() { return largerTextEnabled; @@ -11494,6 +11633,7 @@ public void windowDeiconified(WindowEvent e) { } public void windowActivated(WindowEvent e) { + desktopThemeSettingsChanged(); mainSurfaceFocusChanged(true); } diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h index dad60492a33..b52049ecdec 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux.h @@ -100,7 +100,8 @@ typedef enum { * Java side handed out in setNativeCommands. Queued like every other input so the * command runs on the EDT rather than on the GTK thread. Same number as the Windows * port's, so the two desktop wire protocols do not drift apart. */ - CN1_EVENT_MENU_COMMAND = 23 + CN1_EVENT_MENU_COMMAND = 23, + CN1_EVENT_THEME_SETTINGS_CHANGED = 24 } CN1EventType; /* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */ diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c index 4d5120409e5..efccf73b015 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c @@ -287,6 +287,8 @@ int cn1LinuxPopEvent(int* out) { static volatile int cn1CurrentModifiers = 0; static GtkWidget* cn1Window = 0; +/* Set by initDisplay before publishing the GTK window. */ +static pthread_t cn1GtkThread; static GtkWidget* cn1DrawingArea = 0; static GtkWidget* cn1Overlay = 0; /* GtkOverlay: drawing area + native widget layer */ static GtkWidget* cn1RootBox = 0; /* GtkBox: optional menu bar above the overlay */ @@ -375,7 +377,8 @@ GtkWidget* cn1LinuxWindowWidget(void) { /* Posts fn(arg) onto the GTK main loop and blocks the calling (EDT) thread until * it has run. Shared by the services / edit / browser / media units for the GTK * calls that must happen on the main thread. In headless mode (no window, no - * loop) it runs inline so callers never deadlock. */ + * loop), or when already on the GTK thread, it runs inline. Display initialization + * queries theme settings before the main thread starts pumping GTK events. */ typedef struct { void (*fn)(void*); void* arg; @@ -396,7 +399,7 @@ static gboolean cn1MainCallTrampoline(gpointer p) { void cn1LinuxRunOnMainAndWait(void (*fn)(void*), void* arg) { CN1MainCall mc; - if (cn1Window == 0) { + if (cn1Window == 0 || pthread_equal(pthread_self(), cn1GtkThread)) { fn(arg); return; } @@ -1054,6 +1057,7 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in return; } + cn1GtkThread = pthread_self(); gtk_init(0, 0); cn1Window = gtk_window_new(GTK_WINDOW_TOPLEVEL); gtk_window_set_title(GTK_WINDOW(cn1Window), t); @@ -1841,29 +1845,110 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_captureWindowToPngBytes___R_byt * compiles, links, and leaves the Java method looking unused to the dead-code pass, * which then removes it. scripts/check-native-signatures.sh is what catches that. */ -JAVA_INT com_codename1_impl_linux_LinuxNative_systemColorScheme___R_int(CODENAME_ONE_THREAD_STATE) { +static gint cn1DesktopColorScheme = -1; + +static void cn1ReadDesktopColorScheme(GSettings* settings) { + gchar* scheme = g_settings_get_string(settings, "color-scheme"); + g_atomic_int_set(&cn1DesktopColorScheme, scheme ? (strcmp(scheme, "prefer-dark") == 0 ? 1 : 0) : -1); + g_free(scheme); +} + +static void cn1DesktopColorSchemeChanged(GSettings* settings, gchar* key, gpointer data) { + (void)key; (void)data; + cn1ReadDesktopColorScheme(settings); + cn1LinuxPushEvent(CN1_EVENT_THEME_SETTINGS_CHANGED, 0, 0, 0); +} + +static void cn1ObserveDesktopColorScheme(void* unused) { + (void)unused; GSettingsSchemaSource* source = g_settings_schema_source_get_default(); - if (source == NULL) { - return -1; - } + if (!source) return; GSettingsSchema* schema = g_settings_schema_source_lookup(source, "org.gnome.desktop.interface", TRUE); - if (schema == NULL) { - return -1; - } - int result = -1; - /* has_key as well as the schema lookup: color-scheme arrived in GNOME 42, and the - * schema exists without it on older desktops. g_settings_get_string on a missing key - * aborts the same way a missing schema does. */ + if (!schema) return; + /* Older GNOME schemas do not contain color-scheme. Never read a missing key. */ if (g_settings_schema_has_key(schema, "color-scheme")) { - GSettings* settings = g_settings_new("org.gnome.desktop.interface"); - gchar* scheme = g_settings_get_string(settings, "color-scheme"); - if (scheme != NULL) { - result = strcmp(scheme, "prefer-dark") == 0 ? 1 : 0; - g_free(scheme); - } - g_object_unref(settings); + /* Retained for the process lifetime so change notifications stay connected. + Constructed on the GTK context independently of palette/font snapshots. */ + GSettings* settings = g_settings_new_full(schema, NULL, NULL); + g_signal_connect(settings, "changed::color-scheme", G_CALLBACK(cn1DesktopColorSchemeChanged), NULL); + /* Reading after connecting enables GSettings change delivery for this key. */ + cn1ReadDesktopColorScheme(settings); } g_settings_schema_unref(schema); +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_systemColorScheme___R_int(CODENAME_ONE_THREAD_STATE) { + static gsize initialized; + if (g_once_init_enter(&initialized)) { + cn1LinuxRunOnMainAndWait(cn1ObserveDesktopColorScheme, NULL); + g_once_init_leave(&initialized, 1); + } + /* Style resolution queries this frequently. Only observer installation needs + a GTK thread round trip; notifications publish later values atomically. */ + return g_atomic_int_get(&cn1DesktopColorScheme); +} + +/* Settings and GTK style contexts must only be read on the GTK main thread. */ +static void cn1ThemeSettingsNotify(GObject* object, GParamSpec* property, gpointer data) { + (void)object; (void)property; (void)data; + cn1LinuxPushEvent(CN1_EVENT_THEME_SETTINGS_CHANGED, 0, 0, 0); +} + +static void cn1ReadThemeSettings(void* out) { + GString* result = (GString*)out; + GtkSettings* settings = gtk_settings_get_default(); + if (!settings || !cn1Window) return; + static GtkSettings* observed; + if (observed != settings) { + if (observed) { + g_signal_handlers_disconnect_by_func(observed, G_CALLBACK(cn1ThemeSettingsNotify), NULL); + g_object_unref(observed); + } + observed = (GtkSettings*)g_object_ref(settings); + g_signal_connect(settings, "notify::gtk-font-name", G_CALLBACK(cn1ThemeSettingsNotify), NULL); + g_signal_connect(settings, "notify::gtk-xft-dpi", G_CALLBACK(cn1ThemeSettingsNotify), NULL); + g_signal_connect(settings, "notify::gtk-theme-name", G_CALLBACK(cn1ThemeSettingsNotify), NULL); + g_signal_connect(settings, "notify::gtk-application-prefer-dark-theme", G_CALLBACK(cn1ThemeSettingsNotify), NULL); + } + gchar* fontName = NULL; + g_object_get(settings, "gtk-font-name", &fontName, NULL); + if (fontName) { + PangoFontDescription* font = pango_font_description_from_string(fontName); + const char* family = pango_font_description_get_family(font); + double size = (double)pango_font_description_get_size(font) / PANGO_SCALE; + if (!pango_font_description_get_size_is_absolute(font)) { + double dpi = gdk_screen_get_resolution(gtk_widget_get_screen(cn1Window)); + size *= (dpi > 0 ? dpi : 96) / 72.0; + } + if (family && size > 0) g_string_append_printf(result, "fontFamily=%s\nfontSize=%g\n", family, size); + pango_font_description_free(font); + g_free(fontName); + } + GtkStyleContext* context = gtk_widget_get_style_context(cn1Window); + const char* tokens[] = {"accent-color", "accent-fg-color", "selection-color", "window-bg-color", + "text-color", "view-bg-color", "text-secondary-color", "separator-color"}; + const char* names[] = {"theme_selected_bg_color", "theme_selected_fg_color", "theme_selected_bg_color", + "theme_bg_color", "theme_fg_color", "theme_base_color", "insensitive_fg_color", "borders"}; + /* GTK exposes the active palette only. Supply it to the corresponding appearance, + never overwrite the other appearance with an invented light/dark variant. */ + GdkRGBA bg; + gboolean dark = gtk_style_context_lookup_color(context, "theme_bg_color", &bg) + && (bg.red + bg.green + bg.blue) < 1.5; + for (unsigned i = 0; i < sizeof(tokens) / sizeof(tokens[0]); ++i) { + GdkRGBA color; + if (gtk_style_context_lookup_color(context, names[i], &color) && color.alpha >= 0.999) { + g_string_append_printf(result, "%s%s=%02x%02x%02x\n", tokens[i], dark ? "-dark" : "", + (unsigned)(color.red * 255 + 0.5), (unsigned)(color.green * 255 + 0.5), + (unsigned)(color.blue * 255 + 0.5)); + } + } +} + +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_nativeThemeSettings___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + GString* buffer = g_string_new(NULL); + cn1LinuxRunOnMainAndWait(cn1ReadThemeSettings, buffer); + JAVA_OBJECT result = newStringFromCString(threadStateData, buffer->str); + g_string_free(buffer, TRUE); return result; } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 701b5a935b4..6847c31ffe0 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -128,6 +128,7 @@ public boolean isScreenReaderEnabled() { /// which shares its number with the Windows port's so the two desktop wire protocols /// do not drift apart. private static final int EVENT_MENU_COMMAND = 23; + private static final int EVENT_THEME_SETTINGS_CHANGED = 24; private static final int EVENT_ROTATE = 11; private static final int EVENT_ACCESSIBILITY_ACTION = 12; // Additional desktop windows. These always carry a non-zero window id. @@ -833,7 +834,11 @@ private void drainInput() { case EVENT_WINDOW_CLOSE: Desktop.getInstance().windowCloseRequested(windowId); break; + case EVENT_THEME_SETTINGS_CHANGED: + Display.getInstance().nativeThemeSettingsChanged(); + break; case EVENT_WINDOW_FOCUS: + if (key != 0) { Display.getInstance().nativeThemeSettingsChanged(); } Desktop.getInstance().windowFocusChanged(windowId, key != 0); break; case EVENT_WINDOW_MONITOR: @@ -3460,6 +3465,11 @@ public String[] getPlatformOverrides() { /// platform does not know" (null) from "light" (FALSE) -- UIManager's dark-mode /// resolution tests for null explicitly -- and on Linux that distinction is real: /// a session with no desktop settings daemon has no answer to give. + @Override + public com.codename1.impl.NativeThemeSettings getNativeThemeSettings() { + return com.codename1.impl.NativeThemeSettings.parse(LinuxNative.nativeThemeSettings()); + } + @Override public Boolean isDarkMode() { int v = LinuxNative.systemColorScheme(); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index e956290d9b4..51098d6289d 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -62,6 +62,8 @@ private LinuxNative() { /// event carries that. public static native int currentModifiers(); + public static native String nativeThemeSettings(); + public static native void nativeLog(String message); /** * The desktop's colour scheme: 1 dark, 0 light, -1 unknown. diff --git a/Ports/WindowsPort/nativeSources/cn1_windows.h b/Ports/WindowsPort/nativeSources/cn1_windows.h index 8502ba81bf5..d372576130a 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows.h +++ b/Ports/WindowsPort/nativeSources/cn1_windows.h @@ -126,7 +126,8 @@ typedef enum { * Java side handed out in setNativeCommands; x, y and windowId are unused. Routed * through the same queue as every other event so the command runs on the EDT rather * than on the pump thread, which is the rule the whole bridge is built on. */ - CN1_EVENT_MENU_COMMAND = 23 + CN1_EVENT_MENU_COMMAND = 23, + CN1_EVENT_THEME_SETTINGS_CHANGED = 24 } CN1EventType; /* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */ diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp index a6dd05b8555..a67850d9586 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp @@ -679,6 +679,12 @@ int cn1WinHandleGesture(HWND hwnd, int windowId, LPARAM lParam) { LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { + case WM_SETTINGCHANGE: + case WM_THEMECHANGED: + case WM_SYSCOLORCHANGE: + case WM_DWMCOLORIZATIONCOLORCHANGED: + cn1WinPushEvent(CN1_EVENT_THEME_SETTINGS_CHANGED, 0, 0, 0); + return DefWindowProcW(hwnd, msg, wParam, lParam); case WM_GETOBJECT: return cn1WinAccessibilityObject(hwnd, wParam, lParam); case WM_LBUTTONDOWN: diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_winrt.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_winrt.cpp index 1c157d45cab..858808b8cf0 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_winrt.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_winrt.cpp @@ -41,6 +41,8 @@ #include "cn1_windows.h" #include +#include +#include #include #include #include @@ -458,6 +460,74 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_shareText___java_lang_Stri return JAVA_TRUE; } + +/* UISettings provides the user's actual accent palette. Keep initialization balanced: + this query runs repeatedly on the long-lived EDT, unlike short-lived worker bridges. */ +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_nativeThemeSettings___R_java_lang_String( + CODENAME_ONE_THREAD_STATE) { + using namespace ABI::Windows::UI::ViewManagement; + CN1Buf buffer; + cn1BufInit(&buffer); + NONCLIENTMETRICSW metrics = {}; + metrics.cbSize = sizeof(metrics); + if (SystemParametersInfoW(SPI_GETNONCLIENTMETRICS, sizeof(metrics), &metrics, 0)) { + char family[256]; + int count = WideCharToMultiByte(CP_UTF8, 0, metrics.lfMessageFont.lfFaceName, -1, + family, sizeof(family), NULL, NULL); + // SPI_GETNONCLIENTMETRICS already returns lfHeight in pixels at the + // caller's DPI, the same coordinate system used by screenDpi(). Do not + // normalize it to 96 DPI: CN1 fonts consume device pixels directly. + float size = (float)abs(metrics.lfMessageFont.lfHeight); + if (count > 0 && size > 0) { + char line[320]; + snprintf(line, sizeof(line), "fontFamily=%s\nfontSize=%g\n", family, size); + cn1BufAppendBytes(&buffer, line, strlen(line)); + } + } + HRESULT initialized = RoInitialize(RO_INIT_MULTITHREADED); + { + ComPtr instance; + ComPtr settings; + if (SUCCEEDED(RoActivateInstance(HStringReference(RuntimeClass_Windows_UI_ViewManagement_UISettings).Get(), + &instance)) && SUCCEEDED(instance.As(&settings))) { + const char* tokens[] = {"accent-color", "accent-color-dark", "selection-color", "selection-color-dark"}; + UIColorType types[] = {UIColorType_Accent, UIColorType_AccentLight2, UIColorType_Accent, UIColorType_AccentLight2}; + for (int i = 0; i < 4; ++i) { + ABI::Windows::UI::Color color; + if (SUCCEEDED(settings->GetColorValue(types[i], &color))) { + char line[96]; + snprintf(line, sizeof(line), "%s=%02x%02x%02x\n", tokens[i], color.R, color.G, color.B); + cn1BufAppendBytes(&buffer, line, strlen(line)); + } + } + } + } + if (SUCCEEDED(initialized)) RoUninitialize(); + /* GetSysColor describes the classic/high-contrast palette, not Fluent dark surfaces. + Use these semantic colors only for high contrast; normal Fluent keeps its surfaces. */ + HIGHCONTRASTW contrast = {}; + contrast.cbSize = sizeof(contrast); + if (SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(contrast), &contrast, 0) + && (contrast.dwFlags & HCF_HIGHCONTRASTON)) { + const char* tokens[] = {"window-bg-color", "view-bg-color", "control-bg-color", "text-color", + "text-secondary-color", "selection-color", "accent-color", "accent-fg-color"}; + int indices[] = {COLOR_WINDOW, COLOR_WINDOW, COLOR_BTNFACE, COLOR_WINDOWTEXT, + COLOR_GRAYTEXT, COLOR_HIGHLIGHT, COLOR_HIGHLIGHT, COLOR_HIGHLIGHTTEXT}; + for (int i = 0; i < 8; ++i) { + COLORREF color = GetSysColor(indices[i]); + char line[128]; + for (int dark = 0; dark < 2; ++dark) { + snprintf(line, sizeof(line), "%s%s=%02x%02x%02x\n", tokens[i], dark ? "-dark" : "", + GetRValue(color), GetGValue(color), GetBValue(color)); + cn1BufAppendBytes(&buffer, line, strlen(line)); + } + } + } + JAVA_OBJECT result = newStringFromCString(threadStateData, buffer.data ? buffer.data : ""); + free(buffer.data); + return result; +} + } /* extern "C" */ #endif /* _WIN32 */ diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index ad21a288c61..bbfde989e0e 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -115,6 +115,7 @@ public boolean isScreenReaderEnabled() { /// A native menu bar item was chosen; the key field carries the command id /// setNativeCommands handed out. Must match CN1_EVENT_MENU_COMMAND in cn1_windows.h. private static final int EVENT_MENU_COMMAND = 23; + private static final int EVENT_THEME_SETTINGS_CHANGED = 24; private static final int EVENT_PINCH_BEGIN = 20; private static final int EVENT_PINCH_END = 21; private static final int EVENT_ROTATE = 11; @@ -855,7 +856,11 @@ private void drainInput() { case EVENT_WINDOW_CLOSE: Desktop.getInstance().windowCloseRequested(windowId); break; + case EVENT_THEME_SETTINGS_CHANGED: + Display.getInstance().nativeThemeSettingsChanged(); + break; case EVENT_WINDOW_FOCUS: + if (key != 0) { Display.getInstance().nativeThemeSettingsChanged(); } Desktop.getInstance().windowFocusChanged(windowId, key != 0); break; case EVENT_WINDOW_MONITOR: @@ -3469,6 +3474,11 @@ public String[] getPlatformOverrides() { /// Returns Boolean rather than boolean because the contract distinguishes "the /// platform does not know" (null) from "light" (FALSE), and callers such as /// UIManager's dark-mode resolution treat the two differently. + @Override + public com.codename1.impl.NativeThemeSettings getNativeThemeSettings() { + return com.codename1.impl.NativeThemeSettings.parse(WindowsNative.nativeThemeSettings()); + } + @Override public Boolean isDarkMode() { return WindowsNative.systemUsesDarkTheme() ? Boolean.TRUE : Boolean.FALSE; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 59239492f41..d810068c0d6 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -40,6 +40,8 @@ private WindowsNative() { /* ---------------------------------------------------------- lifecycle */ /** Writes a line to the native debug log (OutputDebugString + stderr). */ + public static native String nativeThemeSettings(); + public static native void nativeLog(String message); /** diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 6dbfd98149d..9ad3e02314f 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -3258,6 +3258,14 @@ @interface CodenameOne_GLViewController () #if !TARGET_OS_OSX @implementation CodenameOne_GLViewController +#if !TARGET_OS_OSX && !TARGET_OS_WATCH +- (void)traitCollectionDidChange:(UITraitCollection*)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + com_codename1_impl_ios_IOSImplementation_nativeThemeSettingsChanged__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} +#endif + + @synthesize displayLink, currentMutableImage, animating; static CodenameOne_GLViewController *sharedSingleton; +(BOOL)isDrawTextureSupported { diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 4b257c37897..4091342e0f5 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -8037,12 +8037,15 @@ void com_codename1_impl_ios_IOSNative_setVideoFullScreen___long_boolean(CN1_THRE void com_codename1_impl_ios_IOSNative_showNativePlayerController___long(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG peer) {} #endif // !TARGET_OS_WATCH && !TARGET_OS_TV (MPMoviePlayer / AVKit video peer functions) +static void cn1ObserveSystemColors(void); + JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isDarkMode___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { #if TARGET_OS_OSX // Asked of the system rather than inferred. The Catalyst path derives dark // mode from the content pane's luma because it has no reliable way to read // the host appearance; AppKit just answers, including when the user changes // it while the application is running. + cn1ObserveSystemColors(); return CN1MacHostIsDarkMode() ? JAVA_TRUE : JAVA_FALSE; #else #if !TARGET_OS_WATCH @@ -8541,7 +8544,117 @@ void com_codename1_impl_ios_IOSNative_bonjourPublishStop___long(CN1_THREAD_STATE #endif } +/* OS theme snapshots are captured on the Apple UI thread; Java receives values only. */ +static void cn1NativeThemeDidChange(NSNotification* notification) { + (void)notification; + com_codename1_impl_ios_IOSImplementation_nativeThemeSettingsChanged__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +static void cn1ObserveSystemColors(void) { +#if TARGET_OS_OSX + static dispatch_once_t observing; + dispatch_once(&observing, ^{ + [[NSNotificationCenter defaultCenter] addObserverForName:NSSystemColorsDidChangeNotification + object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification* n) { cn1NativeThemeDidChange(n); }]; + }); +#endif +} + +static void cn1ObserveContentSizeChanges(void) { +#if !TARGET_OS_OSX && !TARGET_OS_WATCH && !TARGET_OS_TV + static dispatch_once_t observing; + dispatch_once(&observing, ^{ + [[NSNotificationCenter defaultCenter] addObserverForName:UIContentSizeCategoryDidChangeNotification + object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification* n) { cn1NativeThemeDidChange(n); }]; + }); +#endif +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_nativeThemeSettings___R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + POOL_BEGIN(); + __block NSString* snapshot = nil; + void (^readSettings)(void) = ^{ + NSMutableString* values = [NSMutableString string]; +#if TARGET_OS_OSX + cn1ObserveSystemColors(); + [values appendFormat:@"fontFamily=native:\nfontSize=%g\n", [NSFont systemFontSize] * (scaleValue > 0 ? scaleValue : 1)]; + if (@available(macOS 10.14, *)) { + for (int dark = 0; dark < 2; ++dark) { + NSAppearance* appearance = [NSAppearance appearanceNamed:dark ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua]; + void (^readColors)(void) = ^{ + NSArray* tokens = @[@"accent-color", @"selection-color", @"window-bg-color", @"control-bg-color", + @"view-bg-color", @"text-color", @"text-secondary-color", @"separator-color"]; + NSArray* colors = @[[NSColor controlAccentColor], [NSColor selectedTextBackgroundColor], + [NSColor windowBackgroundColor], [NSColor controlBackgroundColor], [NSColor textBackgroundColor], + [NSColor labelColor], [NSColor secondaryLabelColor], [NSColor separatorColor]]; + for (NSUInteger i = 0; i < tokens.count; ++i) { + NSColor* color = [colors[i] colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + if (!color) continue; + // Flatten semantic translucent colors onto the matching window surface. + NSColor* background = [[NSColor windowBackgroundColor] colorUsingColorSpace:[NSColorSpace sRGBColorSpace]]; + CGFloat a = color.alphaComponent; + unsigned r = (unsigned)lround(255 * (color.redComponent*a + background.redComponent*(1-a))); + unsigned g = (unsigned)lround(255 * (color.greenComponent*a + background.greenComponent*(1-a))); + unsigned b = (unsigned)lround(255 * (color.blueComponent*a + background.blueComponent*(1-a))); + [values appendFormat:@"%@%@=%02x%02x%02x\n", tokens[i], dark ? @"-dark" : @"", r, g, b]; + } + }; + if (@available(macOS 11.0, *)) { + [appearance performAsCurrentDrawingAppearance:readColors]; + } else { + // Catalina supports semantic colors but not the block appearance API. + NSAppearance* previous = [[NSAppearance currentAppearance] retain]; + @try { + [NSAppearance setCurrentAppearance:appearance]; + readColors(); + } @finally { + [NSAppearance setCurrentAppearance:previous]; + [previous release]; + } + } + } + } +#elif !TARGET_OS_WATCH && !TARGET_OS_TV + cn1ObserveContentSizeChanges(); + // systemFontSize is the unscaled baseline also used by getLargerTextScale. + [values appendFormat:@"fontFamily=native:\nfontSize=%g\n", [UIFont systemFontSize] * (scaleValue > 0 ? scaleValue : 1)]; + if (@available(iOS 13.0, *)) { + for (int dark = 0; dark < 2; ++dark) { + UITraitCollection* traits = [UITraitCollection traitCollectionWithUserInterfaceStyle: + dark ? UIUserInterfaceStyleDark : UIUserInterfaceStyleLight]; + NSArray* tokens = @[@"accent-color", @"window-bg-color", @"view-bg-color", @"control-bg-color", + @"text-color", @"text-secondary-color", @"separator-color"]; + NSArray* colors = @[[UIColor systemBlueColor], [UIColor systemBackgroundColor], + [UIColor secondarySystemBackgroundColor], [UIColor secondarySystemBackgroundColor], + [UIColor labelColor], [UIColor secondaryLabelColor], [UIColor separatorColor]]; + UIColor* background = [[UIColor systemBackgroundColor] resolvedColorWithTraitCollection:traits]; + CGFloat br=0, bg=0, bb=0, ba=0; + [background getRed:&br green:&bg blue:&bb alpha:&ba]; + for (NSUInteger i = 0; i < tokens.count; ++i) { + UIColor* color = [colors[i] resolvedColorWithTraitCollection:traits]; + CGFloat r=0, g=0, b=0, a=0; + if (![color getRed:&r green:&g blue:&b alpha:&a]) continue; + [values appendFormat:@"%@%@=%02x%02x%02x\n", tokens[i], dark ? @"-dark" : @"", + (unsigned)lround(255*(r*a+br*(1-a))), (unsigned)lround(255*(g*a+bg*(1-a))), + (unsigned)lround(255*(b*a+bb*(1-a)))]; + } + } + } +#endif + snapshot = [values copy]; + }; + if ([NSThread isMainThread]) readSettings(); + else dispatch_sync(dispatch_get_main_queue(), readSettings); + JAVA_OBJECT result = fromNSString(CN1_THREAD_STATE_PASS_ARG snapshot); + [snapshot release]; + POOL_END(); + return result; +} + JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isLargerTextEnabled___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + // Subscribe even at the normal size, before optional palette/font snapshots. + cn1ObserveContentSizeChanges(); #if !TARGET_OS_WATCH && !TARGET_OS_TV if (@available(iOS 7.0, *)) { CGFloat baseSize = [CN1Font systemFontSize]; @@ -8561,6 +8674,7 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isLargerTextEnabled___R_boolean(CN } JAVA_FLOAT com_codename1_impl_ios_IOSNative_getLargerTextScale___R_float(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { + cn1ObserveContentSizeChanges(); #if !TARGET_OS_WATCH && !TARGET_OS_TV if (@available(iOS 7.0, *)) { CGFloat baseSize = [CN1Font systemFontSize]; diff --git a/Ports/iOSPort/nativeSources/iOSModern27Theme.res b/Ports/iOSPort/nativeSources/iOSModern27Theme.res index 3b00f7e7cc7..be993f1d39d 100644 Binary files a/Ports/iOSPort/nativeSources/iOSModern27Theme.res and b/Ports/iOSPort/nativeSources/iOSModern27Theme.res differ diff --git a/Ports/iOSPort/nativeSources/iOSModernTheme.res b/Ports/iOSPort/nativeSources/iOSModernTheme.res index df0f3650494..c4eccd7f439 100644 Binary files a/Ports/iOSPort/nativeSources/iOSModernTheme.res and b/Ports/iOSPort/nativeSources/iOSModernTheme.res differ diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 63a1aa1ce87..f4aa49985b2 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -2826,6 +2826,16 @@ protected com.codename1.io.NetworkTypePlatform createNetworkTypePlatform() { return new IOSNetworkTypePlatform(); } + @Override + public com.codename1.impl.NativeThemeSettings getNativeThemeSettings() { + return com.codename1.impl.NativeThemeSettings.parse(nativeInstance.nativeThemeSettings()); + } + + /// Called by Apple appearance/font notifications; Display coalesces onto the EDT. + public static void nativeThemeSettingsChanged() { + Display.getInstance().nativeThemeSettingsChanged(); + } + @Override public boolean isLargerTextEnabled() { return nativeInstance.isLargerTextEnabled(); @@ -14460,6 +14470,7 @@ public static void applicationDidBecomeActive() { } private static void applicationDidBecomeActive(boolean clearMinimized) { + nativeThemeSettingsChanged(); callInterruptionActive = false; final ArrayList callbacks; synchronized(instance.onActiveListeners) { diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 32491a582b7..44e1946e02f 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -245,6 +245,7 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre native boolean isLargerTextEnabled(); native float getLargerTextScale(); + native String nativeThemeSettings(); native boolean isHighContrastEnabled(); native boolean isDifferentiateWithoutColorEnabled(); native boolean isReduceMotionEnabled(); diff --git a/Themes/AndroidMaterialTheme.res b/Themes/AndroidMaterialTheme.res index de3c0252c13..b500d0eee21 100644 Binary files a/Themes/AndroidMaterialTheme.res and b/Themes/AndroidMaterialTheme.res differ diff --git a/Themes/GnomeAdwaitaTheme.res b/Themes/GnomeAdwaitaTheme.res index 7ef22acc1d6..0908a366e7a 100644 Binary files a/Themes/GnomeAdwaitaTheme.res and b/Themes/GnomeAdwaitaTheme.res differ diff --git a/Themes/MacOSAquaTheme.res b/Themes/MacOSAquaTheme.res index e3d8448a92d..12611ef55fc 100644 Binary files a/Themes/MacOSAquaTheme.res and b/Themes/MacOSAquaTheme.res differ diff --git a/Themes/WindowsFluentTheme.res b/Themes/WindowsFluentTheme.res index 3cdfb38bd71..d278b9086cd 100644 Binary files a/Themes/WindowsFluentTheme.res and b/Themes/WindowsFluentTheme.res differ diff --git a/Themes/iOSModern27Theme.res b/Themes/iOSModern27Theme.res index 3b00f7e7cc7..be993f1d39d 100644 Binary files a/Themes/iOSModern27Theme.res and b/Themes/iOSModern27Theme.res differ diff --git a/Themes/iOSModernTheme.res b/Themes/iOSModernTheme.res index df0f3650494..c4eccd7f439 100644 Binary files a/Themes/iOSModernTheme.res and b/Themes/iOSModernTheme.res differ diff --git a/docs/demos/common/src/main/css/guide-snippets-theme.css b/docs/demos/common/src/main/css/guide-snippets-theme.css index 066d1f1ceec..2123109f569 100644 --- a/docs/demos/common/src/main/css/guide-snippets-theme.css +++ b/docs/demos/common/src/main/css/guide-snippets-theme.css @@ -840,3 +840,13 @@ Button.hover { background-color: #f6f6f6; } /* end::native-themes-css-hover[] */ + +/* tag::native-theme-os-settings[] */ +#Constants { + includeNativeBool: true; + useNativeColorsBool: true; + useNativeFontsBool: true; + useLargerTextScaleBool: true; +} + +/* end::native-theme-os-settings[] */ diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NativeThemesJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NativeThemesJava001Snippet.java index 5c7547dbb5f..1591ebe2669 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NativeThemesJava001Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NativeThemesJava001Snippet.java @@ -107,4 +107,12 @@ void snippet() throws Exception { } // end::native-themes-java-001[] } + void inheritNativeThemeSettings() { + // tag::native-theme-os-settings[] + UIManager ui = UIManager.getInstance(); + ui.setUseNativeColors(true); + ui.setUseNativeFonts(true); + ui.setUseLargerTextScale(true); + // end::native-theme-os-settings[] + } } diff --git a/docs/developer-guide/Native-Themes.asciidoc b/docs/developer-guide/Native-Themes.asciidoc index ab129a91c90..25e50cc2341 100644 --- a/docs/developer-guide/Native-Themes.asciidoc +++ b/docs/developer-guide/Native-Themes.asciidoc @@ -10,6 +10,67 @@ ramps, and state-specific styles are all reachable from your own The legacy iOS 7 (iOS) and Holo Light (Android) themes remain the default so existing apps see no behavior change. +=== Inheriting OS colors and fonts + +Modern native themes can optionally follow the user's operating-system settings. +Colors and fonts are independent opt-ins; both are off by default. Enable them in +an application theme that includes the native theme: + +[source,css] +---- +include::../demos/common/src/main/css/guide-snippets-theme.css[tag=native-theme-os-settings,indent=0] +---- + +Alternatively, configure the `UIManager` before loading the application theme: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/NativeThemesJava001Snippet.java[tag=native-theme-os-settings,indent=0] +---- + +An explicit theme constant takes precedence over its Java setter when the theme +is built. After changing a setter on an already loaded theme, call +`UIManager.refreshTheme()` and refresh/revalidate the visible form. A theme +constant set to `false` must also be changed or removed before a setter can enable +that option. + +Color inheritance feeds the theme's semantic palette bindings. Font inheritance +uses the normal OS UI font family and size while retaining the native theme's +relative heading sizes and styles. Explicit application style properties and +palette constants override these inherited defaults. Bundled application fonts +and icon fonts aren't replaced. Legacy hand-authored native themes don't carry +the inheritance metadata and retain their existing appearance. + +The existing larger-text accessibility option remains independent. It scales +eligible fonts after OS font inheritance, so enabling both doesn't apply the OS +text-size multiplier twice. Existing dark-mode selection remains independent too. + +Settings changes and application activation trigger a coalesced theme refresh. +The framework preserves application overrides, refreshes visible styles and +layout, and restores bundled defaults when settings become unavailable or an +inheritance option is disabled. Unsupported settings fall back individually. + +[cols="1,3", options="header"] +|=== +|Port |Settings source +|Windows native |Windows UISettings accent/selection palette, high-contrast system +colors, and the nonclient message font. Normal Fluent surfaces retain their +bundled colors where no corresponding OS value is exposed. +|macOS native |AppKit semantic colors and normal system UI typography. +|Linux native |The active GTK palette and GTK font setting. Only available named +colors are inherited; the opposite appearance retains its bundled palette. +|Android |Platform normal text appearance and, on Android 12 or newer, public +dynamic-color resources. Older versions retain the bundled palette. +|iOS |UIKit semantic colors on iOS 13 or newer and normal system UI typography. +|JavaSE |Available AWT desktop properties; the simulator uses explicit simulated +settings rather than the host's palette. +|=== + +In the simulator, use *Simulate > OS Theme Settings* to choose an accent and UI +font or reset the simulated settings. These controls exercise the app's opt-ins; +they don't enable inheritance for the app. Tests can supply a deterministic +snapshot with `JavaSEPort.setSimulatorNativeThemeSettings(...)`. + === Selecting a theme Three build hints control the platform native theme. None of them 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 cff76a5f0a3..d8833551ac4 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 @@ -1650,6 +1650,8 @@ public void reset() { largerTextEnabled = false; largerTextScale = 1f; nativeTheme = null; + nativeThemeSettings = new com.codename1.impl.NativeThemeSettings(); + nativeDarkMode = Boolean.FALSE; cameraImpl = null; arImpl = null; visionImplCreationHook = null; @@ -3209,6 +3211,20 @@ public boolean isLargerTextEnabled() { return largerTextEnabled; } + @Override + public com.codename1.impl.NativeThemeSettings getNativeThemeSettings() { + return nativeThemeSettings.copy(); + } + + @Override + public Boolean isDarkMode() { + return nativeDarkMode; + } + + public Boolean nativeDarkMode = Boolean.FALSE; + + public com.codename1.impl.NativeThemeSettings nativeThemeSettings = new com.codename1.impl.NativeThemeSettings(); + @Override public float getLargerTextScale() { return largerTextScale; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerNativeSettingsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerNativeSettingsTest.java new file mode 100644 index 00000000000..f26729b5932 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/plaf/UIManagerNativeSettingsTest.java @@ -0,0 +1,593 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ui.plaf; + +import com.codename1.impl.NativeThemeSettings; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Font; +import java.util.Hashtable; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class UIManagerNativeSettingsTest extends UITestBase { + private UIManager manager; + + @BeforeEach + void resetSettings() { + manager = UIManager.getInstance(); + display.setDarkMode(null); + manager.setUseNativeColors(false); + manager.setUseNativeFonts(false); + manager.setUseLargerTextScale(false); + implementation.setLargerTextEnabled(false); + implementation.nativeThemeSettings = new NativeThemeSettings().color("accent-color", 0x00aa44) + .font("native:", 24); + } + + private Hashtable nativeTheme() { + Hashtable theme = new Hashtable(); + Font font = Font.createTrueTypeFont(Font.NATIVE_MAIN_REGULAR); + Font.clearDerivedFontCache(); + font = font.derive(16f, Font.STYLE_PLAIN); + theme.put("@nativeThemeDefaultsBool", "true"); + theme.put("@accent-color", "112233"); + theme.put("@cn1-bind:Button.fgColor", "accent-color"); + theme.put("Button.fgColor", "112233"); + assertEquals(16f, font.getPixelSize(), "fixture"); + theme.put("font", font); + theme.put("Button.font", font); + theme.put("Heading.font", font.derive(32f, Font.STYLE_BOLD)); + return theme; + } + + private float size(String uiid) { return manager.getComponentStyle(uiid).getFont().getPixelSize(); } + private int color() { return manager.getComponentStyle("Button").getFgColor(); } + + @Test + void optInsAreIndependentAndOffByDefault() { + manager.setThemeProps(nativeTheme()); + assertEquals(0x112233, color()); + assertEquals(16, size("Button")); + manager.setUseNativeColors(true); + manager.refreshTheme(); + assertEquals(0x00aa44, color()); + assertEquals(16, size("Button")); + manager.setUseNativeFonts(true); + manager.refreshTheme(); + assertEquals(24, size("Button")); + assertEquals(48, size("Heading")); + } + + @Test + void refreshAndDisablingRestoreOriginalsWithoutCompounding() { + manager.setUseNativeColors(true); + manager.setUseNativeFonts(true); + manager.setUseLargerTextScale(true); + implementation.setLargerTextEnabled(true); + implementation.setLargerTextScale(1.5f); + manager.setThemeProps(nativeTheme()); + for (int i = 0; i < 3; i++) { + manager.refreshTheme(); + assertEquals(36, size("Button")); + assertEquals(72, size("Heading")); + } + implementation.nativeThemeSettings = new NativeThemeSettings().color("accent-color", 0xcc6600) + .font("native:", 20); + manager.refreshNativeThemeSettings(); + assertEquals(30, size("Button")); + assertEquals(0xcc6600, color()); + manager.setUseNativeFonts(false); + manager.setUseNativeColors(false); + manager.refreshTheme(); + assertEquals(24, size("Button")); + assertEquals(0x112233, color()); + manager.setUseLargerTextScale(false); + manager.refreshTheme(); + assertEquals(16, size("Button")); + } + + @Test + void zoomRetainsNativeFontRestoration() { + assertZoomRetainsNativeFontRestoration(1f); + } + + @Test + void zoomRetainsNativeFontRestorationWithAccessibilityScaling() { + assertZoomRetainsNativeFontRestoration(1.5f); + } + + private void assertZoomRetainsNativeFontRestoration(float accessibilityScale) { + manager.setUseNativeFonts(true); + manager.setUseLargerTextScale(true); + implementation.setLargerTextEnabled(true); + implementation.setLargerTextScale(accessibilityScale); + manager.setThemeProps(nativeTheme()); + Hashtable app = new Hashtable(); + app.put("App.font", Font.createTrueTypeFont(Font.NATIVE_MAIN_REGULAR).derive(19f, Font.STYLE_PLAIN)); + manager.addThemeProps(app); + manager.zoomFonts(2f); + assertEquals(48f * accessibilityScale, size("Button")); + assertEquals(96f * accessibilityScale, size("Heading")); + assertEquals(38f * accessibilityScale, size("App")); + for (int i = 0; i < 2; i++) { + manager.refreshTheme(); + assertEquals(48f * accessibilityScale, size("Button")); + } + implementation.nativeThemeSettings = new NativeThemeSettings().font("native:", 20); + manager.refreshNativeThemeSettings(); + assertEquals(40f * accessibilityScale, size("Button")); + assertEquals(80f * accessibilityScale, size("Heading")); + manager.setUseNativeFonts(false); + manager.refreshTheme(); + assertEquals(32f * accessibilityScale, size("Button")); + manager.setUseLargerTextScale(false); + manager.refreshTheme(); + assertEquals(32f, size("Button")); + assertEquals(38f, size("App")); + } + + @Test + void overlayRefreshUsesInheritedControlStylesOnInitialLoad() throws Exception { + assertOverlayRefreshUsesInheritedControlStyles(false); + } + + @Test + void overlayRefreshUsesInheritedControlStylesWhenAddingTheme() throws Exception { + assertOverlayRefreshUsesInheritedControlStyles(true); + } + + private void assertOverlayRefreshUsesInheritedControlStyles(boolean add) throws Exception { + manager.setUseNativeColors(true); + manager.setUseNativeFonts(true); + Hashtable overlay = new Hashtable(); + overlay.put("@overlayMarker", "loaded"); + com.codename1.ui.util.EditableResources resources = new com.codename1.ui.util.EditableResources(); + resources.setTheme("overlay", overlay); + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + resources.save(bytes); + String resourceName = "native-settings-overlay-" + add; + implementation.putResource("/" + resourceName + ".res", + new java.io.ByteArrayInputStream(bytes.toByteArray())); + Hashtable theme = nativeTheme(); + theme.put("CheckBox.font", theme.get("Button.font")); + theme.put("CheckBox.fgColor", "112233"); + theme.put("@cn1-bind:CheckBox.fgColor", "accent-color"); + theme.put("@OverlayThemes", resourceName); + final java.util.List