From aa70f842f00dadd78130d7a28d33468f27dade1d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:41:22 +0200 Subject: [PATCH 1/2] Refactor CSSThemeCompiler to avoid regex and chained else-if --- .../codename1/ui/css/CSSThemeCompiler.java | 433 ++++++++++++++++++ .../com/codename1/ui/util/MutableResouce.java | 337 +------------- .../codename1/ui/util/MutableResource.java | 272 +++++++++++ .../ui/css/CSSThemeCompilerTest.java | 47 ++ ...ouceTest.java => MutableResourceTest.java} | 18 +- 5 files changed, 765 insertions(+), 342 deletions(-) create mode 100644 CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java create mode 100644 CodenameOne/src/com/codename1/ui/util/MutableResource.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java rename maven/core-unittests/src/test/java/com/codename1/ui/util/{MutableResouceTest.java => MutableResourceTest.java} (92%) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java new file mode 100644 index 0000000000..bebcc768fe --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.ui.css; + +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Image; +import com.codename1.ui.plaf.CSSBorder; +import com.codename1.ui.util.MutableResource; +import java.util.ArrayList; +import java.util.Hashtable; + +/// Compiles a subset of Codename One CSS into theme properties stored in a {@link MutableResource}. +/// +/// ## Supported selector syntax +/// +/// - `UIID` +/// - `UIID:selected` +/// - `UIID:pressed` +/// - `UIID:disabled` +/// - `*` (mapped to `Component`) +/// - `:root` (for constants only) +/// +/// ## Supported declarations +/// +/// - `color` +/// - `background-color` +/// - `padding` +/// - `margin` +/// - `font-family` (mapped to `font` string for later resolution) +/// - `cn1-derive` +/// - `cn1-image-id` +/// - `cn1-mutable-image` +/// - border-related properties: `border`, `border-*`, `background-image`, `background-position`, `background-repeat` +/// +/// ## Theme constants +/// +/// - CSS custom property definitions in `:root`, e.g. `--primary: #ff00ff;` +/// - `@constants { name: value; other: value; }` +/// - `var(--name)` dereferencing in declaration values. +public class CSSThemeCompiler { + + public void compile(String css, MutableResource resources, String themeName) { + Hashtable theme = resources.getTheme(themeName); + if (theme == null) { + theme = new Hashtable(); + } + + compileConstants(css, theme); + Rule[] rules = parseRules(css); + for (int i = 0; i < rules.length; i++) { + applyRule(theme, resources, rules[i]); + } + resolveThemeConstantVars(theme); + resources.setTheme(themeName, theme); + } + + private void resolveThemeConstantVars(Hashtable theme) { + for (Object keyObj : theme.keySet()) { + String key = String.valueOf(keyObj); + if (!key.startsWith("@")) { + continue; + } + Object value = theme.get(key); + if (!(value instanceof String)) { + continue; + } + theme.put(key, resolveVars(theme, (String) value)); + } + } + + private void compileConstants(String css, Hashtable theme) { + String stripped = stripComments(css); + int constantsStart = stripped.indexOf("@constants"); + if (constantsStart < 0) { + return; + } + int open = stripped.indexOf('{', constantsStart); + if (open < 0) { + return; + } + int close = stripped.indexOf('}', open + 1); + if (close <= open) { + return; + } + Declaration[] declarations = parseDeclarations(stripped.substring(open + 1, close)); + for (int i = 0; i < declarations.length; i++) { + Declaration declaration = declarations[i]; + theme.put("@" + declaration.property, declaration.value); + } + } + + private void applyRule(Hashtable theme, MutableResource resources, Rule rule) { + if (":root".equals(rule.selector)) { + applyRootDeclarations(theme, rule.declarations); + return; + } + + String[] selectorParts = selector(rule.selector); + String uiid = selectorParts[0]; + String statePrefix = selectorParts[1]; + StringBuilder borderCss = new StringBuilder(); + + for (int i = 0; i < rule.declarations.length; i++) { + Declaration declaration = rule.declarations[i]; + String property = declaration.property; + String value = resolveVars(theme, declaration.value); + + if (applyThemeConstantProperty(theme, property, value)) { + continue; + } + if (applySimpleThemeProperty(theme, uiid, statePrefix, property, value)) { + continue; + } + if (applyImageProperty(theme, resources, uiid, statePrefix, property, value)) { + continue; + } + if (appendBorderProperty(borderCss, property, value)) { + continue; + } + } + + if (borderCss.length() == 0) { + return; + } + theme.put(uiid + "." + statePrefix + "border", new CSSBorder(null, borderCss.toString())); + } + + private void applyRootDeclarations(Hashtable theme, Declaration[] declarations) { + for (int i = 0; i < declarations.length; i++) { + Declaration declaration = declarations[i]; + if (!declaration.property.startsWith("--")) { + continue; + } + theme.put("@" + declaration.property.substring(2), resolveVars(theme, declaration.value)); + } + } + + private boolean applyThemeConstantProperty(Hashtable theme, String property, String value) { + if (!property.startsWith("--")) { + return false; + } + theme.put("@" + property.substring(2), value); + return true; + } + + private boolean applySimpleThemeProperty(Hashtable theme, String uiid, String statePrefix, String property, String value) { + if ("color".equals(property)) { + theme.put(uiid + "." + statePrefix + "fgColor", normalizeHexColor(value)); + return true; + } + if ("background-color".equals(property)) { + theme.put(uiid + "." + statePrefix + "bgColor", normalizeHexColor(value)); + if (!"transparent".equalsIgnoreCase(value)) { + theme.put(uiid + "." + statePrefix + "transparency", "255"); + } + return true; + } + if ("padding".equals(property) || "margin".equals(property)) { + theme.put(uiid + "." + statePrefix + property, normalizeBox(value)); + return true; + } + if ("cn1-derive".equals(property)) { + theme.put(uiid + "." + statePrefix + "derive", value); + return true; + } + if ("font-family".equals(property)) { + theme.put(uiid + "." + statePrefix + "font", value); + return true; + } + return false; + } + + private boolean applyImageProperty(Hashtable theme, MutableResource resources, String uiid, String statePrefix, String property, String value) { + if ("cn1-image-id".equals(property)) { + Image image = resources.getImage(value); + if (image == null) { + return true; + } + theme.put(uiid + "." + statePrefix + "bgImage", image); + return true; + } + if (!"cn1-mutable-image".equals(property)) { + return false; + } + String[] parts = splitOnWhitespace(value); + if (parts.length < 2) { + return true; + } + String imageId = parts[0]; + Image image = createSolidImage(parts[1]); + resources.setImage(imageId, image); + theme.put(uiid + "." + statePrefix + "bgImage", image); + return true; + } + + private boolean appendBorderProperty(StringBuilder borderCss, String property, String value) { + if (!isBorderProperty(property)) { + return false; + } + if (borderCss.length() > 0) { + borderCss.append(';'); + } + borderCss.append(property).append(':').append(value); + return true; + } + + private String resolveVars(Hashtable theme, String value) { + String out = value; + int varPos = out.indexOf("var(--"); + while (varPos > -1) { + int end = out.indexOf(')', varPos); + if (end < 0) { + break; + } + String key = out.substring(varPos + "var(--".length(), end).trim(); + Object replacement = theme.get("@" + key); + String replaceValue = replacement == null ? "" : replacement.toString(); + out = out.substring(0, varPos) + replaceValue + out.substring(end + 1); + varPos = out.indexOf("var(--"); + } + return out; + } + + 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(); + statePrefix = statePrefix(pseudo); + } + if ("*".equals(uiid) || uiid.length() == 0) { + uiid = "Component"; + } + return new String[]{uiid, statePrefix}; + } + + private String statePrefix(String pseudo) { + if ("selected".equals(pseudo)) { + return "sel#"; + } + if ("pressed".equals(pseudo)) { + return "press#"; + } + if ("disabled".equals(pseudo)) { + return "dis#"; + } + return ""; + } + + private Image createSolidImage(String color) { + int rgb = parseColor(color); + return EncodedImage.createFromRGB(new int[]{rgb}, 1, 1, false); + } + + private int parseColor(String cssColor) { + String hex = normalizeHexColor(cssColor); + return Integer.parseInt(hex, 16) | 0xff000000; + } + + private boolean isBorderProperty(String property) { + return "border".equals(property) + || property.startsWith("border-") + || property.startsWith("background-image") + || property.startsWith("background-position") + || property.startsWith("background-repeat"); + } + + private String normalizeHexColor(String cssColor) { + String value = cssColor.trim(); + if ("transparent".equalsIgnoreCase(value)) { + return "000000"; + } + if (value.startsWith("#")) { + value = value.substring(1); + } + if (value.length() == 3) { + value = "" + value.charAt(0) + value.charAt(0) + + value.charAt(1) + value.charAt(1) + + value.charAt(2) + value.charAt(2); + } + return value.toLowerCase(); + } + + private String normalizeBox(String cssValue) { + String[] parts = splitOnWhitespace(cssValue.trim()); + if (parts.length == 1) { + return scalar(parts[0]) + "," + scalar(parts[0]) + "," + scalar(parts[0]) + "," + scalar(parts[0]); + } + if (parts.length == 2) { + return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[0]) + "," + scalar(parts[1]); + } + if (parts.length == 3) { + return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[2]) + "," + scalar(parts[1]); + } + if (parts.length >= 4) { + return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[2]) + "," + scalar(parts[3]); + } + return "0,0,0,0"; + } + + private String scalar(String value) { + String out = value.trim(); + if (out.endsWith("px")) { + out = out.substring(0, out.length() - 2); + } + return out; + } + + private Rule[] parseRules(String css) { + String stripped = stripComments(css); + ArrayList out = new ArrayList(); + int pos = 0; + while (pos < stripped.length()) { + int open = stripped.indexOf('{', pos); + if (open < 0) { + break; + } + int close = stripped.indexOf('}', open + 1); + if (close < 0) { + break; + } + + String selectors = stripped.substring(pos, open).trim(); + if (selectors.startsWith("@constants")) { + pos = close + 1; + continue; + } + + String body = stripped.substring(open + 1, close).trim(); + Declaration[] declarations = parseDeclarations(body); + String[] selectorsList = splitOnChar(selectors, ','); + for (int i = 0; i < selectorsList.length; i++) { + String selector = selectorsList[i].trim(); + if (selector.length() == 0) { + continue; + } + Rule rule = new Rule(); + rule.selector = selector; + rule.declarations = declarations; + out.add(rule); + } + + pos = close + 1; + } + return out.toArray(new Rule[out.size()]); + } + + private String stripComments(String css) { + StringBuilder out = new StringBuilder(); + int i = 0; + while (i < css.length()) { + char c = css.charAt(i); + if (c == '/' && i + 1 < css.length() && css.charAt(i + 1) == '*') { + i += 2; + while (i + 1 < css.length()) { + if (css.charAt(i) == '*' && css.charAt(i + 1) == '/') { + i += 2; + break; + } + i++; + } + continue; + } + out.append(c); + i++; + } + return out.toString(); + } + + private Declaration[] parseDeclarations(String body) { + ArrayList out = new ArrayList(); + String[] segments = splitOnChar(body, ';'); + for (int i = 0; i < segments.length; i++) { + String line = segments[i]; + int colon = line.indexOf(':'); + if (colon <= 0) { + continue; + } + Declaration dec = new Declaration(); + dec.property = line.substring(0, colon).trim().toLowerCase(); + dec.value = line.substring(colon + 1).trim(); + out.add(dec); + } + return out.toArray(new Declaration[out.size()]); + } + + private String[] splitOnChar(String input, char delimiter) { + ArrayList out = new ArrayList(); + int start = 0; + for (int i = 0; i < input.length(); i++) { + if (input.charAt(i) != delimiter) { + continue; + } + out.add(input.substring(start, i)); + start = i + 1; + } + out.add(input.substring(start)); + return out.toArray(new String[out.size()]); + } + + private String[] splitOnWhitespace(String input) { + ArrayList out = new ArrayList(); + StringBuilder token = new StringBuilder(); + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (Character.isWhitespace(c)) { + if (token.length() > 0) { + out.add(token.toString()); + token.setLength(0); + } + continue; + } + token.append(c); + } + if (token.length() > 0) { + out.add(token.toString()); + } + return out.toArray(new String[out.size()]); + } + + private static class Rule { + String selector; + Declaration[] declarations; + } + + private static class Declaration { + String property; + String value; + } +} diff --git a/CodenameOne/src/com/codename1/ui/util/MutableResouce.java b/CodenameOne/src/com/codename1/ui/util/MutableResouce.java index 7416c5ca25..6de084bc7f 100644 --- a/CodenameOne/src/com/codename1/ui/util/MutableResouce.java +++ b/CodenameOne/src/com/codename1/ui/util/MutableResouce.java @@ -3,351 +3,22 @@ */ package com.codename1.ui.util; -import com.codename1.ui.EncodedImage; -import com.codename1.ui.Image; -import com.codename1.ui.animations.Timeline; - -import java.io.ByteArrayInputStream; -import java.io.DataInputStream; -import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Hashtable; - -/// Mutable variant of {@link Resources} intended for non-designer environments. -/// -/// This class provides a minimal API for programmatically editing resource files -/// inside Codename One core code where JavaSE/Swing designer classes are not available. -/// -/// ## Supported resource categories -/// -/// - `IMAGE` -/// - `DATA` -/// - `L10N` -/// - `THEME` (in-memory editing only; save serialization is intentionally limited) -/// -/// ## Explicitly unsupported categories -/// -/// The following are intentionally rejected with `UnsupportedOperationException` -/// when read or written: -/// -/// - Timeline -/// - Indexed images -/// - SVG -/// - GUI Builder UI resources -public class MutableResouce extends Resources { - private static final short MINOR_VERSION = 12; - private static final short MAJOR_VERSION = 1; - private boolean modified; +/// Deprecated typo-preserving wrapper for {@link MutableResource}. +@Deprecated +public class MutableResouce extends MutableResource { - /// Creates an empty mutable resource container. public MutableResouce() { super(); } - /// Creates a mutable resource container by loading a resource stream. - /// - /// @param input source stream containing a `.res` payload. - /// @throws IOException if the stream cannot be parsed. MutableResouce(InputStream input) throws IOException { - super(); - openFile(input); + super(input); } - /// Opens a mutable resource from an input stream. - /// - /// @param resource source stream. - /// @return loaded mutable resource. - /// @throws IOException if parsing fails. public static MutableResouce open(InputStream resource) throws IOException { return new MutableResouce(resource); } - - /// Validates each top-level resource entry while loading. - /// - /// @param id resource id currently being read. - /// @param magic resource type magic. - @Override - void startingEntry(String id, byte magic) { - if (magic == MAGIC_TIMELINE || magic == MAGIC_SVG || magic == MAGIC_INDEXED_IMAGE_LEGACY || magic == MAGIC_UI) { - throw new UnsupportedOperationException("Unsupported resource type in MutableResouce: " + Integer.toHexString(magic & 0xff)); - } - } - - /// Creates an image entry while rejecting unsupported image subtypes. - /// - /// @param input data stream. - /// @return decoded image. - /// @throws IOException if decoding fails. - @Override - Image createImage(DataInputStream input) throws IOException { - if (majorVersion == 0 && minorVersion == 0) { - return super.createImage(input); - } - int type = input.readByte() & 0xff; - switch (type) { - case 0xF1: - case 0xF2: { - byte[] data = new byte[input.readInt()]; - input.readFully(data, 0, data.length); - int width = input.readInt(); - int height = input.readInt(); - boolean opaque = input.readBoolean(); - return EncodedImage.create(data, width, height, opaque); - } - case 0xF6: - return readMultiImage(input, false); - case 0xEF: - case 0xF3: - case 0xF5: - case 0xF7: - throw new UnsupportedOperationException("Unsupported image subtype in MutableResouce: " + Integer.toHexString(type)); - default: - throw new IOException("Illegal type while creating image: " + Integer.toHexString(type)); - } - } - - /// Stores or removes an image resource. - /// - /// @param name resource id. - /// @param value image value, or `null` to remove. - public void setImage(String name, Image value) { - if (value instanceof Timeline) { - throw new UnsupportedOperationException("Timeline resources are not supported in MutableResouce"); - } - if (value != null && value.isSVG()) { - throw new UnsupportedOperationException("SVG resources are not supported in MutableResouce"); - } - setResource(name, MAGIC_IMAGE, value); - modified = true; - } - - /// Stores or removes raw data resource bytes. - /// - /// @param name resource id. - /// @param data payload bytes, or `null` to remove. - public void setData(String name, byte[] data) { - setResource(name, MAGIC_DATA, data); - modified = true; - } - - /// Stores or replaces a theme resource. - /// - /// @param name theme resource id. - /// @param theme theme properties map. - public void setTheme(String name, Hashtable theme) { - setResource(name, MAGIC_THEME, theme); - modified = true; - } - - /// Sets or removes a single property inside a named theme. - /// - /// If the theme does not exist, a new map is created. - /// Passing `null` value removes the property. - /// - /// @param themeName theme resource id. - /// @param key theme property key. - /// @param value theme property value or `null` to remove. - public void setThemeProperty(String themeName, String key, Object value) { - Hashtable theme = getTheme(themeName); - if (theme == null) { - theme = new Hashtable(); - } - if (value == null) { - theme.remove(key); - } else { - theme.put(key, value); - } - setResource(themeName, MAGIC_THEME, theme); - modified = true; - } - - /// Stores or replaces an L10N resource bundle. - /// - /// @param name l10n resource id. - /// @param l10n locale->translation map. - public void setL10N(String name, Hashtable l10n) { - setResource(name, MAGIC_L10N, l10n); - modified = true; - } - - /// Returns raw bytes for a named data resource. - /// - /// @param id resource id. - /// @return data bytes, or `null` if missing. - public byte[] getDataByteArray(String id) { - return (byte[]) getResourceObject(id); - } - - /// Checks whether any resource exists for a given id. - /// - /// @param name resource id. - /// @return `true` when an entry exists. - public boolean containsResource(String name) { - return getResourceObject(name) != null; - } - - /// Indicates whether this resource set has pending edits. - /// - /// @return `true` if modified. - public boolean isModified() { - return modified; - } - - /// Unsupported GUI builder setter. - /// - /// @param name ignored. - /// @param data ignored. - public void setUi(String name, byte[] data) { - throw new UnsupportedOperationException("GUI Builder resources are not supported in MutableResouce"); - } - - /// Unsupported timeline setter. - /// - /// @param name ignored. - /// @param timeline ignored. - public void setTimeline(String name, Timeline timeline) { - throw new UnsupportedOperationException("Timeline resources are not supported in MutableResouce"); - } - - /// Unsupported indexed image setter. - /// - /// @param name ignored. - /// @param image ignored. - public void setIndexedImage(String name, Image image) { - throw new UnsupportedOperationException("Indexed image resources are not supported in MutableResouce"); - } - - /// Unsupported SVG setter. - /// - /// @param name ignored. - /// @param image ignored. - public void setSVG(String name, Image image) { - throw new UnsupportedOperationException("SVG resources are not supported in MutableResouce"); - } - - /// Clears all resources and resets the modified flag. - @Override - public void clear() { - super.clear(); - modified = false; - } - - /// Serializes the current resources into a `.res` stream. - /// - /// Entries are written in deterministic sorted order by id. - /// - /// @param out output stream. - /// @throws IOException if writing fails. - public void save(OutputStream out) throws IOException { - String[] resourceNames = getResourceNames(); - Arrays.sort(resourceNames); - - DataOutputStream output = new DataOutputStream(out); - output.writeShort(resourceNames.length + 1); - output.writeByte(MAGIC_HEADER); - output.writeUTF(""); - output.writeShort(6); - output.writeShort(MAJOR_VERSION); - output.writeShort(MINOR_VERSION); - output.writeShort(0); - - for (String resourceName : resourceNames) { - byte magic = getResourceType(resourceName); - Object value = getResourceObject(resourceName); - if (magic == MAGIC_UI || magic == MAGIC_TIMELINE || magic == MAGIC_SVG || magic == MAGIC_INDEXED_IMAGE_LEGACY) { - throw new UnsupportedOperationException("Unsupported resource type in MutableResouce: " + Integer.toHexString(magic & 0xff)); - } - output.writeByte(magic); - output.writeUTF(resourceName); - switch (magic) { - case MAGIC_IMAGE: - writeImage(output, (Image) value); - break; - case MAGIC_DATA: - byte[] data = (byte[]) value; - output.writeInt(data.length); - output.write(data); - break; - case MAGIC_L10N: - saveL10N(output, (Hashtable) value); - break; - default: - throw new IOException("MutableResouce save() currently supports IMAGE/DATA/L10N only. Unsupported type: " + Integer.toHexString(magic & 0xff)); - } - } - modified = false; - } - - /// Writes an encoded image entry. - /// - /// @param output output stream. - /// @param image image value. - /// @throws IOException if writing fails. - protected void writeImage(DataOutputStream output, Image image) throws IOException { - if (image instanceof Timeline) { - throw new UnsupportedOperationException("Timeline resources are not supported in MutableResouce"); - } - if (image.isSVG()) { - throw new UnsupportedOperationException("SVG resources are not supported in MutableResouce"); - } - EncodedImage enc = image instanceof EncodedImage ? (EncodedImage) image : EncodedImage.createFromImage(image, false); - byte[] bytes = enc.getImageData(); - output.writeByte(0xF1); - output.writeInt(bytes.length); - output.write(bytes); - output.writeInt(enc.getWidth()); - output.writeInt(enc.getHeight()); - output.writeBoolean(enc.isOpaque()); - } - - /// Writes an L10N map in resource-file format. - /// - /// @param output output stream. - /// @param l10n locale map. - /// @throws IOException if writing fails. - protected void saveL10N(DataOutputStream output, Hashtable l10n) throws IOException { - ArrayList keys = new ArrayList(); - for (Object locale : l10n.keySet()) { - Hashtable current = (Hashtable) l10n.get(locale); - for (Object key : current.keySet()) { - if (!keys.contains(key)) { - keys.add(key); - } - } - } - - output.writeShort(keys.size()); - output.writeShort(l10n.size()); - - for (Object key : keys) { - output.writeUTF((String) key); - } - - for (Object locale : l10n.keySet()) { - Hashtable currentLanguage = (Hashtable) l10n.get(locale); - output.writeUTF((String) locale); - for (Object key : keys) { - String k = (String) currentLanguage.get(key); - output.writeUTF(k == null ? "" : k); - } - } - } - - /// Returns the data resource as a new input stream. - /// - /// @param id resource id. - /// @return data stream or `null`. - @Override - public InputStream getData(String id) { - byte[] data = getDataByteArray(id); - if (data == null) { - return null; - } - return new ByteArrayInputStream(data); - } } diff --git a/CodenameOne/src/com/codename1/ui/util/MutableResource.java b/CodenameOne/src/com/codename1/ui/util/MutableResource.java new file mode 100644 index 0000000000..e1a322b504 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/util/MutableResource.java @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + */ +package com.codename1.ui.util; + +import com.codename1.ui.EncodedImage; +import com.codename1.ui.Image; +import com.codename1.ui.animations.Timeline; + +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Hashtable; + +/// Mutable variant of {@link Resources} intended for non-designer environments. +/// +/// This class provides a minimal API for programmatically editing resource files +/// inside Codename One core code where JavaSE/Swing designer classes are not available. +/// +/// ## Supported resource categories +/// +/// - `IMAGE` +/// - `DATA` +/// - `L10N` +/// - `THEME` (in-memory editing only; save serialization is intentionally limited) +/// +/// ## Explicitly unsupported categories +/// +/// The following are intentionally rejected with `UnsupportedOperationException` +/// when read or written: +/// +/// - Timeline +/// - Indexed images +/// - SVG +/// - GUI Builder UI resources +public class MutableResource extends Resources { + private static final short MINOR_VERSION = 12; + private static final short MAJOR_VERSION = 1; + + private boolean modified; + + /// Creates an empty mutable resource container. + public MutableResource() { + super(); + } + + /// Creates a mutable resource container by loading a resource stream. + /// + /// @param input source stream containing a `.res` payload. + /// @throws IOException if the stream cannot be parsed. + MutableResource(InputStream input) throws IOException { + super(); + openFile(input); + } + + /// Opens a mutable resource from an input stream. + /// + /// @param resource source stream. + /// @return loaded mutable resource. + /// @throws IOException if parsing fails. + public static MutableResource open(InputStream resource) throws IOException { + return new MutableResource(resource); + } + + @Override + void startingEntry(String id, byte magic) { + if (magic == MAGIC_TIMELINE || magic == MAGIC_SVG || magic == MAGIC_INDEXED_IMAGE_LEGACY || magic == MAGIC_UI) { + throw new UnsupportedOperationException("Unsupported resource type in MutableResource: " + Integer.toHexString(magic & 0xff)); + } + } + + @Override + Image createImage(DataInputStream input) throws IOException { + if (majorVersion == 0 && minorVersion == 0) { + return super.createImage(input); + } + int type = input.readByte() & 0xff; + switch (type) { + case 0xF1: + case 0xF2: { + byte[] data = new byte[input.readInt()]; + input.readFully(data, 0, data.length); + int width = input.readInt(); + int height = input.readInt(); + boolean opaque = input.readBoolean(); + return EncodedImage.create(data, width, height, opaque); + } + case 0xF6: + return readMultiImage(input, false); + case 0xEF: + case 0xF3: + case 0xF5: + case 0xF7: + throw new UnsupportedOperationException("Unsupported image subtype in MutableResource: " + Integer.toHexString(type)); + default: + throw new IOException("Illegal type while creating image: " + Integer.toHexString(type)); + } + } + + public void setImage(String name, Image value) { + if (value instanceof Timeline) { + throw new UnsupportedOperationException("Timeline resources are not supported in MutableResource"); + } + if (value != null && value.isSVG()) { + throw new UnsupportedOperationException("SVG resources are not supported in MutableResource"); + } + setResource(name, MAGIC_IMAGE, value); + modified = true; + } + + public void setData(String name, byte[] data) { + setResource(name, MAGIC_DATA, data); + modified = true; + } + + public void setTheme(String name, Hashtable theme) { + setResource(name, MAGIC_THEME, theme); + modified = true; + } + + public void setThemeProperty(String themeName, String key, Object value) { + Hashtable theme = getTheme(themeName); + if (theme == null) { + theme = new Hashtable(); + } + if (value == null) { + theme.remove(key); + } else { + theme.put(key, value); + } + setResource(themeName, MAGIC_THEME, theme); + modified = true; + } + + public void setL10N(String name, Hashtable l10n) { + setResource(name, MAGIC_L10N, l10n); + modified = true; + } + + public byte[] getDataByteArray(String id) { + return (byte[]) getResourceObject(id); + } + + public boolean containsResource(String name) { + return getResourceObject(name) != null; + } + + public boolean isModified() { + return modified; + } + + public void setUi(String name, byte[] data) { + throw new UnsupportedOperationException("GUI Builder resources are not supported in MutableResource"); + } + + public void setTimeline(String name, Timeline timeline) { + throw new UnsupportedOperationException("Timeline resources are not supported in MutableResource"); + } + + public void setIndexedImage(String name, Image image) { + throw new UnsupportedOperationException("Indexed image resources are not supported in MutableResource"); + } + + public void setSVG(String name, Image image) { + throw new UnsupportedOperationException("SVG resources are not supported in MutableResource"); + } + + @Override + public void clear() { + super.clear(); + modified = false; + } + + public void save(OutputStream out) throws IOException { + String[] resourceNames = getResourceNames(); + Arrays.sort(resourceNames); + + DataOutputStream output = new DataOutputStream(out); + output.writeShort(resourceNames.length + 1); + output.writeByte(MAGIC_HEADER); + output.writeUTF(""); + output.writeShort(6); + output.writeShort(MAJOR_VERSION); + output.writeShort(MINOR_VERSION); + output.writeShort(0); + + for (String resourceName : resourceNames) { + byte magic = getResourceType(resourceName); + Object value = getResourceObject(resourceName); + if (magic == MAGIC_UI || magic == MAGIC_TIMELINE || magic == MAGIC_SVG || magic == MAGIC_INDEXED_IMAGE_LEGACY) { + throw new UnsupportedOperationException("Unsupported resource type in MutableResource: " + Integer.toHexString(magic & 0xff)); + } + output.writeByte(magic); + output.writeUTF(resourceName); + switch (magic) { + case MAGIC_IMAGE: + writeImage(output, (Image) value); + break; + case MAGIC_DATA: + byte[] data = (byte[]) value; + output.writeInt(data.length); + output.write(data); + break; + case MAGIC_L10N: + saveL10N(output, (Hashtable) value); + break; + default: + throw new IOException("MutableResource save() currently supports IMAGE/DATA/L10N only. Unsupported type: " + Integer.toHexString(magic & 0xff)); + } + } + modified = false; + } + + protected void writeImage(DataOutputStream output, Image image) throws IOException { + if (image instanceof Timeline) { + throw new UnsupportedOperationException("Timeline resources are not supported in MutableResource"); + } + if (image.isSVG()) { + throw new UnsupportedOperationException("SVG resources are not supported in MutableResource"); + } + EncodedImage enc = image instanceof EncodedImage ? (EncodedImage) image : EncodedImage.createFromImage(image, false); + byte[] bytes = enc.getImageData(); + output.writeByte(0xF1); + output.writeInt(bytes.length); + output.write(bytes); + output.writeInt(enc.getWidth()); + output.writeInt(enc.getHeight()); + output.writeBoolean(enc.isOpaque()); + } + + protected void saveL10N(DataOutputStream output, Hashtable l10n) throws IOException { + ArrayList keys = new ArrayList(); + for (Object locale : l10n.keySet()) { + Hashtable current = (Hashtable) l10n.get(locale); + for (Object key : current.keySet()) { + if (!keys.contains(key)) { + keys.add(key); + } + } + } + + output.writeShort(keys.size()); + output.writeShort(l10n.size()); + + for (Object key : keys) { + output.writeUTF((String) key); + } + + for (Object locale : l10n.keySet()) { + Hashtable currentLanguage = (Hashtable) l10n.get(locale); + output.writeUTF((String) locale); + for (Object key : keys) { + String k = (String) currentLanguage.get(key); + output.writeUTF(k == null ? "" : k); + } + } + } + + @Override + public InputStream getData(String id) { + byte[] data = getDataByteArray(id); + if (data == null) { + return null; + } + return new ByteArrayInputStream(data); + } +} 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 new file mode 100644 index 0000000000..b49b05cd94 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/css/CSSThemeCompilerTest.java @@ -0,0 +1,47 @@ +package com.codename1.ui.css; + +import com.codename1.junit.UITestBase; +import com.codename1.ui.Image; +import com.codename1.ui.plaf.CSSBorder; +import com.codename1.ui.util.MutableResource; +import java.util.Hashtable; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CSSThemeCompilerTest extends UITestBase { + + @Test + public void testCompilesThemeConstantsDeriveAndMutableImages() { + CSSThemeCompiler compiler = new CSSThemeCompiler(); + MutableResource resource = new MutableResource(); + + compiler.compile( + ":root{--primary:#abc;}" + + "@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;}", + resource, + "Theme" + ); + + Hashtable theme = resource.getTheme("Theme"); + assertEquals("aabbcc", theme.get("Button.fgColor")); + assertEquals("112233", theme.get("Button.bgColor")); + assertEquals("255", theme.get("Button.transparency")); + assertEquals("1,2,1,2", theme.get("Button.padding")); + assertEquals("2,4,6,8", theme.get("Label.margin")); + assertEquals("Label", theme.get("Button.derive")); + assertEquals("#abc", theme.get("@primary")); + assertEquals("4px", theme.get("@spacing")); + assertEquals("#abc", theme.get("@primarycolor")); + assertTrue(theme.get("Button.press#border") instanceof CSSBorder); + + Image mutable = resource.getImage("btnBg"); + assertNotNull(mutable); + assertNotNull(theme.get("Button.press#bgImage")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/util/MutableResouceTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/util/MutableResourceTest.java similarity index 92% rename from maven/core-unittests/src/test/java/com/codename1/ui/util/MutableResouceTest.java rename to maven/core-unittests/src/test/java/com/codename1/ui/util/MutableResourceTest.java index 5327aeb897..163075992f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/util/MutableResouceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/util/MutableResourceTest.java @@ -16,12 +16,12 @@ import com.codename1.junit.UITestBase; -public class MutableResouceTest extends UITestBase { +public class MutableResourceTest extends UITestBase { @Test public void testSettersRejectUnsupportedTypes() { - MutableResouce resources = new MutableResouce(); + MutableResource resources = new MutableResource(); assertThrows(UnsupportedOperationException.class, () -> resources.setUi("form", new byte[]{1})); assertThrows(UnsupportedOperationException.class, () -> resources.setTimeline("timeline", null)); @@ -32,32 +32,32 @@ public void testSettersRejectUnsupportedTypes() { @Test public void testOpenRejectsGuiBuilderEntry() throws Exception { byte[] content = createSingleEntryResource(Resources.MAGIC_UI, "form", new byte[]{1, 2, 3}); - assertThrows(UnsupportedOperationException.class, () -> MutableResouce.open(new ByteArrayInputStream(content))); + assertThrows(UnsupportedOperationException.class, () -> MutableResource.open(new ByteArrayInputStream(content))); } @Test public void testOpenRejectsTimelineEntry() throws Exception { byte[] content = createSingleEntryResource(Resources.MAGIC_TIMELINE, "timeline", new byte[]{0}); - assertThrows(UnsupportedOperationException.class, () -> MutableResouce.open(new ByteArrayInputStream(content))); + assertThrows(UnsupportedOperationException.class, () -> MutableResource.open(new ByteArrayInputStream(content))); } @Test public void testOpenRejectsIndexedImageSubtype() throws Exception { byte[] content = createImageEntryResource("img", (byte) 0xF3, new byte[0]); - assertThrows(UnsupportedOperationException.class, () -> MutableResouce.open(new ByteArrayInputStream(content))); + assertThrows(UnsupportedOperationException.class, () -> MutableResource.open(new ByteArrayInputStream(content))); } @Test public void testOpenRejectsSvgSubtype() throws Exception { byte[] content = createImageEntryResource("img", (byte) 0xF5, new byte[0]); - assertThrows(UnsupportedOperationException.class, () -> MutableResouce.open(new ByteArrayInputStream(content))); + assertThrows(UnsupportedOperationException.class, () -> MutableResource.open(new ByteArrayInputStream(content))); } @Test public void testSetThemePropertyStoresSingleProperty() { - MutableResouce resources = new MutableResouce(); + MutableResource resources = new MutableResource(); resources.setThemeProperty("mainTheme", "bgColor", "00ff00"); @@ -66,7 +66,7 @@ public void testSetThemePropertyStoresSingleProperty() { @Test public void testSetThemeStoresThemeResource() { - MutableResouce resources = new MutableResouce(); + MutableResource resources = new MutableResource(); Hashtable theme = new Hashtable(); theme.put("bgColor", "ff0000"); @@ -77,7 +77,7 @@ public void testSetThemeStoresThemeResource() { @Test public void testEditableSaveRoundTripsSupportedTypesWithResourcesReader() throws Exception { - MutableResouce editable = new MutableResouce(); + MutableResource editable = new MutableResource(); editable.setImage("img", EncodedImage.create(SINGLE_PIXEL_PNG)); editable.setData("blob", new byte[]{4, 5, 6}); From 16f15df1e9a0ab5b141f7979154dae5aed9296df Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:13:48 +0200 Subject: [PATCH 2/2] Fix PMD foreach warnings in CSSThemeCompiler --- .../com/codename1/ui/css/CSSThemeCompiler.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index bebcc768fe..7a5065bd83 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -48,8 +48,8 @@ public void compile(String css, MutableResource resources, String themeName) { compileConstants(css, theme); Rule[] rules = parseRules(css); - for (int i = 0; i < rules.length; i++) { - applyRule(theme, resources, rules[i]); + for (Rule rule : rules) { + applyRule(theme, resources, rule); } resolveThemeConstantVars(theme); resources.setTheme(themeName, theme); @@ -84,8 +84,7 @@ private void compileConstants(String css, Hashtable theme) { return; } Declaration[] declarations = parseDeclarations(stripped.substring(open + 1, close)); - for (int i = 0; i < declarations.length; i++) { - Declaration declaration = declarations[i]; + for (Declaration declaration : declarations) { theme.put("@" + declaration.property, declaration.value); } } @@ -127,8 +126,7 @@ private void applyRule(Hashtable theme, MutableResource resources, Rule rule) { } private void applyRootDeclarations(Hashtable theme, Declaration[] declarations) { - for (int i = 0; i < declarations.length; i++) { - Declaration declaration = declarations[i]; + for (Declaration declaration : declarations) { if (!declaration.property.startsWith("--")) { continue; } @@ -332,8 +330,8 @@ private Rule[] parseRules(String css) { String body = stripped.substring(open + 1, close).trim(); Declaration[] declarations = parseDeclarations(body); String[] selectorsList = splitOnChar(selectors, ','); - for (int i = 0; i < selectorsList.length; i++) { - String selector = selectorsList[i].trim(); + for (String selectorEntry : selectorsList) { + String selector = selectorEntry.trim(); if (selector.length() == 0) { continue; } @@ -373,8 +371,7 @@ private String stripComments(String css) { private Declaration[] parseDeclarations(String body) { ArrayList out = new ArrayList(); String[] segments = splitOnChar(body, ';'); - for (int i = 0; i < segments.length; i++) { - String line = segments[i]; + for (String line : segments) { int colon = line.indexOf(':'); if (colon <= 0) { continue;