Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
243ba93
Native theme fidelity suite + Material 3 fidelity fixes
shai-almog Jun 24, 2026
f108bb2
ci: mark iOS fidelity job non-blocking (ParparVM native-render blocker)
shai-almog Jun 24, 2026
ebe84de
ci: make build-fidelity-app.sh executable (exit 126 in CI)
shai-almog Jun 24, 2026
993dc7e
ci: fix build-test (ASCII), update javase goldens, seed fidelity goldens
shai-almog Jun 24, 2026
1c0a57d
ci: commit CI-density Android goldens + baseline, drop seed flags
shai-almog Jun 24, 2026
8f954b4
iOS native render fix (MRC bugs) + better fidelity report stats
shai-almog Jun 24, 2026
125623a
iOS fidelity: real native-widget screenshots from a standalone native…
shai-almog Jun 24, 2026
a146833
fidelity: crop both renders to common region instead of failing on size
shai-almog Jun 24, 2026
b448f7b
fidelity metric: gate on structural salience, not just mean colour delta
shai-almog Jun 24, 2026
1d0d2d0
iOS native refs: adopt iOS 26 Liquid Glass widget styles
shai-almog Jun 24, 2026
c05219a
iOS glass: shared backdrop PNG so Liquid Glass shows (native + CN1)
shai-almog Jun 24, 2026
89d03bc
Merge remote-tracking branch 'origin/master' into native-theme-fideli…
shai-almog Jun 24, 2026
89970ec
iOS fidelity: tune to real iOS 26 Liquid Glass; Slider iOS thumb mode…
shai-almog Jun 24, 2026
bad9898
iOS+Android fidelity feedback: glass commands, pill buttons, switch/s…
shai-almog Jun 24, 2026
50d7134
CI: arm64-only iOS fidelity build (neon); revert Material to 1.12.0 (…
shai-almog Jun 24, 2026
16a6a67
Slider: fix PMD violations (empty catch blocks + one-declaration-per-…
shai-almog Jun 24, 2026
b08b37a
PMD: allow comment-documented empty catches; comment the remaining em…
shai-almog Jun 24, 2026
e0a23df
Refresh 32 Android instrumentation theme screenshot goldens for theme…
shai-almog Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions .github/workflows/scripts-fidelity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
---
name: Native theme fidelity

# Measures how close Codename One's native themes render to the REAL native OS
# widgets. Runs the fidelity app on an Android emulator (Material 3) and an iOS
# simulator (Modern theme, Metal pipeline): each component is rendered as the CN1
# widget AND the native widget IN THE SAME ENVIRONMENT, and the two are diffed.
# Comparing same-environment renders makes the score robust to the subtle
# rendering differences between CI machines; the committed goldens + baselines
# are re-seedable drift artifacts (FIDELITY_UPDATE_GOLDENS / _BASELINE).

'on':
pull_request:
paths:
- '.github/workflows/scripts-fidelity.yml'
- 'scripts/setup-workspace.sh'
- 'scripts/build-android-port.sh'
- 'scripts/build-ios-port.sh'
- 'scripts/build-android-app.sh'
- 'scripts/build-ios-app.sh'
- 'scripts/build-fidelity-app.sh'
- 'scripts/run-android-fidelity-tests.sh'
- 'scripts/run-ios-fidelity-tests.sh'
- 'scripts/lib/cn1ss.sh'
- 'scripts/common/java/**'
- 'scripts/fidelity-app/**'
- 'native-themes/ios-modern/**'
- '!native-themes/ios-modern/**/*.md'
- 'native-themes/android-material/**'
- '!native-themes/android-material/**/*.md'
- 'CodenameOne/src/**'
- '!CodenameOne/src/**/*.md'
- 'Ports/Android/**'
- '!Ports/Android/**/*.md'
- 'Ports/iOSPort/**'
- '!Ports/iOSPort/**/*.md'
- 'vm/**'
- '!vm/**/*.md'
- 'maven/**'
- '!maven/core-unittests/**'
- '!docs/**'
push:
branches: [master]
paths:
- '.github/workflows/scripts-fidelity.yml'
- 'scripts/build-fidelity-app.sh'
- 'scripts/run-android-fidelity-tests.sh'
- 'scripts/run-ios-fidelity-tests.sh'
- 'scripts/lib/cn1ss.sh'
- 'scripts/common/java/**'
- 'scripts/fidelity-app/**'
- 'native-themes/ios-modern/**'
- 'native-themes/android-material/**'
- 'CodenameOne/src/**'
- 'Ports/Android/**'
- 'Ports/iOSPort/**'
- 'vm/**'
- 'maven/**'
- '!maven/core-unittests/**'

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
fidelity-android:
name: Fidelity (Android, Material 3)
permissions:
contents: read
pull-requests: write
issues: write
runs-on: ubuntu-latest
timeout-minutes: 60
env:
GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
CN1SS_FAIL_ON_MISMATCH: '1'
CN1SS_FIDELITY_EPSILON: '2.0'
# CI renders differ subtly from a developer machine, so the committed
# goldens captured elsewhere will not match here. Re-seed goldens AND
# baseline from this environment's own native renders, then the same-run
# comparison + ratchet gate are evaluated against this environment.
# Refresh the per-environment native goldens (drift artifact); the
# ratchet gates the CN1-vs-native SCORE against the committed baseline,
# which is portable across environments because both sides are rendered
# here. A score gap beyond the epsilon fails loudly, surfacing a real
# environment/theme difference to investigate (then reseed deliberately).
FIDELITY_UPDATE_GOLDENS: '1'
steps:
- uses: actions/checkout@v6
- name: Set TMPDIR
run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"
- name: Cache codenameone-tools
uses: actions/cache@v5
with:
path: ${{ runner.temp }}/codenameone-tools
key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }}
restore-keys: |
${{ runner.os }}-cn1-tools-
- name: Cache Maven repository
uses: actions/cache@v5
with:
path: ~/.m2/repository
key: ${{ runner.os }}-m2-fidelity-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-m2-
- name: Cache Gradle
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-fidelity-${{ hashFiles('scripts/fidelity-app/**/gradle*') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Setup workspace
run: ./scripts/setup-workspace.sh -q -DskipTests
- name: Build Android port
run: ./scripts/build-android-port.sh -q -DskipTests
- name: Build fidelity app (Android)
id: build
run: |
mkdir -p ~/.codenameone
cp maven/UpdateCodenameOne.jar ~/.codenameone/
./scripts/build-fidelity-app.sh android -q -DskipTests
- name: Enable KVM for Android emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run fidelity suite (emulator)
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 36
arch: x86_64
target: google_apis
disk-size: 2048M
script: FIDELITY_UPDATE_BASELINE=1 ./scripts/run-android-fidelity-tests.sh "${{ steps.build.outputs.gradle_project_dir }}"
- name: Upload fidelity artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: android-fidelity
path: |
artifacts/android-fidelity/**
scripts/fidelity-app/goldens/android/**
scripts/fidelity-app/baseline/android-fidelity-baseline.json
if-no-files-found: warn
retention-days: 14

fidelity-ios-metal:
name: Fidelity (iOS Modern, Metal)
permissions:
contents: read
pull-requests: write
issues: write
runs-on: macos-15
timeout-minutes: 90
env:
GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }}
CN1SS_FAIL_ON_MISMATCH: '1'
CN1SS_FIDELITY_EPSILON: '2.0'
# iOS native references are COMMITTED goldens generated offline by
# scripts/build-ios-native-ref.sh (a real-UIWindow native app); they are not
# regenerated here, so no FIDELITY_UPDATE_GOLDENS.
steps:
- uses: actions/checkout@v6
- name: Set TMPDIR
run: echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"
- name: Cache codenameone-tools
uses: actions/cache@v5
with:
path: ${{ runner.temp }}/codenameone-tools
key: ${{ runner.os }}-cn1-tools-${{ hashFiles('scripts/setup-workspace.sh') }}
restore-keys: |
${{ runner.os }}-cn1-tools-
- name: Cache Maven repository
uses: actions/cache@v5
with:
path: ~/.m2/repository
key: ${{ runner.os }}-m2-fidelity-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-m2-
- name: Setup workspace
run: ./scripts/setup-workspace.sh -q -DskipTests
- name: Build iOS port
run: ./scripts/build-ios-port.sh -q -DskipTests
- name: Install Metal toolchain
run: xcodebuild -downloadComponent MetalToolchain || true
- name: Build fidelity app (iOS, Metal)
id: build
run: |
mkdir -p ~/.codenameone
cp maven/UpdateCodenameOne.jar ~/.codenameone/
./scripts/build-fidelity-app.sh ios -q -DskipTests
- name: Build simulator app
id: simapp
run: |
set -euo pipefail
WS="$(find scripts/fidelity-app/ios/target -name '*.xcworkspace' -maxdepth 3 | head -n1)"
PROJ_DIR="$(dirname "$WS")"
SCHEME="$(xcodebuild -workspace "$WS" -list 2>/dev/null | awk '/Schemes:/{f=1;next} f&&NF{print $1; exit}')"
DD="$PROJ_DIR/dd"
# arm64-only: the macos-15 runner is Apple Silicon and the x86_64
# simulator slice fails to build the ParparVM SIMD code (clang
# '_Builtin_intrinsics.arm.neon requires feature neon' module error).
xcodebuild -workspace "$WS" -scheme "$SCHEME" -sdk iphonesimulator \
-configuration Debug -derivedDataPath "$DD" \
ARCHS=arm64 ONLY_ACTIVE_ARCH=YES VALID_ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO build
APP="$(find "$DD/Build/Products" -maxdepth 2 -name '*.app' -type d | head -n1)"
echo "app_path=$APP" >> "$GITHUB_OUTPUT"
- name: Boot simulator
id: sim
run: |
UDID="$(xcrun simctl list devices available | grep -E 'iPhone 16 \(' | grep -Eo '[0-9A-F-]{36}' | head -n1)"
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID" -b
echo "udid=$UDID" >> "$GITHUB_OUTPUT"
- name: Run fidelity suite (simulator, Metal)
run: FIDELITY_UPDATE_BASELINE=1 ./scripts/run-ios-fidelity-tests.sh "${{ steps.simapp.outputs.app_path }}" "${{ steps.sim.outputs.udid }}"
- name: Upload fidelity artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: ios-fidelity
path: |
artifacts/ios-fidelity/**
scripts/fidelity-app/goldens/ios-metal/**
scripts/fidelity-app/baseline/ios-metal-fidelity-baseline.json
if-no-files-found: warn
retention-days: 14
34 changes: 34 additions & 0 deletions CodenameOne/src/com/codename1/components/FloatingActionButton.java
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,24 @@ public void setUIID(String id) {
}

private void updateBorder() {
// Material 3 made the FAB a rounded SQUARE (squircle). A theme that sets
// fabCornerRadiusMM gets a RoundRectBorder of that corner radius (with the
// component's own bg colour filling it) instead of the legacy full circle.
String cr = getUIManager().getThemeConstant("fabCornerRadiusMM", null);
if (cr != null) {
try {
float mm = Float.parseFloat(cr.trim());
getUnselectedStyle().setBorder(com.codename1.ui.plaf.RoundRectBorder.create()
.cornerRadius(mm).shadowOpacity(shadowOpacity));
getSelectedStyle().setBorder(com.codename1.ui.plaf.RoundRectBorder.create()
.cornerRadius(mm).shadowOpacity(shadowOpacity));
getPressedStyle().setBorder(com.codename1.ui.plaf.RoundRectBorder.create()
.cornerRadius(mm).shadowOpacity(shadowOpacity));
return;
} catch (NumberFormatException ignore) {
// malformed constant -> fall through to the legacy circular FAB
}
}
getUnselectedStyle().setBorder(RoundBorder.create().
color(getUnselectedStyle().getBgColor()).
shadowOpacity(shadowOpacity).rectangle(rectangle));
Expand Down Expand Up @@ -281,6 +299,22 @@ public FloatingActionButton createSubFAB(char icon, String text) {
@Override
protected Dimension calcPreferredSize() {
if (autoSizing && getIcon() != null) {
// Material 3's standard FAB is a fixed 56dp square (24dp icon). A theme
// can pin that exact diameter via fabDiameterMM, which is more faithful
// than the legacy icon*11/4 (=2.75x) heuristic that yields ~71dp. Falls
// back to the heuristic when the constant is absent.
String diaMm = com.codename1.ui.plaf.UIManager.getInstance()
.getThemeConstant("fabDiameterMM", null);
if (diaMm != null) {
try {
int d = Display.getInstance().convertToPixels(Float.parseFloat(diaMm));
if (d > 0) {
return new Dimension(d, d);
}
} catch (NumberFormatException ignore) {
// malformed fabDiameterMM constant -> fall back to the icon-derived size
}
}
return new Dimension(getIcon().getWidth() * 11 / 4, getIcon().getHeight() * 11 / 4);
}
return super.calcPreferredSize();
Expand Down
73 changes: 63 additions & 10 deletions CodenameOne/src/com/codename1/components/Switch.java
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ private int getFontSize() {

private Image getThumbOnImage() {
if (thumbOnImage == null) {
// The "on" thumb keeps its elevation shadow on every platform (Material 3
// elevates the selected thumb); only the off/disabled thumbs go flat where
// the theme asks (switchThumbShadowSpreadInt).
thumbOnImage = createPlatformThumbImage(this, (int) (getFontSize() * getThumbScaleY()), getSelectedStyle().getFgColor(), 2, getThumbInset());
}
return thumbOnImage;
Expand All @@ -399,7 +402,7 @@ private void setThumbOnImage(Image image) {

private Image getThumbOffImage() {
if (thumbOffImage == null) {
thumbOffImage = createPlatformThumbImage(this, (int) (getFontSize() * getThumbScaleY()), getUnselectedStyle().getFgColor(), 2, getThumbInset()); //getUnselectedStyle().getFgColor(), true);
thumbOffImage = createPlatformThumbImage(this, (int) (getFontSize() * getThumbOffScaleY()), getUnselectedStyle().getFgColor(), getThumbShadowSpread(), getThumbInset()); //getUnselectedStyle().getFgColor(), true);
}
return thumbOffImage;
}
Expand All @@ -420,7 +423,7 @@ private void setThumbOffImage(Image image) {

private Image getThumbDisabledImage() {
if (thumbDisabledImage == null) {
thumbDisabledImage = createPlatformThumbImage(this, (int) (getFontSize() * getThumbScaleY()), getDisabledStyle().getFgColor(), 2, getThumbInset()); //getDisabledStyle().getFgColor(), true);
thumbDisabledImage = createPlatformThumbImage(this, (int) (getFontSize() * getThumbOffScaleY()), getDisabledStyle().getFgColor(), getThumbShadowSpread(), getThumbInset()); //getDisabledStyle().getFgColor(), true);
}
return thumbDisabledImage;
}
Expand Down Expand Up @@ -466,6 +469,51 @@ private double getThumbScaleY() {
getThemeConstant(getUIID().toLowerCase() + "ThumbScaleY", "1.5"));
}

/// Vertical scale of the OFF (and disabled) thumb. Material 3 renders the
/// off-thumb smaller than the on-thumb (16dp vs 24dp); the Android theme sets
/// switchThumbOffScaleY below ThumbScaleY. Defaults to ThumbScaleY (a single
/// thumb size) so iOS and existing themes are unaffected.
private double getThumbOffScaleY() {
return Double.parseDouble(getUIManager().getThemeConstant(
getUIID().toLowerCase() + "ThumbOffScaleY", String.valueOf(getThumbScaleY())));
}

/// Pixels of drop-shadow spread painted under the thumb. Defaults to 2 (the
/// iOS-style elevated thumb). Material 3 renders a FLAT thumb, so the Android
/// native theme sets switchThumbShadowSpreadInt to 0.
private int getThumbShadowSpread() {
return getUIManager().getThemeConstant(
getUIID().toLowerCase() + "ThumbShadowSpreadInt", 2);
}

/// Extra inset (px from mm) of the OFF thumb from the track's leading edge.
/// Material 3 leaves a small gap; defaults to 0 so iOS/legacy are unaffected.
private int getThumbOffInset() {
String v = getUIManager().getThemeConstant(
getUIID().toLowerCase() + "ThumbOffInsetMM", null);
if (v != null) {
try {
return Display.getInstance().convertToPixels(Float.parseFloat(v.trim()));
} catch (NumberFormatException ignore) {
// fall through to no inset
}
}
return 0;
}

/// Colour of the disabled track's outline ring. Material 3 uses a very subtle
/// near-surface tone (distinct from the more visible disabled thumb/fg). A theme
/// names a UIID via switchDisabledOutlineColorUIID whose fg supplies it
/// (dark-resolved); unset falls back to the disabled foreground colour.
private int getTrackDisabledOutlineColor() {
String uiid = getUIManager().getThemeConstant(
getUIID().toLowerCase() + "DisabledOutlineColorUIID", null);
if (uiid != null) {
return getUIManager().getComponentStyle(uiid).getFgColor();
}
return getDisabledStyle().getFgColor();
}

private double getTrackScaleX() {
return Double.parseDouble(getUIManager().
getThemeConstant(getUIID().toLowerCase() + "TrackScaleX", "3"));
Expand Down Expand Up @@ -518,7 +566,12 @@ private void setTrackOnImage(Image image) {

private Image getTrackDisabledImage() {
if (trackDisabledImage == null) {
trackDisabledImage = createPlatformTrackImage(this, (int) (getFontSize() * getTrackScaleX()), (int) (getFontSize() * getTrackScaleY()), getDisabledStyle().getBgColor(), 255, 2, getTrackOffOutlineColor(), getTrackOffOutlineWidth());
// Material 3 disabled switch reads as a thin outline ring over a
// surface-coloured interior (~ the page background, so it looks almost
// fill-less) - NOT the accent or a contrasting fill. The smooth ring is
// the outer pill (foreground colour) minus the inner surface pill, so the
// disabled style's bg must be the surface colour and its fg the outline.
trackDisabledImage = createPlatformTrackImage(this, (int) (getFontSize() * getTrackScaleX()), (int) (getFontSize() * getTrackScaleY()), getDisabledStyle().getBgColor(), 255, 2, getTrackDisabledOutlineColor(), Math.max(1, getTrackOffOutlineWidth()));
}
return trackDisabledImage;
}
Expand Down Expand Up @@ -745,15 +798,15 @@ public void paint(Graphics g) {
int innerWidth = getWidth() - padLeft - padRight;
int halign = s.getAlignment(); //TODO: swap left and right if RTL

int thumbrX = 0; //X position of the thumb relative to the start of the track
// In the OFF position Material 3 leaves a small gap between the (smaller)
// thumb and the track's leading edge - switchThumbOffInsetMM (0 by default,
// so iOS/legacy themes are unaffected). The ON position stays flush.
int offInset = getThumbOffInset();
int thumbrX; //X position of the thumb relative to the start of the track
if (isRTL()) {
if (!value) {
thumbrX = cTrackImage.getWidth() - cthumbImage.getWidth();
}
thumbrX = value ? 0 : (cTrackImage.getWidth() - cthumbImage.getWidth() - offInset);
} else {
if (value) {
thumbrX = cTrackImage.getWidth() - cthumbImage.getWidth();
}
thumbrX = value ? (cTrackImage.getWidth() - cthumbImage.getWidth()) : offInset;
}

Image nextThumbImage = null;
Expand Down
Loading
Loading