diff --git a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java index 89286bb4573..a4938ba97dd 100644 --- a/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java +++ b/CodenameOne/src/com/codename1/ui/css/CSSThemeCompiler.java @@ -22,13 +22,20 @@ */ package com.codename1.ui.css; +import com.codename1.ui.CN; import com.codename1.ui.Component; +import com.codename1.ui.Display; import com.codename1.ui.EncodedImage; +import com.codename1.ui.Font; import com.codename1.ui.Image; import com.codename1.ui.plaf.CSSBorder; +import com.codename1.ui.plaf.Style; import com.codename1.ui.util.MutableResource; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.Hashtable; +import java.util.Map; /// Compiles a subset of Codename One CSS into theme properties stored in a {@link MutableResource}. /// @@ -46,9 +53,15 @@ /// /// - `color` /// - `background-color` -/// - `padding` -/// - `margin` -/// - `font-family` (mapped to `font` string for later resolution) +/// - `padding`, `margin`: one to four values in CSS top/right/bottom/left order, stored in the +/// theme's top/bottom/left/right order together with per-side `padUnit`/`marUnit` metadata. +/// `px`, `pt` and unitless values are pixels (rounded, as the build-time compiler does), `mm` +/// and `cm` are millimetres, and `rem`, `vw`, `vh`, `vmin`, `vmax` keep their own unit. A +/// percentage is rejected, as the build-time compiler rejects it. +/// - `font-family` and `font-size`: resolved to a {@link Font} with the build-time compiler's +/// semantics. The family defaults to `native:MainRegular` and the size to the medium system +/// font; `font-size` accepts `px`, `pt`, `mm`, `cm`, `in`, `rem`, `vw`, `vh`, `vmin`, `vmax`, +/// `%` and the `small`/`medium`/`large` keywords. /// - `cn1-derive` /// - `cn1-image-id` /// - `cn1-mutable-image` @@ -57,8 +70,12 @@ /// ## Theme constants /// /// - CSS custom property definitions in `:root`, e.g. `--primary: #ff00ff;` -/// - `@constants { name: value; other: value; }` +/// - `#Constants { name: value; }`, the block the build-time compiler reads, and the older +/// `@constants { name: value; other: value; }` spelling. /// - `var(--name)` dereferencing in declaration values. +/// +/// A constant keeps the case of its name (`includeNativeBool` becomes `@includeNativeBool`) and +/// a quoted value is stored without its quotes, so `'true'` and `"true"` both become `true`. public class CSSThemeCompiler { public static class CSSSyntaxException extends IllegalArgumentException { @@ -71,6 +88,68 @@ public CSSSyntaxException(String message, Throwable cause) { } } + private static final String DEFERRED_IMAGE = "image"; + private static final String DEFERRED_VAR = "var"; + private static final String DEFERRED_LITERAL = "literal"; + /// Declared by this compile and needing no further handling beyond var() resolution. + private static final String DEFERRED_PLAIN = "plain"; + /// Resolved like any var() constant, but kept as text: it aliases a quoted constant. + private static final String DEFERRED_TEXT = "text"; + + /// The `deferred` table of the compile in progress; resolveVars reads which constants are literal. + private HashMap deferredConstants = new HashMap(); + + /// The `cn1-derive` each UIID (with its `$Dark` prefix) declared last in any state, in source + /// order: the build-time compiler sets every such derive on the owning UIID, so the last one wins. + private HashMap ownerDerives = new HashMap(); + + /// The quoted source of each string constant this compile declared. A style property that + /// uses one gets the string token, not its text: the build-time compiler keeps the token's + /// type, so `color: var(--accent)` with `--accent: "#f00"` is not a color there either. + private HashMap literalTokens = new HashMap(); + /// `#Constants` entries this compile declared without the `--` prefix. The build-time + /// compiler adds only `--name` declarations to its variables, so `var(--name)` does not see + /// these. + private HashSet plainConstants = new HashSet(); + /// Every custom property this compile has read so far, keyed by "@name", as written: the + /// build-time compiler keeps its variables apart from the constants it exports. A variable + /// holds its CSS spelling (`#f00`, not the exported `ff0000`, so a border can use it) and an + /// alias holds `var(--base)` itself, resolved where it is used, so a later `--base` changes + /// it while the exported `@alias` keeps the value it had where it was declared. A later + /// plain `#Constants` entry or `:root` property of the same name changes only its own side. + private HashMap cssVars = new HashMap(); + /// "@name" of every entry a `#Constants` block of this compile exported. The build-time + /// compiler adds a custom property to its constants only inside `#Constants`, so a later + /// `:root { --name: ... }` changes what `var(--name)` reads and leaves `@name` alone. + private HashSet exportedConstants = new HashSet(); + + /// `padding-left` and its kin, per `uiid.state#padding` key, top/right/bottom/left; null is + /// a side not declared. Applied once every rule is read, over the box shorthand. + private HashMap boxSides = new HashMap(); + + /// The UIIDs (`$Dark` ones included) a rule of this compile styled, in first-seen order. + private ArrayList styledUiids = new ArrayList(); + /// The state prefix an `A.unselected {}` rule compiles into until mergeUnselected(). + /// The build-time compiler keeps such a rule apart from a bare `A {}` rule: it wins over the + /// bare rule whatever the source order, yet only the bare rule's values reach the + /// selected, pressed and disabled states, and per-side insets start from the bare box. + private static final String UNSELECTED = "uns#"; + + /// The style keys the build-time compiler carries from a UIID's unselected style into its + /// selected, pressed and disabled ones. The font has its own pass in compileFonts. + private static final String[] STATE_INHERITED_KEYS = { + "fgColor", "fgAlpha", "bgColor", "transparency", "opacity", "padding", "padUnit", "margin", + "marUnit", "align", "border", "bgImage" + }; + /// While set, resolveVars does not see a constant this compile declares unless it holds it: + /// one declared further down is not defined yet. A constant only an earlier compile into the + /// same theme declared is visible. + private HashMap declaredOnly; + /// Every constant and custom property this compile declares, as "@name". + private HashSet declaredThisCompile = new HashSet(); + /// Set by resolveVars when it met a var() naming an undefined variable with no fallback. + private boolean unresolvedVar; + public void compile(String css, MutableResource resources, String themeName) { Hashtable theme = resources.getTheme(themeName); if (theme == null) { @@ -78,16 +157,134 @@ public void compile(String css, MutableResource resources, String themeName) { } String strippedCss = stripComments(css); - compileConstants(strippedCss, theme); Rule[] rules = parseRulesWithMedia(strippedCss); + // Constants whose final value is only known after every rule ran: an image constant, + // whose image a rule may still create (cn1-mutable-image), and one written as var(--x). + // Keyed by "@name"; only the declaration that wins for a name decides its entry. Every + // constant this compile declares has an entry, so constants a previous compile left in + // the same theme are never re-resolved without the typing they were declared with. + HashMap deferred = new HashMap(); + deferredConstants = deferred; + literalTokens = new HashMap(); + plainConstants = new HashSet(); + cssVars = new HashMap(); + exportedConstants = new HashSet(); + declaredThisCompile = new HashSet(); for (Rule rule : rules) { - applyRule(theme, resources, rule); + for (Declaration declaration : rule.declarations) { + if (rule.constants || rule.legacyConstants) { + String n = declaration.name; + if (rule.constants) { + // Decoded, as compileConstants keys it: `--a\62 c` declares `@abc`. + String decoded = decodeIdentifier(n); + if (decoded != null) { + n = decoded; + } + } + declaredThisCompile.add("@" + (n.startsWith("--") ? n.substring(2) : n)); + } else if (declaration.name.startsWith("--")) { + declaredThisCompile.add("@" + customPropertyName(declaration.name)); + } + } + } + // Everything in source order, #Constants blocks included: the build-time parser resolves + // var() in each declaration as it reads it, and a `--name` inside #Constants enters its + // variable map only when that block is reached. A rule above the block does not see it. + // The legacy `@constants` block is this compiler's own syntax, which the build-time + // compiler does not read; it has always been applied before every rule, so a rule may + // use it from above. + for (Rule rule : rules) { + if (rule.legacyConstants) { + compileConstants(rule.declarations, theme, deferred, true); + } + } + HashMap fonts = new HashMap(); + ownerDerives = new HashMap(); + styledUiids = new ArrayList(); + boxSides = new HashMap(); + for (Rule rule : rules) { + if (rule.legacyConstants) { + continue; + } + if (rule.constants) { + compileConstants(rule.declarations, theme, deferred, false); + continue; + } + applyRule(theme, resources, rule, fonts, deferred); } + markLiteralAliases(theme, deferred); + compileFonts(theme, fonts); + applyBoxSides(theme); + inheritUnselectedIntoStates(theme); inheritHoverDerivations(theme); - resolveThemeConstantVars(theme); + resolveThemeConstantVars(theme, deferred); + for (Map.Entry entry : deferred.entrySet()) { + String key = entry.getKey(); + Object value = theme.get(key); + if (!(value instanceof String)) { + continue; + } + String resolved = unquote((String) value); + if (DEFERRED_IMAGE.equals(entry.getValue())) { + theme.put(key, imageConstant(resources, key.substring(1), resolved)); + } else if (DEFERRED_VAR.equals(entry.getValue()) && isColorLiteral(resolved)) { + // A constant written as var(--x) is only a color once x is resolved. + theme.put(key, constantColor(resolved)); + } + } + mergeUnselected(theme); + // The build-time compiler also stores every `Default.*` property unprefixed: the + // theme's root keys, which UIManager's default style reads. + ArrayList defaults = new ArrayList(); + for (Object k : theme.keySet()) { + if (k instanceof String && ((String) k).startsWith("Default.")) { + defaults.add((String) k); + } + } + for (String k : defaults) { + theme.put(k.substring("Default.".length()), theme.get(k)); + } resources.setTheme(themeName, theme); } + /// `alias: var(--literal)` where `literal` is quoted resolves to that text, not to a color: + /// such an alias, followed through any chain of plain aliases, is not reclassified. + private void markLiteralAliases(Hashtable theme, HashMap deferred) { + ArrayList aliases = new ArrayList(); + for (Map.Entry entry : deferred.entrySet()) { + if (DEFERRED_VAR.equals(entry.getValue())) { + aliases.add(entry.getKey()); + } + } + for (String key : aliases) { + ArrayList seen = new ArrayList(); + Object value = theme.get(key); + String target = key; + while (value instanceof String && !seen.contains(target)) { + seen.add(target); + String next = aliasTarget((String) value); + if (next == null) { + break; + } + target = next; + // Across separate compile() calls the theme keeps only final values, and a quoted + // "#abc" is stored exactly like an unquoted #abc custom property, so whether an + // earlier value was quoted is not recoverable from it. Compiling the stylesheet + // whole, as live reload and the GUI builder do, keeps the exact typing. + // A constant a previous compile stored is a final value: a var() still in it was + // quoted text there, and must not be expanded through the alias. Any other earlier + // value is classified like a fresh one, so `#abc` still becomes a color. + Object earlierValue = deferred.get(target) == null ? theme.get(target) : null; + boolean earlier = earlierValue instanceof String && ((String) earlierValue).indexOf("var(--") > -1; + if (DEFERRED_LITERAL.equals(deferred.get(target)) || earlier) { + deferred.put(key, DEFERRED_TEXT); + break; + } + value = theme.get(target); + } + } + } + private void inheritHoverDerivations(Hashtable theme) { ArrayList ids = new ArrayList(); for (Object keyObj : theme.keySet()) { @@ -179,106 +376,1350 @@ private boolean hasHoverDefinition(Hashtable theme, String id) { return false; } - private void resolveThemeConstantVars(Hashtable theme) { - for (Object keyObj : theme.keySet()) { - String key = String.valueOf(keyObj); - if (!key.startsWith("@")) { + private void resolveThemeConstantVars(Hashtable theme, HashMap deferred) { + for (Map.Entry entry : deferred.entrySet()) { + String key = entry.getKey(); + // A quoted value is a string: `"var(--name)"` is text, not a reference. + if (DEFERRED_LITERAL.equals(entry.getValue())) { continue; } Object value = theme.get(key); if (!(value instanceof String)) { continue; } - theme.put(key, resolveVars(theme, (String) value)); + unresolvedVar = false; + String resolved = resolveVars(theme, (String) value); + if (unresolvedVar) { + // The build-time compiler keeps an undefined var() with no fallback as a function + // its constants emitter has no branch for, so the built theme has no such + // constant: the caller's own default applies, not an empty string. + theme.remove(key); + } else { + theme.put(key, typedAlias(key, (String) value, resolved)); + } } } - private void compileConstants(String css, Hashtable theme) { - int constantsStart = css.indexOf("@constants"); - if (constantsStart < 0) { - return; + private void compileConstants(Declaration[] declarations, Hashtable theme, + HashMap deferred, boolean legacy) { + for (Declaration declaration : declarations) { + String declName = declaration.name; + if (!legacy) { + // The build-time parser takes a declaration name only as an identifier, and + // decoded: `1message` is not one, and `a\62 c` is `abc`. + declName = decodeIdentifier(declaration.name); + if (declName == null) { + throw new CSSSyntaxException("Invalid #Constants name: " + declaration.name); + } + } + if (!legacy && declName.startsWith("--")) { + // The build-time compiler adds the raw variable before it evaluates it, so + // `--accent: var(--accent, #f00)` is a cycle there, which fails the build. + cssVars.put("@" + declName.substring(2), declaration.value.trim()); + } + String name = declName; + // `#Constants { --accent: ...; }` is how the build-time compiler exports a + // palette variable as the constant `@accent`. + if (name.startsWith("--")) { + name = name.substring(2); + } + String key = "@" + name; + if (!legacy) { + // Only #Constants is what the build-time compiler exports; a later :root still + // replaces a legacy @constants entry, as it always has. + exportedConstants.add(key); + } + if (legacy || declName.startsWith("--")) { + plainConstants.remove(key); + } else { + plainConstants.add(key); + } + String raw = declaration.value.trim(); + String value = unquote(raw); + deferred.put(key, DEFERRED_PLAIN); + literalTokens.remove(key); + boolean alias = raw.equals(value) && value.indexOf("var(--") > -1; + // Of a chain such as `#f00 extra` the build-time compiler types the leading unit, so + // that is what decides whether an `...Image` name is looked up as an image. + String typed = !legacy && raw.equals(value) && !alias ? leadingToken(value) : value; + if (name.endsWith("Image") && (!raw.equals(value) || (!isColorLiteral(typed) && !isCssNumber(typed)))) { + // The build-time compiler looks an image up only for a string or identifier + // token; a color or number named `...Image` is stored as a color or number. An alias is + // resolved where it is declared, as below, so a later redefinition of its + // target does not change the image. + if (alias && !legacy && declaration.snapshot == null && !declaration.dropped) { + declaration.snapshot = resolveAtDeclaration(theme, key, value, deferred); + declaration.dropped = declaration.snapshot == null; + declaration.snapshotKind = DEFERRED_IMAGE; + } + if (declaration.dropped) { + theme.remove(key); + deferred.remove(key); + continue; + } + boolean fromText = false; + if (alias && declaration.snapshot != null) { + value = declaration.snapshot; + // An alias of a quoted constant names the image by text, as written, and so + // does a quoted fallback: `var(--missing, "my icon")` is `my icon`. + fromText = aliasesQuotedText(raw) || quotedFallbackApplies(raw); + } + if (alias && declaration.snapshot != null && !fromText + && (isColorLiteral(value) || isCssNumber(value))) { + // The alias resolved to a color or a number: the build-time compiler types + // the evaluated value, so it is that, not an image named `#f00`. + theme.put(key, isColorLiteral(value) ? constantColor(value) : buildConstant(name, value, value)); + continue; + } + if (!fromText && raw.equals(value) || alias && !fromText) { + if (!legacy) { + // Of a chain such as `icon extra` the build-time compiler looks up the + // leading unit alone. + value = leadingToken(value); + if (isUriOrDimension(value)) { + // The build-time constants emitter looks an image up only for a + // string or identifier; `url(icon.png)` or `2mm` is silently left out. + theme.remove(key); + deferred.remove(key); + continue; + } + } + // An identifier image name is looked up decoded: `my\ icon` is `my icon`. + String decoded = decodeIdentifier(value); + if (decoded != null) { + value = decoded; + } + } + deferred.put(key, DEFERRED_IMAGE); + if (!raw.equals(value)) { + // A quoted image name stays a string token, so an alias of it names the + // whole `"my icon"`, not the leading `my` of a chain. + literalTokens.put(key, raw); + } + } else if (!raw.equals(value)) { + deferred.put(key, DEFERRED_LITERAL); + literalTokens.put(key, raw); + } else if (isColorLiteral(value) || !legacy && isColorLiteral(leadingToken(value))) { + if (!legacy) { + // `rgb(255, 0, 0) extra` is a chain whose leading unit is the color, and the + // build-time constants emitter writes only that unit. + value = leadingToken(value); + } + // Stored as plain hex, as the build-time compiler stores it: color constants + // are read with Integer.parseInt(value, 16), which a leading '#' would break. + // Only `#hex` and `rgb()`/`rgba()` are color tokens here; a keyword such as `red` + // or `transparent` is an identifier and is stored as written, as in the build. + // testColorKeywordConstantStaysAnIdentifier holds that. + value = constantColor(value); + } else if (value.indexOf("var(--") > -1) { + String kind = DEFERRED_VAR; + if (!legacy && declaration.snapshot == null && !declaration.dropped) { + String target = aliasTarget(value); + String targetKind = target == null ? null : deferred.get(target); + // Text when the target is, through aliases or a redefinition of an exported + // constant, or when an undefined target's fallback is a quoted string. + declaration.snapshotKind = DEFERRED_LITERAL.equals(targetKind) || DEFERRED_TEXT.equals(targetKind) + || aliasesQuotedText(value) || quotedFallbackApplies(value) + ? DEFERRED_LITERAL : DEFERRED_VAR; + declaration.snapshot = resolveAtDeclaration(theme, key, value, deferred); + declaration.dropped = declaration.snapshot == null; + if (!declaration.dropped && declaration.snapshot.indexOf("var(--") > -1) { + // Fully resolved here, so a var() still in it came from text. + declaration.snapshotKind = DEFERRED_LITERAL; + } + } + if (declaration.dropped) { + theme.remove(key); + deferred.remove(key); + continue; + } + // A legacy `@constants` alias is not frozen here: the build-time compiler has no + // `@constants` block to match, so it keeps resolving after every rule, as it + // always has, and `:root { --base: blue; }` later still reaches `alias`. + if (declaration.snapshot != null) { + value = declaration.snapshot; + kind = declaration.snapshotKind; + } + deferred.put(key, kind); + } else if (!legacy) { + // The build-time compiler writes string, identifier, number and color constants + // only; anything else (`50%`, `2mm`, `url(a.png)`) is dropped from the built + // theme without a word, so a preview that showed it would show a constant the + // application never gets. Of a chain such as `foo bar` it writes the leading unit + // alone. The legacy `@constants` block never reaches the build-time compiler and + // keeps such values. + String first = leadingToken(value); + String unquotedFirst = unquote(first); + if (first.length() < value.length() && !unquotedFirst.equals(first)) { + value = unquotedFirst; + } else if (first.length() < value.length() && isColorLiteral(first)) { + value = constantColor(first); + } else if (declName.startsWith("--") && isDimensionToken(first)) { + // `#Constants { --gap: 5px; }` exists to be used as var(--gap) in a + // padding or margin; the build-time compiler resolves those and leaves the + // constant itself out of the theme, without failing the sheet. A plain + // `gap: 5px` has no such use and is still rejected below, so the author + // learns the constant does not exist rather than reading null at runtime. + continue; + } else { + value = buildConstant(name, first, first); + } + } + theme.put(key, value); } - int open = css.indexOf('{', constantsStart); - if (open < 0) { - return; + } + + /// The first whitespace- or comma-separated token of `value`, skipping quoted text and + /// parentheses: `foo bar` is `foo`, `rgb(1, 2, 3) x` is `rgb(1, 2, 3)`. + private static String leadingToken(String value) { + int depth = 0; + char quote = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '\\') { + // An escaped character, `my\ icon`'s space among them, is part of the token, and + // so is the one whitespace ending a hex escape (`\41 x` is `Ax`). + int j = i + 1; + while (j < value.length() && j - i <= 6 && hexDigit(value.charAt(j)) >= 0) { + j++; + } + if (j > i + 1 && skipEscapeTerminator(value, j) > j) { + i = skipEscapeTerminator(value, j) - 1; + } else { + i = j > i + 1 ? j - 1 : i + 1; + } + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (depth == 0 && (c == ',' || c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f')) { + return value.substring(0, i); + } + } + return value; + } + + /// A constant that is one var() takes its value's token type, a fallback's too: the + /// build-time compiler stores the string `"hello"` as hello and the number 1.50 as 1.5, as + /// it would written directly. + private String typedAlias(String key, String original, String resolved) { + String sole = original.trim(); + if (sole.startsWith("var(") && closingParen(sole, "var(".length()) == sole.length() - 1) { + if (aliasesQuotedText(sole)) { + // The target is a string token whose text resolveVars() already decoded: + // `--code: "001"` is copied as 001, not read again as the number 1, and a + // quote character inside it is part of the text. + return resolved; + } + String text = resolved.trim(); + String unquoted = unquote(text); + if (!unquoted.equals(text)) { + return unquoted; + } + if (isCssNumber(text)) { + return buildConstant(key.substring(1), text, text); + } } - int close = css.indexOf('}', open + 1); - if (close <= open) { - throw new CSSSyntaxException("Unterminated @constants block"); + return resolved; + } + + /// Resolves a `#Constants` alias where it is declared, as the build-time compiler does: only + /// names declared above it are seen, so `early: var(--later)` is undefined however `later` is + /// declared afterwards. Returns null, and the constant is left out, when a reference stays + /// undefined without a fallback: the build-time compiler keeps such a var() as a function its + /// constants emitter drops. + private String resolveAtDeclaration(Hashtable theme, String key, String value, HashMap deferred) { + String own = deferred.remove(key); + declaredOnly = deferred; + unresolvedVar = false; + try { + String resolved = resolveVars(theme, value); + if (unresolvedVar) { + return null; + } + String text = resolved.trim(); + String first = leadingToken(text); + if (first.length() < text.length() && !aliasesQuotedText(value)) { + // `pair: var(--x) bar` is a chain of lexical units, and the build-time + // constants emitter writes only the leading one: `@pair` is `foo`. + String unquoted = unquote(first); + if (!unquoted.equals(first)) { + return unquoted; + } + return isCssNumber(first) ? buildConstant(key.substring(1), first, first) : first; + } + String typed = typedAlias(key, value, resolved); + if (typed.equals(text) && !isCssNumber(text) && !isColorLiteral(text) && isUriOrDimension(text)) { + // `var(--missing, 50%)` is the percentage token itself, which the build-time + // emitter leaves out; it is rejected here exactly as `progress: 50%` written + // directly is, so the preview never shows a constant the application lacks. + // An `...Image` constant is the exception: a direct `navImage: url(icon.png)` is + // left out silently, so its fallback is too. + if (key.endsWith("Image")) { + return null; + } + buildConstant(key.substring(1), text, text); + } + return typed; + } finally { + declaredOnly = null; + if (own != null) { + deferred.put(key, own); + } } - Declaration[] declarations = parseDeclarations(css.substring(open + 1, close)); - for (Declaration declaration : declarations) { - theme.put("@" + declaration.property, declaration.value); + } + + /// A `url()` token or a number with a unit: neither is a string or identifier token. + private static boolean isUriOrDimension(String token) { + if (token.regionMatches(true, 0, "url(", 0, 4)) { + return true; + } + int i = token.startsWith("+") || token.startsWith("-") ? 1 : 0; + return i < token.length() && (token.charAt(i) >= '0' && token.charAt(i) <= '9' + || token.charAt(i) == '.' && i + 1 < token.length() + && token.charAt(i + 1) >= '0' && token.charAt(i + 1) <= '9'); + } + + /// `--a\62 c` as the name `abc`, decoded as the build-time parser reads it. + private static String customPropertyName(String property) { + String decoded = decodeIdentifier(property); + return (decoded != null ? decoded : property).substring(2); + } + + /// Whether `value` is `var(--x, "text")` with `x` not declared so far: the fallback is then + /// the value, and it keeps its string type. + private boolean quotedFallbackApplies(String value) { + String v = value.trim(); + String target = aliasTarget(v); + if (target == null || cssVars.get(target) != null) { + return false; + } + String inner = v.substring("var(".length(), v.length() - 1); + int comma = topLevelComma(inner); + if (comma < 0) { + return false; + } + String fallback = inner.substring(comma + 1).trim(); + // The fallback that applies keeps its lexical type through any nesting: + // `var(--missing, var(--text))` is text when `--text` is. + return !unquote(fallback).equals(fallback) || aliasesQuotedText(fallback) + || quotedFallbackApplies(fallback); + } + + /// Whether `value` is an alias that ends, through every intermediate alias, at a quoted + /// string: `--a: var(--name)` keeps the string `--name` was, as the build-time compiler keeps + /// the lexical unit, so `navImage: var(--a)` names the image `my icon`, not `my`. + private boolean aliasesQuotedText(String value) { + String target = aliasTarget(value); + ArrayList seen = new ArrayList(); + while (target != null && literalTokens.get(target) == null && !seen.contains(target)) { + seen.add(target); + String next = cssVars.get(target); + target = next == null ? null : aliasTarget(next); + } + return target != null && literalTokens.get(target) != null; + } + + /// Past the one whitespace that may end a hex escape: any CSS whitespace, with CR LF as one. + private static int skipEscapeTerminator(String value, int j) { + if (j >= value.length()) { + return j; + } + char c = value.charAt(j); + if (c == '\r' && j + 1 < value.length() && value.charAt(j + 1) == '\n') { + return j + 2; + } + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' ? j + 1 : j; + } + + /// `@x` for a value that is exactly `var(--x)` or `var(--x, fallback)`, else null. + private static String aliasTarget(String value) { + String v = value.trim(); + if (!v.startsWith("var(--") || closingParen(v, "var(".length()) != v.length() - 1) { + return null; + } + String inner = v.substring("var(--".length(), v.length() - 1); + int comma = topLevelComma(inner); + String name = (comma < 0 ? inner : inner.substring(0, comma)).trim(); + // Decoded, as resolveVars looks it up: `var(--a\62 c)` is `@abc`. + String decoded = decodeIdentifier("--" + name); + return "@" + (decoded != null ? decoded.substring(2) : name); + } + + /// UIManager.getThemeImageConstant casts an image constant to Image, and on iOS that cast is + /// unchecked: a String left in its place would be read as an Image and crash natively. The + /// build-time compiler fails the build on a missing image too. + private static Image imageConstant(MutableResource resources, String name, String imageName) { + Image image = resources.getImage(imageName); + if (image == null) { + throw new CSSSyntaxException("Constant " + name + " names no image in the resource: " + imageName); + } + return image; + } + + private boolean isColorLiteral(String value) { + String v = cssColorFunction(value); + if (v.regionMatches(true, 0, "rgb(", 0, 4) || v.regionMatches(true, 0, "rgba(", 0, 5)) { + return true; } + int len = value.length(); + return value.startsWith("#") && (len == 4 || len == 7) && isHexColor(value.substring(1)); } - private void applyRule(Hashtable theme, MutableResource resources, Rule rule) { + /// A color constant as plain hex. A color constant has no alpha, so `rgba()` keeps its RGB + /// part, as the build-time compiler stores it. + private String constantColor(String value) { + value = cssColorFunction(value); + if (value.regionMatches(true, 0, "rgba(", 0, 5) && value.endsWith(")")) { + String[] parts = colorArguments(value.substring(5, value.length() - 1), value); + if (parts.length != 4) { + throw new CSSSyntaxException("rgba() must have exactly 4 components: " + value); + } + return normalizeHexColor("rgb(" + parts[0] + "," + parts[1] + "," + parts[2] + ")"); + } + return normalizeHexColor(value); + } + + /// `cn1rgb()` and `cn1rgba()` are the build-time compiler's spellings of `rgb()` and `rgba()`. + private static String cssColorFunction(String value) { + return value.regionMatches(true, 0, "cn1rgb", 0, 6) ? value.substring(3) : value; + } + + private static String derivedUiid(String value) { + String unquoted = unquote(value); + if (!unquoted.equals(value)) { + return unquoted; + } + String decoded = decodeIdentifier(value); + return decoded != null ? decoded : value; + } + + private static boolean isConstantsSelector(String selector) { + if ("@constants".equalsIgnoreCase(selector)) { + return true; + } + if (!selector.startsWith("#")) { + return false; + } + // The build-time parser decodes the ID before its special-selector check, so + // `#\\43 onstants` is `#Constants` too. + String decoded = decodeIdentifier(selector.substring(1)); + return "Constants".equalsIgnoreCase(decoded != null ? decoded : selector.substring(1)); + } + + /// Strips one pair of matching quotes, decoding backslash escapes inside them. Anything + /// else is returned as it is. + private static String unquote(String value) { + String v = value.trim(); + int len = v.length(); + if (len < 2) { + return v; + } + char quote = v.charAt(0); + if ((quote != '"' && quote != '\'') || v.charAt(len - 1) != quote) { + return v; + } + StringBuilder out = new StringBuilder(len - 2); + int end = len - 1; + for (int i = 1; i < end; i++) { + char c = v.charAt(i); + if (c == '\n' || c == '\r' || c == '\f') { + // A CSS string cannot hold an unescaped newline: the build-time parser rejects + // the declaration, so the built theme would not have it. + throw new CSSSyntaxException("Unescaped newline in string: " + value); + } + if (c != '\\' || i + 1 >= end) { + out.append(c); + continue; + } + i++; + c = v.charAt(i); + if (c == '\n' || c == '\r' || c == '\f') { + // An escaped newline (LF, CRLF, CR or FF) continues the string. + if (c == '\r' && i + 1 < end && v.charAt(i + 1) == '\n') { + i++; + } + continue; + } + int digits = 0; + int code = 0; + while (digits < 6 && i + digits < end && hexDigit(v.charAt(i + digits)) > -1) { + code = code * 16 + hexDigit(v.charAt(i + digits)); + digits++; + } + if (digits == 0) { + out.append(c); + continue; + } + // CSS: one to six hex digits name a code point, and one CSS whitespace after them + // (space, tab, LF, FF, CR or CR LF) ends it; an em space (U+2003) after `\\41` is kept. + i += digits - 1; + if (i + 1 < end) { + i = Math.min(end, skipEscapeTerminator(v, i + 1)) - 1; + } + if (code == 0 || code > 0x10FFFF || code >= 0xD800 && code <= 0xDFFF) { + code = 0xFFFD; + } + if (code >= 0x10000) { + code -= 0x10000; + out.append((char) (0xD800 + (code >> 10))).append((char) (0xDC00 + (code & 0x3FF))); + } else { + out.append((char) code); + } + } + return out.toString(); + } + + private void applyRule(Hashtable theme, MutableResource resources, Rule rule, HashMap fonts, + HashMap deferred) { if (":root".equals(rule.selector)) { - applyRootDeclarations(theme, rule.declarations); + // Unlike the build-time compiler, `:root` custom properties are also kept as `@name` + // theme constants: that is this compiler's documented contract for `:root` (see the + // class comment), and applications already read them through getThemeConstant(). + for (Declaration declaration : rule.declarations) { + applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred); + } return; } String[] selectorParts = selector(rule.selector); + boolean universal = rule.universal; String uiid = selectorParts[0]; String statePrefix = selectorParts[1]; + // Only a bare `A {}` rule reaches the other states in the build; `A.unselected {}` + // styles the unselected state alone (see UNSELECTED). + if (!universal && statePrefix.length() == 0) { + styledUiids.add(uiid); + } 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); + // A custom property takes effect where it is declared, also within a rule: the + // build-time parser resolves each declaration as it reads it. + if (applyThemeConstantProperty(theme, declaration.name, declaration.value, deferred)) { + continue; + } + String value = resolveVars(theme, declaration.value, new ArrayList(), true); + if (!universal && ("color".equals(property) || "background-color".equals(property))) { + // `@cn1-bind:Foo.fgColor=accent`, as the build-time compiler writes it, lets + // UIManager retune every color that names `var(--accent)` through one + // addThemeProps({"@accent": ...}). A literal drops an earlier binding. + String bindKey = "@cn1-bind:" + uiid + "." + statePrefix + + ("color".equals(property) ? "fgColor" : "bgColor"); + String bound = boundVariable(declaration.value); + if (bound != null) { + theme.put(bindKey, bound); + } else { + theme.remove(bindKey); + } + } + if ("cn1-derive".equals(property)) { + // The build-time compiler reads the target with getStringValue(): `"Base"` and + // `B\61se` both name the UIID `Base`. + value = derivedUiid(value.trim()); + } + if (universal) { + // The build-time compiler ignores universal rules entirely (CSSTheme.apply() + // returns before any element), so `*` sets no style property and no derive + // there; its custom properties, handled above, still count. Nothing is validated + // either: `* { font-size: smaller; }` builds. + continue; + } + if ("cn1-derive".equals(property) && !"press#".equals(statePrefix)) { + // The build-time compiler's Element.setParent() promotes a derive to the owning + // UIID from every state but pressed. + ownerDerives.put(uiid, value.trim()); + } + if (applyFontProperty(fonts, uiid + "." + statePrefix, 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 boolean applyThemeConstantProperty(Hashtable theme, String property, String value, + HashMap deferred) { + if (!property.startsWith("--")) { + return false; + } + String key = "@" + customPropertyName(property); + String raw = value.trim(); + String unquoted = unquote(raw); + cssVars.put(key, raw); + if (exportedConstants.contains(key)) { + // Only the variable changes; the constant #Constants exported keeps its value. Its + // token type changes with it, so a later alias of a now quoted `--x` is text. + if (!raw.equals(unquoted)) { + literalTokens.put(key, raw); + } else { + literalTokens.remove(key); + } + return true; + } + plainConstants.remove(key); + // An unquoted var() in it is resolved where it is used, against the final values. + deferred.put(key, DEFERRED_PLAIN); + literalTokens.remove(key); + if (!raw.equals(unquoted)) { + deferred.put(key, DEFERRED_LITERAL); + literalTokens.put(key, raw); + } + theme.put(key, unquoted); + return true; + } + + /// Records a `font-family` or `font-size` for one UIID state; `compileFonts` builds the fonts + /// once every rule has been read, so the two may come from different rules. + private boolean applyFontProperty(HashMap fonts, String keyPrefix, + String property, String value) { + int slot; + if ("font-family".equals(property)) { + slot = 0; + } else if ("font-size".equals(property)) { + slot = 1; + if (value.trim().length() == 0) { + // `var(--optional-size)` left undefined: the build-time compiler keeps the + // unresolved function and its font falls back to the default size, so the + // declaration is passed over rather than rejecting the stylesheet. + return true; + } + // Validated here so the error is reported while the rule is being read. + parseFontSize(value); + } else { + return false; + } + String[] spec = fonts.get(keyPrefix); + if (spec == null) { + spec = new String[2]; + fonts.put(keyPrefix, spec); + } + spec[slot] = value; + return true; + } + + /// Builds the font of every UIID state that declared a font property. A state that sets only + /// one of family and size takes the other from what applies in that state, as the build-time + /// compiler's flattening does: the matching state of the UIID, each `cn1-derive` parent and + /// finally `*` in turn, then their unselected styles in the same order. A dark style looks at the dark keys + /// of each UIID before the light ones. + /// + /// Only the states this compile declares a font property for are built. A font an earlier + /// compile into the same theme built is a finished Font, and which parent its family came + /// from is not stored, so a later compile that only changes a `cn1-derive` cannot tell which + /// earlier fonts depended on it. Compiling the stylesheet whole, as live reload and the GUI + /// builder do, rebuilds every font. + private void compileFonts(Hashtable theme, HashMap fonts) { + // A font is a live platform object, so it can only be made with a running Display. + // Without one -- a tool that only checks that a stylesheet compiles -- every + // declaration has still been validated as it was read. + if (!Display.isInitialized()) { + return; + } + // The build-time compiler writes a UIID's font into every state, and UIManager does not + // fall back from `sel#font` to `font`: `Label { font-family: monospace; }` alone would + // be monospace unselected and the system face once selected. A state with no font of + // its own gets an empty spec, which the unselected pass below fills from `Label`. + String[] states = {"sel#", "press#", "dis#"}; + for (String keyPrefix : new ArrayList(fonts.keySet())) { + if (!keyPrefix.endsWith(".") || keyPrefix.indexOf('.') != keyPrefix.length() - 1) { + continue; + } + for (String state : states) { + if (!fonts.containsKey(keyPrefix + state)) { + fonts.put(keyPrefix + state, new String[2]); + } + } + } + for (Map.Entry entry : fonts.entrySet()) { + String keyPrefix = entry.getKey(); + String[] spec = entry.getValue(); + String family = spec[0]; + String size = spec[1]; + int dot = keyPrefix.indexOf('.'); + String uiid = keyPrefix.substring(0, dot); + String state = keyPrefix.substring(dot + 1); + boolean dark = uiid.startsWith("$Dark"); + // The matching state along the whole derive chain comes before any unselected + // style: the build-time flattener lays a parent's selected style over the child's + // unselected one, so `Base:selected` beats `Child`. + // `*` compiles to Component, the implicit parent of every UIID in the build-time + // compiler, so it comes after the explicit derive chain. + String own = dark ? uiid.substring(5) : uiid; + // A font a previous compile into the same theme built, when this compile has no + // spec for that key: its family and size stand in for the missing half. + Font inherited = null; + for (int pass = 0; pass < 2 && (family == null || size == null); pass++) { + String lookup = pass == 0 ? state : ""; + if (pass == 1 && state.length() == 0) { + break; + } + // The state's own derive applies in both passes: its parent's unselected style + // is what a state derived from it falls back to. + int[] normalFrom = {Integer.MAX_VALUE, Integer.MAX_VALUE}; + ArrayList chain = fontChain(theme, own, state, dark, normalFrom); + for (int i = 0; i < chain.size() && (family == null || size == null); i++) { + String id = chain.get(i); + String lookupHere = i >= normalFrom[0] ? "" : lookup; + // A dark rule is its own `$Dark` element in the build-time compiler, with no + // link to the light UIID, so a dark font never takes the light one's half. + String[] fallbacks = dark && i < normalFrom[1] + ? new String[]{"$Dark" + id + "." + lookupHere} + : new String[]{id + "." + lookupHere}; + for (String fallback : fallbacks) { + String[] other = fonts.get(fallback); + if (other != null) { + family = family == null ? other[0] : family; + size = size == null ? other[1] : size; + } + // Also when this compile has a spec for the key: a recompile of the same + // state that sets only one field keeps the other from the earlier font. + if (inherited == null) { + Object existing = theme.get(fallback + Style.FONT); + if (existing instanceof Font) { + inherited = (Font) existing; + } + } + } + } + } + // A system font keeps only what it can say: monospace, its system size and its style, + // so a size-only rule over a bold system font stays bold. + int style = Font.STYLE_PLAIN; + if (inherited != null && !inherited.isTTFNativeFont()) { + style = inherited.getStyle(); + if (family == null && inherited.getFace() == Font.FACE_MONOSPACE) { + family = "monospace"; + } + if (size == null) { + size = inherited.getSize() == Font.SIZE_SMALL ? "small" + : inherited.getSize() == Font.SIZE_LARGE ? "large" : "medium"; + } + inherited = null; + } + FontSize parsed = size == null ? null : parseFontSize(size); + Font font; + if (inherited != null && family == null && parsed != null && parsed.unit >= 0) { + font = inherited.derive(fontSizeInPixels(parsed), inherited.getStyle()); + } else if (inherited != null && family == null && parsed != null) { + // A keyword size (`small`, `medium`, `large`) keeps the inherited family too, at + // the height of the system font of that size. + font = inherited.derive(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, + parsed.systemSize).getHeight(), inherited.getStyle()); + } else { + if (inherited != null && family != null && parsed == null) { + parsed = new FontSize(); + parsed.unit = Style.UNIT_TYPE_PIXELS; + parsed.value = inherited.getPixelSize(); + } + font = createFont(family, parsed, style); + } + theme.put(keyPrefix + Style.FONT, font); + } + } + + /// The build-time compiler's selected, pressed and disabled styles start from the UIID's + /// unselected one, and the built theme holds every state's value. UIManager does not fall + /// back from `sel#bgColor` to `bgColor`, so without this a selected `Label{color:#333}` + /// would take the default selected color. A state keeps its own value, and one a derive + /// target declares for that state: the build lays `Base:selected` over `Child`'s unselected + /// style, and UIManager reaches `Base.sel#` through the derive by itself. + private void inheritUnselectedIntoStates(Hashtable theme) { + String[] states = {"sel#", "press#", "dis#"}; + String[] baseStates = {"sel", "press", "dis"}; + // What the stylesheet itself declared; a value this pass copies into a state is not a + // declaration a derived UIID's state should stop at. + HashMap declared = new HashMap(theme); + // A derived UIID's states derive from the target's matching states, as the build-time + // compiler writes them (`Child.sel#derive=Base.sel`): UIManager reads only the state's + // own derive key, so without it a selected Child would start from the default style. + for (Map.Entry entry : ownerDerives.entrySet()) { + String uiid = entry.getKey(); + for (int i = 0; i < states.length; i++) { + if (theme.get(uiid + "." + states[i] + "derive") == null) { + theme.put(uiid + "." + states[i] + "derive", entry.getValue() + "." + baseStates[i]); + } + } + } + for (String uiid : styledUiids) { + for (String key : STATE_INHERITED_KEYS) { + Object value = theme.get(uiid + "." + key); + if (value == null) { + continue; + } + for (String state : states) { + if (theme.get(uiid + "." + state + key) != null + || declared.get(uiid + "." + state + "derive") != null + || derivedStateDeclares(declared, uiid, state, key)) { + continue; + } + theme.put(uiid + "." + state + key, value); + Object binding = theme.get("@cn1-bind:" + uiid + "." + key); + if (binding != null) { + theme.put("@cn1-bind:" + uiid + "." + state + key, binding); + } + } + } + } + } + + /// Whether a UIID `uiid` derives from, at any depth, declares `key` for `state`. + /// The custom property `value` consists of, `accent` for `var(--accent)` or + /// `var(--accent, red)`, or null when the value is anything more than one var(). + private static String boundVariable(String value) { + String v = value.trim(); + if (!v.startsWith("var(") || !v.endsWith(")")) { + return null; + } + String inner = v.substring(4, v.length() - 1).trim(); + int comma = inner.indexOf(','); + String name = (comma < 0 ? inner : inner.substring(0, comma)).trim(); + if (!name.startsWith("--") || name.length() == 2) { + return null; + } + for (int i = 2; i < name.length(); i++) { + char c = name.charAt(i); + if (c == '(' || c == ')' || c <= ' ') { + return null; + } + } + if (comma < 0 && inner.indexOf('(') >= 0) { + return null; + } + return name.substring(2); + } + + private boolean derivedStateDeclares(Map theme, String uiid, String state, String key) { + boolean dark = uiid.startsWith("$Dark"); + ArrayList seen = new ArrayList(); + String id = uiid; + while (!seen.contains(id)) { + seen.add(id); + String next = ownerDerives.get(id); + if (next == null) { + Object stored = theme.get(id + ".derive"); + next = stored instanceof String ? (String) stored : null; + } + if (next == null) { + return false; + } + id = dark && !next.startsWith("$Dark") && theme.get("$Dark" + next + "." + state + key) != null + ? "$Dark" + next : next; + if (theme.get(id + "." + state + key) != null) { + return true; + } + } + return false; + } + + /// The UIIDs a font in `state` inherits from, nearest first. At every hop a state that + /// declares its own `cn1-derive` (`Child:selected{cn1-derive:Base}`) follows that instead of + /// the UIID's. `*` is not part of it: the build-time compiler ignores universal rules. + /// `normalFrom[0]` is set to the first index past a pressed-only derive: the build-time + /// compiler attaches `Child:pressed{cn1-derive:Base}` to `Base` itself, not `Base:pressed`, + /// so from there on the chain is read in the unselected state. `normalFrom[1]` is the first + /// index past a dark element's derive: `$DarkChild{cn1-derive:Base}` is attached to the + /// light `Base` by name, so from there on the chain is light. + private ArrayList fontChain(Hashtable theme, String id, String state, boolean dark, int[] normalFrom) { + ArrayList chain = new ArrayList(); + while (id != null && !chain.contains(id)) { + chain.add(id); + if (dark && chain.size() > 1) { + dark = false; + normalFrom[1] = chain.size() - 1; + } + // A derive this compile declared, in any state: the last one in source order is the + // owning UIID's only parent in the build-time compiler, for every state. + // The build-time compiler makes `$DarkChild` an element of its own with no link to + // the light `Child`'s derive, so only a dark derive extends the dark chain. + // Except a pressed state's own derive: `Child:pressed{cn1-derive:Base1}` is attached + // to `Base1` directly, whatever the owner's normal derive says. + String prefix = dark ? "$Dark" + id + "." : id + "."; + if ("press#".equals(state) && normalFrom[0] == Integer.MAX_VALUE) { + Object pressDerive = theme.get(prefix + "press#derive"); + if (pressDerive instanceof String) { + id = (String) pressDerive; + normalFrom[0] = chain.size(); + continue; + } + } + String owner = ownerDerives.get(dark ? "$Dark" + id : id); + if (owner != null) { + id = owner; + continue; + } + // Otherwise a derive a previous compile into the same theme stored. For a dark + // element only its own `$Dark` keys: a retained light derive is no link either. + Object stateDerive = state.length() > 0 ? theme.get(prefix + state + "derive") : null; + if (stateDerive instanceof String) { + id = (String) stateDerive; + if ("press#".equals(state) && normalFrom[0] == Integer.MAX_VALUE) { + normalFrom[0] = chain.size(); + } + } else { + Object base = theme.get(prefix + "derive"); + id = base instanceof String ? (String) base : null; + if (id == null) { + id = anyStateDerive(theme, chain.get(chain.size() - 1), dark); + } + } + } + return chain; + } + + /// The build-time compiler applies a `cn1-derive` declared in any state to the owning UIID, + /// so `Child:selected{cn1-derive:Base}` is also where `Child:pressed` inherits from. + private String anyStateDerive(Hashtable theme, String id, boolean dark) { + String recorded = ownerDerives.get(dark ? "$Dark" + id : id); + if (recorded != null) { + return recorded; + } + // A derive a previous compile into the same theme stored: its order is gone. Pressed is + // not among them: the build-time compiler does not promote a pressed derive. + String[] states = {"sel#", "dis#", "hover#"}; + for (int pass = dark ? 0 : 1; pass < (dark ? 1 : 2); pass++) { + String prefix = pass == 0 ? "$Dark" + id + "." : id + "."; + for (String state : states) { + Object derive = theme.get(prefix + state + "derive"); + if (derive instanceof String) { + return (String) derive; + } + } + } + return null; + } + + private Font createFont(String familyList, FontSize size, int style) { + String ttfName = "native:MainRegular"; + boolean monospace = false; + if (familyList != null) { + String[] families = splitOnComma(familyList); + for (String entry : families) { + String family = unquote(entry); + if (family.equals(entry.trim())) { + // An identifier is read decoded, as the build-time parser reads it: + // `native\:MainBold` is `native:MainBold`. + String decoded = decodeIdentifier(family); + if (decoded != null) { + family = decoded; + } + } + // The build-time compiler walks on past a native font, so a later one, or a later + // monospace, wins; only a generic family or monospace ends the walk. + if (family.startsWith("native:")) { + ttfName = family; + continue; + } + if ("monospace".equalsIgnoreCase(family)) { + monospace = true; + break; + } + if (isGenericFamily(family)) { + break; + } + // Any other name has no @font-face here to resolve it, so it is passed over + // the way the build-time compiler passes over a family it cannot find. + } + } + int systemSize = size == null ? Font.SIZE_MEDIUM : size.systemSize; + if (monospace) { + return Font.createSystemFont(Font.FACE_MONOSPACE, style, systemSize); + } + Font fallback = Font.createSystemFont(Font.FACE_SYSTEM, style, systemSize); + float pixels = size == null || size.unit < 0 ? fallback.getHeight() : fontSizeInPixels(size); + Font ttf; + try { + ttf = Font.createTrueTypeFont(ttfName, ttfName); + } catch (RuntimeException err) { + ttf = null; + } + // The same fallback a resource file takes for a truetype font the platform cannot load. + return ttf == null ? fallback : ttf.derive(pixels, style); + } + + private static boolean isGenericFamily(String family) { + String[] generic = {"sans-serif", "serif", "times", "courier", "arial", "cursive", "fantasy", "system-ui"}; + for (String g : generic) { + if (g.equalsIgnoreCase(family)) { + return true; + } + } + return false; + } + + /// Converts the way `Resources` converts a truetype size read from a resource file. + private float fontSizeInPixels(FontSize size) { + switch (size.unit) { + case Style.UNIT_TYPE_DIPS: + return Display.getInstance().convertToPixels((int) (size.value * 10), true) / 10.0f; + case Style.UNIT_TYPE_REM: + return Font.getDefaultFont().getHeight() * size.value; + case Style.UNIT_TYPE_VW: + return CN.getDisplayWidth() * size.value / 100f; + case Style.UNIT_TYPE_VH: + return CN.getDisplayHeight() * size.value / 100f; + case Style.UNIT_TYPE_VMIN: + return Math.min(CN.getDisplayWidth(), CN.getDisplayHeight()) * size.value / 100f; + case Style.UNIT_TYPE_VMAX: + return Math.max(CN.getDisplayWidth(), CN.getDisplayHeight()) * size.value / 100f; + default: + return size.value; + } + } + + /// Parses a `font-size` with the build-time compiler's conversions: `pt`, `cm` and `in` are + /// millimetres, a percentage scales the medium (3mm), small (2mm) or large (4mm) size, and + /// a keyword selects that system font size. + /// `digits` unchanged, unless it uses exponent notation: `1e2px` is a dimension with the + /// unit `e2px` to the build-time parser, which it does not recognise as a size. + private static String fontDigits(String digits, String value) { + if (digits.indexOf('e') > -1 || digits.indexOf('E') > -1) { + throw new CSSSyntaxException("Exponent notation is not supported for font-size: " + value); + } + return digits; + } + + private FontSize parseFontSize(String value) { + String v = value.trim(); + FontSize out = new FontSize(); + // `smaller` and `larger` are relative to the inherited size, which the build-time + // compiler does not implement; they fall through to the unsupported-size error. + if ("small".equalsIgnoreCase(v) || "x-small".equalsIgnoreCase(v) || "xx-small".equalsIgnoreCase(v)) { + out.systemSize = Font.SIZE_SMALL; + return out; + } + if ("large".equalsIgnoreCase(v) || "x-large".equalsIgnoreCase(v) || "xx-large".equalsIgnoreCase(v)) { + out.systemSize = Font.SIZE_LARGE; + return out; + } + if ("medium".equalsIgnoreCase(v)) { + return out; + } + if (v.endsWith("%")) { + out.value = 3f * number(fontDigits(v.substring(0, v.length() - 1), value), value) / 100f; + out.unit = Style.UNIT_TYPE_DIPS; + return out; + } + if (isCssNumber(v) && Float.parseFloat(v) == 0f) { + // `0` is a valid length; the build-time compiler reads the bare number as no size + // and keeps its default medium sizing. + return out; + } + // vmin before in, so the longer suffix wins. + String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "vmin", "in", "rem", "vw", "vh", "vmax"}); + if (unit == null) { + throw new CSSSyntaxException("Unsupported font-size value: " + value); + } + float n = number(fontDigits(v.substring(0, v.length() - unit.length()), value), value); + out.value = n; + if ("px".equals(unit)) { + out.unit = Style.UNIT_TYPE_PIXELS; + // The build-time compiler takes the integer part of a pixel size (12.5px is 12), + // and the fraction only when the integer part is 0. + if ((int) n != 0) { + out.value = (int) n; + } + } else if ("pt".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + out.value = n / 72f * 25.4f; + } else if ("mm".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + } else if ("cm".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + out.value = n * 10f; + } else if ("in".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + out.value = n * 25.4f; + } else { + out.unit = viewportUnit(unit); + } + return out; + } + + /// The first of `units` that `value` ends with, ignoring case, or null. + private static String unitSuffix(String value, String[] units) { + for (String unit : units) { + if (endsWithIgnoreCase(value, unit)) { + return unit; + } + } + return null; + } + + private static byte viewportUnit(String unit) { + if ("rem".equals(unit)) { + return Style.UNIT_TYPE_REM; + } + if ("vw".equals(unit)) { + return Style.UNIT_TYPE_VW; + } + if ("vh".equals(unit)) { + return Style.UNIT_TYPE_VH; + } + if ("vmin".equals(unit)) { + return Style.UNIT_TYPE_VMIN; + } + return Style.UNIT_TYPE_VMAX; + } + + private static float number(String text, String original) { + String t = text.trim(); + if (t.length() == 0) { + throw new CSSSyntaxException("Missing number in: " + original); + } + // CSS number syntax only: Float.parseFloat would also take 1f, 1d, 0x1p3, NaN and + // Infinity, which the build-time parser reads as a number followed by a unit. + if (!isCssNumber(t)) { + throw new CSSSyntaxException("Invalid number in: " + original); + } + try { + return Float.parseFloat(t); + } catch (NumberFormatException err) { + throw new CSSSyntaxException("Invalid number in: " + original, err); + } + } + + /// The value the build-time compiler stores for an unquoted `#Constants` token: a number + /// as it serializes the parsed value (`1.50` is `1.5`, `001` is `1`), an identifier as + /// written. Every other token type is one it drops, so it is rejected here. + /// Whether `token` is a CSS length or percentage such as `5px`, `-1.5mm` or `50%`. + private static boolean isDimensionToken(String token) { + int end = token.length(); + while (end > 0) { + char c = token.charAt(end - 1); + if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '%') { + end--; + } else { + break; + } + } + return end > 0 && end < token.length() && isCssNumber(token.substring(0, end)); + } + + private static String buildConstant(String name, String raw, String value) { + if (isCssNumber(value) && (value.indexOf('e') > -1 || value.indexOf('E') > -1)) { + // The build-time parser reads `1e2` as a dimension, a token its constants emitter + // drops, so the built theme has no such constant. + throw new CSSSyntaxException("#Constants does not support exponent notation: " + + name + ": " + raw + " (write the number out, or quote it to store it as text)"); + } + if (isCssNumber(value)) { + boolean integer = value.indexOf('.') < 0 && value.indexOf('e') < 0 && value.indexOf('E') < 0; + try { + if (integer) { + return String.valueOf(Integer.parseInt(value.charAt(0) == '+' ? value.substring(1) : value)); + } + String real = String.valueOf(Float.parseFloat(value)); + return real.endsWith(".0") ? real.substring(0, real.length() - 2) : real; + } catch (NumberFormatException err) { + throw new CSSSyntaxException("Invalid number in #Constants: " + name + ": " + raw, err); + } + } + String ident = decodeIdentifier(value); + if (ident != null) { + return ident; + } + throw new CSSSyntaxException("#Constants supports strings, identifiers, numbers and colors only: " + + name + ": " + raw + " (quote it to store it as text)"); + } - if (applyThemeConstantProperty(theme, property, value)) { - continue; - } - if (applySimpleThemeProperty(theme, uiid, statePrefix, property, value)) { + /// The decoded value of a CSS identifier token, or null when `t` is not one: `-`? then a + /// name-start character (a letter, `_`, `-`, any non-ASCII character or an escape), then name + /// characters. An escape is a backslash and 1-6 hex digits (plus one optional whitespace) or + /// a backslash and any other character, decoded as the build-time parser stores it. + private static String decodeIdentifier(String t) { + int n = t.length(); + StringBuilder out = new StringBuilder(n); + int i = 0; + boolean first = true; + if (t.startsWith("--")) { + // A custom property: anything may follow the double hyphen, `--1accent` included. + out.append("--"); + i = 2; + first = false; + } + while (i < n) { + char c = t.charAt(i); + if (c == '\\') { + if (i + 1 >= n || t.charAt(i + 1) == '\n' || t.charAt(i + 1) == '\r' || t.charAt(i + 1) == '\f') { + return null; + } + int j = i + 1; + int code = 0; + while (j < n && j - i <= 6 && hexDigit(t.charAt(j)) >= 0) { + code = code * 16 + hexDigit(t.charAt(j)); + j++; + } + if (j == i + 1) { + out.append(t.charAt(j)); + i = j + 1; + } else { + if (code == 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) { + code = 0xfffd; + } + if (code > 0xffff) { + code -= 0x10000; + out.append((char) (0xd800 + (code >> 10))); + out.append((char) (0xdc00 + (code & 0x3ff))); + } else { + out.append((char) code); + } + j = skipEscapeTerminator(t, j); + i = j; + } + first = false; continue; } - if (applyImageProperty(theme, resources, uiid, statePrefix, property, value)) { - continue; + boolean digit = c >= '0' && c <= '9'; + if (!isNameChar(c) || (first && digit)) { + return null; } - if (appendBorderProperty(borderCss, property, value)) { + if (first && c == '-' && out.length() == 0) { + // A leading '-' is followed by the real name start. + out.append(c); + i++; + if (i < n && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + return null; + } continue; } + out.append(c); + first = false; + i++; } - - if (borderCss.length() == 0) { - return; - } - theme.put(uiid + "." + statePrefix + "border", new CSSBorder(null, borderCss.toString())); + return first ? null : out.toString(); } - private void applyRootDeclarations(Hashtable theme, Declaration[] declarations) { - for (Declaration declaration : declarations) { - if (!declaration.property.startsWith("--")) { - continue; + /// `[+-]? digits`. + private static boolean isIntegerSpelling(String t) { + int i = t.length() > 0 && (t.charAt(0) == '+' || t.charAt(0) == '-') ? 1 : 0; + if (i >= t.length()) { + return false; + } + for (; i < t.length(); i++) { + if (t.charAt(i) < '0' || t.charAt(i) > '9') { + return false; } - theme.put("@" + declaration.property.substring(2), resolveVars(theme, declaration.value)); } + return true; } - private boolean applyThemeConstantProperty(Hashtable theme, String property, String value) { - if (!property.startsWith("--")) { + /// `[+-]? (digits | digits? . digits) ([eE] [+-]? digits)?`, the CSS number token. + private static boolean isCssNumber(String t) { + int i = 0; + int n = t.length(); + if (i < n && (t.charAt(i) == '+' || t.charAt(i) == '-')) { + i++; + } + int intDigits = 0; + while (i < n && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i++; + intDigits++; + } + int fracDigits = 0; + if (i < n && t.charAt(i) == '.') { + i++; + while (i < n && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i++; + fracDigits++; + } + if (fracDigits == 0) { + return false; + } + } + if (intDigits + fracDigits == 0) { return false; } - theme.put("@" + property.substring(2), value); - return true; + if (i < n && (t.charAt(i) == 'e' || t.charAt(i) == 'E')) { + i++; + if (i < n && (t.charAt(i) == '+' || t.charAt(i) == '-')) { + i++; + } + int expDigits = 0; + while (i < n && t.charAt(i) >= '0' && t.charAt(i) <= '9') { + i++; + expDigits++; + } + if (expDigits == 0) { + return false; + } + } + return i == n; + } + + private static boolean endsWithIgnoreCase(String value, String suffix) { + int start = value.length() - suffix.length(); + return start >= 0 && value.regionMatches(true, start, suffix, 0, suffix.length()); } private boolean applySimpleThemeProperty(Hashtable theme, String uiid, String statePrefix, String property, String value) { if ("color".equals(property)) { theme.put(uiid + "." + statePrefix + "fgColor", normalizeHexColor(value)); + // As the build-time compiler's getThemeFgAlpha(): `transparent` and an rgba() alpha + // become fgAlpha, any other color leaves it unset. + int alpha = colorAlpha(value); + if (alpha >= 0) { + theme.put(uiid + "." + statePrefix + "fgAlpha", Integer.valueOf(alpha)); + } return true; } if ("background-color".equals(property)) { theme.put(uiid + "." + statePrefix + "bgColor", normalizeHexColor(value)); - if (!"transparent".equalsIgnoreCase(value)) { - theme.put(uiid + "." + statePrefix + "transparency", "255"); + // As getThemeTransparency(): `transparent` is 0, an rgba() its alpha, anything else + // opaque. A transparent background has to say so: the UIID's default may be opaque. + int alpha = colorAlpha(value); + theme.put(uiid + "." + statePrefix + "transparency", String.valueOf(alpha >= 0 ? alpha : 255)); + return true; + } + if ("opacity".equals(property)) { + float opacity; + try { + opacity = Float.parseFloat(value.trim()); + } catch (NumberFormatException err) { + throw new CSSSyntaxException("Unsupported opacity value: " + value, err); } + // As getThemeOpacity(): the fraction scaled to 0-255 and truncated. + theme.put(uiid + "." + statePrefix + "opacity", String.valueOf((int) (opacity * 255.0))); return true; } if ("padding".equals(property) || "margin".equals(property)) { - theme.put(uiid + "." + statePrefix + property, normalizeBox(value)); + applyBox(theme, uiid + "." + statePrefix, property, value); + // A later shorthand replaces the sides declared before it. + boxSides.remove(uiid + "." + statePrefix + property); + return true; + } + int side = boxSide(property); + if (side >= 0) { + String box = property.startsWith("padding") ? "padding" : "margin"; + String key = uiid + "." + statePrefix + box; + Length[] sides = boxSides.get(key); + if (sides == null) { + sides = new Length[4]; + boxSides.put(key, sides); + } + sides[side] = length(value); return true; } if ("cn1-derive".equals(property)) { @@ -324,6 +1765,13 @@ private boolean appendBorderProperty(StringBuilder borderCss, String property, S if (!isBorderProperty(property)) { return false; } + if ("background-image".equals(property) && "none".equalsIgnoreCase(value.trim())) { + // No image, as in the build-time compiler; CSSBorder has no `none` to parse. + return true; + } + // CSSBorder reads only hex and rgb() colors; a keyword such as `red` is written as the + // hex the build-time compiler resolves it to. + value = namedColorsAsHex(value); if ("border".equals(property)) { String expanded = expandBorderShorthand(value); if (expanded.length() == 0) { @@ -351,7 +1799,7 @@ private String expandBorderShorthand(String value) { String style = null; String color = null; for (String part : parts) { - String token = part.trim().toLowerCase(); + String token = asciiLower(part.trim()); if (token.length() == 0) { continue; } @@ -390,22 +1838,153 @@ private String expandBorderShorthand(String value) { } private String resolveVars(Hashtable theme, String value) { - String out = value; - int varPos = out.indexOf("var(--"); + return resolveVars(theme, value, new ArrayList(), false); + } + + /// Expands every `var(--x)`, including those inside the values it substitutes. `resolving` + /// holds the names being expanded, so `a: var(--b); b: var(--a)` is an error instead of a + /// compile that never returns. + private String resolveVars(Hashtable theme, String value, ArrayList resolving, boolean tokens) { + int varPos = value.indexOf("var(--"); + if (varPos < 0) { + return value; + } + StringBuilder out = new StringBuilder(); + int pos = 0; while (varPos > -1) { - int end = out.indexOf(')', varPos); + int end = closingParen(value, varPos + "var(".length()); if (end < 0) { - break; + // The build-time parser rejects the unmatched parenthesis. + throw new CSSSyntaxException("Unterminated var() in: " + value); + } + // `var(--name, fallback)`: the build-time compiler takes the fallback when the + // variable is not defined. + String inner = value.substring(varPos + "var(--".length(), end); + int comma = topLevelComma(inner); + String key = (comma < 0 ? inner : inner.substring(0, comma)).trim(); + // Decoded, as the declaration was: `var(--a\62 c)` is `--abc`. + String decodedKey = decodeIdentifier("--" + key); + if (decodedKey != null) { + key = decodedKey.substring(2); + } + String fallback = comma < 0 ? null : inner.substring(comma + 1).trim(); + if (resolving.contains(key)) { + throw new CSSSyntaxException("Cyclic variable reference: var(--" + key + ")"); + } + String variable = cssVars.get("@" + key); + boolean own = variable != null; + if (own) { + // A quoted variable is text: its token in a style property, its text elsewhere. + String text = unquote(variable); + String spelling = tokens || text.equals(variable) ? variable : text; + if (!text.equals(variable)) { + out.append(value.substring(pos, varPos)).append(spelling); + } else { + resolving.add(key); + out.append(value.substring(pos, varPos)).append(resolveVars(theme, variable, resolving, tokens)); + resolving.remove(resolving.size() - 1); + } + pos = end + 1; + varPos = value.indexOf("var(--", pos); + continue; + } + boolean shadowed = plainConstants.contains("@" + key); + Object replacement = shadowed ? null + : (declaredOnly != null && !declaredOnly.containsKey("@" + key) + && declaredThisCompile.contains("@" + key)) ? null : theme.get("@" + key); + if (replacement == null && fallback != null) { + out.append(value.substring(pos, varPos)).append(resolveVars(theme, fallback, resolving, tokens)); + pos = end + 1; + varPos = value.indexOf("var(--", pos); + continue; + } + if (replacement == null) { + unresolvedVar = true; } - 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(--"); + String token = tokens ? literalTokens.get("@" + key) : null; + if (token != null) { + replaceValue = token; + } + // A quoted constant is text, also when it is reached through another constant. So + // is one a previous compile into the same theme left: it is already resolved, and + // any var() still in it was quoted text there. + String kind = deferredConstants.get("@" + key); + // A quoted token is text whatever its kind (a quoted image name is one). + if (kind != null && !DEFERRED_LITERAL.equals(kind) && token == null) { + resolving.add(key); + replaceValue = resolveVars(theme, replaceValue, resolving, tokens); + resolving.remove(resolving.size() - 1); + } + out.append(value.substring(pos, varPos)).append(replaceValue); + pos = end + 1; + varPos = value.indexOf("var(--", pos); } - return out; + out.append(value.substring(pos)); + return out.toString(); + } + + /// The index of the `)` closing the parenthesis opened just before `from`, skipping nested + /// parentheses and quoted text; -1 when it is not closed. + private static int closingParen(String value, int from) { + int depth = 0; + char quote = 0; + for (int i = from; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '\\') { + // An escape outside a string: `foo\"bar` is one identifier, not a string start. + i++; + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + if (depth == 0) { + return i; + } + depth--; + } + } + return -1; + } + + /// The first comma outside parentheses and quotes, or -1. + private static int topLevelComma(String value) { + int depth = 0; + char quote = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '\\') { + // An escape outside a string: `foo\"bar` is one identifier, not a string start. + i++; + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (c == ',' && depth == 0) { + return i; + } + } + return -1; } + // A selector written as `$DarkA` is kept as the UIID `$DarkA`, the key this compiler has + // always stored dark styles under, although the build-time compiler drops such a selector: + // themes compiled here address their dark variants that way. private String[] selector(String selector) { String statePrefix = ""; String uiid = selector.trim(); @@ -429,12 +2008,42 @@ private String[] selector(String selector) { if ("*".equals(uiid) || uiid.length() == 0) { uiid = "Component"; } + if (uiid.indexOf('\\') > -1) { + // `A\ B` names the UIID `A B`, as the build-time compiler decodes it. + String decoded = decodeIdentifier(uiid); + if (decoded != null) { + uiid = decoded; + } + } return new String[]{uiid, statePrefix}; } + private static boolean isCombinatorSelector(String selector) { + for (int i = 0; i < selector.length(); i++) { + char c = selector.charAt(i); + if (c == '\\') { + // `\41 B` is the UIID `AB`: up to six hex digits, then one whitespace + // character that belongs to the escape. + int digits = 0; + while (digits < 6 && i + 1 < selector.length() && hexDigit(selector.charAt(i + 1)) > -1) { + i++; + digits++; + } + if (digits == 0) { + i++; + } else if (i + 1 < selector.length() && selector.charAt(i + 1) <= ' ') { + i++; + } + } else if (c <= ' ' || c == '>' || c == '+' || c == '~') { + return true; + } + } + return false; + } + private String statePrefix(String pseudo) { if ("unselected".equals(pseudo)) { - return ""; + return UNSELECTED; } if ("selected".equals(pseudo)) { return "sel#"; @@ -474,7 +2083,7 @@ private boolean isBorderProperty(String property) { } private String normalizeHexColor(String cssColor) { - String value = cssColor == null ? "" : cssColor.trim().toLowerCase(); + String value = cssColor == null ? "" : asciiLower(cssColor.trim()); if (value.length() == 0) { throw new CSSSyntaxException("Color value cannot be empty"); } @@ -482,13 +2091,15 @@ private String normalizeHexColor(String cssColor) { return "000000"; } - if (value.startsWith("rgb(")) { + if (value.startsWith("rgb(") || value.startsWith("rgba(")) { + boolean alpha = value.startsWith("rgba("); 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); + String[] parts = colorArguments(value.substring(alpha ? 5 : 4, value.length() - 1), cssColor); + if (parts.length != (alpha ? 4 : 3)) { + throw new CSSSyntaxException((alpha ? "rgba() must have exactly 4" : "rgb() must have exactly 3") + + " components: " + cssColor); } int r = parseRgbChannel(parts[0], cssColor); int g = parseRgbChannel(parts[1], cssColor); @@ -516,7 +2127,7 @@ private String normalizeHexColor(String cssColor) { } private Integer normalizeAlignment(String value) { - String v = value == null ? "" : value.trim().toLowerCase(); + String v = value == null ? "" : asciiLower(value.trim()); if ("left".equals(v) || "start".equals(v)) { return Integer.valueOf(Component.LEFT); } @@ -529,51 +2140,135 @@ private Integer normalizeAlignment(String value) { throw new CSSSyntaxException("Unsupported text-align value: " + value); } + /// The CSS named colors as the build-time compiler's Color table resolves them, name then + /// hex. `transparent` is handled by the callers, which give it its alpha. + private static final String[] NAMED_COLORS = { + "aliceblue", "f0f8ff", "antiquewhite", "faebd7", "aqua", "00ffff", + "aquamarine", "7fffd4", "azure", "f0ffff", "beige", "f5f5dc", + "bisque", "ffe4c4", "black", "000000", "blanchedalmond", "ffebcd", + "blue", "0000ff", "blueviolet", "8a2be2", "brown", "a52a2a", + "burlywood", "deb887", "cadetblue", "5f9ea0", "chartreuse", "7fff00", + "chocolate", "d2691e", "coral", "ff7f50", "cornflowerblue", "6495ed", + "cornsilk", "fff8dc", "crimson", "dc143c", "cyan", "00ffff", + "darkblue", "00008b", "darkcyan", "008b8b", "darkgoldenrod", "b8860b", + "darkgray", "a9a9a9", "darkgreen", "006400", "darkgrey", "a9a9a9", + "darkkhaki", "bdb76b", "darkmagenta", "8b008b", "darkolivegreen", "556b2f", + "darkorange", "ff8c00", "darkorchid", "9932cc", "darkred", "8b0000", + "darksalmon", "e9967a", "darkseagreen", "8fbc8f", "darkslateblue", "483d8b", + "darkslategray", "2f4f4f", "darkslategrey", "2f4f4f", "darkturquoise", "00ced1", + "darkviolet", "9400d3", "deeppink", "ff1493", "deepskyblue", "00bfff", + "dimgray", "696969", "dimgrey", "696969", "dodgerblue", "1e90ff", + "firebrick", "b22222", "floralwhite", "fffaf0", "forestgreen", "228b22", + "fuchsia", "ff00ff", "gainsboro", "dcdcdc", "ghostwhite", "f8f8ff", + "gold", "ffd700", "goldenrod", "daa520", "gray", "808080", + "green", "008000", "greenyellow", "adff2f", "grey", "808080", + "honeydew", "f0fff0", "hotpink", "ff69b4", "indianred", "cd5c5c", + "indigo", "4b0082", "ivory", "fffff0", "khaki", "f0e68c", + "lavender", "e6e6fa", "lavenderblush", "fff0f5", "lawngreen", "7cfc00", + "lemonchiffon", "fffacd", "lightblue", "add8e6", "lightcoral", "f08080", + "lightcyan", "e0ffff", "lightgoldenrodyellow", "fafad2", "lightgray", "d3d3d3", + "lightgreen", "90ee90", "lightgrey", "d3d3d3", "lightpink", "ffb6c1", + "lightsalmon", "ffa07a", "lightseagreen", "20b2aa", "lightskyblue", "87cefa", + "lightslategray", "778899", "lightslategrey", "778899", "lightsteelblue", "b0c4de", + "lightyellow", "ffffe0", "lime", "00ff00", "limegreen", "32cd32", + "linen", "faf0e6", "magenta", "ff00ff", "maroon", "800000", + "mediumaquamarine", "66cdaa", "mediumblue", "0000cd", "mediumorchid", "ba55d3", + "mediumpurple", "9370db", "mediumseagreen", "3cb371", "mediumslateblue", "7b68ee", + "mediumspringgreen", "00fa9a", "mediumturquoise", "48d1cc", "mediumvioletred", "c71585", + "midnightblue", "191970", "mintcream", "f5fffa", "mistyrose", "ffe4e1", + "moccasin", "ffe4b5", "navajowhite", "ffdead", "navy", "000080", + "oldlace", "fdf5e6", "olive", "808000", "olivedrab", "6b8e23", + "orange", "ffa500", "orangered", "ff4500", "orchid", "da70d6", + "palegoldenrod", "eee8aa", "palegreen", "98fb98", "paleturquoise", "afeeee", + "palevioletred", "db7093", "papayawhip", "ffefd5", "peachpuff", "ffdab9", + "peru", "cd853f", "pink", "ffc0cb", "plum", "dda0dd", + "powderblue", "b0e0e6", "purple", "800080", "red", "ff0000", + "rosybrown", "bc8f8f", "royalblue", "4169e1", "saddlebrown", "8b4513", + "salmon", "fa8072", "sandybrown", "f4a460", "seagreen", "2e8b57", + "seashell", "fff5ee", "sienna", "a0522d", "silver", "c0c0c0", + "skyblue", "87ceeb", "slateblue", "6a5acd", "slategray", "708090", + "slategrey", "708090", "snow", "fffafa", "springgreen", "00ff7f", + "steelblue", "4682b4", "tan", "d2b48c", "teal", "008080", + "thistle", "d8bfd8", "tomato", "ff6347", "turquoise", "40e0d0", + "violet", "ee82ee", "wheat", "f5deb3", "white", "ffffff", + "whitesmoke", "f5f5f5", "yellow", "ffff00", "yellowgreen", "9acd32", + }; + 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"; + for (int i = 0; i < NAMED_COLORS.length; i += 2) { + if (NAMED_COLORS[i].equals(value)) { + return NAMED_COLORS[i + 1]; + } } - if ("pink".equals(value)) { - return "ffc0cb"; + return null; + } + + /// `1px solid red` as `1px solid #ff0000`: every whitespace-separated CSS color keyword + /// replaced by its hex. + private String namedColorsAsHex(String value) { + StringBuilder out = new StringBuilder(value.length()); + int i = 0; + int n = value.length(); + while (i < n) { + char c = value.charAt(i); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f') { + out.append(c); + i++; + continue; + } + int end = i; + while (end < n && " \t\n\r\f".indexOf(value.charAt(end)) < 0) { + end++; + } + String token = value.substring(i, end); + String hex = "transparent".equalsIgnoreCase(token) ? null : cssColorKeyword(asciiLower(token)); + out.append(hex != null ? "#" + hex : token); + i = end; } - if ("orange".equals(value)) { - return "ffa500"; + return out.toString(); + } + + /// The alpha a color carries, 0-255, or -1 for an opaque color that states none: + /// `transparent` is 0 and `rgba(0, 0, 0, .5)` is 127, as the build-time compiler truncates it. + private int colorAlpha(String cssColor) { + // Folded on purpose: CSS keywords are case-insensitive, so `TRANSPARENT` is transparent. + // The build-time compiler matches only the lowercase spelling and paints such a value + // opaque, which is its bug, not a behaviour this compiler should copy. + String value = asciiLower(cssColor.trim()); + if ("transparent".equals(value)) { + return 0; } - if ("yellow".equals(value)) { - return "ffff00"; + if (!value.startsWith("rgba(") || !value.endsWith(")")) { + return -1; } - if ("purple".equals(value)) { - return "800080"; + String[] parts = colorArguments(value.substring(5, value.length() - 1), cssColor); + if (parts.length != 4) { + throw new CSSSyntaxException("rgba() must have exactly 4 components: " + cssColor); } - if ("gray".equals(value) || "grey".equals(value)) { - return "808080"; + String a = parts[3].trim(); + float fraction; + try { + fraction = a.endsWith("%") ? Float.parseFloat(a.substring(0, a.length() - 1)) / 100f : Float.parseFloat(a); + } catch (NumberFormatException err) { + throw new CSSSyntaxException("Invalid rgba() alpha in " + cssColor + ": " + a, err); } - return null; + return Math.max(0, Math.min(255, (int) (fraction * 255.0))); } private int parseRgbChannel(String value, String originalColor) { int out; + String v = value.trim(); try { - out = Integer.parseInt(value.trim()); + if (v.endsWith("%")) { + out = Math.round(Float.parseFloat(v.substring(0, v.length() - 1)) * 255f / 100f); + } else { + out = Integer.parseInt(v); + } } 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; + // Out of range is clamped, as CSS specifies and as the build-time compiler does. + return Math.max(0, Math.min(255, out)); } private boolean isHexColor(String value) { @@ -597,38 +2292,173 @@ private String toHexColor(int color) { return hex; } - private String normalizeBox(String cssValue) { + /// Expands a one to four value `padding`/`margin` from CSS top/right/bottom/left order into + /// the theme's top/bottom/left/right value order, and its unit array, which `Style` indexes + /// by `Component.TOP`, `LEFT`, `BOTTOM`, `RIGHT`. + private void applyBox(Hashtable theme, String keyPrefix, String property, 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 == 0 || parts.length > 4) { + throw new CSSSyntaxException(property + " takes one to four values: " + cssValue); + } + Length top = length(parts[0]); + Length right = parts.length > 1 ? length(parts[1]) : top; + Length bottom = parts.length > 2 ? length(parts[2]) : top; + Length left = parts.length > 3 ? length(parts[3]) : right; + theme.put(keyPrefix + property, top.text + "," + bottom.text + "," + left.text + "," + right.text); + String unitKey = "padding".equals(property) ? Style.PADDING_UNIT : Style.MARGIN_UNIT; + theme.put(keyPrefix + unitKey, new byte[]{top.unit, left.unit, bottom.unit, right.unit}); + } + + /// 0-3 for `padding-top`/`-right`/`-bottom`/`-left` (and `margin-`), else -1. + private static int boxSide(String property) { + String rest; + if (property.startsWith("padding-")) { + rest = property.substring(8); + } else if (property.startsWith("margin-")) { + rest = property.substring(7); + } else { + return -1; + } + if ("top".equals(rest)) { + return 0; + } + if ("right".equals(rest)) { + return 1; + } + if ("bottom".equals(rest)) { + return 2; + } + return "left".equals(rest) ? 3 : -1; + } + + /// Writes the declared sides over the box they belong to: the state's own box when one was + /// declared, else (for a state) the UIID's unselected box, which the build-time compiler's + /// state style starts from, else zero. Unselected keys go first so a state can start from + /// a box that itself had sides applied. + /// Lays every `A.uns#key` over `A.key`, with the binding that goes with it. + private static void mergeUnselected(Hashtable theme) { + ArrayList keys = new ArrayList(); + for (Object k : theme.keySet()) { + if (k instanceof String && ((String) k).indexOf("." + UNSELECTED) > -1 + && !((String) k).startsWith("@")) { + keys.add((String) k); + } } - if (parts.length == 2) { - return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[0]) + "," + scalar(parts[1]); + for (String key : keys) { + int at = key.indexOf("." + UNSELECTED); + String base = key.substring(0, at + 1) + key.substring(at + 1 + UNSELECTED.length()); + theme.put(base, theme.remove(key)); + Object binding = theme.remove("@cn1-bind:" + key); + if (binding != null) { + theme.put("@cn1-bind:" + base, binding); + } else { + theme.remove("@cn1-bind:" + base); + } } - if (parts.length == 3) { - return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[2]) + "," + scalar(parts[1]); + } + + private void applyBoxSides(Hashtable theme) { + for (int pass = 0; pass < 2; pass++) { + for (Map.Entry entry : boxSides.entrySet()) { + String key = entry.getKey(); + int dot = key.indexOf('.'); + int hash = key.indexOf('#', dot); + if ((pass == 0) != (hash < 0)) { + continue; + } + String box = key.endsWith("padding") ? "padding" : "margin"; + String unitSuffix = "padding".equals(box) ? Style.PADDING_UNIT : Style.MARGIN_UNIT; + String prefix = key.substring(0, key.length() - box.length()); + String source = theme.get(key) != null || hash < 0 ? prefix : key.substring(0, dot + 1); + Length[] out = readBox(theme.get(source + box), theme.get(source + unitSuffix)); + Length[] sides = entry.getValue(); + for (int i = 0; i < 4; i++) { + if (sides[i] != null) { + out[i] = sides[i]; + } + } + theme.put(key, out[0].text + "," + out[2].text + "," + out[3].text + "," + out[1].text); + theme.put(prefix + unitSuffix, new byte[]{out[0].unit, out[3].unit, out[2].unit, out[1].unit}); + } } - if (parts.length >= 4) { - return scalar(parts[0]) + "," + scalar(parts[1]) + "," + scalar(parts[2]) + "," + scalar(parts[3]); + } + + /// A stored box (`top,bottom,left,right` with units `[top, left, bottom, right]`) as + /// top/right/bottom/left lengths; zero pixels where nothing is stored. + private static Length[] readBox(Object values, Object units) { + Length[] out = new Length[4]; + String[] parts = values instanceof String ? splitCommas((String) values) : null; + byte[] unit = units instanceof byte[] ? (byte[]) units : null; + // Stored order to top/right/bottom/left. + int[] textIndex = {0, 3, 1, 2}; + int[] unitIndex = {0, 3, 2, 1}; + for (int i = 0; i < 4; i++) { + Length l = new Length(); + l.text = parts != null && parts.length == 4 ? parts[textIndex[i]].trim() : "0"; + l.unit = unit != null && unit.length == 4 ? unit[unitIndex[i]] : Style.UNIT_TYPE_PIXELS; + out[i] = l; } - return "0,0,0,0"; + return out; } - private String scalar(String value) { - String out = value.trim(); - if (out.endsWith("px")) { - out = out.substring(0, out.length() - 2); + private static String[] splitCommas(String value) { + ArrayList out = new ArrayList(); + int start = 0; + for (int i = 0; i <= value.length(); i++) { + if (i == value.length() || value.charAt(i) == ',') { + out.add(value.substring(start, i)); + start = i + 1; + } } + return out.toArray(new String[out.size()]); + } + + /// One side of a box. `px`, `pt` and a bare number are pixels rounded to a whole number, + /// which is what the build-time compiler stores: its `pt` is a density independent pixel, + /// not a CSS point. + private Length length(String value) { + String v = value.trim(); + Length out = new Length(); + // No `%`: the build-time compiler rejects a percentage inset, so the preview must too. + if (v.endsWith("%")) { + throw new CSSSyntaxException("Unsupported unit for padding or margin: " + value); + } + String unit = unitSuffix(v, new String[]{"px", "pt", "mm", "cm", "rem", "vw", "vh", "vmin", "vmax"}); + // A bare number must be an integer spelling: the build-time compiler takes `1.5` or + // `1e2` for a dimension missing its unit and fails the build. + if (unit == null && !isIntegerSpelling(v)) { + throw new CSSSyntaxException("A unitless padding or margin must be a whole number: " + value); + } + String digits = unit == null ? v : v.substring(0, v.length() - unit.length()); + if (digits.indexOf('e') > -1 || digits.indexOf('E') > -1) { + // `1e2px` is a dimension with the unit `e2px` to the build-time parser, which the + // inset conversion rejects. + throw new CSSSyntaxException("Exponent notation is not supported for padding or margin: " + value); + } + float n = number(digits, value); + if (unit == null || "px".equals(unit) || "pt".equals(unit)) { + out.unit = Style.UNIT_TYPE_PIXELS; + n = Math.round(n); + } else if ("mm".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + } else if ("cm".equals(unit)) { + out.unit = Style.UNIT_TYPE_DIPS; + n = n * 10f; + } else { + out.unit = viewportUnit(unit); + } + String text = String.valueOf(n); + out.text = text.endsWith(".0") ? text.substring(0, text.length() - 2) : text; return out; } private Rule[] parseRulesWithMedia(String css) { ArrayList out = new ArrayList(); - parseRulesInto(css, out, false); + parseRulesInto(css, out, false, null); return out.toArray(new Rule[out.size()]); } - private void parseRulesInto(String css, ArrayList out, boolean darkContext) { + private void parseRulesInto(String css, ArrayList out, boolean darkContext, String[] mediaPrefixes) { int pos = 0; int len = css.length(); while (pos < len) { @@ -649,14 +2479,34 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext throw new CSSSyntaxException("Missing '}' for CSS rule: " + selectors); } - if (selectors.startsWith("@constants")) { + if (isConstantsSelector(selectors)) { + Rule constants = new Rule(); + constants.selector = selectors; + constants.constants = true; + constants.legacyConstants = selectors.startsWith("@"); + constants.declarations = parseDeclarations(css.substring(open + 1, close)); + out.add(constants); pos = close + 1; continue; } if (selectors.startsWith("@media")) { String mediaQuery = selectors.substring("@media".length()).trim(); - boolean nextDarkContext = darkContext || isDarkModeMediaQuery(mediaQuery); - parseRulesInto(css.substring(open + 1, close), out, nextDarkContext); + boolean dark = isDarkModeMediaQuery(mediaQuery); + String[] nextPrefixes = mediaPrefixes; + if (!dark) { + if (mediaQuery.indexOf('(') >= 0) { + // The build-time parser rejects a feature query such as (min-width: 400px) + // and drops the whole block; a theme has no viewport to test it against. + pos = close + 1; + continue; + } + nextPrefixes = mediaPrefixes(mediaQuery); + if (nextPrefixes == null) { + pos = close + 1; + continue; + } + } + parseRulesInto(css.substring(open + 1, close), out, darkContext || dark, nextPrefixes); pos = close + 1; continue; } @@ -672,10 +2522,38 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext if (selector.length() == 0) { throw new CSSSyntaxException("Empty selector in selector list: " + selectors); } - Rule rule = new Rule(); - rule.selector = darkContext ? toDarkSelector(selector) : selector; - rule.declarations = declarations; - out.add(rule); + if (isCombinatorSelector(selector)) { + // `A B`, `A > B`, `A + B`, `A ~ B`: a theme has no element tree to match a + // combinator against, and the build-time compiler drops such a rule. + continue; + } + if (isConstantsSelector(selector)) { + // `#Constants, Other { ... }`: the build-time compiler still exports the + // constants, and applies the declarations to the other selectors as usual. + Rule constants = new Rule(); + constants.selector = selector; + constants.constants = true; + constants.declarations = parseDeclarations(body); + out.add(constants); + continue; + } + String base = selector.trim(); + boolean universal = base.startsWith("*") || base.startsWith(":") && !":root".equals(base); + String target = darkContext ? toDarkSelector(selector) : selector; + if (mediaPrefixes == null || universal) { + Rule rule = new Rule(); + rule.selector = target; + rule.universal = universal; + rule.declarations = declarations; + out.add(rule); + continue; + } + for (String prefix : mediaPrefixes) { + Rule rule = new Rule(); + rule.selector = prefix.length() == 0 ? target : prefix + "-" + target; + rule.declarations = declarations; + out.add(rule); + } } pos = close + 1; @@ -685,9 +2563,22 @@ private void parseRulesInto(String css, ArrayList out, boolean darkContext private int findMatchingBrace(String css, int openPos) { int depth = 0; int len = css.length(); + char quote = 0; for (int i = openPos; i < len; i++) { char c = css.charAt(i); - if (c == '{') { + // A brace inside a quoted value, such as a #Constants string, is text. + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '\\') { + // An escape outside a string: `foo\"bar` is one identifier, not a string start. + i++; + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '{') { depth++; } else if (c == '}') { depth--; @@ -699,8 +2590,68 @@ private int findMatchingBrace(String css, int openPos) { return -1; } + /// A dark media block's rules land on the same `$Dark` UIID as a `$DarkFoo` rule and merge + /// with it in source order. The build-time compiler differs in one case on purpose left + /// unmatched: a `$DarkFoo` rule BEFORE a `@media (prefers-color-scheme: dark) { Foo {...} }` + /// block loses its other declarations there (the media rule replaces the element instead of + /// merging), so `background-color` set in `$DarkFoo` silently vanishes from the build. That + /// is a defect of the build, not a behaviour a stylesheet relies on, so the preview keeps + /// what the author wrote. + /// The UIID prefixes a `@media` list stands for, as the build-time compiler computes them: + /// `platform-*` crossed with `density-*`, then `device-*` in front, so + /// `@media platform-ios, density-high { A {} }` styles `platform-ios-density-high-A`. A list + /// naming none of the three applies to the plain UIIDs (prefix ""). + private String[] mediaPrefixes(String mediaQuery) { + ArrayList platforms = new ArrayList(); + ArrayList densities = new ArrayList(); + ArrayList types = new ArrayList(); + for (String entry : splitOnChar(mediaQuery, ',')) { + String key = entry.trim(); + for (int i = 0; i < key.length(); i++) { + if (key.charAt(i) <= ' ') { + // `platform-ios and device-tablet`: the build-time compiler matches whole + // media names only and styles nothing for such a query. + return null; + } + } + if (key.startsWith("platform-")) { + platforms.add(key); + } else if (key.startsWith("density-")) { + densities.add(key); + } else if (key.startsWith("device-")) { + types.add(key); + } + } + ArrayList out = new ArrayList(); + if (platforms.isEmpty()) { + if (densities.isEmpty()) { + out.add(""); + } else { + out.addAll(densities); + } + } else { + for (String platform : platforms) { + if (densities.isEmpty()) { + out.add(platform); + } else { + for (String density : densities) { + out.add(platform + "-" + density); + } + } + } + } + int count = out.size(); + for (String type : types) { + for (int i = 0; i < count; i++) { + String curr = out.get(i); + out.set(i, curr.length() == 0 ? type : type + "-" + curr); + } + } + return out.toArray(new String[out.size()]); + } + private boolean isDarkModeMediaQuery(String mediaQuery) { - String normalized = mediaQuery == null ? "" : mediaQuery.toLowerCase(); + String normalized = mediaQuery == null ? "" : asciiLower(mediaQuery); return normalized.indexOf("prefers-color-scheme") > -1 && normalized.indexOf("dark") > -1; } @@ -734,9 +2685,30 @@ private String toDarkSelector(String selector) { private String stripComments(String css) { StringBuilder out = new StringBuilder(); int i = 0; + char quote = 0; while (i < css.length()) { char c = css.charAt(i); - if (c == '/' && i + 1 < css.length() && css.charAt(i + 1) == '*') { + // Comment delimiters inside a quoted string are text. + if (quote != 0) { + out.append(c); + if (c == '\\' && i + 1 < css.length()) { + out.append(css.charAt(i + 1)); + i++; + } else if (c == quote) { + quote = 0; + } + i++; + continue; + } + if (c == '\\' && i + 1 < css.length()) { + // An escaped quote outside a string, as in `foo\"bar`, opens no string. + out.append(c).append(css.charAt(i + 1)); + i += 2; + continue; + } + if (c == '"' || c == '\'') { + quote = c; + } else if (c == '/' && i + 1 < css.length() && css.charAt(i + 1) == '*') { i += 2; boolean closed = false; while (i + 1 < css.length()) { @@ -758,21 +2730,112 @@ private String stripComments(String css) { return out.toString(); } + /// Drops a trailing `!important`: the build-time SAC parser reports priority apart from the + /// value and the compiler ignores it, so `includeNativeBool: true !important` stores `true`. + /// `var( --x )` is `var(--x)`: the build-time parser tokenizes function arguments without + /// regard to whitespace, while every lookup here searches for `var(--`. Quoted text is left + /// as written. + private static String tightenVarCalls(String value) { + if (value.indexOf("var(") < 0) { + return value; + } + StringBuilder out = new StringBuilder(value.length()); + char open = 0; + int n = value.length(); + for (int i = 0; i < n; i++) { + char c = value.charAt(i); + out.append(c); + if (c == '\\' && i + 1 < n) { + out.append(value.charAt(++i)); + } else if (open == 0 && (c == '"' || c == '\'')) { + open = c; + } else if (c == open) { + open = 0; + } else if (open == 0 && c == '(' && i >= 3 && value.regionMatches(true, i - 3, "var", 0, 3) + && (i == 3 || !isNameChar(value.charAt(i - 4)))) { + while (i + 1 < n && value.charAt(i + 1) <= ' ') { + i++; + } + } + } + return out.toString(); + } + + private static boolean isNameChar(char c) { + return c == '-' || c == '_' || c > 127 || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9'); + } + + private static String stripPriority(String value) { + // `!` then optional whitespace then `important`, as the CSS grammar has it. + String marker = "important"; + int at = value.length() - marker.length(); + if (at < 0 || !value.regionMatches(true, at, marker, 0, marker.length())) { + return value; + } + while (at > 0 && value.charAt(at - 1) <= ' ') { + at--; + } + if (at == 0 || value.charAt(at - 1) != '!') { + return value; + } + int bang = at - 1; + // `foo\!important` is an identifier with an escaped bang, not a priority. + int slashes = 0; + while (bang - 1 - slashes >= 0 && value.charAt(bang - 1 - slashes) == '\\') { + slashes++; + } + if ((slashes & 1) != 0) { + return value; + } + while (bang > 0 && value.charAt(bang - 1) <= ' ') { + bang--; + } + // Inside an unterminated quote the marker is text, not a priority. The open delimiter + // is tracked, since the other quote character inside a string (`"don't"`) is text. + char open = 0; + for (int i = 0; i < bang; i++) { + char c = value.charAt(i); + if (c == '\\') { + i++; + } else if (open == 0 && (c == '"' || c == '\'')) { + open = c; + } else if (c == open) { + open = 0; + } + } + return open == 0 ? value.substring(0, bang).trim() : value; + } + + /// The first `:` not escaped by a backslash: `a\:b: hello` names the constant `a:b`. + private static int unescapedColon(String declaration) { + for (int i = 0; i < declaration.length(); i++) { + char c = declaration.charAt(i); + if (c == '\\') { + i++; + } else if (c == ':') { + return i; + } + } + return -1; + } + private Declaration[] parseDeclarations(String body) { ArrayList out = new ArrayList(); - String[] segments = splitOnChar(body, ';'); + String[] segments = splitDeclarations(body); for (String line : segments) { String trimmed = line.trim(); if (trimmed.length() == 0) { continue; } - int colon = trimmed.indexOf(':'); + int colon = unescapedColon(trimmed); if (colon <= 0 || colon == trimmed.length() - 1) { throw new CSSSyntaxException("Malformed declaration: " + trimmed); } Declaration dec = new Declaration(); - dec.property = trimmed.substring(0, colon).trim().toLowerCase(); - dec.value = trimmed.substring(colon + 1).trim(); + dec.name = trimmed.substring(0, colon).trim(); + dec.property = asciiLower(dec.name); + dec.value = tightenVarCalls(stripPriority(trimmed.substring(colon + 1).trim())); if (dec.property.length() == 0 || dec.value.length() == 0) { throw new CSSSyntaxException("Malformed declaration: " + trimmed); } @@ -781,6 +2844,41 @@ private Declaration[] parseDeclarations(String body) { return out.toArray(new Declaration[out.size()]); } + /// Splits a rule body on `;`, except inside a quoted value such as `"a;b"`. + private String[] splitDeclarations(String input) { + ArrayList out = new ArrayList(); + int start = 0; + char quote = 0; + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (quote != 0) { + if (c == '\\') { + i++; + } else if (c == quote) { + quote = 0; + } + } else if (c == '\\') { + // `foo\;bar` is one identifier, as the build-time parser reads it. + i++; + } else if (c == '"' || c == '\'') { + quote = c; + } else if ((c == 'u' || c == 'U') && input.regionMatches(true, i, "url(", 0, 4) + && (i == 0 || !isNameChar(input.charAt(i - 1)))) { + // An unquoted url() is one token, so `url(data:image/png;base64,AAAA)` keeps + // its semicolon, as the build-time parser reads it. + int close = input.indexOf(')', i + 4); + if (close > 0) { + i = close; + } + } else if (c == ';') { + out.add(input.substring(start, i)); + start = i + 1; + } + } + out.add(input.substring(start)); + return out.toArray(new String[out.size()]); + } + private String[] splitOnChar(String input, char delimiter) { ArrayList out = new ArrayList(); int start = 0; @@ -795,11 +2893,77 @@ private String[] splitOnChar(String input, char delimiter) { return out.toArray(new String[out.size()]); } + /// The arguments of `rgb()` or `rgba()`. Unlike a font family list, an empty one is an error: + /// the build-time parser rejects `rgb(255,,0,0)` rather than reading it as red. + private static String[] colorArguments(String input, String color) { + ArrayList out = new ArrayList(); + int start = 0; + for (int i = 0; i <= input.length(); i++) { + if (i == input.length() || input.charAt(i) == ',') { + String part = input.substring(start, i).trim(); + if (part.length() == 0) { + throw new CSSSyntaxException("Empty component in color: " + color); + } + out.add(part); + start = i + 1; + } + } + return out.toArray(new String[out.size()]); + } + + /// Lower-cases ASCII letters only. CSS keywords, property names and colors are ASCII by + /// specification, and String.toLowerCase() follows the device locale: on a Turkish device + /// `I` would fold to a dotless i and `PADDING-LEFT`-style names would never match. + private static String asciiLower(String s) { + StringBuilder out = null; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= 'A' && c <= 'Z') { + if (out == null) { + out = new StringBuilder(s); + } + out.setCharAt(i, (char) (c + 32)); + } + } + return out == null ? s : out.toString(); + } + + /// The value of an ASCII hex digit, or -1. CSS escapes take only `0-9a-fA-F`; + /// `Character.digit` would also accept fullwidth and other Unicode digits. + private static int hexDigit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + } + + /// Splits a family list on its commas, except an escaped one (`foo\,monospace` is one + /// identifier) or one inside a quoted name. private String[] splitOnComma(String input) { ArrayList parts = new ArrayList(); int start = 0; + char quote = 0; for (int i = 0; i < input.length(); i++) { - if (input.charAt(i) == ',') { + char c = input.charAt(i); + if (c == '\\') { + i++; + continue; + } + if (quote != 0) { + if (c == quote) { + quote = 0; + } + continue; + } + if (c == '"' || c == '\'') { + quote = c; + } else if (c == ',') { String token = input.substring(start, i).trim(); if (token.length() > 0) { parts.add(token); @@ -837,10 +3001,37 @@ private String[] splitOnWhitespace(String input) { private static class Rule { String selector; Declaration[] declarations; + boolean constants; + /// An `@constants` block rather than `#Constants`. + boolean legacyConstants; + /// A `*` selector (or a bare state), which the build-time compiler ignores. + boolean universal; } private static class Declaration { + /// A `#Constants` alias whose reference was undefined where it was declared. + boolean dropped; + /// The name as written; constants and custom properties are case sensitive. + String name; + /// The lower case name ordinary CSS properties are matched by. String property; String value; + /// A #Constants alias resolved where it was declared, and how to type it (an alias of + /// text is final text, so it is typed like the quoted constant itself); null when it + /// names something not declared yet and is resolved against the final values instead. + String snapshot; + String snapshotKind; + } + + private static class Length { + String text; + byte unit; + } + + private static class FontSize { + /// A `Style.UNIT_TYPE_*`, or -1 for a size given only as a system size. + byte unit = -1; + float value; + int systemSize = Font.SIZE_MEDIUM; } } diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index 8d884d0ce5c..5f52de9fe3a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -3243,7 +3243,26 @@ public Object createTestFont(int charWidth, int height) { @Override public Object createFont(int face, int style, int size) { - return new TestFont(defaultFont.charWidth, defaultFont.height); + TestFont font = new TestFont(defaultFont.charWidth, defaultFont.height); + font.face = face; + font.style = style; + font.size = size; + return font; + } + + @Override + public int getStyle(Object nativeFont) { + return nativeFont instanceof TestFont ? ((TestFont) nativeFont).style : 0; + } + + @Override + public int getFace(Object nativeFont) { + return nativeFont instanceof TestFont ? ((TestFont) nativeFont).face : 0; + } + + @Override + public int getSize(Object nativeFont) { + return nativeFont instanceof TestFont ? ((TestFont) nativeFont).size : 0; } @Override @@ -5637,6 +5656,10 @@ public String toString() { public static final class TestFont { final int charWidth; final int height; + /// What a system font was created with; a real port reports these from its native font. + int face; + int style; + int size = com.codename1.ui.Font.SIZE_MEDIUM; TestFont(int charWidth, int height) { this.charWidth = charWidth; 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 8161c0a0f8c..f02016adcb8 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 @@ -25,14 +25,20 @@ import com.codename1.junit.UITestBase; import com.codename1.ui.Button; import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Font; import com.codename1.ui.Image; +import com.codename1.ui.Label; import com.codename1.ui.plaf.CSSBorder; +import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.MutableResource; import java.util.Hashtable; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -108,12 +114,14 @@ public void testCompilesThemeConstantsDeriveAndMutableImages() { 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")); - assertEquals("2,4,6,8", theme.get("Label.margin")); + // Theme storage order is top,bottom,left,right; CSS writes top,right,bottom,left. + assertEquals("1,1,2,2", theme.get("Button.padding")); + assertEquals("2,6,8,4", 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")); + // A constant resolving to a color is stored as plain hex; :root's own stays as written. + assertEquals("aabbcc", theme.get("@primaryColor")); assertEquals(Integer.valueOf(Component.CENTER), theme.get("Button.align")); assertTrue(theme.get("Button.border") instanceof CSSBorder); assertEquals("00ff00", theme.get("Button.press#fgColor")); @@ -239,4 +247,1518 @@ public void testCompilesSideBorderLonghands() { assertTrue(css.contains("#ff0000ff")); } + @Test + public void testHashConstantsBlockKeepsCaseAndDecodesQuotes() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants {\n" + + " includeNativeBool: true;\n" + + " customMessage: \"Hello\";\n" + + " singleQuotedBool: 'true';\n" + + " withSemicolon: \"a;b\";\n" + + " --accentColor: #ff2d95;\n" + + "}\n" + + "Label{color:var(--accentColor);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("true", theme.get("@includeNativeBool")); + assertFalse(theme.containsKey("@includenativebool")); + assertEquals("Hello", theme.get("@customMessage")); + assertEquals("true", theme.get("@singleQuotedBool")); + assertEquals("a;b", theme.get("@withSemicolon")); + // Plain hex, as the build-time compiler stores a color constant. + assertEquals("ff2d95", theme.get("@accentColor")); + assertEquals("ff2d95", theme.get("Label.fgColor")); + // The block is constants only, never a UIID called "#Constants". + for (Object key : theme.keySet()) { + assertFalse(String.valueOf(key).indexOf("Constants") > -1, String.valueOf(key)); + } + } + + @Test + public void testLegacyConstantsBlockStillWorksAndMayFollowItsUse() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{color:var(--tint);}" + + "@constants{ tint: #102030; quoted: \"x y\"; plain: 12; }" + + "@constants{ second: 'two'; }", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("102030", theme.get("@tint")); + assertEquals("x y", theme.get("@quoted")); + assertEquals("12", theme.get("@plain")); + assertEquals("two", theme.get("@second")); + assertEquals("102030", theme.get("Label.fgColor")); + } + + @Test + public void testRootCustomPropertiesKeepCase() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--mainColor:#123456;}Label{color:var(--mainColor);}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("#123456", theme.get("@mainColor")); + assertEquals("123456", theme.get("Label.fgColor")); + } + + @Test + public void testBoxShorthandForEveryLength() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("One{padding:5px;}" + + "Two{padding:1px 2px;}" + + "Three{padding:1px 2px 3px;}" + + "Four{padding:1px 2px 3px 4px;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("5,5,5,5", theme.get("One.padding")); + assertEquals("1,1,2,2", theme.get("Two.padding")); + assertEquals("1,3,2,2", theme.get("Three.padding")); + assertEquals("1,3,4,2", theme.get("Four.padding")); + byte px = Style.UNIT_TYPE_PIXELS; + assertArrayEquals(new byte[]{px, px, px, px}, (byte[]) theme.get("Four.padUnit")); + } + + @Test + public void testIssueExampleResolvesToTheRightSides() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label {\n padding: 1px 2px 3px 4px;\n margin: 1mm 2mm;\n}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("1,3,4,2", theme.get("Label.padding")); + assertEquals("1,1,2,2", theme.get("Label.margin")); + byte mm = Style.UNIT_TYPE_DIPS; + assertArrayEquals(new byte[]{mm, mm, mm, mm}, (byte[]) theme.get("Label.marUnit")); + + UIManager.getInstance().addThemeProps(theme); + Style s = new Label("x").getUnselectedStyle(); + assertEquals(1f, s.getPaddingFloatValue(false, Component.TOP), 0f); + assertEquals(2f, s.getPaddingFloatValue(false, Component.RIGHT), 0f); + assertEquals(3f, s.getPaddingFloatValue(false, Component.BOTTOM), 0f); + assertEquals(4f, s.getPaddingFloatValue(false, Component.LEFT), 0f); + assertEquals(1f, s.getMarginFloatValue(false, Component.TOP), 0f); + assertEquals(2f, s.getMarginFloatValue(false, Component.RIGHT), 0f); + assertEquals(1f, s.getMarginFloatValue(false, Component.BOTTOM), 0f); + assertEquals(2f, s.getMarginFloatValue(false, Component.LEFT), 0f); + assertEquals(mm, s.getMarginUnit()[Component.LEFT]); + assertEquals(Style.UNIT_TYPE_PIXELS, s.getPaddingUnit()[Component.LEFT]); + } + + @Test + public void testMixedUnitsKeepEachSidesUnit() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Mixed{padding:1mm 2vw 2.5pt 3px;}" + + "Mixed:pressed{margin:1.5mm 2cm;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + // top=1mm, bottom=2.5pt (a density independent pixel, rounded), left=3px, right=2vw. + assertEquals("1,3,3,2", theme.get("Mixed.padding")); + assertArrayEquals(new byte[]{Style.UNIT_TYPE_DIPS, Style.UNIT_TYPE_PIXELS, + Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_VW}, + (byte[]) theme.get("Mixed.padUnit")); + assertEquals("1.5,1.5,20,20", theme.get("Mixed.press#margin")); + byte mm = Style.UNIT_TYPE_DIPS; + assertArrayEquals(new byte[]{mm, mm, mm, mm}, (byte[]) theme.get("Mixed.press#marUnit")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("Mixed.marUnit")); + } + + @Test + public void testRejectsMalformedBoxValues() { + CSSThemeCompiler compiler = new CSSThemeCompiler(); + MutableResource resource = new MutableResource(); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{padding:1px 2px 3px 4px 5px;}", resource, "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{margin:wide;}", resource, "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{font-size:huge;}", resource, "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{font-size:not-small;}", resource, "Theme")); + // The build-time compiler has no percentage inset. + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + compiler.compile("Label{padding:10%;}", resource, "Theme")); + } + + @Test + public void testFontSizeWithFamilyResolvesToTheRequestedSize() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label {\n font-family: \"native:MainRegular\";\n font-size: 3mm;\n}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + Font font = (Font) theme.get("Label.font"); + assertNotNull(font); + assertTrue(font.isTTFNativeFont()); + assertEquals(Display.getInstance().convertToPixels(30, true) / 10f, font.getPixelSize(), 0.001f); + + UIManager.getInstance().addThemeProps(theme); + assertEquals(font, new Label("x").getUnselectedStyle().getFont()); + } + + @Test + public void testFontSizeUnitsAndStateWithoutFamily() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Sized{font-size:12px;}" + + "Sized:selected{font-size:18pt;}" + + "Split{font-family:'native:MainBold';}" + + "Split{font-size:2mm;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals(12f, ((Font) theme.get("Sized.font")).getPixelSize(), 0.001f); + // 18pt is 6.35mm, the build-time compiler's conversion. + float pt = Display.getInstance().convertToPixels((int) (18f / 72f * 25.4f * 10), true) / 10f; + assertEquals(pt, ((Font) theme.get("Sized.sel#font")).getPixelSize(), 0.001f); + // Family and size declared in two rules for the same UIID still meet. + Font split = (Font) theme.get("Split.font"); + assertEquals(Display.getInstance().convertToPixels(20, true) / 10f, split.getPixelSize(), 0.001f); + } + + @Test + public void testColorConstantsAreStoredAsPlainHex() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ infiniteDefaultColor: #f00; rgbColor: rgb(1, 2, 3);" + + " quotedHash: \"#abc\"; word: red; }", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@infiniteDefaultColor")); + assertEquals(0xff0000, Integer.parseInt((String) theme.get("@infiniteDefaultColor"), 16)); + assertEquals("010203", theme.get("@rgbColor")); + // A quoted value is a string, not a color. + assertEquals("#abc", theme.get("@quotedHash")); + assertEquals("red", theme.get("@word")); + } + + @Test + public void testImageConstantsResolveToTheResourceImage() { + MutableResource resource = new MutableResource(); + Image spinner = Image.createImage(4, 4, 0xff00ff00); + resource.setImage("spinner.png", spinner); + new CSSThemeCompiler().compile("#Constants{ infiniteImage: spinner.png; }", resource, "Theme"); + assertTrue(resource.getTheme("Theme").get("@infiniteImage") instanceof Image); + UIManager.getInstance().addThemeProps(resource.getTheme("Theme")); + assertNotNull(UIManager.getInstance().getThemeImageConstant("infiniteImage")); + + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ missingImage: nothing.png; }", new MutableResource(), "Theme")); + + // Through a custom property, quoted or not. + MutableResource viaVar = new MutableResource(); + viaVar.setImage("spinner.png", spinner); + new CSSThemeCompiler().compile(":root{ --spinner: spinner.png; --q: \"spinner.png\"; }" + + "#Constants{ infiniteImage: var(--spinner); quotedImage: var(--q); }", viaVar, "Theme"); + assertTrue(viaVar.getTheme("Theme").get("@infiniteImage") == spinner); + assertTrue(viaVar.getTheme("Theme").get("@quotedImage") == spinner); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile(":root{ --x: nothing.png; } #Constants{ goneImage: var(--x); }", + new MutableResource(), "Theme")); + } + + @Test + public void testStateFontInheritsTheUndeclaredHalfFromTheBase() { + MutableResource resource = new MutableResource(); + // The state rule comes first on purpose: the merge must not depend on rule order. + new CSSThemeCompiler().compile("Label:selected{font-size:20px;}" + + "Label{font-family:'native:MainBold';font-size:2mm;}" + + "Label:pressed{font-family:'native:MainLight';}" + + "@media (prefers-color-scheme: dark){ Label:disabled{color:#ffffff;font-size:9px;} }", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + Font selected = (Font) theme.get("Label.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + assertEquals("native:MainBold", fontName(selected)); + Font pressed = (Font) theme.get("Label.press#font"); + assertEquals(Display.getInstance().convertToPixels(20, true) / 10f, pressed.getPixelSize(), 0.001f); + assertEquals("native:MainLight", fontName(pressed)); + // A dark rule is its own element in the build-time compiler: no light family reaches it. + Font dark = (Font) theme.get("$DarkLabel.dis#font"); + assertEquals(9f, dark.getPixelSize(), 0.001f); + assertEquals("native:MainRegular", fontName(dark)); + } + + @Test + public void testColorConstantFromVarIsStoredAsPlainHex() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--c:#f00;}" + + ":root{--name:hello;}" + + "#Constants{infiniteDefaultColor:var(--c); label:var(--name);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@infiniteDefaultColor")); + assertEquals("hello", theme.get("@label")); + } + + @Test + public void testLaterDeclarationWinsBetweenRootAndConstants() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--accent:#00ff00;} #Constants{--accent:#ff0000;}" + + "Label{color:var(--accent);}", resource, "Theme"); + assertEquals("ff0000", resource.getTheme("Theme").get("@accent")); + assertEquals("ff0000", resource.getTheme("Theme").get("Label.fgColor")); + + resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#ff0000;} :root{--accent:#00ff00;}" + + "Label{color:var(--accent);}", resource, "Theme"); + // Only #Constants exports: the later :root changes var(--accent), not @accent. + assertEquals("ff0000", resource.getTheme("Theme").get("@accent")); + assertEquals("00ff00", resource.getTheme("Theme").get("Label.fgColor")); + // A constant declared after the rule that uses it is not seen by that rule: the build-time + // parser resolves var() in source order, #Constants blocks included. + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{color:var(--late);} #Constants{--late:#0000ff;}", new MutableResource(), "Theme")); + } + + @Test + public void testStateFontFollowsTheDeriveChain() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Child:selected{font-size:20px;}" + + "Child{cn1-derive:Middle;}" + + "Middle{cn1-derive:Base;}" + + "Base:selected{font-family:'native:MainBold';}" + + "Base{font-family:'native:MainLight';font-size:2mm;}" + + "Cyc1{cn1-derive:Cyc2;} Cyc2{cn1-derive:Cyc1;} Cyc1:pressed{font-size:7px;}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + Font selected = (Font) theme.get("Child.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + // Base's selected family wins over Base's unselected one: same state first. + assertEquals("native:MainBold", fontName(selected)); + Font cyclic = (Font) theme.get("Cyc1.press#font"); + assertEquals(7f, cyclic.getPixelSize(), 0.001f); + assertEquals("native:MainRegular", fontName(cyclic)); + } + + @Test + public void testImageConstantMaySeeAnImageARuleCreates() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Maker{cn1-mutable-image:spinner #ffffff;}" + + "#Constants{infiniteImage:spinner;}", resource, "Theme"); + assertTrue(resource.getTheme("Theme").get("@infiniteImage") == resource.getImage("spinner")); + } + + @Test + public void testOnlyTheWinningConstantDeclarationIsReinterpreted() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--x:#00ff00;}" + + "#Constants{token:var(--x);} #Constants{token:\"#abc\";}", resource, "Theme"); + assertEquals("#abc", resource.getTheme("Theme").get("@token")); + } + + @Test + public void testQuotedBraceDoesNotEndTheConstantsBlock() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{customMessage:\"a}b\"; enabled:true;}" + + "Label{color:#ff0000;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("a}b", theme.get("@customMessage")); + assertEquals("true", theme.get("@enabled")); + assertEquals("ff0000", theme.get("Label.fgColor")); + } + + @Test + public void testDerivedStateFontBeatsTheChildsUnselectedFont() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Child{cn1-derive:Base;font-family:'native:MainLight';}" + + "Base:selected{font-family:'native:MainBold';}" + + "Child:selected{font-size:20px;}", resource, "Theme"); + Font selected = (Font) resource.getTheme("Theme").get("Child.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + assertEquals("native:MainBold", fontName(selected)); + } + + @Test + public void testRgbaColorConstantKeepsItsRgb() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--half:rgba(0, 0, 255, 0.25);}" + + "#Constants{infiniteDefaultColor: rgba(255, 0, 0, 0.5); viaVar: var(--half);}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@infiniteDefaultColor")); + assertEquals("0000ff", theme.get("@viaVar")); + } + + @Test + public void testCommentMarkersInsideQuotesAreText() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("/* a real comment */ #Constants{ message: \"a/*b*/c\"; open: '/*'; }" + + "Label{color:#ff0000;}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("a/*b*/c", theme.get("@message")); + assertEquals("/*", theme.get("@open")); + assertEquals("ff0000", theme.get("Label.fgColor")); + } + + @Test + public void testStateFontIgnoresTheUniversalStyle() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("* { font-family: 'native:MainBold'; } Label:selected { font-size: 20px; }", + resource, "Theme"); + Font selected = (Font) resource.getTheme("Theme").get("Label.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + // The build-time compiler ignores `*` rules, so nothing is inherited from it. + assertEquals("native:MainRegular", fontName(selected)); + } + + @Test + public void testCyclicConstantsAreRejected() { + // The build-time compiler adds a custom property before it evaluates it, so a self or + // mutual reference is a cycle that fails the build. A plain name is no variable, so a + // reference to it is undefined and the constant is left out instead. + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ --a: var(--a); }", new MutableResource(), "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{ --b: var(--c); --c: var(--b); }", new MutableResource(), "Theme")); + MutableResource cyclic = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ a: var(--a); }", cyclic, "Theme"); + assertFalse(cyclic.getTheme("Theme").containsKey("@a")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile(":root{ --a: var(--b); } :root{ --b: var(--a); } Label{ color: var(--a); }", + new MutableResource(), "Theme")); + // Two uses of one constant are not a cycle. The build-time constants emitter writes only + // the leading unit of the chain. + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ --c: #ff0000; pair: var(--c) var(--c); }", resource, "Theme"); + assertEquals("ff0000", resource.getTheme("Theme").get("@pair")); + } + + @Test + public void testPlainConstantDoesNotHideAVariableOfTheSameName() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#f00;} #Constants{accent:blue;}" + + " Label{color:var(--accent);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("blue", theme.get("@accent")); + assertEquals("ff0000", theme.get("Label.fgColor")); + } + + @Test + public void testEscapedFontFamilyIdentifierIsDecoded() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:native\\:MainBold;font-size:2mm;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Label.font"))); + } + + @Test + public void testExponentConstantIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants{ scale: 1e2; }", new MutableResource(), "Theme")); + } + + @Test + public void testLaterRootPropertyChangesTheVariableNotTheConstant() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#f00;} :root{--accent:#0f0;}" + + " Label{color:var(--accent);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@accent")); + assertEquals("00ff00", theme.get("Label.fgColor")); + } + + @Test + public void testVariableAliasIsResolvedWhereItIsUsed() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--base:#f00;--alias:var(--base);--base:#00f;}" + + " Label{color:var(--alias);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@alias")); + assertEquals("0000ff", theme.get("Label.fgColor")); + } + + @Test + public void testColorVariableKeepsItsCssSpellingInABorder() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#f00;} Label{border:1px solid var(--accent);}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", theme.get("@accent")); + assertNotNull(theme.get("Label.border")); + } + + @Test + public void testMultiTokenAliasExportsItsLeadingToken() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--x:foo; pair:var(--x) bar;}", resource, "Theme"); + assertEquals("foo", resource.getTheme("Theme").get("@pair")); + } + + @Test + public void testExponentInsetWithUnitIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("Label{padding:1e2px;}", new MutableResource(), "Theme")); + } + + @Test + public void testUnescapedNewlineInStringIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants{message:\"first\nsecond\";}", new MutableResource(), "Theme")); + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{message:\"first\\\nsecond\";}", resource, "Theme"); + assertEquals("firstsecond", resource.getTheme("Theme").get("@message")); + } + + @Test + public void testQuotedVarTextIsNotResolved() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{ --name: world; --rootQuoted: \"var(--name)\"; }" + + "#Constants{ message: \"var(--name)\"; plain: var(--name); }", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("var(--name)", theme.get("@message")); + assertEquals("var(--name)", theme.get("@rootQuoted")); + assertEquals("world", theme.get("@plain")); + } + + @Test + public void testRulesSeeTheFinalValueOfARedeclaredConstant() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#ff0000;} Label{color:var(--accent);}" + + " #Constants{--accent:#0000ff;}" + + ":root{--bg:#ff0000;} Button{background-color:var(--bg);} :root{--bg:#00ff00;}", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("0000ff", theme.get("@accent")); + // The rule sits between the two blocks, so it sees the first value. + assertEquals("ff0000", theme.get("Label.fgColor")); + // A :root property takes effect where it is declared, as in the build-time parser. + assertEquals("ff0000", theme.get("Button.bgColor")); + assertEquals("#00ff00", theme.get("@bg")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{color:var(--late);} :root{--late:#ff0000;}", new MutableResource(), "Theme")); + } + + @Test + public void testStateDeriveIsFollowedAtEveryHop() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:'native:MainBold';}" + + "Middle:selected{cn1-derive:Base;}" + + "Child:selected{cn1-derive:Middle;font-size:20px;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Child.sel#font"))); + } + + @Test + public void testAliasOfAQuotedConstantStaysText() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--literal:\"#abc\"; --alias:var(--literal); twice:var(--alias);" + + " --color:#abc; colorAlias:var(--color);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("#abc", theme.get("@alias")); + assertEquals("#abc", theme.get("@twice")); + assertEquals("aabbcc", theme.get("@colorAlias")); + } + + @Test + public void testCn1ColorFunctionsInConstants() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ a: cn1rgb(255,0,0); b: cn1rgba(0,255,0,0.5); }", + resource, "Theme"); + assertEquals("ff0000", resource.getTheme("Theme").get("@a")); + assertEquals("00ff00", resource.getTheme("Theme").get("@b")); + } + + @Test + public void testQuotedConstantStaysTextThroughAnAlias() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--name:world;--message:\"var(--name)\";alias:var(--message);}", + resource, "Theme"); + assertEquals("var(--name)", resource.getTheme("Theme").get("@alias")); + } + + @Test + public void testStateFontFollowsTheStatesOwnDerive() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:'native:MainBold';}" + + "Child:selected{cn1-derive:Base;font-size:20px;}", resource, "Theme"); + Font selected = (Font) resource.getTheme("Theme").get("Child.sel#font"); + assertEquals(20f, selected.getPixelSize(), 0.001f); + assertEquals("native:MainBold", fontName(selected)); + } + + @Test + public void testQuotedConstantsDecodeHexEscapes() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ a: \"\\41\"; b: \"\\41 B\"; c: \"\\1F600\"; d: \"x\\\"y\"; }", + resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("A", theme.get("@a")); + assertEquals("AB", theme.get("@b")); + assertEquals(new String(Character.toChars(0x1F600)), theme.get("@c")); + assertEquals("x\"y", theme.get("@d")); + } + + @Test + public void testSecondCompileKeepsEarlierLiteralConstants() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{message:\"var(--name)\";}", resource, "Theme"); + new CSSThemeCompiler().compile("Label{color:#ff0000;}", resource, "Theme"); + assertEquals("var(--name)", resource.getTheme("Theme").get("@message")); + } + + @Test + public void testSiblingStateFontUsesADeriveDeclaredInAnotherState() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:'native:MainBold';}" + + "Child:selected{cn1-derive:Base;}" + + "Child:pressed{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Child.press#font"))); + } + + @Test + public void testRelativeFontSizeKeywordsAreRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{font-size:smaller;}", new MutableResource(), "Theme")); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{font-size:larger;}", new MutableResource(), "Theme")); + } + + @Test + public void testCrlfLineContinuationInAQuotedConstant() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{a:\"first\\\r\nsecond\";b:\"x\\\ry\";}", resource, "Theme"); + assertEquals("firstsecond", resource.getTheme("Theme").get("@a")); + assertEquals("xy", resource.getTheme("Theme").get("@b")); + } + + @Test + public void testCustomPropertyInARuleAppliesInDeclarationOrder() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{--accent:#ff0000;color:var(--accent);--accent:#0000ff;}", + resource, "Theme"); + assertEquals("ff0000", resource.getTheme("Theme").get("Label.fgColor")); + } + + @Test + public void testUnitlessDecimalInsetIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{padding:1.5;}", new MutableResource(), "Theme")); + } + + @Test + public void testLastStateDeriveWinsForTheOwningUiid() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("BaseA{font-family:'native:MainBold';}" + + "BaseB{font-family:'native:MainLight';}" + + "Child:selected{cn1-derive:BaseA;}" + + "Child:disabled{cn1-derive:BaseB;}" + + "Child:pressed{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainLight", fontName((Font) resource.getTheme("Theme").get("Child.press#font"))); + } + + @Test + public void testSecondCompileKeepsTheFamilyOfAnEarlierFont() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:'native:MainBold';font-size:10px;}", resource, "Theme"); + new CSSThemeCompiler().compile("Label:selected{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Label.sel#font"))); + } + + @Test + public void testSameStateRecompileKeepsTheEarlierFamily() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:'native:MainBold';font-size:10px;}", resource, "Theme"); + new CSSThemeCompiler().compile("Label{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainBold", fontName((Font) resource.getTheme("Theme").get("Label.font"))); + } + + @Test + public void testAliasOfAnEarlierCompilesLiteralStaysText() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{message:\"var(--name)\";}", resource, "Theme"); + new CSSThemeCompiler().compile("#Constants{alias:var(--message);}", resource, "Theme"); + assertEquals("var(--name)", resource.getTheme("Theme").get("@alias")); + } + + @Test + public void testAliasOfAnEarlierUnquotedColorBecomesAColor() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(":root{--accent:#abc;}", resource, "Theme"); + new CSSThemeCompiler().compile("#Constants{alias:var(--accent);}", resource, "Theme"); + assertEquals("aabbcc", String.valueOf(resource.getTheme("Theme").get("@alias")).toLowerCase()); + } + + @Test + public void testRecompileKeepsAMonospaceFamily() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:monospace;}", resource, "Theme"); + new CSSThemeCompiler().compile("Label{font-size:20px;}", resource, "Theme"); + assertEquals(Font.FACE_MONOSPACE, ((Font) resource.getTheme("Theme").get("Label.font")).getFace()); + } + + @Test + public void testDeclarationPriorityIsNotPartOfTheValue() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{includeNativeBool:true !important;msg:\"a !important\";}", + resource, "Theme"); + assertEquals("true", resource.getTheme("Theme").get("@includeNativeBool")); + assertEquals("a !important", resource.getTheme("Theme").get("@msg")); + } + + @Test + public void testQuotedCustomPropertyIsNotAColorInAStyle() { + MutableResource resource = new MutableResource(); + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants{--accent:\"#f00\";} Label{color:var(--accent);}", resource, "Theme")); + new CSSThemeCompiler().compile("#Constants{--accent:\"#f00\";}", resource, "Theme"); + assertEquals("#f00", resource.getTheme("Theme").get("@accent")); + } + + @Test + public void testTheLastDeriveWinsAlsoForAStateWithItsOwn() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("BaseA{font-family:'native:MainBold';}" + + "BaseB{font-family:'native:MainLight';}" + + "Child:selected{cn1-derive:BaseA;}" + + "Child:disabled{cn1-derive:BaseB;}" + + "Child:selected{font-size:20px;}", resource, "Theme"); + assertEquals("native:MainLight", fontName((Font) resource.getTheme("Theme").get("Child.sel#font"))); + } + + @Test + public void testFamilyWalkContinuesPastANativeFont() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("A{font-family:'native:MainBold',monospace;}" + + "B{font-family:'native:MainBold','native:MainLight';}", resource, "Theme"); + assertEquals(Font.FACE_MONOSPACE, ((Font) resource.getTheme("Theme").get("A.font")).getFace()); + assertEquals("native:MainLight", fontName((Font) resource.getTheme("Theme").get("B.font"))); + } + + @Test + public void testJavaOnlyNumberSpellingsAreRejected() { + for (String bad : new String[]{"1fpx", "NaNpx", "Infinitypx", "1dpx", "0x1p3px", "1.px", "1e1px"}) { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("Label{padding:" + bad + ";}", new MutableResource(), "Theme"), bad); + } + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{padding:.5mm 10px +2px -0px;}", resource, "Theme"); + assertEquals("0.5,2,0,10", resource.getTheme("Theme").get("Label.padding")); + } + + @Test + public void testConstantsAliasKeepsTheValueAtItsDeclaration() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--accent:#f00;alias:var(--accent);--accent:#00f;" + + "early:var(--later);--later:#0f0;--lit:\"#abc\";litAlias:var(--lit);}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", String.valueOf(theme.get("@alias")).toLowerCase()); + assertEquals("0000ff", String.valueOf(theme.get("@accent")).toLowerCase()); + // A forward reference is undefined where it is declared. + assertFalse(theme.containsKey("@early")); + assertEquals("#abc", theme.get("@litAlias")); + } + + @Test + public void testVarFallbackIsUsedForAnUndefinedVariable() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{accentColor:var(--missing, #f00);--set:#00f;" + + "setColor:var(--set, #f00);} Label{color:var(--nope, rgb(0, 255, 0));}", resource, "Theme"); + Hashtable theme = resource.getTheme("Theme"); + assertEquals("ff0000", String.valueOf(theme.get("@accentColor")).toLowerCase()); + assertEquals("0000ff", String.valueOf(theme.get("@setColor")).toLowerCase()); + assertEquals("00ff00", String.valueOf(theme.get("Label.fgColor")).toLowerCase()); + } + + private static String fontName(Font font) { + try { + java.lang.reflect.Field id = Font.class.getDeclaredField("fontUniqueId"); + id.setAccessible(true); + return (String) id.get(font); + } catch (ReflectiveOperationException err) { + throw new AssertionError(err); + } + } + + + @Test + void testPriorityAfterAStringHoldingTheOtherQuoteIsStripped() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { msg: \"don't\" !important; }", res, "Theme"); + assertEquals("don't", res.getTheme("Theme").get("@msg")); + } + + @Test + void testLengthAndPercentageConstantsAreRejected() { + for (String value : new String[]{"50%", "2mm", "1.5px", "url(foo.png)"}) { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants { progress: " + value + "; }", new MutableResource(), "Theme")); + } + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { progress: \"50%\"; }", res, "Theme"); + assertEquals("50%", res.getTheme("Theme").get("@progress")); + } + + @Test + void testNumericConstantsAreStoredAsTheBuildSerializesThem() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { scale: 1.50; count: 001; whole: 2.0; flag: true; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("1.5", theme.get("@scale")); + assertEquals("1", theme.get("@count")); + assertEquals("2", theme.get("@whole")); + assertEquals("true", theme.get("@flag")); + } + + @Test + void testAKeywordSizeKeepsTheInheritedNativeFamily() { + MutableResource resource = new MutableResource(); + CSSThemeCompiler compiler = new CSSThemeCompiler(); + compiler.compile("Label{font-family:'native:MainBold';font-size:2mm;}", resource, "Theme"); + compiler.compile("Label{font-size:large;}", resource, "Theme"); + Font font = (Font) resource.getTheme("Theme").get("Label.font"); + assertEquals("native:MainBold", fontName(font)); + // Font.derive hands back the cached base font for its own natural height, so the height + // is compared rather than the requested pixel size. + assertEquals(Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE).getHeight(), + font.getHeight()); + } + + @Test + void testUnitlessZeroFontSizeIsAccepted() { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-size:0;}", resource, "Theme"); + assertNotNull(resource.getTheme("Theme").get("Label.font")); + } + + @Test + void testEscapedAndNonAsciiIdentifierConstants() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { a: caf\u00e9; b: foo\\ bar; c: \\41 x; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("caf\u00e9", theme.get("@a")); + assertEquals("foo bar", theme.get("@b")); + assertEquals("Ax", theme.get("@c")); + } + + @Test + void testWhitespaceInsideVarIsAllowed() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile(":root{--accent:#f00} Label{color:var( --accent )}", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); + } + + @Test + void testAConstantWithAnUndefinedVariableIsLeftOut() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { label: var(--missing); other: var(--label); kept: var(--missing, x); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertFalse(theme.containsKey("@label")); + assertFalse(theme.containsKey("@other")); + assertEquals("x", theme.get("@kept")); + } + + @Test + void testUnterminatedVarIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants { label: var(--name; }", new MutableResource(), "Theme")); + } + + @Test + void testExponentUnitlessInsetIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("Label{padding:1e2;}", new MutableResource(), "Theme")); + } + + @Test + void testFractionalPixelFontSizeTakesTheIntegerPart() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:'native:MainBold';font-size:12.5px;} Small{font-family:'native:MainBold';font-size:0.5px;}", + res, "Theme"); + assertEquals(12f, ((Font) res.getTheme("Theme").get("Label.font")).getPixelSize(), 0.001f); + assertEquals(0.5f, ((Font) res.getTheme("Theme").get("Small.font")).getPixelSize(), 0.001f); + } + + @Test + void testAFallbackIsStoredByItsTokenType() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: var(--missing, \"hello\"); scale: var(--missing, 1.50); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("hello", theme.get("@message")); + assertEquals("1.5", theme.get("@scale")); + } + + @Test + void testPriorityWithWhitespaceAfterTheBang() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { includeNativeBool: true ! important; }", res, "Theme"); + assertEquals("true", res.getTheme("Theme").get("@includeNativeBool")); + } + + @Test + void testAnOrdinaryConstantIsNotACustomProperty() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: #f00; --real: #0f0; alias: var(--accent); other: var(--real); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertFalse(theme.containsKey("@alias")); + assertEquals("00ff00", theme.get("@other")); + } + + @Test + void testAColorNamedImageIsStoredAsAColor() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { placeholderImage: #f00; }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("@placeholderImage")); + } + + @Test + void testAnImageAliasKeepsTheImageAtItsDeclaration() { + MutableResource res = new MutableResource(); + Image first = Image.createImage(3, 3); + res.setImage("first.png", first); + res.setImage("second.png", Image.createImage(4, 4)); + new CSSThemeCompiler().compile("#Constants { --icon: \"first.png\"; navImage: var(--icon); --icon: \"second.png\"; }", + res, "Theme"); + assertEquals(3, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testDefaultPropertiesAreAlsoStoredAtTheRoot() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Default{color:#ff0000;font-size:20px;}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("ff0000", theme.get("fgColor")); + assertEquals(20f, ((Font) theme.get("font")).getPixelSize(), 0.001f); + } + + @Test + void testUniversalRulesSetNoFont() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("*{font-size:20px;}", res, "Theme"); + assertFalse(res.getTheme("Theme").containsKey("Component.font")); + } + + @Test + void testEscapedIdentifiers() { + MutableResource res = new MutableResource(); + Image icon = Image.createImage(5, 5); + res.setImage("my icon", icon); + new CSSThemeCompiler().compile("#Constants { emptyImage: my\\ icon; message: foo\\!important; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals(5, ((Image) theme.get("@emptyImage")).getWidth()); + assertEquals("foo!important", theme.get("@message")); + + res = new MutableResource(); + res.setImage("my icon", icon); + new CSSThemeCompiler().compile("#Constants { --icon: my\\ icon; navImage: var(--icon); }", res, "Theme"); + assertEquals(5, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testDirectMultiTokenConstantExportsItsLeadingToken() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{ pair: foo bar; num: 1.50 2; col: #f00 x; str: \"a b\" c; }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("foo", theme.get("@pair")); + assertEquals("1.5", theme.get("@num")); + assertEquals("ff0000", theme.get("@col")); + assertEquals("a b", theme.get("@str")); + } + + @Test + void testLaterRootReplacesALegacyConstant() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("@constants { accent: red; } :root { --accent: blue; }", res, "Theme"); + assertEquals("blue", res.getTheme("Theme").get("@accent")); + } + + @Test + void testSelfReferencingCustomPropertyIsACycle() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler() + .compile("#Constants { --accent: var(--accent, #f00); }", new MutableResource(), "Theme")); + } + + @Test + void testPressedDeriveIsNotPromotedToTheOwner() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainBold;font-size:2mm;}" + + " Child:pressed{cn1-derive:Base;} Child:selected{font-size:20px;}", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("Child.sel#font"))); + } + + @Test + void testImageConstantLooksUpItsLeadingToken() { + MutableResource res = new MutableResource(); + res.setImage("icon", Image.createImage(4, 4)); + new CSSThemeCompiler().compile("#Constants { navImage: icon extra; }", res, "Theme"); + assertEquals(4, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testEscapedRedeclarationHidesAnEarlierCompilesValue() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --abc: red; }", res, "Theme"); + new CSSThemeCompiler().compile("#Constants { early: var(--abc); --a\\62 c: blue; }", res, "Theme"); + assertFalse(res.getTheme("Theme").containsKey("@early")); + assertEquals("blue", res.getTheme("Theme").get("@abc")); + } + + @Test + void testImageAliasChainKeepsItsQuotedName() { + MutableResource res = new MutableResource(); + res.setImage("my icon", Image.createImage(5, 5)); + new CSSThemeCompiler().compile("#Constants { --name: \"my icon\"; --alias: var(--name);" + + " navImage: var(--alias); }", res, "Theme"); + assertEquals(5, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testEscapedSemicolonStaysInTheIdentifier() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: foo\\;bar; }", res, "Theme"); + assertEquals("foo;bar", res.getTheme("Theme").get("@message")); + } + + @Test + void testDarkFontDoesNotFollowTheLightDerive() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Child{cn1-derive:Base;}" + + " @media (prefers-color-scheme: dark){ Base{font-family:native:MainBold;font-size:2mm;}" + + " Child{font-size:20px;} }", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("$DarkChild.font"))); + } + + @Test + void testOutOfRangeRgbChannelIsClamped() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: rgb(200%, 300, -5); }", res, "Theme"); + assertEquals(0xffff00, Integer.parseInt((String) res.getTheme("Theme").get("@accent"), 16)); + } + + @Test + void testEscapedQuoteOutsideAStringOpensNoString() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: foo\\\"bar; } Label{padding:1px;}", res, "Theme"); + assertEquals("foo\"bar", res.getTheme("Theme").get("@message")); + } + + @Test + void testDarkFontIgnoresALightDeriveFromAnEarlierCompile() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Child{cn1-derive:Base;}", res, "Theme"); + new CSSThemeCompiler().compile("@media (prefers-color-scheme: dark){" + + " Base{font-family:native:MainBold;font-size:2mm;} Child{font-size:20px;} }", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("$DarkChild.font"))); + } + + @Test + void testConstantsInASelectorList() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants, Other { accentColor: #ff0000; }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("@accentColor")); + } + + @Test + void testDeriveTargetIsUnquotedAndDecoded() { + for (String target : new String[]{"\"Base\"", "B\\61se"}) { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainBold;font-size:2mm;}" + + " Child{cn1-derive:" + target + ";font-size:20px;}", res, "Theme"); + assertEquals("Base", res.getTheme("Theme").get("Child.derive"), target); + assertEquals("native:MainBold", fontName((Font) res.getTheme("Theme").get("Child.font")), target); + } + } + + @Test + void testHexEscapeEndsAtAnyCssWhitespace() { + for (String ws : new String[]{"\r", "\f", "\r\n"}) { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{a\\62" + ws + "c: hello;}", res, "Theme"); + assertEquals("hello", res.getTheme("Theme").get("@abc")); + } + } + + @Test + void testEscapedVarReferenceIsDecoded() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --a\\62 c: red; alias: var(--a\\62 c); }", res, "Theme"); + assertEquals("red", res.getTheme("Theme").get("@alias")); + } + + @Test + void testCustomPropertyMayStartWithADigit() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --1accent: red; }", res, "Theme"); + assertEquals("red", res.getTheme("Theme").get("@1accent")); + } + + @Test + void testEmptyColorComponentIsRejected() { + for (String color : new String[]{"rgb(255,,0)", "rgb(255,0,)", "rgba(255,,0,0)"}) { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> new CSSThemeCompiler().compile( + "#Constants { accent: " + color + "; }", new MutableResource(), "Theme"), color); + } + } + + @Test + void testPressedOnlyDeriveInheritsTheNormalFont() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainRegular;font-size:2mm;}" + + " Base:pressed{font-family:native:MainBold;}" + + " Child:pressed{cn1-derive:Base;font-size:20px;}", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("Child.press#font"))); + } + + @Test + void testPressedDeriveWinsOverTheOwnerDerive() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base1{font-family:native:MainBold;font-size:2mm;}" + + " Base2{font-family:native:MainLight;font-size:2mm;}" + + " Child{cn1-derive:Base2;} Child:pressed{cn1-derive:Base1;font-size:20px;}", res, "Theme"); + assertEquals("native:MainBold", fontName((Font) res.getTheme("Theme").get("Child.press#font"))); + } + + @Test + void testSizeOnlyRuleKeepsTheInheritedSystemFontStyle() { + MutableResource res = new MutableResource(); + res.setTheme("Theme", new Hashtable()); + res.getTheme("Theme").put("Label.font", + Font.createSystemFont(Font.FACE_MONOSPACE, Font.STYLE_BOLD, Font.SIZE_MEDIUM)); + new CSSThemeCompiler().compile("Label{font-size:large;}", res, "Theme"); + Font font = (Font) res.getTheme("Theme").get("Label.font"); + assertEquals(Font.STYLE_BOLD, font.getStyle() & Font.STYLE_BOLD); + } + + @Test + void testUnquotedUrlKeepsItsSemicolon() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { navImage: url(data:image/png;base64,AAAA); after: \"x\"; }", + res, "Theme"); + assertEquals("x", res.getTheme("Theme").get("@after")); + } + + @Test + void testStringEscapeKeepsANonCssWhitespaceAfterIt() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: \"\\41\u2003B\"; plain: \"\\41 B\"; }", res, "Theme"); + assertEquals("A\u2003B", res.getTheme("Theme").get("@message")); + assertEquals("AB", res.getTheme("Theme").get("@plain")); + } + + @Test + void testUniversalDeriveIsIgnored() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainBold;font-size:2mm;}" + + " *{cn1-derive:Base;} Component{font-size:20px;}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("Component.derive")); + assertEquals("native:MainRegular", fontName((Font) theme.get("Component.font"))); + } + + @Test + void testAliasOfQuotedImageVariableKeepsTheWholeName() { + MutableResource res = new MutableResource(); + res.setImage("my icon", Image.createImage(5, 5)); + new CSSThemeCompiler().compile("#Constants { --baseImage: \"my icon\"; aliasImage: var(--baseImage); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals(5, ((Image) theme.get("@baseImage")).getWidth()); + assertEquals(5, ((Image) theme.get("@aliasImage")).getWidth()); + } + + @Test + void testQuotedFallbackNamesTheWholeImage() { + MutableResource res = new MutableResource(); + res.setImage("my icon", Image.createImage(5, 5)); + new CSSThemeCompiler().compile("#Constants { navImage: var(--missing, \"my icon\"); }", res, "Theme"); + assertEquals(5, ((Image) res.getTheme("Theme").get("@navImage")).getWidth()); + } + + @Test + void testImageNamedChainIsTypedByItsLeadingToken() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { placeholderImage: #f00 extra; countImage: 2 extra; }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals(0xff0000, Integer.parseInt((String) theme.get("@placeholderImage"), 16)); + assertEquals("2", String.valueOf(theme.get("@countImage"))); + } + + @Test + void testUniversalFontSizeIsNotValidated() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("* { font-size: smaller; } Label { color: red; }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); + } + + @Test + void testEscapedConstantsSelector() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#\\43 onstants { accent: blue; }", res, "Theme"); + assertEquals("blue", res.getTheme("Theme").get("@accent")); + } + + @Test + void testEscapedCommaStaysInsideTheFamilyName() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Label{font-family:foo\\,monospace, native\\:MainBold;font-size:2mm;}", + res, "Theme"); + assertEquals("native:MainBold", fontName((Font) res.getTheme("Theme").get("Label.font"))); + } + + @Test + void testPercentageFallbackIsRejectedLikeADirectOne() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants { progress: var(--missing, 50%); }", + new MutableResource(), "Theme")); + } + + @Test + void testFullwidthLetterIsNotAHexEscape() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { message: \"\\\uff21\"; }", res, "Theme"); + assertEquals("\uff21", res.getTheme("Theme").get("@message")); + } + + @Test + void testNestedQuotedFallbackStaysText() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --text: \"#f00\"; accent: var(--missing, var(--text)); }", + res, "Theme"); + assertEquals("#f00", res.getTheme("Theme").get("@accent")); + } + + @Test + void testUrlFallbackOfAnImageConstantIsLeftOut() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { navImage: var(--missing, url(icon.png)); other: x; }", + res, "Theme"); + org.junit.jupiter.api.Assertions.assertNull(res.getTheme("Theme").get("@navImage")); + assertEquals("x", res.getTheme("Theme").get("@other")); + } + + @Test + void testUndefinedFontSizeVariableIsPassedOver() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Label { font-size: var(--optional-size); color: red; }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); + } + + @Test + void testFontReachesEveryState() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("ReviewLabel { font-family: monospace; font-size: large; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + for (String state : new String[]{"", "sel#", "press#", "dis#"}) { + Font font = (Font) theme.get("ReviewLabel." + state + "font"); + org.junit.jupiter.api.Assertions.assertNotNull(font, state); + assertEquals(Font.FACE_MONOSPACE, font.getFace(), state); + } + com.codename1.ui.plaf.UIManager.getInstance().setThemeProps(theme); + assertEquals(Font.FACE_MONOSPACE, com.codename1.ui.plaf.UIManager.getInstance() + .getComponentSelectedStyle("ReviewLabel").getFont().getFace()); + } + + @Test + void testAliasOfQuotedNumberStaysText() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --code: \"001\"; copy: var(--code);" + + " --q: '\"x\"'; quoteCopy: var(--q); }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("001", theme.get("@code")); + assertEquals("001", theme.get("@copy")); + assertEquals("\"x\"", theme.get("@q")); + assertEquals("\"x\"", theme.get("@quoteCopy")); + } + + @Test + void testUnselectedStyleReachesEveryState() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Label{color:#333;background-color:#fff;padding:2px 3mm;margin:1mm}" + + " Label.selected{color:#f00}" + + " Base.pressed{background-color:#0f0} Child{cn1-derive:Base;background-color:#00f}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + for (String state : new String[]{"sel#", "press#", "dis#"}) { + assertEquals("ffffff", theme.get("Label." + state + "bgColor"), state); + assertEquals("255", theme.get("Label." + state + "transparency"), state); + assertEquals(theme.get("Label.padding"), theme.get("Label." + state + "padding"), state); + org.junit.jupiter.api.Assertions.assertNotNull(theme.get("Label." + state + "marUnit"), state); + } + assertEquals("ff0000", theme.get("Label.sel#fgColor")); + assertEquals("333333", theme.get("Label.press#fgColor")); + // Base:pressed is laid over Child's unselected style, through the derive. + org.junit.jupiter.api.Assertions.assertNull(theme.get("Child.press#bgColor")); + assertEquals("0000ff", theme.get("Child.sel#bgColor")); + } + + @Test + void testRgbaAndTransparentColors() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("T{background-color:rgba(0,0,0,0);color:rgba(255,0,0,.5)}" + + " N{background-color:transparent} O{opacity:0.5}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("000000", theme.get("T.bgColor")); + assertEquals("0", theme.get("T.transparency")); + assertEquals("ff0000", theme.get("T.fgColor")); + assertEquals(Integer.valueOf(127), theme.get("T.fgAlpha")); + assertEquals("0", theme.get("N.transparency")); + assertEquals("127", theme.get("O.opacity")); + } + + @Test + void testBorderWithNamedColorAndNoBackgroundImage() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Card{border:1px solid red} N{background-image:none;color:#000}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNotNull(theme.get("Card.border")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("N.border")); + assertEquals("000000", theme.get("N.fgColor")); + } + + @Test + void testDerivedUiidStatesDeriveFromTheTargetStates() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{color:#112233;background-color:#fff;padding:2mm}" + + " Base.pressed{color:#f00} Child{cn1-derive:Base} Child.selected{color:#0f0}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("Base.sel", theme.get("Child.sel#derive")); + assertEquals("Base.press", theme.get("Child.press#derive")); + assertEquals("Base.dis", theme.get("Child.dis#derive")); + assertEquals("00ff00", theme.get("Child.sel#fgColor")); + } + + @Test + void testPerSidePaddingAndMargin() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("S{padding-left:1mm;padding-top:2px;margin-right:3mm;margin-bottom:4pt}" + + " S.pressed{padding-right:5px} T{padding:1px;padding-left:9px}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("2,0,1,0", theme.get("S.padding")); + org.junit.jupiter.api.Assertions.assertArrayEquals(new byte[]{Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_DIPS, + Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS}, (byte[]) theme.get("S.padUnit")); + assertEquals("0,4,0,3", theme.get("S.margin")); + assertEquals("2,0,1,5", theme.get("S.press#padding")); + assertEquals("1,1,9,1", theme.get("T.padding")); + } + + @Test + void testEveryCssNamedColor() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("A{color:silver;background-color:navy} B{color:orchid}" + + " C{color:lightslategrey}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("c0c0c0", theme.get("A.fgColor")); + assertEquals("000080", theme.get("A.bgColor")); + assertEquals("da70d6", theme.get("B.fgColor")); + assertEquals("778899", theme.get("C.fgColor")); + } + + @Test + void testColorKeywordConstantStaysAnIdentifier() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: red; clear: transparent; accentColor: red;" + + " --pal: red; alias: var(--pal); }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("red", theme.get("@accent")); + assertEquals("transparent", theme.get("@clear")); + assertEquals("red", theme.get("@accentColor")); + assertEquals("red", theme.get("@alias")); + } + + @Test + void testEscapedAliasKeepsTheQuotedType() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --a\\62 c: \"#f00\"; alias: var(--a\\62 c); }", res, "Theme"); + assertEquals("#f00", res.getTheme("Theme").get("@alias")); + } + + @Test + void testExplicitDarkDeriveFollowsTheLightTarget() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("Base{font-family:native:MainRegular;font-size:2mm;}" + + " @media (prefers-color-scheme: dark){ Base{font-family:native:MainBold;}" + + " Child{cn1-derive:Base;font-size:20px;} }", res, "Theme"); + assertEquals("native:MainRegular", fontName((Font) res.getTheme("Theme").get("$DarkChild.font"))); + } + + @Test + void testLegacyAliasFollowsALaterRootRedefinition() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("@constants { base: red; alias: var(--base); } :root { --base: blue; }", + res, "Theme"); + assertEquals("blue", res.getTheme("Theme").get("@base")); + assertEquals("blue", res.getTheme("Theme").get("@alias")); + } + + @Test + void testImageConstantOfAUrlOrDimensionIsLeftOut() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { navImage: url(icon.png); sizeImage: 2mm; }", res, "Theme"); + assertFalse(res.getTheme("Theme").containsKey("@navImage")); + assertFalse(res.getTheme("Theme").containsKey("@sizeImage")); + } + + @Test + void testQuotedFallbackStaysText() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: var(--missing, \"#f00\"); }", res, "Theme"); + assertEquals("#f00", res.getTheme("Theme").get("@accent")); + } + + @Test + void testEscapedRootCustomPropertyIsDecoded() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile(":root { --a\\62 c: #f00; } Label { color: var(--a\\62 c); }", res, "Theme"); + assertEquals("ff0000", res.getTheme("Theme").get("Label.fgColor")); + } + + @Test + void testRedefinedExportedConstantRetypesItsAliases() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{--x:#0f0} :root{--x:\"#f00\"} #Constants{alias:var(--x)}", + res, "Theme"); + assertEquals("00ff00", res.getTheme("Theme").get("@x")); + assertEquals("#f00", res.getTheme("Theme").get("@alias")); + } + + @Test + void testUniversalRuleSetsNoStyleProperty() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("* { color: red; padding: 1px; --tone: #00f; } Label { color: var(--tone); }", + res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + for (Object key : theme.keySet()) { + assertFalse(String.valueOf(key).startsWith("Component."), String.valueOf(key)); + } + assertEquals("0000ff", theme.get("Label.fgColor")); + } + + @Test + void testFunctionalColorConstantTakesItsLeadingToken() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { accent: rgb(255, 0, 0) extra; }", res, "Theme"); + assertEquals(0xff0000, Integer.parseInt((String) res.getTheme("Theme").get("@accent"), 16)); + } + + @Test + void testExponentFontSizeIsRejected() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("Label{font-size:1e2px;}", new MutableResource(), "Theme")); + new CSSThemeCompiler().compile("Label{font-size:1rem;}", new MutableResource(), "Theme"); + } + + @Test + void testConstantNameMustBeAnIdentifier() { + assertThrows(CSSThemeCompiler.CSSSyntaxException.class, () -> + new CSSThemeCompiler().compile("#Constants{1message: hello;}", new MutableResource(), "Theme")); + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{a\\62 c: hello;}", res, "Theme"); + assertEquals("hello", res.getTheme("Theme").get("@abc")); + res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants{a\\:b: hello;}", res, "Theme"); + assertEquals("hello", res.getTheme("Theme").get("@a:b")); + } + + @Test + void testImageAliasOfAColorOrNumberIsNotAnImage() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --tone: #f00; --n: 3; placeholderImage: var(--tone);" + + " countImage: var(--n); }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("ff0000", theme.get("@placeholderImage")); + assertEquals("3", theme.get("@countImage")); + } + + @Test + void testPlatformMediaQueryPrefixesTheUiid() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("@media platform-ios { A { color: #111111; } }" + + "@media device-tablet, platform-and, density-high { B { color: #222222; } }" + + "@media (min-width: 400px) { C { color: #333333; } }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("111111", theme.get("platform-ios-A.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A.fgColor")); + assertEquals("222222", theme.get("device-tablet-platform-and-density-high-B.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("C.fgColor")); + } + + @Test + void testUnselectedRuleStaysInTheUnselectedState() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("A.unselected { color: #222222; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("222222", theme.get("A.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A.sel#fgColor")); + } + + @Test + void testVarColorWritesABinding() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile(":root{--a:#112233;--b:var(--a);} Foo{color:var(--b);background-color:var(--a)}" + + " Bar{color:var(--a)} Bar.disabled{color:#000000}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("b", theme.get("@cn1-bind:Foo.fgColor")); + assertEquals("b", theme.get("@cn1-bind:Foo.sel#fgColor")); + assertEquals("a", theme.get("@cn1-bind:Foo.bgColor")); + assertEquals("a", theme.get("@cn1-bind:Bar.press#fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("@cn1-bind:Bar.dis#fgColor")); + } + + @Test + void testLengthConstantIsUsableButNotStored() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("#Constants { --a: 5px; --b: 2mm; } Foo { padding: var(--a); }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("@a")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("@b")); + assertEquals("5,5,5,5", theme.get("Foo.padding")); + } + + @Test + void testCombinatorRulesAreDropped() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("A B{color:#ff0000} A > B{color:#00ff00} C{color:#0000ff}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A B.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A > B.fgColor")); + assertEquals("0000ff", theme.get("C.fgColor")); + } + + @Test + void testUnselectedRuleBesideABareRuleStaysUnselected() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("A{color:#111111;background-color:#00ff00} A.unselected{color:#ff0000;padding:1px}" + + " B.unselected{color:#ff0000} B{background-color:#00ff00}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("ff0000", theme.get("A.fgColor")); + assertEquals("111111", theme.get("A.sel#fgColor")); + assertEquals("00ff00", theme.get("A.press#bgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A.dis#padding")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("B.sel#fgColor")); + assertEquals("00ff00", theme.get("B.sel#bgColor")); + } + + @Test + void testCompoundMediaQueryAndEscapedSpaceSelector() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile("@media platform-ios and device-tablet { A { color: #ff0000; } }" + + " A\\ B { color: #00ff00; }", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + org.junit.jupiter.api.Assertions.assertNull(theme.get("A.fgColor")); + assertEquals(1, countKeysContaining(theme, "fgColor"), theme.toString()); + assertEquals("00ff00", theme.get("A B.fgColor")); + } + + private static int countKeysContaining(Hashtable theme, String part) { + int n = 0; + for (Object k : theme.keySet()) { + if (k.toString().indexOf(part) > -1 && k.toString().indexOf('#') < 0) { + n++; + } + } + return n; + } + + @Test + void testUnselectedIsALayerOverTheBareRule() { + MutableResource res = new MutableResource(); + new CSSThemeCompiler().compile(":root{--c:#ff0000;}" + + " A{padding:1px} A.unselected{padding-top:5px}" + + " B{padding-top:2px} B.unselected{padding:5px} B{padding-left:3px}" + + " C{color:var(--c)} C.unselected{color:#00ff00}" + + " D.unselected{color:#222222} D{color:#333333}" + + " \\41 E{color:#123456}", res, "Theme"); + Hashtable theme = res.getTheme("Theme"); + assertEquals("5,1,1,1", theme.get("A.padding")); + assertEquals("1,1,1,1", theme.get("A.sel#padding")); + assertEquals("5,5,5,5", theme.get("B.padding")); + assertEquals("2,0,3,0", theme.get("B.press#padding")); + assertEquals("00ff00", theme.get("C.fgColor")); + org.junit.jupiter.api.Assertions.assertNull(theme.get("@cn1-bind:C.fgColor")); + assertEquals("c", theme.get("@cn1-bind:C.sel#fgColor")); + assertEquals("ff0000", theme.get("C.dis#fgColor")); + assertEquals("222222", theme.get("D.fgColor")); + assertEquals("333333", theme.get("D.sel#fgColor")); + assertEquals("123456", theme.get("AE.fgColor")); + for (Object k : theme.keySet()) { + org.junit.jupiter.api.Assertions.assertTrue(k.toString().indexOf("uns#") < 0, k.toString()); + } + } } diff --git a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java index 5b04803eaf0..8900cd9fb05 100644 --- a/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java +++ b/maven/css-compiler/src/main/java/com/codename1/designer/css/CSSTheme.java @@ -3246,6 +3246,12 @@ public void updateResources() { } } else if (lu.getLexicalUnitType() == LexicalUnit.SAC_INTEGER) { res.setThemeProperty(themeName, "@"+constantKey, String.valueOf(((ScaledUnit)lu).getIntegerValue())); + } else if (lu.getLexicalUnitType() == LexicalUnit.SAC_REAL) { + // A decimal such as `switchTrackScaleY: 1.5` was silently dropped, so the + // built theme fell back to the default the runtime compiler did not. + String real = String.valueOf(lu.getFloatValue()); + res.setThemeProperty(themeName, "@"+constantKey, + real.endsWith(".0") ? real.substring(0, real.length() - 2) : real); } else if (lu.getLexicalUnitType() == LexicalUnit.SAC_RGBCOLOR || (lu.getLexicalUnitType() == LexicalUnit.SAC_FUNCTION && ("rgb".equals(lu.getFunctionName()) @@ -6825,6 +6831,13 @@ public com.codename1.ui.Font getThemeFont(Map styles) { ttfSizeType = TTF_SIZE_TYPE_LARGE; sysSize = Font.SIZE_LARGE; break outer; + case "medium": + // Without this case `medium` fell through to the pixel branch + // and stored a zero-size font. + iFontSizeType = Font.SIZE_MEDIUM; + ttfSizeType = TTF_SIZE_TYPE_MEDIUM; + sysSize = Font.SIZE_MEDIUM; + break outer; } } @@ -6953,6 +6966,8 @@ public com.codename1.ui.Font getThemeFont(Map styles) { case "arial" : case "cursive" : case "fantasy" : + // The platform's own UI font: a generic family, as in the runtime compiler. + case "system-ui" : iFontFace = Font.FACE_SYSTEM; break loop; case "monospace" : @@ -9348,6 +9363,21 @@ static String getARGBHexString(LexicalUnit color) { return "#" + leftPad(Integer.toHexString(getColorInt(color)), 6) + leftPad(Integer.toHexString(getColorAlphaInt(color)), 2); } + /// One `rgb()` channel: `50%` is half of 255, which getIntegerValue() read as 0, so every + /// percentage color came out black. + private static int rgbChannel(ScaledUnit channel) { + if (channel.getLexicalUnitType() == LexicalUnit.SAC_PERCENTAGE) { + return clampChannel(Math.round(channel.getFloatValue() * 255f / 100f)); + } + return clampChannel(channel.getIntegerValue()); + } + + /// Out of range is clamped, as CSS specifies: `200%` or `300` is 255, not a value `%02X` + /// would write as three digits, breaking the six-digit color. + private static int clampChannel(int value) { + return Math.max(0, Math.min(255, value)); + } + static String getColorString(LexicalUnit color) { return getColorString(color, false); } @@ -9388,9 +9418,9 @@ static String getColorString(LexicalUnit color, boolean premultiplied) { blue = (ScaledUnit)blue.getNextLexicalUnit(); } return String.format( "%02X%02X%02X", - red.getIntegerValue(), - green.getIntegerValue(), - blue.getIntegerValue()); + rgbChannel(red), + rgbChannel(green), + rgbChannel(blue)); } case LexicalUnit.SAC_FUNCTION: { @@ -9435,9 +9465,9 @@ static String getColorString(LexicalUnit color, boolean premultiplied) { throw new RuntimeException("Failed to parse color " + color + ". Received null alpha parameter:" + color); } } - int r = (int)(red.getIntegerValue() * (premultiplied?(1-alpha.getNumericValue()):1)); - int g = (int)(green.getIntegerValue() * (premultiplied?(1-alpha.getNumericValue()):1)); - int b = (int)(blue.getIntegerValue() * (premultiplied?(1-alpha.getNumericValue()):1)); + int r = (int)(rgbChannel(red) * (premultiplied?(1-alpha.getNumericValue()):1)); + int g = (int)(rgbChannel(green) * (premultiplied?(1-alpha.getNumericValue()):1)); + int b = (int)(rgbChannel(blue) * (premultiplied?(1-alpha.getNumericValue()):1)); return String.format( "%02X%02X%02X", diff --git a/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java new file mode 100644 index 00000000000..d6a6834af66 --- /dev/null +++ b/maven/css-compiler/src/test/java/com/codename1/designer/css/RuntimeCompilerParityTest.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.designer.css; + +import com.codename1.ui.css.CSSThemeCompiler; +import com.codename1.ui.util.MutableResource; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Hashtable; + +/** + * The runtime compiler in core ({@link CSSThemeCompiler}, used for live reload and by + * applications that build a theme on the device) against this one, on the declarations both + * support: the same sheet has to store the same constants, padding, margin and unit metadata. + */ +public class RuntimeCompilerParityTest { + + private static final String CSS = "#Constants {\n" + + " includeNativeBool: true;\n" + + " customMessage: \"Hello\";\n" + + " quotedFlagBool: 'false';\n" + + " accentColor: #ff2d95;\n" + + " shortColor: #abc;\n" + + " switchTrackScaleY: 1.5;\n" + + " pctColor: rgb(100%, 0%, 50%);\n" + + " clampColor: rgb(200%, 300, 0);\n" + + "}\n" + + "Label { padding: 1px 2px 3px 4px; margin: 1mm 2mm; }\n" + + "One { padding: 5px; margin: 0.5mm; }\n" + + "Three { padding: 1mm 2px 3mm; }\n" + + "Mixed { padding: 1mm 2px 3pt 4mm; margin: 1px 2mm 3px 4mm; }\n" + + "Mixed.pressed { padding: 2mm 1mm; }\n" + + "Mixed.selected { margin: 3pt; }\n"; + + @BeforeAll + static void installHeadlessImplementation() throws Exception { + HeadlessTestSupport.installHeadlessImplementation(); + } + + @Test + void constantsMatch() throws Exception { + Hashtable standard = compileStandard(CSS); + Hashtable runtime = compileRuntime(CSS); + for (String key : new String[]{"@includeNativeBool", "@customMessage", "@quotedFlagBool"}) { + assertEquals(standard.get(key), runtime.get(key), key); + } + // Hex case differs between the compilers for every color, not just constants (the + // build-time one writes upper case); both are read with Integer.parseInt(value, 16). + for (String key : new String[]{"@accentColor", "@shortColor", "@pctColor", "@clampColor"}) { + assertEquals(Integer.valueOf(Integer.parseInt((String) standard.get(key), 16)), + Integer.valueOf(Integer.parseInt((String) runtime.get(key), 16)), key); + } + assertEquals("true", runtime.get("@includeNativeBool"), "@includeNativeBool"); + assertEquals("Hello", runtime.get("@customMessage"), "@customMessage"); + } + + @Test + void spacingMatches() throws Exception { + Hashtable standard = compileStandard(CSS); + Hashtable runtime = compileRuntime(CSS); + String[] keys = { + "Label.padding", "Label.margin", "Label.padUnit", "Label.marUnit", + "One.padding", "One.margin", "One.padUnit", "One.marUnit", + "Three.padding", "Three.padUnit", + "Mixed.padding", "Mixed.margin", "Mixed.padUnit", "Mixed.marUnit", + "Mixed.press#padding", "Mixed.press#padUnit", + "Mixed.sel#margin", "Mixed.sel#marUnit"}; + for (String key : keys) { + Object expected = standard.get(key); + Object actual = runtime.get(key); + if (expected == null || actual == null) { + throw new AssertionError(key + " standard=" + expected + " runtime=" + actual); + } + if (expected instanceof byte[]) { + if (!Arrays.equals((byte[]) expected, (byte[]) actual)) { + throw new AssertionError(key + " units standard=" + Arrays.toString((byte[]) expected) + + " runtime=" + Arrays.toString((byte[]) actual)); + } + } else if (!Arrays.equals(floats((String) expected), floats((String) actual))) { + throw new AssertionError(key + " standard=" + expected + " runtime=" + actual); + } + } + } + + private static float[] floats(String box) { + String[] parts = box.split(","); + float[] out = new float[parts.length]; + for (int i = 0; i < parts.length; i++) { + out[i] = Float.parseFloat(parts[i].trim()); + } + return out; + } + + private static Hashtable compileRuntime(String css) { + MutableResource resource = new MutableResource(); + new CSSThemeCompiler().compile(css, resource, "Theme"); + return resource.getTheme("Theme"); + } + + private static Hashtable compileStandard(String css) throws Exception { + Path cssFile = Files.createTempFile("cn1-parity", ".css"); + Path resFile = Files.createTempFile("cn1-parity", ".res"); + try { + Files.write(cssFile, css.getBytes(StandardCharsets.UTF_8)); + CSSTheme theme = CSSTheme.load(cssFile.toUri().toURL()); + theme.resourceFile = resFile.toFile(); + theme.res = new com.codename1.ui.util.EditableResourcesForCSS(resFile.toFile()); + theme.res.setTheme("Theme", new Hashtable()); + theme.updateResources(); + return theme.res.getTheme("Theme"); + } finally { + deleteIfExists(cssFile); + deleteIfExists(resFile); + } + } + + private static void deleteIfExists(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + } + + private static void assertEquals(Object expected, Object actual, String message) { + if (expected == null ? actual != null : !expected.equals(actual)) { + throw new AssertionError(message + " expected=" + expected + " actual=" + actual); + } + } +} diff --git a/scripts/flutter-bench/baseline/pr/5976.json b/scripts/flutter-bench/baseline/pr/5976.json index 0cd73c825d2..5b507ff550f 100644 --- a/scripts/flutter-bench/baseline/pr/5976.json +++ b/scripts/flutter-bench/baseline/pr/5976.json @@ -8,7 +8,7 @@ "tolerance": { "value": 0.005 }, - "value": 13238731 + "value": 13304997 }, "reason": "JavaScript sizes on run 38943915580 (13,338,532 / 108,835,838 bytes) are above a baseline taken on the Flutter branch before later master merges; desktop-compat-layer's run 38946610873, which does not contain this PR, is further above it (13,356,239 / 108,853,545). The Flutter benchmark has no master run to separate this PR's share, which is ParparVM's JS output for float rounding (Math.fround) and the single-rounding long-to-float.", "runs": 1, diff --git a/scripts/flutter-bench/baseline/pr/6032.json b/scripts/flutter-bench/baseline/pr/6032.json new file mode 100644 index 00000000000..2dceb5b43e4 --- /dev/null +++ b/scripts/flutter-bench/baseline/pr/6032.json @@ -0,0 +1,33 @@ +{ + "pr": 6032, + "rebaseline": { + "linux": { + "gallery": { + "code_bytes": { + "from": { + "value": 14523602 + }, + "reason": "CSSThemeCompiler (runtime CSS theme compiler in core) gained build-compiler parity: state inheritance, per-side insets, media prefixes, var() bindings, the CSS named-color table", + "runs": 1, + "value": 14592242 + }, + "install_bytes": { + "from": { + "value": 116342496 + }, + "reason": "CSSThemeCompiler (runtime CSS theme compiler in core) gained build-compiler parity: state inheritance, per-side insets, media prefixes, var() bindings, the CSS named-color table", + "runs": 1, + "value": 116468448 + }, + "wire_bytes": { + "from": { + "value": 84685068 + }, + "reason": "CSSThemeCompiler (runtime CSS theme compiler in core) gained build-compiler parity: state inheritance, per-side insets, media prefixes, var() bindings, the CSS named-color table", + "runs": 1, + "value": 84687971 + } + } + } + } +} diff --git a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java index a4638953f1d..52c04cea64d 100644 --- a/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java +++ b/scripts/guibuilder/common/src/main/java/com/codename1/guibuilder/CodenameOneGUIBuilder.java @@ -5269,13 +5269,13 @@ private void saveCss(String css, CodeEditor editor) { private boolean applyProjectCss(String css, CodeEditor editor) { try { - MutableResource resources = new MutableResource(); + MutableResource resources = new ProjectImageResource(binding == null ? null : binding.cssFile()); new CSSThemeCompiler().compile(css, resources, "ProjectTheme"); projectTheme = resources.getTheme("ProjectTheme"); normalizeCompiledTheme(projectTheme); // Declarations this compiler does not implement are reported rather than passed over. // The canvas runs the Codename One CSS compiler that ships in core, while the project - // is built by the Maven one, which understands more -- font-size and font-style among + // is built by the Maven one, which understands more -- font-style and font-weight among // them. Silently dropping those and announcing success let the canvas differ from the // built application with nothing to say so. List ignored = unsupportedDeclarations(css); @@ -5330,11 +5330,16 @@ private List unsupportedDeclarations(String css) { String property = css.substring(nameStart, nameEnd).trim(); String value = css.substring(at + 1, valueEnd).trim(); if (property.length() == 0 || value.length() == 0 || property.startsWith("@")) continue; - Boolean supported = judged.get(property); + // A #Constants entry is a theme constant, not a style property a probe rule could show. + if (isInConstantsBlock(masked, at)) continue; + // Keyed by the value too: whether a declaration applies can depend on it, as a + // cn1-image-id naming a project image that exists or not does. + String judgedKey = property + ":" + value; + Boolean supported = judged.get(judgedKey); if (supported == null) { Set withProperty = compiledKeys(property + ": " + value + ";"); supported = Boolean.valueOf(withProperty == null || !withProperty.equals(baseline)); - judged.put(property, supported); + judged.put(judgedKey, supported); } if (!supported.booleanValue()) { out.add(new CodeDiagnostic(line, nameStart - lineStartBefore(masked, nameStart) + 1, @@ -5345,6 +5350,46 @@ private List unsupportedDeclarations(String css) { return out; } + /** + * True when {@code at} sits inside a {@code #Constants} or {@code @constants} block. Braces + * inside a quoted value are text, so the scan tracks quotes and escapes. + */ + private static boolean isInConstantsBlock(String masked, int at) { + List open = new ArrayList<>(); + int selectorStart = 0; + char quote = 0; + for (int i = 0; i < at && i < masked.length(); i++) { + char c = masked.charAt(i); + if (quote != 0) { + if (c == '\\') i++; + else if (c == quote) quote = 0; + } else if (c == '"' || c == '\'') { + quote = c; + } else if (c == '{') { + open.add(masked.substring(selectorStart, i).trim()); + selectorStart = i + 1; + } else if (c == '}') { + if (!open.isEmpty()) open.remove(open.size() - 1); + selectorStart = i + 1; + } else if (c == ';') { + selectorStart = i + 1; + } + } + if (open.isEmpty()) return false; + String selector = open.get(open.size() - 1); + if ("@constants".equalsIgnoreCase(selector)) return true; + // `#Constants, Other { ... }` exports its constants too, but also styles `Other`, whose + // declarations still need the probe: only a list of nothing but #Constants is skipped. + int start = 0; + for (int i = 0; i <= selector.length(); i++) { + if (i == selector.length() || selector.charAt(i) == ',') { + if (!"#Constants".equalsIgnoreCase(selector.substring(start, i).trim())) return false; + start = i + 1; + } + } + return true; + } + /** True for the characters a CSS property name is made of. */ private static boolean isCssNameChar(char c) { return c == '-' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'; @@ -5363,7 +5408,9 @@ private static int lineStartBefore(String text, int at) { */ private Set compiledKeys(String declaration) { try { - MutableResource probe = new MutableResource(); + // Project-aware, as the real compile is: a cn1-image-id naming an image under + // res/Theme// applies there, so the probe must find it too. + MutableResource probe = new ProjectImageResource(binding == null ? null : binding.cssFile()); new CSSThemeCompiler().compile("CanvasCssProbe { color: #010203; " + declaration + " }", probe, "Probe"); Set keys = new LinkedHashSet<>(); @@ -5432,7 +5479,7 @@ private void refreshProjectThemeOnPreview() { private void loadProjectTheme() { if (binding == null || binding.cssFile() == null) return; try { - MutableResource resources = new MutableResource(); + MutableResource resources = new ProjectImageResource(binding.cssFile()); new CSSThemeCompiler().compile(ProjectIO.read(binding.cssFile()), resources, "ProjectTheme"); projectTheme = resources.getTheme("ProjectTheme"); normalizeCompiledTheme(projectTheme); @@ -5442,6 +5489,171 @@ private void loadProjectTheme() { } } + /** + * The canvas compiles into an empty resource, so an image constant such as + * {@code #Constants{mediaPlayImage:play.png}} found nothing and the stylesheet was rejected, + * although the project build resolves it. This looks an image up where the build-time + * compiler does: {@code res///.png} beside the stylesheet. Images added to the project's own resource file in the designer are not + * visible here. + */ + static final class ProjectImageResource extends MutableResource { + private final String cssDir; + + ProjectImageResource(String cssFile) { + String path = cssFile == null ? null : cssFile.replace('\\', '/'); + int slash = path == null ? -1 : path.lastIndexOf('/'); + cssDir = slash < 0 ? null : path.substring(0, slash); + } + + @Override + public com.codename1.ui.Image getImage(String id) { + com.codename1.ui.Image image = super.getImage(id); + if (image != null || cssDir == null || id == null) return image; + // No file of that name beside the stylesheet: the build-time compiler only reads the + // multi-image folder, so the canvas must not accept what the build would reject. It + // looks in the res folder beside the stylesheet, then the one a level up (a Maven + // project's src/main/res, an Ant project's own res). + // The theme.res the build writes is deliberately not consulted. The CSS CLI loads it + // only for an incremental rebuild (its selector cache present and no asset newer); + // a clean build -- CI, `mvn clean`, a fresh checkout -- starts without it, so an + // image found only there is one the next clean build rejects. Matching that build + // means rejecting it here too; the image belongs in a res// folder. + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + int slash = cssDir.lastIndexOf('/'); + String[] roots = slash < 0 ? new String[]{cssDir + "/res"} + : new String[]{cssDir + "/res", cssDir.substring(0, slash) + "/res"}; + try { + for (int r = 0; r < roots.length && image == null; r++) { + String res = ProjectIO.fsUrl(roots[r]); + if (!fs.isDirectory(res)) continue; + // res/Theme first, as CSSTheme.getResourceImage() does, then the others. + java.util.List themes = new java.util.ArrayList(); + themes.add("Theme"); + for (String listed : fs.listFiles(res)) { + if (!"Theme".equals(stripSlash(listed))) themes.add(listed); + } + for (String theme : themes) { + String folder = res + "/" + stripSlash(theme) + "/" + id; + if (!fs.isDirectory(folder)) continue; + image = bestVariant(folder, fs.listFiles(folder)); + if (image != null) break; + } + } + } catch (IOException ignored) { + // An unreadable folder is an image that is not there. + } + if (image != null) setImage(id, image); + return image; + } + + /** + * Classifies every PNG of a multi-image folder the way the build-time compiler does -- + * a density by its exact lower-case name, any other name (HIGH.PNG included, which the + * build's case-sensitive switch reads as medium) as medium -- and loads the one the + * display density gets. A named medium file wins over another medium-classified one. + */ + private static boolean isDirectory(String path) { + return com.codename1.io.FileSystemStorage.getInstance().isDirectory(path); + } + + private static com.codename1.ui.Image bestVariant(String folder, String[] files) { + // The build stores each variant's bytes with EncodedImage.create(), which does not + // decode them, so a corrupt variant fails nothing there. The canvas only has to + // show one: a selected variant that does not decode falls through to the next. + for (String density : densityOrder(com.codename1.ui.Display.getInstance().getDeviceDensity())) { + String match = null; + for (String listed : files) { + String file = stripSlash(listed); + if (!file.regionMatches(true, file.length() - 4, ".png", 0, 4) + || isDirectory(folder + "/" + file)) continue; + // Several files classified medium (medium.png and any unrecognised name): + // EditableResources.MultiImage.getBest() replaces its pick on every equal + // DPI, so the build keeps the LAST one in its File.listFiles() order, and + // the canvas keeps the last in its own listing. + if (density.equals(file) && !"medium.png".equals(density)) { + match = file; + break; + } + if ("medium.png".equals(density) && (density.equals(file) || !isDensityName(file))) { + match = file; + } + } + if (match != null) { + com.codename1.ui.Image image = load(folder + "/" + match); + if (decodes(image)) return image; + } + } + return null; + } + + private static boolean isDensityName(String file) { + for (String name : DENSITY_FILES) { + if (name.equals(file)) return true; + } + return false; + } + + private static final int[] DENSITIES = { + com.codename1.ui.Display.DENSITY_VERY_LOW, com.codename1.ui.Display.DENSITY_LOW, + com.codename1.ui.Display.DENSITY_MEDIUM, com.codename1.ui.Display.DENSITY_HIGH, + com.codename1.ui.Display.DENSITY_VERY_HIGH, com.codename1.ui.Display.DENSITY_HD, + com.codename1.ui.Display.DENSITY_560, com.codename1.ui.Display.DENSITY_2HD, + com.codename1.ui.Display.DENSITY_4K}; + private static final String[] DENSITY_FILES = { + "verylow.png", "low.png", "medium.png", "high.png", "veryhigh.png", "hd.png", "560.png", "2hd.png", "4k.png"}; + + /** + * The variant files of a multi-image, best first for a display of {@code density}: the + * project build stores every variant and the device picks its own, so the canvas shows + * the one the running density would get. That is the rule of + * {@code EditableResources.MultiImage.getBest()}: the largest variant not above the + * density. When every variant is denser, getBest() takes whichever file + * {@code File.listFiles()} happened to return first, an order the file system does not + * define; that is not reproducible, so the canvas takes the nearest larger one. + */ + static String[] densityOrder(int density) { + int at = 0; + while (at < DENSITIES.length - 1 && DENSITIES[at] < density) at++; + java.util.List out = new java.util.ArrayList(); + if (DENSITIES[at] > density && at > 0) at--; + for (int i = at; i >= 0; i--) out.add(DENSITY_FILES[i]); + for (int i = at + 1; i < DENSITIES.length; i++) out.add(DENSITY_FILES[i]); + return out.toArray(new String[out.size()]); + } + + private static String stripSlash(String name) { + return name.endsWith("/") ? name.substring(0, name.length() - 1) : name; + } + + /** An image can decode lazily; reading its pixels is what proves the bytes are a PNG. */ + private static boolean decodes(com.codename1.ui.Image image) { + if (image == null) return false; + try { + return image.getWidth() > 0 && image.getRGB() != null; + } catch (RuntimeException ex) { + return false; + } + } + + private static com.codename1.ui.Image load(String path) { + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + String url = ProjectIO.fsUrl(path); + if (!fs.exists(url) || fs.isDirectory(url)) return null; + java.io.InputStream in = null; + try { + in = fs.openInputStream(url); + return com.codename1.ui.Image.createImage(in); + } catch (IOException ex) { + return null; + } catch (RuntimeException ex) { + // A PNG the decoder cannot read. + return null; + } finally { + com.codename1.io.Util.cleanup(in); + } + } + } + /** Why the companion cannot be regenerated, or null when it can. */ private String companionRefusal; @@ -5783,6 +5995,11 @@ private void normalizeCompiledTheme(Hashtable theme) { boolean padding = key.endsWith("." + Style.PADDING); boolean margin = key.endsWith("." + Style.MARGIN); if ((!padding && !margin) || !(raw instanceof String)) continue; + String unitKey = key.substring(0, key.length() - (padding ? Style.PADDING : Style.MARGIN).length()) + + (padding ? Style.PADDING_UNIT : Style.MARGIN_UNIT); + // CSSThemeCompiler already stores plain numbers with their units beside them, per + // state; re-deriving the units from the now suffix-free text would reset them to px. + if (theme.get(unitKey) instanceof byte[]) continue; String[] values = ((String) raw).split(","); if (values.length != 4) continue; byte[] units = new byte[4]; @@ -5798,7 +6015,6 @@ private void normalizeCompiledTheme(Hashtable theme) { normalized.append(item.length() == 0 ? "0" : item); } theme.put(keyObject, normalized.toString()); - String unitKey = key.substring(0, key.lastIndexOf('.') + 1) + (padding ? Style.PADDING_UNIT : Style.MARGIN_UNIT); theme.put(unitKey, units); } } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java index 83012914170..692de1b1c64 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/GeneratedSourceTest.java @@ -1773,22 +1773,181 @@ void theCanvasSaysWhichCssDeclarationsItCannotShow() throws Exception { @SuppressWarnings("unchecked") java.util.List reported = (java.util.List) unsupported.invoke(builder, - "Label {\n color: #ff0000;\n font-size: 3mm;\n}\n"); + "Label {\n color: #ff0000;\n font-style: italic;\n}\n"); assertEquals(1, reported.size(), "one declaration the canvas drops: " + reported); - assertTrue(reported.get(0).toString().contains("font-size") - || describe(reported.get(0)).contains("font-size"), + assertTrue(reported.get(0).toString().contains("font-style") + || describe(reported.get(0)).contains("font-style"), "and it names the property: " + describe(reported.get(0))); @SuppressWarnings("unchecked") java.util.List clean = (java.util.List) unsupported.invoke(builder, - "Label { color: #ff0000; background-color: #00ff00; padding: 2mm; margin: 1mm; }\n"); + "Label { color: #ff0000; background-color: #00ff00; padding: 2mm; margin: 1mm; font-size: 3mm; }\n"); assertTrue(clean.isEmpty(), "everything here reaches the canvas: " + clean); // A comment is not a declaration. @SuppressWarnings("unchecked") java.util.List commented = (java.util.List) unsupported.invoke(builder, - "Label {\n /* font-size: 3mm; */\n color: #ff0000;\n}\n"); + "Label {\n /* font-style: italic; */\n color: #ff0000;\n}\n"); assertTrue(commented.isEmpty(), "a commented-out declaration is not reported: " + commented); + + @SuppressWarnings("unchecked") + java.util.List constants = (java.util.List) unsupported.invoke(builder, + "#Constants {\n message: \"a}b\";\n includeNativeBool: true;\n}\nLabel { color: #ff0000; }\n"); + assertTrue(constants.isEmpty(), "a #Constants entry is not a dropped declaration: " + constants); + + @SuppressWarnings("unchecked") + java.util.List listed = (java.util.List) unsupported.invoke(builder, + "#Constants, #Constants {\n accentColor: #ff0000;\n}\n"); + assertTrue(listed.isEmpty(), "a list of only #Constants exports its constants: " + listed); + + @SuppressWarnings("unchecked") + java.util.List mixed = (java.util.List) unsupported.invoke(builder, + "#Constants, Label {\n font-style: italic;\n}\n"); + assertEquals(1, mixed.size(), "Label still gets the probe in a mixed list: " + mixed); + } + + @Test + void anImageConstantResolvesFromTheProjectsResFolder() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + java.awt.image.BufferedImage png = new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB); + javax.imageio.ImageIO.write(png, "png", folder.resolve("medium.png").toFile()); + String css = dir.resolve("theme.css").toString(); + com.codename1.ui.util.MutableResource resources = + new CodenameOneGUIBuilder.ProjectImageResource(css); + new com.codename1.ui.css.CSSThemeCompiler().compile("#Constants{mediaPlayImage:play.png;}", + resources, "ProjectTheme"); + assertTrue(resources.getTheme("ProjectTheme").get("@mediaPlayImage") instanceof com.codename1.ui.Image, + "the image comes from res//play.png/medium.png"); + } + + @Test + void theThemeFolderNamedThemeIsSearchedFirst() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + for (String theme : new String[]{"Aaa", "Theme"}) { + java.nio.file.Path folder = dir.resolve("res").resolve(theme).resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + int size = "Theme".equals(theme) ? 3 : 2; + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(size, size, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); + } + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertEquals(3, image.getWidth()); + } + + @Test + void anUpperCaseVariantNameIsAccepted() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("custom.PNG").toFile()); + assertNotNull(new CodenameOneGUIBuilder.ProjectImageResource(dir.resolve("theme.css").toString()) + .getImage("play.png")); + } + + @Test + void anUpperCaseDensityNameIsReadAsMediumLikeTheBuild() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("high.PNG").toFile()); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("low.png").toFile()); + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + int expected = com.codename1.ui.Display.getInstance().getDeviceDensity() + >= com.codename1.ui.Display.DENSITY_MEDIUM ? 3 : 2; + assertEquals(expected, image.getWidth(), "the build's case-sensitive switch reads high.PNG as medium"); + } + + @Test + void severalMediumVariantsKeepTheLastListedLikeTheBuild() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(5, 5, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("custom.png").toFile()); + // EditableResources.MultiImage.getBest() replaces its pick on each equal DPI. + String[] listed = folder.toFile().list(); + int expected = "custom.png".equals(listed[listed.length - 1]) ? 5 : 3; + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertEquals(expected, image.getWidth()); + } + + @Test + void aDirectoryNamedLikeAPngIsSkippedLikeTheBuild() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder.resolve("high.png")); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertEquals(3, image.getWidth()); + } + + @Test + void aCorruptVariantDoesNotFailTheFolderLikeTheBuild() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("medium.png").toFile()); + java.nio.file.Files.write(folder.resolve("high.png"), new byte[]{1, 2, 3, 4}); + // The build stores the variants undecoded, so the folder is an image; the canvas + // shows the variant that decodes. + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + dir.resolve("theme.css").toString()).getImage("play.png"); + assertNotNull(image); + assertEquals(3, image.getWidth()); + } + + @Test + void anUnnamedVariantCountsAsMediumAndTheParentResIsSearched() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + java.nio.file.Path folder = dir.resolve("res").resolve("Theme").resolve("play.png"); + java.nio.file.Files.createDirectories(folder); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(3, 3, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("custom.png").toFile()); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", folder.resolve("verylow.png").toFile()); + java.nio.file.Path css = dir.resolve("css"); + java.nio.file.Files.createDirectories(css); + com.codename1.ui.Image image = new CodenameOneGUIBuilder.ProjectImageResource( + css.resolve("theme.css").toString()).getImage("play.png"); + assertNotNull(image, "src/main/res beside src/main/css is searched"); + int expected = com.codename1.ui.Display.getInstance().getDeviceDensity() + >= com.codename1.ui.Display.DENSITY_MEDIUM ? 3 : 2; + assertEquals(expected, image.getWidth()); + } + + @Test + void anImageBesideTheStylesheetIsNotAccepted() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("cn1-gb-css"); + javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(2, 2, + java.awt.image.BufferedImage.TYPE_INT_ARGB), "png", dir.resolve("play.png").toFile()); + assertNull(new CodenameOneGUIBuilder.ProjectImageResource(dir.resolve("theme.css").toString()) + .getImage("play.png"), "the build-time compiler reads only res///"); + } + + @Test + void anImageVariantIsChosenForTheDisplayDensity() { + String[] high = CodenameOneGUIBuilder.ProjectImageResource.densityOrder(com.codename1.ui.Display.DENSITY_HIGH); + assertEquals("high.png", high[0]); + assertEquals("medium.png", high[1]); + assertEquals("verylow.png", high[3]); + assertEquals("veryhigh.png", high[4]); + assertEquals("4k.png", high[high.length - 1]); + assertEquals("verylow.png", CodenameOneGUIBuilder.ProjectImageResource.densityOrder(0)[0]); + assertEquals("4k.png", CodenameOneGUIBuilder.ProjectImageResource.densityOrder(1000)[0]); } private static String describe(Object diagnostic) throws Exception { diff --git a/vm/selfhost/perf-baseline/pr/5976.json b/vm/selfhost/perf-baseline/pr/5976.json index 02c3a50438f..4a238c017ad 100644 --- a/vm/selfhost/perf-baseline/pr/5976.json +++ b/vm/selfhost/perf-baseline/pr/5976.json @@ -42,24 +42,6 @@ "time": 0.5 } } - }, - "translator": { - "all": { - "from": { - "memory": 0.519, - "time": 0.626, - "tolerance": { - "time": 0.2 - } - }, - "memory": 0.519, - "reason": "Linux x64 EPYC 7763 translator time 0.49x on run 38943915665 (rounds 0.48-0.54); this PR's earlier runs on that model measured 0.51x and 0.55x against the 0.63x row", - "runs": 3, - "time": 0.508, - "tolerance": { - "time": 0.2 - } - } } }, "macos-arm64": { @@ -156,58 +138,6 @@ } } } - }, - "windows-arm64@armv8-64-bit-family-8-model-d49-microsoft-corporation": { - "hello": { - "all": { - "from": { - "memory": 1.277, - "time": 1.107, - "tolerance": { - "memory": 0.35 - } - }, - "memory": 0.773, - "reason": "First run after merging master with the Flutter runtime (ff8f935c20): hello RAM fell on macOS arm64 (1.17-1.47x before, 0.92x now) and on Windows arm64 d49 (1.28x to 0.77x) in the same run, so the move comes from master's changes, not this PR's", - "runs": 1, - "time": 1.107, - "tolerance": { - "memory": 0.35 - } - } - }, - "translator": { - "all": { - "from": { - "memory": 0.43, - "time": 0.838 - }, - "memory": 0.503, - "reason": "Windows arm64 d49 translator RAM 0.50x (rounds 0.48-0.55) is the 0.513x the row held before pr/5883.json moved it to 0.43x from one run", - "runs": 1, - "time": 0.838 - } - } - }, - "windows-x64@intel64-family-6-model-207-genuineintel": { - "recursion": { - "all": { - "from": { - "memory": 0.059, - "time": 0.945, - "tolerance": { - "time": 0.2 - } - }, - "memory": 0.059, - "reason": "Windows x64 Intel Family 6 Model 207 recursion time is bimodal: base folds moved it 1.204x -> 0.943x -> 1.212x -> 0.945x, each from one or two runs; this branch measured 1.207x across five rounds (1.197-1.217), the upper mode", - "runs": 1, - "time": 1.207, - "tolerance": { - "time": 0.2 - } - } - } } } }