diff --git a/.github/workflows/designer.yml b/.github/workflows/designer.yml index 0fac4af16c..164162b7ec 100644 --- a/.github/workflows/designer.yml +++ b/.github/workflows/designer.yml @@ -6,6 +6,7 @@ on: - master paths: - 'CodenameOneDesigner/**' + - 'maven/css-compiler/**' - 'maven/designer/**' - '.github/workflows/designer.yml' pull_request: @@ -13,6 +14,7 @@ on: - master paths: - 'CodenameOneDesigner/**' + - 'maven/css-compiler/**' - 'maven/designer/**' - '.github/workflows/designer.yml' @@ -38,30 +40,47 @@ jobs: wget https://github.com/codenameone/cn1-binaries/archive/refs/heads/master.zip unzip master.zip -d .. mv ../cn1-binaries-master ../cn1-binaries + mkdir -p maven/target + rm -rf maven/target/cn1-binaries + cp -R ../cn1-binaries maven/target/cn1-binaries - - name: Build core dependencies + - name: Build Designer (pulls in codenameone-css-compiler as dep) + env: + CN1_BINARIES: ${{ github.workspace }}/maven/target/cn1-binaries run: | - xvfb-run -a ant -noinput -buildfile Ports/CLDC11/build.xml jar - xvfb-run -a ant -noinput -buildfile Ports/JavaSE/build.xml jar - xvfb-run -a ant -noinput -buildfile Ports/JavaSEWithSVGSupport/build.xml jar - xvfb-run -a ant -noinput -buildfile CodenameOne/build.xml jar - - - name: Run designer CSS localization tests - run: xvfb-run -a ant -noinput -buildfile CodenameOneDesigner/build.xml test-css-localization + cd maven + mvn -B -pl designer -am -DskipTests -Plocal-dev-javase \ + -Dmaven.javadoc.skip=true -Dcn1.binaries="${CN1_BINARIES}" install - name: Run designer XML parser unit tests (Maven) + env: + CN1_BINARIES: ${{ github.workspace }}/maven/target/cn1-binaries run: | - mkdir -p maven/target - rm -rf maven/target/cn1-binaries - cp -R ../cn1-binaries maven/target/cn1-binaries cd maven - mvn -B -pl designer -am -DunitTests=true -Dcodename1.platform=javase -Plocal-dev-javase -Dmaven.javadoc.skip=true -Dmaven.antrun.skip=true -Dtest=SimpleXmlParserTest -DfailIfNoTests=false test - - - name: Build designer release jar - run: xvfb-run -a ant -noinput -buildfile CodenameOneDesigner/build.xml release + mvn -B -pl designer -am -DunitTests=true -Dcodename1.platform=javase \ + -Plocal-dev-javase -Dmaven.javadoc.skip=true -Dmaven.antrun.skip=true \ + -Dcn1.binaries="${CN1_BINARIES}" \ + -Dtest=SimpleXmlParserTest -DfailIfNoTests=false test - name: Verify designer CLI CSS compilation run: | + # The Maven-built jar-with-dependencies is a ZIP wrapper around the + # actual runnable designer_1.jar (see the antrun + # add-designer-jar-with-dependencies execution in maven/designer/pom.xml). + # Unpack and run the inner jar directly. + wrapped=$(ls maven/designer/target/codenameone-designer-*-jar-with-dependencies.jar | head -n1) + if [ -z "${wrapped}" ] || [ ! -f "${wrapped}" ]; then + echo "designer jar-with-dependencies not found" >&2 + exit 1 + fi + extract_dir="$(mktemp -d)" + unzip -q "${wrapped}" -d "${extract_dir}" + designer_jar="${extract_dir}/designer_1.jar" + if [ ! -f "${designer_jar}" ]; then + echo "designer_1.jar not found inside ${wrapped}" >&2 + ls -la "${extract_dir}" + exit 1 + fi tmp_dir="CodenameOneDesigner/tmp-cli-test" css_file="$tmp_dir/test.css" l10n_dir="$tmp_dir/localization" @@ -75,12 +94,21 @@ jobs: cat <<'EOF' > "$l10n_dir/Strings.properties" greeting=Hello from CLI EOF - xvfb-run -a java -Dcli=true -jar CodenameOneDesigner/dist/designer.jar \ + xvfb-run -a java -Dcli=true -jar "${designer_jar}" \ -css -stateless -input "$css_file" -output "$output_file" -localization "$l10n_dir" test -s "$output_file" + - name: Verify native-themes CEF-free build + run: | + cd maven + mvn -B -pl css-compiler -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase + cd .. + ./scripts/build-native-themes.sh + test -f Themes/iOSModernTheme.res || { echo "missing Themes/iOSModernTheme.res"; exit 1; } + test -f Themes/AndroidMaterialTheme.res || { echo "missing Themes/AndroidMaterialTheme.res"; exit 1; } + - name: Upload designer jar artifact uses: actions/upload-artifact@v4 with: name: designer-jar - path: CodenameOneDesigner/dist/designer.jar + path: maven/designer/target/codenameone-designer-*-jar-with-dependencies.jar diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5e254c1b29..06dc633b27 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -89,6 +89,14 @@ jobs: cd maven mvn clean verify -DunitTests=true -pl core-unittests -am -Dmaven.javadoc.skip=true -Plocal-dev-javase $MVN_ARGS cd .. + - name: Build CSS compiler and smoke native-themes + run: | + cd maven + mvn -B -pl css-compiler -am install -DskipTests -Dmaven.javadoc.skip=true -Plocal-dev-javase + cd .. + ./scripts/build-native-themes.sh + test -f Themes/iOSModernTheme.res || { echo "missing Themes/iOSModernTheme.res"; exit 1; } + test -f Themes/AndroidMaterialTheme.res || { echo "missing Themes/AndroidMaterialTheme.res"; exit 1; } - name: Prepare Codename One binaries for Maven plugin tests run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 7ee5bfbc32..82e5eba372 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ node_modules **/genfiles.properties **/private/private.properties .idea/ +.claude/ target pom.xml.versionsBackup pom.xml.releaseBackup diff --git a/CodenameOne/src/com/codename1/io/Util.java b/CodenameOne/src/com/codename1/io/Util.java index 509bc54b46..6135b6ce29 100644 --- a/CodenameOne/src/com/codename1/io/Util.java +++ b/CodenameOne/src/com/codename1/io/Util.java @@ -213,7 +213,7 @@ public static void copy(InputStream i, OutputStream o, int bufferSize) throws IO } } - /// Closes the object (connection, stream etc.) without throwing any exception, even if the +/// Closes the object (connection, stream etc.) without throwing any exception, even if the /// object is null /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java index ed4e4a580f..448da975a2 100644 --- a/CodenameOne/src/com/codename1/ui/Font.java +++ b/CodenameOne/src/com/codename1/ui/Font.java @@ -23,7 +23,6 @@ */ package com.codename1.ui; -import com.codename1.impl.CodenameOneImplementation; import java.util.HashMap; import java.util.Hashtable; @@ -171,9 +170,7 @@ public class Font extends CN { } Font(int face, int style, int size) { - Display d = Display.getInstance(); - CodenameOneImplementation i = d.getImplementation(); - font = i.createFont(face, style, size); + font = Display.getInstance().getImplementation().createFont(face, style, size); } /// Returns a previously loaded bitmap font from cache diff --git a/CodenameOne/src/com/codename1/ui/Tabs.java b/CodenameOne/src/com/codename1/ui/Tabs.java index a3352ba51b..786fefc565 100644 --- a/CodenameOne/src/com/codename1/ui/Tabs.java +++ b/CodenameOne/src/com/codename1/ui/Tabs.java @@ -94,6 +94,13 @@ public class Tabs extends Container { private final Container contentPane = new Container(new TabsLayout()); private final Container tabsContainer; + /// Optional wrapper around `tabsContainer` whose only job is to absorb the + /// safe-area inset when the theme opts out of internal safe-area padding + /// via the `tabsSafeAreaBool` constant. With the wrapper present, the + /// pill (`tabsContainer`) draws tightly while the wrapper's padding keeps + /// the pill clear of the home indicator. `null` for legacy themes that + /// keep the safe-area inset on the pill itself. + private Container tabsContainerHost; private final ButtonGroup radioGroup = new ButtonGroup(); private final ActionListener press; private final ActionListener drag; @@ -151,11 +158,37 @@ public Tabs(int tabP) { contentPane.setUIID("TabbedPane"); super.addComponent(BorderLayout.CENTER, contentPane); tabsContainer = new Container(); - tabsContainer.setSafeArea(true); + // tabsSafeAreaBool=true (default): legacy / flush-bar themes keep the + // safe-area inset as PADDING on the pill itself - the bar's + // background reaches the screen edge with tabs sitting above the + // home indicator. + // + // tabsSafeAreaBool=false (modern floating pill): the safe-area inset + // moves to a wrapper container so the pill draws tightly and is + // pushed up away from the indicator without extending its own + // background into the indicator zone. + boolean tabsSafeAreaOnPill = getUIManager().isThemeConstant("tabsSafeAreaBool", true); + tabsContainer.setSafeArea(tabsSafeAreaOnPill); tabsContainer.setUIID("TabsContainer"); tabsContainer.setScrollVisible(false); tabsContainer.getStyle().setMargin(0, 0, 0, 0); + if (!tabsSafeAreaOnPill) { + tabsContainerHost = new Container(new BorderLayout()); + tabsContainerHost.setUIID("Container"); + tabsContainerHost.setSafeArea(true); + tabsContainerHost.add(BorderLayout.CENTER, tabsContainer); + } if (tabP == -1) { + // Honor the tabPlacementInt theme constant when no explicit + // placement was requested. Reading the constant here (rather + // than only in initLaf) guarantees the value is seen even + // when initLaf runs polymorphically from Component()'s super + // ctor - at that point the Tabs subclass fields haven't been + // initialised yet and writes to them are brittle. + int themePlacement = getUIManager().getThemeConstant("tabPlacementInt", -1); + if (themePlacement != -1) { + tabPlacement = themePlacement; + } setTabPlacement(tabPlacement); } else { setTabPlacement(tabP); @@ -216,8 +249,20 @@ protected void initLaf(UIManager manager) { } } changeTabContainerStyleOnFocus = manager.isThemeConstant("changeTabContainerStyleOnFocusBool", false); + // tabPlacementInt lets a theme dictate whether tabs live at TOP / + // BOTTOM / LEFT / RIGHT. initLaf is called both during the + // Component() super() chain (before the Tabs ctor body has + // allocated tabsContainer) and again later when styles refresh. + // First call: tabsContainer is null, so just stash the value in + // the field; the ctor's setTabPlacement call at the end will + // pick it up and move the (then-allocated) container. + // Second call and beyond: container exists, so reparent it. if (tabPlace != -1) { - tabPlacement = tabPlace; + if (tabsContainer == null) { + tabPlacement = tabPlace; + } else if (tabPlace != tabPlacement) { + setTabPlacement(tabPlace); + } } } @@ -588,11 +633,28 @@ protected Component createTab(String title, Font font, char icon, float size) { if (tabUIID != null) { b.setUIID(tabUIID); } + applyTabIconUIID(b); b.setFontIcon(font, icon, size); createTabImpl(b); return b; } + /// Detaches the tab's icon style from the Button's selection-state styles. + /// FontImage.setIcon copies the Button's unselected/selected/pressed styles + /// to render four icon variants - which means the icon image carries the + /// Button's bgColor and bgTransparency. With a `cn1-pill-border` selected + /// background, that produces a visible square fill behind the glyph that + /// doesn't follow the pill's rounded shape. Reading `tabIconUIID` from the + /// theme lets a theme route the icon styling to a separate UIID + /// (typically `TabIcon`) where it can be declared transparent. Themes that + /// don't define the constant get the legacy behavior unchanged. + private void applyTabIconUIID(Component b) { + String iconUiid = getUIManager().getThemeConstant("tabIconUIID", null); + if (iconUiid != null && iconUiid.length() > 0 && b instanceof Label) { + ((Label) b).setIconUIID(iconUiid); + } + } + /// Creates a tab component by default this is a RadioButton but subclasses can use this to return anything /// /// #### Parameters @@ -606,6 +668,7 @@ protected Component createTab(String title, Font font, char icon, float size) { /// component instance protected Component createTab(String title, Image icon) { RadioButton b = new RadioButton(title != null ? title : "", icon); + applyTabIconUIID(b); createTabImpl(b); return b; } @@ -1135,22 +1198,23 @@ public void setTabPlacement(int tabPlacement) { tabPlacement != BOTTOM && tabPlacement != RIGHT) { throw new IllegalArgumentException("illegal tab placement: must be TOP, BOTTOM, LEFT, or RIGHT"); } - if (this.tabPlacement == tabPlacement && tabsContainer.getParent() == null && isInitialized()) { + Container slotComponent = tabsContainerHost != null ? tabsContainerHost : tabsContainer; + if (this.tabPlacement == tabPlacement && slotComponent.getParent() == null && isInitialized()) { return; } this.tabPlacement = tabPlacement; - removeComponent(tabsContainer); + removeComponent(slotComponent); setTabsLayout(tabPlacement); if (tabPlacement == TOP) { - super.addComponent(BorderLayout.NORTH, tabsContainer); + super.addComponent(BorderLayout.NORTH, slotComponent); } else if (tabPlacement == BOTTOM) { - super.addComponent(BorderLayout.SOUTH, tabsContainer); + super.addComponent(BorderLayout.SOUTH, slotComponent); } else if (tabPlacement == LEFT) { - super.addComponent(BorderLayout.WEST, tabsContainer); + super.addComponent(BorderLayout.WEST, slotComponent); } else { // RIGHT - super.addComponent(BorderLayout.EAST, tabsContainer); + super.addComponent(BorderLayout.EAST, slotComponent); } initTabsFocus(); @@ -1238,7 +1302,7 @@ protected void selectTab(Component tab) { /// Hide the tabs bar public void hideTabs() { - removeComponent(tabsContainer); + removeComponent(tabsContainerHost != null ? tabsContainerHost : tabsContainer); revalidateLater(); } diff --git a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java index b24a04e8e3..ea50206b3e 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java +++ b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java @@ -2443,15 +2443,24 @@ private void updateCheckBoxConstants(UIManager m, boolean focus, String append) Style unsel = uim.createStyle("CheckBox.", "", false); Style sel = uim.createStyle("CheckBox.", "sel#", true); Style dis = uim.createStyle("CheckBox.", "dis#", false); - FontImage checkedDis = FontImage.createMaterial(FontImage.MATERIAL_CHECK_BOX, dis); - FontImage uncheckedDis = FontImage.createMaterial(FontImage.MATERIAL_CHECK_BOX_OUTLINE_BLANK, sel); + // Optional theme constants to swap the default Material + // check-box icons. Useful for platform-native looks (e.g. + // iOS modern uses CHECK_CIRCLE / RADIO_BUTTON_UNCHECKED + // for a rounded-circle aesthetic). Defaults to the legacy + // square check-box glyphs so existing themes are unaffected. + char checkedIcon = (char) uim.getThemeConstant( + "checkBoxCheckedIconInt", FontImage.MATERIAL_CHECK_BOX); + char uncheckedIcon = (char) uim.getThemeConstant( + "checkBoxUncheckedIconInt", FontImage.MATERIAL_CHECK_BOX_OUTLINE_BLANK); + FontImage checkedDis = FontImage.createMaterial(checkedIcon, dis); + FontImage uncheckedDis = FontImage.createMaterial(uncheckedIcon, sel); if (focus) { - FontImage checkedSelected = FontImage.createMaterial(FontImage.MATERIAL_CHECK_BOX, sel); - FontImage uncheckedSelected = FontImage.createMaterial(FontImage.MATERIAL_CHECK_BOX_OUTLINE_BLANK, sel); + FontImage checkedSelected = FontImage.createMaterial(checkedIcon, sel); + FontImage uncheckedSelected = FontImage.createMaterial(uncheckedIcon, sel); setCheckBoxFocusImages(checkedSelected, uncheckedSelected, checkedDis, uncheckedDis); } else { - FontImage checkedUnselected = FontImage.createMaterial(FontImage.MATERIAL_CHECK_BOX, unsel); - FontImage uncheckedUnselected = FontImage.createMaterial(FontImage.MATERIAL_CHECK_BOX_OUTLINE_BLANK, unsel); + FontImage checkedUnselected = FontImage.createMaterial(checkedIcon, unsel); + FontImage uncheckedUnselected = FontImage.createMaterial(uncheckedIcon, unsel); setCheckBoxImages(checkedUnselected, uncheckedUnselected, checkedDis, uncheckedDis); } } @@ -2484,15 +2493,21 @@ private void updateRadioButtonConstants(UIManager m, boolean focus, String appen Style unsel = uim.createStyle("RadioButton.", "", false); Style sel = uim.createStyle("RadioButton.", "sel#", true); Style dis = uim.createStyle("RadioButton.", "dis#", false); - FontImage checkedDis = FontImage.createMaterial(FontImage.MATERIAL_RADIO_BUTTON_CHECKED, dis); - FontImage uncheckedDis = FontImage.createMaterial(FontImage.MATERIAL_RADIO_BUTTON_UNCHECKED, sel); + // Same override pattern as the check-box icons above - + // theme constants can swap the default circle glyphs. + char checkedIcon = (char) uim.getThemeConstant( + "radioCheckedIconInt", FontImage.MATERIAL_RADIO_BUTTON_CHECKED); + char uncheckedIcon = (char) uim.getThemeConstant( + "radioUncheckedIconInt", FontImage.MATERIAL_RADIO_BUTTON_UNCHECKED); + FontImage checkedDis = FontImage.createMaterial(checkedIcon, dis); + FontImage uncheckedDis = FontImage.createMaterial(uncheckedIcon, sel); if (focus) { - FontImage checkedSelected = FontImage.createMaterial(FontImage.MATERIAL_RADIO_BUTTON_CHECKED, sel); - FontImage uncheckedSelected = FontImage.createMaterial(FontImage.MATERIAL_RADIO_BUTTON_UNCHECKED, sel); + FontImage checkedSelected = FontImage.createMaterial(checkedIcon, sel); + FontImage uncheckedSelected = FontImage.createMaterial(uncheckedIcon, sel); setRadioButtonFocusImages(checkedSelected, uncheckedSelected, checkedDis, uncheckedDis); } else { - FontImage checkedUnselected = FontImage.createMaterial(FontImage.MATERIAL_RADIO_BUTTON_CHECKED, unsel); - FontImage uncheckedUnselected = FontImage.createMaterial(FontImage.MATERIAL_RADIO_BUTTON_UNCHECKED, unsel); + FontImage checkedUnselected = FontImage.createMaterial(checkedIcon, unsel); + FontImage uncheckedUnselected = FontImage.createMaterial(uncheckedIcon, unsel); setRadioButtonImages(checkedUnselected, uncheckedUnselected, checkedDis, uncheckedDis); } } diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index ddf30a4c99..bf15bd88da 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -1320,7 +1320,17 @@ private void resetThemeProps(Hashtable installedTheme) { themeProps.put("CommandList.sel#border", Border.createLineBorder(1)); } - if (installedTheme == null || !installedTheme.containsKey("Toolbar.derive")) { + // The default Toolbar.derive=TitleArea was historically added so + // legacy themes that styled TitleArea got the same look on Toolbar + // for free. The modern themes (and any user theme that wires + // TitleArea.derive=Toolbar) flips the relationship the other way - + // setting both directions creates a cycle that infinite-loops + // createStyle when the resolver follows derive recursively. Skip + // the legacy default in that case. + boolean userDeclaredTitleAreaDerivesToolbar = installedTheme != null + && "Toolbar".equals(installedTheme.get("TitleArea.derive")); + if (!userDeclaredTitleAreaDerivesToolbar + && (installedTheme == null || !installedTheme.containsKey("Toolbar.derive"))) { themeProps.put("Toolbar.derive", "TitleArea"); } if (installedTheme == null || !installedTheme.containsKey("FloatingActionButton.derive")) { @@ -1587,6 +1597,35 @@ private Font scaleFontByFactor(Font font, float factor) { } } + /// Invalidates the cached Style instances and re-runs the theme build pass + /// against the currently installed theme properties. Callers use this after + /// state changes that affect style resolution (notably `Display.setDarkMode`, + /// which makes `$Dark` entries eligible) without reloading the theme + /// from a resource file. Components styled after this call resolve against + /// the refreshed theme; already-resolved Style references on existing + /// components keep their old values until those components re-fetch their + /// styles. + public void refreshTheme() { + if (!accessible || themeProps == null) { + return; + } + Hashtable props = new Hashtable(); + for (Map.Entry e : themeProps.entrySet()) { + props.put(e.getKey(), e.getValue()); + } + // buildTheme strips `@`-prefixed constants into themeConstants and + // drops them from the main themeProps map. Round-tripping through + // setThemePropsImpl would therefore lose every constant - so + // re-add them from themeConstants with the `@` restored, matching + // the shape buildTheme expects on input. + if (themeConstants != null) { + for (Map.Entry e : themeConstants.entrySet()) { + props.put("@" + e.getKey(), e.getValue()); + } + } + setThemePropsImpl(props); + } + /// Returns a theme constant defined in the resource editor /// /// #### Parameters @@ -1714,9 +1753,35 @@ void setThemePropsImpl(Hashtable themeProps) { themelisteners.fireActionEvent(new ActionEvent(themeProps, ActionEvent.Type.Theme)); } buildTheme(themeProps); + breakTitleAreaToolbarDeriveCycle(); current.refreshTheme(true); } + /// resetThemeProps decides whether to install the legacy + /// `Toolbar.derive=TitleArea` default by inspecting only the *immediate* + /// installedTheme it was handed. When a user theme has + /// `@includeNativeBool: true`, buildTheme later layers in a native theme + /// (e.g. iOS Modern's `TitleArea.derive=Toolbar`) and the user theme on + /// top - and those layers can flip the derive direction without the + /// outer reset noticing. Once both `Toolbar.derive=TitleArea` and + /// `TitleArea.derive=Toolbar` exist in the merged themeProps, + /// `createStyle` recurses indefinitely and Logs `Error creating style + /// TitleArea` (the catch returns a default style, but the cycle leaves + /// the chrome unstyled and the app effectively stuck). Drop the legacy + /// default once we can see the merged state. + private void breakTitleAreaToolbarDeriveCycle() { + if (themeProps == null) { + return; + } + Object titleAreaDerive = themeProps.get("TitleArea.derive"); + if ("Toolbar".equals(titleAreaDerive)) { + Object toolbarDerive = themeProps.get("Toolbar.derive"); + if ("TitleArea".equals(toolbarDerive)) { + themeProps.remove("Toolbar.derive"); + } + } + } + private void buildTheme(Hashtable themeProps) { String con = (String) themeProps.get("@includeNativeBool"); if (con != null && "true".equalsIgnoreCase(con) && Display.getInstance().hasNativeTheme()) { diff --git a/CodenameOneDesigner/src/com/codename1/designer/AddThemeResource.java b/CodenameOneDesigner/src/com/codename1/designer/AddThemeResource.java index 368d88911d..6972672a57 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/AddThemeResource.java +++ b/CodenameOneDesigner/src/com/codename1/designer/AddThemeResource.java @@ -26,6 +26,7 @@ package com.codename1.designer; import com.codename1.ui.util.EditableResources; +import com.codename1.ui.util.EditableResourcesEditor; import java.io.IOException; import java.io.InputStream; import java.util.Hashtable; @@ -198,7 +199,7 @@ public String addResource(EditableResources res, ResourceEditorView view) { InputStream is = getClass().getResourceAsStream("/templates/" + template.getSelectedItem().toString() + ".res"); if(is != null) { try { - EditableResources r = new EditableResources(); + EditableResources r = new EditableResourcesEditor(); r.openFile(is); is.close(); theme = r.getTheme(r.getThemeResourceNames()[0]); diff --git a/CodenameOneDesigner/src/com/codename1/designer/AddUIResource.java b/CodenameOneDesigner/src/com/codename1/designer/AddUIResource.java index b4e4c2b2b2..a46c1b8901 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/AddUIResource.java +++ b/CodenameOneDesigner/src/com/codename1/designer/AddUIResource.java @@ -26,6 +26,7 @@ package com.codename1.designer; import com.codename1.ui.util.EditableResources; +import com.codename1.ui.util.EditableResourcesEditor; import java.io.IOException; import java.io.InputStream; import java.util.Hashtable; @@ -199,7 +200,7 @@ public String addResource(EditableResources res, ResourceEditorView view) { InputStream is = getClass().getResourceAsStream("/templates/" + template.getSelectedItem().toString() + ".res"); if(is != null) { try { - EditableResources r = new EditableResources(); + EditableResources r = new EditableResourcesEditor(); r.openFile(is); is.close(); ui = r.getResourceObject("Main"); diff --git a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java index bd068a68f1..a33b44bde6 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java +++ b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorApp.java @@ -36,7 +36,8 @@ //import com.codename1.impl.javase.JavaFXLoader; import com.codename1.ui.plaf.Style; import com.codename1.ui.resource.util.QuitAction; -import com.codename1.ui.util.EditableResources; +import com.codename1.ui.util.EditableResources; +import com.codename1.ui.util.EditableResourcesEditor; import com.codename1.ui.util.Resources; import com.codename1.ui.util.UIBuilderOverride; import java.awt.BorderLayout; @@ -298,7 +299,7 @@ public static void _main(String[] args) throws Exception { boolean isXMLEnabled = Preferences.userNodeForPackage(ResourceEditorView.class).getBoolean("XMLFileMode", true); EditableResources.setXMLEnabled(isXMLEnabled); - EditableResources res = new EditableResources(); + EditableResources res = new EditableResourcesEditor(); File resourceFile = new File(args[1]); res.openFileWithXMLSupport(resourceFile); @@ -424,7 +425,7 @@ public void actionPerformed(ActionEvent e) { boolean isXMLEnabled = Preferences.userNodeForPackage(ResourceEditorView.class).getBoolean("XMLFileMode", true); EditableResources.setXMLEnabled(isXMLEnabled); - EditableResources res = new EditableResources(); + EditableResources res = new EditableResourcesEditor(); res.openFileWithXMLSupport(resourceFile); res.setImage(imageName, img); @@ -499,7 +500,7 @@ public void actionPerformed(ActionEvent e) { boolean isXMLEnabled = Preferences.userNodeForPackage(ResourceEditorView.class).getBoolean("XMLFileMode", true); EditableResources.setXMLEnabled(isXMLEnabled); - EditableResources res = new EditableResources(); + EditableResources res = new EditableResourcesEditor(); res.openFileWithXMLSupport(resourceFile); AddAndScaleMultiImage.generateImpl(new File[] {imageFile}, @@ -524,7 +525,7 @@ public void actionPerformed(ActionEvent e) { com.codename1.ui.Display.init(cnt); File projectDir = new File(args[1]); EditableResources.setXMLEnabled(true); - EditableResources res = new EditableResources(); + EditableResources res = new EditableResourcesEditor(); res.openFileWithXMLSupport(new File(args[2])); migrateGuiBuilder(projectDir, res, args[3]); System.exit(0); @@ -535,7 +536,7 @@ public void actionPerformed(ActionEvent e) { com.codename1.ui.Display.init(cnt); File output = new File(args[1]); EditableResources.setXMLEnabled(true); - EditableResources res = new EditableResources(); + EditableResources res = new EditableResourcesEditor(); res.openFileWithXMLSupport(output); FileOutputStream fos = new FileOutputStream(output); res.save(fos); @@ -550,7 +551,7 @@ public void actionPerformed(ActionEvent e) { com.codename1.ui.Display.init(cnt); File output = new File(args[1]); EditableResources.setXMLEnabled(true); - EditableResources res = new EditableResources(); + EditableResources res = new EditableResourcesEditor(); res.openFileWithXMLSupport(output); FileOutputStream fos = new FileOutputStream(output); res.save(fos); @@ -579,7 +580,7 @@ public void actionPerformed(ActionEvent e) { private static void generateResourceFile(File f, String themeName, String ui) throws Exception { System.out.println("Generating resource file " + f + " theme " + themeName + " template " + ui); - EditableResources res = new EditableResources(); + EditableResources res = new EditableResourcesEditor(); //"native", "leather", "tzone", "tipster", "blank" String template = "Native_Theme"; @@ -1232,7 +1233,7 @@ private static Hashtable importRes(EditableResources res, String file) { Hashtable theme = new Hashtable(); if(is != null) { try { - EditableResources r = new EditableResources(); + EditableResources r = new EditableResourcesEditor(); r.openFile(is); is.close(); if(r.getThemeResourceNames().length > 0) { diff --git a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java index e124a7b015..ca3b798651 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java +++ b/CodenameOneDesigner/src/com/codename1/designer/ResourceEditorView.java @@ -35,6 +35,7 @@ import com.codename1.ui.plaf.Border; import com.codename1.tools.resourcebuilder.ThemeTaskConstants; import com.codename1.ui.util.EditableResources; +import com.codename1.ui.util.EditableResourcesEditor; import com.codename1.ui.Font; import com.codename1.ui.animations.AnimationAccessor; import com.codename1.ui.animations.Timeline; @@ -152,7 +153,7 @@ public class ResourceEditorView extends FrameView { private HelpAction helpAction = new HelpAction(); private static final String IMAGE_DIR = "/com/codename1/designer/resources/"; - private static EditableResources loadedResources = new EditableResources(); + private static EditableResources loadedResources = new EditableResourcesEditor(); private Properties projectGeneratorSettings; private static String manualIDESettings; private List recentFiles = new ArrayList(); @@ -509,7 +510,7 @@ public void actionPerformed(ActionEvent ae) { } File f = getPlatformOverrideFile(); if(f != null) { - EditableResources platformOverrideResource = new EditableResources(); + EditableResources platformOverrideResource = new EditableResourcesEditor(); if(f.exists()) { try { platformOverrideResource.openFile(new FileInputStream(f)); @@ -657,7 +658,7 @@ public void setSelectedResource(String selectedResource) { // tree tries to restore selection sometimes with a non-existing resource: for(String s : loadedResources.getResourceNames()) { if(s.equals(selectedResource)) { - BaseForm b = (BaseForm)loadedResources.getResourceEditor(selectedResource, ResourceEditorView.this); + BaseForm b = (BaseForm)((EditableResourcesEditor)loadedResources).getResourceEditor(selectedResource, ResourceEditorView.this); if(loadedResources.isOverrideMode() && !loadedResources.isOverridenResource(selectedResource)) { b.setOverrideMode(true, mainPanel); } @@ -2052,7 +2053,7 @@ private static void checkDuplicateResources(EditableResources r, String[] loaded } public void importResourceStream(InputStream is) throws IOException { - EditableResources r = new EditableResources(); + EditableResources r = new EditableResourcesEditor(); r.openFile(is); checkDuplicateResourcesLoop(r, loadedResources.getThemeResourceNames(), r.getThemeResourceNames(), "Rename Theme", "Theme "); @@ -2826,7 +2827,7 @@ private void duplicateItemActionPerformed(java.awt.event.ActionEvent evt) {//GEN loadedResources.setModified(); } bo.close(); - EditableResources r = new EditableResources(); + EditableResources r = new EditableResourcesEditor(); r.openFile(new ByteArrayInputStream(bo.toByteArray())); loadedResources.addResourceObjectDuplicate(selectedResource, val, r.getResourceObject(selectedResource)); setSelectedResource(val); @@ -3292,7 +3293,7 @@ private void setNativeTheme(String file, boolean local) { } else { i = new FileInputStream(file); } - EditableResources er = new EditableResources(); + EditableResources er = new EditableResourcesEditor(); er.openFile(i); JavaSEPortWithSVGSupport.setNativeTheme(er); JavaSEPortWithSVGSupport.setShowEDTWarnings(false); diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCLI.java b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCLI.java index c0c3da67cb..811e5b2e16 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCLI.java +++ b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCLI.java @@ -615,14 +615,22 @@ public static void main(String[] args) throws Exception { System.out.println(" -l, -localization Directory containing Java resource bundle .properties files to include."); System.out.println(" -w, -watch Run in watch mode."); System.out.println(" Watches input files for changes and automatically recompiles."); + System.out.println(" -no-cef Disallow any CSS feature that would trigger CEF-backed image"); + System.out.println(" rasterization (9-piece borders, complex gradients, shadows, filters)."); + System.out.println(" The compile fails listing offending rules instead of falling back."); + System.out.println(" Used by the framework native-themes build."); System.out.println("\nSystem Properties:"); System.out.println(" cef.dir The path to the CEF directory."); System.out.println(" Required for generation of image borders."); System.out.println(" parent.port The port number to connect to the parent process for watch mode so that it knows "); System.out.println(" to exit if the parent process ends."); return; - - + + + } + if (getArgByName(args, "no-cef") != null) { + CSSTheme.strictNoCef = true; + System.out.println("CSS compiler running in no-cef mode: any rule requiring CEF rasterization will fail the build."); } statelessMode = getArgByName(args, "i", "input") != null; String localizationPath = getArgByName(args, "l", "localization"); diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompiler.java b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompiler.java index 708f972ef7..151ac37519 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompiler.java +++ b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSCompiler.java @@ -28,6 +28,7 @@ import com.codename1.ui.CN; import com.codename1.ui.Display; import com.codename1.ui.util.EditableResources; +import com.codename1.ui.util.EditableResourcesEditor; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.image.BufferedImage; @@ -165,7 +166,7 @@ public CN1CSSCompiler() { public void startDocument(InputSource is) throws CSSException { //props = new Properties(); theme = new Hashtable(); - res = new EditableResources(); + res = new EditableResourcesEditor(); includedDensities.clear(); includedDensities.addAll(defaultDensities); } @@ -206,7 +207,7 @@ public void endDocument(InputSource is) throws CSSException { //cn1.addTheme(theme); //cn1.execute(); - EditableResources output = new EditableResources(); + EditableResources output = new EditableResourcesEditor(); theme.addToResources(output); System.out.println(output.getTheme(inputFile.getName())); @@ -225,7 +226,7 @@ public void endDocument(InputSource is) throws CSSException { /* - EditableResources output = new EditableResources(); + EditableResources output = new EditableResourcesEditor(); Hashtable theme = new Hashtable(); for (String key : props.stringPropertyNames()) { theme.put(key, props.get(key)); diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSInstallerCLI.java b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSInstallerCLI.java index 395ec26815..ca2b4d8859 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSInstallerCLI.java +++ b/CodenameOneDesigner/src/com/codename1/designer/css/CN1CSSInstallerCLI.java @@ -25,6 +25,7 @@ import com.codename1.impl.javase.JavaSEPort; import com.codename1.ui.Display; import com.codename1.ui.util.EditableResources; +import com.codename1.ui.util.EditableResourcesEditor; import java.awt.EventQueue; import java.io.DataOutputStream; import java.io.File; @@ -52,7 +53,7 @@ private static void install(String[] args) throws Exception { frm.setVisible(false); Display.init(frm.getContentPane()); JavaSEPort.setBaseResourceDir(resFile.getParentFile()); - EditableResources res = new EditableResources(); + EditableResources res = new EditableResourcesEditor(); res.openFile(new FileInputStream(resFile)); String mainTheme = res.getThemeResourceNames()[0]; res.setThemeProperty(mainTheme, "@OverlayThemes", cssFile.getName()); diff --git a/CodenameOneDesigner/src/com/codename1/tools/resourcebuilder/CodenameOneTask.java b/CodenameOneDesigner/src/com/codename1/tools/resourcebuilder/CodenameOneTask.java index 4f4fab8a43..c4d5c7cc32 100644 --- a/CodenameOneDesigner/src/com/codename1/tools/resourcebuilder/CodenameOneTask.java +++ b/CodenameOneDesigner/src/com/codename1/tools/resourcebuilder/CodenameOneTask.java @@ -26,6 +26,7 @@ import com.codename1.ui.Display; import com.codename1.ui.util.EditableResources; +import com.codename1.ui.util.EditableResourcesEditor; import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; @@ -88,7 +89,7 @@ public void execute() throws BuildException { System.out.println("Processing " + dest); Display.init(null); - EditableResources output = new EditableResources(); + EditableResources output = new EditableResourcesEditor(); for(ResourceTask task : resources) { task.addToResources(output); } diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/EditableResourcesEditor.java b/CodenameOneDesigner/src/com/codename1/ui/util/EditableResourcesEditor.java new file mode 100644 index 0000000000..376b23f101 --- /dev/null +++ b/CodenameOneDesigner/src/com/codename1/ui/util/EditableResourcesEditor.java @@ -0,0 +1,152 @@ +/* + * 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. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.ui.util; + +import com.codename1.designer.DataEditor; +import com.codename1.designer.FontEditor; +import com.codename1.designer.ImageMultiEditor; +import com.codename1.designer.ImageRGBEditor; +import com.codename1.designer.L10nEditor; +import com.codename1.designer.MultiImageSVGEditor; +import com.codename1.designer.ResourceEditorView; +import com.codename1.designer.ThemeEditor; +import com.codename1.designer.TimelineEditor; +import com.codename1.designer.UserInterfaceEditor; +import com.codename1.impl.javase.JavaSEPortWithSVGSupport; +import com.codename1.ui.Container; +import com.codename1.ui.Image; +import com.codename1.ui.animations.Timeline; +import com.codename1.ui.util.xml.comps.ComponentEntry; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import javax.swing.JComponent; + +/** + * Designer-side subclass of EditableResources that wires the GUI hooks. + * EditableResources itself lives in the css-compiler module with clean + * dependencies so the native-themes build can use it without pulling in + * JavaSE / JavaFX / CEF / Designer GUI classes. + */ +public class EditableResourcesEditor extends EditableResources { + + public EditableResourcesEditor() { + super(); + } + + public EditableResourcesEditor(InputStream input) throws IOException { + super(input); + } + + @Override + protected byte[] persistUIContainer(Container cnt) { + return UserInterfaceEditor.persistContainer(cnt, this); + } + + @Override + protected Container loadUIContainerFromXml(ComponentEntry uiXMLData) { + UIBuilderOverride uib = new UIBuilderOverride(); + return uib.createInstance(uiXMLData, this); + } + + @Override + protected Container materializeUIContainer(String resourceName) { + UIBuilderOverride u = new UIBuilderOverride(); + return u.createContainer(this, resourceName); + } + + @Override + protected void writeUIXml(Container cnt, FileOutputStream dest) throws IOException { + Writer w = new OutputStreamWriter(dest, "UTF-8"); + w.write("\n\n"); + StringBuilder bld = new StringBuilder(); + UserInterfaceEditor.persistToXML(cnt, cnt, bld, this, ""); + w.write(bld.toString()); + w.flush(); + } + + @Override + protected void onOpenFileComplete() { + ThemeEditor.resetThemeLoaded(); + } + + @Override + protected EditableResources getRuntimeNativeTheme() { + return (EditableResources) JavaSEPortWithSVGSupport.getNativeTheme(); + } + + @Override + protected File getLoadedFile() { + File override = super.getLoadedFile(); + if (override != null) { + return override; + } + return ResourceEditorView.getLoadedFile(); + } + + /** + * Opens a GUI editor for the named resource. Only available on the editor + * subclass because the editors themselves live in the Designer module. + */ + public JComponent getResourceEditor(String name, ResourceEditorView view) { + byte magic = getResourceType(name); + switch (magic) { + case MAGIC_IMAGE: + case MAGIC_IMAGE_LEGACY: + Image i = getImage(name); + if (getResourceObject(name) instanceof MultiImage) { + ImageMultiEditor tl = new ImageMultiEditor(this, name, view); + tl.setImage((MultiImage) getResourceObject(name)); + return tl; + } + if (i instanceof Timeline) { + TimelineEditor tl = new TimelineEditor(this, name, view); + tl.setImage((Timeline) i); + return tl; + } + if (i.isSVG()) { + MultiImageSVGEditor img = new MultiImageSVGEditor(this, name); + img.setImage(i); + return img; + } + ImageRGBEditor img = new ImageRGBEditor(this, name, view); + img.setImage(i); + return img; + case MAGIC_TIMELINE: + TimelineEditor tl = new TimelineEditor(this, name, view); + tl.setImage((Timeline) getImage(name)); + return tl; + case MAGIC_THEME: + case MAGIC_THEME_LEGACY: + ThemeEditor theme = new ThemeEditor(this, name, getTheme(name), view); + return theme; + case MAGIC_FONT: + case MAGIC_FONT_LEGACY: + case MAGIC_INDEXED_FONT_LEGACY: + FontEditor fonts = new FontEditor(this, getFont(name), name); + return fonts; + case MAGIC_DATA: + DataEditor data = new DataEditor(this, name); + return data; + case MAGIC_UI: + UserInterfaceEditor uie = new UserInterfaceEditor(name, this, view.getProjectGeneratorSettings(), view); + return uie; + case MAGIC_L10N: + L10nEditor l10n = new L10nEditor(this, name); + return l10n; + default: + throw new IllegalArgumentException("Unrecognized magic number: " + Integer.toHexString(magic & 0xff)); + } + } +} diff --git a/CodenameOneDesigner/test/com/codename1/designer/css/CSSDarkModeMediaQueryTest.java b/CodenameOneDesigner/test/com/codename1/designer/css/CSSDarkModeMediaQueryTest.java index cbc4b3ced0..263bcc528f 100644 --- a/CodenameOneDesigner/test/com/codename1/designer/css/CSSDarkModeMediaQueryTest.java +++ b/CodenameOneDesigner/test/com/codename1/designer/css/CSSDarkModeMediaQueryTest.java @@ -13,6 +13,7 @@ public class CSSDarkModeMediaQueryTest { public static void main(String[] args) throws Exception { testDarkMediaCompilesToDarkUiids(); + testAtMediaInsideHeaderCommentIsIgnored(); } private static void testDarkMediaCompilesToDarkUiids() throws Exception { @@ -42,6 +43,39 @@ private static void testDarkMediaCompilesToDarkUiids() throws Exception { } } + /** + * Regression: the dark-mode rewriter must not trigger on the literal + * "@media (prefers-color-scheme:" string sitting inside a header + * comment. Before the fix it swallowed everything up to the next {, + * treated the subsequent block's properties as dark selectors, and + * ran the tokenizer off EOF later on. + */ + private static void testAtMediaInsideHeaderCommentIsIgnored() throws Exception { + Path cssFile = Files.createTempFile("cn1-dark-comment", ".css"); + Path resFile = Files.createTempFile("cn1-dark-comment", ".res"); + try { + String css = "/* header doc mentions @media (prefers-color-scheme: dark) for reference */\n" + + "#Constants { tabsGridBool: true; }\n" + + "Button { color: #111111; }\n" + + "@media (prefers-color-scheme: dark) {\n" + + " Button { color: #eeeeee; }\n" + + "}\n"; + 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"), "Light Button fgColor survives comment"); + assertEquals("eeeeee", themeProps.get("$DarkButton.fgColor"), "Real dark block still compiles"); + assertEquals("true", themeProps.get("@tabsGridBool"), "#Constants block isn't mangled"); + } finally { + deleteIfExists(cssFile); + deleteIfExists(resFile); + } + } + private static void deleteIfExists(Path path) { try { Files.deleteIfExists(path); diff --git a/Ports/Android/build.xml b/Ports/Android/build.xml index 7fbaa816d5..7fbc590bae 100644 --- a/Ports/Android/build.xml +++ b/Ports/Android/build.xml @@ -74,6 +74,12 @@ + + diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 05eb6e651c..632ca0b996 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -4925,23 +4925,52 @@ public boolean hasNativeTheme() { */ public void installNativeTheme() { hasNativeTheme(); - if (nativeThemeAvailable) { - try { - InputStream is; - if (android.os.Build.VERSION.SDK_INT < 14 && !isTablet() || Display.getInstance().getProperty("and.hololight", "false").equals("true")) { - is = getResourceAsStream(getClass(), "/androidTheme.res"); + if (!nativeThemeAvailable) { + return; + } + try { + // Resolve desired theme flavor. cn1.androidTheme is the new per-CN1 + // hint (material | hololight | legacy). The Material 3 modern theme + // is opt-in via cn1.androidTheme=material / modern. Default is + // android_holo_light - what master shipped and what existing + // screenshot goldens are anchored against. The ancient pre-Holo + // androidTheme.res is only reached via explicit and.hololight=true + // (historical back-compat) or cn1.androidTheme=legacy. + String mode = Display.getInstance().getProperty("cn1.androidTheme", null); + if (mode == null) { + if ("true".equalsIgnoreCase(Display.getInstance().getProperty("and.hololight", "false"))) { + mode = "legacy"; } else { - is = getResourceAsStream(getClass(), "/android_holo_light.res"); + mode = "hololight"; } - Resources r = Resources.open(is); - Hashtable h = r.getTheme(r.getThemeResourceNames()[0]); - h.put("@commandBehavior", "Native"); - UIManager.getInstance().setThemeProps(h); - is.close(); - Display.getInstance().setCommandBehavior(Display.COMMAND_BEHAVIOR_NATIVE); - } catch (IOException ex) { - ex.printStackTrace(); + } else { + mode = mode.toLowerCase(); } + + String resPath; + if ("material".equals(mode) || "modern".equals(mode)) { + resPath = "/AndroidMaterialTheme.res"; + } else if ("hololight".equals(mode) || "holo".equals(mode)) { + resPath = "/android_holo_light.res"; + } else { + resPath = "/androidTheme.res"; + } + + InputStream is = getResourceAsStream(getClass(), resPath); + if (is == null) { + // Modern theme may not be in the apk if the framework build + // skipped native-themes generation. Fall back to Holo Light + // (master's default) so the app still boots with a known look. + is = getResourceAsStream(getClass(), "/android_holo_light.res"); + } + Resources r = Resources.open(is); + Hashtable h = r.getTheme(r.getThemeResourceNames()[0]); + h.put("@commandBehavior", "Native"); + UIManager.getInstance().setThemeProps(h); + is.close(); + Display.getInstance().setCommandBehavior(Display.COMMAND_BEHAVIOR_NATIVE); + } catch (IOException ex) { + ex.printStackTrace(); } } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java new file mode 100644 index 0000000000..484d9d1cdd --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults.java @@ -0,0 +1,95 @@ +/* + * 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. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codename1.impl.javase; + +/** + * Registers schema metadata for the native-theme build hints + * (ios.themeMode, cn1.androidTheme, cn1.nativeTheme) so that the + * Build Hints UI inside the Codename One Simulator can show them as + * labelled Select dropdowns instead of opaque key/value entries. + * + *

Why this class exists: {@link com.codename1.impl.javase.BuildHintEditor} + * is the dialog that lets developers set build hints from the + * Simulator menu (Project → Build Hints). It populates its rows by + * scanning system properties whose keys match + * {@code codename1.arg.{{ HintName }}.} (label / type / values + * / description / group). Hints contributed by cn1libs typically + * register themselves via that property convention from the cn1lib's + * own code, but the three hints introduced by the CSS-driven + * native-themes work are framework-level - they are not part of any + * cn1lib - and need to be visible to every project, including the + * very first one a new developer creates. Without this class the + * dropdowns would not appear and users would have to type the hint + * name and value by hand into {@code codenameone_settings.properties}, + * which most developers would never discover. + * + *

This is not related to live CSS recompilation. The CSS + * watcher in the Simulator is a separate component; this class only + * publishes the build-hint schema. + * + *

Lifecycle: {@link #register()} is invoked once from + * {@code Simulator.main(String[])} during simulator startup, before + * the BuildHintEditor reads its registry. Re-invoking is harmless - + * each {@link System#setProperty(String, String)} call simply + * overwrites the previous value. Hints set here can still be + * overridden by per-project properties or by a cn1lib that registers + * the same key with different metadata. + */ +final class BuildHintSchemaDefaults { + + private BuildHintSchemaDefaults() { + } + + static void register() { + // Group. + set("{{@nativeTheme}}.label", "Native Theme"); + set("{{@nativeTheme}}.description", + "Controls the Codename One look & feel on iOS and Android. " + + "Modern themes are generated from CSS under native-themes/; " + + "legacy themes remain selectable via the values below."); + + // Cross-platform meta hint. + set("{{#nativeTheme#cn1.nativeTheme}}.label", "Shared override"); + set("{{#nativeTheme#cn1.nativeTheme}}.type", "Select"); + set("{{#nativeTheme#cn1.nativeTheme}}.values", "modern,legacy,custom"); + set("{{#nativeTheme#cn1.nativeTheme}}.description", + "Overrides both iOS and Android native theme selection. " + + "\"modern\" = liquid glass / Material 3. \"legacy\" = iOS 7 " + + "flat / Android Holo Light. \"custom\" disables the framework " + + "default and expects the app to install its own."); + + // iOS. + set("{{#nativeTheme#ios.themeMode}}.label", "iOS theme"); + set("{{#nativeTheme#ios.themeMode}}.type", "Select"); + set("{{#nativeTheme#ios.themeMode}}.values", "auto,modern,ios7,legacy"); + set("{{#nativeTheme#ios.themeMode}}.description", + "auto = modern (default). modern / liquid = Liquid Glass. " + + "ios7 / flat = pre-liquid flat iOS 7 theme. " + + "legacy / iphone = pre-iOS7 theme."); + + // Android. + set("{{#nativeTheme#cn1.androidTheme}}.label", "Android theme"); + set("{{#nativeTheme#cn1.androidTheme}}.type", "Select"); + set("{{#nativeTheme#cn1.androidTheme}}.values", "material,hololight,legacy"); + set("{{#nativeTheme#cn1.androidTheme}}.description", + "material = Material 3 (default). hololight = Android Holo " + + "Light (API 14+). legacy = pre-Holo Android theme. " + + "and.hololight=true is accepted for back-compat."); + } + + /** Idempotent setter: does not overwrite user / project-level hint metadata. */ + private static void set(String suffix, String value) { + String key = "codename1.arg." + suffix; + if (System.getProperty(key) == null) { + System.setProperty(key, value); + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 86117ff3f3..404e329d76 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -2769,6 +2769,48 @@ private void loadSkinFile(InputStream skin, final JFrame frm) { platformName = props.getProperty("platformName", "se"); platformOverrides = props.getProperty("overrideNames", "").split(","); + + // Native theme override: the simulator ships all shipped-with- + // framework themes (iOSModernTheme.res, AndroidMaterialTheme.res, + // plus the legacy ones). The user can override via the + // Simulator's "Native Theme" submenu (stored in the + // simulatorNativeTheme Preference) or the cn1.forceSimulatorTheme + // system property. If neither is set, platformName maps ios -> + // iOSModernTheme and and -> AndroidMaterialTheme. Anything else + // keeps whatever the skin archive embedded. + String overrideTheme = System.getProperty("cn1.forceSimulatorTheme", + Preferences.userNodeForPackage(JavaSEPort.class) + .get("simulatorNativeTheme", null)); + if (overrideTheme == null || overrideTheme.isEmpty() || "auto".equalsIgnoreCase(overrideTheme)) { + if ("ios".equals(platformName)) { + overrideTheme = "iOSModernTheme"; + } else if ("and".equals(platformName)) { + overrideTheme = "AndroidMaterialTheme"; + } else { + overrideTheme = null; + } + } else if ("embedded".equalsIgnoreCase(overrideTheme)) { + // Explicit "keep the skin's embedded theme". + overrideTheme = null; + } + if (overrideTheme != null) { + InputStream bundled = JavaSEPort.class.getResourceAsStream("/" + overrideTheme + ".res"); + if (bundled != null) { + try { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = bundled.read(buf)) > 0) { + bo.write(buf, 0, n); + } + nativeThemeData = bo.toByteArray(); + } catch (IOException ioErr) { + ioErr.printStackTrace(); + } finally { + try { bundled.close(); } catch (IOException ignored) { System.err.println("close: " + ignored); } + } + } + } String ua = null; if (platformName.equals("and")) { ua = "Mozilla/5.0 (Linux; U; Android 2.2; en-us; Nexus One Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1"; @@ -4629,6 +4671,7 @@ public void actionPerformed(ActionEvent e) { bar.add(simulateMenu); bar.add(toolsMenu); bar.add(skinMenu); + bar.add(createNativeThemeMenu()); bar.add(helpMenu); } @@ -4736,6 +4779,49 @@ private String getCurrentSkinName() { return skin; } + /** + * Build the Native Theme override menu. By default the simulator picks a + * theme from the current skin's platformName ("ios" -> iOSModernTheme, + * "and" -> AndroidMaterialTheme); this menu lets the user force one + * of the shipped themes or "Use skin's embedded theme" to bypass the + * heuristic entirely. Selection is written to the simulatorNativeTheme + * Preference and the simulator is reloaded. + */ + private JMenu createNativeThemeMenu() { + JMenu m = new JMenu("Native Theme"); + m.setDoubleBuffered(true); + String[][] items = { + {"auto", "Auto (based on skin)"}, + {"iOSModernTheme", "iOS Modern (Liquid Glass)"}, + {"iOS7Theme", "iOS 7 (Flat)"}, + {"iPhoneTheme", "iPhone (Pre-Flat)"}, + {"AndroidMaterialTheme", "Android Material"}, + {"android_holo_light", "Android Holo Light"}, + {"androidTheme", "Android Legacy"}, + {"embedded", "Use skin's embedded theme"} + }; + String current = Preferences.userNodeForPackage(JavaSEPort.class) + .get("simulatorNativeTheme", "auto"); + ButtonGroup group = new ButtonGroup(); + for (final String[] entry : items) { + JRadioButtonMenuItem mi = new JRadioButtonMenuItem(entry[1]); + mi.setSelected(current.equals(entry[0])); + mi.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent e) { + Preferences.userNodeForPackage(JavaSEPort.class) + .put("simulatorNativeTheme", entry[0]); + System.setProperty("reload.simulator", "true"); + if (window != null) { + window.dispose(); + } + } + }); + group.add(mi); + m.add(mi); + } + return m; + } + private JMenu createSkinsMenu(final JFrame frm, final JMenu menu) throws MalformedURLException { JMenu m; if (menu == null) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/Simulator.java b/Ports/JavaSE/src/com/codename1/impl/javase/Simulator.java index 1c75fc1b69..e5d7587b46 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/Simulator.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/Simulator.java @@ -93,6 +93,12 @@ public static void main(final String[] argv) throws Exception { } catch (ClassNotFoundException ex) { } System.setProperty("NSHighResolutionCapable", "true"); + + // Register framework-level BuildHintEditor schema defaults (see + // Ports/JavaSE/src/com/codename1/impl/javase/BuildHintSchemaDefaults) + // before any code reads codename1.arg.{{*}} system properties. + BuildHintSchemaDefaults.register(); + String skin = System.getProperty("dskin"); if (skin == null) { System.setProperty("dskin", DEFAULT_SKIN); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index 46979f06b6..0ecfd85d57 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -2657,9 +2657,44 @@ public void setHeight(HTMLCanvasElement canvas, int canvasHeight) { @Override public void installNativeTheme(){ try { - String nativeTheme = Display.getInstance().getProperty("javascript.native.theme", isAndroid_() ? "/android_holo_light.res" : "/iOS7Theme.res"); + // Prefer the modern native theme when explicitly requested via + // ios.themeMode / cn1.androidTheme / javascript.native.theme. If + // the hint isn't set we keep the pre-existing JS-port default + // (iOS 7 / Holo Light) since the JS bundle may not include the + // modern .res files (scripts/build-native-themes.sh has to have + // mirrored them before the JS bundle was produced). + String defaultTheme = isAndroid_() ? "/android_holo_light.res" : "/iOS7Theme.res"; + String iosMode = Display.getInstance().getProperty("ios.themeMode", null); + String androidMode = Display.getInstance().getProperty("cn1.androidTheme", null); + if (isAndroid_() && androidMode != null) { + androidMode = androidMode.toLowerCase(); + if ("material".equals(androidMode) || "modern".equals(androidMode)) { + defaultTheme = "/AndroidMaterialTheme.res"; + } else if ("legacy".equals(androidMode)) { + defaultTheme = "/androidTheme.res"; + } else if ("hololight".equals(androidMode) || "holo".equals(androidMode)) { + defaultTheme = "/android_holo_light.res"; + } + } else if (!isAndroid_() && iosMode != null) { + iosMode = iosMode.toLowerCase(); + if ("modern".equals(iosMode) || "liquid".equals(iosMode) || "auto".equals(iosMode)) { + defaultTheme = "/iOSModernTheme.res"; + } else if ("legacy".equals(iosMode) || "iphone".equals(iosMode)) { + defaultTheme = "/iPhoneTheme.res"; + } + } + String nativeTheme = Display.getInstance().getProperty("javascript.native.theme", defaultTheme); Log.p("[installNativeTheme] attempting to load theme from " + nativeTheme); - Resources r = Resources.open(nativeTheme); + Resources r; + try { + r = Resources.open(nativeTheme); + } catch (Throwable notFound) { + // Fall back to the legacy theme if the chosen .res isn't in + // the JS bundle (partial build, missing mirror step, etc.). + String fallback = isAndroid_() ? "/android_holo_light.res" : "/iOS7Theme.res"; + Log.p("[installNativeTheme] " + nativeTheme + " missing, falling back to " + fallback); + r = Resources.open(fallback); + } Log.p("[installNativeTheme] loaded theme resources, theme names: " + java.util.Arrays.toString(r.getThemeResourceNames())); Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); diff --git a/Ports/JavaScriptPort/src/main/webapp/assets/.gitignore b/Ports/JavaScriptPort/src/main/webapp/assets/.gitignore new file mode 100644 index 0000000000..caa62305a9 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/webapp/assets/.gitignore @@ -0,0 +1,5 @@ +# Generated by scripts/build-native-themes.sh. Mirrors of Themes/ so the +# JS port runtime picks up the modern native themes. The CSS sources in +# native-themes/ are authoritative. +iOSModernTheme.res +AndroidMaterialTheme.res diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index c58bf484c1..1a0ff1051f 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -2783,7 +2783,21 @@ const baseTestFailMethodId = "cn1_com_codenameone_examples_hellocodenameone_test const baseTestDoneMethodId = "cn1_com_codenameone_examples_hellocodenameone_tests_BaseTest_done"; const cn1ssForcedTimeoutTestClasses = Object.freeze({ "com_codenameone_examples_hellocodenameone_tests_MediaPlaybackScreenshotTest": "mediaPlayback", - "com_codenameone_examples_hellocodenameone_tests_BytecodeTranslatorRegressionTest": "bytecodeTranslatorRegression" + "com_codenameone_examples_hellocodenameone_tests_BytecodeTranslatorRegressionTest": "bytecodeTranslatorRegression", + "com_codenameone_examples_hellocodenameone_tests_ButtonThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_TextFieldThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_CheckBoxRadioThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_SwitchThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_PickerThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_ToolbarThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_TabsThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_MultiButtonThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_ListThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_DialogThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_FloatingActionButtonThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_SpanLabelThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_DarkLightShowcaseThemeScreenshotTest": "themeScreenshot", + "com_codenameone_examples_hellocodenameone_tests_PaletteOverrideThemeScreenshotTest": "themeScreenshot" }); const cn1ssForcedTimeoutTestNames = Object.freeze({ "MediaPlaybackScreenshotTest": "mediaPlayback", @@ -2793,7 +2807,21 @@ const cn1ssForcedTimeoutTestNames = Object.freeze({ "CallDetectionAPITest": "callDetectionApi", "LocalNotificationOverrideTest": "localNotificationOverride", "Base64NativePerformanceTest": "base64NativePerformance", - "AccessibilityTest": "accessibility" + "AccessibilityTest": "accessibility", + "ButtonThemeScreenshotTest": "themeScreenshot", + "TextFieldThemeScreenshotTest": "themeScreenshot", + "CheckBoxRadioThemeScreenshotTest": "themeScreenshot", + "SwitchThemeScreenshotTest": "themeScreenshot", + "PickerThemeScreenshotTest": "themeScreenshot", + "ToolbarThemeScreenshotTest": "themeScreenshot", + "TabsThemeScreenshotTest": "themeScreenshot", + "MultiButtonThemeScreenshotTest": "themeScreenshot", + "ListThemeScreenshotTest": "themeScreenshot", + "DialogThemeScreenshotTest": "themeScreenshot", + "FloatingActionButtonThemeScreenshotTest": "themeScreenshot", + "SpanLabelThemeScreenshotTest": "themeScreenshot", + "DarkLightShowcaseThemeScreenshotTest": "themeScreenshot", + "PaletteOverrideThemeScreenshotTest": "themeScreenshot" }); if (jvm && typeof jvm.addVirtualMethod === "function" && jvm.classes && jvm.classes["java_lang_String"]) { diff --git a/Ports/iOSPort/build.xml b/Ports/iOSPort/build.xml index 3bd0652555..c7aafebde3 100644 --- a/Ports/iOSPort/build.xml +++ b/Ports/iOSPort/build.xml @@ -74,6 +74,11 @@ + + diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 308d902775..7a930b52e7 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1341,8 +1341,25 @@ public void run() { public void installNativeTheme() { try { Resources r; - - if(iosMode.equals("modern")) { + String mode = iosMode == null ? "auto" : iosMode.toLowerCase(); + // Modern (liquid-glass) theme is opt-in via ios.themeMode=modern / + // liquid / material. Keep the default ("auto" or unset) on the + // legacy iOS 7 / pre-flat theme so existing apps and screenshot + // goldens aren't disturbed. Apps that want the new look set + // ios.themeMode=modern in their build hints or via + // Display.setProperty("ios.themeMode", "modern") before the + // first Form is shown. + if(mode.equals("modern") || mode.equals("liquid")) { + InputStream in = getResourceAsStream("/iOSModernTheme.res"); + if (in != null) { + r = Resources.open(in); + UIManager.getInstance().setThemeProps(r.getTheme(r.getThemeResourceNames()[0])); + return; + } + // Modern theme isn't in the jar (e.g. framework build hasn't + // generated it yet) - fall back to iOS 7 so the app still boots. + } + if(mode.equals("ios7") || mode.equals("flat") || mode.equals("auto") || mode.equals("modern") || mode.equals("liquid")) { r = Resources.open("/iOS7Theme.res"); Hashtable tp = r.getTheme(r.getThemeResourceNames()[0]); if(!nativeInstance.isIOS7()) { @@ -1351,20 +1368,16 @@ public void installNativeTheme() { UIManager.getInstance().setThemeProps(tp); return; } - if(iosMode.equals("auto")) { - if(nativeInstance.isIOS7()) { - r = Resources.open("/iOS7Theme.res"); - } else { - r = Resources.open("/iPhoneTheme.res"); - } - UIManager.getInstance().setThemeProps(r.getTheme(r.getThemeResourceNames()[0])); - return; - } + // "legacy" / "iphone" / anything else: pre-flat iPhone theme. r = Resources.open("/iPhoneTheme.res"); UIManager.getInstance().setThemeProps(r.getTheme(r.getThemeResourceNames()[0])); } catch (IOException ex) { ex.printStackTrace(); - } + } + } + + private InputStream getResourceAsStream(String name) { + return IOSImplementation.class.getResourceAsStream(name); } private long getNSData(InputStream i) { diff --git a/Themes/.gitignore b/Themes/.gitignore new file mode 100644 index 0000000000..36ce6ac0c2 --- /dev/null +++ b/Themes/.gitignore @@ -0,0 +1,4 @@ +# Generated by scripts/build-native-themes.sh from native-themes/*/theme.css. +# These are build artifacts; the CSS sources in native-themes/ are authoritative. +iOSModernTheme.res +AndroidMaterialTheme.res diff --git a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc index b1351ef427..fc1ca0d1c4 100644 --- a/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc +++ b/docs/developer-guide/Advanced-Topics-Under-The-Hood.asciidoc @@ -310,7 +310,13 @@ Currently only supported for App Store builds. See https://www.codenameone.com/ |Comma separated list of url schemes that `canExecute` will respect on iOS. If the url scheme isn't mentioned here `canExecute` will return false starting with iOS 9. Notice that this collides with `ios.plistInject` when used with the `LSApplicationQueriesSchemes...` value so you should use one or the other. E.g. to enable `canExecute` for a url like `myurl://xys` you can use: `myurl,myotherurl` |ios.themeMode -|default/legacy/modern/auto (defaults to default). Default means you don't define a theme mode. Currently this is equivalent to legacy. In the future we'll switch this to be equivalent to auto. legacy - this will behave like iOS 6 regardless of the device you're running on. modern - this will behave like iOS 7 regardless of the device you're running on. auto - this will behave like iOS 6 on older devices and iOS 7 on newer devices. +|`auto` (default), `modern`, `ios7`, `legacy`. `auto` (unset) keeps the existing iOS 7 flat theme so pre-refactor screenshot goldens and apps see no behavior change. `modern` / `liquid` opts in to the CSS-generated iOS Modern (liquid-glass) theme shipped from `native-themes/ios-modern/theme.css`. `ios7` / `flat` is the same as `auto` - pre-liquid iOS 7 flat theme; `legacy` / `iphone` loads the pre-iOS 7 iPhone theme. The `auto` -> modern flip is planned for a future release. + +|cn1.androidTheme +|`hololight` (default), `material`, `legacy`. Default is `hololight` (Android Holo Light, what the framework shipped on API 14+ before this refactor). `material` / `modern` opts in to the CSS-generated Android Material 3 theme from `native-themes/android-material/theme.css`. `legacy` loads the pre-Holo Android theme. `and.hololight=true` is still accepted for back-compat and maps to `hololight`. The default stays on `hololight` until we flip in a future release. + +|cn1.nativeTheme +|`modern`, `legacy`, `custom` (default unset). Cross-platform override that sets both `ios.themeMode` and `cn1.androidTheme` together when those are not set explicitly. `modern` = liquid glass + Material 3, `legacy` = iOS 7 flat + Holo Light, `custom` disables the framework native theme entirely. |ios.interface_orientation |UIInterfaceOrientationPortrait by default. Indicates the orientation, one or more of (separated by colon :): `UIInterfaceOrientationPortrait`, `UIInterfaceOrientationPortraitUpsideDown`, `UIInterfaceOrientationLandscapeLeft`, `UIInterfaceOrientationLandscapeRight`. Notice that the IDE plugin has an "Interface Orientation" combo box you *should* use under the iOS section. diff --git a/docs/developer-guide/Native-Themes.asciidoc b/docs/developer-guide/Native-Themes.asciidoc new file mode 100644 index 0000000000..796b8b4a7c --- /dev/null +++ b/docs/developer-guide/Native-Themes.asciidoc @@ -0,0 +1,419 @@ +== Native Modern Themes + +Codename One ships an iOS Modern (liquid-glass) theme and an Android +Material 3 theme that you can opt your app into with a single build +hint. Both are fully overridable - their colour palettes, type +ramps, and state-specific styles are all reachable from your own +`theme.css` and from runtime API. + +The legacy iOS 7 (iOS) and Holo Light (Android) themes remain the +default so existing apps see no behaviour change. + +=== Selecting a theme + +Three build hints control the platform native theme. None of them +are required; with all three unset every app continues to load the +legacy theme it always did. + +[cols="1,2,3", options="header"] +|=== +|Hint |Values |Description + +|`ios.themeMode` +|`auto` (default) + +`modern` / `liquid` + +`ios7` / `flat` + +`legacy` / `iphone` +|`auto` and `ios7` both load the iOS 7 flat theme. `modern` / +`liquid` opts in to the modern theme generated from +`native-themes/ios-modern/theme.css`. `legacy` / `iphone` loads the +pre-iOS 7 theme. The `auto` -> `modern` flip is planned for a +future release. + +|`cn1.androidTheme` +|`hololight` (default) + +`material` / `modern` + +`legacy` +|`hololight` is Android Holo Light. `material` / `modern` opts in +to the modern theme generated from +`native-themes/android-material/theme.css`. `legacy` is the +pre-Holo theme. + +|`cn1.nativeTheme` +|`modern` / `legacy` / `custom` +|Cross-platform shortcut that sets both `ios.themeMode` and +`cn1.androidTheme` together. `modern` = liquid glass + Material 3, +`legacy` = iOS 7 + Holo Light, `custom` disables the framework +theme entirely so your own `theme.css` owns the base layer. +|=== + +The legacy `and.hololight=true` hint still works and maps to +`cn1.androidTheme=hololight`. + +=== Light and dark mode + +Both modern themes ship a dark variant via a +`@media (prefers-color-scheme: dark)` block in their CSS. The CSS +compiler rewrites each rule inside that block into a `$Dark` +style entry; UIManager picks them up whenever +`CN.isDarkMode() == true`. + +`Display.getInstance().setDarkMode(Boolean)` controls the active +appearance: + +* `null` (the default) - follow the device. iOS reads + `UITraitCollection`, Android reads `Configuration.uiMode`. When + the user toggles light / dark in system settings the app reflects + the change. +* `true` / `false` - force the app into one mode regardless of + system setting (useful for theme-preview screens or accessibility + toggles). + +=== Material 3 colour palette (Android) + +The Android Material 3 theme is built around the Material Design 3 +"baseline" palette. Each colour is referenced from one or more +UIIDs; if you change a colour the change ripples to every UIID that +inherits from it. + +[cols="1,1,1,2", options="header"] +|=== +|Role |Light |Dark |Used by + +|primary +|`#6750a4` +|`#d0bcff` +|Default `Button` fill, `BackCommand` / `TitleCommand` text, +`SelectedTab` text colour. + +|on-primary +|`#ffffff` +|`#381e72` +|Text printed on top of the primary fill. + +|primary-container +|`#eaddff` +|`#4f378b` +|`RaisedButton` (the elevated tone), `Button.pressed` and +`MultiButton.pressed` highlight, `FloatingActionButton`. + +|surface +|`#fef7ff` +|`#141218` +|`Form`, `ContentPane`, `Toolbar`, `TitleArea`, `List`, `Tabs`, +`SideNavigationPanel`. The "page" colour. + +|on-surface +|`#1d1b20` +|`#e6e0e9` +|Body text on surfaces (`Label`, `Title`, `MultiLine1`). + +|surface-variant +|`#e7e0ec` +|`#49454f` +|Track of the off-state `Switch`. + +|on-surface-variant +|`#49454f` +|`#cac4d0` +|Secondary text (`SecondaryLabel`, `MultiLine2`, +`UnselectedTab` text). + +|surface-container +|`#f3edf7` +|`#211f26` +|`TextField` / `TextArea` filled background. + +|surface-container-highest +|`#e6e0e9` +|`#211f26` +|`Dialog`, `PopupContent`. Sits one elevation step above +`surface` so dialogs read as raised cards. + +|outline / outline-variant +|`#79747e` / `#cac4d0` +|`#938f99` / `#49454f` +|`Separator`, `TertiaryLabel`, disabled-text muting. + +|state-pressed +|`#d0bcff` +|`#4f378b` +|All `.pressed` overrides on Button-family UIIDs. + +|state-disabled / on-disabled +|`#e0dce4` / `#a5a0ab` +|`#2b2930` / `#5c5967` +|All `.disabled` overrides. +|=== + +To rebrand the app, override the colour at the role level rather +than touching every UIID. For example, to flip the primary +container palette to teal: + +[source,css] +---- +#Constants { + includeNativeBool: true; + darkModeBool: true; +} + +/* Touch the four UIIDs that paint with primary / primary-container. + The role-as-rule pattern keeps brand changes localised. */ +Button { background-color: #00796b; } +Button.pressed { background-color: #4db6ac; color: #00251a; } +RaisedButton { background-color: #b2dfdb; color: #00251a; } +RaisedButton.pressed { background-color: #80cbc4; } +SelectedTab { color: #00796b; } +BackCommand { color: #00796b; } +TitleCommand { color: #00796b; } +---- + +=== iOS modern colour palette + +The iOS modern theme follows Apple's system palette. + +[cols="1,1,1,2", options="header"] +|=== +|Role |Light |Dark |Used by + +|accent +|`#007aff` +|`#0a84ff` +|`Button` text, `RaisedButton` fill, `SelectedTab` text, +`BackCommand` / `TitleCommand`, `FloatingActionButton`. + +|accent-pressed +|`#0064d1` +|`#64b1ff` +|All `.pressed` accent overrides. + +|accent-disabled +|`#b3d4ff` +|`#004a99` +|All `.disabled` accent overrides. + +|surface +|`#ffffff` +|`#000000` +|Pure surface used by `Toolbar`, `TitleArea`, `Title`, `List`. + +|surface-grouped +|`#f2f2f7` +|`#1c1c1e` +|Form / ContentPane (the iOS "grouped" form bg). `Dialog` / +`Tabs` use this colour at reduced opacity for the liquid-glass +look. + +|surface-tertiary +|`#e5e5ea` +|`#2c2c2e` +|`Button.pressed` highlight, elevated dark Dialog surface. + +|text-primary +|`#000000` +|`#ffffff` +|`Label`, `MultiLine1`, `Title`, primary body text. + +|text-secondary +|`#3c3c43` +|`#ebebf5` +|`SecondaryLabel`, `MultiLine2`, unselected tabs. + +|text-tertiary / text-disabled +|`#8e8e93` / `#c7c7cc` +|`#8e8e93` / `#48484a` +|`TertiaryLabel`, `MultiLine3-4`, disabled-state text. + +|separator +|`#c6c6c8` +|`#38383a` +|`Separator`. + +|success +|`#34c759` +|`#30d158` +|`Switch.selected`, `OnOffSwitch.selected`. +|=== + +To layer a brand colour on top, override `RaisedButton` and +accent-driven UIIDs the same way as Android above. The colour names +match Apple's `UIColor.systemBlue` etc. so you can mirror the SF +Symbols semantics if you want. + +=== Runtime palette override + +Push a Hashtable of theme props through `UIManager.addThemeProps` +to flip the palette live, without recompiling the theme: + +[source,java] +---- +Hashtable override = new Hashtable(); +override.put("RaisedButton.bgColor", "d81b60"); +override.put("RaisedButton.sel#bgColor", "b71c5c"); +override.put("RaisedButton.press#bgColor", "ad1457"); +override.put("BackCommand.fgColor", "d81b60"); +UIManager.getInstance().addThemeProps(override); +Form.getCurrentForm().refreshTheme(); +---- + +Common cases are demonstrated by the `PaletteOverrideThemeScreenshotTest` +in the hellocodenameone test suite, which flips the primary accent +to magenta at runtime and re-renders the same form. + +=== Platform-specific UIIDs + +A handful of UIIDs exist only because one platform draws them +differently from the other. They are still safe to override; you +just want to know which platform's screen the override is going to +land on. + +iOS-only behaviour: + +* `Toolbar`, `TitleArea`, `Title` paint over the status-bar area. + iOS reserves room above for the notch / status bar; in your + capture this reads as a coloured strip at the very top of the + Form. In production the device fills it with system content + (signal, battery, time). +* `MultiButton` is styled as an iOS Settings row (multi-line text + ramp + chevron). Compare against Material 3's denser list-item + pattern. + +Android-only behaviour: + +* `Toolbar` does not paint over the system status bar; the native + Android status bar handles that. +* `Tabs` use Material 3 top tabs (flat, underline-by-color). iOS + modern renders Tabs as a bottom-anchored pill group via the + `tabPlacementInt` constant - that constant is intentionally only + set in the iOS theme so behaviour stays consistent on each + platform. + +=== Switching CheckBox / RadioButton glyphs + +The default check / radio glyphs are Material icons drawn by +`DefaultLookAndFeel`. The theme can swap them via four optional +constants (Material codepoints): + +[cols="1,1,3", options="header"] +|=== +|Constant |Default |Notes + +|`@checkBoxCheckedIconInt` +|`MATERIAL_CHECK_BOX` (`E834`) +|Override to e.g. `MATERIAL_CHECK_CIRCLE` (`E86C` / `59500`) for an +iOS-style filled circle. The iOS modern theme does this. + +|`@checkBoxUncheckedIconInt` +|`MATERIAL_CHECK_BOX_OUTLINE_BLANK` (`E835`) +|Match the empty version of whatever you chose above. + +|`@radioCheckedIconInt` +|`MATERIAL_RADIO_BUTTON_CHECKED` (`E837`) +|Filled circle with dot. + +|`@radioUncheckedIconInt` +|`MATERIAL_RADIO_BUTTON_UNCHECKED` (`E836`) +|Empty circle. +|=== + +Set these in your `#Constants { ... }` block; the icons rebuild on +the next style refresh. + +=== Component-specific tuning constants + +[cols="2,3", options="header"] +|=== +|Constant |Effect + +|`@switchTrackScaleX`, `@switchTrackScaleY` +|Stretches the Switch track. Larger X = longer pill, larger Y = +thicker. iOS uses 2.5 / 1.5; Material uses 3.0 / 0.9. + +|`@switchThumbScaleY` +|Scales the Switch thumb relative to the track. iOS 1.4, Material +1.5. + +|`@switchThumbPaddingInt`, `@switchThumbInsetMM` +|Pixels of breathing room between thumb and track. + +|`@switchTrackOffOutlineWidthMM`, `@switchTrackOffOutlineColor` +|iOS draws a faint ring around the off-state track; Material does +not. + +|`@tabPlacementInt` +|`Component.TOP` (`0`), `Component.BOTTOM` (`2`), +`Component.LEFT` (`1`), `Component.RIGHT` (`3`). Where the +`Tabs` widget anchors its tab bar. Set in iOS modern theme to +`2` for the iOS 26 bottom-bar look; unset on Android (Tabs at +top). + +|`@tabsFillRowsBool`, `@tabsGridBool` +|Distribute tabs evenly across the bar. + +|`@darkModeBool` +|`true` enables `$DarkUIID` resolution when the app is in dark +mode. Both modern themes set this; user themes that want dark +mode also need to set it. +|=== + +=== Customising in your own theme + +Your app's `theme.css` inherits from the installed native theme: + +[source,css] +---- +#Constants { + includeNativeBool: true; + darkModeBool: true; +} + +/* Tweak only what's different. Everything you do not redeclare + keeps coming from the native theme. */ +RaisedButton { background-color: #d81b60; } +RaisedButton.pressed { background-color: #b71c5c; } +RaisedButton.disabled { background-color: #ffd6e2; color: #ffffff; } + +@media (prefers-color-scheme: dark) { + RaisedButton { background-color: #ff80ab; color: #4a0026; } + RaisedButton.pressed { background-color: #f06292; } +} +---- + +The user's CSS is layered on top of the native theme at app launch, +so refresh / restart picks the override up. + +=== Inheriting from a native UIID + +`cn1-derive` lets a custom UIID start from one of the native +theme's UIIDs and refine it: + +[source,css] +---- +DangerButton { + cn1-derive: RaisedButton; + background-color: #d32f2f; +} +DangerButton.pressed { + cn1-derive: RaisedButton; + background-color: #b71c1c; +} +---- + +`cn1-derive` works best when the relationship is *child refines +parent*. Avoid deriving across unrelated UIIDs (e.g. a TitleArea +that derives from Toolbar) - inline the properties instead. + +=== Translucency, glass effects, and the test harness + +The iOS modern Dialog and Tabs use translucent surfaces (rgba with +alpha < 1) so any backdrop reads through the widget. To exercise +this in screenshot tests, the hellocodenameone fidelity tests +opt-in to a diagonal-stripe textured backdrop on the form +(`useTexturedBackdrop()` in `DualAppearanceBaseTest`). Translucent +widgets show their see-through tint against the stripes; opaque +widgets cover the stripes entirely. + +A real backdrop blur (`UIVisualEffectView` on iOS, `RenderEffect` on +Android) lands as a separate native primitive in a future release. +The current rgba approximation is the closest the framework can do +without that primitive. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 10b626fab5..f28a0b27b5 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -53,6 +53,8 @@ include::Theme-Basics.asciidoc[] include::Advanced-Theming.asciidoc[] +include::Native-Themes.asciidoc[] + include::css.asciidoc[] include::The-Components-Of-Codename-One.asciidoc[] diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 5585e6a5a9..b888fbb8cc 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -356,7 +356,20 @@ public boolean build(File sourceZip, BuildRequest request) throws BuildException } debug("Xcode version is "+xcodeVersion); - String iosMode = request.getArg("ios.themeMode", "auto"); + // ios.themeMode stays the platform-specific knob; cn1.nativeTheme is + // the cross-platform meta hint. modern / legacy on the meta hint + // translate to the equivalent iOS values when ios.themeMode is unset. + String iosMode = request.getArg("ios.themeMode", null); + if (iosMode == null) { + String sharedMode = request.getArg("cn1.nativeTheme", null); + if ("legacy".equalsIgnoreCase(sharedMode)) { + iosMode = "ios7"; + } else if ("modern".equalsIgnoreCase(sharedMode)) { + iosMode = "modern"; + } else { + iosMode = "auto"; + } + } tmpFile = getBuildDirectory(); if (tmpFile == null) { diff --git a/maven/css-compiler/pom.xml b/maven/css-compiler/pom.xml new file mode 100644 index 0000000000..9189e8fb4f --- /dev/null +++ b/maven/css-compiler/pom.xml @@ -0,0 +1,100 @@ + + + + + com.codenameone + codenameone + 8.0-SNAPSHOT + + 4.0.0 + com.codenameone + codenameone-css-compiler + 8.0-SNAPSHOT + jar + codenameone-css-compiler + + + UTF-8 + 1.8 + 1.8 + + + + + com.codenameone + codenameone-core + + + com.vaadin.external.flute + flute + 1.3.0.gg2 + + + org.w3c.css + sac + 1.3 + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.3.0 + + + build-jar-with-dependencies + package + + single + + + + + com.codename1.designer.css.NoCefCSSCLI + + + + jar-with-dependencies + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + attach-jar-with-dependencies + package + + attach-artifact + + + + + ${project.build.directory}/${project.build.finalName}-jar-with-dependencies.jar + jar + jar-with-dependencies + + + + + + + + + diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java similarity index 97% rename from CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java rename to maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 341e20e7ac..20c0b5d7b0 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -117,7 +117,15 @@ public class CSSTheme { EditableResources res; private String themeName = "Theme"; private ImagesMetadata imagesMetadata = new ImagesMetadata(); - + + /** + * When true, {@link #createImageBorders(WebViewProvider)} refuses to rasterize any + * style via CEF and instead throws an {@link IllegalStateException} listing offending + * rules. Used by the native-themes build to guarantee that shipped platform themes + * contain no rasterized fallback images (which would bloat the .res file). + */ + public static boolean strictNoCef = false; + private List fontFaces = new ArrayList(); public static final int DEFAULT_TARGET_DENSITY = com.codename1.ui.Display.DENSITY_HD; public static final String[] supportedNativeBorderTypes = new String[]{ @@ -2611,10 +2619,51 @@ public static interface WebViewProvider { com.codename1.ui.BrowserComponent getWebView(); } private static String currentId; + + private void enforceNoCef() { + List offenders = new ArrayList(); + String[] states = new String[] {"unselected", "selected", "pressed", "disabled"}; + for (String id : elements.keySet()) { + if (!isModified(id)) { + continue; + } + Element e = (Element) elements.get(id); + Element[] stateElements = new Element[] { + e.getUnselected(), e.getSelected(), e.getPressed(), e.getDisabled() + }; + for (int i = 0; i < stateElements.length; i++) { + Map styles = + (Map) stateElements[i].getFlattenedStyle(); + if (e.requiresImageBorder(styles)) { + offenders.add(id + "." + states[i] + " (image border)"); + } else if (e.requiresBackgroundImageGeneration(styles)) { + offenders.add(id + "." + states[i] + " (background image)"); + } + } + } + if (!offenders.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append("CSS rules require CEF-backed image rasterization, which is disabled "); + sb.append("in no-cef mode (native-themes build). Offending rules:\n"); + for (String o : offenders) { + sb.append(" - ").append(o).append("\n"); + } + sb.append("Fix: avoid box-shadow, border-radius combined with a visible border, "); + sb.append("mixed-side borders, filter, and complex gradients. "); + sb.append("Use cn1-round-border / cn1-pill-border or solid backgrounds instead. "); + sb.append("If the effect is genuinely required, extend the CSS compiler and/or "); + sb.append("the resource format with a native primitive rather than rasterizing."); + throw new IllegalStateException(sb.toString()); + } + } + public void createImageBorders(WebViewProvider webviewProvider) { if (res == null) { res = new EditableResourcesForCSS(resourceFile); } + if (strictNoCef) { + enforceNoCef(); + } ArrayList borders = new ArrayList(); ResourcesMutator resm = new ResourcesMutator(res, Display.DENSITY_VERY_HIGH, minDpi, maxDpi); @@ -5899,9 +5948,27 @@ public void apply(Element style, String property, LexicalUnit value) { case "radial-gradient" : style.put("background", value); break; + case "rgb" : + case "rgba" : + case "cn1rgb" : + case "cn1rgba" : + // SAC reports `rgba()` (and `rgb()`) as + // SAC_FUNCTION rather than SAC_RGBCOLOR, + // so the previous default-throw silently + // dropped the entire `background:` + // shorthand on rules like + // `background: rgba(11,32,85,0.75)` - + // leaving the UIID with no bgColor / + // transparency, which fell back to the + // theme's Component default (often a + // very visible white in dark mode). + // Treat them the same as SAC_RGBCOLOR + // and route to background-color. + apply(style, "background-color", value); + break; default: throw new RuntimeException("Unsupported function in background property"); - + } break; @@ -6947,7 +7014,7 @@ private static String transformDarkModeMediaQueries(String css) { int len = css.length(); int pos = 0; while (pos < len) { - int mediaPos = css.indexOf("@media", pos); + int mediaPos = indexOfOutsideComments(css, "@media", pos); if (mediaPos < 0) { out.append(css.substring(pos)); break; @@ -6980,6 +7047,13 @@ private static String prefixSelectorsWithDark(String block) { int len = block.length(); int pos = 0; while (pos < len) { + // Whitespace and `/* ... */` block comments are pass-through: + // they're emitted verbatim and consumed BEFORE the next selector + // string is captured. Without this, a comment immediately + // preceding a rule like `/* note */ Form { ... }` would be + // treated as part of the selector text and prefixed as + // `CN1DARK_/* note */ Form`, which Flute then rejects as a + // malformed selector and silently drops the entire rule. while (pos < len && Character.isWhitespace(block.charAt(pos))) { out.append(block.charAt(pos)); pos++; @@ -6987,6 +7061,16 @@ private static String prefixSelectorsWithDark(String block) { if (pos >= len) { break; } + if (pos + 1 < len && block.charAt(pos) == '/' && block.charAt(pos + 1) == '*') { + int commentEnd = block.indexOf("*/", pos + 2); + if (commentEnd < 0) { + out.append(block.substring(pos)); + break; + } + out.append(block, pos, commentEnd + 2); + pos = commentEnd + 2; + continue; + } int open = block.indexOf('{', pos); if (open < 0) { out.append(block.substring(pos)); @@ -7004,6 +7088,13 @@ private static String prefixSelectorsWithDark(String block) { return out.toString(); } + /// SAC / Flute rejects `$` as a selector-name starter, so we can't + /// emit `$DarkButton { ... }` directly or the parser silently drops + /// the whole rule with a "Skipping" warning. Emit a Flute-valid + /// identifier prefix (`CN1DARK_`) here and rename the elements back + /// to `$Dark...` in `renameDarkElements` after parsing completes. + static final String DARK_PLACEHOLDER = "CN1DARK_"; + private static String toDarkSelectors(String selectors) { StringBuilder out = new StringBuilder(); String[] parts = selectors.split(","); @@ -7012,7 +7103,7 @@ private static String toDarkSelectors(String selectors) { out.append(','); } String selector = parts[i].trim(); - if (selector.length() == 0 || selector.startsWith("$Dark")) { + if (selector.length() == 0 || selector.startsWith("$Dark") || selector.startsWith(DARK_PLACEHOLDER)) { out.append(parts[i]); continue; } @@ -7031,11 +7122,61 @@ private static String toDarkSelectors(String selectors) { if ("*".equals(base) || base.length() == 0) { base = "Component"; } - out.append("$Dark").append(base).append(suffix); + out.append(DARK_PLACEHOLDER).append(base).append(suffix); } return out.toString(); } + /// After the Flute parser populates `theme.elements` with keys like + /// `CN1DARK_Button`, rename them to `$DarkButton` so `updateResources` + /// emits the proper `$Dark.fgColor` entries the runtime UIManager + /// consults via `shouldUseDarkStyle`. + private static void renameDarkElements(CSSTheme theme) { + if (theme == null || theme.elements == null) { + return; + } + LinkedHashMap renamed = new LinkedHashMap(); + boolean changed = false; + for (Map.Entry e : theme.elements.entrySet()) { + String k = e.getKey(); + if (k != null && k.startsWith(DARK_PLACEHOLDER)) { + renamed.put("$Dark" + k.substring(DARK_PLACEHOLDER.length()), e.getValue()); + changed = true; + } else { + renamed.put(k, e.getValue()); + } + } + if (changed) { + theme.elements = renamed; + } + } + + /// Finds the next occurrence of `needle` in `css` starting at `fromPos`, + /// skipping over any `/* ... */` block-comment regions. Used by the + /// dark-mode rewriter so a literal "@media" token documented inside a + /// header comment isn't mistaken for a real media block. + private static int indexOfOutsideComments(String css, String needle, int fromPos) { + int len = css.length(); + int pos = fromPos; + while (pos < len) { + int commentStart = css.indexOf("/*", pos); + int hit = css.indexOf(needle, pos); + if (hit < 0) { + return -1; + } + if (commentStart < 0 || hit < commentStart) { + return hit; + } + int commentEnd = css.indexOf("*/", commentStart + 2); + if (commentEnd < 0) { + // Unterminated comment - everything from here on is comment. + return -1; + } + pos = commentEnd + 2; + } + return -1; + } + private static int findMatchingBrace(String css, int openPos) { int depth = 0; int len = css.length(); @@ -7312,6 +7453,7 @@ private void property_(String string, LexicalUnit _lu, boolean bln) throws CSSEx parser.parseStyleSheet(source); stream.close(); + renameDarkElements(theme); return theme; } catch (ClassNotFoundException ex) { Logger.getLogger(CSSTheme.class.getName()).log(Level.SEVERE, null, ex); @@ -7320,11 +7462,12 @@ private void property_(String string, LexicalUnit _lu, boolean bln) throws CSSEx } catch (InstantiationException ex) { Logger.getLogger(CSSTheme.class.getName()).log(Level.SEVERE, null, ex); } catch (NullPointerException ex) { - if (ex.getMessage().contains("encoding properties")) { + String msg = ex.getMessage(); + if (msg != null && msg.contains("encoding properties")) { // This error always happens and there doesn't seem to be a way to fix it... so let's just hide // it . Doesn't seem to hurt anything. } else { - //Logger.getLogger(CSSTheme.class.getName()).log(Level.SEVERE, null, ex); + Logger.getLogger(CSSTheme.class.getName()).log(Level.SEVERE, "Failed to load CSS theme", ex); } } catch (ClassCastException ex) { Logger.getLogger(CSSTheme.class.getName()).log(Level.SEVERE, null, ex); diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/Color.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/Color.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/designer/css/Color.java rename to maven/css-compiler/src/main/java/com/codename1/designer/css/Color.java diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java new file mode 100644 index 0000000000..8b6cec9fb0 --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/HeadlessCssCompilerImplementation.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.designer.css; + +import com.codename1.impl.CodenameOneImplementation; +import com.codename1.l10n.L10NManager; +import com.codename1.ui.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Minimal stub of {@link CodenameOneImplementation} the headless CSS + * compiler installs into {@code Display.impl} via reflection before + * {@link CSSTheme#load} runs. + * + *

Why this exists: the CSS compiler reads CSS source, builds a theme + * Hashtable in memory and serializes it to a {@code .res} file. It does + * not render anything - no graphics, no text shaping, no networking. But + * a handful of CN1 core classes (Font, Util, Display, UIManager) call + * through {@link com.codename1.ui.Display#getInstance} -> + * {@code impl.something()} during theme construction (font face/size + * round-trips, cleanup of theme-resource streams, dip->pixel + * conversion for {@code mm} units, and so on). Without an installed + * implementation those calls NPE. + * + *

Rather than littering CN1 core with {@code if (impl == null)} + * fallbacks, the css-compiler module installs this stub at startup + * (NoCefCSSCLI.main). The pattern mirrors what the unit-test module + * does with {@code TestCodenameOneImplementation}: provide a minimal + * subclass and inject it via reflection. + * + *

Most overrides return zero / null / -1 / false. The only methods + * that need to do real work are the ones that the theme-build path + * actually calls: + *

    + *
  • {@link #createFont(int,int,int)} - Font's constructor stores + * the returned object as {@code font}, then later asks + * {@link #getFace(Object)} / {@link #getSize(Object)} / + * {@link #getStyle(Object)} for the original face/style/size. + * We round-trip via a small {@link Triple} carrier.
  • + *
  • {@link #convertToPixels(int,boolean)} - returns 1:1 (1 mm = 1 pixel) + * so theme padding/margin serialization does not collapse to + * zero. The actual conversion happens at app runtime when a + * full implementation is loaded.
  • + *
  • {@link #cleanup(Object)} - closes any closeable streams used + * by Util.copy when serializing the resource.
  • + *
+ */ +final class HeadlessCssCompilerImplementation extends CodenameOneImplementation { + + private static final class Triple { + final int face, style, size; + Triple(int f, int s, int sz) { this.face = f; this.style = s; this.size = sz; } + } + + @Override public Object createFont(int face, int style, int size) { + return new Triple(face, style, size); + } + @Override public int getFace(Object nativeFont) { + return nativeFont instanceof Triple ? ((Triple) nativeFont).face : 0; + } + @Override public int getStyle(Object nativeFont) { + return nativeFont instanceof Triple ? ((Triple) nativeFont).style : 0; + } + @Override public int getSize(Object nativeFont) { + return nativeFont instanceof Triple ? ((Triple) nativeFont).size : 0; + } + @Override public int convertToPixels(int dipCount, boolean horizontal) { + // 1:1 - the real device DPI is only needed at app runtime. + return Math.round(dipCount / 1000f); + } + @Override public void cleanup(Object o) { + if (o instanceof java.io.Closeable) { + try { ((java.io.Closeable) o).close(); } catch (IOException ignored) {} + } + } + + // ---- Everything below is unreachable from the css-compiler path; ---- + // ---- the overrides exist only so the abstract class compiles. ---- + + @Override public void init(Object m) {} + @Override public int getDisplayWidth() { return 0; } + @Override public int getDisplayHeight() { return 0; } + @Override public void editString(Component cmp, int maxSize, int constraint, String text, int initiatingKeycode) {} + @Override public void flushGraphics(int x, int y, int width, int height) {} + @Override public void flushGraphics() {} + @Override public void getRGB(Object nativeImage, int[] arr, int offset, int x, int y, int width, int height) {} + @Override public Object createImage(int[] rgb, int width, int height) { return null; } + @Override public Object createImage(String path) throws IOException { return null; } + @Override public Object createImage(InputStream i) throws IOException { return null; } + @Override public Object createMutableImage(int width, int height, int fillColor) { return null; } + @Override public Object createImage(byte[] bytes, int offset, int len) { return null; } + @Override public int getImageWidth(Object i) { return 0; } + @Override public int getImageHeight(Object i) { return 0; } + @Override public Object scale(Object nativeImage, int width, int height) { return nativeImage; } + @Override public int getSoftkeyCount() { return 0; } + @Override public int[] getSoftkeyCode(int index) { return new int[0]; } + @Override public int getClearKeyCode() { return 0; } + @Override public int getBackspaceKeyCode() { return 0; } + @Override public int getBackKeyCode() { return 0; } + @Override public int getGameAction(int keyCode) { return 0; } + @Override public int getKeyCode(int gameAction) { return 0; } + @Override public boolean isTouchDevice() { return false; } + @Override public int getColor(Object graphics) { return 0; } + @Override public void setColor(Object graphics, int rgb) {} + @Override public void setAlpha(Object graphics, int alpha) {} + @Override public int getAlpha(Object graphics) { return 255; } + @Override public void setNativeFont(Object graphics, Object font) {} + @Override public int getClipX(Object graphics) { return 0; } + @Override public int getClipY(Object graphics) { return 0; } + @Override public int getClipWidth(Object graphics) { return 0; } + @Override public int getClipHeight(Object graphics) { return 0; } + @Override public void setClip(Object graphics, int x, int y, int width, int height) {} + @Override public void clipRect(Object graphics, int x, int y, int width, int height) {} + @Override public void drawLine(Object graphics, int x1, int y1, int x2, int y2) {} + @Override public void fillRect(Object graphics, int x, int y, int width, int height) {} + @Override public void drawRect(Object graphics, int x, int y, int width, int height) {} + @Override public void drawRoundRect(Object graphics, int x, int y, int width, int height, int arcWidth, int arcHeight) {} + @Override public void fillRoundRect(Object graphics, int x, int y, int width, int height, int arcWidth, int arcHeight) {} + @Override public void fillArc(Object graphics, int x, int y, int width, int height, int startAngle, int arcAngle) {} + @Override public void drawArc(Object graphics, int x, int y, int width, int height, int startAngle, int arcAngle) {} + @Override public void drawString(Object graphics, String str, int x, int y) {} + @Override public void drawImage(Object graphics, Object img, int x, int y) {} + @Override public void drawRGB(Object graphics, int[] rgbData, int offset, int x, int y, int w, int h, boolean processAlpha) {} + @Override public Object getNativeGraphics() { return null; } + @Override public Object getNativeGraphics(Object image) { return null; } + @Override public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { return 0; } + @Override public int stringWidth(Object nativeFont, String str) { return 0; } + @Override public int charWidth(Object nativeFont, char ch) { return 0; } + @Override public int getHeight(Object nativeFont) { return 0; } + @Override public Object getDefaultFont() { return new Triple(0, 0, 0); } + @Override public Object connect(String url, boolean read, boolean write) throws IOException { return null; } + @Override public void setHeader(Object connection, String key, String val) {} + @Override public int getContentLength(Object connection) { return 0; } + @Override public OutputStream openOutputStream(Object connection) throws IOException { return null; } + @Override public OutputStream openOutputStream(Object connection, int offset) throws IOException { return null; } + @Override public InputStream openInputStream(Object connection) throws IOException { return null; } + @Override public void setPostRequest(Object connection, boolean p) {} + @Override public int getResponseCode(Object connection) throws IOException { return 0; } + @Override public String getResponseMessage(Object connection) throws IOException { return null; } + @Override public String getHeaderField(String name, Object connection) throws IOException { return null; } + @Override public String[] getHeaderFieldNames(Object connection) throws IOException { return new String[0]; } + @Override public String[] getHeaderFields(String name, Object connection) throws IOException { return new String[0]; } + @Override public void deleteStorageFile(String name) {} + @Override public OutputStream createStorageOutputStream(String name) throws IOException { return null; } + @Override public InputStream createStorageInputStream(String name) throws IOException { return null; } + @Override public boolean storageFileExists(String name) { return false; } + @Override public String[] listStorageEntries() { return new String[0]; } + @Override public String[] listFilesystemRoots() { return new String[0]; } + @Override public String[] listFiles(String directory) throws IOException { return new String[0]; } + @Override public long getRootSizeBytes(String root) { return 0; } + @Override public long getRootAvailableSpace(String root) { return 0; } + @Override public void mkdir(String directory) {} + @Override public void deleteFile(String file) {} + @Override public boolean isHidden(String file) { return false; } + @Override public void setHidden(String file, boolean h) {} + @Override public long getFileLength(String file) { return 0; } + @Override public boolean isDirectory(String file) { return false; } + @Override public boolean exists(String file) { return false; } + @Override public void rename(String file, String newName) {} + @Override public char getFileSystemSeparator() { return '/'; } + @Override public String getPlatformName() { return "headless"; } + @Override public L10NManager getLocalizationManager() { return null; } +} diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/MissingNativeBrowserException.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/MissingNativeBrowserException.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/designer/css/MissingNativeBrowserException.java rename to maven/css-compiler/src/main/java/com/codename1/designer/css/MissingNativeBrowserException.java diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/NoCefCSSCLI.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/NoCefCSSCLI.java new file mode 100644 index 0000000000..127f38f0cc --- /dev/null +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/NoCefCSSCLI.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * + * Minimal CLI entry point for compiling a CSS theme to a .res file with the + * strictNoCef flag enabled. Unlike CN1CSSCLI (in the Designer module), + * this class does NOT + * pull in any JavaSE port classes or initialize a BrowserComponent host -- + * it is designed to live in a thin "css-compiler" jar that depends only on + * codenameone-core, flute, and sac. + * + * The intended consumer is the native-themes build, which generates the + * shipped platform themes from CSS source. Any CSS rule that would require + * CEF-backed image rasterization fails the compile (see CSSTheme.enforceNoCef). + * + * Usage: + * java -jar codenameone-css-compiler-jar-with-dependencies.jar \ + * -input path/to/theme.css \ + * -output path/to/Theme.res + */ +package com.codename1.designer.css; + +import java.io.File; +import java.net.URL; + +public class NoCefCSSCLI { + + public static void main(String[] args) throws Exception { + // Headless theme build: install a minimal CodenameOneImplementation + // before any CSSTheme code touches Display / Font / Util. Avoids + // peppering CN1 core with `if (impl == null)` fallbacks - the + // unit-test module follows the same pattern with + // TestCodenameOneImplementation. + installHeadlessImplementation(); + if (hasArg(args, "help") || hasArg(args, "h") || args.length == 0) { + printUsage(); + return; + } + String inputPath = getArg(args, "input", "i"); + String outputPath = getArg(args, "output", "o"); + if (inputPath == null || outputPath == null) { + printUsage(); + System.exit(1); + } + + File inputFile = new File(inputPath); + File outputFile = new File(outputPath); + if (!inputFile.exists()) { + System.err.println("Input CSS file does not exist: " + inputFile.getAbsolutePath()); + System.exit(2); + } + File parent = outputFile.getAbsoluteFile().getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + System.err.println("Could not create output directory: " + parent.getAbsolutePath()); + System.exit(3); + } + + CSSTheme.strictNoCef = true; + System.out.println("Compiling " + inputFile.getName() + " -> " + outputFile.getName() + " (no-cef)"); + + URL url = inputFile.toURI().toURL(); + CSSTheme theme = CSSTheme.load(url); + if (theme == null) { + System.err.println("CSSTheme.load returned null for " + inputFile + + " - parser probably failed to initialize. See stderr above for details."); + System.exit(4); + } + theme.cssFile = inputFile; + theme.resourceFile = outputFile; + + // createImageBorders walks every rule. With strictNoCef=true it + // throws an IllegalStateException listing any rule that would need + // CEF rasterization before the unreachable webview path would run. + // The provider below is a safety net: if a post-enforceNoCef code + // path still asks for a WebView, fail loud instead of NPE. + theme.createImageBorders(new CSSTheme.WebViewProvider() { + @Override + public com.codename1.ui.BrowserComponent getWebView() { + throw new IllegalStateException( + "CSS compile in no-cef mode must not request a WebView. " + + "enforceNoCef should have rejected the offending rule; " + + "please report this bug."); + } + }); + + theme.updateResources(); + theme.save(outputFile); + System.out.println("Wrote " + outputFile.getAbsolutePath()); + } + + private static boolean hasArg(String[] args, String... names) { + return getArg(args, names) != null; + } + + private static String getArg(String[] args, String... names) { + for (int i = 0; i < args.length; i++) { + String a = args[i]; + if (a == null) { + continue; + } + if (a.startsWith("-")) { + String key = a.substring(1); + while (key.startsWith("-")) { + key = key.substring(1); + } + int eq = key.indexOf('='); + String value; + if (eq >= 0) { + value = key.substring(eq + 1); + key = key.substring(0, eq); + } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) { + value = args[i + 1]; + } else { + value = "true"; + } + for (String n : names) { + if (n.equals(key)) { + return value; + } + } + } + } + return null; + } + + private static void installHeadlessImplementation() throws Exception { + // Display.impl is package-private and there is no public installer. + // Reflect into the field once at startup; mirrors the pattern used + // by the unit-test DisplayContext helper. Util keeps its own copy of + // the implementation reference (Util.implInstance) which is normally + // pushed via Util.setImplementation - that one is public so we use + // it directly. + HeadlessCssCompilerImplementation stub = new HeadlessCssCompilerImplementation(); + Class displayCls = Class.forName("com.codename1.ui.Display"); + java.lang.reflect.Field implField = displayCls.getDeclaredField("impl"); + implField.setAccessible(true); + if (implField.get(null) == null) { + implField.set(null, stub); + } + com.codename1.io.Util.setImplementation(stub); + } + + private static void printUsage() { + System.out.println("Codename One CSS Compiler (no-cef, native-themes build)"); + System.out.println(); + System.out.println("Usage:"); + System.out.println(" java -jar codenameone-css-compiler--jar-with-dependencies.jar \\"); + System.out.println(" -input \\"); + System.out.println(" -output "); + System.out.println(); + System.out.println("Any CSS rule requiring CEF-backed image rasterization (box-shadow,"); + System.out.println("border-radius combined with visible border, filter, complex gradients)"); + System.out.println("fails the compile with the list of offending rules."); + } +} diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/PollingFileWatcher.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/PollingFileWatcher.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/designer/css/PollingFileWatcher.java rename to maven/css-compiler/src/main/java/com/codename1/designer/css/PollingFileWatcher.java diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/ResourcesMutator.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/ResourcesMutator.java similarity index 99% rename from CodenameOneDesigner/src/com/codename1/designer/css/ResourcesMutator.java rename to maven/css-compiler/src/main/java/com/codename1/designer/css/ResourcesMutator.java index 5011ba220c..9764c5b21a 100644 --- a/CodenameOneDesigner/src/com/codename1/designer/css/ResourcesMutator.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/ResourcesMutator.java @@ -625,9 +625,9 @@ public void run() { web.execute("$(document).ready(function(){ captureScreenshots();});"); //web.getEngine().executeScript("window.onload = function(){window.app.ready()};"); } catch (IllegalArgumentException ex) { - Logger.getLogger(CN1CSSCompiler.class.getName()).log(Level.SEVERE, null, ex); + Logger.getLogger(ResourcesMutator.class.getName()).log(Level.SEVERE, null, ex); } catch (SecurityException ex) { - Logger.getLogger(CN1CSSCompiler.class.getName()).log(Level.SEVERE, null, ex); + Logger.getLogger(ResourcesMutator.class.getName()).log(Level.SEVERE, null, ex); } }; @@ -696,7 +696,7 @@ BufferedImage createHtmlScreenshot(BrowserComponent web, String html) { try { lock.wait(); } catch (InterruptedException ex) { - Logger.getLogger(CN1CSSCompiler.class.getName()).log(Level.SEVERE, null, ex); + Logger.getLogger(ResourcesMutator.class.getName()).log(Level.SEVERE, null, ex); } } } diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/SimpleWebServer.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/SimpleWebServer.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/designer/css/SimpleWebServer.java rename to maven/css-compiler/src/main/java/com/codename1/designer/css/SimpleWebServer.java diff --git a/CodenameOneDesigner/src/com/codename1/designer/css/WebviewSnapshotter.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/WebviewSnapshotter.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/designer/css/WebviewSnapshotter.java rename to maven/css-compiler/src/main/java/com/codename1/designer/css/WebviewSnapshotter.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/CodenameOneAccessor.java b/maven/css-compiler/src/main/java/com/codename1/ui/CodenameOneAccessor.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/CodenameOneAccessor.java rename to maven/css-compiler/src/main/java/com/codename1/ui/CodenameOneAccessor.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/EditorFont.java b/maven/css-compiler/src/main/java/com/codename1/ui/EditorFont.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/EditorFont.java rename to maven/css-compiler/src/main/java/com/codename1/ui/EditorFont.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/EditorTTFFont.java b/maven/css-compiler/src/main/java/com/codename1/ui/EditorTTFFont.java similarity index 94% rename from CodenameOneDesigner/src/com/codename1/ui/EditorTTFFont.java rename to maven/css-compiler/src/main/java/com/codename1/ui/EditorTTFFont.java index f2d967602c..7646e53dc3 100644 --- a/CodenameOneDesigner/src/com/codename1/ui/EditorTTFFont.java +++ b/maven/css-compiler/src/main/java/com/codename1/ui/EditorTTFFont.java @@ -108,6 +108,14 @@ public void refresh() { throw new IllegalArgumentException("Unsupported native font type: " + nativeFontName); } InputStream is = getClass().getResourceAsStream("/com/codename1/impl/javase/Roboto-" + res + ".ttf"); + if (is == null) { + // Headless css-compiler run (native-themes build) does + // not ship the Roboto TTF resources from javase. The + // serialized .res stores the nativeFontName; the native + // handle is recreated at app runtime when a full CN1 + // implementation is available. + return; + } try { f = java.awt.Font.createFont(java.awt.Font.TRUETYPE_FONT, is); is.close(); diff --git a/CodenameOneDesigner/src/com/codename1/ui/animations/AnimationAccessor.java b/maven/css-compiler/src/main/java/com/codename1/ui/animations/AnimationAccessor.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/animations/AnimationAccessor.java rename to maven/css-compiler/src/main/java/com/codename1/ui/animations/AnimationAccessor.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/plaf/Accessor.java b/maven/css-compiler/src/main/java/com/codename1/ui/plaf/Accessor.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/plaf/Accessor.java rename to maven/css-compiler/src/main/java/com/codename1/ui/plaf/Accessor.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/plaf/ProtectedUIManager.java b/maven/css-compiler/src/main/java/com/codename1/ui/plaf/ProtectedUIManager.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/plaf/ProtectedUIManager.java rename to maven/css-compiler/src/main/java/com/codename1/ui/plaf/ProtectedUIManager.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/EditableResources.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java similarity index 95% rename from CodenameOneDesigner/src/com/codename1/ui/util/EditableResources.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java index 7a0ebeb2a2..2b4d6eef1f 100644 --- a/CodenameOneDesigner/src/com/codename1/ui/util/EditableResources.java +++ b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResources.java @@ -25,16 +25,6 @@ package com.codename1.ui.util; import com.codename1.ui.Display; -import com.codename1.designer.ResourceEditorView; -import com.codename1.designer.DataEditor; -import com.codename1.designer.FontEditor; -import com.codename1.designer.ImageMultiEditor; -import com.codename1.designer.ImageRGBEditor; -import com.codename1.designer.L10nEditor; -import com.codename1.designer.MultiImageSVGEditor; -import com.codename1.designer.ThemeEditor; -import com.codename1.designer.TimelineEditor; -import com.codename1.designer.UserInterfaceEditor; import com.codename1.ui.EditorFont; import com.codename1.ui.EditorTTFFont; import com.codename1.ui.EncodedImage; @@ -44,12 +34,9 @@ import com.codename1.ui.animations.AnimationObject; import com.codename1.ui.animations.Motion; import com.codename1.ui.animations.Timeline; -import com.codename1.impl.javase.SVG; import com.codename1.ui.plaf.Border; import com.codename1.ui.plaf.Accessor; import com.codename1.ui.plaf.Style; -import com.codename1.designer.ResourceEditorApp; -import com.codename1.impl.javase.JavaSEPortWithSVGSupport; import com.codename1.ui.plaf.CSSBorder; import com.codename1.ui.plaf.RoundBorder; import com.codename1.ui.plaf.RoundRectBorder; @@ -93,7 +80,6 @@ import java.util.Map; import javax.imageio.ImageIO; import javax.swing.Icon; -import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JOptionPane; import javax.swing.JPasswordField; @@ -112,10 +98,16 @@ public class EditableResources extends Resources implements TreeModel { private static final short MINOR_VERSION = 12; private static final short MAJOR_VERSION = 1; + private static final boolean IS_MAC; + static { + String osName = System.getProperty("os.name", ""); + IS_MAC = osName.toLowerCase().contains("mac"); + } + private boolean modified; private boolean loadingMode = false; private boolean xmlUI; - + private boolean ignoreSVGMode; private boolean ignorePNGMode; @@ -123,8 +115,77 @@ public class EditableResources extends Resources implements TreeModel { private File overrideFile; private EditableResources parentResource; private static boolean xmlEnabled; - + private HashSet themeLoadingErrors; + + /** + * Optional file context used to resolve relative TTF font paths embedded in + * the resource. The Designer subclass wires this to the currently loaded + * resource file; headless callers (e.g. the CSS native-themes build) leave + * it null and treat the legacy-font fallback as unavailable. + */ + private File loadedBaseFile; + + public void setLoadedBaseFile(File loadedBaseFile) { + this.loadedBaseFile = loadedBaseFile; + } + + /** + * Returns the base file used for TTF font path resolution, or null when + * no resource is currently anchored to a file. Overridable by the Designer + * subclass to query its GUI-managed loaded-file state. + */ + protected File getLoadedFile() { + return loadedBaseFile; + } + + /** + * Serialize a freshly-loaded UI container to the binary resource format. + * Called during openFile() when reading an XML-sourced resource. Throws + * in the base class: UI container persistence lives in the Designer + * editor subclass, which has the UserInterfaceEditor on its classpath. + */ + protected byte[] persistUIContainer(com.codename1.ui.Container cnt) { + throw new UnsupportedOperationException( + "UI container persistence requires EditableResourcesEditor"); + } + + /** + * Materialize a UI container from an XML component description. Called + * during openFile() when the resource was saved as XML. Returns null in + * the base class so openFile falls back to reading the binary UI blob; + * the Designer subclass overrides to drive UIBuilderOverride. + */ + protected com.codename1.ui.Container loadUIContainerFromXml(com.codename1.ui.util.xml.comps.ComponentEntry uiXMLData) { + return null; + } + + /** + * Materialize a UI container from a named binary UI resource so the + * containing resource can be re-serialized as XML. The Designer subclass + * overrides this with UIBuilderOverride; headless callers never hit the + * XML save path. + */ + protected com.codename1.ui.Container materializeUIContainer(String resourceName) { + throw new UnsupportedOperationException( + "UI container materialization for XML save requires EditableResourcesEditor"); + } + + /** + * Invoked at the end of openFile(). The Designer subclass overrides this + * to reset GUI-side theme caches; headless callers do nothing. + */ + protected void onOpenFileComplete() { + } + + /** + * Returns the runtime native theme as an EditableResources (may be null). + * The Designer subclass queries JavaSEPortWithSVGSupport; headless callers + * have no runtime theme so the base returns null. + */ + protected EditableResources getRuntimeNativeTheme() { + return null; + } public static void setXMLEnabled(boolean b) { xmlEnabled = b; @@ -836,9 +897,8 @@ public int compare(ComponentEntry o1, ComponentEntry o2) { } }); for(ComponentEntry uiXMLData : guiElements) { - UIBuilderOverride uib = new UIBuilderOverride(); - com.codename1.ui.Container cnt = uib.createInstance(uiXMLData, this); - + com.codename1.ui.Container cnt = loadUIContainerFromXml(uiXMLData); + // encountered an error loading the component fallback to loading with the binary types if(cnt == null) { for(Ui ui : xmlData.getUi()) { @@ -846,7 +906,7 @@ public int compare(ComponentEntry o1, ComponentEntry o2) { } break; } else { - byte[] data = UserInterfaceEditor.persistContainer(cnt, this); + byte[] data = persistUIContainer(cnt); setResource(uiXMLData.getName(), MAGIC_UI, data); } } @@ -982,7 +1042,7 @@ private void saveXMLFile(File xml, File resourcesDir) throws IOException { case 0xf5: // multiimage with SVG case 0xf7: - SVG s = (SVG)image.getSVGDocument(); + SvgBridge s = SvgBridge.of(image.getSVGDocument()); writeToFile(s.getSvgData(), new File(resourcesDir, normalizeFileName(resourceNames[iter]))); if(s.getBaseURL() != null && s.getBaseURL().length() > 0) { @@ -1414,8 +1474,7 @@ private void saveXMLFile(File xml, File resourcesDir) throws IOException { } case MAGIC_UI: { File uiXML = new File(resourcesDir, resourceNames[iter] + ".ui"); - UIBuilderOverride u = new UIBuilderOverride(); - com.codename1.ui.Container cnt = u.createContainer(this, resourceNames[iter]); + com.codename1.ui.Container cnt = materializeUIContainer(resourceNames[iter]); FileOutputStream fos = new FileOutputStream(uiXML); writeUIXml(cnt, fos); fos.close(); @@ -1463,14 +1522,14 @@ private void saveXMLFile(File xml, File resourcesDir) throws IOException { } } - private void writeUIXml(com.codename1.ui.Container cnt, FileOutputStream dest) throws IOException { - Writer w = new OutputStreamWriter(dest, "UTF-8"); - w.write("\n\n"); - - StringBuilder bld = new StringBuilder(); - UserInterfaceEditor.persistToXML(cnt, cnt, bld, this, ""); - w.write(bld.toString()); - w.flush(); + /** + * Writes a UI container as XML to the given stream. Overridden by the + * Designer editor subclass; the base class throws because the XML + * persister lives in the Designer module. + */ + protected void writeUIXml(com.codename1.ui.Container cnt, FileOutputStream dest) throws IOException { + throw new UnsupportedOperationException( + "UI container XML persistence requires EditableResourcesEditor"); } public void saveXML(File resFile) throws IOException { @@ -1511,7 +1570,7 @@ public void openFile(final InputStream input) throws IOException { undoQueue.clear(); redoQueue.clear(); } - ThemeEditor.resetThemeLoaded(); + onOpenFileComplete(); } /** @@ -1574,7 +1633,7 @@ private void updateModified() { overrideResource.updateModified(); return; } - if(ResourceEditorApp.IS_MAC) { + if(IS_MAC) { for(java.awt.Window w : java.awt.Frame.getWindows()) { if(w instanceof JFrame) { if(modified) { @@ -1933,13 +1992,14 @@ public String[] getDataResourceNames() { com.codename1.ui.Font createTrueTypeFont(com.codename1.ui.Font f, String fontName, String fileName, float fontSize, int sizeSetting) { // workaround for NPE in case of people doing stupid things like moving the res file. - if(ResourceEditorView.getLoadedFile() == null && !fileName.startsWith("native:")) { + File loadedFile = getLoadedFile(); + if(loadedFile == null && !fileName.startsWith("native:")) { return f; } if(fileName.startsWith("native:")) { - return new EditorTTFFont(fileName, sizeSetting, fontSize, f); + return new EditorTTFFont(fileName, sizeSetting, fontSize, f); } - File fontFile = new File(ResourceEditorView.getLoadedFile().getParentFile(), fileName); + File fontFile = new File(loadedFile.getParentFile(), fileName); if(fontFile.exists()) { return new EditorTTFFont(fontFile, sizeSetting, fontSize, f); } @@ -2594,7 +2654,7 @@ private void writeMotion(Motion m, DataOutputStream output) throws IOException { } private void saveSVG(DataOutputStream out, Image i, boolean isMultiImage) throws IOException { - SVG s = (SVG)i.getSVGDocument(); + SvgBridge s = SvgBridge.of(i.getSVGDocument()); out.writeInt(s.getSvgData().length); out.write(s.getSvgData()); if(s.getBaseURL() == null) { @@ -2634,7 +2694,7 @@ private com.codename1.ui.EncodedImage toEncodedImage(Image image) throws IOExcep } private MultiImage svgToMulti(Image image) throws IOException { - SVG s = (SVG)image.getSVGDocument(); + SvgBridge s = SvgBridge.of(image.getSVGDocument()); MultiImage mi = new MultiImage(); mi.dpi = s.getDpis(); if(mi.dpi == null || mi.dpi.length == 0) { @@ -2654,7 +2714,7 @@ private MultiImage svgToMulti(Image image) throws IOException { @Override com.codename1.ui.Image createSVG(boolean animated, byte[] data) throws IOException { com.codename1.ui.Image img = super.createSVG(animated, data); - SVG s = (SVG)img.getSVGDocument(); + SvgBridge s = SvgBridge.of(img.getSVGDocument()); if(s != null) { s.setDpis(dpisLoaded); s.setWidthForDPI(widthForDPI); @@ -2730,7 +2790,7 @@ void loadSVGRatios(DataInputStream input) throws IOException { Image createImage() throws IOException { Image i = super.createImage(); if(i.isSVG()) { - SVG s = (SVG)i.getSVGDocument(); + SvgBridge s = SvgBridge.of(i.getSVGDocument()); s.setRatioH(ratioH); s.setRatioW(ratioW); } @@ -2741,7 +2801,7 @@ Image createImage() throws IOException { Image createImage(DataInputStream input) throws IOException { Image i = super.createImage(input); if(i.isSVG()) { - SVG s = (SVG)i.getSVGDocument(); + SvgBridge s = SvgBridge.of(i.getSVGDocument()); s.setRatioH(ratioH); s.setRatioW(ratioW); } @@ -2835,7 +2895,7 @@ protected String performUndo() { } public void setSVGDPIs(final String name, final int[] dpi, final int[] widths, final int[] heights) { - final SVG sv = (SVG)getImage(name).getSVGDocument(); + final SvgBridge sv = SvgBridge.of(getImage(name).getSVGDocument()); final int[] currentDPIs = sv.getDpis(); final int[] currentWidths = sv.getWidthForDPI(); final int[] currentHeights = sv.getHeightForDPI(); @@ -3191,7 +3251,7 @@ protected String performUndo() { } public void refreshThemeMultiImages() { - EditableResources ed = (EditableResources)JavaSEPortWithSVGSupport.getNativeTheme(); + EditableResources ed = getRuntimeNativeTheme(); if(ed != null && ed != this) { ed.refreshThemeMultiImages(); } @@ -3344,58 +3404,11 @@ byte getResourceType(String name) { return super.getResourceType(name); } - public JComponent getResourceEditor(String name, ResourceEditorView view) { - byte magic = getResourceType(name); - switch(magic) { - case MAGIC_IMAGE: - case MAGIC_IMAGE_LEGACY: - Image i = getImage(name); - if(getResourceObject(name) instanceof MultiImage) { - ImageMultiEditor tl = new ImageMultiEditor(this, name, view); - tl.setImage((MultiImage)getResourceObject(name)); - return tl; - } - if(i instanceof Timeline) { - TimelineEditor tl = new TimelineEditor(this, name, view); - tl.setImage((Timeline)i); - return tl; - } - if(i.isSVG()) { - MultiImageSVGEditor img = new MultiImageSVGEditor(this, name); - img.setImage(i); - return img; - } - ImageRGBEditor img = new ImageRGBEditor(this, name, view); - img.setImage(i); - return img; - case MAGIC_TIMELINE: - TimelineEditor tl = new TimelineEditor(this, name, view); - tl.setImage((Timeline)getImage(name)); - return tl; - case MAGIC_THEME: - case MAGIC_THEME_LEGACY: - ThemeEditor theme = new ThemeEditor(this, name, getTheme(name), view); - return theme; - case MAGIC_FONT: - case MAGIC_FONT_LEGACY: - case MAGIC_INDEXED_FONT_LEGACY: - FontEditor fonts = new FontEditor(this, getFont(name), name); - return fonts; - case MAGIC_DATA: - DataEditor data = new DataEditor(this, name); - return data; - case MAGIC_UI: - UserInterfaceEditor uie = new UserInterfaceEditor(name, this, view.getProjectGeneratorSettings(), view); - return uie; - case MAGIC_L10N: - // we are cheating this isn't a theme but it should work since - // this is a hashtable that will include the nested locales - L10nEditor l10n = new L10nEditor(this, name); - return l10n; - default: - throw new IllegalArgumentException("Unrecognized magic number: " + Integer.toHexString(magic & 0xff)); - } - } + // getResourceEditor(String, ResourceEditorView) lives on EditableResourcesEditor, + // which extends this class inside the Designer module. Headless callers (e.g. + // the native-themes CSS build) never open a GUI editor, so keeping it out of + // the base class lets this module compile without the Designer GUI on its + // classpath. public static EditableResources open(InputStream resource) throws IOException { return new EditableResources(resource); @@ -3631,4 +3644,52 @@ public com.codename1.ui.EncodedImage getBest() { return getInternalImages()[bestFitOffset]; } } + + /** + * Reflective bridge to the javase-svg SVG class. Kept internal so that + * neither core nor the css-compiler module has to expose an SVG-facing + * public API, and so that neither module has to name the SVG class at + * compile time (it lives in javase-svg). The reflection paths are cold + * in the headless css-compiler run - they execute only when the resource + * being serialized actually contains SVG images. + */ + static final class SvgBridge { + private final Object svg; + + private SvgBridge(Object svg) { + this.svg = svg; + } + + static SvgBridge of(Object svg) { + return svg == null ? null : new SvgBridge(svg); + } + + byte[] getSvgData() { return (byte[]) call("getSvgData"); } + String getBaseURL() { return (String) call("getBaseURL"); } + float getRatioW() { return ((Number) call("getRatioW")).floatValue(); } + float getRatioH() { return ((Number) call("getRatioH")).floatValue(); } + int[] getDpis() { return (int[]) call("getDpis"); } + int[] getWidthForDPI() { return (int[]) call("getWidthForDPI"); } + int[] getHeightForDPI() { return (int[]) call("getHeightForDPI"); } + + void setRatioW(float v) { call("setRatioW", new Class[]{float.class}, v); } + void setRatioH(float v) { call("setRatioH", new Class[]{float.class}, v); } + void setDpis(int[] v) { call("setDpis", new Class[]{int[].class}, (Object) v); } + void setWidthForDPI(int[] v) { call("setWidthForDPI", new Class[]{int[].class}, (Object) v); } + void setHeightForDPI(int[] v) { call("setHeightForDPI", new Class[]{int[].class}, (Object) v); } + + private Object call(String method) { + return call(method, new Class[0]); + } + + private Object call(String method, Class[] ptypes, Object... args) { + try { + return svg.getClass().getMethod(method, ptypes).invoke(svg, args); + } catch (Exception e) { + throw new RuntimeException( + "SVG bridge failed to invoke " + method + " on " + + (svg == null ? "null" : svg.getClass().getName()), e); + } + } + } } diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/EditableResourcesForCSS.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResourcesForCSS.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/EditableResourcesForCSS.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/EditableResourcesForCSS.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Border.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Border.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Border.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Border.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Data.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Data.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Data.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Data.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Entry.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Entry.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Entry.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Entry.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Font.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Font.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Font.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Font.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Gradient.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Gradient.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Gradient.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Gradient.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Image.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Image.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Image.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Image.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/L10n.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/L10n.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/L10n.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/L10n.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Lang.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Lang.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Lang.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Lang.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/LegacyFont.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/LegacyFont.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/LegacyFont.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/LegacyFont.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/ResourceFileXML.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/ResourceFileXML.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/ResourceFileXML.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/ResourceFileXML.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/SimpleXmlParser.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/SimpleXmlParser.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/SimpleXmlParser.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/SimpleXmlParser.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Theme.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Theme.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Theme.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Theme.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Ui.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Ui.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Ui.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Ui.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/Val.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Val.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/Val.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/Val.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/ArrayEntry.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/ArrayEntry.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/ArrayEntry.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/ArrayEntry.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/CommandEntry.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/CommandEntry.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/CommandEntry.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/CommandEntry.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/ComponentEntry.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/ComponentEntry.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/ComponentEntry.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/ComponentEntry.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/Custom.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/Custom.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/Custom.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/Custom.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/LayoutConstraint.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/LayoutConstraint.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/LayoutConstraint.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/LayoutConstraint.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/MapItems.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/MapItems.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/MapItems.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/MapItems.java diff --git a/CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/StringEntry.java b/maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/StringEntry.java similarity index 100% rename from CodenameOneDesigner/src/com/codename1/ui/util/xml/comps/StringEntry.java rename to maven/css-compiler/src/main/java/com/codename1/ui/util/xml/comps/StringEntry.java diff --git a/maven/designer/pom.xml b/maven/designer/pom.xml index 0745ea4749..8b5e310b81 100644 --- a/maven/designer/pom.xml +++ b/maven/designer/pom.xml @@ -28,6 +28,10 @@ codenameone-javase-svg + + com.codenameone + codenameone-css-compiler + org.swinglabs diff --git a/maven/javase/pom.xml b/maven/javase/pom.xml index f63b923550..bf65ded8c7 100644 --- a/maven/javase/pom.xml +++ b/maven/javase/pom.xml @@ -142,6 +142,24 @@ + + + + + + + + + + +
diff --git a/maven/pom.xml b/maven/pom.xml index 116039f3ee..104f8ecd52 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -60,6 +60,7 @@ java-runtime core factory + css-compiler sqlite-jdbc javase javase-svg @@ -93,6 +94,11 @@ codenameone-core ${project.version} + + com.codenameone + codenameone-css-compiler + ${project.version} + com.codenameone sqlite-jdbc diff --git a/native-themes/README.md b/native-themes/README.md new file mode 100644 index 0000000000..0aeca31dc8 --- /dev/null +++ b/native-themes/README.md @@ -0,0 +1,104 @@ +# Native theme CSS sources + +This directory holds the Codename One platform native themes authored in CSS. +They are compiled by `scripts/build-native-themes.sh` (which invokes the thin +`maven/css-compiler` jar with `strictNoCef=true`) into `.res` files under the +repo's `Themes/` directory, alongside the legacy hand-authored themes. + +## Layout + +``` +native-themes/ + base/ shared tokens, @constants, @font-face (future) + ios-modern/theme.css iOS liquid-glass theme + android-material/theme.css Android Material 3 theme +``` + +Each `theme.css` is fed directly to the compiler. Until `@import` support is +confirmed in Flute/SAC, `theme.css` is a single self-contained file (no +`@import`). + +## Authoring rules + +Because these themes ship inside the port jars, rasterized image fallbacks +are forbidden. The compiler runs in `strictNoCef` mode: any rule that would +require CEF rasterization fails the build and lists the offending UIID. + +**Allowed:** + +- Solid `color` / `background-color`. +- `cn1-round-border`, `cn1-pill-border`, simple matched-side `border`. +- `padding`, `margin`, typography (`font-family`/`font-size`/`font-weight`). +- `cn1-derive`, `cn1-image-id` (resource images shipped as PNG), `cn1-mutable-image`. +- `cn1-source-dpi` for multi-DPI image variants. +- `.pressed`, `.selected`, `.unselected`, `.disabled` state selectors (dot-class + syntax — the CN1 CSS compiler translates these to the binding state of the + UIID, not CSS classes in the HTML sense). +- `@media (prefers-color-scheme: dark)` for dark palette overrides. +- `var(--x)` and `@constants { ... }`. + +**Forbidden (trigger CEF):** + +- `box-shadow`, `cn1-box-shadow-*` -> 9-piece fallback. +- `border-radius` combined with a visible border -> 9-piece fallback. +- Mixed border widths/styles/colors per side. +- `filter`. +- Complex `linear-gradient` / `radial-gradient` that can't be expressed as a + native gradient. + +If a visual effect isn't in the allowed list, extend the CSS compiler and/or +`.res` format with a new native primitive -- don't rasterize. + +## Mandatory constants + +Each theme must declare these in `#Constants`: + +- `includeNativeBool: false` -- native themes are the base; user themes set + this to `true` and inherit from us. If we set it to `true` ourselves we'd + try to inherit from ourselves and recurse at load time. +- `darkModeBool: true` -- enables UIManager's `$Dark` style resolution, + which is populated from the theme's `@media (prefers-color-scheme: dark)` + blocks. + +## cn1-derive inheritance rule + +`cn1-derive` only works reliably when the derived UIID is a straightforward +refinement of the base (child refines parent). Examples that are fine: + +- `SecondaryLabel { cn1-derive: Label; ... }` +- `MainTitle { cn1-derive: Title; ... }` +- `RaisedButton { cn1-derive: Button; ... }` +- `SelectedTab { cn1-derive: Tab; ... }` + +Examples that were problematic and are now inlined: + +- `TitleArea -> Toolbar` hung the iOS UIManager style resolver after + `setThemeProps()` swapped in the theme mid-flight. Both themes inline + Toolbar's props directly into TitleArea. +- `DialogTitle -> Title`, `DialogBody -> Dialog`, `PopupContent -> Dialog` + are cross-context (different UIIDs, not refinement). Inlined. +- `TextArea -> TextField`, `RadioButton -> CheckBox` are specializations + rather than refinements. Inlined for simplicity. + +Rule of thumb: if a reader would have to check the base UIID to understand +the derived one, inline instead. + +## Future: real backdrop-filter glass + +The iOS 26 tab bar (and equivalent Material 3 surfaces) use an OS-provided +backdrop blur (UIVisualEffectView on iOS, RenderEffect on Android). The +current CSS approximates it with a solid surface-container color on the +tabs group; a real glass effect will need a new CSS primitive +(`cn1-backdrop-filter: glass()`) and port-side code that maps +it to UIVisualEffectView / RenderEffect. That lands in a separate PR. + +## Rebuilding + +``` +./scripts/build-native-themes.sh +``` + +Outputs: + +- `Themes/iOSModernTheme.res` +- `Themes/AndroidMaterialTheme.res` diff --git a/native-themes/android-material/theme.css b/native-themes/android-material/theme.css new file mode 100644 index 0000000000..8e7553dc84 --- /dev/null +++ b/native-themes/android-material/theme.css @@ -0,0 +1,494 @@ +/* + * Android Material 3 native theme. + * + * CEF-free subset of the Codename One CSS compiler: solid fills, native + * rounded/pill borders, state selectors via .pressed / .selected / + * .disabled. Light and dark palettes live at the bottom of this file + * in a prefers-color-scheme block the compiler rewrites into $DarkUIID + * entries. Colors are inlined (not CSS variables) because the rewriter + * operates at string level and doesn't re-scope :root declarations. + * + * Material 3 baseline palette reference: + * primary #6750a4 dark #d0bcff + * on-primary #ffffff dark #381e72 + * primary-container #eaddff dark #4f378b + * on-primary-container #21005d dark #eaddff + * surface #fef7ff dark #141218 + * on-surface #1d1b20 dark #e6e0e9 + * surface-variant #e7e0ec dark #49454f + * on-surface-variant #49454f dark #cac4d0 + * surface-container #f3edf7 dark #211f26 + * outline #79747e dark #938f99 + * outline-variant #cac4d0 dark #49454f + * state-pressed #d0bcff dark #4f378b + * state-disabled #e0dce4 dark #2b2930 + * on-disabled #a5a0ab dark #5c5967 + * + * Overridable palette: user apps layer their own theme.css or runtime + * UIManager.addThemeProps on top of this theme - see + * PaletteOverrideThemeScreenshotTest for a working example. + */ + +#Constants { + includeNativeBool: false; + darkModeBool: true; + commandBehavior: Native; + paintsTitleBarBool: false; + /* Material 3 Tabs stay at the top, edge-to-edge. The Material 3 + bottom-nav-rail is a separate component (NavigationBar) and + is not what Codename One's Tabs widget represents. + tabPlacementInt is set explicitly so flipping the playground + device toggle from iOS Modern (which sets it to BOTTOM=2) + back to Android resets it to TOP=0; without an explicit value + the iOS constant would leak across themes after a reload. + tabsSafeAreaBool is the framework default (true) - Material + tab strip is full-width and reserves the indicator space + internally, no host wrapper needed. */ + tabPlacementInt: 0; + tabsSafeAreaBool: true; + tabsFillRowsBool: true; + tabsGridBool: true; + switchThumbPaddingInt: 2; + switchThumbScaleY: "1.5"; + switchTrackScaleY: "0.9"; + switchTrackScaleX: "3"; + switchTrackOffOutlineWidthMM: "0"; + switchTrackOnOutlineWidthMM: "0"; + switchTrackOffOutlineColor: "79747e"; + switchThumbInsetMM: "0"; + /* Dialog sizing/layout constants - see iOS Modern theme for the + rationale. Without these the framework falls back to a wider + default Dialog layout that ballooned Dialog.show() to near-full + screen with the body floating in empty space. */ + hideEmptyTitleBool: true; + shrinkPopupTitleBool: true; + dialogButtonCommandsBool: true; + dlgCommandGridBool: true; +} + +Component { + color: #1d1b20; + background-color: #fef7ff; + padding: 0; + margin: 0; +} + +Form { background-color: #fef7ff; padding: 0; margin: 0; } +ContentPane { background-color: #fef7ff; padding: 0; margin: 0; } +/* Container is layout-only by default - transparent so wrappers + like SpanLabel (which uses UIID="Container") inside a coloured + parent (Dialog, Tab pill) let that parent's surface show + through instead of painting an opaque block over it. */ +Container { background-color: transparent; padding: 0; margin: 0; } + +/* Label transparent so it sits cleanly over its parent's surface. + SpanLabel's internal TextArea uses UIID="Label" so this also + fixes the visible block behind dialog body text. */ +Label { + color: #1d1b20; + background-color: transparent; + padding: 0.5mm 1.5mm 0.5mm 1.5mm; + margin: 0; + font-family: "native:MainRegular"; + font-size: 3.5mm; +} + +SecondaryLabel { cn1-derive: Label; color: #49454f; } +TertiaryLabel { cn1-derive: Label; color: #79747e; } +/* SpanLabel + SpanLabelText transparent so they sit cleanly over a + translucent / coloured parent (Dialog, PopupContent) without + painting their own surface fill. */ +SpanLabel { cn1-derive: Label; background-color: transparent; } +SpanLabelText { cn1-derive: Label; background-color: transparent; } + +/* Material 3 button: fully-rounded "pill" shape via cn1-pill-border + (RoundBorder, CEF-free). border-radius (RoundRectBorder) was leaving + visible artefacts when the button height didn't divide cleanly - the + pill border paints a single rounded fill that scales correctly. + Generous horizontal padding so short labels still get clear pill + ends. cn1-derive doesn't carry cn1-background-type forward, so the + derived UIIDs (RaisedButton, FlatButton) re-declare it explicitly. */ +/* Re-declare every state-affecting property (padding, margin, font, + text-align) on `.pressed` and `.disabled` so the compiled $press# + / $dis# style entries keep IDENTICAL geometry to the base. Without + the re-declaration the framework's per-state Style merge introduces + a subtle vertical drift while a button is held - text "jumps" by + a pixel or two between unselected and pressed. cn1-derive on a + state selector wouldn't help here (the compiler's getThemeDerive + only honours base-level cn1-derive), so explicit values are the + reliable fix. */ +Button { + color: #ffffff; + background-color: #6750a4; + padding: 1.5mm 4mm 1.5mm 4mm; + margin: 0.8mm; + font-family: "native:MainRegular"; + font-size: 3.5mm; + cn1-background-type: cn1-pill-border; + text-align: center; +} +Button.pressed { + color: #21005d; + background-color: #d0bcff; + padding: 1.5mm 4mm 1.5mm 4mm; + margin: 0.8mm; + font-family: "native:MainRegular"; + font-size: 3.5mm; + cn1-background-type: cn1-pill-border; + text-align: center; +} +Button.disabled { + color: #a5a0ab; + background-color: #e0dce4; + padding: 1.5mm 4mm 1.5mm 4mm; + margin: 0.8mm; + font-family: "native:MainRegular"; + font-size: 3.5mm; + cn1-background-type: cn1-pill-border; + text-align: center; +} + +/* RaisedButton: bumped surface (M3 "elevated" tone) to keep it + visibly distinct from the default filled Button, including in + dark mode where Button's own container color otherwise matches. */ +RaisedButton { + cn1-derive: Button; + color: #21005d; + background-color: #eaddff; + padding: 1.5mm 4mm 1.5mm 4mm; + margin: 0.8mm; + font-family: "native:MainRegular"; + font-size: 3.5mm; + cn1-background-type: cn1-pill-border; + text-align: center; +} +RaisedButton.pressed { + color: #21005d; + background-color: #d0bcff; + padding: 1.5mm 4mm 1.5mm 4mm; + margin: 0.8mm; + font-family: "native:MainRegular"; + font-size: 3.5mm; + cn1-background-type: cn1-pill-border; + text-align: center; +} +RaisedButton.disabled { + color: #a5a0ab; + background-color: #e0dce4; + padding: 1.5mm 4mm 1.5mm 4mm; + margin: 0.8mm; + font-family: "native:MainRegular"; + font-size: 3.5mm; + cn1-background-type: cn1-pill-border; + text-align: center; +} + +FlatButton { + cn1-derive: Button; + background-color: transparent; + color: #6750a4; + cn1-background-type: cn1-pill-border; + text-align: center; +} +FlatButton.pressed { background-color: #eaddff; color: #21005d; cn1-background-type: cn1-pill-border; } + +TextField { + color: #1d1b20; + background-color: #f3edf7; + padding: 1.5mm 2.5mm 1.5mm 2.5mm; + margin: 0.8mm 1.5mm 0.8mm 1.5mm; + font-family: "native:MainRegular"; + font-size: 3.5mm; + border-radius: 1mm; +} +TextField.pressed { background-color: #e7e0ec; } +TextField.disabled { color: #a5a0ab; background-color: #e0dce4; } + +TextArea { + color: #1d1b20; + background-color: #f3edf7; + padding: 1.5mm 2.5mm 1.5mm 2.5mm; + margin: 0.8mm 1.5mm 0.8mm 1.5mm; + font-family: "native:MainRegular"; + font-size: 3.5mm; + border-radius: 1mm; +} +TextArea.pressed { background-color: #e7e0ec; } +TextArea.disabled { color: #a5a0ab; background-color: #e0dce4; } + +TextHint { + color: #79747e; + padding: 2mm 3mm 2mm 3mm; + font-family: "native:MainRegular"; +} + +CheckBox { + color: #1d1b20; + background-color: transparent; + padding: 0.5mm 1.5mm 0.5mm 1.5mm; + margin: 0; + font-family: "native:MainRegular"; + font-size: 3.5mm; + icon-gap: 2mm; +} +CheckBox.selected { color: #6750a4; } +CheckBox.disabled { color: #a5a0ab; background-color: #e0dce4; } + +RadioButton { + color: #1d1b20; + background-color: transparent; + padding: 0.5mm 1.5mm 0.5mm 1.5mm; + margin: 0; + font-family: "native:MainRegular"; + font-size: 3.5mm; + icon-gap: 2mm; +} +RadioButton.selected { color: #6750a4; } +RadioButton.disabled { color: #a5a0ab; background-color: #e0dce4; } + +/* Switch's track left-edge aligns with Label's text left-edge + above (margin-left + padding-left = label padding-left). */ +Switch { + color: #79747e; + background-color: #e7e0ec; + padding: 0; + margin: 1mm 1.5mm 1mm 1.5mm; + text-align: left; +} +Switch.selected { color: #ffffff; background-color: #6750a4; } +Switch.disabled { color: #a5a0ab; background-color: #e0dce4; } + +OnOffSwitch { + cn1-derive: Label; + color: #ffffff; + background-color: #e7e0ec; + padding: 1mm 2mm 1mm 2mm; + cn1-background-type: cn1-pill-border; +} +OnOffSwitch.selected { background-color: #6750a4; color: #ffffff; } + +Toolbar { + background-color: #fef7ff; + color: #1d1b20; + padding: 1mm; + margin: 0; +} +/* TitleArea inlined rather than cn1-derive'd to Toolbar - see the iOS + modern theme for the resolver-hang note. The two UIIDs serve + different purposes and keeping inheritance simple is safer. */ +TitleArea { + background-color: #fef7ff; + color: #1d1b20; + padding: 1mm 2mm 1mm 2mm; + margin: 0; +} + +Title { + color: #1d1b20; + background-color: #fef7ff; + padding: 0.8mm 2mm 0.8mm 2mm; + font-family: "native:MainBold"; + font-size: 4mm; +} +MainTitle { cn1-derive: Title; } + +BackCommand { cn1-derive: Button; background-color: transparent; color: #6750a4; padding: 1mm 2mm 1mm 2mm; } +TitleCommand { cn1-derive: Button; background-color: transparent; color: #6750a4; padding: 1mm 2mm 1mm 2mm; } + +/* Material 3 top tabs: flat surface with selected tab underlined by + color rather than a pill fill. No border-radius here - + combining it with a visible border would drop into the 9-piece + rasterization path. */ +Tabs { background-color: #fef7ff; padding: 0; margin: 0; } +TabsContainer { background-color: #fef7ff; color: #1d1b20; padding: 0; margin: 0; } + +Tab { + color: #49454f; + background-color: #fef7ff; + padding: 1.5mm 2mm 1.5mm 2mm; + margin: 0; + font-family: "native:MainRegular"; + font-size: 3.5mm; + text-align: center; +} +Tab.selected { color: #6750a4; background-color: #fef7ff; } +Tab.pressed { color: #21005d; background-color: #eaddff; } + +SelectedTab { cn1-derive: Tab; color: #6750a4; } +UnselectedTab { cn1-derive: Tab; color: #49454f; } + +SideNavigationPanel { background-color: #fef7ff; padding: 0; margin: 0; } + +SideCommand { + cn1-derive: Button; + background-color: transparent; + color: #1d1b20; + padding: 2mm 3mm 2mm 3mm; + margin: 0; +} +SideCommand.pressed { background-color: #eaddff; } + +List { background-color: #fef7ff; padding: 0; margin: 0; } + +ListRenderer { cn1-derive: Label; padding: 1.5mm 2mm 1.5mm 2mm; } + +/* MultiButton is a list-row component with multi-line content, NOT a + bordered pill. Don't cn1-derive from Button - that pulls in + cn1-pill-border which then renders as an outline only on certain + surfaces (visible in dark mode against the panel surface, invisible + in light mode against white). Inline a clean rectangular row look + instead. */ +MultiButton { + background-color: #fef7ff; + color: #1d1b20; + padding: 2mm 3mm 2mm 3mm; + margin: 0; + font-family: "native:MainRegular"; + font-size: 3.5mm; +} +MultiButton.pressed { background-color: #eaddff; } +MultiButton.disabled { color: #a5a0ab; background-color: #e0dce4; } + +MultiLine1 { cn1-derive: Label; color: #1d1b20; font-family: "native:MainBold"; } +MultiLine2 { cn1-derive: Label; color: #49454f; } +MultiLine3 { cn1-derive: Label; color: #79747e; } +MultiLine4 { cn1-derive: Label; color: #79747e; } + +/* Material 3 dialogs use surface-container-highest (#e6e0e9) so the + dialog sits visibly above the surface background (#fef7ff). All + Dialog sub-UIIDs (Body / Title / ContentPane / CommandArea) are + transparent so the Dialog's surface paints once - otherwise each + sub-region's bgColor draws over Dialog's and reads as a + visibly different shade in the rounded corners. */ +/* margin: 0 - see iOS Modern Dialog rule for the rationale. A non-zero + margin here forced Dialog.show() to grow the dialog to screen size + with the body floating in mostly empty space. */ +Dialog { + background-color: #e6e0e9; + color: #1d1b20; + padding: 2mm; + margin: 0; + border-radius: 6mm; +} +DialogBody { + background-color: transparent; + color: #1d1b20; + padding: 1.5mm; + margin: 0; +} +DialogTitle { + color: #1d1b20; + background-color: transparent; + padding: 1.5mm 2mm 1.5mm 2mm; + font-family: "native:MainBold"; + font-size: 4mm; +} +DialogContentPane { background-color: transparent; padding: 2mm; margin: 0; } +DialogCommandArea { background-color: transparent; padding: 1mm; } + +FloatingActionButton { + color: #21005d; + background-color: #eaddff; + padding: 3mm; + margin: 3mm; + font-family: "native:MainBold"; + cn1-background-type: cn1-pill-border; +} +FloatingActionButton.pressed { background-color: #d0bcff; } + +Separator { background-color: #cac4d0; padding: 0; margin: 0; } +PopupContent { + background-color: #e6e0e9; + color: #1d1b20; + padding: 2mm; + margin: 0; + border-radius: 7mm; +} + +@media (prefers-color-scheme: dark) { + Component { color: #e6e0e9; background-color: #141218; } + Form { background-color: #141218; padding: 0; margin: 0; } + ContentPane { background-color: #141218; padding: 0; margin: 0; } + + Label { color: #e6e0e9; background-color: transparent; } + SecondaryLabel { color: #cac4d0; background-color: transparent; } + TertiaryLabel { color: #938f99; background-color: transparent; } + SpanLabel { color: #e6e0e9; background-color: transparent; } + SpanLabelText { color: #e6e0e9; background-color: transparent; } + + /* Re-declare cn1-background-type on each dark Button override - + the compiler doesn't carry the light declaration's pill border + forward into the $Dark entries. */ + Button { color: #381e72; background-color: #d0bcff; cn1-background-type: cn1-pill-border; } + Button.pressed { background-color: #4f378b; color: #eaddff; cn1-background-type: cn1-pill-border; } + Button.disabled { color: #5c5967; background-color: #2b2930; cn1-background-type: cn1-pill-border; } + + RaisedButton { color: #eaddff; background-color: #4f378b; cn1-background-type: cn1-pill-border; } + RaisedButton.pressed { color: #eaddff; background-color: #6750a4; cn1-background-type: cn1-pill-border; } + RaisedButton.disabled { background-color: #2b2930; color: #5c5967; cn1-background-type: cn1-pill-border; } + + FlatButton { color: #d0bcff; background-color: transparent; cn1-background-type: cn1-pill-border; } + FlatButton.pressed { background-color: #4f378b; color: #eaddff; cn1-background-type: cn1-pill-border; } + + TextField { color: #e6e0e9; background-color: #211f26; } + TextField.pressed { background-color: #49454f; } + TextField.disabled { color: #5c5967; background-color: #2b2930; } + TextArea { color: #e6e0e9; background-color: #211f26; } + TextArea.pressed { background-color: #49454f; } + TextArea.disabled { color: #5c5967; background-color: #2b2930; } + TextHint { color: #938f99; } + + CheckBox { color: #e6e0e9; background-color: transparent; } + CheckBox.selected { color: #d0bcff; } + CheckBox.disabled { color: #5c5967; background-color: #2b2930; } + RadioButton { color: #e6e0e9; background-color: transparent; } + RadioButton.selected { color: #d0bcff; } + RadioButton.disabled { color: #5c5967; background-color: #2b2930; } + + Switch { color: #938f99; background-color: #49454f; } + Switch.selected { color: #381e72; background-color: #d0bcff; } + Switch.disabled { color: #5c5967; background-color: #2b2930; } + + OnOffSwitch { color: #381e72; background-color: #49454f; } + OnOffSwitch.selected { background-color: #d0bcff; color: #381e72; } + + Toolbar { background-color: #141218; color: #e6e0e9; } + TitleArea { background-color: #141218; color: #e6e0e9; } + Title { color: #e6e0e9; background-color: #141218; } + MainTitle { color: #e6e0e9; background-color: #141218; } + BackCommand { color: #d0bcff; background-color: transparent; } + TitleCommand { color: #d0bcff; background-color: transparent; } + + Tabs { background-color: #141218; } + TabsContainer { background-color: #141218; color: #e6e0e9; } + Tab { color: #cac4d0; background-color: #141218; } + Tab.selected { color: #d0bcff; background-color: #141218; } + Tab.pressed { color: #eaddff; background-color: #4f378b; } + SelectedTab { color: #d0bcff; } + UnselectedTab { color: #cac4d0; } + + SideNavigationPanel { background-color: #141218; } + SideCommand { color: #e6e0e9; background-color: transparent; } + SideCommand.pressed { background-color: #4f378b; } + + List { background-color: #141218; } + ListRenderer { color: #e6e0e9; background-color: transparent; } + MultiButton { background-color: #141218; color: #e6e0e9; } + MultiButton.pressed { background-color: #4f378b; } + MultiLine1 { color: #e6e0e9; } + MultiLine2 { color: #cac4d0; } + MultiLine3 { color: #938f99; } + MultiLine4 { color: #938f99; } + + Dialog { background-color: #211f26; color: #e6e0e9; } + DialogBody { background-color: #211f26; color: #e6e0e9; } + DialogTitle { color: #e6e0e9; background-color: #211f26; } + DialogContentPane { background-color: #211f26; } + DialogCommandArea { background-color: #211f26; } + PopupContent { background-color: #211f26; color: #e6e0e9; } + + FloatingActionButton { color: #eaddff; background-color: #4f378b; } + FloatingActionButton.pressed { background-color: #d0bcff; } + + Separator { background-color: #49454f; } +} diff --git a/native-themes/ios-modern/theme.css b/native-themes/ios-modern/theme.css new file mode 100644 index 0000000000..8149446f39 --- /dev/null +++ b/native-themes/ios-modern/theme.css @@ -0,0 +1,519 @@ +/* + * iOS modern (liquid-glass) native theme. + * + * CEF-free subset of the Codename One CSS compiler: solid fills, native + * rounded/pill borders, state selectors via .pressed / .selected / + * .disabled. Light and dark palettes live at the bottom of this file + * in a prefers-color-scheme block the compiler rewrites into $DarkUIID + * entries. Colors are inlined (not CSS variables) because the rewriter + * operates at string level and doesn't re-scope :root declarations. + * + * Apple system palette reference (light / dark): + * accent 007aff / 0a84ff + * accent-pressed 0064d1 / 64b1ff + * accent-disabled b3d4ff / 004a99 + * text-primary 000000 / ffffff + * text-secondary 3c3c43 / ebebf5 + * text-tertiary 8e8e93 / 8e8e93 + * text-disabled c7c7cc / 48484a + * surface ffffff / 000000 + * surface-grouped f2f2f7 / 1c1c1e + * surface-tertiary e5e5ea / 2c2c2e + * separator c6c6c8 / 38383a + * success 34c759 / 30d158 + * + * Overridable palette: user apps layer their own theme.css or runtime + * UIManager.addThemeProps on top of this theme - see + * PaletteOverrideThemeScreenshotTest for a working example that flips + * the accent to magenta at runtime. + */ + +#Constants { + includeNativeBool: false; + darkModeBool: true; + commandBehavior: Native; + ios7StatusBarHack: true; + paintsTitleBarBool: true; + /* iOS 26 puts tab bars at the bottom, spanning full width with a + single pill-shaped glass group rather than individual tabs up + top. tabPlacementInt=2 is Component.BOTTOM; tabsFillRowsBool + distributes tabs across the available horizontal space. */ + tabPlacementInt: 2; + tabsFillRowsBool: true; + tabsGridBool: true; + /* For a floating-pill tab bar, Tabs wraps the pill in a host + container that absorbs the safe-area inset as bottom padding - + pushing the pill UP, away from the home indicator, without + extending the pill's own background into the indicator zone. + Themes painting a flush full-width tab strip leave this true (the + framework default) so the tab strip itself reserves the inset. */ + tabsSafeAreaBool: false; + /* Route the Tab icon's styling through a separate UIID. FontImage + copies the Button's bgColor/transparency into the rendered glyph + image; over the cn1-pill-border selected pill that paints a + visible square fill behind the glyph that doesn't follow the + rounded shape. Declaring TabIcon as transparent below means only + the glyph itself paints. */ + tabIconUIID: TabIcon; + switchThumbPaddingInt: 2; + switchThumbScaleY: "1.4"; + switchTrackScaleY: "1.5"; + switchTrackScaleX: "2.5"; + switchTrackOffOutlineWidthMM: "0.25"; + switchTrackOnOutlineWidthMM: "0"; + switchTrackOffOutlineColor: "cccccc"; + switchThumbInsetMM: "0.25"; + /* iOS uses a circular mark for checkboxes (Reminders-style) and + keeps the circular radio button. These integer constants are + Material icon codepoints that DefaultLookAndFeel reads to + swap the default square check-box glyphs. CHECK_CIRCLE = E86C + (59500), CHECK_CIRCLE_OUTLINE = E92D (59693), + RADIO_BUTTON_CHECKED = E837 (59447), RADIO_BUTTON_UNCHECKED + = E836 (59446). */ + checkBoxCheckedIconInt: 59500; + checkBoxUncheckedIconInt: 59693; + radioCheckedIconInt: 59447; + radioUncheckedIconInt: 59446; + /* Dialog sizing/layout constants the legacy iOS7 theme set and + the modern theme had been missing. Without these the framework + falls back to a wider default Dialog layout that left + Dialog.show("Clicked","Clicked item N","OK",null) ballooning to + near-full screen with the body floating in empty space. */ + hideEmptyTitleBool: true; + shrinkPopupTitleBool: true; + dialogButtonCommandsBool: true; + dlgCommandGridBool: true; +} + +Component { + color: #000000; + background-color: #ffffff; + padding: 0; + margin: 0; +} + +Form { background-color: #f2f2f7; padding: 0; margin: 0; } +ContentPane { background-color: #f2f2f7; padding: 0; margin: 0; } +/* Container has no surface of its own; specific UIIDs that need a + fill (Form, Dialog, Tab, etc.) declare it explicitly. Transparent + default means a SpanLabel / FlowLayout / generic wrapper inside a + translucent Dialog or pill Tabs lets the parent's surface show + through instead of painting an opaque white block over it. */ +Container { background-color: transparent; padding: 0; margin: 0; } + +/* Label transparent so it sits cleanly over its parent's surface + (Form, Dialog, Tab pill). UIIDs that need a colored label + (RaisedButton, FloatingActionButton, etc.) declare their own + bgColor explicitly. Note SpanLabel's internal TextArea is + styled with UIID="Label" so this transparency also fixes the + visible block behind dialog body text. */ +Label { + color: #000000; + background-color: transparent; + padding: 1mm 2mm 1mm 2mm; + margin: 0; + font-family: "native:MainRegular"; +} + +SecondaryLabel { cn1-derive: Label; color: #3c3c43; } +TertiaryLabel { cn1-derive: Label; color: #8e8e93; } +/* SpanLabel + its inner SpanLabelText render transparent so when + placed on a translucent surface (Dialog, PopupContent) the + parent's background shows through cleanly without a hard-edged + white block behind the wrapped text. */ +SpanLabel { cn1-derive: Label; background-color: transparent; } +SpanLabelText { cn1-derive: Label; background-color: transparent; } + +/* iOS rounded-rect button style. Using border-radius (RoundRectBorder, + CEF-free) rather than cn1-pill-border so short-text buttons don't + degenerate to a circle when width<=height - RoundBorder paints a + circle in that case, border-radius always keeps the corner radius. */ +Button { + color: #007aff; + padding: 2mm 4mm 2mm 4mm; + margin: 1mm; + font-family: "native:MainRegular"; + background-color: transparent; + border-radius: 3mm; + text-align: center; +} +Button.pressed { color: #0064d1; background-color: #e5e5ea; } +Button.disabled { color: #b3d4ff; } + +RaisedButton { + cn1-derive: Button; + color: #ffffff; + background-color: #007aff; + border-radius: 3mm; + text-align: center; +} +RaisedButton.pressed { color: #ffffff; background-color: #0064d1; } +RaisedButton.disabled { background-color: #b3d4ff; color: #ffffff; } + +FlatButton { cn1-derive: Button; } + +TextField { + color: #000000; + background-color: #ffffff; + padding: 2mm 3mm 2mm 3mm; + margin: 1mm 2mm 1mm 2mm; + font-family: "native:MainRegular"; + border-radius: 2mm; +} +TextField.pressed { background-color: #e5e5ea; } +TextField.disabled { color: #c7c7cc; background-color: #e5e5ea; } + +TextArea { + color: #000000; + background-color: #ffffff; + padding: 2mm 3mm 2mm 3mm; + margin: 1mm 2mm 1mm 2mm; + font-family: "native:MainRegular"; + border-radius: 2mm; +} +TextArea.pressed { background-color: #e5e5ea; } +TextArea.disabled { color: #c7c7cc; background-color: #e5e5ea; } + +TextHint { + color: #8e8e93; + padding: 2mm 3mm 2mm 3mm; + font-family: "native:MainRegular"; +} + +CheckBox { + color: #000000; + background-color: transparent; + padding: 1mm 2mm 1mm 2mm; + margin: 0; + font-family: "native:MainRegular"; + icon-gap: 2mm; +} +CheckBox.selected { color: #007aff; } +CheckBox.disabled { color: #c7c7cc; background-color: #e5e5ea; } + +RadioButton { + color: #000000; + background-color: transparent; + padding: 1mm 2mm 1mm 2mm; + margin: 0; + font-family: "native:MainRegular"; + icon-gap: 2mm; +} +RadioButton.selected { color: #007aff; } +RadioButton.disabled { color: #c7c7cc; background-color: #e5e5ea; } + +/* Switch's track-left needs to land at the same x as the Label + text-left of the row above so the column reads as aligned. + Label text starts at margin-left + padding-left = 0 + 2mm = 2mm. + Switch track starts at margin-left + padding-left so we keep + margin-left at 2mm and zero out padding. */ +Switch { + color: #ffffff; + background-color: #ffffff; + padding: 0; + margin: 1mm 2mm 1mm 2mm; + text-align: left; +} +Switch.selected { color: #ffffff; background-color: #34c759; } +Switch.disabled { color: #c7c7cc; background-color: #e5e5ea; } + +OnOffSwitch { + cn1-derive: Label; + color: #007aff; + background-color: #e5e5ea; + padding: 1mm 2mm 1mm 2mm; + cn1-background-type: cn1-pill-border; +} +OnOffSwitch.selected { background-color: #34c759; color: #ffffff; } + +Toolbar { + background-color: #ffffff; + color: #000000; + padding: 1mm; + margin: 0; +} +/* TitleArea derives from Toolbar so theme overrides on Toolbar cascade + to TitleArea. UIManager used to install a defensive + Toolbar.derive=TitleArea default that produced a cycle when paired + with TitleArea.derive=Toolbar; the framework now skips that default + when the user theme has already wired TitleArea -> Toolbar. */ +TitleArea { cn1-derive: Toolbar; padding: 1mm 2mm 1mm 2mm; } + +Title { + color: #000000; + background-color: #ffffff; + padding: 1mm; + font-family: "native:MainBold"; + font-size: 4.5mm; + text-align: center; +} +MainTitle { cn1-derive: Title; } + +BackCommand { cn1-derive: Button; color: #007aff; padding: 1mm 2mm 1mm 2mm; } +TitleCommand { cn1-derive: Button; color: #007aff; padding: 1mm 2mm 1mm 2mm; } + +/* iOS 26 tab-bar look: the TabsContainer is the visible pill group + hugging the bottom edge, with individual tabs rendered as + transparent hits inside it. cn1-pill-border draws a true pill; + rgba on the fill approximates frosted glass within the CEF-free + subset. A real backdrop-filter is future work. + + Text + icons stay neutral (black light / white dark) regardless of + selection state - selected vs unselected differ by background fill, + not foreground colour. The selected pill uses surface-tertiary + (#e5e5ea) for visible contrast against the frosted glass group. */ +Tabs { background-color: transparent; padding: 0; margin: 0; } +/* TabbedPane is the inner content area where the active tab page + renders. It sits below the floating pill group (TabsContainer) + and stays opaque on the form's surface colour - the pill is the + only translucent element. */ +TabbedPane { background-color: #f2f2f7; padding: 0; margin: 0; } +TabsContainer { + background-color: rgba(242,242,247,0.7); + color: #000000; + padding: 0.5mm; + /* Bottom margin only buys visual breathing room above the home + indicator; the actual safe-area inset is absorbed by Tabs's host + wrapper because tabsSafeAreaBool=false (see #Constants). */ + margin: 1mm 3mm 1mm 3mm; + cn1-background-type: cn1-pill-border; +} + +Tab { + color: #000000; + background-color: transparent; + padding: 1mm 2mm 1mm 2mm; + margin: 0; + font-family: "native:MainRegular"; + text-align: center; +} +Tab.selected { color: #000000; background-color: #e5e5ea; cn1-background-type: cn1-pill-border; } +Tab.pressed { color: #000000; background-color: rgba(229,229,234,0.85); cn1-background-type: cn1-pill-border; } + +SelectedTab { cn1-derive: Tab; color: #000000; } +UnselectedTab { cn1-derive: Tab; color: #000000; } + +/* TabIcon: separate UIID applied via tabIconUIID so the icon's glyph + image paints with no fill behind it. Without this the FontImage + inherits the Tab.selected background color and renders a visible + square inside the pill. Color (light/dark) inherits from the parent + Tab via Style.fgColor at icon-render time. */ +TabIcon { background-color: transparent; padding: 0; margin: 0; } +TabIcon.selected { background-color: transparent; } +TabIcon.pressed { background-color: transparent; } + +SideNavigationPanel { background-color: #ffffff; padding: 0; margin: 0; } + +SideCommand { + cn1-derive: Button; + color: #000000; + padding: 2mm 3mm 2mm 3mm; + margin: 0; +} +SideCommand.pressed { background-color: #e5e5ea; } + +List { background-color: #ffffff; padding: 0; margin: 0; } + +ListRenderer { cn1-derive: Label; padding: 2mm 3mm 2mm 3mm; } + +/* iOS Settings-style list row: translucent white surface, generous + vertical padding, bold primary line, lighter secondary lines. + Margins keep each row floating slightly off the form edge and + away from the next row, mimicking iOS 26 grouped-list cards. */ +MultiButton { + cn1-derive: Button; + background-color: rgba(255,255,255,0.85); + color: #000000; + padding: 3mm 4mm 3mm 4mm; + margin: 1mm 3mm 1mm 3mm; + font-family: "native:MainRegular"; + font-size: 4mm; + text-align: left; + icon-gap: 3mm; + border-radius: 3mm; +} +MultiButton.pressed { background-color: #e5e5ea; } + +MultiLine1 { + cn1-derive: Label; + color: #000000; + font-family: "native:MainBold"; + font-size: 4mm; + padding: 0 0 0.5mm 0; +} +MultiLine2 { + cn1-derive: Label; + color: #3c3c43; + font-family: "native:MainRegular"; + font-size: 3.5mm; + padding: 0; +} +MultiLine3 { + cn1-derive: Label; + color: #8e8e93; + font-family: "native:MainRegular"; + font-size: 3mm; + padding: 0.5mm 0 0 0; +} +MultiLine4 { + cn1-derive: Label; + color: #8e8e93; + font-family: "native:MainRegular"; + font-size: 3mm; + padding: 0; +} + +/* Dialog uses a translucent surface so any backdrop (form bg or + the test harness's diagonal-stripe texture) reads through - + approximates the iOS 26 vibrancy effect within the CEF-free + subset. DialogBody is left fully transparent: it was previously + painting its own opaque shade on top of Dialog and reading as a + visibly different colour. All Dialog sub-UIIDs share the same + translucent surface (or stay transparent so Dialog's surface + shows through). */ +/* margin: 0 - CN1's Dialog uses its own UIID margin as a positioning + hint; a non-zero value here makes Dialog.show() lay the dialog out + at screen-minus-margin (looks correct for a small modal but for + `Dialog.show("title", "body", "OK", null)` it grows to ~full + screen with empty space around the body). */ +Dialog { + background-color: rgba(255,255,255,0.78); + color: #000000; + padding: 3mm; + margin: 0; + border-radius: 4mm; +} +DialogBody { + background-color: transparent; + color: #000000; + padding: 2mm; + margin: 0; +} +DialogTitle { + color: #000000; + background-color: transparent; + padding: 2mm; + font-family: "native:MainBold"; + font-size: 4mm; + text-align: center; +} +DialogContentPane { background-color: transparent; padding: 2mm; margin: 0; } +DialogCommandArea { background-color: transparent; padding: 1mm; } + +FloatingActionButton { + color: #ffffff; + background-color: #007aff; + padding: 3mm; + margin: 3mm; + font-family: "native:MainBold"; + cn1-background-type: cn1-pill-border; +} +FloatingActionButton.pressed { background-color: #0064d1; } + +Separator { background-color: #c6c6c8; padding: 0; margin: 0; } +PopupContent { + background-color: #ffffff; + color: #000000; + padding: 2mm; + margin: 0; + border-radius: 4mm; +} + +@media (prefers-color-scheme: dark) { + Component { color: #ffffff; background-color: #000000; } + Form { background-color: #1c1c1e; padding: 0; margin: 0; } + ContentPane { background-color: #1c1c1e; padding: 0; margin: 0; } + + Label { color: #ffffff; background-color: transparent; } + SecondaryLabel { color: #ebebf5; background-color: transparent; } + TertiaryLabel { color: #8e8e93; background-color: transparent; } + SpanLabel { color: #ffffff; background-color: transparent; } + SpanLabelText { color: #ffffff; background-color: transparent; } + + Button { color: #0a84ff; } + Button.pressed { color: #64b1ff; background-color: #3a3a3c; } + Button.disabled { color: #004a99; } + + RaisedButton { color: #ffffff; background-color: #0a84ff; } + RaisedButton.pressed { background-color: #64b1ff; } + RaisedButton.disabled { background-color: #004a99; } + + TextField { color: #ffffff; background-color: #1c1c1e; } + TextField.pressed { background-color: #2c2c2e; } + TextField.disabled { color: #48484a; background-color: #2c2c2e; } + TextArea { color: #ffffff; background-color: #1c1c1e; } + TextArea.pressed { background-color: #2c2c2e; } + TextArea.disabled { color: #48484a; background-color: #2c2c2e; } + TextHint { color: #8e8e93; } + + CheckBox { color: #ffffff; background-color: transparent; } + CheckBox.selected { color: #0a84ff; } + CheckBox.disabled { color: #48484a; background-color: #2c2c2e; } + RadioButton { color: #ffffff; background-color: transparent; } + RadioButton.selected { color: #0a84ff; } + RadioButton.disabled { color: #48484a; background-color: #2c2c2e; } + + Switch { color: #ffffff; background-color: #2c2c2e; } + Switch.selected { color: #ffffff; background-color: #30d158; } + Switch.disabled { color: #48484a; background-color: #2c2c2e; } + + OnOffSwitch { color: #0a84ff; background-color: #2c2c2e; } + OnOffSwitch.selected { background-color: #30d158; color: #ffffff; } + + Toolbar { background-color: #000000; color: #ffffff; } + TitleArea { background-color: #000000; color: #ffffff; } + Title { color: #ffffff; background-color: #000000; } + MainTitle { color: #ffffff; background-color: #000000; } + BackCommand { color: #0a84ff; } + TitleCommand { color: #0a84ff; } + + Tabs { background-color: transparent; } + TabbedPane { background-color: #1c1c1e; } + TabsContainer { background-color: rgba(44,44,46,0.7); color: #ffffff; } + Tab { color: #ffffff; background-color: transparent; } + /* Re-declare cn1-background-type on the dark overrides - the + compiler doesn't carry it forward from the light Tab.selected + style, so without this the selected pill renders as a flat + rectangular block instead of a rounded pill in dark mode. */ + Tab.selected { color: #ffffff; background-color: #3a3a3c; cn1-background-type: cn1-pill-border; } + Tab.pressed { color: #ffffff; background-color: rgba(58,58,60,0.9); cn1-background-type: cn1-pill-border; } + SelectedTab { color: #ffffff; } + UnselectedTab { color: #ffffff; } + + /* Dark overrides for the icon-only UIID. Background stays transparent + so the pill shows through; only the glyph (fgColor) is forced + white. */ + TabIcon { color: #ffffff; background-color: transparent; } + TabIcon.selected { color: #ffffff; background-color: transparent; } + TabIcon.pressed { color: #ffffff; background-color: transparent; } + + SideNavigationPanel { background-color: #000000; } + SideCommand { color: #ffffff; } + SideCommand.pressed { background-color: #3a3a3c; } + + List { background-color: #000000; } + ListRenderer { color: #ffffff; background-color: transparent; } + MultiButton { background-color: #000000; color: #ffffff; } + MultiButton.pressed { background-color: #3a3a3c; } + MultiLine1 { color: #ffffff; } + MultiLine2 { color: #ebebf5; } + MultiLine3 { color: #8e8e93; } + MultiLine4 { color: #8e8e93; } + + /* Dark Dialog: nearly opaque so white text stays readable - a + lower opacity over the textured test backdrop made bright + stripes bleed through and obscure the text. DialogBody / + Title / ContentPane / CommandArea stay transparent so the + Dialog surface is the single colour they all share. */ + Dialog { background-color: rgba(44,44,46,0.95); color: #ffffff; } + DialogBody { background-color: transparent; color: #ffffff; } + DialogTitle { color: #ffffff; background-color: transparent; } + DialogContentPane { background-color: transparent; } + DialogCommandArea { background-color: transparent; } + PopupContent { background-color: rgba(44,44,46,0.95); color: #ffffff; } + + FloatingActionButton { color: #ffffff; background-color: #0a84ff; } + FloatingActionButton.pressed { background-color: #64b1ff; } + + Separator { background-color: #38383a; } +} diff --git a/scripts/android/screenshots/ButtonTheme_dark.png b/scripts/android/screenshots/ButtonTheme_dark.png new file mode 100644 index 0000000000..ad5fe62ff2 Binary files /dev/null and b/scripts/android/screenshots/ButtonTheme_dark.png differ diff --git a/scripts/android/screenshots/ButtonTheme_light.png b/scripts/android/screenshots/ButtonTheme_light.png new file mode 100644 index 0000000000..8a325dc024 Binary files /dev/null and b/scripts/android/screenshots/ButtonTheme_light.png differ diff --git a/scripts/android/screenshots/CheckBoxRadioTheme_dark.png b/scripts/android/screenshots/CheckBoxRadioTheme_dark.png new file mode 100644 index 0000000000..7f3dad955c Binary files /dev/null and b/scripts/android/screenshots/CheckBoxRadioTheme_dark.png differ diff --git a/scripts/android/screenshots/CheckBoxRadioTheme_light.png b/scripts/android/screenshots/CheckBoxRadioTheme_light.png new file mode 100644 index 0000000000..dd4416ed8e Binary files /dev/null and b/scripts/android/screenshots/CheckBoxRadioTheme_light.png differ diff --git a/scripts/android/screenshots/DialogTheme_dark.png b/scripts/android/screenshots/DialogTheme_dark.png new file mode 100644 index 0000000000..4f3a2f283c Binary files /dev/null and b/scripts/android/screenshots/DialogTheme_dark.png differ diff --git a/scripts/android/screenshots/DialogTheme_light.png b/scripts/android/screenshots/DialogTheme_light.png new file mode 100644 index 0000000000..75bbd321a9 Binary files /dev/null and b/scripts/android/screenshots/DialogTheme_light.png differ diff --git a/scripts/android/screenshots/FloatingActionButtonTheme_dark.png b/scripts/android/screenshots/FloatingActionButtonTheme_dark.png new file mode 100644 index 0000000000..f169ca9c07 Binary files /dev/null and b/scripts/android/screenshots/FloatingActionButtonTheme_dark.png differ diff --git a/scripts/android/screenshots/FloatingActionButtonTheme_light.png b/scripts/android/screenshots/FloatingActionButtonTheme_light.png new file mode 100644 index 0000000000..8bb6051ad6 Binary files /dev/null and b/scripts/android/screenshots/FloatingActionButtonTheme_light.png differ diff --git a/scripts/android/screenshots/ListTheme_dark.png b/scripts/android/screenshots/ListTheme_dark.png new file mode 100644 index 0000000000..94e62c9038 Binary files /dev/null and b/scripts/android/screenshots/ListTheme_dark.png differ diff --git a/scripts/android/screenshots/ListTheme_light.png b/scripts/android/screenshots/ListTheme_light.png new file mode 100644 index 0000000000..c537a7c829 Binary files /dev/null and b/scripts/android/screenshots/ListTheme_light.png differ diff --git a/scripts/android/screenshots/MultiButtonTheme_dark.png b/scripts/android/screenshots/MultiButtonTheme_dark.png new file mode 100644 index 0000000000..459cf047ac Binary files /dev/null and b/scripts/android/screenshots/MultiButtonTheme_dark.png differ diff --git a/scripts/android/screenshots/MultiButtonTheme_light.png b/scripts/android/screenshots/MultiButtonTheme_light.png new file mode 100644 index 0000000000..3a59de137a Binary files /dev/null and b/scripts/android/screenshots/MultiButtonTheme_light.png differ diff --git a/scripts/android/screenshots/PaletteOverrideTheme_dark.png b/scripts/android/screenshots/PaletteOverrideTheme_dark.png new file mode 100644 index 0000000000..621ccf0381 Binary files /dev/null and b/scripts/android/screenshots/PaletteOverrideTheme_dark.png differ diff --git a/scripts/android/screenshots/PaletteOverrideTheme_light.png b/scripts/android/screenshots/PaletteOverrideTheme_light.png new file mode 100644 index 0000000000..4eb64f0550 Binary files /dev/null and b/scripts/android/screenshots/PaletteOverrideTheme_light.png differ diff --git a/scripts/android/screenshots/PickerTheme_dark.png b/scripts/android/screenshots/PickerTheme_dark.png new file mode 100644 index 0000000000..4dd9fa79b6 Binary files /dev/null and b/scripts/android/screenshots/PickerTheme_dark.png differ diff --git a/scripts/android/screenshots/PickerTheme_light.png b/scripts/android/screenshots/PickerTheme_light.png new file mode 100644 index 0000000000..bafc2856b8 Binary files /dev/null and b/scripts/android/screenshots/PickerTheme_light.png differ diff --git a/scripts/android/screenshots/ShowcaseTheme_dark.png b/scripts/android/screenshots/ShowcaseTheme_dark.png new file mode 100644 index 0000000000..5e991a58e5 Binary files /dev/null and b/scripts/android/screenshots/ShowcaseTheme_dark.png differ diff --git a/scripts/android/screenshots/ShowcaseTheme_light.png b/scripts/android/screenshots/ShowcaseTheme_light.png new file mode 100644 index 0000000000..15e1ab73eb Binary files /dev/null and b/scripts/android/screenshots/ShowcaseTheme_light.png differ diff --git a/scripts/android/screenshots/SpanLabelTheme_dark.png b/scripts/android/screenshots/SpanLabelTheme_dark.png new file mode 100644 index 0000000000..43330971ef Binary files /dev/null and b/scripts/android/screenshots/SpanLabelTheme_dark.png differ diff --git a/scripts/android/screenshots/SpanLabelTheme_light.png b/scripts/android/screenshots/SpanLabelTheme_light.png new file mode 100644 index 0000000000..cefe757927 Binary files /dev/null and b/scripts/android/screenshots/SpanLabelTheme_light.png differ diff --git a/scripts/android/screenshots/SwitchTheme_dark.png b/scripts/android/screenshots/SwitchTheme_dark.png new file mode 100644 index 0000000000..00c9476651 Binary files /dev/null and b/scripts/android/screenshots/SwitchTheme_dark.png differ diff --git a/scripts/android/screenshots/SwitchTheme_light.png b/scripts/android/screenshots/SwitchTheme_light.png new file mode 100644 index 0000000000..e57e8a19de Binary files /dev/null and b/scripts/android/screenshots/SwitchTheme_light.png differ diff --git a/scripts/android/screenshots/TabsTheme_dark.png b/scripts/android/screenshots/TabsTheme_dark.png new file mode 100644 index 0000000000..bd617aada3 Binary files /dev/null and b/scripts/android/screenshots/TabsTheme_dark.png differ diff --git a/scripts/android/screenshots/TabsTheme_light.png b/scripts/android/screenshots/TabsTheme_light.png new file mode 100644 index 0000000000..e1b949a65d Binary files /dev/null and b/scripts/android/screenshots/TabsTheme_light.png differ diff --git a/scripts/android/screenshots/TextFieldTheme_dark.png b/scripts/android/screenshots/TextFieldTheme_dark.png new file mode 100644 index 0000000000..a4b52aa228 Binary files /dev/null and b/scripts/android/screenshots/TextFieldTheme_dark.png differ diff --git a/scripts/android/screenshots/TextFieldTheme_light.png b/scripts/android/screenshots/TextFieldTheme_light.png new file mode 100644 index 0000000000..5f0a270978 Binary files /dev/null and b/scripts/android/screenshots/TextFieldTheme_light.png differ diff --git a/scripts/android/screenshots/ToolbarTheme_dark.png b/scripts/android/screenshots/ToolbarTheme_dark.png new file mode 100644 index 0000000000..606922db86 Binary files /dev/null and b/scripts/android/screenshots/ToolbarTheme_dark.png differ diff --git a/scripts/android/screenshots/ToolbarTheme_light.png b/scripts/android/screenshots/ToolbarTheme_light.png new file mode 100644 index 0000000000..1bbf61e723 Binary files /dev/null and b/scripts/android/screenshots/ToolbarTheme_light.png differ diff --git a/scripts/build-android-port.sh b/scripts/build-android-port.sh index fada3e3866..7a56e86c7c 100755 --- a/scripts/build-android-port.sh +++ b/scripts/build-android-port.sh @@ -131,4 +131,12 @@ if [ ! -f "$BUILD_CLIENT" ]; then fi fi +# Compile native CSS themes (AndroidMaterialTheme.res) and stage them in the +# Android port's src/ so the Maven build packages them into the port jar. The +# runtime falls back to android_holo_light.res if AndroidMaterialTheme.res is +# missing, which loses the Material 3 palette + all $DarkUIID entries. +./scripts/build-native-themes.sh +mkdir -p Ports/Android/src +cp Themes/AndroidMaterialTheme.res Ports/Android/src/AndroidMaterialTheme.res + run_maven -q -f maven/pom.xml -pl android -am -Dcn1.binaries="$CN1_BINARIES" -P !download-cn1-binaries -T 1C -Dmaven.javadoc.skip=true -Dmaven.source.skip=true -Djava.awt.headless=true clean install "$@" diff --git a/scripts/build-ios-port.sh b/scripts/build-ios-port.sh index 5fe8af9d97..0b75835ba9 100755 --- a/scripts/build-ios-port.sh +++ b/scripts/build-ios-port.sh @@ -37,4 +37,13 @@ if [ ! -f "$BUILD_CLIENT" ]; then fi fi +# Compile native CSS themes (iOSModernTheme.res) and copy into the iOS port's +# native sources so the Maven iOS build packages them into nativeios.jar. The +# iOS runtime falls back to iOS7Theme.res when iOSModernTheme.res is missing, +# which loses all $DarkUIID entries (dark mode appears broken) and the liquid- +# glass styling — so make sure this runs before the port is built. +./scripts/build-native-themes.sh +mkdir -p Ports/iOSPort/nativeSources +cp Themes/iOSModernTheme.res Ports/iOSPort/nativeSources/iOSModernTheme.res + "$MAVEN_HOME/bin/mvn" -q -f maven/pom.xml -pl ios -am -Djava.awt.headless=true clean install "$@" diff --git a/scripts/build-native-themes.sh b/scripts/build-native-themes.sh new file mode 100755 index 0000000000..6befddb66a --- /dev/null +++ b/scripts/build-native-themes.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +### +# Compile the shipped platform native themes from CSS source. +# +# Uses the thin codenameone-css-compiler jar (no JavaFX / no CEF, depends only +# on codenameone-core + flute + sac) so it runs fast and fails loudly if any +# CSS rule would require CEF-backed rasterization (box-shadow, border-radius +# with visible border, filter, complex gradients). +# +# Source layout: +# native-themes/ +# ios-modern/theme.css +# android-material/theme.css +# (see native-themes/README for authoring rules) +# +# Outputs land in the existing Themes/ directory next to the hand-authored +# legacy themes, and are picked up by each port's build.xml the same way the +# legacy .res files are today. Outputs are gitignored (build artifacts). +### +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +cd "$REPO_ROOT" + +log() { echo "[build-native-themes] $1" >&2; } + +CSS_COMPILER_MODULE="$REPO_ROOT/maven/css-compiler" +CSS_SRC_ROOT="$REPO_ROOT/native-themes" +OUT_DIR="$REPO_ROOT/Themes" +# JavaScriptPort's runtime serves themes out of its webapp assets folder; +# mirror the generated .res files there too so the JS port picks them up. +JS_ASSETS_DIR="$REPO_ROOT/Ports/JavaScriptPort/src/main/webapp/assets" + +# Resolve the compiler jar. Prefer a freshly-built target/ jar (so CSS compiler +# source edits are always picked up); fall back to the installed copy in ~/.m2 +# when the module hasn't been rebuilt in this session. +locate_jar() { + local target_jar installed_jar version + target_jar="$(ls "$CSS_COMPILER_MODULE"/target/codenameone-css-compiler-*-jar-with-dependencies.jar 2>/dev/null | head -n1 || true)" + if [ -n "$target_jar" ] && [ -f "$target_jar" ]; then + echo "$target_jar" + return + fi + version="$(grep -m1 '' "$CSS_COMPILER_MODULE/pom.xml" | sed -E 's#.*([^<]+).*#\1#')" + if [ -z "$version" ]; then + # Fall back to parent pom version if the module inherits it. + version="$(grep -m1 '' "$REPO_ROOT/maven/pom.xml" | sed -E 's#.*([^<]+).*#\1#')" + fi + installed_jar="$HOME/.m2/repository/com/codenameone/codenameone-css-compiler/$version/codenameone-css-compiler-${version}-jar-with-dependencies.jar" + if [ -f "$installed_jar" ]; then + echo "$installed_jar" + return + fi + return 1 +} + +ensure_jar() { + local jar + if jar="$(locate_jar)"; then + log "Using CSS compiler jar: $jar" + printf '%s\n' "$jar" + return + fi + log "CSS compiler jar not found; building it via Maven." + local mvn="${MAVEN_HOME:+$MAVEN_HOME/bin/mvn}" + mvn="${mvn:-mvn}" + # Redirect Maven output to stderr - otherwise its stdout gets captured + # by the calling `jar="$(ensure_jar)"` and ends up concatenated with + # the jar path, which `java -jar` then chokes on. The parent pom + # initialise antrun also clones cn1-binaries with failonerror unset, + # so a benign `[ERROR] [exec] Result: 128` (already-exists clone) + # would pollute stdout if we let it through. + ( + cd "$REPO_ROOT/maven" + "$mvn" -pl css-compiler -am -q -DskipTests install + ) >&2 + if jar="$(locate_jar)"; then + printf '%s\n' "$jar" + return + fi + log "FAILED: CSS compiler jar could not be located after build." + exit 1 +} + +compile_theme() { + local jar="$1" name="$2" basename="$3" + local css="$CSS_SRC_ROOT/$name/theme.css" + local out="$OUT_DIR/$basename" + if [ ! -f "$css" ]; then + log "Skipping $name: no source at $css" + return + fi + mkdir -p "$OUT_DIR" + log "Compiling $name -> $out" + java -jar "$jar" -input "$css" -output "$out" + if [ -d "$JS_ASSETS_DIR" ]; then + cp "$out" "$JS_ASSETS_DIR/$basename" + log "Mirrored -> $JS_ASSETS_DIR/$basename" + fi +} + +main() { + local jar + jar="$(ensure_jar)" + compile_theme "$jar" ios-modern iOSModernTheme.res + compile_theme "$jar" android-material AndroidMaterialTheme.res + log "Native themes written to $OUT_DIR/" +} + +main "$@" diff --git a/scripts/cn1playground/common/codenameone_settings.properties b/scripts/cn1playground/common/codenameone_settings.properties index 6b4d78d8b2..5836a1ef55 100644 --- a/scripts/cn1playground/common/codenameone_settings.properties +++ b/scripts/cn1playground/common/codenameone_settings.properties @@ -4,6 +4,12 @@ codename1.android.keystorePassword= codename1.arg.block_server_registration=true codename1.arg.ios.newStorageLocation=true codename1.arg.ios.NSCameraUsageDescription=Some functionality of the application requires your camera +# Preview the iOS Modern (liquid-glass) and Android Material 3 +# themes inside the playground so users see the modern look when +# they explore components. +codename1.arg.ios.themeMode=modern +codename1.arg.cn1.androidTheme=material +codename1.arg.cn1.nativeTheme=modern codename1.arg.java.version=17 codename1.arg.javascript.inject_proxy=false codename1.cssTheme=true diff --git a/scripts/cn1playground/common/pom.xml b/scripts/cn1playground/common/pom.xml index a6c0f462a7..04b672d8b3 100644 --- a/scripts/cn1playground/common/pom.xml +++ b/scripts/cn1playground/common/pom.xml @@ -416,10 +416,42 @@ + + + org.apache.maven.plugins + maven-antrun-plugin + + + copy-native-themes + process-resources + + run + + + + + + + + + + + + + + + - + diff --git a/scripts/cn1playground/common/src/main/css/theme.css b/scripts/cn1playground/common/src/main/css/theme.css index 49862ae2fb..37b44461de 100644 --- a/scripts/cn1playground/common/src/main/css/theme.css +++ b/scripts/cn1playground/common/src/main/css/theme.css @@ -120,7 +120,7 @@ PlaygroundAppIcon { } PlaygroundAppIconDark { - background: rgba(255, 255, 255, 0.1); + background-color: rgba(255, 255, 255, 0.1); color: #FFFFFF; padding: 0.5mm 1mm 0.5mm 1mm; margin: 0 0.5mm 0 0.5mm; @@ -211,7 +211,7 @@ PlaygroundSegmentOptionSelectedDark { /* ----- Status pill ----- */ PlaygroundStatusPill { - background: rgba(240, 242, 247, 0.75); + background-color: rgba(240, 242, 247, 0.75); color: #112247; border: 1px solid #E1E4EA; border-radius: 3mm; @@ -223,7 +223,7 @@ PlaygroundStatusPill { } PlaygroundStatusPillDark { - background: rgba(11, 32, 85, 0.75); + background-color: rgba(11, 32, 85, 0.75); color: #F5F8FF; border: 1px solid #253B73; border-radius: 3mm; @@ -235,7 +235,7 @@ PlaygroundStatusPillDark { } PlaygroundStatusPillError { - background: rgba(251, 230, 230, 0.75); + background-color: rgba(251, 230, 230, 0.75); color: #D93636; border: 1px solid #F0C2C2; border-radius: 3mm; @@ -247,7 +247,7 @@ PlaygroundStatusPillError { } PlaygroundStatusPillErrorDark { - background: rgba(58, 21, 21, 0.75); + background-color: rgba(58, 21, 21, 0.75); color: #FF6B6B; border: 1px solid #5A2525; border-radius: 3mm; @@ -756,16 +756,29 @@ PlaygroundEmbeddedForm { background: #F7F8FB; } +/* Embedded form / title area dark backgrounds aligned with the modern + iOS / Material themes (Form #1c1c1e on iOS, Toolbar #000 on iOS). + The previous navy palette belonged to the legacy iOS7 / Holo Light + bezel and clashed visibly against the modern dark Form fill that + the preview Form now uses, leaving a wide blue band around the + actual app surface. */ PlaygroundEmbeddedFormDark { - background: #112247; + background: #1c1c1e; } +/* Title area sits on top of the embedded Form's surface. Painting an + opaque colour here would draw a visible band across the top of the + form (the host's previous navy contrasted with the legacy iOS7 form + bg by design, but against the modern Form surface it just reads as + "two different shades of dark"). Keep the title area transparent so + the form's surface paints uniformly underneath - the user's Title + text is what styles the title visually. */ PlaygroundEmbeddedTitleArea { - background: #F3F4F7; + background-color: transparent; } PlaygroundEmbeddedTitleAreaDark { - background: #163575; + background-color: transparent; } /* ----- Inspector ----- */ diff --git a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/CN1Playground.java b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/CN1Playground.java index 8b19d1441f..03edcc398a 100644 --- a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/CN1Playground.java +++ b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/CN1Playground.java @@ -1001,31 +1001,25 @@ private void applyDeviceTheme(String device) { private void layerAndroidTheme() { if (androidTheme == null) { try { - androidTheme = Resources.open("/androidTheme.res"); + androidTheme = Resources.openLayered("/AndroidMaterialTheme"); } catch (java.io.IOException ex) { - Log.p("Android theme unavailable: " + ex); + Log.p("Android Material theme unavailable: " + ex); return; } } applyThemeOverlay(androidTheme); } - /// Layers the builtin iPhone native theme on top of the playground base. - /// `iOS7Theme.res` ships with the Codename One distribution; we load it from the - /// classpath instead of copying a local copy into the project resources. - /// If the resource isn't on the runtime classpath (falls back silently) the - /// iPhone skin renders with the playground base theme only. + /// Layers the iOS Modern (liquid-glass) native theme on top of the + /// playground base so the iPhone preview matches what a generated + /// project will render at runtime. private void layerIosTheme() { if (iosTheme == null) { try { - iosTheme = Resources.openLayered("/iOS7Theme"); + iosTheme = Resources.openLayered("/iOSModernTheme"); } catch (java.io.IOException ex) { - try { - iosTheme = Resources.openLayered("/iPhoneTheme"); - } catch (java.io.IOException ex2) { - Log.p("iOS theme unavailable: " + ex2); - return; - } + Log.p("iOS Modern theme unavailable: " + ex); + return; } } applyThemeOverlay(iosTheme); @@ -1041,10 +1035,52 @@ private void applyThemeOverlay(Resources res) { } Hashtable props = res.getTheme(names[0]); if (props != null && !props.isEmpty()) { - UIManager.getInstance().addThemeProps(props); + UIManager.getInstance().addThemeProps(forwardCompatTitleAreaDerive(props)); } } + /// Forward-compatible patch for the `TitleArea` <-> `Toolbar` derive + /// cycle that the modern themes hit on older CN1 frameworks. + /// + /// `UIManager.resetThemeProps` in CN1 builds shipped before the + /// rebuild-themes work unconditionally installs `Toolbar.derive=TitleArea` + /// as a legacy default. The modern iOS / Material themes flip the + /// relationship the other way (`TitleArea.derive=Toolbar`) and the + /// pairing makes `createStyle` recurse - the playground freezes at boot + /// with `Error creating style TitleArea` in the console. + /// + /// We can't change UIManager itself in the deployed playground (it's + /// pinned to the published `cn1.version`), so we patch the theme props + /// before they reach `addThemeProps`: when the modern overlay declares + /// `TitleArea.derive=Toolbar`, we explicitly point `Toolbar.derive` at + /// `Component`. That overrides the legacy default with a target that + /// can't ever cycle back to TitleArea, and Toolbar's own explicit + /// properties (bgColor, padding, ...) still win during style resolution + /// so the visual outcome is unchanged. + /// + /// Forward-compat: when the framework's own `breakTitleAreaToolbarDeriveCycle` + /// post-build cleanup ships in a future CN1 release, this preview-side + /// patch keeps applying without conflict - the framework only removes + /// `Toolbar.derive` when its value is exactly `TitleArea`, while we set + /// it to `Component`. The patch becomes harmless duplication. + private static Hashtable forwardCompatTitleAreaDerive(Hashtable props) { + if (!"Toolbar".equals(props.get("TitleArea.derive"))) { + return props; + } + // Defensive copy so we don't mutate the cached Resources theme map. + Hashtable patched = new Hashtable(); + java.util.Enumeration e = props.keys(); + while (e.hasMoreElements()) { + Object k = e.nextElement(); + patched.put(k, props.get(k)); + } + patched.put("Toolbar.derive", "Component"); + patched.put("Toolbar.sel#derive", "Component.sel"); + patched.put("Toolbar.press#derive", "Component.press"); + patched.put("Toolbar.dis#derive", "Component.dis"); + return patched; + } + private void applyCssToPreview(Form form, String css) { String normalized = PlaygroundCssSupport.normalizeCustomCss(css); if (normalized.length() == 0) { diff --git a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java index 2d65ce3d02..ecf4606630 100644 --- a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java +++ b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java @@ -19,24 +19,34 @@ static final class Sample { import com.codename1.components.*; import com.codename1.ui.plaf.*; - Container root = new Container(BoxLayout.y()); - root.setScrollableY(true); - root.getAllStyles().setPaddingUnit(Style.UNIT_TYPE_DIPS); - root.getAllStyles().setPadding(3, 3, 3, 3); + Form form = new Form("Welcome", BoxLayout.y()); + + form.add(new SpanLabel( + "Codename One Playground - tap the device toggle above to flip between iOS and Android themes.")); + + Button btn = new Button("Default Button"); + form.add(btn); - SpanLabel title = new SpanLabel("Codename One Playground"); - title.getAllStyles().setFgColor(0x1f3a5f); - root.add(title); + Button raised = new Button("Raised Button"); + raised.setUIID("RaisedButton"); + form.add(raised); - Button button = new Button("Tap me"); - button.setText("Interactive controls can be added next"); - root.add(button); + Switch wifi = new Switch(); + wifi.setValue(true); + form.add(BoxLayout.encloseX(new Label("Wi-Fi"), new Label(" "), wifi)); - Label info = new Label("Rendered inside the preview panel"); - root.add(info); + CheckBox subscribe = new CheckBox("Subscribe"); + subscribe.setSelected(true); + form.add(subscribe); + + form.add(new TextField("", "Type something")); + + FloatingActionButton fab = FloatingActionButton.createFAB(FontImage.MATERIAL_ADD); + fab.bindFabToContainer(form.getContentPane()); + fab.addActionListener(e -> Dialog.show("FAB", "Tapped!", "OK", null)); ctx.log("Preview built successfully"); - root; + form; """; static final String HELLO_WORLD_SCRIPT = """ @@ -51,6 +61,7 @@ static final class Sample { static final String DATE_PICKER_SCRIPT = """ import com.codename1.ui.*; import com.codename1.ui.layouts.*; + import com.codename1.ui.spinner.*; Container root = new Container(BoxLayout.y()); Picker datePicker = new Picker(); @@ -103,14 +114,29 @@ public void actionPerformed(ActionEvent evt) { import com.codename1.ui.layouts.*; import com.codename1.components.*; - Container root = new Container(BoxLayout.y()); - root.setScrollableY(true); - root.add(new Label("Profile Card")); - root.add(new SpanLabel("Use the side menu to load more samples or restore history.")); - root.add(new TextField("Ada Lovelace", "Name")); - root.add(new TextField("Mathematician", "Title")); - root.add(new Button("Save")); - root; + Form form = new Form("Profile", BoxLayout.y()); + + form.add(new SpanLabel( + "Modern theme inputs adapt automatically to the active device skin.")); + + form.add(new TextField("Ada Lovelace", "Name")); + form.add(new TextField("Mathematician", "Title")); + form.add(new TextField("ada@analytical.engine", "Email")); + form.add(new TextArea("First programmer; collaborator on Babbage's Analytical Engine.", 3, 30)); + + CheckBox notify = new CheckBox("Email me about updates"); + notify.setSelected(true); + form.add(notify); + + Button save = new Button("Save"); + save.setUIID("RaisedButton"); + save.addActionListener(e -> Dialog.show("Saved", "Profile updated.", "OK", null)); + form.add(save); + + Button cancel = new Button("Cancel"); + form.add(cancel); + + form; """; static final String LIST_SCRIPT = """ @@ -118,36 +144,97 @@ public void actionPerformed(ActionEvent evt) { import com.codename1.ui.layouts.*; import com.codename1.components.*; - Container root = new Container(BoxLayout.y()); - root.setScrollableY(true); - for (int i = 1; i <= 8; i++) { - MultiButton row = new MultiButton("Menu Item " + i); - row.addActionListener(e -> Dialog.show("Clicked", "Clicked item " + i, "OK", null)); - row.setTextLine2("Secondary line for item " + i); - root.add(row); + String[] titles = {"Inbox", "Starred", "Archive", "Snoozed", "Folders", "Settings"}; + String[] subtitles = { + "12 unread", "Starred messages", "Older threads", + "Snoozed for later", "Shared with the team", "Account & preferences" + }; + + Form form = new Form("Menu", BoxLayout.y()); + for (int i = 0; i < titles.length; i++) { + int idx = i; + MultiButton row = new MultiButton(titles[i]); + row.setTextLine2(subtitles[i]); + row.addActionListener(e -> Dialog.show(titles[idx], subtitles[idx], "OK", null)); + form.add(row); } ctx.log("List sample loaded"); - root; + form; + """; + + static final String UI_SHOWCASE_SCRIPT = """ + import com.codename1.ui.*; + import com.codename1.ui.layouts.*; + import com.codename1.components.*; + import com.codename1.ui.spinner.*; + + Form form = new Form("UI Showcase", BoxLayout.y()); + + form.add(new Label("Buttons")); + Button flat = new Button("Flat"); + Button raised = new Button("Raised"); + raised.setUIID("RaisedButton"); + Button disabled = new Button("Disabled"); + disabled.setEnabled(false); + form.add(BoxLayout.encloseX(flat, raised, disabled)); + + form.add(new Label("Toggles")); + CheckBox check = new CheckBox("Notifications"); + check.setSelected(true); + RadioButton radioA = new RadioButton("Light"); + RadioButton radioB = new RadioButton("Dark"); + radioB.setSelected(true); + ButtonGroup g = new ButtonGroup(radioA, radioB); + Switch sw = new Switch(); + sw.setValue(true); + form.add(check); + form.add(BoxLayout.encloseX(radioA, radioB)); + form.add(BoxLayout.encloseX(new Label("Dark mode"), sw)); + + form.add(new Label("Inputs")); + form.add(new TextField("ada@analytical.engine", "Email")); + form.add(new TextArea("First programmer.", 2, 28)); + + Picker date = new Picker(); + date.setType(Display.PICKER_TYPE_DATE); + form.add(BoxLayout.encloseX(new Label("Birthday"), date)); + + FloatingActionButton fab = FloatingActionButton.createFAB(FontImage.MATERIAL_EDIT); + fab.bindFabToContainer(form.getContentPane()); + fab.addActionListener(e -> Dialog.show("Edit", "FAB tapped.", "OK", null)); + + form; """; static final String TABS_SCRIPT = """ import com.codename1.ui.*; import com.codename1.ui.layouts.*; + import com.codename1.components.*; + // Tabs render with each platform's native placement: iOS Modern + // floats a pill bar at the bottom, Material 3 stays on top with + // an underline indicator. The theme decides; we just add tabs. + Form form = new Form("Tabs", new BorderLayout()); Tabs tabs = new Tabs(); - tabs.setTabPlacement(Component.TOP); - tabs.addTab("News", BoxLayout.encloseY(new Label("Latest updates"), new Label("Deployment is green"))); - tabs.addTab("Stats", BoxLayout.encloseY(new Label("Users: 42"), new Label("Build time: 3m"))); - tabs.addTab("Notes", BoxLayout.encloseY(new Label("Dark mode follows the website theme"))); - tabs.getTabsContainer().getAllStyles().setBgTransparency(255); - tabs.getTabsContainer().getAllStyles().setBgColor(0xe2e8f0); - for (int i = 0; i < tabs.getTabCount(); i++) { - Component tab = tabs.getTabsContainer().getComponentAt(i); - tab.getAllStyles().setBgTransparency(255); - tab.getAllStyles().setBgColor(0xe2e8f0); - tab.getAllStyles().setFgColor(0x0f172a); - } - tabs; + tabs.addTab("Home", + FontImage.createMaterial(FontImage.MATERIAL_HOME, "Tab", 4), + BoxLayout.encloseY( + new SpanLabel("Latest activity"), + new Label("3 new notifications"), + new Button("Open inbox"))); + tabs.addTab("Search", + FontImage.createMaterial(FontImage.MATERIAL_SEARCH, "Tab", 4), + BoxLayout.encloseY( + new TextField("", "Search anything"), + new SpanLabel("Results appear here"))); + tabs.addTab("Profile", + FontImage.createMaterial(FontImage.MATERIAL_PERSON, "Tab", 4), + BoxLayout.encloseY( + new Label("Ada Lovelace"), + new Label("ada@analytical.engine"), + new Button("Sign out"))); + form.add(BorderLayout.CENTER, tabs); + form; """; static final String BROWSER_SCRIPT = """ @@ -255,10 +342,12 @@ public void actionPerformed(ActionEvent evt) { static final Sample[] SAMPLES = new Sample[]{ new Sample("Welcome", DEFAULT_SCRIPT), + new Sample("UI Showcase", UI_SHOWCASE_SCRIPT), new Sample("Hello World", HELLO_WORLD_SCRIPT), new Sample("Lifecycle Demo", LIFECYCLE_SCRIPT), new Sample("Date Picker", DATE_PICKER_SCRIPT), new Sample("Menu List", LIST_SCRIPT), + new Sample("Profile Form", FORM_SCRIPT), new Sample("Tabs", TABS_SCRIPT), new Sample("BrowserComponent", BROWSER_SCRIPT), new Sample("Network Fetch", NETWORK_SCRIPT), diff --git a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundPreviewColumn.java b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundPreviewColumn.java index 1c2f87fe38..7bd4b3ad0b 100644 --- a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundPreviewColumn.java +++ b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundPreviewColumn.java @@ -250,7 +250,7 @@ private void rebuildStage() { contentHost.setUIID(darkMode ? "PlaygroundNoSkinStageDark" : "PlaygroundNoSkinStage"); stageWrapper.setUIID(darkMode ? "PlaygroundNoSkinStageDark" : "PlaygroundNoSkinStage"); if (currentPreview != null) { - contentHost.add(BorderLayout.CENTER, currentPreview); + contentHost.add(BorderLayout.CENTER, wrapNonFormWithFormBackdrop(currentPreview)); } dimensionsLabel.setText("Fills preview"); } else { @@ -270,6 +270,23 @@ private void rebuildStage() { } } + /// When the user's preview is something other than a Form (e.g. a Container + /// from `new Container(BoxLayout.y())`), the preview is rendered directly + /// inside the playground's device screen and there's no Form to paint a + /// surface behind it. Wrap such previews in a tiny BorderLayout container + /// styled with the `Form` UIID so the surrounding skin shows the same + /// surface the active native theme paints behind a real Form. Forms pass + /// through unchanged - they paint their own surface. + private static Component wrapNonFormWithFormBackdrop(Component preview) { + if (preview == null || preview instanceof com.codename1.ui.Form) { + return preview; + } + Container wrapper = new Container(new BorderLayout()); + wrapper.setUIID("Form"); + wrapper.add(BorderLayout.CENTER, preview); + return wrapper; + } + private void detachPreview() { if (currentPreview == null) { return; @@ -320,7 +337,7 @@ private Container buildBezel() { Container content = new Container(new BorderLayout()); content.setUIID(darkMode ? "PlaygroundDeviceScreenDark" : "PlaygroundDeviceScreen"); if (currentPreview != null) { - content.add(BorderLayout.CENTER, currentPreview); + content.add(BorderLayout.CENTER, wrapNonFormWithFormBackdrop(currentPreview)); } CornerMaskOverlay cornerMask = new CornerMaskOverlay(BEZEL_FILL_COLOR, screenCornerPx); diff --git a/scripts/cn1playground/common/src/main/resources/common.zip b/scripts/cn1playground/common/src/main/resources/common.zip index f6da65b787..5f35bf517a 100644 Binary files a/scripts/cn1playground/common/src/main/resources/common.zip and b/scripts/cn1playground/common/src/main/resources/common.zip differ diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ButtonThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ButtonThemeScreenshotTest.java new file mode 100644 index 0000000000..971105d030 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ButtonThemeScreenshotTest.java @@ -0,0 +1,60 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Button; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +/** + * Theme-fidelity screenshot of Button / RaisedButton / FlatButton in + * default, pressed, and disabled states. Emits light + dark pair. + */ +public class ButtonThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "ButtonTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + Button defaultBtn = new Button("Default"); + form.add(defaultBtn); + // Annotate the primary button so reviewers can see top/bottom + // edges, the inner text band (inset by padding), and a measured + // height callout. Design-system spec for a text button is ~10mm + // (Material 40dp / iOS 44pt) with text centered vertically. + annotateComponent(defaultBtn, "Default button: ~10mm target, text centered"); + + form.add(pressed(new Button("Pressed"))); + form.add(disabled(new Button("Disabled"))); + + Button raised = new Button("Raised"); + raised.setUIID("RaisedButton"); + form.add(raised); + annotateComponent(raised, "RaisedButton: primary fill, same 10mm rhythm"); + + Button raisedPressed = new Button("Raised pressed"); + raisedPressed.setUIID("RaisedButton"); + form.add(pressed(raisedPressed)); + + Button flat = new Button("Flat"); + flat.setUIID("FlatButton"); + form.add(flat); + } + + private static Button pressed(Button b) { + // Toggle the pressed UIID state so the .pressed style is rendered. + b.pressed(); + return b; + } + + private static Button disabled(Button b) { + b.setEnabled(false); + return b; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CheckBoxRadioThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CheckBoxRadioThemeScreenshotTest.java new file mode 100644 index 0000000000..72f8991cff --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/CheckBoxRadioThemeScreenshotTest.java @@ -0,0 +1,41 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.CheckBox; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.RadioButton; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +public class CheckBoxRadioThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "CheckBoxRadioTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + form.add(new Label("CheckBoxes")); + form.add(new CheckBox("Unselected")); + CheckBox selected = new CheckBox("Selected"); + selected.setSelected(true); + form.add(selected); + CheckBox disabled = new CheckBox("Disabled"); + disabled.setEnabled(false); + form.add(disabled); + + form.add(new Label("RadioButtons")); + form.add(new RadioButton("Unselected")); + RadioButton rSel = new RadioButton("Selected"); + rSel.setSelected(true); + form.add(rSel); + RadioButton rDis = new RadioButton("Disabled"); + rDis.setEnabled(false); + form.add(rDis); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 2520a2a7eb..844711619f 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -82,6 +82,22 @@ public final class Cn1ssDeviceRunner extends DeviceRunner { new ValidatorLightweightPickerScreenshotTest(), new LightweightPickerButtonsScreenshotTest(), new ToastBarTopPositionScreenshotTest(), + // Native-theme fidelity tests (Phase 7): each emits a light+dark PNG pair + // so the iOS Modern and Android Material themes get exercised per UIID. + new ButtonThemeScreenshotTest(), + new TextFieldThemeScreenshotTest(), + new CheckBoxRadioThemeScreenshotTest(), + new SwitchThemeScreenshotTest(), + new PickerThemeScreenshotTest(), + new ToolbarThemeScreenshotTest(), + new TabsThemeScreenshotTest(), + new MultiButtonThemeScreenshotTest(), + new ListThemeScreenshotTest(), + new DialogThemeScreenshotTest(), + new FloatingActionButtonThemeScreenshotTest(), + new SpanLabelThemeScreenshotTest(), + new DarkLightShowcaseThemeScreenshotTest(), + new PaletteOverrideThemeScreenshotTest(), // Keep this as the last screenshot test; orientation changes can leak into subsequent screenshots. new OrientationLockScreenshotTest(), new InPlaceEditViewTest(), @@ -154,7 +170,27 @@ private boolean shouldForceTimeoutInHtml5(String testName) { || "CallDetectionAPITest".equals(testName) || "LocalNotificationOverrideTest".equals(testName) || "Base64NativePerformanceTest".equals(testName) - || "AccessibilityTest".equals(testName); + || "AccessibilityTest".equals(testName) + // The native-theme fidelity tests (each emits a light+dark PNG + // pair) matter for iOS/Android/JavaSE where the user actually + // looks at visual output. The JS port run has a tight 150s + // browser-lifetime budget that doesn't accommodate another + // 13 x 2 captures; skip them here. Re-enable selectively when + // we move the JS port to a longer-lived harness. + || "ButtonThemeScreenshotTest".equals(testName) + || "TextFieldThemeScreenshotTest".equals(testName) + || "CheckBoxRadioThemeScreenshotTest".equals(testName) + || "SwitchThemeScreenshotTest".equals(testName) + || "PickerThemeScreenshotTest".equals(testName) + || "ToolbarThemeScreenshotTest".equals(testName) + || "TabsThemeScreenshotTest".equals(testName) + || "MultiButtonThemeScreenshotTest".equals(testName) + || "ListThemeScreenshotTest".equals(testName) + || "DialogThemeScreenshotTest".equals(testName) + || "FloatingActionButtonThemeScreenshotTest".equals(testName) + || "SpanLabelThemeScreenshotTest".equals(testName) + || "DarkLightShowcaseThemeScreenshotTest".equals(testName) + || "PaletteOverrideThemeScreenshotTest".equals(testName); } private void awaitTestCompletion(int index, BaseTest testClass, String testName, long deadline) { diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DarkLightShowcaseThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DarkLightShowcaseThemeScreenshotTest.java new file mode 100644 index 0000000000..64da2cf48a --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DarkLightShowcaseThemeScreenshotTest.java @@ -0,0 +1,60 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.CheckBox; +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.RadioButton; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +/** + * Mixed-components showcase: one screen with Button / RaisedButton / + * TextField / CheckBox / RadioButton / SpanLabel stacked together, to + * catch regressions where the light and dark palettes diverge in contrast + * across a realistic form mix. + */ +public class DarkLightShowcaseThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "ShowcaseTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + form.add(new Label("Showcase " + suffix)); + + Container row = new Container(BoxLayout.x()); + row.add(new Button("Default")); + Button raised = new Button("Raised"); + raised.setUIID("RaisedButton"); + row.add(raised); + form.add(row); + + TextField tf = new TextField("hello@example.com"); + form.add(tf); + + Container toggles = new Container(BoxLayout.x()); + CheckBox cb = new CheckBox("Remember me"); + cb.setSelected(true); + toggles.add(cb); + RadioButton rb = new RadioButton("Agree"); + rb.setSelected(true); + toggles.add(rb); + form.add(toggles); + + SpanLabel body = new SpanLabel( + "Body copy using the theme's default SpanLabel styling. This " + + "should be clearly legible against the form background in " + + "both light and dark appearances."); + form.add(body); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DialogThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DialogThemeScreenshotTest.java new file mode 100644 index 0000000000..66b7051f56 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DialogThemeScreenshotTest.java @@ -0,0 +1,64 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Dialog; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.Layout; +import com.codename1.ui.plaf.Style; + +/** + * Screenshot coverage for Dialog / DialogBody / DialogTitle / dialog command + * area. The dialog is rendered inline as a styled container (not as a modal + * show()) so the screenshot captures the dialog chrome reliably without + * waiting for modal animation to settle. + */ +public class DialogThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "DialogTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected boolean useTexturedBackdrop() { + // Dialog may have translucent tints in the modern theme - paint + // over a colourful texture so any see-through is visible. + return true; + } + + @Override + protected void populate(Form form, String suffix) { + Container dialog = new Container(new BorderLayout()); + dialog.setUIID("Dialog"); + + Container body = new Container(BoxLayout.y()); + body.setUIID("DialogBody"); + + Label title = new Label("Example dialog"); + title.setUIID("DialogTitle"); + body.add(title); + + SpanLabel message = new SpanLabel( + "Are you sure you want to continue with this action? " + + "This is a sample of a dialog body with a span label message."); + body.add(message); + + Container commands = new Container(new FlowLayout(Component.RIGHT)); + commands.setUIID("DialogCommandArea"); + commands.add(new Button("Cancel")).add(new Button("OK")); + + dialog.add(BorderLayout.CENTER, body).add(BorderLayout.SOUTH, commands); + form.add(dialog); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DualAppearanceBaseTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DualAppearanceBaseTest.java new file mode 100644 index 0000000000..b9bc76ca9f --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/DualAppearanceBaseTest.java @@ -0,0 +1,465 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.SpanLabel; +import com.codename1.io.Log; +import com.codename1.io.Util; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Font; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Painter; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.Layout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Base for theme-fidelity screenshot tests that emit a light + dark image + * pair against the modern iOS or Android Material native theme. The + * legacy iOS 7 / Android Holo themes stay in place as the framework's + * default - the modern theme is opt-in specifically for tests in this + * family so existing screenshot goldens aren't silently redesigned. + * + * Subclasses implement {@link #populate(Form, String)} to add the + * component(s) to exercise. During populate() they can also call + * {@link #annotateComponent(Component, String)} to register a specific + * component for the designer-style grid overlay - a thin horizontal + * line at its top, its content/text band, its bottom, plus a legend + * SpanLabel at the bottom of the form describing the measurement. The + * overlay is opt-in per component instead of painted on every Button/ + * Label blindly, so it stays readable even when the form is dense. + */ +public abstract class DualAppearanceBaseTest extends BaseTest { + + /** + * Populate the given form with the component(s) to exercise. Called + * once per appearance (first light, then dark) on a fresh form. + * Use {@link #annotateComponent(Component, String)} from inside + * populate() to tag specific components for the grid overlay. + * + * @param form fresh form with its Layout already set + * @param suffix "light" or "dark" - useful if populate() wants to + * surface the active appearance in a Label, for example. + */ + protected abstract void populate(Form form, String suffix); + + /** + * Subclasses override to provide the image-name prefix used for both + * captures. The emitted chunks will be named {@code _light} + * and {@code _dark}. + */ + protected abstract String baseName(); + + /** + * Subclasses override to provide the root layout. A fresh instance is + * requested for each appearance. + */ + protected abstract Layout newLayout(); + + /** + * Subclasses override if a specific test should stay on the legacy + * default theme (iOS 7 / Android Holo Light) - e.g. a regression + * test that must exercise the legacy palette. Default: modern theme. + */ + protected boolean useModernTheme() { + return true; + } + + /** + * Subclasses override (return true) when the widget under test has + * translucent aspects (Dialog, Tabs pill, PopupContent, ...). A + * colourful diagonal-stripe texture is painted behind the form + * so any see-through tint is visible in the screenshot rather than + * blending into a plain Form bg. Default: plain form background. + */ + protected boolean useTexturedBackdrop() { + return false; + } + + private final List annotations = new ArrayList(); + + /** + * Register a component for the designer-style grid overlay. Call + * from inside {@link #populate(Form, String)}. A thin guide line is + * drawn at the component's top, text band, and bottom, and the + * supplied legend is appended as a SpanLabel at the bottom of the + * form describing what's being measured (e.g. "Primary button: + * Material 3 full rounded, target H=10mm / 40dp, text centered"). + */ + protected final void annotateComponent(Component c, String legend) { + if (c == null) { + return; + } + annotations.add(new Annotation(c, legend)); + } + + @Override + public boolean runTest() { + installModernThemeIfRequested(); + runAppearance(false, "light", () -> runAppearance(true, "dark", this::finish)); + return true; + } + + private void runAppearance(boolean dark, final String suffix, final Runnable next) { + Display.getInstance().setDarkMode(dark); + // UIManager caches resolved Style objects per UIID; without this call + // the next lookup returns the Style that was resolved while the other + // appearance was active, and the screenshot comes out in the wrong + // appearance. UIManager.refreshTheme() clears the caches and re-runs + // the theme build pass against CN.isDarkMode()'s current value, so + // fresh components on the new Form pick up the correct $Dark + // entries (emitted by the native theme's @media dark block). + UIManager.getInstance().refreshTheme(); + + annotations.clear(); + + final String imageName = baseName() + "_" + suffix; + final boolean textured = useTexturedBackdrop(); + final TextureBackdropPainter backdrop = textured + ? new TextureBackdropPainter(dark) + : null; + Form form = new Form(baseName() + " / " + suffix, newLayout()) { + @Override + public void paintBackground(Graphics g) { + if (backdrop != null) { + // Paint the diagonal-stripe pattern into the form's + // backing area before the rest of the render pipeline + // runs. Any translucent widget above (Dialog, pill + // Tabs, Popup) then reveals its see-through tint + // against a visible pattern instead of a plain surface. + backdrop.paint(g, new Rectangle(0, 0, getWidth(), getHeight())); + return; + } + super.paintBackground(g); + } + + @Override + protected void onShowCompleted() { + registerReadyCallback(this, () -> { + // Chain next.run() through emitCurrentFormScreenshot's + // onComplete callback. If we call next.run() inline the + // dark-appearance flow kicks off Form2.show() before the + // Display.screenshot() callback has fired, so both emits + // race over the same transitioning buffer and produce + // byte-identical PNGs (classic symptom was + // ButtonTheme_light.png == ButtonTheme_dark.png). + Cn1ssDeviceRunnerHelper.emitCurrentFormScreenshot(imageName, next); + }); + } + }; + populate(form, suffix); + if (textured) { + // The ContentPane sits on top of the Form and paints its own + // theme-supplied bgColor on every render; without making it + // transparent the texture paint underneath is hidden by a + // solid wash. TitleArea / Toolbar likewise opaque - clear + // them too so the backdrop reads edge-to-edge. + form.getContentPane().getUnselectedStyle().setBgTransparency((byte) 0); + form.getTitleArea().getUnselectedStyle().setBgTransparency((byte) 0); + } + if (!annotations.isEmpty()) { + form.setGlassPane(new AnnotationPainter(annotations, dark)); + SpanLabel legend = buildLegend(); + if (legend != null) { + form.add(legend); + } + } + form.show(); + } + + private SpanLabel buildLegend() { + if (annotations.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + sb.append("Grid: "); + for (int i = 0; i < annotations.size(); i++) { + Annotation a = annotations.get(i); + if (a.legend == null || a.legend.length() == 0) { + continue; + } + if (sb.length() > 7) { + sb.append(" - "); + } + sb.append(a.legend); + } + SpanLabel s = new SpanLabel(sb.toString()); + s.setUIID("TertiaryLabel"); + return s; + } + + private void finish() { + // Restore platform-default dark mode + the app's own theme so + // subsequent tests in the suite (legacy screenshots matching + // pre-change goldens) see exactly the state they had before + // this test ran. + Display.getInstance().setDarkMode(null); + if (useModernTheme()) { + // UIManager.initFirstTheme loads /theme.res (the app's + // compiled theme.css). With includeNativeBool=true in its + // constants it triggers Display.installNativeTheme() - + // Holo Light / iPhoneTheme per the platform's legacy default - + // and then layers the user's UIID overrides on top. This + // recreates the original startup theme state, which + // Display.installNativeTheme alone doesn't (it drops the + // user's font / padding / colour overrides). + UIManager.initFirstTheme("/theme"); + } + UIManager.getInstance().refreshTheme(); + done(); + } + + private void installModernThemeIfRequested() { + if (!useModernTheme()) { + return; + } + String resourceName = pickModernThemeResource(); + if (resourceName == null) { + logDiag("CN1SS:INFO:DualAppearance no modern theme resource for platform=" + + Display.getInstance().getPlatformName()); + return; + } + // Try the CN1 resource path first (Display.getResourceAsStream goes + // through each port's impl - Android via getAssets(), iOS via + // nativeInstance.getResourceSize/NSFileInputStream), then fall back + // to Class.getResourceAsStream for platforms where the .res sits on + // the Java classpath (JavaSE / JavaScript). + InputStream in = openModernResource(resourceName); + if (in == null) { + logDiag("CN1SS:WARN:DualAppearance modern theme resource missing: " + resourceName + + " test=" + baseName() + " platform=" + Display.getInstance().getPlatformName()); + return; + } + try { + Resources r = Resources.open(in); + String[] names = r.getThemeResourceNames(); + if (names == null || names.length == 0) { + logDiag("CN1SS:ERR:DualAppearance modern theme has no themes resource=" + resourceName + + " test=" + baseName()); + return; + } + UIManager.getInstance().setThemeProps(r.getTheme(names[0])); + logDiag("CN1SS:INFO:DualAppearance installed modern theme " + resourceName + + " themeName=" + names[0] + " test=" + baseName()); + } catch (IOException ex) { + logDiag("CN1SS:ERR:DualAppearance modern theme load failed: " + ex + + " resource=" + resourceName + " test=" + baseName()); + } finally { + Util.cleanup(in); + } + } + + private InputStream openModernResource(String resourceName) { + InputStream in = Display.getInstance().getResourceAsStream(getClass(), resourceName); + if (in != null) { + return in; + } + return DualAppearanceBaseTest.class.getResourceAsStream(resourceName); + } + + // Route diagnostic messages through com.codename1.io.Log as well as + // System.out. On iOS, `simctl log stream` sheds `stdout` lines when the + // CN1SS base64 PNG burst saturates unified logging; Log.p ends up in + // device-runner.log's fallback persistence path and survives the drop. + private static void logDiag(String message) { + System.out.println(message); + Log.p(message); + } + + private String pickModernThemeResource() { + String platform = Display.getInstance().getPlatformName(); + if ("ios".equals(platform)) { + return "/iOSModernTheme.res"; + } + if ("and".equals(platform)) { + return "/AndroidMaterialTheme.res"; + } + return null; + } + + private static final class Annotation { + final Component component; + final String legend; + + Annotation(Component component, String legend) { + this.component = component; + this.legend = legend; + } + } + + /** + * Designer-style overlay: for each annotated component, paints three + * thin horizontal guide lines (top edge, content/text band, bottom + * edge) plus an H=NNmm callout. The text band is derived from the + * component's padding so reviewers can eyeball the spec (e.g. + * "button text centered with 2mm top/bottom padding"). + */ + private static final class AnnotationPainter implements Painter { + private final List annotations; + private final boolean dark; + + AnnotationPainter(List annotations, boolean dark) { + this.annotations = annotations; + this.dark = dark; + } + + @Override + public void paint(Graphics g, Rectangle rect) { + if (annotations.isEmpty()) { + return; + } + int prevColor = g.getColor(); + int prevAlpha = g.getAlpha(); + int pxPerMm = Math.max(1, Display.getInstance().convertToPixels(1f)); + int rightEdge = rect.getX() + rect.getWidth(); + + int edgeColor = dark ? 0x66bbff : 0xcc0088; + int textBandColor = dark ? 0x88ff99 : 0x00aa55; + int labelBg = dark ? 0x002233 : 0xfff0f8; + + for (Annotation a : annotations) { + Component c = a.component; + if (c == null) { + continue; + } + int x = c.getAbsoluteX(); + int y = c.getAbsoluteY(); + int w = c.getWidth(); + int h = c.getHeight(); + if (w <= 0 || h <= 0) { + continue; + } + + Style s = c.getUnselectedStyle(); + int padTop = s != null ? s.getPaddingTop() : 0; + int padBottom = s != null ? s.getPaddingBottom() : 0; + int textTop = y + padTop; + int textBottom = y + h - padBottom; + int heightMm = Math.round(((float) h) / pxPerMm); + int textHeightMm = Math.round(((float) (textBottom - textTop)) / pxPerMm); + + // Edge guide lines at the top and bottom of the component. + g.setColor(edgeColor); + g.setAlpha(180); + g.drawLine(x, y, x + w - 1, y); + g.drawLine(x, y + h - 1, x + w - 1, y + h - 1); + + // End ticks so the reviewer can visually measure the box. + int tick = Math.max(2, pxPerMm / 2); + g.drawLine(x, y - tick, x, y + tick); + g.drawLine(x + w - 1, y - tick, x + w - 1, y + tick); + g.drawLine(x, y + h - 1 - tick, x, y + h - 1 + tick); + g.drawLine(x + w - 1, y + h - 1 - tick, x + w - 1, y + h - 1 + tick); + + // Text-band guides (inset by padding) in a second colour + // so the text position inside the component is measurable + // too, not just the outer box. + if (textBottom > textTop + pxPerMm) { + g.setColor(textBandColor); + g.setAlpha(140); + g.drawLine(x, textTop, x + w - 1, textTop); + g.drawLine(x, textBottom, x + w - 1, textBottom); + } + + // Callout placed outside the component (to the right if + // there's room, otherwise below). + String label = "H=" + heightMm + "mm, text=" + textHeightMm + "mm"; + Font f = g.getFont(); + if (f == null) { + f = Font.getDefaultFont(); + g.setFont(f); + } + int textW = f.stringWidth(label); + int textH = f.getHeight(); + int labelX = x + w + 2; + int labelY = y + (h - textH) / 2; + if (labelX + textW + 4 > rightEdge) { + labelX = Math.max(0, rightEdge - textW - 6); + labelY = y + h + 2; + } + + g.setAlpha(210); + g.setColor(labelBg); + g.fillRect(labelX - 2, labelY - 1, textW + 4, textH + 2); + g.setColor(edgeColor); + g.drawString(label, labelX, labelY); + } + + g.setAlpha(prevAlpha); + g.setColor(prevColor); + } + } + + /** + * Diagonal-stripe texture backdrop. Bright alternating bands behind + * the Form so a translucent widget above (Dialog, pill Tabs, + * PopupContent) reveals its see-through tint in the screenshot + * instead of painting over a plain surface that would make the + * translucency invisible. + */ + private static final class TextureBackdropPainter implements Painter { + private final boolean dark; + + TextureBackdropPainter(boolean dark) { + this.dark = dark; + } + + @Override + public void paint(Graphics g, Rectangle rect) { + int prevColor = g.getColor(); + int prevAlpha = g.getAlpha(); + int x = rect.getX(); + int y = rect.getY(); + int w = rect.getWidth(); + int h = rect.getHeight(); + + // Base fill - a neutral mid-tone so stripes have somewhere + // to sit. Dark mode uses a dark base, light uses a light base. + g.setAlpha(255); + g.setColor(dark ? 0x202030 : 0xf0e8f8); + g.fillRect(x, y, w, h); + + // Diagonal stripes painted as rotated rectangles. 6mm-ish band + // width reads well at phone resolution. Palette is kept + // saturated so even a 10% translucent widget's tint is clearly + // picked up against it. + int pxPerMm = Math.max(1, Display.getInstance().convertToPixels(1f)); + int bandW = pxPerMm * 6; + int[] lightPalette = { 0xff7eb2, 0x7ec8ff, 0xffd67e, 0x9affc8, 0xd8a0ff }; + int[] darkPalette = { 0x882244, 0x224488, 0x886622, 0x226644, 0x664488 }; + int[] palette = dark ? darkPalette : lightPalette; + g.setAlpha(180); + int diagonalOffset = -h; // start off-screen so the pattern fills + int band = 0; + while (diagonalOffset < w + h) { + g.setColor(palette[band % palette.length]); + // diagonal band = a quad from (diagonalOffset, 0) to + // (diagonalOffset + bandW, 0) down to (diagonalOffset + bandW + h, h) + // / (diagonalOffset + h, h). Approximate with scanlines so + // this stays portable across ports that may lack fillPolygon. + for (int row = 0; row < h; row++) { + int x0 = x + diagonalOffset + row; + int x1 = x0 + bandW; + if (x1 < x || x0 > x + w) { + continue; + } + if (x0 < x) x0 = x; + if (x1 > x + w) x1 = x + w; + g.fillRect(x0, y + row, x1 - x0, 1); + } + diagonalOffset += bandW; + band++; + } + + g.setAlpha(prevAlpha); + g.setColor(prevColor); + } + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/FloatingActionButtonThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/FloatingActionButtonThemeScreenshotTest.java new file mode 100644 index 0000000000..dc38003c50 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/FloatingActionButtonThemeScreenshotTest.java @@ -0,0 +1,29 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.FloatingActionButton; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.Layout; + +public class FloatingActionButtonThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "FloatingActionButtonTheme"; + } + + @Override + protected Layout newLayout() { + return new BorderLayout(); + } + + @Override + protected void populate(Form form, String suffix) { + Container content = new Container(new BorderLayout()); + content.add(BorderLayout.CENTER, new Label("Body content")); + FloatingActionButton fab = FloatingActionButton.createFAB(FontImage.MATERIAL_ADD); + form.add(BorderLayout.CENTER, fab.bindFabToContainer(content)); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ListThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ListThemeScreenshotTest.java new file mode 100644 index 0000000000..1b962161bc --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ListThemeScreenshotTest.java @@ -0,0 +1,38 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Container; +import com.codename1.ui.Form; +import com.codename1.ui.List; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.Layout; + +public class ListThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "ListTheme"; + } + + @Override + protected Layout newLayout() { + return new BorderLayout(); + } + + @Override + protected void populate(Form form, String suffix) { + List list = new List(new Object[]{ + "First item", + "Second item", + "Third item", + "Fourth item", + "Fifth item", + "Sixth item", + "Seventh item", + "Eighth item" + }); + list.setSelectedIndex(1); + + Container wrap = new Container(new BorderLayout()); + wrap.add(BorderLayout.CENTER, list); + form.add(BorderLayout.CENTER, wrap); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MultiButtonThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MultiButtonThemeScreenshotTest.java new file mode 100644 index 0000000000..9356f1a0bc --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MultiButtonThemeScreenshotTest.java @@ -0,0 +1,42 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.MultiButton; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +public class MultiButtonThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "MultiButtonTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + form.add(build("Title only", null, null, null, FontImage.MATERIAL_PERSON)); + form.add(build("First row", "Secondary line", null, null, FontImage.MATERIAL_EMAIL)); + form.add(build("Three lines", "Secondary line", "Tertiary line", null, FontImage.MATERIAL_PHONE)); + form.add(build("Four lines", "Secondary", "Tertiary", "Quaternary line", FontImage.MATERIAL_SCHEDULE)); + } + + private static MultiButton build(String l1, String l2, String l3, String l4, char icon) { + MultiButton b = new MultiButton(l1); + if (l2 != null) { + b.setTextLine2(l2); + } + if (l3 != null) { + b.setTextLine3(l3); + } + if (l4 != null) { + b.setTextLine4(l4); + } + FontImage.setMaterialIcon(b, icon); + return b; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PaletteOverrideThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PaletteOverrideThemeScreenshotTest.java new file mode 100644 index 0000000000..af0555a085 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PaletteOverrideThemeScreenshotTest.java @@ -0,0 +1,106 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Button; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; +import com.codename1.ui.plaf.UIManager; + +import java.util.Hashtable; + +/** + * Verifies that a sub-theme can re-skin the native palette without + * touching the native theme's CSS source. + * + * The native CSS declares a palette (--cn1-accent, --cn1-primary etc.) + * that's inlined into each UIID at compile time. At runtime a user app + * overrides specific colors by layering an additional {@link Hashtable} + * of theme props on top of the installed native theme via + * {@link UIManager#addThemeProps}. This test installs a magenta + * override - vivid enough that a visual diff against the native + * baseline is unmistakable - and verifies both the light and dark + * captures pick it up. + * + * The override is installed once when the suite reaches this test; the + * light capture exercises it with the light base styles, the dark + * capture exercises it with the base styles picking up the + * {@code $Dark} variants merged under the same override layer. + * Because {@link Style#setBgColor} on an override key blows away the + * {@code $Dark} variant for that specific key, the dark capture also + * ends up showing the override color - proving the override reaches + * every appearance. + */ +public class PaletteOverrideThemeScreenshotTest extends DualAppearanceBaseTest { + + private static final String OVERRIDE_ACCENT = "ff2d95"; + private static final String OVERRIDE_ACCENT_PRESSED = "c71a75"; + private static final String OVERRIDE_ACCENT_TEXT = "ffffff"; + private boolean overrideInstalled; + + @Override + protected String baseName() { + return "PaletteOverrideTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + if (!overrideInstalled) { + installPaletteOverride(); + overrideInstalled = true; + } + + form.add(new Label("Primary / accent UIIDs")); + + Button primary = new Button("Raised"); + primary.setUIID("RaisedButton"); + form.add(primary); + + Button text = new Button("Text"); + form.add(text); + + form.add(new Label("Disabled state")); + Button disabled = new Button("Disabled"); + disabled.setUIID("RaisedButton"); + disabled.setEnabled(false); + form.add(disabled); + + Label footer = new Label("Magenta override active in both appearances"); + footer.setUIID("SecondaryLabel"); + form.add(footer); + } + + /** + * Adds a palette-override layer on top of the installed native + * theme. Uses {@link UIManager#addThemeProps} so the native theme + * stays resident underneath - the override table only has to + * redeclare the handful of keys it wants to change, plus the + * matching {@code $Dark} keys so the override applies in dark + * mode too. + */ + private void installPaletteOverride() { + Hashtable override = new Hashtable(); + override.put("RaisedButton.bgColor", OVERRIDE_ACCENT); + override.put("RaisedButton.fgColor", OVERRIDE_ACCENT_TEXT); + override.put("RaisedButton.press#bgColor", OVERRIDE_ACCENT_PRESSED); + override.put("RaisedButton.press#fgColor", OVERRIDE_ACCENT_TEXT); + override.put("Button.fgColor", OVERRIDE_ACCENT); + override.put("Button.press#fgColor", OVERRIDE_ACCENT_PRESSED); + // Dark override mirrors the light override so the magenta + // applies across both appearances. A real user theme would + // probably choose two variants; this test keeps them identical + // for easy visual confirmation. + override.put("$DarkRaisedButton.bgColor", OVERRIDE_ACCENT); + override.put("$DarkRaisedButton.fgColor", OVERRIDE_ACCENT_TEXT); + override.put("$DarkRaisedButton.press#bgColor", OVERRIDE_ACCENT_PRESSED); + override.put("$DarkRaisedButton.press#fgColor", OVERRIDE_ACCENT_TEXT); + override.put("$DarkButton.fgColor", OVERRIDE_ACCENT); + override.put("$DarkButton.press#fgColor", OVERRIDE_ACCENT_PRESSED); + UIManager.getInstance().addThemeProps(override); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PickerThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PickerThemeScreenshotTest.java new file mode 100644 index 0000000000..9da5e261ea --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/PickerThemeScreenshotTest.java @@ -0,0 +1,35 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.spinner.Picker; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +public class PickerThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "PickerTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + form.add(new Label("String picker")); + Picker stringPicker = new Picker(); + stringPicker.setStrings("Red", "Green", "Blue", "Yellow", "Purple"); + stringPicker.setSelectedString("Green"); + form.add(stringPicker); + + form.add(new Label("Disabled picker")); + Picker disabled = new Picker(); + disabled.setStrings("Option 1", "Option 2"); + disabled.setSelectedString("Option 1"); + disabled.setEnabled(false); + form.add(disabled); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SpanLabelThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SpanLabelThemeScreenshotTest.java new file mode 100644 index 0000000000..a1d8fb9a54 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SpanLabelThemeScreenshotTest.java @@ -0,0 +1,42 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.SpanLabel; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +public class SpanLabelThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "SpanLabelTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + form.add(new Label("Single-line Label for reference")); + + SpanLabel shortSpan = new SpanLabel( + "Short SpanLabel text that fits on one or two lines depending " + + "on width."); + form.add(shortSpan); + + SpanLabel longSpan = new SpanLabel( + "Longer SpanLabel paragraph. SpanLabel wraps across lines " + + "using the current theme's font settings. This lets us " + + "verify that paragraph text spacing, line height, color, " + + "and contrast all render correctly in both light and dark " + + "appearances."); + form.add(longSpan); + + SpanLabel secondary = new SpanLabel( + "Secondary caption styled via the SecondaryLabel UIID."); + secondary.setUIID("SecondaryLabel"); + form.add(secondary); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SwitchThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SwitchThemeScreenshotTest.java new file mode 100644 index 0000000000..16856ec924 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/SwitchThemeScreenshotTest.java @@ -0,0 +1,39 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.components.Switch; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +public class SwitchThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "SwitchTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + form.add(new Label("Switch off")); + Switch off = new Switch(); + off.setValue(false); + form.add(off); + annotateComponent(off, "Switch track: pill + 1.4x thumb scale (iOS) / 1.5x (Material)"); + + form.add(new Label("Switch on")); + Switch on = new Switch(); + on.setValue(true); + form.add(on); + + form.add(new Label("Disabled switch")); + Switch disabled = new Switch(); + disabled.setValue(true); + disabled.setEnabled(false); + form.add(disabled); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsThemeScreenshotTest.java new file mode 100644 index 0000000000..25eb617c41 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsThemeScreenshotTest.java @@ -0,0 +1,48 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Tabs; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.Layout; + +public class TabsThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "TabsTheme"; + } + + @Override + protected Layout newLayout() { + return new BorderLayout(); + } + + @Override + protected boolean useTexturedBackdrop() { + // iOS liquid-glass Tabs group approximates frosted-glass with a + // semi-opaque fill; paint against a colourful backdrop so the + // translucency (once real backdrop-filter lands) is visible. + return true; + } + + @Override + protected void populate(Form form, String suffix) { + Tabs tabs = new Tabs(); + Container first = new Container(new BorderLayout()); + first.add(BorderLayout.CENTER, new Label("First tab content")); + Container second = new Container(new BorderLayout()); + second.add(BorderLayout.CENTER, new Button("Second tab button")); + Container third = new Container(new BorderLayout()); + third.add(BorderLayout.CENTER, new Label("Third tab content")); + + tabs.addTab("Home", FontImage.MATERIAL_HOME, 8, first); + tabs.addTab("Search", FontImage.MATERIAL_SEARCH, 8, second); + tabs.addTab("Info", FontImage.MATERIAL_INFO, 8, third); + tabs.setSelectedIndex(0); + + form.add(BorderLayout.CENTER, tabs); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TextFieldThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TextFieldThemeScreenshotTest.java new file mode 100644 index 0000000000..1c848ccc66 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TextFieldThemeScreenshotTest.java @@ -0,0 +1,41 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +public class TextFieldThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "TextFieldTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + TextField filled = new TextField("Hello theme"); + form.add(new Label("TextField")); + form.add(filled); + + TextField empty = new TextField(); + empty.setHint("Type here"); + form.add(new Label("TextField (hint)")); + form.add(empty); + + TextField disabled = new TextField("Disabled"); + disabled.setEnabled(false); + form.add(new Label("TextField disabled")); + form.add(disabled); + + TextArea area = new TextArea("Multi-line text\nacross several lines.", 3, 20); + form.add(new Label("TextArea")); + form.add(area); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ToolbarThemeScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ToolbarThemeScreenshotTest.java new file mode 100644 index 0000000000..61b4657c1b --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/ToolbarThemeScreenshotTest.java @@ -0,0 +1,43 @@ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Command; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Toolbar; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.Layout; + +public class ToolbarThemeScreenshotTest extends DualAppearanceBaseTest { + @Override + protected String baseName() { + return "ToolbarTheme"; + } + + @Override + protected Layout newLayout() { + return BoxLayout.y(); + } + + @Override + protected void populate(Form form, String suffix) { + Toolbar tb = form.getToolbar(); + if (tb == null) { + tb = new Toolbar(); + form.setToolbar(tb); + } + tb.setTitle("Theme Gallery"); + tb.addMaterialCommandToLeftBar("Menu", FontImage.MATERIAL_MENU, + (ActionEvent e) -> { /* no-op */ }); + tb.addMaterialCommandToRightBar("Search", FontImage.MATERIAL_SEARCH, + (ActionEvent e) -> { /* no-op */ }); + Command moreCmd = new Command("More") { + public void actionPerformed(ActionEvent evt) { + } + }; + tb.addCommandToOverflowMenu(moreCmd); + + form.add(new Label("Body content under the Toolbar.")); + } +} diff --git a/scripts/initializr/common/codenameone_settings.properties b/scripts/initializr/common/codenameone_settings.properties index d328af1547..3156747c7b 100644 --- a/scripts/initializr/common/codenameone_settings.properties +++ b/scripts/initializr/common/codenameone_settings.properties @@ -2,6 +2,12 @@ codename1.android.keystore= codename1.android.keystoreAlias= codename1.android.keystorePassword= codename1.arg.ios.newStorageLocation=true +# Opt new projects into the iOS Modern (liquid-glass) and Android +# Material 3 themes by default. See docs/developer-guide +# Native-Themes for the values these hints accept. +codename1.arg.ios.themeMode=modern +codename1.arg.cn1.androidTheme=material +codename1.arg.cn1.nativeTheme=modern codename1.arg.java.version=8 codename1.displayName=Initializr codename1.icon=icon.png diff --git a/scripts/initializr/common/pom.xml b/scripts/initializr/common/pom.xml index 115e183e0d..34167c9ded 100644 --- a/scripts/initializr/common/pom.xml +++ b/scripts/initializr/common/pom.xml @@ -373,6 +373,38 @@ + + + org.apache.maven.plugins + maven-antrun-plugin + + + copy-native-themes + process-resources + + run + + + + + + + + + + + + + + + diff --git a/scripts/initializr/common/src/main/resources/common.zip b/scripts/initializr/common/src/main/resources/common.zip index f6da65b787..5f35bf517a 100644 Binary files a/scripts/initializr/common/src/main/resources/common.zip and b/scripts/initializr/common/src/main/resources/common.zip differ diff --git a/scripts/ios/screenshots/ButtonTheme_dark.png b/scripts/ios/screenshots/ButtonTheme_dark.png new file mode 100644 index 0000000000..1417cd2900 Binary files /dev/null and b/scripts/ios/screenshots/ButtonTheme_dark.png differ diff --git a/scripts/ios/screenshots/ButtonTheme_light.png b/scripts/ios/screenshots/ButtonTheme_light.png new file mode 100644 index 0000000000..d6ca83b13d Binary files /dev/null and b/scripts/ios/screenshots/ButtonTheme_light.png differ diff --git a/scripts/ios/screenshots/CheckBoxRadioTheme_dark.png b/scripts/ios/screenshots/CheckBoxRadioTheme_dark.png new file mode 100644 index 0000000000..77f7fe78f8 Binary files /dev/null and b/scripts/ios/screenshots/CheckBoxRadioTheme_dark.png differ diff --git a/scripts/ios/screenshots/CheckBoxRadioTheme_light.png b/scripts/ios/screenshots/CheckBoxRadioTheme_light.png new file mode 100644 index 0000000000..410f96ffe1 Binary files /dev/null and b/scripts/ios/screenshots/CheckBoxRadioTheme_light.png differ diff --git a/scripts/ios/screenshots/DialogTheme_dark.png b/scripts/ios/screenshots/DialogTheme_dark.png new file mode 100644 index 0000000000..34fc2e4832 Binary files /dev/null and b/scripts/ios/screenshots/DialogTheme_dark.png differ diff --git a/scripts/ios/screenshots/DialogTheme_light.png b/scripts/ios/screenshots/DialogTheme_light.png new file mode 100644 index 0000000000..d013dfac26 Binary files /dev/null and b/scripts/ios/screenshots/DialogTheme_light.png differ diff --git a/scripts/ios/screenshots/FloatingActionButtonTheme_dark.png b/scripts/ios/screenshots/FloatingActionButtonTheme_dark.png new file mode 100644 index 0000000000..9e98449c37 Binary files /dev/null and b/scripts/ios/screenshots/FloatingActionButtonTheme_dark.png differ diff --git a/scripts/ios/screenshots/FloatingActionButtonTheme_light.png b/scripts/ios/screenshots/FloatingActionButtonTheme_light.png new file mode 100644 index 0000000000..2b2a88cb74 Binary files /dev/null and b/scripts/ios/screenshots/FloatingActionButtonTheme_light.png differ diff --git a/scripts/ios/screenshots/ListTheme_dark.png b/scripts/ios/screenshots/ListTheme_dark.png new file mode 100644 index 0000000000..3e5a4556bf Binary files /dev/null and b/scripts/ios/screenshots/ListTheme_dark.png differ diff --git a/scripts/ios/screenshots/ListTheme_light.png b/scripts/ios/screenshots/ListTheme_light.png new file mode 100644 index 0000000000..e0b67cdb4d Binary files /dev/null and b/scripts/ios/screenshots/ListTheme_light.png differ diff --git a/scripts/ios/screenshots/MultiButtonTheme_dark.png b/scripts/ios/screenshots/MultiButtonTheme_dark.png new file mode 100644 index 0000000000..0d2f923f33 Binary files /dev/null and b/scripts/ios/screenshots/MultiButtonTheme_dark.png differ diff --git a/scripts/ios/screenshots/MultiButtonTheme_light.png b/scripts/ios/screenshots/MultiButtonTheme_light.png new file mode 100644 index 0000000000..64150a7649 Binary files /dev/null and b/scripts/ios/screenshots/MultiButtonTheme_light.png differ diff --git a/scripts/ios/screenshots/PaletteOverrideTheme_dark.png b/scripts/ios/screenshots/PaletteOverrideTheme_dark.png new file mode 100644 index 0000000000..694eb9550d Binary files /dev/null and b/scripts/ios/screenshots/PaletteOverrideTheme_dark.png differ diff --git a/scripts/ios/screenshots/PaletteOverrideTheme_light.png b/scripts/ios/screenshots/PaletteOverrideTheme_light.png new file mode 100644 index 0000000000..336bf90dc5 Binary files /dev/null and b/scripts/ios/screenshots/PaletteOverrideTheme_light.png differ diff --git a/scripts/ios/screenshots/PickerTheme_dark.png b/scripts/ios/screenshots/PickerTheme_dark.png new file mode 100644 index 0000000000..10981661ff Binary files /dev/null and b/scripts/ios/screenshots/PickerTheme_dark.png differ diff --git a/scripts/ios/screenshots/PickerTheme_light.png b/scripts/ios/screenshots/PickerTheme_light.png new file mode 100644 index 0000000000..16d26bbb45 Binary files /dev/null and b/scripts/ios/screenshots/PickerTheme_light.png differ diff --git a/scripts/ios/screenshots/ShowcaseTheme_dark.png b/scripts/ios/screenshots/ShowcaseTheme_dark.png new file mode 100644 index 0000000000..09281c8030 Binary files /dev/null and b/scripts/ios/screenshots/ShowcaseTheme_dark.png differ diff --git a/scripts/ios/screenshots/ShowcaseTheme_light.png b/scripts/ios/screenshots/ShowcaseTheme_light.png new file mode 100644 index 0000000000..966a77e69e Binary files /dev/null and b/scripts/ios/screenshots/ShowcaseTheme_light.png differ diff --git a/scripts/ios/screenshots/SpanLabelTheme_dark.png b/scripts/ios/screenshots/SpanLabelTheme_dark.png new file mode 100644 index 0000000000..5ab62a7188 Binary files /dev/null and b/scripts/ios/screenshots/SpanLabelTheme_dark.png differ diff --git a/scripts/ios/screenshots/SpanLabelTheme_light.png b/scripts/ios/screenshots/SpanLabelTheme_light.png new file mode 100644 index 0000000000..97c978d9bd Binary files /dev/null and b/scripts/ios/screenshots/SpanLabelTheme_light.png differ diff --git a/scripts/ios/screenshots/SwitchTheme_dark.png b/scripts/ios/screenshots/SwitchTheme_dark.png new file mode 100644 index 0000000000..695e515368 Binary files /dev/null and b/scripts/ios/screenshots/SwitchTheme_dark.png differ diff --git a/scripts/ios/screenshots/SwitchTheme_light.png b/scripts/ios/screenshots/SwitchTheme_light.png new file mode 100644 index 0000000000..eeaa98f24a Binary files /dev/null and b/scripts/ios/screenshots/SwitchTheme_light.png differ diff --git a/scripts/ios/screenshots/TabsTheme_dark.png b/scripts/ios/screenshots/TabsTheme_dark.png new file mode 100644 index 0000000000..da7f9a9da6 Binary files /dev/null and b/scripts/ios/screenshots/TabsTheme_dark.png differ diff --git a/scripts/ios/screenshots/TabsTheme_light.png b/scripts/ios/screenshots/TabsTheme_light.png new file mode 100644 index 0000000000..67420515b2 Binary files /dev/null and b/scripts/ios/screenshots/TabsTheme_light.png differ diff --git a/scripts/ios/screenshots/TextFieldTheme_dark.png b/scripts/ios/screenshots/TextFieldTheme_dark.png new file mode 100644 index 0000000000..8043312883 Binary files /dev/null and b/scripts/ios/screenshots/TextFieldTheme_dark.png differ diff --git a/scripts/ios/screenshots/TextFieldTheme_light.png b/scripts/ios/screenshots/TextFieldTheme_light.png new file mode 100644 index 0000000000..dd5ac799a9 Binary files /dev/null and b/scripts/ios/screenshots/TextFieldTheme_light.png differ diff --git a/scripts/ios/screenshots/ToolbarTheme_dark.png b/scripts/ios/screenshots/ToolbarTheme_dark.png new file mode 100644 index 0000000000..5b353783ff Binary files /dev/null and b/scripts/ios/screenshots/ToolbarTheme_dark.png differ diff --git a/scripts/ios/screenshots/ToolbarTheme_light.png b/scripts/ios/screenshots/ToolbarTheme_light.png new file mode 100644 index 0000000000..bbd7dee754 Binary files /dev/null and b/scripts/ios/screenshots/ToolbarTheme_light.png differ diff --git a/scripts/website/build.sh b/scripts/website/build.sh index f511a81d55..e3fc8998c7 100755 --- a/scripts/website/build.sh +++ b/scripts/website/build.sh @@ -35,6 +35,15 @@ if [ "${WEBSITE_INCLUDE_INITIALIZR}" = "auto" ]; then fi fi +ensure_native_themes() { + if [ -f "${REPO_ROOT}/Themes/iOSModernTheme.res" ] \ + && [ -f "${REPO_ROOT}/Themes/AndroidMaterialTheme.res" ]; then + return + fi + echo "Generating native theme .res files via build-native-themes.sh..." >&2 + bash "${REPO_ROOT}/scripts/build-native-themes.sh" +} + bootstrap_local_cn1_snapshots() { if [ "${WEBSITE_BOOTSTRAP_CN1_SNAPSHOTS}" != "true" ]; then return @@ -557,6 +566,12 @@ build_initializr_for_site() { return fi + # The initializr's live preview overlays the iOS Modern theme, which is + # bundled from the gitignored Themes/iOSModernTheme.res. Generate it now + # so the antrun copy in scripts/initializr/common/pom.xml has something + # to pick up. + ensure_native_themes + echo "Building Initializr JavaScript bundle for website..." >&2 ( cd "${REPO_ROOT}/scripts/initializr" @@ -618,6 +633,12 @@ build_playground_for_site() { bootstrap_local_cn1_snapshots + # The playground's live preview switches between iOS Modern and Android + # Material via Resources.openLayered. Both .res files are gitignored + # build artifacts; generate them now so the antrun copy in + # scripts/cn1playground/common/pom.xml has something to pick up. + ensure_native_themes + echo "Building Playground JavaScript bundle for website..." >&2 ( cd "${REPO_ROOT}/scripts/cn1playground"