Skip to content

Native theme accents: runtime var() bindings via addThemeProps#4884

Merged
shai-almog merged 14 commits into
masterfrom
theme-constants-runtime
May 9, 2026
Merged

Native theme accents: runtime var() bindings via addThemeProps#4884
shai-almog merged 14 commits into
masterfrom
theme-constants-runtime

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

Summary

  • CSS compiler keeps the var(--accent-color, fallback) syntax in the shipped native theme CSS but additionally emits @cn1-bind:<UIID>.<key>=accent-color constants alongside the inlined fallback. The .res ships with every accent-bearing UIID quietly tracking the underlying palette variable.
  • UIManager.buildTheme() gains an applyThemeBindings() pass that overlays @<varname> constants supplied via addThemeProps onto every bound theme key — so a single addThemeProps({"@accent-color": "ff2d95", ...}) call retunes every UIID at once. Light/dark variants use distinct accent-color / accent-color-dark constants; values can be passed as "ff2d95", "#FF2D95", or "#f0a" shorthand.
  • Re-applies the var() syntax + #Constants declarations from the reverted Added theme constants for accents #4848 to both native-themes/ios-modern/theme.css and native-themes/android-material/theme.css. iOS uses 4 vars, Android adds container/on-container/on-color-dark for the M3 token model.
  • Docs (docs/developer-guide/Native-Themes.asciidoc) replace the "Forking a theme to rebrand" section with a runtime-override section documenting the @accent-* constant vocabulary per platform.
  • PaletteOverrideThemeScreenshotTest swapped its 12-key per-UIID override for a tighter @-prefixed constant set demonstrating the new path.
  • CI path filters trigger on native-themes/ edits so theme.css changes re-run the platform builds.

Why this replaces #4848

#4848 also surfaced these constants but did so at CSS-compile timevar() resolved against the fallback and the native theme had to be forked + recompiled to rebrand. Native themes ship inside the framework build, so forking isn't actually viable for app developers. This PR keeps the same author ergonomics in the CSS source but moves resolution to runtime: the .res carries enough metadata for addThemeProps to retune every bound UIID without recompiling anything.

Test plan

  • mvn -pl css-compiler install builds the CSS compiler with the new binding tracking.
  • scripts/build-native-themes.sh regenerates iOSModernTheme.res / AndroidMaterialTheme.res. Verified @cn1-bind: entries are present in the .res output.
  • mvn -Dtest='*UIManager*,*Theme*,*Style*' test — 46 tests pass.
  • New UIManagerThemeBindingsTest (6 tests) covers default fallback, override, hash-prefix and 3-digit shorthand normalization, orphan-binding skip, invalid-color leaving default intact.
  • New NativeThemeBindingsTest end-to-end loads the freshly built iOSModernTheme.res and confirms @accent-color retunes Button.fgColor.
  • Heads-up for the maintainer: the screenshot baselines under scripts/{ios,android}/screenshots/PaletteOverrideTheme_*.png (and the matching screenshots-metal/) will need to be regenerated. The new override touches @accent-container-color too, so Android RaisedButton goes magenta where the old test left it at the M3 default tone. iOS captures should be unchanged.

🤖 Generated with Claude Code

…ntime

CSS rules in the iOS Modern and Android Material 3 native themes
reference an accent palette via var(--accent-color, fallback). The
Flute compiler still inlines the fallback as the baked-in default
AND additionally emits a @cn1-bind:<UIID>.<key>=accent-color
constant alongside, so the .res file remembers which style keys
track which palette variable.

UIManager.buildTheme() gains an applyThemeBindings() pass that
overlays @<varname> overrides supplied via addThemeProps onto every
bound theme key. A user app rebrands the accent with a single
addThemeProps({"@accent-color": "ff2d95", ...}) call - no per-UIID
rule duplication, no theme recompile.

Replaces the compile-time-only var() approach reverted in #4877
(PR #4848). The same accent vocabulary works at runtime now and
the docs no longer suggest forking the shipped native theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Developer Guide build artifacts are available for download from this workflow run:

Developer Guide quality checks:

  • AsciiDoc linter: No issues found (report)
  • Vale: Vale failed (exit code 2) (report)
  • Image references: 1 unused image(s) found (report)

Unused image preview:

  • img/skin-designer/.gitkeep

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Cloudflare Preview

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 8, 2026

Compared 90 screenshots: 90 matched.

Native Android coverage

  • 📊 Line coverage: 10.21% (5577/54610 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 8.03% (27364/340775), branch 3.67% (1200/32660), complexity 4.68% (1464/31283), method 8.24% (1204/14618), class 13.64% (268/1965)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 10.21% (5577/54610 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 8.03% (27364/340775), branch 3.67% (1200/32660), complexity 4.68% (1464/31283), method 8.24% (1204/14618), class 13.64% (268/1965)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1381.000 ms
Base64 CN1 encode 106.000 ms
Base64 encode ratio (CN1/native) 0.077x (92.3% faster)
Base64 native decode 1228.000 ms
Base64 CN1 decode 329.000 ms
Base64 decode ratio (CN1/native) 0.268x (73.2% faster)
Image encode benchmark status skipped (SIMD unsupported)

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 8, 2026

Compared 89 screenshots: 89 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 144 seconds

Build and Run Timing

Metric Duration
Simulator Boot 63000 ms
Simulator Boot (Run) 0 ms
App Install 21000 ms
App Launch 4000 ms
Test Execution 224000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1018.000 ms
Base64 CN1 encode 1204.000 ms
Base64 encode ratio (CN1/native) 1.183x (18.3% slower)
Base64 native decode 639.000 ms
Base64 CN1 decode 972.000 ms
Base64 decode ratio (CN1/native) 1.521x (52.1% slower)
Base64 SIMD encode 431.000 ms
Base64 encode ratio (SIMD/native) 0.423x (57.7% faster)
Base64 encode ratio (SIMD/CN1) 0.358x (64.2% faster)
Base64 SIMD decode 513.000 ms
Base64 decode ratio (SIMD/native) 0.803x (19.7% faster)
Base64 decode ratio (SIMD/CN1) 0.528x (47.2% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 85.000 ms
Image createMask (SIMD on) 10.000 ms
Image createMask ratio (SIMD on/off) 0.118x (88.2% faster)
Image applyMask (SIMD off) 173.000 ms
Image applyMask (SIMD on) 121.000 ms
Image applyMask ratio (SIMD on/off) 0.699x (30.1% faster)
Image modifyAlpha (SIMD off) 131.000 ms
Image modifyAlpha (SIMD on) 61.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.466x (53.4% faster)
Image modifyAlpha removeColor (SIMD off) 267.000 ms
Image modifyAlpha removeColor (SIMD on) 132.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.494x (50.6% faster)
Image PNG encode (SIMD off) 1375.000 ms
Image PNG encode (SIMD on) 852.000 ms
Image PNG encode ratio (SIMD on/off) 0.620x (38.0% faster)
Image JPEG encode 557.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 8, 2026

Compared 90 screenshots: 90 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 231 seconds

Build and Run Timing

Metric Duration
Simulator Boot 72000 ms
Simulator Boot (Run) 1000 ms
App Install 15000 ms
App Launch 8000 ms
Test Execution 252000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1142.000 ms
Base64 CN1 encode 1157.000 ms
Base64 encode ratio (CN1/native) 1.013x (1.3% slower)
Base64 native decode 688.000 ms
Base64 CN1 decode 879.000 ms
Base64 decode ratio (CN1/native) 1.278x (27.8% slower)
Base64 SIMD encode 390.000 ms
Base64 encode ratio (SIMD/native) 0.342x (65.8% faster)
Base64 encode ratio (SIMD/CN1) 0.337x (66.3% faster)
Base64 SIMD decode 406.000 ms
Base64 decode ratio (SIMD/native) 0.590x (41.0% faster)
Base64 decode ratio (SIMD/CN1) 0.462x (53.8% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 55.000 ms
Image createMask (SIMD on) 9.000 ms
Image createMask ratio (SIMD on/off) 0.164x (83.6% faster)
Image applyMask (SIMD off) 111.000 ms
Image applyMask (SIMD on) 49.000 ms
Image applyMask ratio (SIMD on/off) 0.441x (55.9% faster)
Image modifyAlpha (SIMD off) 116.000 ms
Image modifyAlpha (SIMD on) 53.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.457x (54.3% faster)
Image modifyAlpha removeColor (SIMD off) 130.000 ms
Image modifyAlpha removeColor (SIMD on) 58.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.446x (55.4% faster)
Image PNG encode (SIMD off) 927.000 ms
Image PNG encode (SIMD on) 790.000 ms
Image PNG encode ratio (SIMD on/off) 0.852x (14.8% faster)
Image JPEG encode 417.000 ms

shai-almog and others added 13 commits May 8, 2026 07:30
The old per-UIID override and the new @accent-color override happen
to map to the same set of visible widgets on the iOS Modern capture
form (Button.fgColor, RaisedButton.bg/fg) - both produce the same
magenta there, so the iOS pipeline shows zero unmatched screenshots
which masks whether the new binding mechanism actually fires on iOS.

Add a vivid teal override on @accent-disabled-color (iOS-only - the
M3 theme hard-codes its disabled colours and has no binding for this
slot) so the disabled RaisedButton on the form switches from the
default iOS accent-disabled blue to teal. iOS captures now diverge
from the pre-binding baseline, confirming the runtime binding pass
fires on iOS too. Android's diff is already covered by the magenta
@accent-container-color retuning RaisedButton's tonal fill.

Add a sanity log at install time that surfaces any leak from a
previous test in the suite (a stale @accent-color constant). The
test runs near the tail of Cn1ssDeviceRunner and finish() reloads
/theme via initFirstTheme which clears themeConstants - so the
expected pre-state is "no leak". The log is the cheap signal we
need if a future framework regression ever drops that cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Android Material 3's RaisedButton (and other UIIDs using
cn1-pill-border) wraps its fill in a RoundBorder whose color the CSS
compiler bakes in at compile time. By default RoundBorder paints from
that baked field via fillShape() with uiid=false, ignoring
Style.bgColor at render time. The runtime binding pass updates
themeProps[<UIID>.bgColor] correctly when the user pushes an
@accent-color override, but the visible pill stays at the
compile-time fallback because the border, not the Style, owns the
visible color.

When the source background-color came from a var() expansion (i.e.
the binding mechanism wants this fill to be runtime-tunable), flip
the RoundBorder into uiid mode so it routes through
Style.getBgPainter() at paint time. Style.bgColor then drives the
fill, and a runtime @accent-* override propagates all the way to the
visible pixels.

Legacy themes whose backgrounds are inlined hex (no var()) keep the
existing baked-color path, so this is a no-op for everything that
isn't already opted into the binding mechanism.

Update iOS PaletteOverrideTheme_light/dark goldens (both GL and Metal)
to the captures produced by the previously-pushed override-color
expansion - iOS uses border-radius (RoundRectBorder) which already
respects Style.bgColor, so its captures only changed because we
added @accent-disabled-color to the override and the disabled
RaisedButton on the form is now teal instead of accent-disabled
blue. Android goldens will need a fresh CI run with this fix to
capture the now-correct magenta RaisedButton; deferring those.

NativeThemeBindingsTest: extended to cover the AndroidMaterialTheme
.res so the binding round-trip is exercised on both shipped
native-theme palettes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…constants

Lets a user app's theme.css override a native-theme palette variable
purely from CSS:

    #Constants {
        includeNativeBool: true;
        --accent-color: #ff2d95;
        --accent-color-dark: #ff2d95;
    }

Previously `--name` declarations short-circuited into the parser-
internal variables map (used only for compile-time var() resolution
within the same compilation unit) and never reached the runtime, so
a user's --accent-color redeclaration was silently dropped. The
Flute compiler now ALSO emits these as @name theme constants when
they sit inside a #Constants pseudo-element, which routes them
through UIManager.themeConstants where the binding-overlay pass
already knows what to do with them - the user theme.css is loaded
after the native theme (via includeNativeBool=true), the @-constant
overwrites the native default, applyThemeBindings retunes every
bound UIID. Same end-state as runtime addThemeProps but driven from
CSS, no Java code, no Hashtable.

Adds SAC_RGBCOLOR / SAC_FUNCTION (rgb, rgba) handling to the
constants-serialization loop so hex / rgb() colors in #Constants
make it out as plain hex strings (the format runtime themeProps and
applyThemeBindings expect for color values).

Native theme captures still emit their own @accent-color etc. from
their #Constants blocks - this is by design: the constants are
already in themeProps with the native default, so a no-op overlay
runs after each native-theme-load. When the user theme then loads
on top, the user's @accent-color overwrites the native default and
the next applyThemeBindings overlays the user's value.

NativeThemeBindingsTest now also asserts @accent-color is present
in the loaded theme so the round-trip CSS -> .res -> Hashtable is
covered for both shipped native themes.

Native-Themes docs lead with the CSS-from-theme.css path; the
runtime addThemeProps path is documented as the dynamic-theming
counterpart for cases like in-app accent toggles. Test docstring
clarifies it's exercising the runtime path because screenshot
tests can't easily mutate the app's compiled theme.css.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…literal

Element.resolveBinding walked the parent chain whenever the current
Element had no `bindings` entry for the requested property, but
`bindings` only records properties whose VALUE came from a var() -
not properties the rule set with a literal. So a state rule like
`Button.disabled { background-color: #e0dce4 }` (a literal that
deliberately breaks the inheritance from base `Button { background-
color: var(--accent-color) }`) was treated by the binding walker as
"no value of its own" and the parent's accent binding was emitted
for `Button.dis#bgColor`. At runtime the @accent-color override
then stomped the disabled tone with the primary colour, visibly
shifting `Button.disabled` away from the M3 baseline.

Fix: in resolveBinding, after the local `bindings` miss, check
whether the current Element's `style` map has an entry for the
property. If yes, this rule overrode the value with a literal;
return null so the override stops at this level. Only walk to the
parent when the Element has no value of its own (the derive-only
case Button-derived RaisedButton relies on, or the implicit
unselected state inheriting from the base UIID).

Caught by Android ButtonTheme_dark/light captures shifting in the
disabled-button band on the latest CI run; the Button.dis#bgColor
binding is now correctly absent from the rebuilt
AndroidMaterialTheme.res.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Metal screenshot pipeline hit the 30-minute step timeout on the
latest PR run. Tracing the device-runner.log: the suite reached
TabsTheme_light (~13 min into test execution), captured 111533
PNG bytes + 84124-byte preview chunk stream at preview_quality=6
(still over the 20480-byte preview cap), then went silent for
~18 minutes before the timer killed it. No FATAL / Test-failure
markers - just dead air on the logcat replay.

The earlier passing Metal run finished ~28 minutes in, so the suite
is consistently running right at the wall. Bump the timeout to 45
minutes - matches the build-ios job's own cap and the iOS Metal
runner's natural ceiling - so a borderline-slow chunk-stream replay
doesn't get conflated with a real Metal port hang.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The texture-backdrop test painter (used by Tabs / Dialog screenshot
tests in hellocodenameone) was the immediate trigger, but the
underlying robustness issue is in the Metal port itself: drawQuad and
drawSolidPrimitive called setRenderPipelineState + 3-4 setVertexBytes
on EVERY draw, even when consecutive draws shared the same pipeline
and matrix snapshot.

For one-off fills that's fine. For burst patterns -- the painter was
issuing 2556 fillRect calls per band x ~50 bands = ~125k fillRects
per textured-backdrop frame -- the redundant per-call setVertexBytes
of a 192-byte matrix struct plus the redundant pipeline state-set
choke the CAMetalLayer command buffer. The TabsTheme dark-mode
capture stalled the iOS Metal screenshot suite for 18 minutes (until
the surrounding step's wall-clock timer fired).

Two complementary fixes:

1. Painter (hellocodenameone DualAppearanceBaseTest.TextureBackdrop
   Painter): each diagonal stripe is a parallelogram. Replace the
   2556-iteration scanline fillRect loop with one fillPolygon call
   per band. Polygon fill is universally supported by every CN1 port
   we ship (Graphics.fillPolygon is core API, not a port extension);
   the previous comment claiming otherwise was wrong. ~125k draws
   per frame -> ~50.

2. Metal port (CN1Metalcompat.m): track last-bound pipeline state
   and last-uploaded matrix bytes per encoder. Skip the Metal API
   call when they haven't changed. Cache invalidates on every
   activeEncoder reassignment (BeginFrame, BeginMutableImageDraw,
   EndMutableImageDraw restore, EndFrame) because Metal command
   encoders don't carry state between encoders. memcmp on a 192-byte
   struct per draw is much cheaper than the encoder's setVertexBytes
   argument-buffer copy.

This isn't only about TextureBackdropPainter -- it makes Metal
robust to any code path that emits a long burst of same-pipeline /
same-matrix draws (gradient scanline approximations, RoundRectBorder
interior scanline fills, custom painters in user apps that happen to
choose scanline strategies for portability). A small CSS or layout
mistake should not be able to cascade into 100k+ redundant Metal
encoder calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Rect

Each diagonal band is a parallelogram. The previous loop emitted one
fillRect per row (h rows) per band; at phone resolution that's
~2500 fillRects x 50 bands = ~125k draw calls per backdrop frame.
On iOS Metal that call volume saturated the CAMetalLayer command
buffer and stalled the dark-mode transition for TabsTheme by 18
minutes (until the surrounding step's wall-clock timer fired).

Replace with one fillPolygon per band - 50 draw calls instead of
125k. Graphics.fillPolygon is core CN1 API, supported on every port
we ship; the previous comment claiming otherwise was wrong.

This is the painter half of the earlier "Metal port: cache pipeline
+ matrix state" commit. The Metal-side state caching half is left
out of this revert chain because the original CI run with both
pieces produced a different failure mode (simctl couldn't launch
the rebuilt app -- "Application unknown to FrontBoard") that I
couldn't reproduce locally and don't want to chase blind. The
painter fix alone reduces the call volume by ~2500x, which is the
actual root-cause mitigation; the encoder state cache was a
defensive second pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
drawQuad and drawSolidPrimitive called setRenderPipelineState + 3-4
setVertexBytes on EVERY draw, even when consecutive draws shared the
same pipeline and matrix snapshot.

For one-off fills that's fine. For burst patterns -- the
hellocodenameone TextureBackdropPainter (used by Tabs / Dialog
screenshot tests) is the immediate trigger, issuing ~60k fillRects per
textured-backdrop frame, and locally instrumented Metal draw counts
confirmed ~60,000 draw calls/frame sustained over 25+ frames per
TabsTheme capture (~1.5M Metal API calls total). On the GitHub Actions
runner this stalls the CAMetalLayer command buffer for 18+ minutes
until the surrounding step's wall-clock timer fires. The local M-series
hardware tolerates the call volume but at ~40-50ms per frame.

Fix: track last-bound pipeline state and last-uploaded matrix bytes
per encoder. Skip the Metal API call when they haven't changed. Cache
invalidates on every activeEncoder reassignment (BeginFrame, BeginMutab
leImageDraw, EndMutableImageDraw restore, EndFrame) because Metal
command encoders don't carry state between encoders. memcmp on a
192-byte struct per draw is much cheaper than the encoder's
setVertexBytes argument-buffer copy.

This isn't only about TextureBackdropPainter -- it makes Metal robust
to any code path that emits a long burst of same-pipeline / same-matrix
draws (gradient scanline approximations, RoundRectBorder interior
scanline fills, custom painters in user apps that happen to choose
scanline strategies for portability). A small CSS or layout mistake
should not be able to cascade into 100k+ redundant Metal encoder
calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The painter is invoked once per Form paint cycle. The screenshot
test framework's settle window emits ~30 paints at 60Hz before the
capture fires; multiplied by ~50 bands x ~2532 rows of fillRect per
paint, that's ~3.8M fillRect calls per textured-backdrop capture.
Even after Metal port pipeline+matrix state caching skips 99.99% of
redundant API calls, the remaining setVertexBytes(positions)/
setVertexBytes(color)/drawPrimitives per fillRect is enough to keep
the GitHub Actions runner stalled past the 30-minute screenshot
step timeout on TabsThemeScreenshotTest.

Render the pattern into an Image once (size keyed on form bounds)
and drawImage(cached) on every subsequent paint. First paint:
~125k draw calls; later paints in the same form: 1 drawImage call.
Total per capture drops from ~3.8M draws to ~125k.

Cross-port safe: Image.createImage + drawImage are core CN1 API
that every port implements, unlike the per-band fillPolygon variant
that silently dropped on Android (canvas.drawPath behaviour at
extreme parallelogram coordinates). The cache is keyed on
(width, height) so a rotation/resize re-renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The painter now renders the diagonal-stripe texture into a cached
Image and drawImage()s it on subsequent paints, instead of
re-running the per-row scanline fillRects every frame. Visually
identical to the previous goldens, but sub-pixel alpha-blend
rounding differs (Image alpha-blend vs direct framebuffer
alpha-blend produces tiny per-pixel deltas on iOS GL).

iOS Metal and Android goldens already accept the new render
(Metal: 90/90 matched, Android: 90/90 matched). Only iOS GL
strict pixel comparison flagged the four textured tests:
DialogTheme_light/dark, TabsTheme_light/dark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shai-almog shai-almog merged commit 2b63085 into master May 9, 2026
25 checks passed
shai-almog added a commit that referenced this pull request May 12, 2026
… jar (#4929)

* Fix CSSWatcher live reload: drop stale bindings + extract m2 designer jar

Two recent CSS/localization changes regressed the simulator's live CSS
reload, in different ways.

1. addThemeProps stomped user edits with stale @cn1-bind entries.
   PR #4884 added applyThemeBindings() inside UIManager.buildTheme so a
   single addThemeProps({"@accent-color": ...}) override could retune
   every var()-bound theme key. But CSSWatcher reloads the theme through
   the same code path -- and addThemeProps never clears themeConstants.
   When the user replaced a `var()` rule with a literal in their CSS,
   the recompiled theme.res no longer emitted the matching
   `@cn1-bind:<key>` entry, but the previous binding was still sitting
   in themeConstants. applyThemeBindings happily re-overlaid the
   user's fresh literal value with the stale binding's resolved value,
   so the visible change disappeared on every reload.

   Fix: in buildTheme, before iterating the incoming Hashtable, detect
   any binding whose subject style key the new load is re-setting
   without re-asserting the binding alongside, and drop those bindings
   before the overlay pass runs. Pure `@accent-color` overrides keep
   working because they don't carry style keys, so no bindings are
   considered stale.

2. MavenUtils.findDesignerJarInM2 returned the unrunnable wrapper zip.
   PR #4852 added an m2 fallback for the CSSWatcher's designer-jar
   lookup, used whenever -Dcodename1.designer.jar isn't passed in (e.g.
   simulator launched from the IDE rather than `mvn cn1:run`). The
   helper returned `codenameone-designer-<v>-jar-with-dependencies.jar`
   directly from m2 -- but that artifact is a zip wrapper containing a
   single inner designer_1.jar (see maven/designer/pom.xml's antrun
   step), with no top-level Main-Class manifest. `java -jar wrapper.zip`
   fails with "no main manifest attribute", the CSS subprocess never
   starts, and the watcher silently waits for ::refresh:: lines that
   never come.

   Fix: mirror AbstractCN1Mojo.getDesignerJar's pattern -- unzip the
   wrapper to an `<artifact>.jar-extracted/` sibling on demand and
   return the inner designer_1.jar so `java -jar` actually launches.

Tests:

- UIManagerThemeBindingsTest gains three regression cases:
  cssReloadDropsStaleBindingWhenRuleBecomesLiteral (the actual
  reproducer), cssReloadKeepsBindingWhenStillEmittedTogether (guard
  against an over-eager fix), and overrideOnlyReloadKeepsBindings
  (repeated `@accent-color` retunes still work). The first fails
  before the UIManager fix; all three pass after.

- MavenUtilsTest is new and covers the wrapper-vs-inner-jar resolution
  with five cases: happy path, re-use of extracted inner jar when the
  wrapper hasn't changed, re-extract when the wrapper mtime advances,
  null when the core jar isn't in an m2 layout, and null when the
  designer artifact is missing. To make these actually executable, the
  javase pom now pins maven-surefire-plugin to 3.2.5 (the parent's
  2.21.0 doesn't auto-discover JUnit Jupiter). The pre-existing
  CSSWatcherTest + LocationSimulationTest + JavaSEPortFontMappingTest
  in the same module also start running as a side effect.

- pr.yml gets a new "Run JavaSE port unit tests" step so this whole
  test class -- which compiled but never executed -- is wired into CI.
  Without it, regressions in CSSWatcher/MavenUtils/JavaSEPort helpers
  would continue to slip through, which was the original gap the user
  flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address PR review: harden Zip Slip + install javase deps in CI step

- MavenUtils.extractInnerJar no longer derives a File path from
  ZipEntry.getName(). CodeQL flagged the previous loop as a Zip Slip
  risk because a wrapper containing `../../etc/passwd` would have been
  written outside the extraction directory. The wrapper produced by
  maven/designer/pom.xml has a single designer_1.jar entry by design,
  so the extractor now (a) writes only to a single fixed destination
  path under destDir and (b) only matches entries whose literal name
  equals "designer_1.jar". Anything else is skipped; if the canonical
  entry is absent, the method throws. Two new MavenUtilsTest cases:
  refusesPathTraversalEntriesAndDoesNotWriteOutsideExtractDir packs a
  `../../escaped.txt` entry and asserts no escaped file appears in the
  temp root; skipsUnexpectedEntriesAndStillExtractsDesignerJar mixes
  a README and a subdir/other.jar with the real designer_1.jar and
  asserts only the inner jar lands on disk.

- pr.yml's new "Run JavaSE port unit tests" step failed with
  "Could not find artifact com.codenameone:sqlite-jdbc:jar:8.0-SNAPSHOT"
  on all three matrix entries (Java 8/17/21). The earlier "Build
  Codename One" step builds core-unittests with -am, which doesn't
  install sqlite-jdbc into the local repo. Split the new step into
  two mvn invocations: first install javase's transitive deps without
  running their tests, then run javase's tests in isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* UIManager: scope stale-binding drop to addThemeProps, fix iOS hang

The previous fix ran the stale-binding preprocessing inside buildTheme,
which is also called from the @includeNativeBool layered initial load
(setThemePropsImpl -> buildTheme -> Display.installNativeTheme() ->
buildTheme(native) -> outer buildTheme(userTheme) continues). After the
native theme installs its bindings into themeConstants, the outer call's
preprocessing would drop them whenever the user's app theme.css set a
literal value for the same UIID -- which the existing iOS / Android
screenshot goldens were captured against.

The iOS PR check hit this: the device-runner log shows the suite ran
fine through ChartCubicLineScreenshotTest and then hung in
ChartBarScreenshotTest setup until the 30-minute timeout fired. The
inconsistent themeConstants state left over once the layered native
bindings were dropped manifests as a hang in chart-component initialization
(presumably a Style.derive cycle or similar) rather than as a pixel
diff.

Move the drop pre-pass out of buildTheme and into a new
dropSupersededBindings() called only from addThemeProps. This keeps
the CSSWatcher reload fix (the actual reported regression) and the
companion regression tests passing, while restoring the original
behavior of the layered initial-load path -- bindings declared by the
native theme via @includeNativeBool stay live, user-app literals don't
silently strip them out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant