diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 7a5065bd83..5756f3a8c9 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -3,6 +3,7 @@ */ package com.codename1.ui.css; +import com.codename1.ui.Component; import com.codename1.ui.EncodedImage; import com.codename1.ui.Image; import com.codename1.ui.plaf.CSSBorder; @@ -40,6 +41,16 @@ /// - `var(--name)` dereferencing in declaration values. public class CSSThemeCompiler { + public static class CSSSyntaxException extends IllegalArgumentException { + public CSSSyntaxException(String message) { + super(message); + } + + public CSSSyntaxException(String message, Throwable cause) { + super(message, cause); + } + } + public void compile(String css, MutableResource resources, String themeName) { Hashtable theme = resources.getTheme(themeName); if (theme == null) { @@ -81,7 +92,7 @@ private void compileConstants(String css, Hashtable theme) { } int close = stripped.indexOf('}', open + 1); if (close <= open) { - return; + throw new CSSSyntaxException("Unterminated @constants block"); } Declaration[] declarations = parseDeclarations(stripped.substring(open + 1, close)); for (Declaration declaration : declarations) { @@ -166,6 +177,11 @@ private boolean applySimpleThemeProperty(Hashtable theme, String uiid, String st theme.put(uiid + "." + statePrefix + "font", value); return true; } + if ("text-align".equals(property)) { + Integer align = normalizeAlignment(value); + theme.put(uiid + "." + statePrefix + "align", align); + return true; + } return false; } @@ -196,6 +212,17 @@ private boolean appendBorderProperty(StringBuilder borderCss, String property, S if (!isBorderProperty(property)) { return false; } + if ("border".equals(property)) { + String expanded = expandBorderShorthand(value); + if (expanded.length() == 0) { + return true; + } + if (borderCss.length() > 0) { + borderCss.append(';'); + } + borderCss.append(expanded); + return true; + } if (borderCss.length() > 0) { borderCss.append(';'); } @@ -203,6 +230,53 @@ private boolean appendBorderProperty(StringBuilder borderCss, String property, S return true; } + private String expandBorderShorthand(String value) { + String[] parts = splitOnWhitespace(value); + if (parts.length == 0) { + throw new CSSSyntaxException("border shorthand is missing value"); + } + String width = null; + String style = null; + String color = null; + for (String part : parts) { + String token = part.trim().toLowerCase(); + if (token.length() == 0) { + continue; + } + if (width == null && (token.endsWith("px") || token.endsWith("mm") || token.endsWith("pt") || token.endsWith("%") || "0".equals(token))) { + width = part; + continue; + } + if (style == null && ("none".equals(token) || "solid".equals(token) || "dashed".equals(token) || "dotted".equals(token))) { + style = token; + continue; + } + if (color == null) { + normalizeHexColor(part); + color = part; + continue; + } + throw new CSSSyntaxException("Unsupported border shorthand token: " + part); + } + StringBuilder out = new StringBuilder(); + if (width != null) { + out.append("border-width:").append(width); + } + if (style != null) { + if (out.length() > 0) { + out.append(';'); + } + out.append("border-style:").append(style); + } + if (color != null) { + if (out.length() > 0) { + out.append(';'); + } + out.append("border-color:").append(color); + } + return out.toString(); + } + private String resolveVars(Hashtable theme, String value) { String out = value; int varPos = out.indexOf("var(--"); @@ -223,10 +297,21 @@ private String resolveVars(Hashtable theme, String value) { private String[] selector(String selector) { String statePrefix = ""; String uiid = selector.trim(); + int pseudoPos = uiid.indexOf(':'); - if (pseudoPos > -1) { - String pseudo = uiid.substring(pseudoPos + 1).trim(); - uiid = uiid.substring(0, pseudoPos).trim(); + int classStatePos = uiid.indexOf('.'); + int statePos = -1; + if (pseudoPos > -1 && classStatePos > -1) { + statePos = Math.min(pseudoPos, classStatePos); + } else if (pseudoPos > -1) { + statePos = pseudoPos; + } else if (classStatePos > -1) { + statePos = classStatePos; + } + + if (statePos > -1) { + String pseudo = uiid.substring(statePos + 1).trim(); + uiid = uiid.substring(0, statePos).trim(); statePrefix = statePrefix(pseudo); } if ("*".equals(uiid) || uiid.length() == 0) { @@ -245,7 +330,7 @@ private String statePrefix(String pseudo) { if ("disabled".equals(pseudo)) { return "dis#"; } - return ""; + throw new CSSSyntaxException("Unsupported pseudo state: " + pseudo); } private Image createSolidImage(String color) { @@ -267,10 +352,33 @@ private boolean isBorderProperty(String property) { } private String normalizeHexColor(String cssColor) { - String value = cssColor.trim(); - if ("transparent".equalsIgnoreCase(value)) { + String value = cssColor == null ? "" : cssColor.trim().toLowerCase(); + if (value.length() == 0) { + throw new CSSSyntaxException("Color value cannot be empty"); + } + if ("transparent".equals(value)) { return "000000"; } + + if (value.startsWith("rgb(")) { + if (!value.endsWith(")")) { + throw new CSSSyntaxException("Malformed rgb() color: " + cssColor); + } + String[] parts = splitOnComma(value.substring(4, value.length() - 1)); + if (parts.length != 3) { + throw new CSSSyntaxException("rgb() must have exactly 3 components: " + cssColor); + } + int r = parseRgbChannel(parts[0], cssColor); + int g = parseRgbChannel(parts[1], cssColor); + int b = parseRgbChannel(parts[2], cssColor); + return toHexColor((r << 16) | (g << 8) | b); + } + + String keyword = cssColorKeyword(value); + if (keyword != null) { + return keyword; + } + if (value.startsWith("#")) { value = value.substring(1); } @@ -279,7 +387,92 @@ private String normalizeHexColor(String cssColor) { + value.charAt(1) + value.charAt(1) + value.charAt(2) + value.charAt(2); } - return value.toLowerCase(); + if (value.length() != 6 || !isHexColor(value)) { + throw new CSSSyntaxException("Unsupported color value: " + cssColor); + } + return value; + } + + private Integer normalizeAlignment(String value) { + String v = value == null ? "" : value.trim().toLowerCase(); + if ("left".equals(v) || "start".equals(v)) { + return Integer.valueOf(Component.LEFT); + } + if ("center".equals(v)) { + return Integer.valueOf(Component.CENTER); + } + if ("right".equals(v) || "end".equals(v)) { + return Integer.valueOf(Component.RIGHT); + } + throw new CSSSyntaxException("Unsupported text-align value: " + value); + } + + private String cssColorKeyword(String value) { + if ("black".equals(value)) { + return "000000"; + } + if ("white".equals(value)) { + return "ffffff"; + } + if ("red".equals(value)) { + return "ff0000"; + } + if ("green".equals(value)) { + return "008000"; + } + if ("blue".equals(value)) { + return "0000ff"; + } + if ("pink".equals(value)) { + return "ffc0cb"; + } + if ("orange".equals(value)) { + return "ffa500"; + } + if ("yellow".equals(value)) { + return "ffff00"; + } + if ("purple".equals(value)) { + return "800080"; + } + if ("gray".equals(value) || "grey".equals(value)) { + return "808080"; + } + return null; + } + + private int parseRgbChannel(String value, String originalColor) { + int out; + try { + out = Integer.parseInt(value.trim()); + } catch (RuntimeException err) { + throw new CSSSyntaxException("Invalid rgb() channel value in " + originalColor + ": " + value, err); + } + if (out < 0 || out > 255) { + throw new CSSSyntaxException("rgb() channel out of range in " + originalColor + ": " + value); + } + return out; + } + + private boolean isHexColor(String value) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean hex = (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + } + return true; + } + + private String toHexColor(int color) { + String hex = Integer.toHexString(color & 0xffffff); + while (hex.length() < 6) { + hex = "0" + hex; + } + return hex; } private String normalizeBox(String cssValue) { @@ -312,13 +505,22 @@ private Rule[] parseRules(String css) { ArrayList out = new ArrayList(); int pos = 0; while (pos < stripped.length()) { + while (pos < stripped.length() && Character.isWhitespace(stripped.charAt(pos))) { + pos++; + } + if (pos >= stripped.length()) { + break; + } int open = stripped.indexOf('{', pos); if (open < 0) { - break; + throw new CSSSyntaxException("Missing '{' in CSS rule near: " + stripped.substring(pos)); } int close = stripped.indexOf('}', open + 1); if (close < 0) { - break; + throw new CSSSyntaxException("Missing '}' for CSS rule: " + stripped.substring(pos, open).trim()); + } + if (stripped.indexOf('{', open + 1) > -1 && stripped.indexOf('{', open + 1) < close) { + throw new CSSSyntaxException("Nested '{' is not supported in CSS block: " + stripped.substring(pos, open).trim()); } String selectors = stripped.substring(pos, open).trim(); @@ -326,6 +528,9 @@ private Rule[] parseRules(String css) { pos = close + 1; continue; } + if (selectors.length() == 0) { + throw new CSSSyntaxException("Missing selector before '{'"); + } String body = stripped.substring(open + 1, close).trim(); Declaration[] declarations = parseDeclarations(body); @@ -333,7 +538,7 @@ private Rule[] parseRules(String css) { for (String selectorEntry : selectorsList) { String selector = selectorEntry.trim(); if (selector.length() == 0) { - continue; + throw new CSSSyntaxException("Empty selector in selector list: " + selectors); } Rule rule = new Rule(); rule.selector = selector; @@ -353,13 +558,18 @@ private String stripComments(String css) { char c = css.charAt(i); if (c == '/' && i + 1 < css.length() && css.charAt(i + 1) == '*') { i += 2; + boolean closed = false; while (i + 1 < css.length()) { if (css.charAt(i) == '*' && css.charAt(i + 1) == '/') { i += 2; + closed = true; break; } i++; } + if (!closed) { + throw new CSSSyntaxException("Unterminated CSS comment"); + } continue; } out.append(c); @@ -372,13 +582,20 @@ private Declaration[] parseDeclarations(String body) { ArrayList out = new ArrayList(); String[] segments = splitOnChar(body, ';'); for (String line : segments) { - int colon = line.indexOf(':'); - if (colon <= 0) { + String trimmed = line.trim(); + if (trimmed.length() == 0) { continue; } + int colon = trimmed.indexOf(':'); + if (colon <= 0 || colon == trimmed.length() - 1) { + throw new CSSSyntaxException("Malformed declaration: " + trimmed); + } Declaration dec = new Declaration(); - dec.property = line.substring(0, colon).trim().toLowerCase(); - dec.value = line.substring(colon + 1).trim(); + dec.property = trimmed.substring(0, colon).trim().toLowerCase(); + dec.value = trimmed.substring(colon + 1).trim(); + if (dec.property.length() == 0 || dec.value.length() == 0) { + throw new CSSSyntaxException("Malformed declaration: " + trimmed); + } out.add(dec); } return out.toArray(new Declaration[out.size()]); @@ -398,6 +615,25 @@ private String[] splitOnChar(String input, char delimiter) { return out.toArray(new String[out.size()]); } + private String[] splitOnComma(String input) { + ArrayList parts = new ArrayList(); + int start = 0; + for (int i = 0; i < input.length(); i++) { + if (input.charAt(i) == ',') { + String token = input.substring(start, i).trim(); + if (token.length() > 0) { + parts.add(token); + } + start = i + 1; + } + } + String tail = input.substring(start).trim(); + if (tail.length() > 0) { + parts.add(tail); + } + return parts.toArray(new String[parts.size()]); + } + private String[] splitOnWhitespace(String input) { ArrayList out = new ArrayList(); StringBuilder token = new StringBuilder(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java index b49b05cd94..f06d92ee1e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -1,8 +1,11 @@ package com.codename1.ui.css; import com.codename1.junit.UITestBase; +import com.codename1.ui.Button; +import com.codename1.ui.Component; import com.codename1.ui.Image; import com.codename1.ui.plaf.CSSBorder; +import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.MutableResource; import java.util.Hashtable; import org.junit.jupiter.api.Test; @@ -10,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; public class CSSThemeCompilerTest extends UITestBase { @@ -23,13 +27,15 @@ public void testCompilesThemeConstantsDeriveAndMutableImages() { + "@constants{spacing: 4px; primaryColor: var(--primary);}" + "Button{color:var(--primary);background-color:#112233;padding:1px 2px;cn1-derive:Label;}" + "Button:pressed{border-width:2px;border-style:solid;border-color:#ffffff;cn1-mutable-image:btnBg #ff00ff;}" - + "Label{margin:2px 4px 6px 8px;}", + + "Label{margin:2px 4px 6px 8px;}" + + "Button{color:pink;text-align:center;border:1px solid #00ff00;}" + + "Button.pressed{color:#00ff00;}", resource, "Theme" ); Hashtable theme = resource.getTheme("Theme"); - assertEquals("aabbcc", theme.get("Button.fgColor")); + assertEquals("ffc0cb", theme.get("Button.fgColor")); assertEquals("112233", theme.get("Button.bgColor")); assertEquals("255", theme.get("Button.transparency")); assertEquals("1,2,1,2", theme.get("Button.padding")); @@ -38,10 +44,35 @@ public void testCompilesThemeConstantsDeriveAndMutableImages() { assertEquals("#abc", theme.get("@primary")); assertEquals("4px", theme.get("@spacing")); assertEquals("#abc", theme.get("@primarycolor")); - assertTrue(theme.get("Button.press#border") instanceof CSSBorder); + assertEquals(Integer.valueOf(Component.CENTER), theme.get("Button.align")); + assertTrue(theme.get("Button.border") instanceof CSSBorder); + assertEquals("00ff00", theme.get("Button.press#fgColor")); + + UIManager.getInstance().addThemeProps(theme); + Button runtimeButton = new Button("Runtime"); + runtimeButton.setUIID("Button"); + assertEquals(0xffc0cb, runtimeButton.getUnselectedStyle().getFgColor()); + assertEquals(Component.CENTER, runtimeButton.getUnselectedStyle().getAlignment()); + assertNotNull(runtimeButton.getUnselectedStyle().getBorder()); Image mutable = resource.getImage("btnBg"); assertNotNull(mutable); assertNotNull(theme.get("Button.press#bgImage")); } + @Test + public void testThrowsOnMalformedCss() { + CSSThemeCompiler compiler = new CSSThemeCompiler(); + MutableResource resource = new MutableResource(); + + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Button{color:#12;}", resource, "Theme") + ); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Button{color:#ff00ff;text-align:middle;}", resource, "Theme") + ); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Button:hover{color:#ff00ff;}", resource, "Theme") + ); + } + } diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java index 9c342572c8..1d50691909 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/Initializr.java @@ -23,6 +23,7 @@ import com.codename1.ui.Form; import com.codename1.ui.Label; import com.codename1.ui.RadioButton; +import com.codename1.ui.TextArea; import com.codename1.ui.TextField; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; @@ -51,10 +52,13 @@ public void runApp() { final boolean[] includeLocalizationBundles = new boolean[]{false}; final ProjectOptions.PreviewLanguage[] previewLanguage = new ProjectOptions.PreviewLanguage[]{ProjectOptions.PreviewLanguage.ENGLISH}; final ProjectOptions.JavaVersion[] javaVersion = new ProjectOptions.JavaVersion[]{ProjectOptions.JavaVersion.JAVA_8}; + final String[] customThemeCss = new String[]{""}; final RadioButton[] templateButtons = new RadioButton[Template.values().length]; final SpanLabel summaryLabel = new SpanLabel(); final TemplatePreviewPanel previewPanel = new TemplatePreviewPanel(selectedTemplate[0]); final Container[] themePanelRef = new Container[1]; + final Label customCssError = new Label(""); + final boolean[] customCssValid = new boolean[]{true}; appNameField.setUIID("InitializrField"); packageField.setUIID("InitializrField"); @@ -65,15 +69,30 @@ public void runApp() { packageError.setHidden(true); packageError.setVisible(false); summaryLabel.setUIID("InitializrSummary"); + customCssError.setUIID("InitializrValidationError"); + customCssError.setHidden(true); + customCssError.setVisible(false); final Runnable refresh = new Runnable() { public void run() { ProjectOptions options = new ProjectOptions( selectedThemeMode[0], selectedAccent[0], roundedButtons[0], - includeLocalizationBundles[0], previewLanguage[0], javaVersion[0] + includeLocalizationBundles[0], previewLanguage[0], javaVersion[0], + customThemeCss[0] ); - previewPanel.setTemplate(selectedTemplate[0]); - previewPanel.setOptions(options); + customCssValid[0] = true; + customCssError.setText(""); + customCssError.setHidden(true); + customCssError.setVisible(false); + try { + previewPanel.setTemplate(selectedTemplate[0]); + previewPanel.setOptions(options); + } catch (IllegalArgumentException cssErr) { + customCssValid[0] = false; + customCssError.setText("Custom CSS Error: " + cssErr.getMessage()); + customCssError.setHidden(false); + customCssError.setVisible(true); + } boolean canCustomizeTheme = supportsLivePreview(selectedTemplate[0]); if (themePanelRef[0] != null) { setEnabledRecursive(themePanelRef[0], canCustomizeTheme); @@ -98,7 +117,7 @@ public void run() { createTemplateSelector(selectedTemplate, templateButtons, refresh) ); final Container idePanel = createIdeSelectorPanel(selectedIde, refresh); - final Container themePanel = createThemeOptionsPanel(selectedThemeMode, selectedAccent, roundedButtons, refresh); + final Container themePanel = createThemeOptionsPanel(selectedThemeMode, selectedAccent, roundedButtons, customThemeCss, customCssError, refresh); final Container localizationPanel = createLocalizationPanel(includeLocalizationBundles, previewLanguage, refresh, previewPanel); final Container javaPanel = createJavaOptionsPanel(javaVersion, refresh); themePanelRef[0] = themePanel; @@ -126,9 +145,9 @@ public void run() { generateButton.setUIID("InitializrPrimaryButton"); generateButton.addActionListener(e -> { - if (!validateInputs(appNameField, packageField)) { + if (!validateInputs(appNameField, packageField) || !customCssValid[0]) { updateValidationErrorLabels(appNameField, packageField, appNameError, packageError); - ToastBar.showErrorMessage("Please fix validation errors before generating."); + ToastBar.showErrorMessage(customCssValid[0] ? "Please fix validation errors before generating." : "Please fix custom CSS errors before generating."); form.revalidate(); return; } @@ -136,7 +155,8 @@ public void run() { String packageName = packageField.getText() == null ? "" : packageField.getText().trim(); ProjectOptions options = new ProjectOptions( selectedThemeMode[0], selectedAccent[0], roundedButtons[0], - includeLocalizationBundles[0], previewLanguage[0], javaVersion[0] + includeLocalizationBundles[0], previewLanguage[0], javaVersion[0], + customThemeCss[0] ); GeneratorModel.create(selectedIde[0], selectedTemplate[0], appName, packageName, options).generate(); }); @@ -190,7 +210,6 @@ private Container createLocalizationPanel(boolean[] includeLocalizationBundles, String selected = languagePicker.getSelectedString(); previewLanguage[0] = findLanguageByLabel(selected); onSelectionChanged.run(); - previewPanel.showUpdatedLivePreview(); }); return BoxLayout.encloseY(includeBundles, labeledField("Preview Language", languagePicker)); @@ -261,6 +280,8 @@ private Container createIdeSelectorPanel(IDE[] selectedIde, Runnable onSelection private Container createThemeOptionsPanel(ProjectOptions.ThemeMode[] selectedThemeMode, ProjectOptions.Accent[] selectedAccent, boolean[] roundedButtons, + String[] customThemeCss, + Label customCssError, Runnable onSelectionChanged) { Container modeRow = new Container(new GridLayout(1, 2)); modeRow.setUIID("InitializrChoicesGrid"); @@ -310,10 +331,22 @@ private Container createThemeOptionsPanel(ProjectOptions.ThemeMode[] selectedThe onSelectionChanged.run(); }); + TextArea cssEditor = new TextArea(customThemeCss[0], 8, 30); + cssEditor.setName("appendCustomCssEditor"); + cssEditor.setUIID("InitializrField"); + cssEditor.setHint("/* Appended to generated theme.css */\nButton {\n border-radius: 0;\n}"); + cssEditor.setGrowByContent(true); + cssEditor.addDataChangedListener((type, index) -> { + customThemeCss[0] = cssEditor.getText(); + onSelectionChanged.run(); + }); + return BoxLayout.encloseY( labeledField("Mode", modeRow), labeledField("Accent", accentRow), - rounded + rounded, + labeledField("Append Custom CSS", cssEditor), + customCssError ); } @@ -538,6 +571,7 @@ private String createSummary(String appName, String packageName, Template templa + "Localization Bundles: " + (options.includeLocalizationBundles ? "Yes" : "No") + "\n" + "Preview Language: " + options.previewLanguage.label + "\n" + "Java: " + options.javaVersion.label + "\n" + + "Append Custom CSS: " + (options.customThemeCss == null || options.customThemeCss.trim().length() == 0 ? "No" : "Yes") + "\n" + "Kotlin: " + (template.IS_KOTLIN ? "Yes" : "No"); } diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index 27fd27899c..7b179ef6c6 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -185,7 +185,7 @@ private byte[] applyDataReplacements(String targetPath, byte[] sourceData) throw content = injectLocalizationBootstrap(targetPath, content); } if (isBareTemplate() && "common/src/main/css/theme.css".equals(targetPath)) { - content += buildThemeOverrides(); + content += buildThemeCss(); } if ("common/pom.xml".equals(targetPath)) { content = applyJavaVersionToPom(content); @@ -469,13 +469,19 @@ private void appendIdeSection(StringBuilder out) { .append("Open the project folder in VS Code and make sure Java + Maven extensions are installed.\n\n"); } - private String buildThemeOverrides() { - if (isDefaultBarebonesOptions()) { + public static String buildThemeOverrides(ProjectOptions options) { + ProjectOptions effective = options == null ? ProjectOptions.defaults() : options; + String customCss = normalizeCustomCss(effective.customThemeCss); + boolean hasCustomCss = customCss.length() > 0; + if (isDefaultBarebonesOptions(effective) && !hasCustomCss) { return ""; } - StringBuilder out = new StringBuilder("\n\n/* Initializr Theme Overrides */\n"); + StringBuilder out = new StringBuilder(); + if (!isDefaultBarebonesOptions(effective)) { + out.append("\n\n/* Initializr Theme Overrides */\n"); + } - if (options.themeMode == ProjectOptions.ThemeMode.DARK) { + if (effective.themeMode == ProjectOptions.ThemeMode.DARK) { out.append("Form {\n") .append(" background-color: #0f172a;\n") .append(" color: #e2e8f0;\n") @@ -491,7 +497,7 @@ private String buildThemeOverrides() { .append(" color: #e2e8f0;\n") .append("}\n"); - if (options.accent == ProjectOptions.Accent.DEFAULT) { + if (effective.accent == ProjectOptions.Accent.DEFAULT) { out.append("Button {\n") .append(" color: #e2e8f0;\n") .append(" background-color: #1f2937;\n") @@ -502,16 +508,19 @@ private String buildThemeOverrides() { .append(" background-color: #334155;\n") .append(" border: 1px solid #64748b;\n") .append("}\n"); + appendCustomCss(out, customCss); return out.toString(); } - } else if (options.accent == ProjectOptions.Accent.DEFAULT) { - // Light + Clean intentionally inherits template defaults (rounded ignored). - return ""; + } else if (effective.accent == ProjectOptions.Accent.DEFAULT) { + // Light + Clean intentionally inherits template defaults (rounded ignored) unless custom CSS is provided. + out.setLength(0); + appendCustomCss(out, customCss); + return out.toString(); } - int accent = resolveAccentColor(); + int accent = resolveAccentColor(effective); int accentPressed = darkenColor(accent, 0.22f); - String buttonRadius = options.roundedButtons ? "3mm" : "0"; + String buttonRadius = effective.roundedButtons ? "3mm" : "0"; out.append("Button {\n") .append(" background-color: ").append(toCssColor(accent)).append(";\n") .append(" color: #ffffff;\n") @@ -524,15 +533,33 @@ private String buildThemeOverrides() { .append(" color: #ffffff;\n") .append(" border-radius: ").append(buttonRadius).append(";\n") .append("}\n"); + appendCustomCss(out, customCss); return out.toString(); } - private boolean isDefaultBarebonesOptions() { + private static String normalizeCustomCss(String css) { + if (css == null) { + return ""; + } + String trimmed = css.trim(); + return trimmed.length() == 0 ? "" : trimmed; + } + + private static void appendCustomCss(StringBuilder out, String customCss) { + if (customCss.length() == 0) { + return; + } + out.append("\n/* Initializr Appended Custom CSS */\n") + .append(customCss) + .append('\n'); + } + + private static boolean isDefaultBarebonesOptions(ProjectOptions options) { return options.themeMode == ProjectOptions.ThemeMode.LIGHT && options.accent == ProjectOptions.Accent.DEFAULT; } - private int resolveAccentColor() { + private static int resolveAccentColor(ProjectOptions options) { if (options.accent == ProjectOptions.Accent.DEFAULT) { return 0x0f766e; } @@ -545,6 +572,11 @@ private int resolveAccentColor() { return 0x0f766e; } + + private String buildThemeCss() { + return buildThemeOverrides(options); + } + private static String toCssColor(int color) { String hex = Integer.toHexString(color & 0xffffff); while (hex.length() < 6) { diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java index 1d082c0758..fa5546003f 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/ProjectOptions.java @@ -65,16 +65,24 @@ public String toString() { public final boolean includeLocalizationBundles; public final PreviewLanguage previewLanguage; public final JavaVersion javaVersion; + public final String customThemeCss; public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, boolean includeLocalizationBundles, PreviewLanguage previewLanguage, JavaVersion javaVersion) { + this(themeMode, accent, roundedButtons, includeLocalizationBundles, previewLanguage, javaVersion, null); + } + + public ProjectOptions(ThemeMode themeMode, Accent accent, boolean roundedButtons, + boolean includeLocalizationBundles, PreviewLanguage previewLanguage, + JavaVersion javaVersion, String customThemeCss) { this.themeMode = themeMode; this.accent = accent; this.roundedButtons = roundedButtons; this.includeLocalizationBundles = includeLocalizationBundles; this.previewLanguage = previewLanguage == null ? PreviewLanguage.ENGLISH : previewLanguage; this.javaVersion = javaVersion == null ? JavaVersion.JAVA_8 : javaVersion; + this.customThemeCss = customThemeCss; } public static ProjectOptions defaults() { diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/TemplatePreviewPanel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/TemplatePreviewPanel.java index 74d83ce090..6672b87b37 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/TemplatePreviewPanel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/ui/TemplatePreviewPanel.java @@ -13,7 +13,6 @@ import com.codename1.ui.FontImage; import com.codename1.ui.Form; import com.codename1.ui.InterFormContainer; -import com.codename1.ui.Label; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; import com.codename1.ui.plaf.UIManager; @@ -28,19 +27,17 @@ public class TemplatePreviewPanel { private final Container root; private final Container previewHolder; private final ImageViewer staticPreview; - private final Label staticPreviewFallback; private InterFormContainer liveFormPreview; private Template template; private ProjectOptions options = ProjectOptions.defaults(); + private Form lastLiveForm; + private Button lastLiveHelloButton; public TemplatePreviewPanel(Template template) { this.template = template; staticPreview = new ImageViewer(); - staticPreviewFallback = new Label("Preview unavailable"); - staticPreviewFallback.setUIID("InitializrTip"); - previewHolder = new Container(new BorderLayout()); previewHolder.setUIID("InitializrPreviewHolder"); @@ -67,8 +64,9 @@ public void setOptions(ProjectOptions options) { public void showUpdatedLivePreview() { if (template == Template.BAREBONES || template == Template.KOTLIN) { Form liveForm = createBarebonesPreviewForm(options); - liveFormPreview = new InterFormContainer(liveForm); - liveFormPreview.setUIID("InitializrLiveFrame"); + InterFormContainer next = new InterFormContainer(liveForm); + next.setUIID("InitializrLiveFrame"); + liveFormPreview = next; previewHolder.removeAll(); previewHolder.add(BorderLayout.CENTER, liveFormPreview); previewHolder.revalidate(); @@ -79,15 +77,27 @@ private Form createBarebonesPreviewForm(ProjectOptions options) { installBundle(options); Form form = new Form("Hi World", BoxLayout.y()); Button helloButton = new Button("Hello World"); + helloButton.setName("previewHelloButton"); helloButton.setUIID("Button"); helloButton.addActionListener(e -> Dialog.show("Hello Codename One", "Welcome to Codename One", "OK", null)); form.add(helloButton); form.getToolbar().addMaterialCommandToSideMenu("Hello Command", FontImage.MATERIAL_CHECK, 4, e -> Dialog.show("Hello Codename One", "Welcome to Codename One", "OK", null)); applyLivePreviewOptions(form, helloButton, null, options); + validateCustomCss(options.customThemeCss); + lastLiveForm = form; + lastLiveHelloButton = helloButton; return form; } + Form getLastLiveFormForTesting() { + return lastLiveForm; + } + + Button getLastLiveHelloButtonForTesting() { + return lastLiveHelloButton; + } + private void installBundle(ProjectOptions options) { if (!options.includeLocalizationBundles) { UIManager.getInstance().setBundle(null); @@ -152,15 +162,39 @@ private Hashtable loadBundleProperties(String resourcePath) { } } + private void validateCustomCss(String rawCustomCss) { + String customCss = normalizeCustomCss(rawCustomCss); + if (customCss.length() > 0) { + String wrappedCustomCss = "\n/* Initializr Appended Custom CSS */\n" + customCss + "\n"; + try { + com.codename1.ui.css.CSSThemeCompiler compiler = new com.codename1.ui.css.CSSThemeCompiler(); + com.codename1.ui.util.MutableResource resource = new com.codename1.ui.util.MutableResource(); + compiler.compile(wrappedCustomCss, resource, "InitializrLiveThemeValidation"); + } catch (RuntimeException err) { + throw new IllegalArgumentException(err.getMessage(), err); + } + } + } + + private String normalizeCustomCss(String css) { + if (css == null) { + return ""; + } + String trimmed = css.trim(); + return trimmed.length() == 0 ? "" : trimmed; + } + private void updateMode() { - previewHolder.removeAll(); if (template == Template.BAREBONES || template == Template.KOTLIN) { Form liveForm = createBarebonesPreviewForm(options); - liveFormPreview = new InterFormContainer(liveForm); - liveFormPreview.setUIID("InitializrLiveFrame"); + InterFormContainer next = new InterFormContainer(liveForm); + next.setUIID("InitializrLiveFrame"); + liveFormPreview = next; + previewHolder.removeAll(); previewHolder.add(BorderLayout.CENTER, liveFormPreview); } else { staticPreview.setImage(Resources.getGlobalResources().getImage(template.IMAGE_NAME)); + previewHolder.removeAll(); previewHolder.add(BorderLayout.CENTER, staticPreview); } previewHolder.revalidate(); diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/InitializrThemeInteractionTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/InitializrThemeInteractionTest.java new file mode 100644 index 0000000000..d039d0185b --- /dev/null +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/InitializrThemeInteractionTest.java @@ -0,0 +1,71 @@ +package com.codename1.initializr; + +import com.codename1.testing.AbstractTest; +import com.codename1.ui.Button; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Form; + +public class InitializrThemeInteractionTest extends AbstractTest { + + @Override + public boolean shouldExecuteOnEDT() { + return true; + } + + @Override + public boolean runTest() throws Exception { + new Initializr().runApp(); + Form current = Display.getInstance().getCurrent(); + assertNotNull(current, "Initializr should show a form"); + + Button initialHello = getPreviewHelloButton(); + assertNotNull(initialHello, "Preview should include Hello World button"); + assertEqual("Button", initialHello.getUIID(), "Default preview should start in clean light mode"); + + clickByLabel("DARK"); + Button darkHello = getPreviewHelloButton(); + assertNotNull(darkHello, "Preview button should still exist after switching to dark mode"); + assertEqual("InitializrLiveButtonDarkClean", darkHello.getUIID(), "Dark mode should update preview button UIID"); + + clickByLabel("TEAL"); + Button darkTealHello = getPreviewHelloButton(); + assertNotNull(darkTealHello, "Preview button should still exist after switching accent"); + assertEqual("InitializrLiveButtonDarkTealRound", darkTealHello.getUIID(), "Teal accent should update preview button UIID"); + + clickByLabel("LIGHT"); + Button lightTealHello = getPreviewHelloButton(); + assertNotNull(lightTealHello, "Preview button should still exist after switching back to light mode"); + assertEqual("InitializrLiveButtonLightTealRound", lightTealHello.getUIID(), "Light mode should update preview button UIID"); + + setText("appendCustomCssEditor", "Button { border-radius: 0; }"); + waitFor(100); + + clickByLabel("DARK"); + clickByLabel("BLUE"); + Button darkBlueHello = getPreviewHelloButton(); + assertNotNull(darkBlueHello, "Preview button should exist after applying custom CSS and toggling"); + assertEqual("InitializrLiveButtonDarkBlueRound", darkBlueHello.getUIID(), + "Mode/accent toggles should still update preview with custom CSS"); + + clickByLabel("LIGHT"); + clickByLabel("ORANGE"); + Button lightOrangeHello = getPreviewHelloButton(); + assertNotNull(lightOrangeHello, "Preview button should exist after switching back with custom CSS"); + assertEqual("InitializrLiveButtonLightOrangeRound", lightOrangeHello.getUIID(), + "Accent toggles should keep working after custom CSS is set"); + return true; + } + + private void clickByLabel(String text) { + clickButtonByLabel(text); + waitFor(50); + } + + private Button getPreviewHelloButton() { + Component component = findByName("previewHelloButton"); + assertNotNull(component, "Unable to find preview hello button"); + assertTrue(component instanceof Button, "previewHelloButton should be a Button"); + return (Button) component; + } +} diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index cdcd876dfe..b3ae939ca7 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -2,6 +2,8 @@ import com.codename1.io.Util; import com.codename1.testing.AbstractTest; +import com.codename1.ui.css.CSSThemeCompiler; +import com.codename1.ui.util.MutableResource; import com.codename1.util.StringUtil; import net.sf.zipme.ZipEntry; import net.sf.zipme.ZipInputStream; @@ -23,10 +25,95 @@ public boolean runTest() throws Exception { } validateExperimentalJava17Generation(); validateExperimentalJava17RegressionFixes(); + validateAppendedCustomCssGeneration(); + validateCustomCssWithoutPresetOverrides(); + validateThemeCssCompilesForAllVariants(); return true; } + + private void validateAppendedCustomCssGeneration() throws Exception { + String mainClassName = "DemoAdvancedTheme"; + String packageName = "com.acme.advanced.theme"; + String customCss = "Button {\n border-radius: 0;\n}\n"; + ProjectOptions options = new ProjectOptions( + ProjectOptions.ThemeMode.LIGHT, + ProjectOptions.Accent.BLUE, + true, + true, + ProjectOptions.PreviewLanguage.ENGLISH, + ProjectOptions.JavaVersion.JAVA_8, + customCss + ); + + byte[] zipData = createProjectZip(IDE.INTELLIJ, Template.BAREBONES, mainClassName, packageName, options); + Map entries = readZipEntries(zipData); + + String themeCss = getText(entries, "common/src/main/css/theme.css"); + assertContains(themeCss, "Initializr Theme Overrides", "Theme CSS should include generated theme overrides marker"); + assertContains(themeCss, "background-color: #1d4ed8", "Theme CSS should preserve selected accent overrides when custom CSS is appended"); + assertContains(themeCss, "Initializr Appended Custom CSS", "Theme CSS should include appended custom CSS marker"); + assertContains(themeCss, "border-radius: 0", "Theme CSS should include custom advanced CSS"); + } + + private void validateCustomCssWithoutPresetOverrides() throws Exception { + String mainClassName = "DemoCustomOnly"; + String packageName = "com.acme.custom.only"; + String customCss = "Button {\n border-radius: 0;\n}\n"; + ProjectOptions options = new ProjectOptions( + ProjectOptions.ThemeMode.LIGHT, + ProjectOptions.Accent.DEFAULT, + true, + true, + ProjectOptions.PreviewLanguage.ENGLISH, + ProjectOptions.JavaVersion.JAVA_8, + customCss + ); + + byte[] zipData = createProjectZip(IDE.INTELLIJ, Template.BAREBONES, mainClassName, packageName, options); + Map entries = readZipEntries(zipData); + + String themeCss = getText(entries, "common/src/main/css/theme.css"); + assertFalse(themeCss.indexOf("Initializr Theme Overrides") >= 0, "Theme CSS should not add preset overrides for light/default mode"); + assertContains(themeCss, "Initializr Appended Custom CSS", "Theme CSS should include custom CSS marker in custom-only mode"); + assertContains(themeCss, "border-radius: 0", "Theme CSS should include custom CSS in custom-only mode"); + } + + private void validateThemeCssCompilesForAllVariants() { + for (ProjectOptions.ThemeMode mode : ProjectOptions.ThemeMode.values()) { + for (ProjectOptions.Accent accent : ProjectOptions.Accent.values()) { + validateThemeCssCompiles(new ProjectOptions( + mode, + accent, + true, + true, + ProjectOptions.PreviewLanguage.ENGLISH, + ProjectOptions.JavaVersion.JAVA_8 + )); + validateThemeCssCompiles(new ProjectOptions( + mode, + accent, + false, + true, + ProjectOptions.PreviewLanguage.ENGLISH, + ProjectOptions.JavaVersion.JAVA_8, + "Button { border-radius: 0; }" + )); + } + } + } + + private void validateThemeCssCompiles(ProjectOptions options) { + String css = GeneratorModel.buildThemeOverrides(options); + if (css == null || css.trim().length() == 0) { + return; + } + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(css, resource, "CompileCheckTheme"); + assertNotNull(resource.getTheme("CompileCheckTheme"), "Generated CSS should compile to a theme"); + } + private void validateExperimentalJava17Generation() throws Exception { String mainClassName = "DemoExperimentalJava17"; String packageName = "com.acme.experimental.java17"; diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/ui/TemplatePreviewPanelThemeTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/ui/TemplatePreviewPanelThemeTest.java new file mode 100644 index 0000000000..04b82cd495 --- /dev/null +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/ui/TemplatePreviewPanelThemeTest.java @@ -0,0 +1,69 @@ +package com.codename1.initializr.ui; + +import com.codename1.initializr.model.ProjectOptions; +import com.codename1.initializr.model.Template; +import com.codename1.testing.AbstractTest; +import com.codename1.ui.Button; +import com.codename1.ui.Form; + +public class TemplatePreviewPanelThemeTest extends AbstractTest { + + @Override + public boolean shouldExecuteOnEDT() { + return true; + } + + @Override + public boolean runTest() throws Exception { + validateModeAndAccentUiidUpdates(); + validateThemeTogglesStillApplyWithCustomCss(); + return true; + } + + private void validateModeAndAccentUiidUpdates() { + TemplatePreviewPanel panel = new TemplatePreviewPanel(Template.BAREBONES); + + panel.setOptions(options(ProjectOptions.ThemeMode.LIGHT, ProjectOptions.Accent.TEAL, true, null)); + Button firstButton = panel.getLastLiveHelloButtonForTesting(); + Form firstForm = panel.getLastLiveFormForTesting(); + assertNotNull(firstButton, "Preview should include a hello button"); + assertNotNull(firstForm, "Preview should include a form"); + assertEqual("InitializrLiveButtonLightTealRound", firstButton.getUIID(), "Light/Teal/Rounded should map to expected UIID"); + assertEqual("Container", firstForm.getContentPane().getUIID(), "Light mode should use container content UIID"); + + panel.setOptions(options(ProjectOptions.ThemeMode.DARK, ProjectOptions.Accent.ORANGE, false, null)); + Button secondButton = panel.getLastLiveHelloButtonForTesting(); + Form secondForm = panel.getLastLiveFormForTesting(); + assertEqual("InitializrLiveButtonDarkOrangeSquare", secondButton.getUIID(), "Dark/Orange/Square should map to expected UIID"); + assertEqual("InitializrLiveContentDark", secondForm.getContentPane().getUIID(), "Dark mode should use dark content UIID"); + assertNotEqual(firstButton.getUIID(), secondButton.getUIID(), "Toggling options should change button UIID"); + } + + private void validateThemeTogglesStillApplyWithCustomCss() { + TemplatePreviewPanel panel = new TemplatePreviewPanel(Template.BAREBONES); + String customCss = "Button { border-radius: 0; }"; + + panel.setOptions(options(ProjectOptions.ThemeMode.LIGHT, ProjectOptions.Accent.BLUE, false, customCss)); + Button blueButton = panel.getLastLiveHelloButtonForTesting(); + int blueBg = blueButton.getUnselectedStyle().getBgColor(); + assertEqual("InitializrLiveButtonLightBlueSquare", blueButton.getUIID(), "Custom CSS should not replace computed UIID"); + + panel.setOptions(options(ProjectOptions.ThemeMode.LIGHT, ProjectOptions.Accent.ORANGE, false, customCss)); + Button orangeButton = panel.getLastLiveHelloButtonForTesting(); + int orangeBg = orangeButton.getUnselectedStyle().getBgColor(); + assertEqual("InitializrLiveButtonLightOrangeSquare", orangeButton.getUIID(), "Accent toggle should continue to update UIID with custom CSS"); + assertNotEqual(blueBg, orangeBg, "Accent toggles should still change applied button colors with custom CSS"); + } + + private ProjectOptions options(ProjectOptions.ThemeMode mode, ProjectOptions.Accent accent, boolean rounded, String customCss) { + return new ProjectOptions( + mode, + accent, + rounded, + true, + ProjectOptions.PreviewLanguage.ENGLISH, + ProjectOptions.JavaVersion.JAVA_8, + customCss + ); + } +}