diff --git a/.github/workflows/scripts-fidelity.yml b/.github/workflows/scripts-fidelity.yml new file mode 100644 index 0000000000..7a30ba4913 --- /dev/null +++ b/.github/workflows/scripts-fidelity.yml @@ -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 diff --git a/CodenameOne/src/com/codename1/components/FloatingActionButton.java b/CodenameOne/src/com/codename1/components/FloatingActionButton.java index 53e58c814e..820a2c0ff9 100644 --- a/CodenameOne/src/com/codename1/components/FloatingActionButton.java +++ b/CodenameOne/src/com/codename1/components/FloatingActionButton.java @@ -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)); @@ -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(); diff --git a/CodenameOne/src/com/codename1/components/Switch.java b/CodenameOne/src/com/codename1/components/Switch.java index b7116bf4d7..03a197e066 100644 --- a/CodenameOne/src/com/codename1/components/Switch.java +++ b/CodenameOne/src/com/codename1/components/Switch.java @@ -281,43 +281,53 @@ public Switch(String uiid) { } private static Image createRoundThumbImage(Component context, int pxDim, int color, int shadowSpread, int thumbInset) { - Image img = ImageFactory.createImage(context, pxDim + 2 * shadowSpread, pxDim + 2 * shadowSpread, 0x0); + // switchThumbWidthScale (>1) stretches the thumb horizontally into an + // elongated pill (the iOS knob is a touch wider than tall); default 1.0 + // keeps the circular Material thumb. + float widthScale = 1.0f; + try { + widthScale = Float.parseFloat(UIManager.getInstance().getThemeConstant( + "switchThumbWidthScale", "1.0")); + } catch (NumberFormatException malformed) { + widthScale = 1.0f; // bad constant -> circular thumb + } + int baseH = Math.max(1, pxDim - 2 * thumbInset); + int baseW = Math.max(baseH, Math.round(baseH * widthScale)); + int imgW = baseW + 2 * (shadowSpread + thumbInset); + int imgH = pxDim + 2 * shadowSpread; + Image img = ImageFactory.createImage(context, imgW, imgH, 0x0); Graphics g = img.getGraphics(); g.setAntiAliased(true); int shadowOpacity = 200; float shadowBlur = 10; + int arc = baseH; if (shadowSpread > 0) { - // draw a gradient of sort for the shadow + // soft drop shadow tracing the pill body for (int iter = shadowSpread - 1; iter >= 0; iter--) { g.translate(iter, iter); g.setColor(0); int alpha = g.concatenateAlpha(shadowOpacity / shadowSpread); - g.fillArc( + g.fillRoundRect( Math.max(1, thumbInset + shadowSpread + shadowSpread / 2 - iter), Math.max(1, thumbInset + 2 * shadowSpread - iter), - Math.max(1, pxDim - (iter * 2) - 2 * thumbInset), - Math.max(1, pxDim - (iter * 2) - 2 * thumbInset), 0, 360); + Math.max(1, baseW - (iter * 2)), + Math.max(1, baseH - (iter * 2)), arc, arc); g.setAlpha(alpha); g.translate(-iter, -iter); } if (Display.getInstance().isGaussianBlurSupported()) { Image blured = Display.getInstance().gaussianBlurImage(img, shadowBlur / 2); - //img = Image.createImage(pxDim+2*shadowSpread, pxDim+2*shadowSpread, 0); img = blured; g = img.getGraphics(); - //g.drawImage(blured, 0, 0); g.setAntiAliased(true); } } - //g.translate(shadowSpread, shadowSpread); int alpha = g.concatenateAlpha(255); g.setColor(color); - g.fillArc(shadowSpread + thumbInset, shadowSpread + thumbInset, Math.max(1, pxDim - 2 * thumbInset), Math.max(1, pxDim - 2 * thumbInset), 0, 360); - //g.setColor(outlinecolor); - //g.drawArc(shadowSize, shadowSize, pxDim-1, pxDim-1, 0, 360); + g.fillRoundRect(shadowSpread + thumbInset, shadowSpread + thumbInset, baseW, baseH, arc, arc); g.setAlpha(alpha); return img; } @@ -378,6 +388,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; @@ -399,7 +412,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; } @@ -420,7 +433,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; } @@ -466,6 +479,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")); @@ -518,7 +576,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; } @@ -745,15 +808,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; diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 692b720cc4..7ea12bd582 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -3951,6 +3951,15 @@ public Object deriveTrueTypeFont(Object font, float size, int weight) { throw new RuntimeException("Unsupported operation"); } + /// Returns a variant of the given native TrueType font with the supplied letter + /// spacing (in EM units) applied to its glyph advances, or the same font when the + /// platform does not support letter spacing. Used by Style.letterSpacing so a + /// per-component spacing is carried by the font itself (consistent for layout + /// measurement and rendering). The default is a no-op. + public Object deriveTrueTypeFontWithLetterSpacing(Object font, float letterSpacing) { + return font; + } + /// Returns true if the system supports dynamically loading truetype fonts from /// a file. /// diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index e1c5300d37..36b24a49b5 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -3019,6 +3019,19 @@ private void paintInternalImpl(Graphics g, boolean paintIntersects) { void internalPaintImpl(Graphics g, boolean paintIntersects) { g.clipRect(getX(), getY(), getWidth(), getHeight()); + // CSS backdrop-filter:blur() -- the "liquid glass" effect. Blur whatever has + // already been painted behind this component (the clip confines it to our + // bounds) BEFORE our own translucent background and content paint on top. This + // runs regardless of opacity, since a glass surface is by definition + // translucent (opaque would be false and skip paintComponentBackground). The + // port blurs the destination region in place; an unsupported port returns + // false and the component simply paints without the blur. + // NOTE: this blurs on every paint -- fine for the static themed bars that use + // it today; a future optimisation could cache the blurred backdrop. + float backdropBlur = getStyle().getBackdropFilterBlurRadius(); + if (backdropBlur > 0) { + g.blurRegion(getX(), getY(), getWidth(), getHeight(), backdropBlur); + } paintComponentBackground(g); if (isScrollable()) { diff --git a/CodenameOne/src/com/codename1/ui/Dialog.java b/CodenameOne/src/com/codename1/ui/Dialog.java index c5e3b54fe5..1119abeea6 100644 --- a/CodenameOne/src/com/codename1/ui/Dialog.java +++ b/CodenameOne/src/com/codename1/ui/Dialog.java @@ -855,8 +855,19 @@ private void initImpl(String dialogUIID, String dialogTitleUIID, Layout lm) { dialogContentPane.setUIID("DialogContentPane"); dialogTitle = new Label("", dialogTitleUIID); super.getContentPane().setLayout(new BorderLayout()); - super.getContentPane().addComponent(BorderLayout.NORTH, dialogTitle); - super.getContentPane().addComponent(BorderLayout.CENTER, dialogContentPane); + // Liquid-glass / iOS alert layout: the title sits as the prominent centred + // text and the body (content pane) drops BELOW it, rather than the title being + // a top bar with the body filling the centre. Opt in via dialogTitleCenterBool + // so existing dialogs are unaffected. The title Label is centred; an app that + // needs a wrapping multi-line title can supply one via setTitleComponent. + if (UIManager.getInstance().isThemeConstant("dialogTitleCenterBool", false)) { + dialogTitle.getAllStyles().setAlignment(Component.CENTER); + super.getContentPane().addComponent(BorderLayout.CENTER, dialogTitle); + super.getContentPane().addComponent(BorderLayout.SOUTH, dialogContentPane); + } else { + super.getContentPane().addComponent(BorderLayout.NORTH, dialogTitle); + super.getContentPane().addComponent(BorderLayout.CENTER, dialogContentPane); + } super.getContentPane().setScrollable(false); super.getContentPane().setAlwaysTensile(false); diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java index 448da975a2..4b640ceff6 100644 --- a/CodenameOne/src/com/codename1/ui/Font.java +++ b/CodenameOne/src/com/codename1/ui/Font.java @@ -153,6 +153,14 @@ public class Font extends CN { private static final Hashtable bitmapCache = new Hashtable(); private static final HashMap derivedFontCache = new HashMap(); + + /// Clears the cache of derived TrueType fonts. Called when the theme changes so + /// that fonts whose platform rendering depends on theme constants (e.g. a native + /// theme's text letter spacing) are re-derived against the freshly-installed + /// constants instead of returning a stale pre-theme paint. + public static void clearDerivedFontCache() { + derivedFontCache.clear(); + } private static Font defaultFont = new Font(null); private static boolean enableBitmapFont = true; private static float fontReturnedHeight; @@ -522,6 +530,25 @@ public Font derive(float size, int weight, byte unitType) { return derive(Display.getInstance().convertToPixels(size, unitType), weight); } + /// Returns a variant of this truetype font with the given letter spacing (EM + /// units) applied to its glyph advances. Used by Style.letterSpacing so a + /// per-UIID spacing is baked into the font that measures and renders the text. + /// Returns this font unchanged when it is not a truetype font or the platform + /// does not support letter spacing. + public Font deriveLetterSpacing(float letterSpacing) { + if (font == null) { + return this; + } + Font f = new Font(Display.impl.deriveTrueTypeFontWithLetterSpacing(font, letterSpacing)); + f.pixelSize = pixelSize; + // Give letter-spacing variants a distinct cache id so a later derive(size) + // does not collide with the base (no-spacing) font in derivedFontCache and + // return a font without the spacing. + f.fontUniqueId = fontUniqueId == null ? null : (fontUniqueId + "_ls" + letterSpacing); + f.ttf = true; + return f; + } + /// Creates a font based on this truetype font with the given pixel, **WARNING**! This method /// will only work in the case of truetype fonts! /// @@ -788,7 +815,17 @@ public boolean equals(Object o) { if (font == null) { return f.font == null; } - return font.equals(f.font); + if (!font.equals(f.font)) { + return false; + } + // Letter-spacing (and similar) variants share their native font + // attributes with the base font but carry a distinct fontUniqueId. + // Treat differing ids as different fonts so Style.setFont does not + // skip a spacing variant as a no-op (native equals ignores spacing). + if (fontUniqueId != null && f.fontUniqueId != null) { + return fontUniqueId.equals(f.fontUniqueId); + } + return true; } if (f.getClass() != getClass()) { return false; diff --git a/CodenameOne/src/com/codename1/ui/Slider.java b/CodenameOne/src/com/codename1/ui/Slider.java index 46d843e010..3df609920b 100644 --- a/CodenameOne/src/com/codename1/ui/Slider.java +++ b/CodenameOne/src/com/codename1/ui/Slider.java @@ -432,6 +432,17 @@ protected Dimension calcPreferredSize() { /// Paint the progress indicator @Override public void paintComponentBackground(Graphics g) { + // Opt-in native-slider look (Material 3): a thin rounded track with the + // active portion in the accent colour and a vertical bar thumb, instead of + // the legacy full-height fill. Gated on the sliderTrackThicknessMM theme + // constant AND isEditable() so progress bars (non-editable sliders) and + // every theme that doesn't set the constant keep the existing rendering. + if (!infinite && !vertical && isEditable()) { + String trackMM = getUIManager().getThemeConstant("sliderTrackThicknessMM", null); + if (trackMM != null && paintNativeSlider(g, trackMM)) { + return; + } + } super.paintComponentBackground(g); int clipX = g.getClipX(); int clipY = g.getClipY(); @@ -483,6 +494,151 @@ public void paintComponentBackground(Graphics g) { } } + /// Paints the Material-3 style slider: a thin rounded track (inactive colour + /// from the Slider style, active colour from the SliderFull style) plus a + /// vertical rounded bar thumb at the current value. Returns false (so the + /// caller falls back to the legacy painter) when the track constant is + /// malformed or non-positive. Horizontal, finite, editable sliders only. + private boolean paintNativeSlider(Graphics g, String trackMM) { + float tmm; + try { + tmm = Float.parseFloat(trackMM.trim()); + } catch (NumberFormatException notANumber) { + return false; + } + if (tmm <= 0) { + return false; + } + Display d = Display.getInstance(); + int track = Math.max(2, d.convertToPixels(tmm)); + String thumbC = getUIManager().getThemeConstant("sliderThumbWidthMM", null); + int thumbW = Math.max(3, track); + if (thumbC != null) { + try { + thumbW = Math.max(3, d.convertToPixels(Float.parseFloat(thumbC.trim()))); + } catch (NumberFormatException notANumber) { + thumbW = Math.max(3, track); // malformed constant -> track-derived default + } + } + int x0 = getX(); + int y0 = getY(); + int w = getWidth(); + int h = getHeight(); + int range = maxValue - minValue; + int valueW = range <= 0 ? 0 : (int) (((float) (value - minValue) / (float) range) * w); + int bandY = y0 + (h - track) / 2; + int trackColor; + int fullColor; + // The thumb takes the Slider style's foreground colour so a native theme can + // give it a distinct tone (Material 3 renders the bar thumb neutral-grey, + // not the accent of the active track). + int thumbColor; + if (isEnabled()) { + trackColor = getSliderEmptyUnselectedStyle().getBgColor(); + fullColor = getSliderFullSelectedStyle().getBgColor(); + thumbColor = getSliderEmptyUnselectedStyle().getFgColor(); + } else { + // A disabled M3 slider greys EVERY part - the active track is never + // the accent (which would be a bright purple in dark mode). Pull the + // greyed tones from the *.disabled styles so light and dark each match. + UIManager uim = getUIManager(); + Style sdis = uim.getComponentCustomStyle(getUIID(), "dis"); + Style fdis = uim.getComponentCustomStyle(getUIID() + "Full", "dis"); + trackColor = sdis.getBgColor(); + fullColor = fdis.getBgColor(); + thumbColor = sdis.getFgColor(); + } + boolean aa = g.isAntiAliasingSupported(); + boolean priorAa = g.isAntiAliased(); + if (aa) { + g.setAntiAliased(true); + } + int thumbX = Math.max(x0, Math.min(x0 + w - thumbW, x0 + valueW - thumbW / 2)); + // Thumb height: Material's bar thumb spans the component height (a tall pill); + // iOS 26's slider thumb is a short horizontal capsule (wider than tall). Themes + // opt into the iOS look via sliderThumbHeightMM; without it the Material + // full-height pill is preserved. + int thumbH = Math.max(track, h); + String thumbHC = getUIManager().getThemeConstant("sliderThumbHeightMM", null); + if (thumbHC != null) { + try { + thumbH = Math.max(track, d.convertToPixels(Float.parseFloat(thumbHC.trim()))); + } catch (NumberFormatException notANumber) { + thumbH = Math.max(track, h); // malformed constant -> full-height default + } + } + int thumbY = y0 + (h - thumbH) / 2; + // iOS renders ONE continuous track under the thumb (no M3 gap, no stop + // indicator); themes opt in via sliderContinuousTrackBool. + boolean continuousTrack = getUIManager().isThemeConstant("sliderContinuousTrackBool", false); + if (continuousTrack) { + g.setColor(trackColor); + g.fillRoundRect(x0, bandY, w, track, track, track); + if (valueW > 0) { + g.setColor(fullColor); + g.fillRoundRect(x0, bandY, Math.min(w, valueW), track, track, track); + } + } else { + // Material 3: TWO rounded segments with a gap on each side of the thumb; + // each is a full pill on its OUTER end, subtly rounded on the inner end. + int gap = Math.max(2, d.convertToPixels(0.6f)); + int innerArc = Math.max(2, d.convertToPixels(0.35f)); + int inStart = Math.min(x0 + w, thumbX + thumbW + gap); + int inW = x0 + w - inStart; + if (inW > 0) { + g.setColor(trackColor); + g.fillRoundRect(inStart, bandY, inW, track, track, track); + if (inW >= track) { + g.fillRoundRect(inStart, bandY, track, track, innerArc, innerArc); + } + } + int acW = Math.max(0, (thumbX - gap) - x0); + if (acW > 0) { + g.setColor(fullColor); + g.fillRoundRect(x0, bandY, acW, track, track, track); + if (acW >= track) { + g.fillRoundRect(x0 + acW - track, bandY, track, track, innerArc, innerArc); + } + } + // M3 "stop indicator": a small dot near the inactive (far) end. + int dotD = Math.max(3, track / 6); + g.setColor(fullColor); + g.fillArc(x0 + w - track / 2 - dotD / 2, y0 + (h - dotD) / 2, dotD, dotD, 0, 360); + } + // Optional soft drop-shadow under the thumb (the iOS knob casts one; without + // it a white knob is invisible on a light background). sliderThumbShadowSizeMM + // sets the spread; a few concentric low-alpha rings approximate a soft blur. + String shadowC = getUIManager().getThemeConstant("sliderThumbShadowSizeMM", null); + int shadow = 0; + if (shadowC != null) { + try { + shadow = d.convertToPixels(Float.parseFloat(shadowC.trim())); + } catch (NumberFormatException notANumber) { + shadow = 0; // malformed constant -> no shadow + } + } + if (shadow > 0) { + int drop = Math.max(1, shadow / 2); + for (int i = shadow; i >= 1; i--) { + g.setColor(0x000000); + int oldAlpha = g.concatenateAlpha(10); + int sArc = Math.min(thumbW, thumbH) + 2 * i; + g.fillRoundRect(thumbX - i, thumbY - i + drop, thumbW + 2 * i, thumbH + 2 * i, + sArc, sArc); + g.setAlpha(oldAlpha); + } + } + g.setColor(thumbColor); + // Use the smaller dimension as the corner arc so the knob is a true capsule + // whether it is taller than wide (Material) or wider than tall (iOS). + int thumbArc = Math.min(thumbW, thumbH); + g.fillRoundRect(thumbX, thumbY, thumbW, thumbH, thumbArc, thumbArc); + if (aa) { + g.setAntiAliased(priorAa); + } + return true; + } + /// Indicates the slider is vertical /// /// #### Returns diff --git a/CodenameOne/src/com/codename1/ui/Tabs.java b/CodenameOne/src/com/codename1/ui/Tabs.java index db88706f69..62bbe0b818 100644 --- a/CodenameOne/src/com/codename1/ui/Tabs.java +++ b/CodenameOne/src/com/codename1/ui/Tabs.java @@ -182,6 +182,7 @@ public Tabs(int tabP) { @Override public void paint(Graphics g) { super.paint(g); + paintBottomDivider(g); paintAnimatedIndicator(g); } }; @@ -1406,6 +1407,39 @@ private void startIndicatorAnimation(int fromIndex, int toIndex) { } } + /// Material 3 tab strips carry a full-width hairline divider along the bottom + /// edge of the tab row (the surfaceVariant outline separating the bar from the + /// content below). A CSS `border-bottom` cannot be relied on here -- the tab + /// row is a custom Container whose painting path does not surface the underline + /// border -- so themes opt in via the `tabsBottomDividerBool` constant and we + /// paint it directly. The colour comes from the `TabsDivider` UIID's background + /// (so it tracks light/dark automatically, like `TabIndicator`); + /// `tabsBottomDividerThicknessMm` (default 0.15mm) sets the line weight. + void paintBottomDivider(Graphics g) { + if (!getUIManager().isThemeConstant("tabsBottomDividerBool", false)) { + return; + } + int color = getUIManager().getComponentStyle("TabsDivider").getBgColor(); + float thickMm = 0.15f; + try { + thickMm = Float.parseFloat(getUIManager().getThemeConstant("tabsBottomDividerThicknessMm", "0.15")); + } catch (NumberFormatException ignore) { + // malformed constant -> keep the 0.15mm default + } + int thickness = Display.getInstance().convertToPixels(thickMm); + if (thickness < 1) { + thickness = 1; + } + int oldColor = g.getColor(); + int oldAlpha = g.getAlpha(); + g.setColor(color); + g.setAlpha(255); + int y = tabsContainer.getY() + tabsContainer.getHeight() - thickness; + g.fillRect(tabsContainer.getX(), y, tabsContainer.getWidth(), thickness); + g.setColor(oldColor); + g.setAlpha(oldAlpha); + } + /// Draws the animated indicator inside `tabsContainer`'s paint flow. Called /// from the inner `Container` subclass installed as `tabsContainer`. void paintAnimatedIndicator(Graphics g) { @@ -1424,7 +1458,18 @@ void paintAnimatedIndicator(Graphics g) { x = active.getX(); w = active.getWidth(); } - int thicknessMm = getUIManager().getThemeConstant("tabsAnimatedIndicatorThicknessMm", animatedIndicatorThicknessMm); + // Read as a FLOAT mm value: the Material 3 indicator is ~0.45mm, but an + // int read truncates fractional millimetres (and a non-integer constant + // like "0.45" fails int parsing, silently falling back to the 1mm default + // -- a ~2x-too-thick indicator). Parse the string form so sub-mm + // thicknesses survive. + float thicknessMm = animatedIndicatorThicknessMm; + try { + thicknessMm = Float.parseFloat(getUIManager().getThemeConstant( + "tabsAnimatedIndicatorThicknessMm", String.valueOf(animatedIndicatorThicknessMm))); + } catch (NumberFormatException ignore) { + // malformed constant -> keep the default indicator thickness + } int thickness = Display.getInstance().convertToPixels(thicknessMm); // Use TabIndicator UIID color when its fg is set; otherwise pull // from the selected tab's foreground. `getComponentStyle(...)` @@ -1444,7 +1489,32 @@ void paintAnimatedIndicator(Graphics g) { g.setColor(color); g.setAlpha(255); int y = tabsContainer.getInnerY() + tabsContainer.getInnerHeight() - thickness; - g.fillRect(tabsContainer.getInnerX() + x, y, w, thickness); + // Material 3 draws the active indicator as a SHORT rounded pill matching the + // selected tab's LABEL width (not the full tab cell). Opt in with + // tabsIndicatorPillBool; legacy themes keep the full-width square line. + int indX = tabsContainer.getInnerX() + x; + int indW = w; + boolean pill = getUIManager().isThemeConstant("tabsIndicatorPillBool", false); + if (pill) { + Component active = tabsContainer.getComponentAt(activeComponent); + if (active instanceof Button) { + Button ab = (Button) active; + // stringWidth is the glyph ADVANCE, a few px wider than the visible + // ink; Material's indicator matches the ink width, so trim a hair. + int textW = ab.getStyle().getFont().stringWidth(ab.getText()) + - Display.getInstance().convertToPixels(0.45f); + if (textW > 0 && textW < w) { + indW = textW; + indX = tabsContainer.getInnerX() + x + (w - indW) / 2; + } + } + boolean priorAa = g.isAntiAliased(); + g.setAntiAliased(true); + g.fillRoundRect(indX, y, indW, thickness, thickness, thickness); + g.setAntiAliased(priorAa); + } else { + g.fillRect(indX, y, indW, thickness); + } g.setColor(oldColor); g.setAlpha(oldAlpha); } diff --git a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java index bb766668d1..88f77bcb4e 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java +++ b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java @@ -1272,7 +1272,6 @@ public Dimension getTextAreaSize(TextArea ta, boolean pref) { prefW = Math.max(style.getBgImage().getWidth(), prefW); prefH = Math.max(style.getBgImage().getHeight(), prefH); } - return new Dimension(prefW, prefH); } @@ -2594,6 +2593,21 @@ public void refreshTheme(boolean b) { updateRadioButtonConstants(m, true, "Focus"); } + /// Builds a check-box / radio glyph. When sizeMM is non-null it sizes the box + /// in millimetres (decoupled from the label font, so a native theme's box can + /// be larger than its text); otherwise the box is sized to the style font + /// height (legacy behaviour, leaving existing themes untouched). + private static FontImage stateIcon(char icon, Style s, String sizeMM) { + if (sizeMM != null) { + try { + return FontImage.createMaterial(icon, s, Float.parseFloat(sizeMM.trim())); + } catch (NumberFormatException ignore) { + // malformed constant -> fall back to font-height sizing + } + } + return FontImage.createMaterial(icon, s); + } + private void updateCheckBoxConstants(UIManager m, boolean focus, String append) { Image checkSel = m.getThemeImageConstant("checkBoxChecked" + append + "Image"); if (checkSel != null) { @@ -2637,15 +2651,47 @@ private void updateCheckBoxConstants(UIManager m, boolean focus, String append) "checkBoxCheckedIconInt", FontImage.MATERIAL_CHECK_BOX); char uncheckedIcon = (char) uim.getThemeConstant( "checkBoxUncheckedIconInt", FontImage.MATERIAL_CHECK_BOX_OUTLINE_BLANK); - FontImage checkedDis = FontImage.createMaterial(checkedIcon, dis); - FontImage uncheckedDis = FontImage.createMaterial(uncheckedIcon, sel); + // Optional explicit box size in mm. Without it the box is sized to the + // label font height (legacy). A native theme whose box is larger than + // its text (Material's 18dp box vs 14sp label) sets checkBoxIconSizeMM + // so the box matches the native control independent of the text. + String iconMM = uim.getThemeConstant("checkBoxIconSizeMM", null); + // Material's UNCHECKED box outline is on-surface-variant (a mid grey), + // distinct from the on-surface label colour. A theme names a UIID via + // checkBoxUncheckedColorUIID whose fg supplies that colour; createStyle + // resolves its dark ($Dark) variant too. Themes that do not set it keep + // the legacy behaviour (box == label colour). + Style uncheckedBox = unsel; + String boxUIID = uim.getThemeConstant("checkBoxUncheckedColorUIID", null); + if (boxUIID != null) { + uncheckedBox = uim.createStyle(boxUIID + ".", "", false); + } + FontImage checkedDis = stateIcon(checkedIcon, dis, iconMM); + // Disabled-unchecked uses the disabled style (greyed), NOT the + // selected style - otherwise a disabled, unchecked box/circle is + // tinted with the accent colour (very visible in dark mode where + // the accent is a bright purple), which does not match native. + // When a theme names an unchecked-colour UIID, the disabled box draws + // from that UIID's OWN disabled variant rather than the disabled label + // style, so the greyed box outline can differ from the (darker) disabled + // label text - which is what Material renders. + Style uncheckedBoxDis = dis; + if (boxUIID != null) { + uncheckedBoxDis = uim.createStyle(boxUIID + ".", "dis#", false); + } + FontImage uncheckedDis = stateIcon(uncheckedIcon, uncheckedBoxDis, iconMM); if (focus) { - FontImage checkedSelected = FontImage.createMaterial(checkedIcon, sel); - FontImage uncheckedSelected = FontImage.createMaterial(uncheckedIcon, sel); + FontImage checkedSelected = stateIcon(checkedIcon, sel, iconMM); + FontImage uncheckedSelected = stateIcon(uncheckedIcon, sel, iconMM); setCheckBoxFocusImages(checkedSelected, uncheckedSelected, checkedDis, uncheckedDis); } else { - FontImage checkedUnselected = FontImage.createMaterial(checkedIcon, unsel); - FontImage uncheckedUnselected = FontImage.createMaterial(uncheckedIcon, unsel); + // The checked glyph reflects the selected style so a theme that + // defines a distinct CheckBox.selected colour (e.g. a native + // Material/iOS theme using its accent) tints the checked box with + // it. Themes whose selected colour equals the unselected colour + // are unaffected. + FontImage checkedUnselected = stateIcon(checkedIcon, sel, iconMM); + FontImage uncheckedUnselected = stateIcon(uncheckedIcon, uncheckedBox, iconMM); setCheckBoxImages(checkedUnselected, uncheckedUnselected, checkedDis, uncheckedDis); } } @@ -2684,15 +2730,40 @@ private void updateRadioButtonConstants(UIManager m, boolean focus, String appen "radioCheckedIconInt", FontImage.MATERIAL_RADIO_BUTTON_CHECKED); char uncheckedIcon = (char) uim.getThemeConstant( "radioUncheckedIconInt", FontImage.MATERIAL_RADIO_BUTTON_UNCHECKED); - FontImage checkedDis = FontImage.createMaterial(checkedIcon, dis); - FontImage uncheckedDis = FontImage.createMaterial(uncheckedIcon, sel); + // See updateCheckBoxConstants: optional explicit circle size in mm, + // decoupled from the label font for native-matching geometry. + String iconMM = uim.getThemeConstant("radioIconSizeMM", null); + // See updateCheckBoxConstants: Material's unchecked circle is the mid-grey + // on-surface-variant, not the on-surface label colour. radioUncheckedColorUIID + // names a UIID whose fg supplies it (dark-resolved); unset keeps legacy. + Style uncheckedBox = unsel; + String boxUIID = uim.getThemeConstant("radioUncheckedColorUIID", null); + if (boxUIID != null) { + uncheckedBox = uim.createStyle(boxUIID + ".", "", false); + } + FontImage checkedDis = stateIcon(checkedIcon, dis, iconMM); + // Disabled-unchecked uses the disabled style (greyed), NOT the + // selected style - otherwise a disabled, unchecked box/circle is + // tinted with the accent colour (very visible in dark mode where + // the accent is a bright purple), which does not match native. + // When a theme names an unchecked-colour UIID, the disabled box draws + // from that UIID's OWN disabled variant rather than the disabled label + // style, so the greyed box outline can differ from the (darker) disabled + // label text - which is what Material renders. + Style uncheckedBoxDis = dis; + if (boxUIID != null) { + uncheckedBoxDis = uim.createStyle(boxUIID + ".", "dis#", false); + } + FontImage uncheckedDis = stateIcon(uncheckedIcon, uncheckedBoxDis, iconMM); if (focus) { - FontImage checkedSelected = FontImage.createMaterial(checkedIcon, sel); - FontImage uncheckedSelected = FontImage.createMaterial(uncheckedIcon, sel); + FontImage checkedSelected = stateIcon(checkedIcon, sel, iconMM); + FontImage uncheckedSelected = stateIcon(uncheckedIcon, sel, iconMM); setRadioButtonFocusImages(checkedSelected, uncheckedSelected, checkedDis, uncheckedDis); } else { - FontImage checkedUnselected = FontImage.createMaterial(checkedIcon, unsel); - FontImage uncheckedUnselected = FontImage.createMaterial(uncheckedIcon, unsel); + // See updateCheckBoxConstants: the checked glyph reflects the + // selected style so native themes tint it with their accent. + FontImage checkedUnselected = stateIcon(checkedIcon, sel, iconMM); + FontImage uncheckedUnselected = stateIcon(uncheckedIcon, uncheckedBox, iconMM); setRadioButtonImages(checkedUnselected, uncheckedUnselected, checkedDis, uncheckedDis); } } diff --git a/CodenameOne/src/com/codename1/ui/plaf/Style.java b/CodenameOne/src/com/codename1/ui/plaf/Style.java index 0894efdeb6..47e48eb486 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/Style.java +++ b/CodenameOne/src/com/codename1/ui/plaf/Style.java @@ -116,6 +116,8 @@ public class Style { /// #### Since /// /// 8.0 + public static final String LETTER_SPACING = "letterSpacing"; + public static final String ICON_GAP = "iconGap"; /// Icon gap unit attribute. /// @@ -291,6 +293,7 @@ public class Style { private static final int BACKDROP_FILTER_BLUR_MODIFIED = 4194304; private static final int FILTER_COLOR_MATRIX_MODIFIED = 8388608; private static final int BACKDROP_FILTER_COLOR_MATRIX_MODIFIED = 16777216; + private static final int LETTER_SPACING_MODIFIED = 33554432; float[] padding = new float[4]; float[] margin = new float[4]; /// Indicates the units used for padding elements, if null pixels are used if not this is a 4 element array containing values @@ -320,6 +323,7 @@ public class Style { private int elevation; // the elevation. private float iconGap = -1; private byte iconGapUnit; + private float letterSpacing; // EM units, 0 = default (no extra spacing) private boolean surface; // whether this should be treated as a surface private byte backgroundType = BACKGROUND_IMAGE_SCALED; private byte backgroundAlignment = BACKGROUND_IMAGE_ALIGN_TOP; @@ -372,6 +376,7 @@ public Style(Style style) { elevation = style.elevation; iconGap = style.iconGap; iconGapUnit = style.iconGapUnit; + letterSpacing = style.letterSpacing; surface = style.surface; opacity = style.opacity; modifiedFlag = 0; @@ -583,6 +588,9 @@ public void merge(Style style) { if ((modifiedFlag & ICON_GAP_MODIFIED) == 0) { setIconGap(style.iconGap, style.iconGapUnit); } + if ((modifiedFlag & LETTER_SPACING_MODIFIED) == 0) { + setLetterSpacing(style.letterSpacing); + } if ((modifiedFlag & SURFACE_MODIFIED) == 0) { setSurface(style.isSurface()); } @@ -629,6 +637,37 @@ public int getIconGap() { return CN.convertToPixels(iconGap, iconGapUnit); } + /// Returns the letter spacing (in EM units, font-size relative) applied to text + /// drawn with this style. 0 means the platform default (no extra spacing). + public float getLetterSpacing() { + return letterSpacing; + } + + /// Sets the letter spacing in EM units (font-size relative). A native theme uses + /// this to match a Material/iOS text appearance's tracking per component. The + /// spacing is carried by the style's font so it affects both layout measurement + /// and rendering. + public void setLetterSpacing(float letterSpacing) { + setLetterSpacing(letterSpacing, false); + } + + /// Sets the letter spacing in EM units. See [#setLetterSpacing(float)]. + public void setLetterSpacing(float spacing, boolean override) { + if (proxyTo != null) { + for (Style s : proxyTo) { + s.setLetterSpacing(spacing, override); + } + return; + } + if (Math.abs(spacing - letterSpacing) > 0.00001) { + letterSpacing = spacing; + if (!override) { + modifiedFlag |= LETTER_SPACING_MODIFIED; + } + firePropertyChanged(LETTER_SPACING); + } + } + /// Sets the icon gap in the current units. /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index 688a4e735b..97aa13c46d 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -1830,6 +1830,11 @@ private void breakTitleAreaToolbarDeriveCycle() { } private void buildTheme(Hashtable themeProps) { + // A new theme may change constants that the platform consults while deriving + // fonts (e.g. a native theme's text letter spacing). Flush the derived-font + // cache so those fonts are rebuilt against the incoming constants rather than + // returning a paint derived under the previous (or no) theme. + Font.clearDerivedFontCache(); String con = (String) themeProps.get("@includeNativeBool"); if (con != null && "true".equalsIgnoreCase(con) && Display.getInstance().hasNativeTheme()) { boolean a = accessible; @@ -2473,6 +2478,16 @@ private Style createStyle(String id, String prefix, boolean selected, boolean al style.setFont((Font) font); } } + if (themeProps.containsKey(id + Style.LETTER_SPACING)) { + float ls = ((Number) themeProps.get(id + Style.LETTER_SPACING)).floatValue(); + style.setLetterSpacing(ls); + // Bake the spacing into the style's font so it is applied + // consistently for both text measurement and rendering. + Font lsFont = style.getFont(); + if (ls != 0 && lsFont != null) { + style.setFont(lsFont.deriveLetterSpacing(ls)); + } + } if (border != null) { style.setBorder((Border) border); } diff --git a/CodenameOne/src/com/codename1/ui/spinner/SpinnerRenderer.java b/CodenameOne/src/com/codename1/ui/spinner/SpinnerRenderer.java index 88fdf96e72..267af837b1 100644 --- a/CodenameOne/src/com/codename1/ui/spinner/SpinnerRenderer.java +++ b/CodenameOne/src/com/codename1/ui/spinner/SpinnerRenderer.java @@ -101,7 +101,19 @@ public void paint(Graphics g) { return; } Style s = getStyle(); - drawStringPerspectivePosition(g, getText(), getX() + s.getPaddingLeftNoRTL(), getY() + s.getPaddingTop()); + // Centre the perspective row horizontally (the native iOS picker centres + // every row; drawing at paddingLeft left-aligned the off-centre rows). The + // perspective scaling is vertical only, so the horizontal width is ~the + // normal glyph run minus the 4px per-gap overlap drawStringPerspective uses. + String text = getText(); + Font f = s.getFont(); + int tw = 0; + for (int i = 0; i < text.length(); i++) { + tw += f.charWidth(text.charAt(i)); + } + tw -= 4 * Math.max(0, text.length() - 1); + int cx = getX() + Math.max(s.getPaddingLeftNoRTL(), (getWidth() - tw) / 2); + drawStringPerspectivePosition(g, text, cx, getY() + s.getPaddingTop()); } } @@ -125,7 +137,12 @@ private int drawCharPerspectivePosition(Graphics g, char c, int x, int y) { i = ImageFactory.createImage(this, w, h, 0); g = i.getGraphics(); UIManager.getInstance().getLookAndFeel().setFG(g, this); - int alpha = g.concatenateAlpha(getStyle().getFgAlpha()); + // Fade rows away from the centre (the native picker dims off-selection rows + // toward grey). Depth 0 = adjacent to the front row .. up to FRONT_ANGLE. + // Baked into the per-perspective glyph cache, so it is computed once. + int depth = Math.abs(perspective - FRONT_ANGLE); + float fade = Math.max(0.35f, 1f - 0.16f * depth); + int alpha = g.concatenateAlpha((int) (getStyle().getFgAlpha() * fade)); g.drawChar(c, 0, 0); g.setAlpha(alpha); i = Effects.verticalPerspective(i, TOP_SCALE[perspective], BOTTOM_SCALE[perspective], VERTICAL_SHRINK[perspective]); diff --git a/CodenameOne/src/com/codename1/ui/util/Resources.java b/CodenameOne/src/com/codename1/ui/util/Resources.java index e75b2438da..04474c131d 100644 --- a/CodenameOne/src/com/codename1/ui/util/Resources.java +++ b/CodenameOne/src/com/codename1/ui/util/Resources.java @@ -1669,6 +1669,11 @@ Hashtable loadTheme(String id, boolean newerVersion) throws IOException { continue; } + if (key.endsWith("letterSpacing")) { + theme.put(key, input.readFloat()); + continue; + } + if (key.endsWith("iconGapUnit")) { theme.put(key, input.readByte()); continue; diff --git a/Ports/Android/src/AndroidMaterialTheme.res b/Ports/Android/src/AndroidMaterialTheme.res new file mode 100644 index 0000000000..9fca6c9449 Binary files /dev/null and b/Ports/Android/src/AndroidMaterialTheme.res differ diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidGraphics.java b/Ports/Android/src/com/codename1/impl/android/AndroidGraphics.java index 0a2b1ae24f..cbed5f03b0 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidGraphics.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidGraphics.java @@ -68,6 +68,10 @@ class AndroidGraphics { protected Canvas canvas; protected Paint paint; + // The Bitmap behind a mutable-image canvas (null for the screen canvas). Lets + // CSS backdrop-filter:blur read/write the destination region directly (absolute + // bitmap coordinates, bypassing the canvas transform). See blurRegion. + Bitmap underlyingBitmap; private boolean isMutableImageGraphics; private CodenameOneTextPaint font; private Transform transform; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index b86ba51da9..02741fcb85 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -2208,6 +2208,20 @@ public int hashCode() { } } + /// Returns a copy of the given native font with its paint's letter spacing set + /// to the supplied value (Android letter spacing is in EM units, independent of + /// font size). Used by Style.letterSpacing so a per-UIID spacing -- matching the + /// Material text-appearance for each component -- is baked into the SAME paint + /// that does both measureText (layout) and drawText (render), keeping advances + /// consistent. Other ports get the default no-op. + @Override + public Object deriveTrueTypeFontWithLetterSpacing(Object font, float letterSpacing) { + NativeFont fnt = (NativeFont) font; + CodenameOneTextPaint copy = new CodenameOneTextPaint((CodenameOneTextPaint) fnt.font); + copy.setLetterSpacing(letterSpacing); + return new NativeFont(fnt.face, fnt.style, fnt.size, copy, fnt.fileName, fnt.height, fnt.weight); + } + @Override public Object deriveTrueTypeFont(Object font, float size, int weight) { NativeFont fnt = (NativeFont)font; @@ -2225,6 +2239,8 @@ public Object deriveTrueTypeFont(Object font, float size, int weight) { CodenameOneTextPaint newPaint = new CodenameOneTextPaint(type); newPaint.setTextSize(size); newPaint.setAntiAlias(true); + // preserve any letter spacing already configured on the source paint + newPaint.setLetterSpacing(paint.getLetterSpacing()); NativeFont n = new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, weight, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fnt.fileName, size, weight); return n; } @@ -2357,6 +2373,7 @@ public Object getNativeGraphics() { @Override public Object getNativeGraphics(Object image) { AndroidGraphics g = new AndroidGraphics(this, new Canvas((Bitmap) image), true); + g.underlyingBitmap = (Bitmap) image; g.setClip(0, 0, ((Bitmap)image).getWidth(), ((Bitmap)image).getHeight()); return g; } @@ -12302,6 +12319,59 @@ public boolean isGaussianBlurSupported() { return (!brokenGaussian) && android.os.Build.VERSION.SDK_INT >= 11; } + @Override + public boolean blurRegion(Object graphics, int x, int y, int width, int height, float radius) { + if (radius <= 0f || width <= 0 || height <= 0 || !isGaussianBlurSupported()) { + return radius <= 0f || width <= 0 || height <= 0; + } + // In-place CSS backdrop-filter:blur on a mutable-image target. Read/write the + // backing Bitmap directly at absolute coordinates (bypassing the canvas + // transform), Gaussian-blur the region via RenderScript. The live screen + // canvas has no backing Bitmap here -> returns false (component paints + // without the blur). + if (!(graphics instanceof AndroidGraphics)) { + return false; + } + Bitmap dest = ((AndroidGraphics) graphics).underlyingBitmap; + if (dest == null || !dest.isMutable()) { + return false; + } + try { + int rx = Math.max(0, x), ry = Math.max(0, y); + int rw = Math.min(width, dest.getWidth() - rx); + int rh = Math.min(height, dest.getHeight() - ry); + if (rw <= 0 || rh <= 0) { + return true; + } + int[] pix = new int[rw * rh]; + dest.getPixels(pix, 0, rw, rx, ry, rw, rh); + Bitmap region = Bitmap.createBitmap(pix, rw, rh, Bitmap.Config.ARGB_8888); + Bitmap blurred = Bitmap.createBitmap(region); + RenderScript rs = RenderScript.create(getContext()); + try { + ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + Allocation tmpIn = Allocation.createFromBitmap(rs, region); + Allocation tmpOut = Allocation.createFromBitmap(rs, blurred); + // RenderScript blur radius is capped at 25. + theIntrinsic.setRadius(Math.min(25f, radius)); + theIntrinsic.setInput(tmpIn); + theIntrinsic.forEach(tmpOut); + tmpOut.copyTo(blurred); + tmpIn.destroy(); + tmpOut.destroy(); + theIntrinsic.destroy(); + } finally { + rs.destroy(); + } + blurred.getPixels(pix, 0, rw, 0, 0, rw, rh); + dest.setPixels(pix, 0, rw, rx, ry, rw, rh); + return true; + } catch (Throwable t) { + brokenGaussian = true; + return false; + } + } + public static boolean checkForPermission(String permission, String description){ return checkForPermission(permission, description, false); } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index e39738eff6..5e1a535de6 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -16120,21 +16120,52 @@ public boolean blurRegion(Object graphics, int x, int y, int width, int height, if (radius <= 0f || width <= 0 || height <= 0) { return true; } - Graphics2D ng = getGraphics(graphics); - // The target buffer the simulator paints into is typically a BufferedImage - // accessible via getDeviceConfiguration().createCompatibleImage during paint. - // For backdrop-filter we snapshot whatever the destination shows under the - // rectangle, blur it, and draw it back. Falling back to false signals the - // caller to use the snapshot+drawImage path instead. + // In-place backdrop-filter:blur. Read the destination region, Gaussian-blur it + // and paint it back. We can do this when the destination is a BufferedImage we + // own: a mutable Image's backing raster (the off-screen tiles / Dialog blur) at + // 1:1, or the simulator's edtBuffer at retinaScale. An unknown raw Graphics2D + // target returns false so the caller skips the blur (component still paints). try { - java.awt.geom.AffineTransform tx = ng.getTransform(); - int sx = (int) Math.round(tx.getTranslateX()) + x; - int sy = (int) Math.round(tx.getTranslateY()) + y; - BufferedImage snap = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - java.awt.GraphicsConfiguration gc = ng.getDeviceConfiguration(); - BufferedImage dest = (gc != null) ? gc.createCompatibleImage(width, height, java.awt.Transparency.TRANSLUCENT) : snap; - // Java2D doesn't easily let us read back from the destination - fall back. - return false; + BufferedImage dest; + double scale; + if (graphics instanceof NativeScreenGraphics) { + NativeScreenGraphics ng = (NativeScreenGraphics) graphics; + if (ng.sourceImage != null) { + dest = ng.sourceImage; // mutable image target (fidelity tiles, blur-to-image) + scale = 1.0; + } else if (canvas != null && canvas.edtBuffer != null) { + dest = canvas.edtBuffer; // simulator screen buffer (rendered at retinaScale) + scale = retinaScale; + } else { + return false; + } + } else { + return false; // raw Graphics2D with no readable backing buffer + } + int rx = (int) Math.round(x * scale); + int ry = (int) Math.round(y * scale); + int rw = (int) Math.round(width * scale); + int rh = (int) Math.round(height * scale); + int dw = dest.getWidth(), dh = dest.getHeight(); + // clamp to the destination bounds + if (rx < 0) { rw += rx; rx = 0; } + if (ry < 0) { rh += ry; ry = 0; } + if (rx + rw > dw) { rw = dw - rx; } + if (ry + rh > dh) { rh = dh - ry; } + if (rw <= 0 || rh <= 0) { + return true; + } + // Copy the region out (a fresh ARGB buffer so the filter does not read and + // write the same shared raster), blur it, draw it back. + BufferedImage src = new BufferedImage(rw, rh, BufferedImage.TYPE_INT_ARGB); + Graphics2D cg = src.createGraphics(); + cg.drawImage(dest.getSubimage(rx, ry, rw, rh), 0, 0, null); + cg.dispose(); + BufferedImage blurred = new GaussianFilter((float) (radius * scale)).filter(src, null); + Graphics2D dg = dest.createGraphics(); + dg.drawImage(blurred, rx, ry, null); + dg.dispose(); + return true; } catch (Throwable t) { return false; } diff --git a/Ports/iOSPort/nativeSources/iOSModernTheme.res b/Ports/iOSPort/nativeSources/iOSModernTheme.res new file mode 100644 index 0000000000..6f8ce31de8 Binary files /dev/null and b/Ports/iOSPort/nativeSources/iOSModernTheme.res differ diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 43c28da701..2a02fe1e21 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1795,6 +1795,44 @@ public Image gaussianBlurImage(Image image, float radius) { return Image.createImage(n); } + @Override + public boolean blurRegion(Object graphics, int x, int y, int width, int height, float radius) { + if (radius <= 0f || width <= 0 || height <= 0) { + return true; + } + NativeGraphics ng = (NativeGraphics) graphics; + // In-place CSS backdrop-filter:blur on a mutable-image target (the off-screen + // fidelity tiles, the Dialog blur-to-image path). The live screen drawable is + // not handled here -> returns false and the component paints without the blur. + if (ng.associatedImage == null) { + return false; + } + // Flush whatever has been painted into the image so its peer is current, read + // the region behind us, Gaussian-blur it (Metal-backed CIGaussianBlur) and draw + // the blurred patch back where it was read. + ng.checkControl(); + ng.associatedImage.peer = finishDrawingOnImage(); + currentlyDrawingOn = null; + NativeImage target = ng.associatedImage; + int rx = Math.max(0, x), ry = Math.max(0, y); + int rw = Math.min(width, target.width - rx), rh = Math.min(height, target.height - ry); + if (rw <= 0 || rh <= 0) { + return true; + } + int[] rgb = new int[rw * rh]; + getRGB(target, rgb, 0, rx, ry, rw, rh); + NativeImage blurred = new NativeImage("backdrop-filter blur"); + blurred.peer = nativeInstance.gausianBlurImage(createImageFromARGB(rgb, rw, rh), radius); + blurred.width = rw; + blurred.height = rh; + // drawImage applies this graphics' transform; pass coordinates relative to that + // transform's translation so the blurred patch lands back where we read it. + int tx = (int) Math.round(ng.transform.getTranslateX()); + int ty = (int) Math.round(ng.transform.getTranslateY()); + drawImage(ng, blurred, rx - tx, ry - ty); + return true; + } + public Object createImage(byte[] bytes, int offset, int len) { int[] wh = widthHeight; diff --git a/Themes/AndroidMaterialTheme.res b/Themes/AndroidMaterialTheme.res index a31a5dd352..9fca6c9449 100644 Binary files a/Themes/AndroidMaterialTheme.res and b/Themes/AndroidMaterialTheme.res differ diff --git a/maven/core-unittests/pmd.xml b/maven/core-unittests/pmd.xml index 6736e93f75..a4ecf17455 100644 --- a/maven/core-unittests/pmd.xml +++ b/maven/core-unittests/pmd.xml @@ -57,6 +57,18 @@ + + + + + + + + + "; + private static final String DEFAULT_TITLE = "Native fidelity report"; + // Aspirational (non-blocking) bar: pairs below this are flagged in the + // backlog as still needing theme work. The hard regression gate lives in + // FidelityGate; this threshold never fails the build on its own. + private static final double ASPIRATIONAL_THRESHOLD = 99.0d; + + public static void main(String[] args) throws Exception { + Arguments arguments = Arguments.parse(args); + if (arguments == null) { + System.exit(2); + return; + } + if (!Files.isRegularFile(arguments.compareJson)) { + System.err.println("Comparison JSON not found: " + arguments.compareJson); + System.exit(1); + } + String text = Files.readString(arguments.compareJson, StandardCharsets.UTF_8); + Map data = JsonUtil.asObject(JsonUtil.parse(text)); + Map baseline = loadBaseline(arguments.baselineJson); + String marker = arguments.marker != null ? arguments.marker : DEFAULT_MARKER; + String title = arguments.title != null ? arguments.title : DEFAULT_TITLE; + + Report report = buildReport(data, baseline, title, marker, arguments.aspirational); + writeLines(arguments.summaryOut, report.summaryLines); + writeLines(arguments.commentOut, report.commentLines); + } + + private static Report buildReport(Map data, Map baseline, + String title, String marker, double aspirational) { + List summaryLines = new ArrayList<>(); + List commentLines = new ArrayList<>(); + List results = JsonUtil.asArray(data.get("results")); + + List rows = new ArrayList<>(); + int compared = 0; + int missing = 0; + int errors = 0; + double fidelitySum = 0.0d; + for (Object item : results) { + Map result = JsonUtil.asObject(item); + String test = stringValue(result.get("test"), "unknown"); + String status = stringValue(result.get("status"), "unknown"); + Map details = JsonUtil.asObject(result.get("details")); + Double fidelity = toDouble(details.get("fidelity_percent")); + Double ssim = toDouble(details.get("ssim")); + Double meanDelta = toDouble(details.get("mean_channel_delta")); + Double base = baseline.get(test); + Double delta = (fidelity != null && base != null) ? (fidelity - base) : null; + + PairRow row = new PairRow(test, status, fidelity, ssim, meanDelta, base, delta, + stringValue(result.get("native_path"), ""), + stringValue(result.get("cn1_path"), ""), + JsonUtil.asObject(result.get("preview")), + JsonUtil.asObject(result.get("native_preview")), + stringValue(result.get("message"), "")); + rows.add(row); + + String message; + switch (status) { + case "compared" -> { + compared++; + if (fidelity != null) { + fidelitySum += fidelity; + } + message = String.format("Fidelity %.2f%% (SSIM %.4f, mean delta %.2f)%s", + nz(fidelity), nz(ssim), nz(meanDelta), deltaSuffix(delta)); + } + case "missing_actual" -> { + missing++; + message = "CN1 render not delivered."; + } + case "missing_expected" -> { + missing++; + message = "Native golden missing (regenerate with FIDELITY_UPDATE_GOLDENS=1)."; + } + case "size_mismatch" -> { + errors++; + message = stringValue(result.get("message"), "CN1 tile size differs from native golden."); + } + case "error" -> { + errors++; + message = "Comparison error: " + stringValue(result.get("message"), "unknown error"); + } + default -> message = "Status: " + status + "."; + } + // Pipe-delimited summary consumed by cn1ss.sh (status|test|message| + // copyFlag|cn1Path|fidelity). copyFlag is always 1 so the CN1 render + // is archived as an artifact regardless of score. + summaryLines.add(String.join("|", List.of( + status, test, message, "1", + stringValue(result.get("cn1_path"), ""), + fidelity != null ? String.format("%.2f", fidelity) : ""))); + } + + double meanFidelity = compared > 0 ? fidelitySum / compared : 0.0d; + + // Compared pairs sorted ascending (worst first) -- the basis for the + // distribution statistics, the per-pair percentage table and the cards. + List comparedRows = new ArrayList<>(); + for (PairRow row : rows) { + if ("compared".equals(row.status) && row.fidelity != null) { + comparedRows.add(row); + } + } + comparedRows.sort(Comparator.comparingDouble(r -> r.fidelity)); + + if (title != null && !title.isEmpty()) { + commentLines.add("### " + title); + commentLines.add(""); + } + + if (compared > 0) { + double median = percentile(comparedRows, 50); + double p25 = percentile(comparedRows, 25); + PairRow worst = comparedRows.get(0); + // Distribution, not just the mean: a single average hides the low + // points, so report where the pairs actually land. + int b99 = 0, b95 = 0, b90 = 0, bLow = 0; + for (PairRow row : comparedRows) { + double f = row.fidelity; + if (f >= 99.0d) { + b99++; + } else if (f >= 95.0d) { + b95++; + } else if (f >= 90.0d) { + b90++; + } else { + bLow++; + } + } + commentLines.add(String.format( + "**%d pairs compared** -- median **%.1f%%**, worst **%.1f%%** (`%s`), 25th pct %.1f%%, mean %.1f%%.", + compared, median, worst.fidelity, worst.test, p25, meanFidelity)); + commentLines.add(""); + commentLines.add(String.format( + "Distribution -- `>=99%%`: **%d** | `95-99%%`: **%d** | `90-95%%`: **%d** | `<90%%`: **%d**%s%s", + b99, b95, b90, bLow, + missing > 0 ? String.format(" | %d not delivered/missing golden", missing) : "", + errors > 0 ? String.format(" | %d error(s)", errors) : "")); + } else { + commentLines.add(String.format("**No pairs could be compared.**%s%s", + missing > 0 ? " " + missing + " not delivered/missing golden." : "", + errors > 0 ? " " + errors + " error(s)." : "")); + } + commentLines.add(""); + + // Per-pair fidelity table (worst first): the percentage data for every + // mismatch, at a glance, without scrolling through the image cards. + if (compared > 0) { + commentLines.add("| Component | State | Appearance | Fidelity | SSIM | mean delta | vs base |"); + commentLines.add("|---|---|---|--:|--:|--:|--:|"); + for (PairRow row : comparedRows) { + String[] p = splitTest(row.test); + commentLines.add(String.format("| %s | %s | %s | %.1f%% | %.3f | %.2f | %s |", + p[0], p[1], p[2], row.fidelity, nz(row.ssim), nz(row.meanDelta), deltaCell(row.delta))); + } + commentLines.add(""); + } + + // Non-compared pairs (errors / not delivered / missing golden) listed + // explicitly so they are never silently dropped from the percentages. + List problemRows = new ArrayList<>(); + for (PairRow row : rows) { + if (!"compared".equals(row.status)) { + problemRows.add(row); + } + } + if (!problemRows.isEmpty()) { + commentLines.add(String.format("**%d pair(s) not scored:**", problemRows.size())); + for (PairRow row : problemRows) { + commentLines.add(String.format("- `%s` -- %s%s", row.test, row.status, + row.message != null && !row.message.isEmpty() ? " (" + row.message + ")" : "")); + } + commentLines.add(""); + } + + // Side-by-side comparison cards, worst first, then the unscored pairs. + commentLines.add("#### Side-by-side comparisons (worst first)"); + commentLines.add(""); + List cardRows = new ArrayList<>(comparedRows); + cardRows.addAll(problemRows); + for (PairRow row : cardRows) { + commentLines.add(detailHeadline(row)); + addPreviewPair(commentLines, row); + commentLines.add(""); + } + + if (marker != null && !marker.isEmpty()) { + commentLines.add(marker); + } + return new Report(summaryLines, commentLines); + } + + private static int rowPriority(PairRow row) { + if (row.delta != null && row.delta < 0) { + return 0; // regressions first + } + switch (row.status) { + case "size_mismatch": + case "error": + case "missing_actual": + case "missing_expected": + return 1; + default: + return 2; + } + } + + private static String detailHeadline(PairRow row) { + switch (row.status) { + case "compared": + return String.format("- **%s** -- %.2f%% fidelity (SSIM %.4f)%s", + row.test, nz(row.fidelity), nz(row.ssim), deltaSuffix(row.delta)); + case "missing_actual": + return String.format("- **%s** -- CN1 render not delivered.", row.test); + case "missing_expected": + return String.format("- **%s** -- native golden missing.", row.test); + case "size_mismatch": + return String.format("- **%s** -- size mismatch: %s", row.test, row.message); + case "error": + return String.format("- **%s** -- error: %s", row.test, row.message); + default: + return String.format("- **%s** -- %s", row.test, row.status); + } + } + + private static String deltaSuffix(Double delta) { + if (delta == null) { + return " (no baseline)"; + } + if (Math.abs(delta) < 0.005d) { + return " (no change)"; + } + return String.format(" (%+.2f vs baseline)", delta); + } + + /// Value at percentile p (0-100) of an ascending-sorted list (nearest rank). + private static double percentile(List sortedAsc, double p) { + if (sortedAsc.isEmpty()) { + return 0.0d; + } + int idx = (int) Math.round((p / 100.0d) * (sortedAsc.size() - 1)); + if (idx < 0) { + idx = 0; + } + if (idx >= sortedAsc.size()) { + idx = sortedAsc.size() - 1; + } + return sortedAsc.get(idx).fidelity; + } + + /// Splits a "Component_state_appearance" test id into its three parts. The + /// appearance and state are the last two underscore-separated tokens; the + /// component name (which may itself contain no underscore) is the remainder. + private static String[] splitTest(String test) { + int last = test.lastIndexOf('_'); + if (last < 0) { + return new String[] {test, "", ""}; + } + String appearance = test.substring(last + 1); + int prev = test.lastIndexOf('_', last - 1); + if (prev < 0) { + return new String[] {test.substring(0, last), "", appearance}; + } + return new String[] {test.substring(0, prev), test.substring(prev + 1, last), appearance}; + } + + /// Baseline-delta for a table cell (ASCII only). + private static String deltaCell(Double delta) { + if (delta == null) { + return "n/a"; + } + if (Math.abs(delta) < 0.05d) { + return "0.0"; + } + return String.format("%+.1f", delta); + } + + private static void addPreviewPair(List lines, PairRow row) { + String nativeName = stringValue(row.nativePreview.get("name"), null); + String cn1Name = stringValue(row.cn1Preview.get("name"), null); + if (nativeName == null && cn1Name == null) { + return; + } + // Two attachment images on one line: native (left) vs CN1 (right). + // PostPrComment uploads the preview files and resolves attachment:NAME. + StringBuilder sb = new StringBuilder(" "); + if (nativeName != null) { + sb.append("![native ").append(row.test).append("](attachment:").append(nativeName).append(") "); + } + if (cn1Name != null) { + sb.append("![cn1 ").append(row.test).append("](attachment:").append(cn1Name).append(")"); + } + lines.add(""); + lines.add(sb.toString().stripTrailing()); + lines.add(" _Left: native widget. Right: Codename One render._"); + } + + private static Map loadBaseline(Path baselinePath) { + Map baseline = new LinkedHashMap<>(); + if (baselinePath == null || !Files.isRegularFile(baselinePath)) { + return baseline; + } + try { + String text = Files.readString(baselinePath, StandardCharsets.UTF_8); + Map obj = JsonUtil.asObject(JsonUtil.parse(text)); + // Baseline format: { "pairs": { "": , ... } } + Map pairs = JsonUtil.asObject(obj.get("pairs")); + for (Map.Entry entry : pairs.entrySet()) { + Double value = toDouble(entry.getValue()); + if (value != null) { + baseline.put(entry.getKey(), value); + } + } + } catch (IOException ex) { + System.err.println("Warning: could not read baseline " + baselinePath + ": " + ex.getMessage()); + } + return baseline; + } + + private static void writeLines(Path path, List lines) throws IOException { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + sb.append(lines.get(i)); + if (i + 1 < lines.size()) { + sb.append('\n'); + } + } + if (!lines.isEmpty()) { + sb.append('\n'); + } + Files.writeString(path, sb.toString(), StandardCharsets.UTF_8); + } + + private static double nz(Double value) { + return value == null ? 0.0d : value; + } + + private static String stringValue(Object value, String fallback) { + if (value == null) { + return fallback; + } + if (value instanceof String s) { + return s; + } + return value.toString(); + } + + private static Double toDouble(Object value) { + if (value instanceof Number n) { + return n.doubleValue(); + } + if (value instanceof String s) { + try { + return Double.parseDouble(s); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private record Report(List summaryLines, List commentLines) { + } + + private static final class PairRow { + final String test; + final String status; + final Double fidelity; + final Double ssim; + final Double meanDelta; + final Double baseline; + final Double delta; + final String nativePath; + final String cn1Path; + final Map cn1Preview; + final Map nativePreview; + final String message; + + PairRow(String test, String status, Double fidelity, Double ssim, Double meanDelta, Double baseline, + Double delta, String nativePath, String cn1Path, Map cn1Preview, + Map nativePreview, String message) { + this.test = test; + this.status = status; + this.fidelity = fidelity; + this.ssim = ssim; + this.meanDelta = meanDelta; + this.baseline = baseline; + this.delta = delta; + this.nativePath = nativePath; + this.cn1Path = cn1Path; + this.cn1Preview = cn1Preview; + this.nativePreview = nativePreview; + this.message = message; + } + } + + private static final class Arguments { + final Path compareJson; + final Path commentOut; + final Path summaryOut; + final Path baselineJson; + final String marker; + final String title; + final double aspirational; + + private Arguments(Path compareJson, Path commentOut, Path summaryOut, Path baselineJson, + String marker, String title, double aspirational) { + this.compareJson = compareJson; + this.commentOut = commentOut; + this.summaryOut = summaryOut; + this.baselineJson = baselineJson; + this.marker = marker; + this.title = title; + this.aspirational = aspirational; + } + + static Arguments parse(String[] args) { + Path compare = null; + Path comment = null; + Path summary = null; + Path baseline = null; + String marker = null; + String title = null; + double aspirational = ASPIRATIONAL_THRESHOLD; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (arg) { + case "--compare-json" -> { + if (++i >= args.length) { + System.err.println("Missing value for --compare-json"); + return null; + } + compare = Path.of(args[i]); + } + case "--comment-out" -> { + if (++i >= args.length) { + System.err.println("Missing value for --comment-out"); + return null; + } + comment = Path.of(args[i]); + } + case "--summary-out" -> { + if (++i >= args.length) { + System.err.println("Missing value for --summary-out"); + return null; + } + summary = Path.of(args[i]); + } + case "--baseline" -> { + if (++i >= args.length) { + System.err.println("Missing value for --baseline"); + return null; + } + baseline = Path.of(args[i]); + } + case "--marker" -> { + if (++i >= args.length) { + System.err.println("Missing value for --marker"); + return null; + } + marker = args[i]; + } + case "--title" -> { + if (++i >= args.length) { + System.err.println("Missing value for --title"); + return null; + } + title = args[i]; + } + case "--aspirational" -> { + if (++i >= args.length) { + System.err.println("Missing value for --aspirational"); + return null; + } + try { + aspirational = Double.parseDouble(args[i]); + } catch (NumberFormatException ex) { + System.err.println("Invalid value for --aspirational: " + args[i]); + return null; + } + } + default -> { + System.err.println("Unknown argument: " + arg); + return null; + } + } + } + if (compare == null || comment == null || summary == null) { + System.err.println("--compare-json, --comment-out, and --summary-out are required"); + return null; + } + return new Arguments(compare, comment, summary, baseline, marker, title, aspirational); + } + } +} + +class JsonUtil { + private JsonUtil() {} + + public static Object parse(String text) { + return new Parser(text).parseValue(); + } + + public static String stringify(Object value) { + StringBuilder sb = new StringBuilder(); + writeValue(sb, value); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + public static Map asObject(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (key instanceof String s) { + result.put(s, entry.getValue()); + } + } + return result; + } + return new LinkedHashMap<>(); + } + + @SuppressWarnings("unchecked") + public static List asArray(Object value) { + if (value instanceof List list) { + return new ArrayList<>((List) list); + } + return new ArrayList<>(); + } + + private static void writeValue(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + } else if (value instanceof String s) { + writeString(sb, s); + } else if (value instanceof Number || value instanceof Boolean) { + sb.append(value.toString()); + } else if (value instanceof Map map) { + sb.append('{'); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + Object key = entry.getKey(); + if (!(key instanceof String sKey)) { + continue; + } + if (!first) { + sb.append(','); + } + first = false; + writeString(sb, sKey); + sb.append(':'); + writeValue(sb, entry.getValue()); + } + sb.append('}'); + } else if (value instanceof List list) { + sb.append('['); + boolean first = true; + for (Object item : list) { + if (!first) { + sb.append(','); + } + first = false; + writeValue(sb, item); + } + sb.append(']'); + } else { + writeString(sb, value.toString()); + } + } + + private static void writeString(StringBuilder sb, String value) { + sb.append('"'); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\b' -> sb.append("\\b"); + case '\f' -> sb.append("\\f"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (ch < 0x20) { + sb.append(String.format("\\u%04x", (int) ch)); + } else { + sb.append(ch); + } + } + } + } + sb.append('"'); + } + + private static final class Parser { + private final String text; + private int index; + + Parser(String text) { + this.text = text; + } + + Object parseValue() { + skipWhitespace(); + if (index >= text.length()) { + throw new IllegalArgumentException("Unexpected end of JSON"); + } + char ch = text.charAt(index); + return switch (ch) { + case '{' -> parseObject(); + case '[' -> parseArray(); + case '"' -> parseString(); + case 't' -> parseLiteral("true", Boolean.TRUE); + case 'f' -> parseLiteral("false", Boolean.FALSE); + case 'n' -> parseLiteral("null", null); + default -> parseNumber(); + }; + } + + private Map parseObject() { + index++; + Map result = new LinkedHashMap<>(); + skipWhitespace(); + if (peek('}')) { + index++; + return result; + } + while (true) { + skipWhitespace(); + String key = parseString(); + skipWhitespace(); + expect(':'); + index++; + Object value = parseValue(); + result.put(key, value); + skipWhitespace(); + if (peek('}')) { + index++; + break; + } + expect(','); + index++; + } + return result; + } + + private List parseArray() { + index++; + List result = new ArrayList<>(); + skipWhitespace(); + if (peek(']')) { + index++; + return result; + } + while (true) { + Object value = parseValue(); + result.add(value); + skipWhitespace(); + if (peek(']')) { + index++; + break; + } + expect(','); + index++; + } + return result; + } + + private String parseString() { + expect('"'); + index++; + StringBuilder sb = new StringBuilder(); + while (index < text.length()) { + char ch = text.charAt(index++); + if (ch == '"') { + return sb.toString(); + } + if (ch == '\\') { + if (index >= text.length()) { + throw new IllegalArgumentException("Invalid escape sequence"); + } + char esc = text.charAt(index++); + sb.append(switch (esc) { + case '"' -> '"'; + case '\\' -> '\\'; + case '/' -> '/'; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'u' -> parseUnicode(); + default -> throw new IllegalArgumentException("Invalid escape character: " + esc); + }); + } else { + sb.append(ch); + } + } + throw new IllegalArgumentException("Unterminated string"); + } + + private char parseUnicode() { + if (index + 4 > text.length()) { + throw new IllegalArgumentException("Incomplete unicode escape"); + } + int value = 0; + for (int i = 0; i < 4; i++) { + char ch = text.charAt(index++); + int digit = Character.digit(ch, 16); + if (digit < 0) { + throw new IllegalArgumentException("Invalid hex digit in unicode escape"); + } + value = (value << 4) | digit; + } + return (char) value; + } + + private Object parseLiteral(String literal, Object value) { + if (!text.startsWith(literal, index)) { + throw new IllegalArgumentException("Expected '" + literal + "'"); + } + index += literal.length(); + return value; + } + + private Number parseNumber() { + int start = index; + if (peek('-')) { + index++; + } + if (peek('0')) { + index++; + } else { + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid number"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + boolean isFloat = false; + if (peek('.')) { + isFloat = true; + index++; + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid fractional number"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + if (peek('e') || peek('E')) { + isFloat = true; + index++; + if (peek('+') || peek('-')) { + index++; + } + if (!Character.isDigit(peekChar())) { + throw new IllegalArgumentException("Invalid exponent"); + } + while (Character.isDigit(peekChar())) { + index++; + } + } + String number = text.substring(start, index); + try { + if (!isFloat) { + long value = Long.parseLong(number); + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return (int) value; + } + return value; + } + return Double.parseDouble(number); + } catch (NumberFormatException ex) { + throw new IllegalArgumentException("Invalid number: " + number, ex); + } + } + + private void expect(char ch) { + if (!peek(ch)) { + throw new IllegalArgumentException("Expected '" + ch + "'"); + } + } + + private boolean peek(char ch) { + return index < text.length() && text.charAt(index) == ch; + } + + private char peekChar() { + return index < text.length() ? text.charAt(index) : '\0'; + } + + private void skipWhitespace() { + while (index < text.length()) { + char ch = text.charAt(index); + if (ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t') { + index++; + } else { + break; + } + } + } + } +} diff --git a/scripts/fidelity-app/.mvn/jvm.config b/scripts/fidelity-app/.mvn/jvm.config new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/fidelity-app/.mvn/wrapper/maven-wrapper.properties b/scripts/fidelity-app/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..d58dfb70ba --- /dev/null +++ b/scripts/fidelity-app/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/scripts/fidelity-app/README.md b/scripts/fidelity-app/README.md new file mode 100644 index 0000000000..d58629b192 --- /dev/null +++ b/scripts/fidelity-app/README.md @@ -0,0 +1,153 @@ +# Native theme fidelity suite + +Measures how close Codename One's **native themes** (`iOSModernTheme`, +`AndroidMaterialTheme`) render compared to the **real native OS widgets**, so the +themes can be driven toward 99-100% fidelity. It is a different kind of test from +the `hellocodenameone` CN1SS suite: that one asserts CN1 output is pixel-identical +to a *stored CN1 golden*; this one measures the visual *similarity* between CN1's +render of a component and the *real native widget* (UIKit / Material). + +## How it works + +For every component with a native equivalent, for every state +(normal/pressed/disabled/selected) and appearance (light/dark), the on-device +runner produces two identically-sized tiles. Both the native widget and the CN1 +component are anchored **top-left at their natural (preferred) size** in the +tile -- laid out identically -- so the comparison is fair and a genuine +size/extent difference shows up as a real fidelity gap rather than a harness +artifact: + +1. **CN1 tile** -- the CN1 component under the native theme, captured via + `Display.screenshot()` and cropped to the tile. +2. **Native tile** -- the REAL native widget (`NativeWidgetFactory`), rasterized + off-screen to PNG bytes (Android `View.draw`, iOS `CALayer renderInContext`). + +The host then scores each pair with a **structure-aware perceptual metric** +(`ProcessScreenshots --mode fidelity`). A naive per-pixel colour delta is useless +here: tiles are mostly background, and CN1 fills are near-white, so two widgets +that look nothing alike used to score 95%. Instead the score is the geometric +mean of two factors, so BOTH must be high: + +- **shape_sim** -- each widget is cropped to its content bounding box and + normalized onto a common 64x64 canvas, then compared by colour. This asks "is + it the same kind of widget, styled the same?" independent of size/position (so + a few-pixel shift does not tank it). +- **size_agreement** -- the ratio of the two content bounding-box dimensions: + "is it the same size?" (a CN1 radio rendered 1.5x larger than Material's is a + real but partial gap). + +`fidelity_percent = 100 * sqrt(shape_sim * size_agreement)`. A recognizably +similar widget at a different size scores in the middle (~60-80%); a genuinely +different one scores low; an identical one scores 100. (`ssim` and +`mean_channel_delta` are still emitted as diagnostics.) `RenderFidelityReport` +renders the side-by-side report; `FidelityGate` enforces a one-way ratchet +(fidelity may not drop below the committed baseline minus an epsilon); +`FidelityComposite` renders the **visual fidelity cards** -- one PNG per +component+state showing the native widget (left) next to the CN1 render (right) +for each appearance with the fidelity % beside each pair, plus a single +`fidelity-overview.png` contact sheet. Cards are generated automatically by every +run and land in `artifacts/-fidelity/cards/` (uploaded by CI). They are +the human-readable "where do the themes stand" guide, and they make degenerate raw +tiles legible -- e.g. a Material progress bar is a thin line on a near-black dark +tile, so its raw PNG reads as solid black, but the card frames and scores it. + +### Why the comparison is same-run (environment robustness) + +The CN1 tile and the native tile are produced **in the same environment** every +run. Native widgets render slightly differently across environments (emulator +GPU, OS version, font hinting), so a native golden captured on one machine would +not match a CN1 render on another. Comparing same-environment renders makes the +*score* portable. The committed `goldens/` are re-seedable drift artifacts for +human review (`FIDELITY_UPDATE_GOLDENS=1`); the committed `baseline/` holds the +expected scores the ratchet gates against (`FIDELITY_UPDATE_BASELINE=1` to +re-record, a deliberate, reviewed action). + +Native renders are snapped to their final state before rasterizing +(`jumpDrawablesToCurrentState` on Android) so the off-screen capture is +deterministic run-to-run. + +## Layout + +``` +common/ CN1 app: FidelityApp, FidelityDeviceRunner, Cn1WidgetRenderer, + NativeWidgetFactory, spec parser, fidelity-tests.yaml +ios/ Objective-C NativeWidgetFactory impl (UIKit) +android/ Java NativeWidgetFactory impl (Material 3) +goldens/ committed per-env native reference PNGs (drift artifact) +baseline/ committed per-platform expected fidelity scores (gated) +tools/ fidelity-stats.py -- summarize a baseline into Markdown +``` + +The component matrix is data-driven in `common/src/main/resources/fidelity-tests.yaml`. + +## Running locally + +```bash +# Android (emulator must be booted) +./scripts/build-fidelity-app.sh android # or drive android-source + gradle directly +./scripts/run-android-fidelity-tests.sh + +# iOS (Metal pipeline; simulator must be booted) +./scripts/build-fidelity-app.sh ios +xcodebuild -workspace -scheme FidelityApp \ + -sdk iphonesimulator -destination "id=" ARCHS=arm64 ONLY_ACTIVE_ARCH=YES build +./scripts/run-ios-fidelity-tests.sh +``` + +To re-seed in a fresh environment: +`FIDELITY_UPDATE_GOLDENS=1 FIDELITY_UPDATE_BASELINE=1 ./scripts/run-...`. + +CI runs both platforms in `.github/workflows/scripts-fidelity.yml`, gating each +PR that touches the native themes, the app, or the renderers. + +## Current standing + +See `tools/fidelity-stats.py` for the live summary +(`python3 scripts/fidelity-app/tools/fidelity-stats.py`). + +### Android (Material 3) -- complete, verified + +44 component/state/appearance pairs, run on an API-34 emulator, with the +top-left-anchored harness and the structure-aware metric: + +- **Overall mean ~48%** -- an honest figure. (An earlier color-delta metric + reported a meaningless 82.6%: it was mostly measuring "both tiles share a + background colour", and scored a small outlined native field vs a full-width + CN1 field at 94.6%. The structure-aware metric scores that pair at ~40%.) +- **Per component (mean):** Switch ~81%, RaisedButton ~70%, Button ~67%, + RadioButton ~61%, CheckBox ~60%, FlatButton ~57%, TextField ~39%, Slider ~13%, + ProgressBar ~0%. +- **Real findings it surfaces:** CN1's radio/checkbox render ~1.5x larger than + Material; CN1 TextField defaults to a full-width filled field vs Material's + narrow outlined one; CN1 Slider/ProgressBar differ markedly from Material; the + native Material progress indicator barely renders at this tile size (a + per-component capture issue to tune). Switch is the closest match. +- The off-screen native raster is deterministic run-to-run after + `jumpDrawablesToCurrentState` (a real bug the gate caught and we fixed). + +### iOS (Modern theme, Metal) -- CN1 side working; native reference blocked + +- The **CN1 render pipeline works end-to-end on the Metal simulator**: all 44 + CN1 tiles render under `iOSModernTheme` and ship over the WebSocket; the suite + completes cleanly (`CN1SS:SUITE:FINISHED`). iOS CN1 tiles are captured at + Retina resolution (e.g. 1087x254 px for a 60x14mm tile). +- The **native UIKit reference capture is blocked** by a ParparVM bridge issue: + the native `renderWidgetPng` (returning `byte[]`) executes its Objective-C + body (confirmed via NSLog) but the call surfaces a `NullPointerException` for a + deterministic subset of widget kinds (`ios_uiswitch`, `ios_uislider`, + `ios_uiprogress`, the SF-symbol check/radio) while `ios_uibutton_*` / + `ios_uitextfield` return empty. `bool`-returning methods (`isSupported`) work; + only the array-returning path fails. UIKit must run on the iOS main thread, + but CN1's EDT is a separate thread, and neither calling from the EDT nor a + `dispatch_sync(main)` hop from it has produced delivered bytes. ParparVM does + not populate `getStackTrace()`, so the exact failing line is not yet pinned. +- **Recommended next approach for iOS:** instead of a `byte[]`-returning native + interface, wrap the native `UIView` in a CN1 `PeerComponent` and capture it via + `Display.screenshot()` cropped to the peer bounds. On iOS, + `cn1_captureView` (`Ports/iOSPort/nativeSources/IOSNative.m`) already composites + peer `UIView`s into the screenshot via `drawViewHierarchyInRect:`, so this runs + entirely on CN1's own main-thread capture path and sidesteps the array-return + bridge. (Android stays on the off-screen `renderViewOnBitmap` path, which is + reliable there.) + +These numbers are the baseline the theme work drives upward. diff --git a/scripts/fidelity-app/android/pom.xml b/scripts/fidelity-app/android/pom.xml new file mode 100644 index 0000000000..954accae42 --- /dev/null +++ b/scripts/fidelity-app/android/pom.xml @@ -0,0 +1,134 @@ + + + 4.0.0 + + com.codenameone.fidelity + fidelity-app + 1.0-SNAPSHOT + + com.codenameone.fidelity + fidelity-app-android + 1.0-SNAPSHOT + + fidelity-app-android + + + UTF-8 + 17 + 17 + android + android + android-device + + + src/main/empty + + + + src/main/java + + + src/main/resources + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + build-android + package + + build + + + + + + + + + + + com.codenameone + codenameone-core + provided + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + tests + test + + + + + + + run-android + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/../common/codenameone_settings.properties + + + + + + + + maven-antrun-plugin + + + adb-install + verify + + run + + + + Running adb install + + + + + + + Trying to start app on device using adb + + + + + + + + + + + + + + + + + + diff --git a/scripts/fidelity-app/android/src/main/java/com/codenameone/fidelity/NativeWidgetFactoryImpl.java b/scripts/fidelity-app/android/src/main/java/com/codenameone/fidelity/NativeWidgetFactoryImpl.java new file mode 100644 index 0000000000..cfdb383943 --- /dev/null +++ b/scripts/fidelity-app/android/src/main/java/com/codenameone/fidelity/NativeWidgetFactoryImpl.java @@ -0,0 +1,415 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * + * 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 Codename One 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.codenameone.fidelity; + +import android.app.Activity; +import android.content.Context; +import android.graphics.Bitmap; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.appcompat.view.ContextThemeWrapper; + +import com.codename1.impl.android.AndroidNativeUtil; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.checkbox.MaterialCheckBox; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.materialswitch.MaterialSwitch; +import com.google.android.material.progressindicator.LinearProgressIndicator; +import com.google.android.material.radiobutton.MaterialRadioButton; +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.slider.Slider; +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; + +import java.io.ByteArrayOutputStream; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Android side of {@link NativeWidgetFactory}: builds REAL Material 3 widgets to + * serve as the fidelity reference. The CN1 build wraps the returned android.view.View + * in a PeerComponent automatically (PeerComponent.create(view)), so each method + * here just returns the native View. + * + * Views are constructed on the UI thread under a Material 3 themed context so the + * Material components resolve their attributes; the requested appearance picks the + * light/dark Material theme. + */ +public class NativeWidgetFactoryImpl { + + public boolean isSupported() { + return true; + } + + public boolean isWidgetSupported(String kind) { + return mapsToKnownWidget(kind); + } + + private boolean mapsToKnownWidget(String kind) { + return "material_button_text".equals(kind) + || "material_button_filled".equals(kind) + || "material_button_tonal".equals(kind) + || "material_button_outlined".equals(kind) + || "material_textinput".equals(kind) + || "material_checkbox".equals(kind) + || "material_radio".equals(kind) + || "material_switch".equals(kind) + || "material_slider".equals(kind) + || "material_progress_linear".equals(kind) + || "material_fab".equals(kind) + || "material_tablayout".equals(kind) + || "material_toolbar".equals(kind) + || "material_alert_view".equals(kind); + } + + public boolean renderWidgetToFile(final String kind, final String state, final String appearance, + final String text, final String outPath, final int widthPx, final int heightPx) { + if (!mapsToKnownWidget(kind) || widthPx <= 0 || heightPx <= 0 || outPath == null) { + return false; + } + final Activity activity = AndroidNativeUtil.getActivity(); + if (activity == null) { + return false; + } + // Build + measure + lay out the native tile on the UI thread. + final AtomicReference tileRef = new AtomicReference(); + final CountDownLatch latch = new CountDownLatch(1); + activity.runOnUiThread(new Runnable() { + public void run() { + try { + tileRef.set(buildOnUiThread(activity, kind, state, appearance, text, widthPx, heightPx)); + } catch (Throwable t) { + System.out.println("CN1SS:ERR:fidelity native build failed kind=" + kind + " " + t); + } finally { + latch.countDown(); + } + } + }); + try { + latch.await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + View tile = tileRef.get(); + if (tile == null) { + return false; + } + // Rasterize the laid-out tile off-screen (AndroidNativeUtil draws on the + // UI thread internally) and PNG-encode it. + Bitmap bmp = AndroidNativeUtil.renderViewOnBitmap(tile, widthPx, heightPx); + if (bmp == null) { + return false; + } + try { + // Write the PNG bytes to the caller-supplied outPath (a String ARG, the + // only object-transport direction that marshals cleanly on the iOS + // bridge). The device reads it back via FileSystemStorage. + String fsPath = outPath; + if (fsPath.startsWith("file://")) { + fsPath = fsPath.substring("file://".length()); + } + java.io.File f = new java.io.File(fsPath); + java.io.File parent = f.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + java.io.FileOutputStream fos = new java.io.FileOutputStream(f); + bmp.compress(Bitmap.CompressFormat.PNG, 100, fos); + fos.flush(); + fos.close(); + return true; + } catch (Throwable t) { + System.out.println("CN1SS:ERR:fidelity native png encode failed kind=" + kind + " " + t); + return false; + } finally { + bmp.recycle(); + } + } + + private View buildOnUiThread(Activity activity, String kind, String state, String appearance, + String text, int widthPx, int heightPx) { + Context ctx = new ContextThemeWrapper(activity, themeFor(appearance)); + String label = text != null ? text : ""; + View view; + if ("material_button_filled".equals(kind)) { + // Material 3 filled button (primary) -- maps to CN1 "Button". + MaterialButton b = new MaterialButton(ctx); + b.setText(label); + applyEnabledPressed(b, state); + view = b; + } else if ("material_button_tonal".equals(kind)) { + // Material 3 filled-tonal button (secondary container) -- maps to CN1 + // "RaisedButton". Material 1.12 exposes tonal only as a style, not a + // defStyleAttr, so apply its palette (secondary container fill + + // on-secondary-container text) directly onto a filled button. + MaterialButton b = new MaterialButton(ctx); + b.setText(label); + int container = themeColor(ctx, com.google.android.material.R.attr.colorSecondaryContainer); + int onContainer = themeColor(ctx, com.google.android.material.R.attr.colorOnSecondaryContainer); + int onSurface = themeColor(ctx, com.google.android.material.R.attr.colorOnSurface); + int surface = themeColor(ctx, com.google.android.material.R.attr.colorSurface); + // Material 3 disabled filled/tonal button = container at on-surface @ 12%, + // label at on-surface @ 38%. Using ColorStateList.valueOf() (a single + // state) instead would leave the disabled button looking enabled, since + // it overrides MaterialButton's built-in stateful colours. + int disBg = compositeOver((onSurface & 0xffffff) | (0x1f << 24), surface); + int disText = (onSurface & 0xffffff) | (0x61 << 24); + int[][] sts = {{-android.R.attr.state_enabled}, {}}; + b.setBackgroundTintList(new android.content.res.ColorStateList(sts, new int[]{disBg, container})); + b.setTextColor(new android.content.res.ColorStateList(sts, new int[]{disText, onContainer})); + applyEnabledPressed(b, state); + view = b; + } else if ("material_button_outlined".equals(kind) || "material_button_text".equals(kind)) { + // Material 3 outlined button (pill outline, transparent) -- maps to CN1 + // "FlatButton", which the theme styles with a transparent pill + stroke. + MaterialButton b = new MaterialButton(ctx, null, + com.google.android.material.R.attr.materialButtonOutlinedStyle); + b.setText(label); + applyEnabledPressed(b, state); + view = b; + } else if ("material_textinput".equals(kind)) { + TextInputLayout til = new TextInputLayout(ctx, null, + com.google.android.material.R.attr.textInputOutlinedStyle); + TextInputEditText edit = new TextInputEditText(til.getContext()); + edit.setText(label); + til.addView(edit); + applyEnabled(til, state); + view = til; + } else if ("material_checkbox".equals(kind)) { + MaterialCheckBox cb = new MaterialCheckBox(ctx); + cb.setText(label); + cb.setChecked("selected".equals(state)); + applyEnabled(cb, state); + view = cb; + } else if ("material_radio".equals(kind)) { + MaterialRadioButton rb = new MaterialRadioButton(ctx); + rb.setText(label); + rb.setChecked("selected".equals(state)); + applyEnabled(rb, state); + view = rb; + } else if ("material_switch".equals(kind)) { + MaterialSwitch sw = new MaterialSwitch(ctx); + sw.setChecked("selected".equals(state)); + applyEnabled(sw, state); + view = sw; + } else if ("material_slider".equals(kind)) { + Slider s = new Slider(ctx); + s.setValueFrom(0f); + s.setValueTo(100f); + s.setValue(50f); + applyEnabled(s, state); + view = s; + } else if ("material_progress_linear".equals(kind)) { + // Material's LinearProgressIndicator does not paint when rendered + // off-screen (it is animation/visibility driven). The classic + // horizontal ProgressBar paints a determinate bar reliably and picks + // up Material colors from the themed context. + android.widget.ProgressBar p = new android.widget.ProgressBar( + ctx, null, android.R.attr.progressBarStyleHorizontal); + p.setMax(100); + p.setProgress(50); + view = p; + } else if ("material_tablayout".equals(kind)) { + // Material 3 fixed tab strip, 3 tabs, first selected (the indicator + // underlines it). Sized full-width like slider/progress below. + TabLayout tabs = new TabLayout(ctx); + tabs.setTabMode(TabLayout.MODE_FIXED); + tabs.addTab(tabs.newTab().setText("Tab 1")); + tabs.addTab(tabs.newTab().setText("Tab 2")); + tabs.addTab(tabs.newTab().setText("Tab 3")); + tabs.selectTab(tabs.getTabAt(0)); + view = tabs; + } else if ("material_toolbar".equals(kind)) { + // Material 3 small top app bar with a title. A bare MaterialToolbar is + // transparent, so it would render against the bare tile (black in dark + // mode) rather than the M3 surface the bar actually sits on. Pin the + // surface colour so the reference shows the intended bar background. + MaterialToolbar tb = new MaterialToolbar(ctx); + tb.setTitle(label); + tb.setBackgroundColor(themeColor(ctx, com.google.android.material.R.attr.colorSurface)); + view = tb; + } else if ("material_alert_view".equals(kind)) { + // Material 3 alert dialog CONTENT (not the presented modal): a rounded + // surface-container card with a headline, supporting text and two text + // action buttons (Cancel / OK), built directly so it renders off-screen. + float density = ctx.getResources().getDisplayMetrics().density; + MaterialCardView card = new MaterialCardView(ctx); + card.setRadius(28 * density); + card.setCardBackgroundColor(themeColor(ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh)); + card.setCardElevation(0); + android.widget.LinearLayout col = new android.widget.LinearLayout(ctx); + col.setOrientation(android.widget.LinearLayout.VERTICAL); + int pad = (int) (24 * density); + col.setPadding(pad, pad, pad, (int) (18 * density)); + android.widget.TextView title = new android.widget.TextView(ctx); + title.setText("Title"); + title.setTextSize(24); + title.setTextColor(themeColor(ctx, com.google.android.material.R.attr.colorOnSurface)); + android.widget.TextView body = new android.widget.TextView(ctx); + body.setText(label); + body.setTextSize(14); + body.setTextColor(themeColor(ctx, com.google.android.material.R.attr.colorOnSurfaceVariant)); + android.widget.LinearLayout.LayoutParams blp = new android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + blp.topMargin = (int) (16 * density); + body.setLayoutParams(blp); + android.widget.LinearLayout btns = new android.widget.LinearLayout(ctx); + btns.setOrientation(android.widget.LinearLayout.HORIZONTAL); + btns.setGravity(Gravity.END); + android.widget.LinearLayout.LayoutParams rlp = new android.widget.LinearLayout.LayoutParams( + android.widget.LinearLayout.LayoutParams.MATCH_PARENT, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT); + rlp.topMargin = (int) (18 * density); + btns.setLayoutParams(rlp); + int accent = themeColor(ctx, com.google.android.material.R.attr.colorPrimary); + for (String t : new String[]{"Cancel", "OK"}) { + android.widget.TextView b2 = new android.widget.TextView(ctx); + b2.setText(t); + b2.setTextSize(14); + b2.setAllCaps(false); + b2.setTextColor(accent); + b2.setPadding((int) (12 * density), (int) (10 * density), (int) (12 * density), (int) (10 * density)); + btns.addView(b2); + } + col.addView(title); + col.addView(body); + col.addView(btns); + card.addView(col); + view = card; + } else if ("material_fab".equals(kind)) { + FloatingActionButton fab = new FloatingActionButton(ctx); + // A real FAB carries an icon - give it the standard "+" so it matches the + // CN1 FAB (FontImage.MATERIAL_ADD) rather than an empty button. + fab.setImageResource(android.R.drawable.ic_input_add); + applyEnabledPressed(fab, state); + view = fab; + } else { + return null; + } + // Anchor the widget TOP-LEFT at its natural (WRAP_CONTENT) size in a fixed + // tile -- matching how the CN1 side anchors its component -- so the two are + // laid out identically and directly comparable. The tile background + // matches the CN1 tile backdrop (white/black per appearance) so + // anti-aliased edges blend identically on both sides. + FrameLayout tile = new FrameLayout(ctx); + tile.setBackgroundColor("dark".equals(appearance) ? 0xFF000000 : 0xFFFFFFFF); + // Sliders and progress bars are inherently full-width: at WRAP_CONTENT they + // collapse to ~0. Give them a fixed width so they render meaningfully; the + // CN1 side sizes its Slider to the same fraction of the tile. + boolean stretchWidth = "material_slider".equals(kind) || "material_progress_linear".equals(kind); + int tileChildW; + if ("material_tablayout".equals(kind) || "material_toolbar".equals(kind)) { + tileChildW = widthPx; // tab strip / app bar are edge-to-edge full-width + } else if (stretchWidth) { + tileChildW = widthPx * 2 / 3; + } else { + tileChildW = ViewGroup.LayoutParams.WRAP_CONTENT; + } + FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams( + tileChildW, ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.TOP | Gravity.START); + tile.addView(view, lp); + tile.measure(View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(heightPx, View.MeasureSpec.EXACTLY)); + tile.layout(0, 0, widthPx, heightPx); + // Material controls animate their state transitions (the radio/checkbox + // mark, the slider thumb sliding to its value, ripples). Rendering + // immediately would capture a mid-animation frame, making the off-screen + // raster nondeterministic run-to-run. Snap every drawable to its final + // state so the rasterized reference is stable. + jumpDrawables(tile); + return tile; + } + + /** Recursively snap every drawable in the tree to its current (final) state. */ + private void jumpDrawables(View v) { + v.jumpDrawablesToCurrentState(); + if (v instanceof ViewGroup) { + ViewGroup g = (ViewGroup) v; + for (int i = 0; i < g.getChildCount(); i++) { + jumpDrawables(g.getChildAt(i)); + } + } + } + + private int themeFor(String appearance) { + if ("dark".equals(appearance)) { + return com.google.android.material.R.style.Theme_Material3_Dark; + } + return com.google.android.material.R.style.Theme_Material3_Light; + } + + /** Alpha-composites a (possibly translucent) foreground colour over an opaque bg. */ + private static int compositeOver(int fg, int bg) { + int a = (fg >>> 24) & 0xff; + int r = (((fg >> 16) & 0xff) * a + ((bg >> 16) & 0xff) * (255 - a)) / 255; + int g = (((fg >> 8) & 0xff) * a + ((bg >> 8) & 0xff) * (255 - a)) / 255; + int b = ((fg & 0xff) * a + (bg & 0xff) * (255 - a)) / 255; + return 0xff000000 | (r << 16) | (g << 8) | b; + } + + /** Resolves a Material theme colour attribute to its colour int. */ + private int themeColor(Context ctx, int attr) { + android.util.TypedValue tv = new android.util.TypedValue(); + if (ctx.getTheme().resolveAttribute(attr, tv, true)) { + if (tv.resourceId != 0) { + return ctx.getResources().getColor(tv.resourceId, ctx.getTheme()); + } + return tv.data; + } + return 0; + } + + private void applyEnabled(View v, String state) { + if ("disabled".equals(state)) { + v.setEnabled(false); + } + } + + private void applyEnabledPressed(View v, String state) { + if ("disabled".equals(state)) { + v.setEnabled(false); + } else if ("pressed".equals(state)) { + // The M3 pressed state layer / ripple only paints when the drawable's + // state actually carries state_pressed AND (for a RippleDrawable) a + // hotspot is set. Off-screen rendering does not run the touch pipeline, + // so set the hotspot to the view centre and refresh the drawable state + // explicitly before snapping -- otherwise pressed rasterizes identically + // to normal. + v.setPressed(true); + v.refreshDrawableState(); + android.graphics.drawable.Drawable bg = v.getBackground(); + if (bg != null) { + bg.setState(new int[]{android.R.attr.state_enabled, android.R.attr.state_pressed}); + int cx = Math.max(1, v.getWidth() / 2); + int cy = Math.max(1, v.getHeight() / 2); + bg.setHotspot(cx, cy); + } + v.jumpDrawablesToCurrentState(); + } + } +} diff --git a/scripts/fidelity-app/baseline/android-fidelity-baseline.json b/scripts/fidelity-app/baseline/android-fidelity-baseline.json new file mode 100644 index 0000000000..71740c800a --- /dev/null +++ b/scripts/fidelity-app/baseline/android-fidelity-baseline.json @@ -0,0 +1 @@ +{"pairs":{"Button_disabled_dark":93.84,"Button_disabled_light":98.0,"Button_normal_dark":98.48,"Button_normal_light":98.54,"CheckBox_disabled_dark":96.63,"CheckBox_disabled_light":96.6,"CheckBox_normal_dark":94.13,"CheckBox_normal_light":94.72,"CheckBox_selected_dark":94.13,"CheckBox_selected_light":95.48,"Dialog_normal_dark":91.05,"Dialog_normal_light":93.93,"FlatButton_normal_dark":92.85,"FlatButton_normal_light":94.49,"FloatingActionButton_normal_dark":99.0,"FloatingActionButton_normal_light":99.12,"FloatingActionButton_pressed_dark":99.0,"FloatingActionButton_pressed_light":99.12,"ProgressBar_normal_dark":100.0,"ProgressBar_normal_light":100.0,"RadioButton_disabled_dark":97.04,"RadioButton_disabled_light":97.12,"RadioButton_normal_dark":94.94,"RadioButton_normal_light":95.15,"RadioButton_selected_dark":95.56,"RadioButton_selected_light":96.13,"RaisedButton_disabled_dark":98.69,"RaisedButton_disabled_light":98.75,"RaisedButton_normal_dark":98.67,"RaisedButton_normal_light":98.6,"Slider_disabled_dark":99.79,"Slider_disabled_light":99.8,"Slider_normal_dark":97.08,"Slider_normal_light":99.8,"Switch_disabled_dark":93.55,"Switch_disabled_light":91.1,"Switch_normal_dark":91.42,"Switch_normal_light":94.1,"Switch_selected_dark":97.37,"Switch_selected_light":97.53,"Tabs_normal_dark":99.43,"Tabs_normal_light":91.54,"TextField_disabled_dark":95.08,"TextField_disabled_light":98.2,"TextField_normal_dark":95.43,"TextField_normal_light":95.64,"Toolbar_normal_dark":92.18,"Toolbar_normal_light":96.45}} \ No newline at end of file diff --git a/scripts/fidelity-app/baseline/ios-metal-fidelity-baseline.json b/scripts/fidelity-app/baseline/ios-metal-fidelity-baseline.json new file mode 100644 index 0000000000..3260efeddc --- /dev/null +++ b/scripts/fidelity-app/baseline/ios-metal-fidelity-baseline.json @@ -0,0 +1 @@ +{"pairs":{"Button_disabled_dark":91.92,"Button_disabled_light":90.91,"Button_normal_dark":86.85,"Button_normal_light":86.34,"CheckBox_disabled_dark":92.76,"CheckBox_disabled_light":92.58,"CheckBox_normal_dark":91.77,"CheckBox_normal_light":89.77,"CheckBox_selected_dark":95.63,"CheckBox_selected_light":94.7,"Dialog_normal_dark":96.97,"Dialog_normal_light":97.09,"FlatButton_normal_dark":91.17,"FlatButton_normal_light":91.9,"ProgressBar_normal_dark":94.44,"ProgressBar_normal_light":95.41,"RadioButton_disabled_dark":92.76,"RadioButton_disabled_light":92.58,"RadioButton_normal_dark":91.77,"RadioButton_normal_light":89.77,"RadioButton_selected_dark":87.76,"RadioButton_selected_light":87.76,"RaisedButton_disabled_dark":89.71,"RaisedButton_disabled_light":89.67,"RaisedButton_normal_dark":87.58,"RaisedButton_normal_light":87.02,"Slider_disabled_dark":92.44,"Slider_disabled_light":94.01,"Slider_normal_dark":92.74,"Slider_normal_light":95.13,"Spinner_normal_dark":89.8,"Spinner_normal_light":90.01,"Switch_disabled_dark":85.42,"Switch_disabled_light":96.22,"Switch_normal_dark":90.82,"Switch_normal_light":94.94,"Switch_selected_dark":93.93,"Switch_selected_light":90.38,"Tabs_normal_dark":74.97,"Tabs_normal_light":79.24,"TextField_disabled_dark":97.56,"TextField_disabled_light":97.46,"TextField_normal_dark":97.46,"TextField_normal_light":97.35,"Toolbar_normal_dark":74.08,"Toolbar_normal_light":78.42}} \ No newline at end of file diff --git a/scripts/fidelity-app/common/androidCerts/KeyChain.ks b/scripts/fidelity-app/common/androidCerts/KeyChain.ks new file mode 100644 index 0000000000..d6b4dcba8b Binary files /dev/null and b/scripts/fidelity-app/common/androidCerts/KeyChain.ks differ diff --git a/scripts/fidelity-app/common/codenameone_settings.properties b/scripts/fidelity-app/common/codenameone_settings.properties new file mode 100644 index 0000000000..d3f6f26ad8 --- /dev/null +++ b/scripts/fidelity-app/common/codenameone_settings.properties @@ -0,0 +1,33 @@ +#Updated keystore +#Mon Jun 22 11:54:44 IDT 2026 +codename1.android.keystore=/Users/shai/dev/cn4/CodenameOne/scripts/fidelity-app/android/../common/androidCerts/KeyChain.ks +codename1.android.keystoreAlias=androidKey +codename1.android.keystorePassword=password +codename1.arg.android.gradleDep=implementation 'com.google.android.material\:material\:1.12.0' +codename1.arg.android.useAndroidX=true +codename1.arg.ios.metal=true +codename1.arg.ios.newStorageLocation=true +codename1.arg.ios.uiscene=true +codename1.arg.java.version=17 +codename1.cssTheme=true +codename1.displayName=Fidelity +codename1.icon=icon.png +codename1.ios.appid=Q5GHSKAL2F.com.codenameone.fidelity +codename1.ios.certificate= +codename1.ios.certificatePassword= +codename1.ios.debug.certificate= +codename1.ios.debug.certificatePassword= +codename1.ios.debug.provision= +codename1.ios.provision= +codename1.ios.release.certificate= +codename1.ios.release.certificatePassword= +codename1.kotlin=false +codename1.languageLevel=5 +codename1.mainName=FidelityApp +codename1.packageName=com.codenameone.fidelity +codename1.rim.certificatePassword= +codename1.rim.signtoolCsk= +codename1.rim.signtoolDb= +codename1.secondaryTitle=Fidelity +codename1.vendor=CodenameOne +codename1.version=1.0 diff --git a/scripts/fidelity-app/common/icon.png b/scripts/fidelity-app/common/icon.png new file mode 100644 index 0000000000..1f4fa5dd25 Binary files /dev/null and b/scripts/fidelity-app/common/icon.png differ diff --git a/scripts/fidelity-app/common/pom.xml b/scripts/fidelity-app/common/pom.xml new file mode 100644 index 0000000000..74ce849d54 --- /dev/null +++ b/scripts/fidelity-app/common/pom.xml @@ -0,0 +1,159 @@ + + + 4.0.0 + + com.codenameone.fidelity + fidelity-app + 1.0-SNAPSHOT + + com.codenameone.fidelity + fidelity-app-common + 1.0-SNAPSHOT + jar + + + + com.codenameone + codenameone-core + provided + + + + + + javase + + + codename1.platform + javase + + + + javase + + + + + org.codehaus.mojo + exec-maven-plugin + + java + true + + -Xmx1024M + -classpath + + ${exec.mainClass} + ${cn1.mainClass} + + + + + + + + simulator + + javase + + + + ios-debug + + iphone + ios + + + + ios-release + + iphone + true + ios + true + + + + android + + android + android + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/codenameone_settings.properties + + + + + + + + com.codenameone + codenameone-maven-plugin + + + transcode-svg + generate-sources + + transcode-svg + + + + generate-gui-sources + process-sources + + generate-gui-sources + + + + cn1-process-classes + process-classes + + bytecode-compliance + css + process-annotations + + + + attach-test-artifact + test + + attach-test-artifact + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + diff --git a/scripts/fidelity-app/common/src/main/css/theme.css b/scripts/fidelity-app/common/src/main/css/theme.css new file mode 100644 index 0000000000..273f098083 --- /dev/null +++ b/scripts/fidelity-app/common/src/main/css/theme.css @@ -0,0 +1,8 @@ +/* + * Minimal app theme. The fidelity suite installs the real native theme + * (iOSModernTheme.res / AndroidMaterialTheme.res) at runtime over this base, + * so the compiled app theme only needs to exist for the CSS build step. + */ +#Constants { + includeNativeBool: true; +} diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/Cn1ssDeviceRunnerHelper.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/Cn1ssDeviceRunnerHelper.java new file mode 100644 index 0000000000..c1b4df690b --- /dev/null +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/Cn1ssDeviceRunnerHelper.java @@ -0,0 +1,724 @@ +package com.codenameone.fidelity; + +import com.codename1.io.Log; +import com.codename1.io.Storage; +import com.codename1.io.WebSocket; +import com.codename1.io.WebSocketState; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.util.ImageIO; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/// Device-side helper that ships screenshots to the host over a single +/// transport: a WebSocket to the host-side Cn1ssScreenshotServer. The device +/// connects to ws://HOST:8765, sends a JSON META text frame followed by the +/// binary PNG, and the host writes the file and echoes an ACK. Native ports +/// use the blocking, ACK-paced sink (Cn1ssWebSocketSink.trySend); the JS port +/// (which can't block the browser event loop) uses the async sink that +/// advances the sequential suite from the ACK callback. There is no +/// base64-over-stdout or filesystem fallback -- when the socket is +/// unavailable the screenshot is simply absent and the host-side +/// missing-screenshot guard flags it. +interface Cn1ssDeviceRunnerHelper { + // Standard, fixed port the host-side Cn1ssScreenshotServer listens on + // (scripts/lib/cn1ss.sh starts it with --port 8765). The runner does not + // inject the URL per-run; the device defaults to ws://HOST:8765 below so + // no platform-specific env/property plumbing is needed. Keep this value in + // sync with CN1SS_WS_PORT in scripts/lib/cn1ss.sh. + int CN1SS_WS_DEFAULT_PORT = 8765; + + static void runOnEdtSync(Runnable runnable) { + Display display = Display.getInstance(); + if (display.isEdt()) { + runnable.run(); + } else if (isHtml5()) { + display.callSerially(runnable); + } else { + display.callSeriallyAndWait(runnable); + } + } + + static void emitCurrentFormScreenshot(String testName) { + emitCurrentFormScreenshot(testName, null); + } + + /// Ships already-encoded PNG bytes straight to the host over the WebSocket, + /// bypassing Image/ImageIO entirely. Used for the native fidelity reference, + /// which the platform produces as PNG bytes off-screen -- decoding to a CN1 + /// Image only to re-encode would be wasteful and, on some ports, fails when + /// ImageIO is handed a freshly-decoded image. Synchronous (blocks for the + /// ACK on native platforms), matching emitImage. + static void emitPngBytes(byte[] pngBytes, String testName) { + String safeName = sanitizeTestName(testName); + if (pngBytes == null || pngBytes.length == 0) { + println("CN1SS:ERR:test=" + safeName + " message=Empty PNG bytes"); + emitPlaceholderScreenshot(safeName); + return; + } + String hash = fnv1a64Hex(pngBytes); + println("CN1SS:INFO:test=" + safeName + " png_bytes=" + pngBytes.length + " png_fnv1a64=" + hash); + String previous = Cn1ssHashTracker.recordAndCheck(hash, safeName); + if (previous != null) { + println("CN1SS:WARN:test=" + safeName + " duplicate_image_with=" + previous + " png_fnv1a64=" + hash); + } + if (isHtml5()) { + Cn1ssWebSocketSink.trySendAsync(safeName, pngBytes, hash, null); + } else if (!Cn1ssWebSocketSink.trySend(safeName, pngBytes, hash)) { + println("CN1SS:ERR:test=" + safeName + " message=websocket-unavailable"); + } + } + + static void emitImage(Image image, String testName, Runnable onComplete) { + String safeName = sanitizeTestName(testName); + if (image == null) { + println("CN1SS:ERR:test=" + safeName + " message=Image is null"); + emitPlaceholderScreenshot(safeName); + complete(onComplete); + return; + } + int width = Math.max(1, image.getWidth()); + int height = Math.max(1, image.getHeight()); + boolean async = false; + try { + async = emitImageScreenshot(safeName, image, width, height, onComplete); + } finally { + if (!async) { + complete(onComplete); + } + } + } + + static void emitCurrentFormScreenshot(String testName, Runnable onComplete) { + String safeName = sanitizeTestName(testName); + Form current = Display.getInstance().getCurrent(); + if (current == null) { + println("CN1SS:ERR:test=" + safeName + " message=Current form is null"); + emitPlaceholderScreenshot(safeName); + complete(onComplete); + return; + } + int width = Math.max(1, current.getWidth()); + int height = Math.max(1, current.getHeight()); + Display.getInstance().screenshot(screen -> { + if (screen == null) { + println("CN1SS:ERR:test=" + safeName + " message=Screenshot callback returned null"); + emitPlaceholderScreenshot(safeName); + complete(onComplete); + return; + } + boolean async = false; + try { + async = emitImageScreenshot(safeName, screen, width, height, onComplete); + } finally { + if (!async) { + complete(onComplete); + } + } + }); + } + + /// Encodes the PNG once, logs the size/hash/dupe diagnostics, then sends it + /// to the host over the WebSocket sink (the only transport). Returns true + /// when the async WebSocket path (JS port) has taken ownership of + /// `onComplete` -- it will be invoked from the ACK callback, so the caller + /// must NOT call it. Returns false on every synchronous path (native WS, or + /// WS unavailable), where the caller advances the suite itself. + /// `onComplete` may be null. + private static boolean emitImageScreenshot(String safeName, Image screenshot, int width, int height, + Runnable onComplete) { + try { + ImageIO io = ImageIO.getImageIO(); + if (io == null || !io.isFormatSupported(ImageIO.FORMAT_PNG)) { + println("CN1SS:ERR:test=" + safeName + " message=PNG encoding unavailable"); + emitPlaceholderScreenshot(safeName); + return false; + } + if (Display.getInstance().isSimulator()) { + io.save(screenshot, Storage.getInstance().createOutputStream(safeName + ".png"), ImageIO.FORMAT_PNG, 1); + } + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(Math.max(1024, width * height / 2)); + io.save(screenshot, pngOut, ImageIO.FORMAT_PNG, 1f); + byte[] pngBytes = pngOut.toByteArray(); + String hash = fnv1a64Hex(pngBytes); + println("CN1SS:INFO:test=" + safeName + " png_bytes=" + pngBytes.length + + " png_fnv1a64=" + hash); + String previous = Cn1ssHashTracker.recordAndCheck(hash, safeName); + if (previous != null) { + println("CN1SS:WARN:test=" + safeName + + " duplicate_image_with=" + previous + " png_fnv1a64=" + hash); + } + + if (isHtml5()) { + // JS cannot block on a monitor; the async WS sink advances the + // suite from the ACK callback via onComplete. + if (Cn1ssWebSocketSink.trySendAsync(safeName, pngBytes, hash, onComplete)) { + return true; + } + println("CN1SS:ERR:test=" + safeName + " message=websocket-unavailable"); + } else if (!Cn1ssWebSocketSink.trySend(safeName, pngBytes, hash)) { + println("CN1SS:ERR:test=" + safeName + " message=websocket-unavailable"); + } + // WebSocket is the only transport. When it is unavailable the + // screenshot is simply absent and the host-side missing-screenshot + // guard flags it -- there is no base64 / file fallback any more. + return false; + } catch (IOException ex) { + println("CN1SS:ERR:test=" + safeName + " message=" + ex); + Log.e(ex); + emitPlaceholderScreenshot(safeName); + return false; + } finally { + screenshot.dispose(); + } + } + + static String sanitizeTestName(String testName) { + if (testName == null || testName.length() == 0) { + return "default"; + } + StringBuffer sanitized = new StringBuffer(testName.length()); + for (int i = 0; i < testName.length(); i++) { + char ch = testName.charAt(i); + if (isSafeChar(ch)) { + sanitized.append(ch); + } else { + sanitized.append('_'); + } + } + return sanitized.toString(); + } + + static boolean isSafeChar(char ch) { + if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) { + return true; + } + if (ch >= '0' && ch <= '9') { + return true; + } + return ch == '_' || ch == '.' || ch == '-'; + } + + static void println(String line) { + System.out.println(line); + } + + static void emitPlaceholderScreenshot(String safeName) { + try { + ImageIO io = ImageIO.getImageIO(); + if (io == null || !io.isFormatSupported(ImageIO.FORMAT_PNG)) { + println("CN1SS:END:" + safeName); + return; + } + Image placeholder = Image.createImage(1, 1, 0xffffffff); + try { + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(128); + io.save(placeholder, pngOut, ImageIO.FORMAT_PNG, 1f); + byte[] pngBytes = pngOut.toByteArray(); + println("CN1SS:INFO:test=" + safeName + " png_bytes=" + pngBytes.length + " placeholder=1"); + if (isHtml5()) { + // Fire-and-forget on JS (no onComplete: the caller advances + // the suite for placeholders). + Cn1ssWebSocketSink.trySendAsync(safeName, pngBytes, fnv1a64Hex(pngBytes), null); + } else { + Cn1ssWebSocketSink.trySend(safeName, pngBytes, fnv1a64Hex(pngBytes)); + } + // WebSocket-only: if the socket is unavailable the placeholder + // is dropped along with the real screenshot; no base64 channel. + println("CN1SS:END:" + safeName); + } finally { + placeholder.dispose(); + } + } catch (Throwable t) { + println("CN1SS:ERR:test=" + safeName + " message=placeholder_emit_failed " + t); + println("CN1SS:END:" + safeName); + } + } + + static void complete(Runnable runnable) { + if (runnable != null) { + runnable.run(); + } + } + + static boolean isHtml5() { + return "HTML5".equals(Display.getInstance().getPlatformName()); + } + + /// Returns the JS port's cumulative bridge-call counters as + /// "jso=N:host=M", or null on platforms without a JS bridge. On HTML5 + /// the translated body below is replaced at runtime by a port.js + /// bindCiFallback override reading jvm.__cn1JsoDispatchCount / + /// jvm.__cn1HostCallCount. Consumed by BridgeBulkTransferGuardTest to + /// assert that large-volume transfers (resource streams, pixel + /// buffers, storage) cost bridge calls proportional to OPERATIONS, + /// not BYTES -- the per-element regression class that has now bitten + /// three separate times (single-byte ArrayBufferInputStream.read, + /// pre-bulk readBulkImpl, surface-encode/getRGB). + static String jsBridgeCallCounts() { + return null; + } + + /// Computes a 64-bit FNV-1a hash of the given bytes. FNV-1a is fast and + /// has no platform dependencies (no java.security, no java.util.zip + /// CRC32 wrapping subtleties). 64 bits is enough to make accidental + /// collisions on real-world PNG payloads vanishingly unlikely while + /// keeping the hash short enough to log on a single line. The mixup + /// detector in `Cn1ssHashTracker` calls this on every emitted image so + /// that two tests producing bit-identical bytes (the symptom of an iOS + /// Metal stale-frame capture: MultiButtonTheme_light returning Tabs + /// Theme_light's pixels because the CAMetalLayer hadn't been re- + /// presented in time) get flagged with a CN1SS:WARN line. + static String fnv1a64Hex(byte[] bytes) { + long h = 0xcbf29ce484222325L; + long prime = 0x100000001b3L; + for (int i = 0; i < bytes.length; i++) { + h ^= bytes[i] & 0xff; + h *= prime; + } + StringBuilder sb = new StringBuilder(16); + for (int i = 60; i >= 0; i -= 4) { + int nib = (int) ((h >>> i) & 0xf); + sb.append((char) (nib < 10 ? '0' + nib : 'a' + (nib - 10))); + } + return sb.toString(); + } +} + +/// Tracks recently-emitted screenshot hashes per test name so a stale-frame +/// capture (the same PNG bytes attributed to two different tests in a row) +/// gets surfaced via CN1SS:WARN markers instead of silently shipping the +/// wrong image to the comparator. Keeps the most recent 64 entries. +/// +/// Lives in a separate package-private class because Cn1ssDeviceRunnerHelper +/// is an interface and can't hold mutable static state. +/// +/// Storage uses two parallel arrays (hash[i] paired with testName[i]) rather +/// than a HashMap-typed static field. The Cn1ssDeviceRunner header-comment +/// at lines 215-222 documents that "static collections initialised via a +/// static method call ... broke iOS class loading -- Cn1ssDeviceRunner +/// failed to load before runSuite() could even log a single starting +/// test=... entry, leaving the suite to time out at the 300s end-marker +/// deadline." The first attempt at this tracker used `private static final +/// Map hashToTest = new LinkedHashMap<>()` and reproduced +/// exactly that symptom on the iOS Metal CI run -- the simulator booted, +/// installed the app, then never emitted a single CN1SS line and timed +/// out at 30 minutes. Plain primitive arrays of String avoid touching the +/// HashMap class init path during the host class's ``. +final class Cn1ssHashTracker { + private static final int MAX_TRACKED = 64; + private static final String[] hashes = new String[MAX_TRACKED]; + private static final String[] tests = new String[MAX_TRACKED]; + private static int count; + + private Cn1ssHashTracker() { + } + + /// Records the hash for `safeName` and returns the test name that + /// previously emitted the same hash, or null if this is the first time. + /// Caller logs a CN1SS:WARN line when a duplicate is found so the + /// downstream comparator can flag the affected test as a likely + /// stale-frame capture. + /// + /// O(MAX_TRACKED) per call -- 64-entry linear scan is trivial vs the + /// PNG hash itself (which scans every byte of the image). + static synchronized String recordAndCheck(String hashHex, String safeName) { + String previous = null; + for (int i = 0; i < count; i++) { + if (hashHex.equals(hashes[i])) { + previous = tests[i]; + if (safeName.equals(previous)) { + return null; + } + break; + } + } + if (count < MAX_TRACKED) { + hashes[count] = hashHex; + tests[count] = safeName; + count++; + } else { + System.arraycopy(hashes, 1, hashes, 0, MAX_TRACKED - 1); + System.arraycopy(tests, 1, tests, 0, MAX_TRACKED - 1); + hashes[MAX_TRACKED - 1] = hashHex; + tests[MAX_TRACKED - 1] = safeName; + } + return previous; + } +} + +/// Singleton WebSocket sink. Lazily connects on first send. ACK pacing: +/// after every binary upload, the sender thread blocks on a per-test latch +/// that the WS onTextMessage handler releases when the host echoes back an +/// `ACK ` text frame. ACK_TIMEOUT_MS is generous (10s) -- the +/// host writes the PNG to disk and ACKs immediately on LAN; if we hit the +/// timeout something is genuinely broken and the test should fail loudly. +/// +/// `trySend` returns true if the WS path successfully uploaded the PNG +/// (or transiently failed after the connection was established), and false +/// if WS is unavailable (no URL configured, unsupported platform, connect +/// timed out). WebSocket is the only transport now, so a false return just +/// means the screenshot is absent and the host-side guard flags it. +final class Cn1ssWebSocketSink { + private static final int ACK_TIMEOUT_MS = 10_000; + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final Map pending = new HashMap(); + private static WebSocket socket; + private static volatile boolean attemptedConnect; + private static volatile boolean unavailable; + + // ---- Async path (JavaScript port) ---- + // The JS port runs on the browser event loop and forbids blocking on a + // monitor (Object.wait throws BlockingDisallowedException, even off the + // EDT), so the blocking trySend/connect above cannot be used there. The + // async path never blocks: it connects, sends on open, and advances the + // sequential test suite from the ACK callback by invoking the per-test + // onComplete. ASYNC_IDLE -> ASYNC_CONNECTING -> ASYNC_OPEN / ASYNC_FAILED. + private static final int ASYNC_IDLE = 0; + private static final int ASYNC_CONNECTING = 1; + private static final int ASYNC_OPEN = 2; + private static final int ASYNC_FAILED = 3; + private static int asyncState = ASYNC_IDLE; + private static WebSocket asyncSocket; + private static final Map asyncPending = new HashMap(); + // The suite is sequential (each test waits for onComplete before the next), + // so at most one screenshot is in flight; this holds the single send that + // arrived while the socket was still connecting. {name, png, hash, onComplete} + private static Object[] asyncQueuedWhileConnecting; + + private Cn1ssWebSocketSink() { + } + + /// The server URL: an explicit -Dcn1ss.websocket.url wins (JavaSE), else the + /// fixed standard port on the host loopback (10.0.2.2 from the Android + /// emulator, 127.0.0.1 elsewhere -- iOS sim, Mac Catalyst, the browser). + private static String resolveUrl() { + String url = Display.getInstance().getProperty("cn1ss.websocket.url", ""); + if (url == null || url.length() == 0) { + String host = "and".equals(Display.getInstance().getPlatformName()) + ? "10.0.2.2" : "127.0.0.1"; + url = "ws://" + host + ":" + Cn1ssDeviceRunnerHelper.CN1SS_WS_DEFAULT_PORT; + } + return url; + } + + /// Non-blocking send for the JS port. Returns true when the WebSocket path + /// has taken ownership of completion (it will run onComplete from the ACK + /// callback, or immediately if the send fails); false when WS is + /// unavailable, in which case the screenshot is simply absent (no fallback). + static synchronized boolean trySendAsync(String safeName, byte[] pngBytes, String hashHex, Runnable onComplete) { + if (asyncState == ASYNC_FAILED) { + return false; + } + if (asyncState == ASYNC_IDLE) { + if (!WebSocket.isSupported()) { + asyncState = ASYNC_FAILED; + System.out.println("CN1SS:INFO:ws-sink-unavailable reason=not-supported"); + return false; + } + connectAsync(); + } + if (asyncState == ASYNC_OPEN) { + sendAsyncNow(safeName, pngBytes, hashHex, onComplete); + return true; + } + if (asyncState == ASYNC_CONNECTING) { + // Hold the single in-flight send until onConnect flushes it. + asyncQueuedWhileConnecting = new Object[] { safeName, pngBytes, hashHex, onComplete }; + return true; + } + return false; + } + + private static void connectAsync() { + asyncState = ASYNC_CONNECTING; + WebSocket ws = WebSocket.build(resolveUrl()) + .onConnect(new WebSocket.ConnectHandler() { + public void onConnect(WebSocket w) { + asyncState = ASYNC_OPEN; + flushQueuedAsync(); + } + }) + .onTextMessage(new WebSocket.TextHandler() { + public void onText(WebSocket w, String message) { + handleAckAsync(message); + } + }) + .onClose(new WebSocket.CloseHandler() { + public void onClose(WebSocket w, int code, String reason) { + failAsync("closed:" + code); + } + }) + .onError(new WebSocket.ErrorHandler() { + public void onError(WebSocket w, Exception ex) { + failAsync("error:" + ex.getMessage()); + } + }); + asyncSocket = ws; + ws.connect(0); + } + + private static void sendAsyncNow(String name, byte[] png, String hash, Runnable onComplete) { + try { + String meta = "META {\"test\":\"" + name + "\",\"png_bytes\":" + + png.length + ",\"png_fnv1a64\":\"" + hash + "\"}"; + asyncSocket.send(meta); + asyncSocket.send(png); + if (onComplete != null) { + synchronized (asyncPending) { + asyncPending.put(name, onComplete); + } + } + } catch (Throwable t) { + System.out.println("CN1SS:ERR:test=" + name + " message=ws-async-send-failed:" + t); + Log.e(t); + if (onComplete != null) { + onComplete.run(); // never stall the sequential suite + } + } + } + + private static void flushQueuedAsync() { + Object[] q = asyncQueuedWhileConnecting; + asyncQueuedWhileConnecting = null; + if (q != null) { + sendAsyncNow((String) q[0], (byte[]) q[1], (String) q[2], (Runnable) q[3]); + } + } + + private static void handleAckAsync(String text) { + if (text == null || !text.startsWith("ACK ")) { + return; + } + String body = text.substring(4).trim(); + int sp = body.indexOf(' '); + String name = sp > 0 ? body.substring(0, sp) : body; + Runnable r; + synchronized (asyncPending) { + r = asyncPending.remove(name); + } + if (r != null) { + r.run(); // advance the suite to the next test + } + } + + /// Connection failed or dropped: stop using WS and release every waiter so + /// the sequential suite proceeds. Missing screenshots then surface through + /// the host-side count guard rather than hanging the run. + private static void failAsync(String reason) { + boolean firstFailure = asyncState != ASYNC_FAILED; + asyncState = ASYNC_FAILED; + if (firstFailure) { + System.out.println("CN1SS:INFO:ws-sink-unavailable reason=" + reason); + } + Object[] q = asyncQueuedWhileConnecting; + asyncQueuedWhileConnecting = null; + if (q != null && q[3] != null) { + ((Runnable) q[3]).run(); + } + java.util.List waiters = new java.util.ArrayList(); + synchronized (asyncPending) { + waiters.addAll(asyncPending.values()); + asyncPending.clear(); + } + for (Runnable r : waiters) { + if (r != null) { + r.run(); + } + } + } + + static synchronized boolean trySend(String safeName, byte[] pngBytes, String hashHex) { + if (!ensureConnected()) { + return false; + } + final AckLatch latch = new AckLatch(); + synchronized (pending) { + pending.put(safeName, latch); + } + try { + String meta = "META {\"test\":\"" + safeName + "\",\"png_bytes\":" + + pngBytes.length + ",\"png_fnv1a64\":\"" + hashHex + "\"}"; + socket.send(meta); + socket.send(pngBytes); + } catch (Throwable t) { + synchronized (pending) { + pending.remove(safeName); + } + System.out.println("CN1SS:ERR:test=" + safeName + " message=ws-send-failed:" + t); + Log.e(t); + return true; // WS path was attempted; don't fall through to chunks. + } + boolean acked = latch.await(ACK_TIMEOUT_MS); + synchronized (pending) { + pending.remove(safeName); + } + if (!acked) { + System.out.println("CN1SS:ERR:test=" + safeName + + " message=ws-ack-timeout-after-" + ACK_TIMEOUT_MS + "ms"); + } + return true; + } + + private static boolean ensureConnected() { + if (socket != null && socket.getReadyState() == WebSocketState.OPEN) { + return true; + } + if (unavailable) { + return false; + } + if (attemptedConnect) { + // Previous attempt completed but the socket is no longer open + // (closed/errored). Treat as unavailable for the rest of the + // run; the runner script's whole point of launching the WS + // server is that it stays up for the whole suite. + unavailable = true; + return false; + } + // A -Dcn1ss.websocket.url override still wins where the launcher can + // set Display properties (e.g. the JavaSE simulator via the maven + // plugin's -Dproperty=...). Everywhere else we don't inject anything: + // the host runs Cn1ssScreenshotServer on the fixed standard port and + // the device defaults to ws://HOST:CN1SS_WS_DEFAULT_PORT. HOST is the + // host loopback as seen from the app -- the Android emulator reaches + // it via 10.0.2.2, every other target (iOS simulator, Mac Catalyst, + // the browser, JavaSE) shares 127.0.0.1. + String url = Display.getInstance().getProperty("cn1ss.websocket.url", ""); + if (url == null || url.length() == 0) { + String host = "and".equals(Display.getInstance().getPlatformName()) + ? "10.0.2.2" : "127.0.0.1"; + url = "ws://" + host + ":" + Cn1ssDeviceRunnerHelper.CN1SS_WS_DEFAULT_PORT; + } + if (!WebSocket.isSupported()) { + unavailable = true; + System.out.println("CN1SS:INFO:ws-sink-unavailable reason=not-supported"); + return false; + } + attemptedConnect = true; + return connect(url); + } + + private static boolean connect(String url) { + final Object connectGate = new Object(); + final boolean[] connected = new boolean[1]; + final String[] errReason = new String[1]; + WebSocket ws = WebSocket.build(url) + .onConnect(new WebSocket.ConnectHandler() { + @Override + public void onConnect(WebSocket w) { + synchronized (connectGate) { + connected[0] = true; + connectGate.notifyAll(); + } + } + }) + .onTextMessage(new WebSocket.TextHandler() { + @Override + public void onText(WebSocket w, String message) { + handleAck(message); + } + }) + .onClose(new WebSocket.CloseHandler() { + @Override + public void onClose(WebSocket w, int code, String reason) { + drainPending(); + } + }) + .onError(new WebSocket.ErrorHandler() { + @Override + public void onError(WebSocket w, Exception ex) { + synchronized (connectGate) { + errReason[0] = ex.getMessage(); + connectGate.notifyAll(); + } + drainPending(); + } + }); + socket = ws; + ws.connect(CONNECT_TIMEOUT_MS); + long deadline = System.currentTimeMillis() + CONNECT_TIMEOUT_MS; + synchronized (connectGate) { + while (!connected[0] && errReason[0] == null) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + errReason[0] = "connect-timeout"; + break; + } + try { + connectGate.wait(remaining); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + errReason[0] = "interrupted"; + break; + } + } + } + if (connected[0]) { + return true; + } + unavailable = true; + socket = null; + System.out.println("CN1SS:INFO:ws-sink-unavailable reason=" + errReason[0]); + return false; + } + + private static void handleAck(String text) { + if (text == null || !text.startsWith("ACK ")) { + return; + } + String body = text.substring(4).trim(); + String testName; + int spaceIdx = body.indexOf(' '); + if (spaceIdx > 0) { + testName = body.substring(0, spaceIdx); + } else { + testName = body; + } + AckLatch latch; + synchronized (pending) { + latch = pending.get(testName); + } + if (latch != null) { + latch.release(); + } + } + + private static void drainPending() { + synchronized (pending) { + for (Map.Entry e : pending.entrySet()) { + e.getValue().release(); + } + pending.clear(); + } + } + + private static final class AckLatch { + private boolean released; + + synchronized boolean await(long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (!released) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + return false; + } + try { + wait(remaining); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return false; + } + } + return true; + } + + synchronized void release() { + released = true; + notifyAll(); + } + } +} diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityApp.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityApp.java new file mode 100644 index 0000000000..739b0c9859 --- /dev/null +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityApp.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * + * 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 Codename One 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.codenameone.fidelity; + +import com.codename1.system.Lifecycle; + +/** + * Entry point for the native-theme fidelity test app. It is not an interactive + * app: on launch it runs the whole fidelity suite (render every component as the + * CN1 widget, and -- in golden-regen mode -- as the native widget too), ships the + * screenshots to the host over the CN1SS WebSocket, then prints + * CN1SS:SUITE:FINISHED and exits. + */ +public class FidelityApp extends Lifecycle { + @Override + public void runApp() { + // Run off the EDT so the suite can block on screenshot ACKs without + // freezing the UI thread (mirrors the hellocodenameone runner). + Thread runner = new Thread(new Runnable() { + public void run() { + new FidelityDeviceRunner().runSuite(); + } + }, "CN1SS-Fidelity-Runner"); + runner.start(); + } +} diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityDeviceRunner.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityDeviceRunner.java new file mode 100644 index 0000000000..2f2ee9fa01 --- /dev/null +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/FidelityDeviceRunner.java @@ -0,0 +1,716 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * + * 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 Codename One 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.codenameone.fidelity; + +import com.codename1.io.Log; +import com.codename1.io.Util; +import com.codename1.system.NativeLookup; +import com.codename1.ui.animations.CommonTransitions; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Image; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import com.codenameone.fidelity.render.Cn1WidgetRenderer; +import com.codenameone.fidelity.spec.ComponentSpec; +import com.codenameone.fidelity.spec.FidelitySpec; +import com.codenameone.fidelity.spec.FidelitySpecParser; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Drives the fidelity suite on device. For every component that applies to the + * current platform, for every appearance and state, it renders the CN1 widget in + * a fixed-size tile, screenshots the form, crops to the tile, and ships it to the + * host as "<id>_<state>_<appearance>_cn1.png". In golden-regen + * mode (-Dcn1ss.fidelity.captureNative=true) it additionally renders the real + * native widget through {@link NativeWidgetFactory} and ships "..._native.png". + * + * The two renders share the exact same tile pixel dimensions, so the host can + * diff them directly without cropping/alignment math. + */ +public class FidelityDeviceRunner { + private static final String SPEC_RESOURCE = "/fidelity-tests.yaml"; + private static final long SETTLE_MS = 700; + private static final long SCREENSHOT_TIMEOUT_MS = 15000; + + private FidelitySpec spec; + private String platform; + private boolean captureNative; + private NativeWidgetFactory nativeFactory; + + public void runSuite() { + try { + runSuiteImpl(); + } catch (Throwable t) { + println("CN1SS:ERR:fidelity suite crashed: " + t); + Log.e(t); + } finally { + println("CN1SS:SUITE:FINISHED"); + Log.p("CN1SS:SUITE:FINISHED"); + try { + Thread.sleep(500); + } catch (InterruptedException ignored) { + } + try { + Display.getInstance().exitApplication(); + } catch (Throwable ignored) { + } + } + } + + private void runSuiteImpl() { + platform = resolvePlatform(); + // Default ON: every run also renders the native reference so the host + // can (re)generate goldens and report fidelity without separate device + // property plumbing. Set cn1ss.fidelity.captureNative=false to skip. + captureNative = !"false".equals(Display.getInstance().getProperty("cn1ss.fidelity.captureNative", "true")); + println("CN1SS:INFO:fidelity platform=" + platform + " captureNative=" + captureNative); + spec = loadSpec(); + if (spec == null) { + println("CN1SS:ERR:fidelity spec failed to load from " + SPEC_RESOURCE); + return; + } + if (captureNative) { + try { + NativeWidgetFactory f = (NativeWidgetFactory) NativeLookup.create(NativeWidgetFactory.class); + if (f != null && f.isSupported()) { + nativeFactory = f; + } else { + println("CN1SS:WARN:fidelity native factory unavailable on " + platform); + } + } catch (Throwable t) { + println("CN1SS:WARN:fidelity native factory lookup failed: " + t); + } + } + installNativeTheme(); + + List components = spec.getComponents(); + List appearances = spec.getAppearances(); + for (int i = 0; i < components.size(); i++) { + ComponentSpec c = (ComponentSpec) components.get(i); + if (!c.appliesToPlatform(platform)) { + println("CN1SS:INFO:fidelity skip " + c.getId() + " (not applicable on " + platform + ")"); + continue; + } + if (!Cn1WidgetRenderer.isSupported(c.getId())) { + println("CN1SS:INFO:fidelity skip " + c.getId() + " (CN1 renderer not implemented yet)"); + continue; + } + for (int a = 0; a < appearances.size(); a++) { + String appearance = (String) appearances.get(a); + // Isolate each component+appearance so one bad render (e.g. a + // native bridge failure) cannot abort the whole suite. + try { + renderCn1(c, appearance); + } catch (Throwable t) { + println("CN1SS:ERR:fidelity cn1 render failed " + c.getId() + " " + appearance + " " + t); + } + if (nativeFactory != null) { + try { + renderNative(c, appearance); + } catch (Throwable t) { + println("CN1SS:ERR:fidelity native render failed " + c.getId() + " " + appearance + " " + t); + StackTraceElement[] st = t.getStackTrace(); + for (int k = 0; k < st.length && k < 10; k++) { + println("CN1SS:ERR:fidelity at " + st[k]); + } + } + } + } + } + } + + // ---- CN1 render ---- + + private void renderCn1(final ComponentSpec c, final String appearance) { + final int w = pixels(spec.tileWidthMm(c), true); + final int h = pixels(spec.tileHeightMm(c), false); + final List states = c.getStates(); + final List wrappers = new ArrayList(); + final List names = new ArrayList(); + runOnEdtSync(new Runnable() { + public void run() { + applyAppearance(appearance); + Form form = new Form("fidelity", BoxLayout.y()); + form.getAllStyles().setBgColor(bgColor(appearance)); + form.getAllStyles().setBgTransparency(255); + form.getContentPane().getAllStyles().setBgColor(bgColor(appearance)); + form.getContentPane().getAllStyles().setBgTransparency(255); + for (int s = 0; s < states.size(); s++) { + String state = (String) states.get(s); + Component comp = Cn1WidgetRenderer.build(c, state, appearance); + if (comp == null) { + continue; + } + com.codename1.ui.Display disp = com.codename1.ui.Display.getInstance(); + com.codename1.ui.plaf.Style st = comp.getAllStyles(); + st.setMarginUnit(com.codename1.ui.plaf.Style.UNIT_TYPE_PIXELS, + com.codename1.ui.plaf.Style.UNIT_TYPE_PIXELS, + com.codename1.ui.plaf.Style.UNIT_TYPE_PIXELS, + com.codename1.ui.plaf.Style.UNIT_TYPE_PIXELS); + if ("ios".equals(platform)) { + // iOS: the native reference renders full-width widgets filling + // the tile (handled by newTile's BorderLayout) and content-sized + // controls pinned top-left at frame (0,0). Slider/progress are + // full-width but thin, vertically centred (newTile uses a + // LEFT/CENTRE flow). There is no Material 48dp touch-target inset + // on iOS, so every widget anchors at the top-left with no margin. + if ("Toolbar".equals(c.getId())) { + // Match the native nav bar's covered height (~7.3mm); the + // bar is NORTH-anchored so the sharp backdrop fills below. + comp.setPreferredH(disp.convertToPixels(7.3f)); + } else if ("Slider".equals(c.getId())) { + comp.setPreferredW(w); + // Height = the knob's height (the painter draws the thumb at + // the component height); taller so the knob is a tall vertical + // capsule, not a short horizontal oval. + comp.setPreferredH(disp.convertToPixels(5.5f)); + } else if ("ProgressBar".equals(c.getId())) { + comp.setPreferredW(w); + comp.setPreferredH(Math.max(8, disp.convertToPixels(0.8f))); // ~2x thicker bar + } + st.setMargin(0, 0, 0, 0); + } else { + // Android Material: size full-width widgets and land the visible + // part where native lands it -- centred vertically inside a 48dp + // minimum touch target (an 88px switch sits 22px down in a 132px + // view, a 110px button 11px down). CheckBox/Radio and Slider also + // carry a horizontal box/track inset on the native side. + if ("Slider".equals(c.getId())) { + comp.setPreferredW(w * 3 / 5 - disp.convertToPixels(0.4f)); // ~616px drawn width + comp.setPreferredH(disp.convertToPixels(7f)); // M3 slider is tall (~122px) + } else if ("ProgressBar".equals(c.getId())) { + comp.setPreferredW(w * 2 / 3); + comp.setPreferredH(Math.max(6, h / 21)); // thin line (~11px) + } else if ("Tabs".equals(c.getId()) || "Toolbar".equals(c.getId())) { + comp.setPreferredW(w); + } + int band = disp.convertToPixels(7.62f); // ~48dp = 132px + int prefH = comp.getPreferredH(); + int topInset; + if ("TextField".equals(c.getId())) { + topInset = disp.convertToPixels(0.87f); // ~15px outlined-field top inset + } else if ("ProgressBar".equals(c.getId())) { + topInset = disp.convertToPixels(0.93f); // ~16px; no 48dp touch target + } else if ("Tabs".equals(c.getId()) || "Toolbar".equals(c.getId()) || "Dialog".equals(c.getId())) { + topInset = 0; // app bar / dialog card anchor top-left + } else { + topInset = Math.max(0, (band - prefH) / 2); // centre in 48dp target + } + int leftInset = 0; + if ("Slider".equals(c.getId())) { + leftInset = disp.convertToPixels(2.2f); // ~38px track side padding + } + st.setMargin(topInset, 0, leftInset, 0); + } + Container tile = newTile(comp, c.getId(), w, h, appearance); + form.add(centerRow(tile)); + wrappers.add(tile); + names.add(c.getId() + "_" + state + "_" + appearance + "_cn1"); + } + // Switch forms instantly: the default slide transition would + // otherwise be captured mid-animation, bleeding the previous + // (e.g. light) form into this one's screenshot. + form.setTransitionInAnimator(CommonTransitions.createEmpty()); + form.setTransitionOutAnimator(CommonTransitions.createEmpty()); + form.show(); + } + }); + settle(); + emitTiles(wrappers, names); + } + + // Render each tile into its OWN mutable Image via paintComponent, rather than + // screenshotting the live form. This is what makes CSS backdrop-filter:blur work + // for the glass tiles: the blur hook (Component.internalPaintImpl) calls + // impl.blurRegion, and the iOS/Android/JavaSE ports can blur a mutable image's + // backing buffer in place -- which they cannot do for the live screen drawable. + // It also sidesteps screen retina-scale / peer-compositing entirely. + private void emitTiles(List wrappers, List names) { + final int n = wrappers.size(); + final Image[] imgs = new Image[n]; + runOnEdtSync(new Runnable() { + public void run() { + for (int i = 0; i < n; i++) { + Container tile = (Container) wrappers.get(i); + int cw = tile.getWidth() > 0 ? tile.getWidth() : 1; + int ch = tile.getHeight() > 0 ? tile.getHeight() : 1; + Image img = Image.createImage(cw, ch, 0xffffffff); + com.codename1.ui.Graphics g = img.getGraphics(); + g.translate(-tile.getAbsoluteX(), -tile.getAbsoluteY()); + tile.paintComponent(g, true); + imgs[i] = img; + } + } + }); + for (int i = 0; i < n; i++) { + if (imgs[i] != null) { + Cn1ssDeviceRunnerHelper.emitImage(imgs[i], (String) names.get(i), null); + } + } + } + + // ---- Native render ---- + // + // The native reference is rasterized OFF-SCREEN by the factory (returns PNG + // bytes), so there is no form, no peer, and no window screenshot. This is + // synchronous and GPU-independent, which is what makes it reliable on a + // headless emulator/simulator. The bytes are decoded into a CN1 Image and + // shipped through the same WebSocket path as the CN1 tiles. + + private void renderNative(final ComponentSpec c, final String appearance) { + int w = pixels(spec.tileWidthMm(c), true); + int h = pixels(spec.tileHeightMm(c), false); + String kind = c.getNativeKind(platform); + String text = c.getText(); + if (text == null) { + text = ""; // never pass null across the native bridge (toNSString(null) is unsafe) + } + List states = c.getStates(); + if (kind == null || !nativeFactory.isWidgetSupported(kind)) { + println("CN1SS:INFO:fidelity native skip " + c.getId() + " kind=" + kind); + return; + } + final int fw = w; + final int fh = h; + final String fkind = kind; + final String ftext = text; + for (int s = 0; s < states.size(); s++) { + final String state = (String) states.get(s); + final String appr = appearance; + String name = c.getId() + "_" + state + "_" + appearance + "_native"; + // Hand the native side a writable absolute path as a String ARGUMENT + // (which marshals cleanly on iOS) and get only a boolean back -- no + // object crosses the return boundary, sidestepping the fromNSString / + // nsDataToByteArr return-marshaling NPE in this ParparVM build. + com.codename1.io.FileSystemStorage fs0 = com.codename1.io.FileSystemStorage.getInstance(); + String home = fs0.getAppHomePath(); + if (home == null) { + home = ""; + } + final String outPath = home + (home.endsWith("/") ? "" : "/") + + "cn1ss_native_" + c.getId() + "_" + state + "_" + appearance + ".png"; + final boolean[] holder = new boolean[1]; + // UIKit construction must happen on the iOS main thread; the .m hops + // there itself. We call on the EDT so the native invocation carries a + // valid CN1 thread context. The off-EDT WS emit stays on this thread. + runOnEdtSync(new Runnable() { + public void run() { + try { + holder[0] = nativeFactory.renderWidgetToFile(fkind, state, appr, ftext, outPath, fw, fh); + } catch (Throwable t) { + println("CN1SS:ERR:fidelity native render threw " + state + " " + appr + " " + t); + StackTraceElement[] st = t.getStackTrace(); + for (int k = 0; k < st.length && k < 8; k++) { + println("CN1SS:ERR:fidelity at " + st[k]); + } + } + } + }); + if (!holder[0]) { + println("CN1SS:WARN:fidelity native render returned false " + name); + continue; + } + // The factory wrote the PNG to outPath; read it back and ship the bytes. + byte[] png = readFileBytes(outPath); + if (png == null || png.length == 0) { + println("CN1SS:WARN:fidelity native unreadable " + name + " path=" + outPath); + continue; + } + Cn1ssDeviceRunnerHelper.emitPngBytes(png, name); + } + } + + /** Reads a PNG file the native factory wrote (path returned across the bridge). */ + private byte[] readFileBytes(String path) { + try { + com.codename1.io.FileSystemStorage fs = com.codename1.io.FileSystemStorage.getInstance(); + java.io.InputStream is = fs.openInputStream(path); + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = is.read(buf)) > 0) { + bos.write(buf, 0, n); + } + is.close(); + return bos.toByteArray(); + } catch (Throwable t) { + println("CN1SS:ERR:fidelity read native file failed " + path + " " + t); + return null; + } + } + + // ---- shared capture/crop/emit ---- + + private void cropAndEmit(Image screen, List wrappers, List names, int w, int h) { + if (screen == null) { + println("CN1SS:ERR:fidelity screenshot returned null"); + return; + } + int sw = screen.getWidth(); + int sh = screen.getHeight(); + for (int i = 0; i < wrappers.size(); i++) { + Container tile = (Container) wrappers.get(i); + String name = (String) names.get(i); + int ax = tile.getAbsoluteX(); + int ay = tile.getAbsoluteY(); + int cw = tile.getWidth() > 0 ? tile.getWidth() : w; + int ch = tile.getHeight() > 0 ? tile.getHeight() : h; + // Clamp the crop rectangle inside the screenshot bounds. + if (ax < 0) { + ax = 0; + } + if (ay < 0) { + ay = 0; + } + if (ax + cw > sw) { + cw = sw - ax; + } + if (ay + ch > sh) { + ch = sh - ay; + } + if (cw <= 0 || ch <= 0) { + println("CN1SS:ERR:fidelity bad crop " + name + " ax=" + ax + " ay=" + ay + " cw=" + cw + " ch=" + ch); + continue; + } + Image tileImage = screen.subImage(ax, ay, cw, ch, true); + Cn1ssDeviceRunnerHelper.emitImage(tileImage, name, null); + } + } + + private Image captureScreen() { + final Image[] out = new Image[1]; + final Object lock = new Object(); + final boolean[] done = new boolean[1]; + Display.getInstance().callSerially(new Runnable() { + public void run() { + try { + Display.getInstance().screenshot(new com.codename1.util.SuccessCallback() { + public void onSucess(Image value) { + synchronized (lock) { + out[0] = value; + done[0] = true; + lock.notifyAll(); + } + } + }); + } catch (Throwable t) { + println("CN1SS:ERR:fidelity screenshot threw " + t); + synchronized (lock) { + done[0] = true; + lock.notifyAll(); + } + } + } + }); + synchronized (lock) { + long deadline = System.currentTimeMillis() + SCREENSHOT_TIMEOUT_MS; + while (!done[0]) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) { + break; + } + try { + lock.wait(remaining); + } catch (InterruptedException ie) { + break; + } + } + } + return out[0]; + } + + // ---- helpers ---- + + private Container newTile(Component comp, String compId, int w, int h, String appearance) { + // Android Material widgets centre their VISIBLE part inside a 48dp minimum + // touch target (e.g. an 88px switch track sits 22px down inside a 132px + // view; a 110px button 11px down). The native reference render carries that + // inset, so to land the CN1 widget at the SAME absolute position we place it + // left-aligned but vertically centred inside an identical 48dp band at the + // top of the tile. Component theme margin is neutralized (it is external + // spacing, absent on the native side) so only the touch-target inset places + // the widget. + // Genuinely full-width widgets (text field, bars, toolbar, tabs, dialog) + // span the whole tile in a real app, so we stretch them edge-to-edge with + // BorderLayout.CENTER -- matching the native reference, which fills the tile + // for these kinds. Content-sized controls (buttons, switch, checkbox/radio) + // keep their preferred size pinned top-left. + boolean fullWidth = isFullWidthKind(compId); + boolean widthCenter = isWidthCenterKind(compId); + // The iOS 26 tab bar is a floating glass PILL, content-sized and CENTRED + // horizontally near the top -- not stretched to the tile width. + boolean centered = "ios".equals(platform) && "Tabs".equals(compId); + Container tile; + if (centered) { + tile = new Container(new FlowLayout(Component.CENTER, Component.TOP)); + } else if (fullWidth) { + tile = new Container(new BorderLayout()); + } else if (widthCenter) { + // Full-width but thin. The slider track floats vertically centred; the + // progress bar sits at the TOP of the tile (the native linear bar is a + // top-anchored hairline), so progress is top-aligned, slider centred. + int valign = "ProgressBar".equals(compId) ? Component.TOP : Component.CENTER; + tile = new Container(new FlowLayout(Component.LEFT, valign)); + } else { + tile = new Container(new FlowLayout(Component.LEFT, Component.TOP)); + } + Image backdrop = isGlassKind(compId) ? getGlassBackdrop() : null; + if (backdrop != null) { + // Liquid Glass needs content behind it. The iOS native reference renders + // these widgets over the SAME committed backdrop PNG, so CN1 must too -- + // the only difference that should remain is how each renders the glass. + // STRETCH (SCALED, ignore aspect) to match the native ref's .scaleToFill + // so the two backdrops are pixel-for-pixel the same gradient; the + // comparator then masks that shared backdrop out and scores only the + // widget (SCALED_FILL aspect-cropped differently from native and let the + // gradient mismatch leak into the fidelity score). + tile.getAllStyles().setBgImage(backdrop); + tile.getAllStyles().setBackgroundType(com.codename1.ui.plaf.Style.BACKGROUND_IMAGE_SCALED); + tile.getAllStyles().setBgTransparency(255); + } else { + tile.getAllStyles().setBgColor(bgColor(appearance)); + tile.getAllStyles().setBgTransparency(255); + } + tile.getAllStyles().setPadding(0, 0, 0, 0); + tile.getAllStyles().setMargin(0, 0, 0, 0); + tile.setPreferredW(w); + tile.setPreferredH(h); + if (fullWidth) { + if ("ios".equals(platform) && "Toolbar".equals(compId)) { + // The native nav bar covers only the top ~7mm of the tile (its blurred + // glass); the rest of the tile shows the SHARP backdrop. Anchor the CN1 + // bar NORTH at its natural height so the tile's (sharp) backdrop shows + // below it, matching the native golden -- not a full-height blurred bar. + tile.add(BorderLayout.NORTH, comp); + } else { + tile.add(BorderLayout.CENTER, comp); + } + } else { + tile.add(comp); + } + return tile; + } + + /// Full-width widgets that fill the whole tile (both CN1 and the native + /// reference stretch them edge-to-edge). iOS only -- on Android the tuned + /// preferred-size + 48dp inset path handles layout, so this stays false there + /// to preserve the committed Android baseline. Buttons/switch/checkbox/radio + /// are content-sized and excluded on every platform. + private boolean isFullWidthKind(String compId) { + if (!"ios".equals(platform) || compId == null) { + return false; + } + return "TextField".equals(compId) + || "Toolbar".equals(compId) || "Dialog".equals(compId) + || "Spinner".equals(compId); // picker wheel fills the tile, like UIPickerView + } + + /// Full-width-but-thin iOS widgets (slider, progress) that span the tile width + /// and float vertically centred, like the native track. iOS only. + private boolean isWidthCenterKind(String compId) { + if (!"ios".equals(platform) || compId == null) { + return false; + } + return "Slider".equals(compId) || "ProgressBar".equals(compId); + } + + /// Glass-styled iOS widgets that are rendered over the shared backdrop (the iOS + /// native reference uses iOS 26 Liquid Glass for these). iOS only -- Android + /// Material does not use glass, so its tiles stay on the plain background. + private boolean isGlassKind(String compId) { + if (!"ios".equals(platform) || compId == null) { + return false; + } + return "Button".equals(compId) || "RaisedButton".equals(compId) || "FlatButton".equals(compId) + || "Toolbar".equals(compId) || "Tabs".equals(compId); + } + + private Image glassBackdrop; + private boolean glassBackdropLoaded; + + /// The shared glass backdrop PNG (same asset the native reference uses), loaded + /// from the app resources once. Null if absent. + private Image getGlassBackdrop() { + if (!glassBackdropLoaded) { + glassBackdropLoaded = true; + InputStream in = Display.getInstance().getResourceAsStream(getClass(), "/glass-backdrop.png"); + if (in == null) { + in = getClass().getResourceAsStream("/glass-backdrop.png"); + } + if (in != null) { + try { + glassBackdrop = Image.createImage(in); + } catch (Throwable t) { + println("CN1SS:WARN:fidelity glass backdrop load failed " + t); + } finally { + Util.cleanup(in); + } + } + } + return glassBackdrop; + } + + private Container centerRow(Container tile) { + Container row = new Container(new FlowLayout(Component.CENTER, Component.CENTER)); + row.getAllStyles().setPadding(1, 1, 0, 0); + row.getAllStyles().setMargin(0, 0, 0, 0); + row.add(tile); + return row; + } + + private int bgColor(String appearance) { + return "dark".equals(appearance) ? 0x000000 : 0xffffff; + } + + private void applyAppearance(String appearance) { + boolean dark = "dark".equals(appearance); + try { + Display.getInstance().setDarkMode(Boolean.valueOf(dark)); + } catch (Throwable ignored) { + } + try { + UIManager.getInstance().refreshTheme(); + } catch (Throwable ignored) { + } + } + + private int pixels(int mm, boolean horizontal) { + int px = Display.getInstance().convertToPixels(mm, horizontal); + return px > 0 ? px : mm; + } + + private void settle() { + try { + Thread.sleep(SETTLE_MS); + } catch (InterruptedException ignored) { + } + } + + private void runOnEdtSync(Runnable r) { + Display d = Display.getInstance(); + if (d.isEdt()) { + r.run(); + } else { + d.callSeriallyAndWait(r); + } + } + + private FidelitySpec loadSpec() { + InputStream in = Display.getInstance().getResourceAsStream(getClass(), SPEC_RESOURCE); + if (in == null) { + in = getClass().getResourceAsStream(SPEC_RESOURCE); + } + if (in == null) { + return null; + } + try { + return FidelitySpecParser.parse(in); + } catch (Throwable t) { + println("CN1SS:ERR:fidelity spec parse failed " + t); + return null; + } finally { + Util.cleanup(in); + } + } + + private InputStream openTheme(String name) { + InputStream in = Display.getInstance().getResourceAsStream(getClass(), name); + if (in == null) { + in = getClass().getResourceAsStream(name); + } + return in; + } + + private void installNativeTheme() { + String resourceName = resolveThemeResource(); + if (resourceName == null) { + println("CN1SS:WARN:fidelity no native theme resource for platform=" + platform); + return; + } + // Prefer a bundled dev override (e.g. /AndroidMaterialThemeDev.res) when + // present, so theme-development iterations can ship a freshly-compiled + // theme inside the app without rebuilding the platform port. Falls back + // to the port's shipped theme otherwise. + String devName = resourceName.substring(0, resourceName.length() - 4) + "Dev.res"; + InputStream in = openTheme(devName); + if (in != null) { + println("CN1SS:INFO:fidelity using dev theme override " + devName); + resourceName = devName; + } else { + in = openTheme(resourceName); + } + if (in == null) { + println("CN1SS:WARN:fidelity native theme resource missing: " + resourceName); + return; + } + try { + Resources r = Resources.open(in); + String[] names = r.getThemeResourceNames(); + if (names == null || names.length == 0) { + println("CN1SS:ERR:fidelity native theme has no themes: " + resourceName); + return; + } + UIManager.getInstance().setThemeProps(r.getTheme(names[0])); + println("CN1SS:INFO:fidelity installed theme " + resourceName + " name=" + names[0]); + } catch (Throwable ex) { + println("CN1SS:ERR:fidelity native theme load failed: " + ex + " resource=" + resourceName); + } finally { + Util.cleanup(in); + } + } + + private String resolveThemeResource() { + String forced = Display.getInstance().getProperty("cn1ss.fidelity.themeResource", null); + if (forced != null && forced.length() > 0) { + return forced; + } + if ("ios".equals(platform)) { + return "/iOSModernTheme.res"; + } + if (platform != null && platform.startsWith("and")) { + return "/AndroidMaterialTheme.res"; + } + return Display.getInstance().getProperty("cn1.modernThemeResource", null); + } + + private String resolvePlatform() { + String forced = Display.getInstance().getProperty("cn1ss.fidelity.platform", null); + if (forced != null && forced.length() > 0) { + return forced; + } + return Display.getInstance().getPlatformName(); + } + + private static void println(String line) { + System.out.println(line); + } +} diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/NativeWidgetFactory.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/NativeWidgetFactory.java new file mode 100644 index 0000000000..04eddbeb6b --- /dev/null +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/NativeWidgetFactory.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * + * 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 Codename One 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.codenameone.fidelity; + +import com.codename1.system.NativeInterface; + +/** + * Bridge to the REAL native OS widgets used as fidelity references. Each platform + * implementation (Objective-C UIKit on iOS, Material Views on Android) builds the + * requested widget at the exact pixel size, applies the requested visual state + * and appearance, rasterizes it OFF-SCREEN (Android: View.draw onto a Bitmap; + * iOS: CALayer renderInContext), and returns the PNG bytes. + * + * Off-screen rasterization (rather than wrapping in a peer and screenshotting the + * window) is deliberate: it is synchronous, deterministic, exactly tile-sized, + * and independent of the GPU/compositor, so it works reliably on a headless + * emulator/simulator where a full-window capture of a native peer can fail. + */ +public interface NativeWidgetFactory extends NativeInterface { + /** + * Builds the native widget identified by {@code kind} (the YAML "native" / + * "native_android" key) at {@code widthPx} x {@code heightPx}, in the given + * state ("normal", "pressed", "disabled", "selected") and appearance + * ("light"/"dark"), rasterizes it to a PNG, and writes those bytes to the + * absolute filesystem path {@code outPath} (which the caller then reads back + * via FileSystemStorage). Returns true on success, false if the kind is + * unknown on this platform or rendering/writing failed. + * + * The transport is deliberate: in this ParparVM iOS build, native methods that + * RETURN an object (byte[] via nsDataToByteArr, or String via fromNSString) NPE + * in the return marshaling, but String ARGUMENTS (toNSString) and primitive + * boolean returns marshal cleanly. So the PNG path is handed IN as a String arg + * and only a boolean comes back -- no object ever crosses the return boundary. + */ + boolean renderWidgetToFile(String kind, String state, String appearance, String text, String outPath, int widthPx, int heightPx); + + /** True when this platform can build the given widget kind. */ + boolean isWidgetSupported(String kind); +} diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/render/Cn1WidgetRenderer.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/render/Cn1WidgetRenderer.java new file mode 100644 index 0000000000..f276b361cd --- /dev/null +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/render/Cn1WidgetRenderer.java @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * + * 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 Codename One 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.codenameone.fidelity.render; + +import com.codename1.components.FloatingActionButton; +import com.codename1.components.Switch; +import com.codename1.ui.Button; +import com.codename1.ui.FontImage; +import com.codename1.ui.Image; +import com.codename1.ui.CheckBox; +import com.codename1.ui.Component; +import com.codename1.ui.Label; +import com.codename1.ui.RadioButton; +import com.codename1.ui.Container; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.GridLayout; +import com.codename1.ui.Slider; +import com.codename1.ui.Tabs; +import com.codename1.ui.TextField; +import com.codename1.ui.Toolbar; +import com.codenameone.fidelity.spec.ComponentSpec; + +/** + * Builds the Codename One component for a spec + state, applying the native theme + * UIID and the requested visual state. Returns null for component kinds not yet + * supported (containers like Tabs/Toolbar/Dialog are handled separately), so the + * runner can skip them cleanly. + */ +public final class Cn1WidgetRenderer { + private Cn1WidgetRenderer() { + } + + /** Returns true when this renderer knows how to build the given component id. */ + public static boolean isSupported(String id) { + return "Button".equals(id) || "RaisedButton".equals(id) || "FlatButton".equals(id) + || "TextField".equals(id) || "CheckBox".equals(id) || "RadioButton".equals(id) + || "Switch".equals(id) || "Slider".equals(id) || "ProgressBar".equals(id) + || "FloatingActionButton".equals(id) || "Tabs".equals(id) || "Toolbar".equals(id) + || "Dialog".equals(id) || "Spinner".equals(id); + } + + /** + * Builds the CN1 component, applies UIID + state. The caller is responsible + * for sizing/placing it in a fixed tile and capturing it. + */ + public static Component build(ComponentSpec spec, String state) { + return build(spec, state, "light"); + } + + public static Component build(ComponentSpec spec, String state, String appearance) { + String id = spec.getId(); + String uiid = spec.getCn1Uiid(); + boolean dark = "dark".equals(appearance); + String text = spec.getText() != null ? spec.getText() : ""; + Component c; + if ("Button".equals(id) || "RaisedButton".equals(id) || "FlatButton".equals(id)) { + Button b = new Button(text); + b.setUIID(uiid); + applyButtonState(b, state); + // iOS 26 prominentGlass (RaisedButton) is a translucent fill -- the + // backdrop shows faintly through the blue. Drop the fill alpha a touch + // so the CN1 raised button reads as glass rather than a flat opaque blue. + if ("RaisedButton".equals(id)) { + b.getAllStyles().setBgTransparency(225); + } + c = b; + } else if ("TextField".equals(id)) { + TextField tf = new TextField(text); + tf.setUIID(uiid); + tf.setEditable(false); + // Size to the actual text content. getTextAreaSize() reserves + // columns*widestChar ('m') which overshoots the rendered string and + // made the field box ~10px wider than the native content-sized field. + // columns=1 lets stringWidth(text) drive the width so the box matches. + tf.setColumns(1); + tf.setGrowByContent(true); + if ("disabled".equals(state)) { + tf.setEnabled(false); + } + c = tf; + } else if ("CheckBox".equals(id)) { + // iOS has no native checkbox; the native reference is a glyph only (no + // label). Drop the label on iOS so we compare box-against-box rather + // than penalising CN1 for a label the glyph-only reference omits. + boolean iosGlyph = "ios".equals(com.codename1.ui.Display.getInstance().getPlatformName()); + CheckBox cb = new CheckBox(iosGlyph ? "" : text); + cb.setUIID(uiid); + if ("selected".equals(state)) { + cb.setSelected(true); + } else if ("disabled".equals(state)) { + cb.setEnabled(false); + } + c = cb; + } else if ("RadioButton".equals(id)) { + boolean iosGlyph = "ios".equals(com.codename1.ui.Display.getInstance().getPlatformName()); + RadioButton rb = new RadioButton(iosGlyph ? "" : text); + rb.setUIID(uiid); + if ("selected".equals(state)) { + rb.setSelected(true); + } else if ("disabled".equals(state)) { + rb.setEnabled(false); + } + c = rb; + } else if ("Switch".equals(id)) { + Switch sw = new Switch(); + sw.setUIID(uiid); + if ("selected".equals(state)) { + sw.setValue(true); + } + if ("disabled".equals(state)) { + sw.setEnabled(false); + } + c = sw; + } else if ("Slider".equals(id) || "ProgressBar".equals(id)) { + Slider s = new Slider(); + s.setUIID(uiid); + // An editable slider draws a thumb (matching Material's slider); a + // progress bar has no thumb and is rendered thin by the runner. + s.setEditable("Slider".equals(id)); + s.setMinValue(0); + s.setMaxValue(100); + s.setProgress(50); + if ("disabled".equals(state)) { + s.setEnabled(false); + } + c = s; + } else if ("FloatingActionButton".equals(id)) { + // Material FAB: circular accent button with a "+" glyph. + FloatingActionButton fab = FloatingActionButton.createFAB(FontImage.MATERIAL_ADD); + fab.setUIID(uiid); + if ("disabled".equals(state)) { + fab.setEnabled(false); + } else { + applyButtonState(fab, state); + } + // The native FAB golden is anchored at the tile's top-left corner with + // no app-margin; the FAB's 3mm float-from-edge margin is app layout, not + // widget fidelity, so zero it here to compare widget-against-widget. + fab.getAllStyles().setMargin(0, 0, 0, 0); + // The Android off-screen golden (View rasterized via renderViewOnBitmap) + // does NOT capture the FAB's elevation shadow, whereas CN1's RoundRectBorder + // reserves shadow space (shadowSpread + blur) that insets the rounded-square + // body ~1.5mm from the bounds. To compare the widget body apples-to-apples, + // give the FAB a flat, shadowless rounded-square border for the test so its + // body fills the bounds at the corner, matching the shadowless native ref. + float fabRadius = 2.4f; + try { + fabRadius = Float.parseFloat(com.codename1.ui.plaf.UIManager.getInstance() + .getThemeConstant("fabCornerRadiusMM", "2.4")); + } catch (Throwable ignore) { + } + com.codename1.ui.plaf.RoundRectBorder flat = com.codename1.ui.plaf.RoundRectBorder.create() + .cornerRadius(fabRadius).shadowOpacity(0).shadowSpread(0); + fab.getUnselectedStyle().setBorder(flat); + fab.getSelectedStyle().setBorder(flat); + fab.getPressedStyle().setBorder(flat); + c = fab; + } else if ("Tabs".equals(id)) { + // iOS UITabBar: an icon-over-label bar at the TOP, three items + // (Featured / Search / More) mirroring the native reference's system + // tab items; the first is selected (blue), the rest grey. NOT a + // Material pill strip. + Tabs tabs = new Tabs(Component.TOP); + // The material icons don't auto-tint to the tab's fg, so build them with + // explicit colours: the selected item (Featured) is blue, the rest grey, + // matching the native UITabBar tint. + // The glass pill mutes the selected tint, so start from a more vivid blue. + int selColor = dark ? 0x409cff : 0x0a84ff; + int unselColor = dark ? 0xebebf5 : 0x3c3c43; + com.codename1.ui.plaf.Style selS = new com.codename1.ui.plaf.Style(); + selS.setFgColor(selColor); + selS.setBgTransparency(0); + com.codename1.ui.plaf.Style unS = new com.codename1.ui.plaf.Style(); + unS.setFgColor(unselColor); + unS.setBgTransparency(0); + // Bigger icons (the native tab item is icon-dominant); the label font is + // cut in the theme so the overall item roughly doubles toward native size. + Image star = FontImage.createMaterial(FontImage.MATERIAL_STAR, selS, 4.6f); + Image search = FontImage.createMaterial(FontImage.MATERIAL_SEARCH, unS, 4.6f); + Image more = FontImage.createMaterial(FontImage.MATERIAL_MORE_HORIZ, unS, 4.6f); + tabs.addTab("Featured", star, star, new Container()); + tabs.addTab("Search", search, search, new Container()); + tabs.addTab("More", more, more, new Container()); + tabs.setTabTextPosition(Component.BOTTOM); + c = tabs; + } else if ("Toolbar".equals(id)) { + // Material small top app bar: title on the bar. The CN1 Toolbar + // component requires a Form (setToolBar), so for the standalone tile + // we mirror its appearance with a Toolbar-styled bar + a Title label. + Container bar = new Container(new BorderLayout()); + bar.setUIID("Toolbar"); + Label title = new Label(text); + title.setUIID("Title"); + if ("ios".equals(com.codename1.ui.Display.getInstance().getPlatformName())) { + // A representative iOS navigation bar: a leading back command, a + // centred title and a trailing action -- the bar button items are a + // defining part of the look. The native UINavigationBar lays its + // content row at the TOP of the bar (the bar is taller than the row), + // so anchor the row NORTH and let the bar background fill the tile. + title.getAllStyles().setAlignment(Component.CENTER); + // The iOS 26 glass nav bar is translucent: the backdrop shows + // through, washed toward the bar's base colour. CN1 cannot blur + // (CEF-free), but a translucent bar over the shared backdrop + // approximates it. The light bar washes heavily toward white (232); + // the dark bar keeps far more of the backdrop's colour (the native + // dark glass barely lightens it), so it stays much more translucent. + // iOS 26 glass bar is very translucent -- the colourful backdrop reads + // through at near-full saturation, especially in dark mode (the dark + // glass barely darkens it). Wash only lightly. + // The iOS 26 glass nav bar is VERY translucent -- the colourful backdrop + // reads through at high saturation; an opaque-ish wash (175) read as a + // near-white bar that didn't match the native glass at all. Light glass + // washes only lightly toward white (~90/255); dark glass barely darkens. + // Native nav bar adds NO light tint (the previous white wash was wrong): + // light mode is just the blurred backdrop. The native DARK glass darkens + // the backdrop a touch, so dark keeps a very light black frost; light is + // fully transparent. The blur hook runs regardless of opacity. + bar.getAllStyles().setBgTransparency(dark ? 16 : 0); + Container row = new Container(new BorderLayout()); + row.setUIID("Container"); + row.getAllStyles().setBgTransparency(0); + // iOS 26 bar items are ICON-ONLY inside circular translucent-glass + // buttons. The glyph matches the TITLE colour (black in light, white + // in dark) -- NOT blue. + int tint = dark ? 0xffffff : 0x000000; + com.codename1.ui.plaf.Style tintS = new com.codename1.ui.plaf.Style(); + tintS.setFgColor(tint); + tintS.setBgTransparency(0); + Button back = new Button(""); + back.setUIID("BackCommand"); + back.setIcon(FontImage.createMaterial(FontImage.MATERIAL_ARROW_BACK_IOS_NEW, tintS, 3.2f)); + Button action = new Button(""); + action.setUIID("TitleCommand"); + action.setIcon(FontImage.createMaterial(FontImage.MATERIAL_ADD, tintS, 3.6f)); + row.add(BorderLayout.WEST, back); + row.add(BorderLayout.CENTER, title); + row.add(BorderLayout.EAST, action); + bar.add(BorderLayout.NORTH, row); + } else { + bar.add(BorderLayout.WEST, title); + } + c = bar; + } else if ("Dialog".equals(id)) { + // iOS alert: a rounded card with a centred title + supporting text in + // the middle and a hairline-separated row of two equal blue actions + // pinned to the bottom (Cancel | OK, split by a vertical divider). + Container dialog = new Container(new BorderLayout()); + dialog.setUIID("Dialog"); + Label title = new Label("Title"); + title.setUIID("DialogTitle"); + title.getAllStyles().setAlignment(Component.CENTER); + Label body = new Label(text); + body.setUIID("DialogBody"); + body.getAllStyles().setAlignment(Component.CENTER); + Container content = new Container(BoxLayout.y()); + content.getAllStyles().setBgTransparency(0); + content.add(title); + content.add(body); + Button cancel = new Button("Cancel"); + cancel.setUIID("DialogButton"); + Button ok = new Button("OK"); + ok.setUIID("DialogButton"); + Container btns = new Container(new GridLayout(1, 2)); + btns.setUIID("DialogCommandArea"); + btns.add(cancel); + btns.add(ok); + dialog.add(BorderLayout.CENTER, content); + dialog.add(BorderLayout.SOUTH, btns); + c = dialog; + } else if ("Spinner".equals(id)) { + // iOS picker wheel: a single-column spinner showing several rows with the + // middle one selected, the curved perspective fade and the glass selection + // band -- matching a native UIPickerView. The wheel rows/overlay are styled + // by the SpinnerRenderer / SpinnerOverlay UIIDs in the theme. + com.codename1.ui.spinner.GenericSpinner spinner = new com.codename1.ui.spinner.GenericSpinner(); + com.codename1.ui.list.DefaultListModel model = new com.codename1.ui.list.DefaultListModel( + new Object[]{"Value 1", "Value 2", "Value 3", "Value 4", "Value 5"}); + spinner.setModel(model); + spinner.setRenderingPrototype("Value 0"); + spinner.setValue("Value 3"); + c = spinner; + } else { + return null; + } + return c; + } + + private static void applyButtonState(Button b, String state) { + if ("disabled".equals(state)) { + b.setEnabled(false); + } else if ("pressed".equals(state)) { + // Force the pressed visual state so the pressed style is painted. + b.pressed(); + } + } +} diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/ComponentSpec.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/ComponentSpec.java new file mode 100644 index 0000000000..6c4bafce55 --- /dev/null +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/ComponentSpec.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * + * 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 Codename One 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.codenameone.fidelity.spec; + +import java.util.ArrayList; +import java.util.List; + +/** + * One component under fidelity test, as parsed from fidelity-tests.yaml. Plain + * fields only (no records / no generics-heavy API) so it translates cleanly on + * ParparVM and the JavaScript port as well as running in the simulator. + */ +public class ComponentSpec { + private String id; + private String cn1Uiid; + private String nativeKind; + private String nativeAndroidKind; + private String text; + private int tileWidthMm = -1; + private int tileHeightMm = -1; + private List states = new ArrayList(); + private List platforms = new ArrayList(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCn1Uiid() { + return cn1Uiid != null ? cn1Uiid : id; + } + + public void setCn1Uiid(String cn1Uiid) { + this.cn1Uiid = cn1Uiid; + } + + /** Native widget key for the given platform name ("ios" or "and"/"android"). */ + public String getNativeKind(String platformName) { + if (platformName != null && platformName.startsWith("and")) { + return nativeAndroidKind; + } + return nativeKind; + } + + public String getNativeKindIos() { + return nativeKind; + } + + public void setNativeKindIos(String nativeKind) { + this.nativeKind = nativeKind; + } + + public String getNativeKindAndroid() { + return nativeAndroidKind; + } + + public void setNativeKindAndroid(String nativeAndroidKind) { + this.nativeAndroidKind = nativeAndroidKind; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public int getTileWidthMm() { + return tileWidthMm; + } + + public void setTileWidthMm(int tileWidthMm) { + this.tileWidthMm = tileWidthMm; + } + + public int getTileHeightMm() { + return tileHeightMm; + } + + public void setTileHeightMm(int tileHeightMm) { + this.tileHeightMm = tileHeightMm; + } + + public List getStates() { + return states; + } + + public void setStates(List states) { + this.states = states; + } + + public List getPlatforms() { + return platforms; + } + + public void setPlatforms(List platforms) { + this.platforms = platforms; + } + + /** + * True when this component should run on the given platform. A component with + * no explicit platforms list runs everywhere; otherwise the platform name + * must match one of the listed entries (matched by prefix so "and" covers + * "android"). Also returns false when the platform has no native widget key. + */ + public boolean appliesToPlatform(String platformName) { + if (platformName == null) { + return false; + } + if (getNativeKind(platformName) == null) { + return false; + } + if (platforms == null || platforms.isEmpty()) { + return true; + } + for (int i = 0; i < platforms.size(); i++) { + String p = (String) platforms.get(i); + if (p == null) { + continue; + } + if (platformName.startsWith(p) || p.startsWith(platformName)) { + return true; + } + } + return false; + } +} diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/FidelitySpec.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/FidelitySpec.java new file mode 100644 index 0000000000..aeaf765485 --- /dev/null +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/FidelitySpec.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * + * 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 Codename One 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.codenameone.fidelity.spec; + +import java.util.ArrayList; +import java.util.List; + +/** + * Parsed fidelity-tests.yaml: the global defaults plus the component list. + */ +public class FidelitySpec { + private int defaultTileWidthMm = 60; + private int defaultTileHeightMm = 14; + private String backgroundHex = "ffffff"; + private List appearances = new ArrayList(); + private List components = new ArrayList(); + + public int getDefaultTileWidthMm() { + return defaultTileWidthMm; + } + + public void setDefaultTileWidthMm(int defaultTileWidthMm) { + this.defaultTileWidthMm = defaultTileWidthMm; + } + + public int getDefaultTileHeightMm() { + return defaultTileHeightMm; + } + + public void setDefaultTileHeightMm(int defaultTileHeightMm) { + this.defaultTileHeightMm = defaultTileHeightMm; + } + + public String getBackgroundHex() { + return backgroundHex; + } + + public void setBackgroundHex(String backgroundHex) { + this.backgroundHex = backgroundHex; + } + + public List getAppearances() { + return appearances; + } + + public void setAppearances(List appearances) { + this.appearances = appearances; + } + + public List getComponents() { + return components; + } + + public void setComponents(List components) { + this.components = components; + } + + /** Effective tile width for a component, honouring its per-component override. */ + public int tileWidthMm(ComponentSpec component) { + return component.getTileWidthMm() > 0 ? component.getTileWidthMm() : defaultTileWidthMm; + } + + /** Effective tile height for a component, honouring its per-component override. */ + public int tileHeightMm(ComponentSpec component) { + return component.getTileHeightMm() > 0 ? component.getTileHeightMm() : defaultTileHeightMm; + } +} diff --git a/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/FidelitySpecParser.java b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/FidelitySpecParser.java new file mode 100644 index 0000000000..ce8d988e01 --- /dev/null +++ b/scripts/fidelity-app/common/src/main/java/com/codenameone/fidelity/spec/FidelitySpecParser.java @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * + * 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 Codename One 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.codenameone.fidelity.spec; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Minimal, dependency-free parser for the flat YAML subset used by + * fidelity-tests.yaml. Deliberately tiny so it translates on every CN1 backend + * (no SnakeYAML, no regex-heavy logic). Understands: + * - two top-level sections: "defaults:" and "components:" + * - 2-space-indented "key: value" maps + * - comma-separated scalar lists (appearances, states, platforms) + * - list items under components introduced by "- " + * - "#" line comments and optional single/double quotes around values + * It does NOT support anchors, flow style, nested maps, or tabs. + */ +public class FidelitySpecParser { + private FidelitySpecParser() { + } + + /** Reads the whole stream as UTF-8 and parses it. Closes the stream. */ + public static FidelitySpec parse(InputStream in) throws IOException { + if (in == null) { + throw new IOException("fidelity spec stream is null"); + } + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) >= 0) { + bos.write(buffer, 0, read); + } + return parse(new String(bos.toByteArray(), "UTF-8")); + } finally { + try { + in.close(); + } catch (IOException ignored) { + } + } + } + + public static FidelitySpec parse(String text) { + FidelitySpec spec = new FidelitySpec(); + if (text == null) { + return spec; + } + String section = ""; + ComponentSpec current = null; + String[] lines = splitLines(text); + for (int i = 0; i < lines.length; i++) { + String raw = stripComment(lines[i]); + if (raw.trim().length() == 0) { + continue; + } + int indent = leadingSpaces(raw); + String trimmed = raw.trim(); + if (indent == 0 && trimmed.endsWith(":")) { + section = trimmed.substring(0, trimmed.length() - 1).trim(); + current = null; + continue; + } + if ("defaults".equals(section)) { + applyDefault(spec, trimmed); + } else if ("components".equals(section)) { + if (trimmed.startsWith("- ")) { + current = new ComponentSpec(); + spec.getComponents().add(current); + applyComponentField(current, trimmed.substring(2).trim()); + } else if (current != null) { + applyComponentField(current, trimmed); + } + } + } + // Default any component without an explicit states list to a single + // "normal" state so the suite always renders something for it. + for (int i = 0; i < spec.getComponents().size(); i++) { + ComponentSpec c = (ComponentSpec) spec.getComponents().get(i); + if (c.getStates() == null || c.getStates().isEmpty()) { + List def = new ArrayList(); + def.add("normal"); + c.setStates(def); + } + } + if (spec.getAppearances() == null || spec.getAppearances().isEmpty()) { + List def = new ArrayList(); + def.add("light"); + spec.getAppearances().add("light"); + } + return spec; + } + + private static void applyDefault(FidelitySpec spec, String keyValue) { + int idx = keyValue.indexOf(':'); + if (idx < 0) { + return; + } + String key = keyValue.substring(0, idx).trim(); + String value = unquote(keyValue.substring(idx + 1).trim()); + if ("tile_width_mm".equals(key)) { + spec.setDefaultTileWidthMm(parseInt(value, spec.getDefaultTileWidthMm())); + } else if ("tile_height_mm".equals(key)) { + spec.setDefaultTileHeightMm(parseInt(value, spec.getDefaultTileHeightMm())); + } else if ("bg".equals(key)) { + spec.setBackgroundHex(value); + } else if ("appearances".equals(key)) { + spec.setAppearances(splitList(value)); + } + } + + private static void applyComponentField(ComponentSpec component, String keyValue) { + int idx = keyValue.indexOf(':'); + if (idx < 0) { + return; + } + String key = keyValue.substring(0, idx).trim(); + String value = unquote(keyValue.substring(idx + 1).trim()); + if ("id".equals(key)) { + component.setId(value); + } else if ("cn1_uiid".equals(key)) { + component.setCn1Uiid(value); + } else if ("native".equals(key)) { + component.setNativeKindIos(value); + } else if ("native_android".equals(key)) { + component.setNativeKindAndroid(value); + } else if ("text".equals(key)) { + component.setText(value); + } else if ("tile_width_mm".equals(key)) { + component.setTileWidthMm(parseInt(value, -1)); + } else if ("tile_height_mm".equals(key)) { + component.setTileHeightMm(parseInt(value, -1)); + } else if ("states".equals(key)) { + component.setStates(splitList(value)); + } else if ("platforms".equals(key)) { + component.setPlatforms(splitList(value)); + } + } + + private static String[] splitLines(String text) { + String normalized = replaceAll(text, "\r\n", "\n"); + normalized = replaceAll(normalized, "\r", "\n"); + List parts = new ArrayList(); + int start = 0; + for (int i = 0; i < normalized.length(); i++) { + if (normalized.charAt(i) == '\n') { + parts.add(normalized.substring(start, i)); + start = i + 1; + } + } + parts.add(normalized.substring(start)); + String[] out = new String[parts.size()]; + for (int i = 0; i < parts.size(); i++) { + out[i] = (String) parts.get(i); + } + return out; + } + + private static String replaceAll(String s, String from, String to) { + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < s.length()) { + if (s.regionMatches(i, from, 0, from.length())) { + sb.append(to); + i += from.length(); + } else { + sb.append(s.charAt(i)); + i++; + } + } + return sb.toString(); + } + + private static String stripComment(String line) { + int hash = line.indexOf('#'); + if (hash < 0) { + return line; + } + return line.substring(0, hash); + } + + private static int leadingSpaces(String line) { + int n = 0; + while (n < line.length() && line.charAt(n) == ' ') { + n++; + } + return n; + } + + private static List splitList(String value) { + List out = new ArrayList(); + if (value == null || value.length() == 0) { + return out; + } + int start = 0; + for (int i = 0; i <= value.length(); i++) { + if (i == value.length() || value.charAt(i) == ',') { + String item = unquote(value.substring(start, i).trim()); + if (item.length() > 0) { + out.add(item); + } + start = i + 1; + } + } + return out; + } + + private static String unquote(String value) { + if (value.length() >= 2) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return value.substring(1, value.length() - 1); + } + } + return value; + } + + private static int parseInt(String value, int fallback) { + try { + return Integer.parseInt(value.trim()); + } catch (NumberFormatException ex) { + return fallback; + } + } +} diff --git a/scripts/fidelity-app/common/src/main/resources/AndroidMaterialTheme.res b/scripts/fidelity-app/common/src/main/resources/AndroidMaterialTheme.res new file mode 100644 index 0000000000..9fca6c9449 Binary files /dev/null and b/scripts/fidelity-app/common/src/main/resources/AndroidMaterialTheme.res differ diff --git a/scripts/fidelity-app/common/src/main/resources/fidelity-tests.yaml b/scripts/fidelity-app/common/src/main/resources/fidelity-tests.yaml new file mode 100644 index 0000000000..c89a94e9ca --- /dev/null +++ b/scripts/fidelity-app/common/src/main/resources/fidelity-tests.yaml @@ -0,0 +1,130 @@ +# Native fidelity test specification. +# +# Each component below is rendered TWICE on device: once as the real native OS +# widget (UIKit on iOS, Material on Android) and once as the Codename One +# equivalent under the native theme (iOSModernTheme / AndroidMaterialTheme). The +# two renders are diffed to produce a per-(component,state,appearance) fidelity +# score; the goal is to drive every score toward 99-100%. +# +# This file is the trigger for the fidelity CI workflow: editing it re-runs the +# suite. It is parsed on-device by FidelitySpecParser, which understands a flat, +# 2-space-indented subset of YAML (maps + comma-separated scalar lists, "#" +# comments, optional quotes). Do NOT use anchors, flow style, or tabs. +# +# Per-component keys: +# id required; stable identifier, used in screenshot names. +# cn1_uiid CN1 UIID to apply to the CN1 render (defaults to id). +# native iOS native widget key (see NativeWidgetFactoryImpl .m switch). +# native_android Android native widget key (see NativeWidgetFactoryImpl.java). +# text label text for widgets that show text. +# states comma-separated: normal, pressed, disabled, selected. +# platforms comma-separated allow-list (ios, android). Omit = both. +# tile_width_mm per-component width override (logical mm). +# tile_height_mm per-component height override (logical mm). + +defaults: + tile_width_mm: 60 + tile_height_mm: 14 + bg: ffffff + appearances: light,dark + +components: + - id: Button + cn1_uiid: Button + native: ios_uibutton_system + native_android: material_button_filled + text: Default + states: normal,disabled + + - id: RaisedButton + cn1_uiid: RaisedButton + native: ios_uibutton_filled + native_android: material_button_tonal + text: Raised + states: normal,disabled + + - id: FlatButton + cn1_uiid: FlatButton + native: ios_uibutton_plain + native_android: material_button_outlined + text: Flat + states: normal + + - id: TextField + cn1_uiid: TextField + native: ios_uitextfield + native_android: material_textinput + text: Hello + states: normal,disabled + + - id: CheckBox + cn1_uiid: CheckBox + native: ios_check_glyph + native_android: material_checkbox + text: Enabled + states: normal,selected,disabled + + - id: RadioButton + cn1_uiid: RadioButton + native: ios_radio_glyph + native_android: material_radio + text: Option + states: normal,selected,disabled + + - id: Switch + cn1_uiid: Switch + native: ios_uiswitch + native_android: material_switch + states: normal,selected,disabled + + - id: Slider + cn1_uiid: Slider + native: ios_uislider + native_android: material_slider + states: normal,disabled + + - id: ProgressBar + cn1_uiid: ProgressBar + native: ios_uiprogress + native_android: material_progress_linear + states: normal + + - id: Tabs + cn1_uiid: Tabs + native: ios_uitabbar + native_android: material_tablayout + tile_height_mm: 16 + states: normal + + - id: Toolbar + cn1_uiid: Toolbar + native: ios_uinavbar + native_android: material_toolbar + text: Title + tile_height_mm: 16 + states: normal + + - id: Dialog + cn1_uiid: Dialog + native: ios_alert_view + native_android: material_alert_view + text: Message + tile_width_mm: 60 + tile_height_mm: 40 + states: normal + + - id: FloatingActionButton + cn1_uiid: FloatingActionButton + native_android: material_fab + platforms: android + tile_width_mm: 20 + tile_height_mm: 20 + states: normal,pressed + + - id: Spinner + cn1_uiid: Spinner + native: ios_uipickerview + platforms: ios + tile_width_mm: 60 + tile_height_mm: 34 + states: normal diff --git a/scripts/fidelity-app/common/src/main/resources/glass-backdrop.png b/scripts/fidelity-app/common/src/main/resources/glass-backdrop.png new file mode 100644 index 0000000000..9cad2c22f1 Binary files /dev/null and b/scripts/fidelity-app/common/src/main/resources/glass-backdrop.png differ diff --git a/scripts/fidelity-app/common/src/main/resources/iOSModernTheme.res b/scripts/fidelity-app/common/src/main/resources/iOSModernTheme.res new file mode 100644 index 0000000000..6f8ce31de8 Binary files /dev/null and b/scripts/fidelity-app/common/src/main/resources/iOSModernTheme.res differ diff --git a/scripts/fidelity-app/goldens/android/Button_disabled_dark.png b/scripts/fidelity-app/goldens/android/Button_disabled_dark.png new file mode 100644 index 0000000000..57e7d20d24 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Button_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Button_disabled_light.png b/scripts/fidelity-app/goldens/android/Button_disabled_light.png new file mode 100644 index 0000000000..f75d81246d Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Button_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Button_normal_dark.png b/scripts/fidelity-app/goldens/android/Button_normal_dark.png new file mode 100644 index 0000000000..c6a6c0e1ca Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Button_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Button_normal_light.png b/scripts/fidelity-app/goldens/android/Button_normal_light.png new file mode 100644 index 0000000000..0e2d326c82 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Button_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/CheckBox_disabled_dark.png b/scripts/fidelity-app/goldens/android/CheckBox_disabled_dark.png new file mode 100644 index 0000000000..73ca58bda7 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/CheckBox_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/CheckBox_disabled_light.png b/scripts/fidelity-app/goldens/android/CheckBox_disabled_light.png new file mode 100644 index 0000000000..11e16accd8 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/CheckBox_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/android/CheckBox_normal_dark.png b/scripts/fidelity-app/goldens/android/CheckBox_normal_dark.png new file mode 100644 index 0000000000..48f8fa7449 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/CheckBox_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/CheckBox_normal_light.png b/scripts/fidelity-app/goldens/android/CheckBox_normal_light.png new file mode 100644 index 0000000000..90ff4601ec Binary files /dev/null and b/scripts/fidelity-app/goldens/android/CheckBox_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/CheckBox_selected_dark.png b/scripts/fidelity-app/goldens/android/CheckBox_selected_dark.png new file mode 100644 index 0000000000..584e5a5b70 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/CheckBox_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/CheckBox_selected_light.png b/scripts/fidelity-app/goldens/android/CheckBox_selected_light.png new file mode 100644 index 0000000000..d8b959fc2c Binary files /dev/null and b/scripts/fidelity-app/goldens/android/CheckBox_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Dialog_normal_dark.png b/scripts/fidelity-app/goldens/android/Dialog_normal_dark.png new file mode 100644 index 0000000000..a8cf74e184 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Dialog_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Dialog_normal_light.png b/scripts/fidelity-app/goldens/android/Dialog_normal_light.png new file mode 100644 index 0000000000..8162c965d9 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Dialog_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/FlatButton_normal_dark.png b/scripts/fidelity-app/goldens/android/FlatButton_normal_dark.png new file mode 100644 index 0000000000..f8053c7ff3 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/FlatButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/FlatButton_normal_light.png b/scripts/fidelity-app/goldens/android/FlatButton_normal_light.png new file mode 100644 index 0000000000..47adfd44af Binary files /dev/null and b/scripts/fidelity-app/goldens/android/FlatButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/FloatingActionButton_normal_dark.png b/scripts/fidelity-app/goldens/android/FloatingActionButton_normal_dark.png new file mode 100644 index 0000000000..8982e56718 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/FloatingActionButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/FloatingActionButton_normal_light.png b/scripts/fidelity-app/goldens/android/FloatingActionButton_normal_light.png new file mode 100644 index 0000000000..e90a069270 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/FloatingActionButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/FloatingActionButton_pressed_dark.png b/scripts/fidelity-app/goldens/android/FloatingActionButton_pressed_dark.png new file mode 100644 index 0000000000..8982e56718 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/FloatingActionButton_pressed_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/FloatingActionButton_pressed_light.png b/scripts/fidelity-app/goldens/android/FloatingActionButton_pressed_light.png new file mode 100644 index 0000000000..e90a069270 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/FloatingActionButton_pressed_light.png differ diff --git a/scripts/fidelity-app/goldens/android/ProgressBar_normal_dark.png b/scripts/fidelity-app/goldens/android/ProgressBar_normal_dark.png new file mode 100644 index 0000000000..93d5b73502 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/ProgressBar_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/ProgressBar_normal_light.png b/scripts/fidelity-app/goldens/android/ProgressBar_normal_light.png new file mode 100644 index 0000000000..acbbfd7512 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/ProgressBar_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/RadioButton_disabled_dark.png b/scripts/fidelity-app/goldens/android/RadioButton_disabled_dark.png new file mode 100644 index 0000000000..c30ed65cf1 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RadioButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/RadioButton_disabled_light.png b/scripts/fidelity-app/goldens/android/RadioButton_disabled_light.png new file mode 100644 index 0000000000..c15633555f Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RadioButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/android/RadioButton_normal_dark.png b/scripts/fidelity-app/goldens/android/RadioButton_normal_dark.png new file mode 100644 index 0000000000..ca69444abd Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RadioButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/RadioButton_normal_light.png b/scripts/fidelity-app/goldens/android/RadioButton_normal_light.png new file mode 100644 index 0000000000..09beefd49a Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RadioButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/RadioButton_selected_dark.png b/scripts/fidelity-app/goldens/android/RadioButton_selected_dark.png new file mode 100644 index 0000000000..6b1c6e5088 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RadioButton_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/RadioButton_selected_light.png b/scripts/fidelity-app/goldens/android/RadioButton_selected_light.png new file mode 100644 index 0000000000..6f4577c267 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RadioButton_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/android/RaisedButton_disabled_dark.png b/scripts/fidelity-app/goldens/android/RaisedButton_disabled_dark.png new file mode 100644 index 0000000000..cf645d397b Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RaisedButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/RaisedButton_disabled_light.png b/scripts/fidelity-app/goldens/android/RaisedButton_disabled_light.png new file mode 100644 index 0000000000..e248e8bf00 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RaisedButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/android/RaisedButton_normal_dark.png b/scripts/fidelity-app/goldens/android/RaisedButton_normal_dark.png new file mode 100644 index 0000000000..cfd6939ffa Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RaisedButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/RaisedButton_normal_light.png b/scripts/fidelity-app/goldens/android/RaisedButton_normal_light.png new file mode 100644 index 0000000000..c866fb1bd3 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/RaisedButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Slider_disabled_dark.png b/scripts/fidelity-app/goldens/android/Slider_disabled_dark.png new file mode 100644 index 0000000000..0f6bf1af00 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Slider_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Slider_disabled_light.png b/scripts/fidelity-app/goldens/android/Slider_disabled_light.png new file mode 100644 index 0000000000..b44c7e0898 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Slider_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Slider_normal_dark.png b/scripts/fidelity-app/goldens/android/Slider_normal_dark.png new file mode 100644 index 0000000000..bdbb9be5b5 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Slider_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Slider_normal_light.png b/scripts/fidelity-app/goldens/android/Slider_normal_light.png new file mode 100644 index 0000000000..ad4b5c0311 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Slider_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Switch_disabled_dark.png b/scripts/fidelity-app/goldens/android/Switch_disabled_dark.png new file mode 100644 index 0000000000..3219a3a296 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Switch_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Switch_disabled_light.png b/scripts/fidelity-app/goldens/android/Switch_disabled_light.png new file mode 100644 index 0000000000..b7b198a5b3 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Switch_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Switch_normal_dark.png b/scripts/fidelity-app/goldens/android/Switch_normal_dark.png new file mode 100644 index 0000000000..3eaf0ca205 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Switch_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Switch_normal_light.png b/scripts/fidelity-app/goldens/android/Switch_normal_light.png new file mode 100644 index 0000000000..94f59508cb Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Switch_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Switch_selected_dark.png b/scripts/fidelity-app/goldens/android/Switch_selected_dark.png new file mode 100644 index 0000000000..b5705db7f6 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Switch_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Switch_selected_light.png b/scripts/fidelity-app/goldens/android/Switch_selected_light.png new file mode 100644 index 0000000000..d01f9f94bd Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Switch_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Tabs_normal_dark.png b/scripts/fidelity-app/goldens/android/Tabs_normal_dark.png new file mode 100644 index 0000000000..a5500011c7 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Tabs_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Tabs_normal_light.png b/scripts/fidelity-app/goldens/android/Tabs_normal_light.png new file mode 100644 index 0000000000..df26dd5765 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Tabs_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/TextField_disabled_dark.png b/scripts/fidelity-app/goldens/android/TextField_disabled_dark.png new file mode 100644 index 0000000000..c5413a3c78 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/TextField_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/TextField_disabled_light.png b/scripts/fidelity-app/goldens/android/TextField_disabled_light.png new file mode 100644 index 0000000000..a7351a7c09 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/TextField_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/android/TextField_normal_dark.png b/scripts/fidelity-app/goldens/android/TextField_normal_dark.png new file mode 100644 index 0000000000..0f4198cffd Binary files /dev/null and b/scripts/fidelity-app/goldens/android/TextField_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/TextField_normal_light.png b/scripts/fidelity-app/goldens/android/TextField_normal_light.png new file mode 100644 index 0000000000..374ca7b558 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/TextField_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/android/Toolbar_normal_dark.png b/scripts/fidelity-app/goldens/android/Toolbar_normal_dark.png new file mode 100644 index 0000000000..53caf04546 Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Toolbar_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/android/Toolbar_normal_light.png b/scripts/fidelity-app/goldens/android/Toolbar_normal_light.png new file mode 100644 index 0000000000..faa4cfb8bc Binary files /dev/null and b/scripts/fidelity-app/goldens/android/Toolbar_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Button_disabled_dark.png b/scripts/fidelity-app/goldens/ios-metal/Button_disabled_dark.png new file mode 100644 index 0000000000..a726e55941 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Button_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Button_disabled_light.png b/scripts/fidelity-app/goldens/ios-metal/Button_disabled_light.png new file mode 100644 index 0000000000..aaa457b5c2 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Button_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Button_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/Button_normal_dark.png new file mode 100644 index 0000000000..fc875ca60b Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Button_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Button_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/Button_normal_light.png new file mode 100644 index 0000000000..c56cc01415 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Button_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/CheckBox_disabled_dark.png b/scripts/fidelity-app/goldens/ios-metal/CheckBox_disabled_dark.png new file mode 100644 index 0000000000..33b34daed2 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/CheckBox_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/CheckBox_disabled_light.png b/scripts/fidelity-app/goldens/ios-metal/CheckBox_disabled_light.png new file mode 100644 index 0000000000..3b0bda53f5 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/CheckBox_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/CheckBox_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/CheckBox_normal_dark.png new file mode 100644 index 0000000000..1a81135d4c Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/CheckBox_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/CheckBox_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/CheckBox_normal_light.png new file mode 100644 index 0000000000..13f82551ca Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/CheckBox_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/CheckBox_selected_dark.png b/scripts/fidelity-app/goldens/ios-metal/CheckBox_selected_dark.png new file mode 100644 index 0000000000..776a8a4a6a Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/CheckBox_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/CheckBox_selected_light.png b/scripts/fidelity-app/goldens/ios-metal/CheckBox_selected_light.png new file mode 100644 index 0000000000..20a6456a8a Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/CheckBox_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Dialog_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/Dialog_normal_dark.png new file mode 100644 index 0000000000..a67ca231df Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Dialog_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Dialog_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/Dialog_normal_light.png new file mode 100644 index 0000000000..5029dd44c4 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Dialog_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/FlatButton_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/FlatButton_normal_dark.png new file mode 100644 index 0000000000..992e6fa4e4 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/FlatButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/FlatButton_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/FlatButton_normal_light.png new file mode 100644 index 0000000000..10168aacfb Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/FlatButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/ProgressBar_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/ProgressBar_normal_dark.png new file mode 100644 index 0000000000..0648e29652 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/ProgressBar_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/ProgressBar_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/ProgressBar_normal_light.png new file mode 100644 index 0000000000..65947bbcac Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/ProgressBar_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RadioButton_disabled_dark.png b/scripts/fidelity-app/goldens/ios-metal/RadioButton_disabled_dark.png new file mode 100644 index 0000000000..33b34daed2 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RadioButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RadioButton_disabled_light.png b/scripts/fidelity-app/goldens/ios-metal/RadioButton_disabled_light.png new file mode 100644 index 0000000000..3b0bda53f5 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RadioButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RadioButton_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/RadioButton_normal_dark.png new file mode 100644 index 0000000000..1a81135d4c Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RadioButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RadioButton_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/RadioButton_normal_light.png new file mode 100644 index 0000000000..13f82551ca Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RadioButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RadioButton_selected_dark.png b/scripts/fidelity-app/goldens/ios-metal/RadioButton_selected_dark.png new file mode 100644 index 0000000000..1eb1145831 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RadioButton_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RadioButton_selected_light.png b/scripts/fidelity-app/goldens/ios-metal/RadioButton_selected_light.png new file mode 100644 index 0000000000..5774443edd Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RadioButton_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RaisedButton_disabled_dark.png b/scripts/fidelity-app/goldens/ios-metal/RaisedButton_disabled_dark.png new file mode 100644 index 0000000000..0a8452fb96 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RaisedButton_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RaisedButton_disabled_light.png b/scripts/fidelity-app/goldens/ios-metal/RaisedButton_disabled_light.png new file mode 100644 index 0000000000..afbd47b823 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RaisedButton_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RaisedButton_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/RaisedButton_normal_dark.png new file mode 100644 index 0000000000..c91e792c54 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RaisedButton_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/RaisedButton_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/RaisedButton_normal_light.png new file mode 100644 index 0000000000..e16c1451ed Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/RaisedButton_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Slider_disabled_dark.png b/scripts/fidelity-app/goldens/ios-metal/Slider_disabled_dark.png new file mode 100644 index 0000000000..ffe6f49bc4 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Slider_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Slider_disabled_light.png b/scripts/fidelity-app/goldens/ios-metal/Slider_disabled_light.png new file mode 100644 index 0000000000..2b92bf7efd Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Slider_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Slider_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/Slider_normal_dark.png new file mode 100644 index 0000000000..b8c37a3a5e Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Slider_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Slider_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/Slider_normal_light.png new file mode 100644 index 0000000000..cbefac078e Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Slider_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Spinner_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/Spinner_normal_dark.png new file mode 100644 index 0000000000..85815d0ed0 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Spinner_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Spinner_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/Spinner_normal_light.png new file mode 100644 index 0000000000..44ab19994d Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Spinner_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Switch_disabled_dark.png b/scripts/fidelity-app/goldens/ios-metal/Switch_disabled_dark.png new file mode 100644 index 0000000000..4845f141bd Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Switch_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Switch_disabled_light.png b/scripts/fidelity-app/goldens/ios-metal/Switch_disabled_light.png new file mode 100644 index 0000000000..a3e635db17 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Switch_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Switch_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/Switch_normal_dark.png new file mode 100644 index 0000000000..8daec03b4e Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Switch_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Switch_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/Switch_normal_light.png new file mode 100644 index 0000000000..4e37aba797 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Switch_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Switch_selected_dark.png b/scripts/fidelity-app/goldens/ios-metal/Switch_selected_dark.png new file mode 100644 index 0000000000..51d3f3ad50 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Switch_selected_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Switch_selected_light.png b/scripts/fidelity-app/goldens/ios-metal/Switch_selected_light.png new file mode 100644 index 0000000000..299206d701 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Switch_selected_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Tabs_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/Tabs_normal_dark.png new file mode 100644 index 0000000000..db6cc8a56e Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Tabs_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Tabs_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/Tabs_normal_light.png new file mode 100644 index 0000000000..6bd6bdf965 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Tabs_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/TextField_disabled_dark.png b/scripts/fidelity-app/goldens/ios-metal/TextField_disabled_dark.png new file mode 100644 index 0000000000..423848fd82 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/TextField_disabled_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/TextField_disabled_light.png b/scripts/fidelity-app/goldens/ios-metal/TextField_disabled_light.png new file mode 100644 index 0000000000..775e6bf287 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/TextField_disabled_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/TextField_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/TextField_normal_dark.png new file mode 100644 index 0000000000..80aba64eb2 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/TextField_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/TextField_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/TextField_normal_light.png new file mode 100644 index 0000000000..8559bca6cc Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/TextField_normal_light.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Toolbar_normal_dark.png b/scripts/fidelity-app/goldens/ios-metal/Toolbar_normal_dark.png new file mode 100644 index 0000000000..94d4b78b5e Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Toolbar_normal_dark.png differ diff --git a/scripts/fidelity-app/goldens/ios-metal/Toolbar_normal_light.png b/scripts/fidelity-app/goldens/ios-metal/Toolbar_normal_light.png new file mode 100644 index 0000000000..f5434ef84e Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-metal/Toolbar_normal_light.png differ diff --git a/scripts/fidelity-app/ios-native-ref/NativeRef.swift b/scripts/fidelity-app/ios-native-ref/NativeRef.swift new file mode 100644 index 0000000000..2e0ba8cd3b --- /dev/null +++ b/scripts/fidelity-app/ios-native-ref/NativeRef.swift @@ -0,0 +1,347 @@ +// Standalone native iOS app that renders each reference UIKit widget in a REAL +// UIWindow and captures a real screenshot of it (drawHierarchy:afterScreenUpdates), +// so navigation/tab bars and other views that render blank off-screen come out +// correct. The PNGs land in the app's Documents dir and are pulled out by +// build-ios-native-ref.sh and committed as the iOS fidelity goldens. +// +// Sizing: the Codename One side renders each tile at CN1's pixel density +// (~18.1 px per logical mm on the reference simulator). We match that exactly so +// the native and CN1 renders overlay 1:1 with no scaling: the tile is laid out in +// points at PT_PER_MM and captured at scale PX_PER_MM/PT_PER_MM, yielding a PNG of +// (mm * PX_PER_MM) pixels -- identical to the CN1 tile. +import UIKit + +// Keep these in sync with the CN1 iOS render density. PX_PER_MM is measured from a +// CN1 tile (60mm -> 1087px => 18.117). PT_PER_MM is the iOS point density +// (1pt = 1/163in @1x => 6.417 pt/mm) so a widget's natural point size maps to a +// physically sensible pixel size. +let PX_PER_MM: CGFloat = 18.117 +let PT_PER_MM: CGFloat = 6.417 +let CAPTURE_SCALE: CGFloat = PX_PER_MM / PT_PER_MM // ~2.824 + +// Liquid Glass is translucent -- it only reveals itself by refracting/blurring +// content BEHIND it. For the glass widgets we therefore render over a fixed, +// committed backdrop PNG (glass-backdrop.png) shared 1:1 with the Codename One +// side, so the only variance between the two renders is the glass itself, not the +// background. Non-glass widgets keep the plain tile so their diff stays clean. +let GLASS_KINDS: Set = [ + "ios_uibutton_system", "ios_uibutton_plain", "ios_uibutton_filled", + "ios_uinavbar", "ios_uitabbar", +] +// Genuinely full-width widgets that stretch to fill their tile on both sides +// (the CN1 harness fills these too). Content-sized controls -- buttons, switch, +// checkbox/radio glyphs -- keep their natural size pinned top-left so they line +// up with the content-sized CN1 widgets. +let FILL_KINDS: Set = [ + "ios_uitextfield", "ios_uislider", "ios_uiprogress", + "ios_uinavbar", "ios_uitabbar", "ios_alert_view", "ios_uipickerview", +] +let BACKDROP: UIImage? = { + if let p = Bundle.main.path(forResource: "glass-backdrop", ofType: "png") { + return UIImage(contentsOfFile: p) + } + return nil +}() + +struct Spec { + let component: String + let kind: String + let states: [String] + let wMM: CGFloat + let hMM: CGFloat +} + +let SPECS: [Spec] = [ + Spec(component: "Button", kind: "ios_uibutton_system", states: ["normal","disabled"], wMM: 60, hMM: 14), + Spec(component: "RaisedButton",kind: "ios_uibutton_filled", states: ["normal","disabled"], wMM: 60, hMM: 14), + Spec(component: "FlatButton", kind: "ios_uibutton_plain", states: ["normal"], wMM: 60, hMM: 14), + Spec(component: "TextField", kind: "ios_uitextfield", states: ["normal","disabled"], wMM: 60, hMM: 14), + Spec(component: "CheckBox", kind: "ios_check_glyph", states: ["normal","selected","disabled"],wMM: 60, hMM: 14), + Spec(component: "RadioButton", kind: "ios_radio_glyph", states: ["normal","selected","disabled"],wMM: 60, hMM: 14), + Spec(component: "Switch", kind: "ios_uiswitch", states: ["normal","selected","disabled"],wMM: 60, hMM: 14), + Spec(component: "Slider", kind: "ios_uislider", states: ["normal","disabled"], wMM: 60, hMM: 14), + Spec(component: "ProgressBar", kind: "ios_uiprogress", states: ["normal"], wMM: 60, hMM: 14), + Spec(component: "Tabs", kind: "ios_uitabbar", states: ["normal"], wMM: 60, hMM: 16), + Spec(component: "Toolbar", kind: "ios_uinavbar", states: ["normal"], wMM: 60, hMM: 16), + Spec(component: "Dialog", kind: "ios_alert_view", states: ["normal"], wMM: 60, hMM: 40), + Spec(component: "Spinner", kind: "ios_uipickerview", states: ["normal"], wMM: 60, hMM: 34), +] + +// Minimal data source/delegate for the reference UIPickerView (5 string rows). +final class RefPickerDelegate: NSObject, UIPickerViewDataSource, UIPickerViewDelegate { + static let shared = RefPickerDelegate() + let rows = ["Value 1", "Value 2", "Value 3", "Value 4", "Value 5"] + func numberOfComponents(in pickerView: UIPickerView) -> Int { return 1 } + func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int { return rows.count } + func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { return rows[row] } +} + +func textFor(_ kind: String) -> String { + switch kind { + case "ios_uibutton_system": return "Default" + case "ios_uibutton_filled": return "Raised" + case "ios_uibutton_plain": return "Flat" + case "ios_uitextfield": return "Hello" + case "ios_uinavbar": return "Title" + default: return "" + } +} + +func buildControl(_ kind: String, _ state: String, _ wPt: CGFloat, _ hPt: CGFloat) -> UIView? { + let disabled = state == "disabled" + let pressed = state == "pressed" + let selected = state == "selected" + let label = textFor(kind) + switch kind { + case "ios_uibutton_system": + // Modern tinted action button -> iOS 26 Liquid Glass (regular glass). + let b: UIButton + if #available(iOS 26.0, *) { + var cfg = UIButton.Configuration.glass() + cfg.title = label + b = UIButton(configuration: cfg) + } else { + b = UIButton(type: .system) + b.setTitle(label, for: .normal) + } + b.isEnabled = !disabled + b.isHighlighted = pressed + return b + case "ios_uibutton_plain": + // Borderless text button: the clear-glass variant on iOS 26. + let b: UIButton + if #available(iOS 26.0, *) { + var cfg = UIButton.Configuration.clearGlass() + cfg.title = label + b = UIButton(configuration: cfg) + } else { + b = UIButton(type: .system) + b.setTitle(label, for: .normal) + } + b.isEnabled = !disabled + b.isHighlighted = pressed + return b + case "ios_uibutton_filled": + // Prominent / call-to-action button -> iOS 26 prominent Liquid Glass. + let b: UIButton + if #available(iOS 26.0, *) { + var cfg = UIButton.Configuration.prominentGlass() + cfg.title = label + b = UIButton(configuration: cfg) + } else if #available(iOS 15.0, *) { + var cfg = UIButton.Configuration.filled() + cfg.title = label + b = UIButton(configuration: cfg) + } else { + b = UIButton(type: .system) + b.setTitle(label, for: .normal) + b.backgroundColor = .systemBlue + } + b.isEnabled = !disabled + b.isHighlighted = pressed + return b + case "ios_uitextfield": + let tf = UITextField(frame: CGRect(x: 0, y: 0, width: wPt, height: hPt)) + tf.borderStyle = .roundedRect + // The roundedRect default fill collapses to ~pure black in dark mode (the + // field becomes invisible). Use the system's elevated field fill so the + // field reads as a filled control in both appearances -- secondary system + // background is white in light and #1c1c1e in dark, matching CN1's field. + tf.backgroundColor = .secondarySystemBackground + tf.text = label + tf.isEnabled = !disabled + return tf + case "ios_check_glyph": + let b = UIButton(type: .system) + let sym = selected ? "checkmark.circle.fill" : "circle" + let cfg = UIImage.SymbolConfiguration(pointSize: 30) + b.setImage(UIImage(systemName: sym, withConfiguration: cfg), for: .normal) + b.isEnabled = !disabled + return b + case "ios_radio_glyph": + let b = UIButton(type: .system) + let sym = selected ? "largecircle.fill.circle" : "circle" + let cfg = UIImage.SymbolConfiguration(pointSize: 30) + b.setImage(UIImage(systemName: sym, withConfiguration: cfg), for: .normal) + b.isEnabled = !disabled + return b + case "ios_uiswitch": + let sw = UISwitch() + sw.isOn = selected + sw.isEnabled = !disabled + return sw + case "ios_uislider": + let s = UISlider(frame: CGRect(x: 0, y: 0, width: wPt, height: hPt)) + s.minimumValue = 0; s.maximumValue = 100; s.value = 50 + s.isEnabled = !disabled + return s + case "ios_uiprogress": + let p = UIProgressView(progressViewStyle: .default) + p.frame = CGRect(x: 0, y: 0, width: wPt, height: hPt) + p.progress = 0.5 + return p + case "ios_uitabbar": + let bar = UITabBar(frame: CGRect(x: 0, y: 0, width: wPt, height: hPt)) + // Three UNIFORM tab items (custom title+SF-symbol). The .search SYSTEM item + // gets a special floating button on iOS 26, which a normal tab strip does + // not have, so we avoid it to keep a representative glass-pill tab bar. + let a = UITabBarItem(title: "Featured", image: UIImage(systemName: "star.fill"), tag: 0) + let b = UITabBarItem(title: "Search", image: UIImage(systemName: "magnifyingglass"), tag: 1) + let c = UITabBarItem(title: "More", image: UIImage(systemName: "ellipsis"), tag: 2) + bar.items = [a, b, c]; bar.selectedItem = a + // Modern Liquid Glass bar background (default = glass material on iOS 26), + // not the legacy opaque fill. + let ap = UITabBarAppearance() + ap.configureWithDefaultBackground() + bar.standardAppearance = ap + if #available(iOS 15.0, *) { bar.scrollEdgeAppearance = ap } + return bar + case "ios_uinavbar": + let nav = UINavigationBar(frame: CGRect(x: 0, y: 0, width: wPt, height: hPt)) + // A representative nav bar carries a leading back button and a trailing + // action -- bar button items are a defining part of the iOS nav-bar look. + // Pushing a root item makes the system render the "< Back" chevron. + let root = UINavigationItem(title: "Back") + let item = UINavigationItem(title: label) + item.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) + nav.items = [root, item] + // Modern Liquid Glass nav-bar background (default = glass on iOS 26). + let ap = UINavigationBarAppearance() + ap.configureWithDefaultBackground() + nav.standardAppearance = ap + nav.scrollEdgeAppearance = ap + if #available(iOS 15.0, *) { nav.compactScrollEdgeAppearance = ap } + return nav + case "ios_alert_view": + // The presented content view of a UIAlertController (built directly so it + // renders off the presentation flow). + let card = UIView() + card.backgroundColor = UIColor.secondarySystemBackground + card.layer.cornerRadius = 14 + let title = UILabel(); title.text = "Title"; title.font = .boldSystemFont(ofSize: 17); title.textAlignment = .center + let body = UILabel(); body.text = "Message"; body.font = .systemFont(ofSize: 13); body.textAlignment = .center; body.textColor = .secondaryLabel + let sep = UIView(); sep.backgroundColor = .separator + let cancel = UILabel(); cancel.text = "Cancel"; cancel.textColor = .systemBlue; cancel.font = .systemFont(ofSize: 17); cancel.textAlignment = .center + let ok = UILabel(); ok.text = "OK"; ok.textColor = .systemBlue; ok.font = .boldSystemFont(ofSize: 17); ok.textAlignment = .center + let vsep = UIView(); vsep.backgroundColor = .separator + for v in [title, body, sep, cancel, ok, vsep] { v.translatesAutoresizingMaskIntoConstraints = false; card.addSubview(v) } + NSLayoutConstraint.activate([ + card.widthAnchor.constraint(equalToConstant: wPt * 0.92), + title.topAnchor.constraint(equalTo: card.topAnchor, constant: 19), + title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), + title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), + body.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4), + body.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), + body.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), + sep.topAnchor.constraint(equalTo: body.bottomAnchor, constant: 19), + sep.leadingAnchor.constraint(equalTo: card.leadingAnchor), + sep.trailingAnchor.constraint(equalTo: card.trailingAnchor), + sep.heightAnchor.constraint(equalToConstant: 0.5), + cancel.topAnchor.constraint(equalTo: sep.bottomAnchor), + cancel.leadingAnchor.constraint(equalTo: card.leadingAnchor), + cancel.bottomAnchor.constraint(equalTo: card.bottomAnchor), + cancel.heightAnchor.constraint(equalToConstant: 44), + ok.topAnchor.constraint(equalTo: sep.bottomAnchor), + ok.trailingAnchor.constraint(equalTo: card.trailingAnchor), + ok.bottomAnchor.constraint(equalTo: card.bottomAnchor), + ok.leadingAnchor.constraint(equalTo: vsep.trailingAnchor), + cancel.widthAnchor.constraint(equalTo: ok.widthAnchor), + vsep.topAnchor.constraint(equalTo: sep.bottomAnchor), + vsep.bottomAnchor.constraint(equalTo: card.bottomAnchor), + vsep.trailingAnchor.constraint(equalTo: cancel.trailingAnchor), + vsep.widthAnchor.constraint(equalToConstant: 0.5), + ]) + return card + case "ios_uipickerview": + let picker = UIPickerView(frame: CGRect(x: 0, y: 0, width: wPt, height: hPt)) + picker.dataSource = RefPickerDelegate.shared + picker.delegate = RefPickerDelegate.shared + picker.selectRow(2, inComponent: 0, animated: false) // middle row selected + return picker + default: + return nil + } +} + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + let w = UIWindow(frame: UIScreen.main.bounds) + w.rootViewController = UIViewController() + w.makeKeyAndVisible() + self.window = w + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.renderAll(host: w.rootViewController!.view) + } + return true + } + + func renderAll(host: UIView) { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + var count = 0 + for spec in SPECS { + for appearance in ["light", "dark"] { + for state in spec.states { + let wPt = spec.wMM * PT_PER_MM + let hPt = spec.hMM * PT_PER_MM + let container = UIView(frame: CGRect(x: 0, y: 0, width: wPt, height: hPt)) + container.backgroundColor = appearance == "dark" ? .black : .white + container.overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light + if GLASS_KINDS.contains(spec.kind), let bd = BACKDROP { + let iv = UIImageView(frame: container.bounds) + iv.image = bd + iv.contentMode = .scaleToFill + iv.autoresizingMask = [.flexibleWidth, .flexibleHeight] + container.addSubview(iv) + } + guard let control = buildControl(spec.kind, state, wPt, hPt) else { continue } + // The CN1 fidelity harness renders every widget filling its tile and + // (for text widgets) centring the content vertically. Match that for + // the widgets that stretch -- buttons, fields, bars, slider, progress -- + // so the comparison measures the WIDGET rendering (colour, font, corner + // radius, glass) rather than a layout difference the app controls. + // Intrinsically-sized controls (switch, checkbox/radio glyphs) keep + // their natural size pinned top-left, matching how CN1 lays them out. + if FILL_KINDS.contains(spec.kind) { + control.frame = CGRect(x: 0, y: 0, width: wPt, height: hPt) + } else { + control.sizeToFit() + var cs = control.bounds.size + if cs.width <= 0 || cs.width > wPt { cs.width = wPt } + if cs.height <= 0 || cs.height > hPt { cs.height = hPt } + control.frame = CGRect(x: 0, y: 0, width: cs.width, height: cs.height) + } + container.addSubview(control) + host.addSubview(container) + container.setNeedsLayout() + container.layoutIfNeeded() + + let fmt = UIGraphicsImageRendererFormat() + fmt.scale = CAPTURE_SCALE + fmt.opaque = true + // iOS 26 defaults the renderer to extended (16-bit, wide-gamut) + // range, which produces 16-bit PNGs the host comparator can't + // read. Force standard 8-bit sRGB output. + fmt.preferredRange = .standard + let renderer = UIGraphicsImageRenderer(size: CGSize(width: wPt, height: hPt), format: fmt) + let img = renderer.image { _ in + container.drawHierarchy(in: container.bounds, afterScreenUpdates: true) + } + container.removeFromSuperview() + let name = "\(spec.component)_\(state)_\(appearance).png" + if let data = img.pngData() { + try? data.write(to: docs.appendingPathComponent(name)) + count += 1 + let px = Int(wPt * CAPTURE_SCALE) + print("NATIVEREF:wrote \(name) \(px)x\(Int(hPt*CAPTURE_SCALE)) bytes=\(data.count)") + } + } + } + } + print("NATIVEREF:DONE count=\(count) dir=\(docs.path)") + exit(0) + } +} diff --git a/scripts/fidelity-app/ios/pom.xml b/scripts/fidelity-app/ios/pom.xml new file mode 100644 index 0000000000..71970a9950 --- /dev/null +++ b/scripts/fidelity-app/ios/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + com.codenameone.fidelity + fidelity-app + 1.0-SNAPSHOT + + com.codenameone.fidelity + fidelity-app-ios + 1.0-SNAPSHOT + + fidelity-app-ios + + + UTF-8 + 17 + 17 + ios + ios + ios-device + + + + + src/main/objectivec + + + src/main/resources + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + build-ios + package + + build + + + + + + + + + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + tests + test + + + + + + + + + diff --git a/scripts/fidelity-app/ios/src/main/objectivec/com_codenameone_fidelity_NativeWidgetFactoryImpl.h b/scripts/fidelity-app/ios/src/main/objectivec/com_codenameone_fidelity_NativeWidgetFactoryImpl.h new file mode 100644 index 0000000000..f49e508a91 --- /dev/null +++ b/scripts/fidelity-app/ios/src/main/objectivec/com_codenameone_fidelity_NativeWidgetFactoryImpl.h @@ -0,0 +1,11 @@ +#import +#import + +@interface com_codenameone_fidelity_NativeWidgetFactoryImpl : NSObject { +} + +-(BOOL)renderWidgetToFile:(NSString*)kind param1:(NSString*)state param2:(NSString*)appearance param3:(NSString*)text param4:(NSString*)outPath param5:(int)widthPx param6:(int)heightPx; +-(BOOL)isWidgetSupported:(NSString*)kind; +-(BOOL)isSupported; + +@end diff --git a/scripts/fidelity-app/ios/src/main/objectivec/com_codenameone_fidelity_NativeWidgetFactoryImpl.m b/scripts/fidelity-app/ios/src/main/objectivec/com_codenameone_fidelity_NativeWidgetFactoryImpl.m new file mode 100644 index 0000000000..05ade8f355 --- /dev/null +++ b/scripts/fidelity-app/ios/src/main/objectivec/com_codenameone_fidelity_NativeWidgetFactoryImpl.m @@ -0,0 +1,239 @@ +#import "com_codenameone_fidelity_NativeWidgetFactoryImpl.h" + +// iOS side of NativeWidgetFactory: build a REAL UIKit widget, lay it out centered +// in a fixed w x h tile, and rasterize it off-screen to PNG bytes (returned as +// NSData, which the CN1 bridge maps to a Java byte[]). Off-screen rendering via +// -[CALayer renderInContext:] is synchronous and Metal/compositor-independent, +// so it works reliably on the simulator regardless of the rendering backend. + +@implementation com_codenameone_fidelity_NativeWidgetFactoryImpl + +-(BOOL)isSupported { + return YES; +} + +-(BOOL)isWidgetSupported:(NSString*)kind { + return [self knownKind:kind]; +} + +-(BOOL)knownKind:(NSString*)kind { + if (kind == nil) { + return NO; + } + // ParparVM-generated Objective-C is compiled MRC (no ARC), so an autoreleased + // +[NSSet setWithObjects:] cached in a static would be deallocated when the + // autorelease pool drains between native calls -- the next call then derefs a + // freed pointer (EXC_BAD_ACCESS), which ParparVM's signal handler surfaces as a + // bogus java.lang.NullPointerException on the Java side. -[alloc initWithObjects:] + // returns a +1 retained set, so the static cache stays valid for the app's life. + static NSSet* kinds = nil; + if (kinds == nil) { + kinds = [[NSSet alloc] initWithObjects: + @"ios_uibutton_system", @"ios_uibutton_filled", @"ios_uibutton_plain", + @"ios_uitextfield", @"ios_check_glyph", @"ios_radio_glyph", + @"ios_uiswitch", @"ios_uislider", @"ios_uiprogress", + @"ios_uitabbar", @"ios_uinavbar", nil]; + } + return [kinds containsObject:kind]; +} + +-(BOOL)renderWidgetToFile:(NSString*)kind param1:(NSString*)state param2:(NSString*)appearance param3:(NSString*)text param4:(NSString*)outPath param5:(int)widthPx param6:(int)heightPx { + NSLog(@"CN1SS:NATIVE enter kind=%@ state=%@ appearance=%@ w=%d h=%d main=%d out=%@", + kind, state, appearance, widthPx, heightPx, (int)[NSThread isMainThread], outPath); + @try { + if (![self knownKind:kind] || widthPx <= 0 || heightPx <= 0 || outPath == nil) { + NSLog(@"CN1SS:NATIVE reject kind=%@ known=%d w=%d h=%d out=%@", kind, (int)[self knownKind:kind], widthPx, heightPx, outPath); + return NO; + } + // UIKit construction + layout MUST run on the main thread: building most + // controls off-main "works", but some (e.g. an SF-Symbol image button) + // hang in -sizeToFit/-layoutIfNeeded off-main. This native method runs on + // the CN1 EDT (not the iOS main thread), so hop the build to main via + // dispatch_sync. The EDT is free to block here -- the main runloop runs + // independently -- so it does not deadlock. + // buildAndRender returns an AUTORELEASED NSData. It runs on the main queue + // (a different thread, with its own autorelease pool) while the rest of this + // method runs on the calling CN1 EDT. When dispatch_sync returns, main's + // autorelease pool drains and would free that NSData out from under us + // (ParparVM's MRC Obj-C does not retain across the boundary) -- the EDT's + // writeToFile would then hit freed memory. So -retain it inside the block + // and -release it after we are done. + __block NSData* result = nil; + void (^buildBlock)(void) = ^{ + @try { + result = [[self buildAndRender:kind state:state appearance:appearance text:text w:widthPx h:heightPx] retain]; + } @catch (NSException* ex) { + NSLog(@"CN1SS:NATIVE exception kind=%@ : %@", kind, ex); + } + }; + if ([NSThread isMainThread]) { + buildBlock(); + } else { + dispatch_sync(dispatch_get_main_queue(), buildBlock); + } + if (result == nil) { + NSLog(@"CN1SS:NATIVE nil-result kind=%@ -> fallback", kind); + result = [[self fallbackPng:widthPx h:heightPx] retain]; + } + NSString* fsPath = outPath; + if ([fsPath hasPrefix:@"file://"]) { + fsPath = [[NSURL URLWithString:fsPath] path]; + } + BOOL ok = result != nil && [result writeToFile:fsPath atomically:YES]; + NSLog(@"CN1SS:NATIVE done kind=%@ bytes=%lu wrote=%d", kind, (unsigned long)result.length, (int)ok); + [result release]; + return ok; + } @catch (id ex) { + NSLog(@"CN1SS:NATIVE objc-exception kind=%@ : %@", kind, ex); + return NO; + } +} + +// Never return nil to the bridge (nsDataToByteArr(nil) NPEs on the Java side): +// a solid-color tile is a visible, diff-able placeholder when a widget fails. +-(NSData*)fallbackPng:(int)w h:(int)h { + int ww = w > 0 ? w : 2; + int hh = h > 0 ? h : 2; + UIGraphicsImageRendererFormat* fmt = [UIGraphicsImageRendererFormat defaultFormat]; + fmt.scale = 1.0; + fmt.opaque = YES; + UIGraphicsImageRenderer* r = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(ww, hh) format:fmt]; + return [r PNGDataWithActions:^(UIGraphicsImageRendererContext* ctx) { + [[UIColor magentaColor] setFill]; + UIRectFill(CGRectMake(0, 0, ww, hh)); + }]; +} + +-(NSData*)buildAndRender:(NSString*)kind state:(NSString*)state appearance:(NSString*)appearance text:(NSString*)text w:(int)w h:(int)h { + BOOL dark = [@"dark" isEqualToString:appearance]; + UIView* tile = [[UIView alloc] initWithFrame:CGRectMake(0, 0, w, h)]; + tile.backgroundColor = dark ? [UIColor blackColor] : [UIColor whiteColor]; + if (@available(iOS 13.0, *)) { + tile.overrideUserInterfaceStyle = dark ? UIUserInterfaceStyleDark : UIUserInterfaceStyleLight; + } + + UIView* control = [self buildControl:kind state:state text:text w:w h:h]; + if (control == nil) { + return nil; + } + // Anchor the control TOP-LEFT at its natural size within the tile, matching + // the CN1 side (which anchors its component top-left at preferred size in an + // identically sized tile), so the two renders are directly comparable. + [control sizeToFit]; + CGSize cs = control.bounds.size; + if (cs.width <= 0 || cs.width > w) { cs.width = w; } + if (cs.height <= 0 || cs.height > h) { cs.height = h; } + control.frame = CGRectMake(0, 0, cs.width, cs.height); + [tile addSubview:control]; + [tile layoutIfNeeded]; + + UIGraphicsImageRendererFormat* fmt = [UIGraphicsImageRendererFormat defaultFormat]; + fmt.scale = 1.0; // 1 point == 1 pixel, so the PNG is exactly w x h pixels + fmt.opaque = YES; + UIGraphicsImageRenderer* renderer = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(w, h) format:fmt]; + NSData* png = [renderer PNGDataWithActions:^(UIGraphicsImageRendererContext* ctx) { + [tile.layer renderInContext:ctx.CGContext]; + }]; + return png; +} + +-(UIView*)buildControl:(NSString*)kind state:(NSString*)state text:(NSString*)text w:(int)w h:(int)h { + BOOL disabled = [@"disabled" isEqualToString:state]; + BOOL pressed = [@"pressed" isEqualToString:state]; + BOOL selected = [@"selected" isEqualToString:state]; + NSString* label = text != nil ? text : @""; + + if ([@"ios_uibutton_system" isEqualToString:kind]) { + UIButton* b = [UIButton buttonWithType:UIButtonTypeSystem]; + [b setTitle:label forState:UIControlStateNormal]; + b.enabled = !disabled; + b.highlighted = pressed; + return b; + } + if ([@"ios_uibutton_filled" isEqualToString:kind]) { + UIButton* b; + if (@available(iOS 15.0, *)) { + UIButtonConfiguration* cfg = [UIButtonConfiguration filledButtonConfiguration]; + cfg.title = label; + b = [UIButton buttonWithConfiguration:cfg primaryAction:nil]; + } else { + b = [UIButton buttonWithType:UIButtonTypeSystem]; + [b setTitle:label forState:UIControlStateNormal]; + b.backgroundColor = [UIColor systemBlueColor]; + } + b.enabled = !disabled; + b.highlighted = pressed; + return b; + } + if ([@"ios_uibutton_plain" isEqualToString:kind]) { + UIButton* b = [UIButton buttonWithType:UIButtonTypeSystem]; + [b setTitle:label forState:UIControlStateNormal]; + b.enabled = !disabled; + b.highlighted = pressed; + return b; + } + if ([@"ios_uitextfield" isEqualToString:kind]) { + UITextField* tf = [[UITextField alloc] initWithFrame:CGRectMake(0, 0, w, h)]; + tf.borderStyle = UITextBorderStyleRoundedRect; + tf.text = label; + tf.enabled = !disabled; + return tf; + } + if ([@"ios_check_glyph" isEqualToString:kind]) { + // iOS has no native checkbox; the closest analogue is an SF Symbol. + UIButton* b = [UIButton buttonWithType:UIButtonTypeSystem]; + if (@available(iOS 13.0, *)) { + NSString* sym = selected ? @"checkmark.circle.fill" : @"circle"; + [b setImage:[UIImage systemImageNamed:sym] forState:UIControlStateNormal]; + } + b.enabled = !disabled; + return b; + } + if ([@"ios_radio_glyph" isEqualToString:kind]) { + UIButton* b = [UIButton buttonWithType:UIButtonTypeSystem]; + if (@available(iOS 13.0, *)) { + NSString* sym = selected ? @"largecircle.fill.circle" : @"circle"; + [b setImage:[UIImage systemImageNamed:sym] forState:UIControlStateNormal]; + } + b.enabled = !disabled; + return b; + } + if ([@"ios_uiswitch" isEqualToString:kind]) { + UISwitch* sw = [[UISwitch alloc] init]; + [sw setOn:selected]; + sw.enabled = !disabled; + return sw; + } + if ([@"ios_uislider" isEqualToString:kind]) { + UISlider* s = [[UISlider alloc] initWithFrame:CGRectMake(0, 0, w, h)]; + s.minimumValue = 0; + s.maximumValue = 100; + s.value = 50; + s.enabled = !disabled; + return s; + } + if ([@"ios_uiprogress" isEqualToString:kind]) { + UIProgressView* p = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault]; + p.frame = CGRectMake(0, 0, w, h); + p.progress = 0.5; + return p; + } + if ([@"ios_uitabbar" isEqualToString:kind]) { + UITabBar* bar = [[UITabBar alloc] initWithFrame:CGRectMake(0, 0, w, h)]; + UITabBarItem* a = [[UITabBarItem alloc] initWithTabBarSystemItem:UITabBarSystemItemFeatured tag:0]; + UITabBarItem* b = [[UITabBarItem alloc] initWithTabBarSystemItem:UITabBarSystemItemSearch tag:1]; + UITabBarItem* c = [[UITabBarItem alloc] initWithTabBarSystemItem:UITabBarSystemItemMore tag:2]; + bar.items = @[a, b, c]; + bar.selectedItem = a; + return bar; + } + if ([@"ios_uinavbar" isEqualToString:kind]) { + UINavigationBar* nav = [[UINavigationBar alloc] initWithFrame:CGRectMake(0, 0, w, h)]; + UINavigationItem* item = [[UINavigationItem alloc] initWithTitle:label]; + nav.items = @[item]; + return nav; + } + return nil; +} + +@end diff --git a/scripts/fidelity-app/javase/pom.xml b/scripts/fidelity-app/javase/pom.xml new file mode 100644 index 0000000000..5aebf58802 --- /dev/null +++ b/scripts/fidelity-app/javase/pom.xml @@ -0,0 +1,788 @@ + + + 4.0.0 + + com.codenameone.fidelity + fidelity-app + 1.0-SNAPSHOT + + com.codenameone.fidelity + fidelity-app-javase + 1.0-SNAPSHOT + + fidelity-app-javase + + + UTF-8 + 17 + 17 + javase + javase + + + ${project.basedir}/../common/src/test/java + + + codenameone-maven-plugin + com.codenameone + ${cn1.plugin.version} + + + add-se-sources + + generate-javase-sources + + generate-sources + + + + + + + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + + + ${project.groupId} + ${cn1app.name}-common + ${project.version} + tests + test + + + com.codenameone + codenameone-core + test + + + com.codenameone + codenameone-core + provided + + + com.codenameone + codenameone-javase + test + + + com.codenameone + codenameone-javase + provided + + + + + + + + executable-jar + + javase + com.codenameone.fidelity.FidelityAppStub + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + compile + + + + + + src/main/resources + src/desktop/resources + + + + org.codehaus.mojo + properties-maven-plugin + 1.0.0 + + + initialize + + read-project-properties + + + + ${basedir}/../common/codenameone_settings.properties + + + + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-dependencies + prepare-package + + copy-dependencies + + + + ${project.build.directory}/libs + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + libs/ + + ${codename1.packageName}.${codename1.mainName}Stub + + + + + + + maven-antrun-plugin + + + generate-javase-zip + package + + + + + + + + + + + + + + + + + run + + + + + + + + + + + run-desktop + + javase + com.codenameone.fidelity.FidelityAppStub + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + compile + + + + + + src/main/resources + src/desktop/resources + + + + com.codenameone + codenameone-maven-plugin + + + generate-icons + generate-sources + + generate-desktop-app-wrapper + + + + + + org.codehaus.mojo + exec-maven-plugin + + + run-desktop + verify + + java + + + + + + + + + + desktop_build + + + codename1.buildTarget + + + + + com.codenameone + codenameone-core + provided + + + com.codenameone + codenameone-javase + provided + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + build-desktop-macosx + package + + build + + + + + + + + + + + test + + + true + + + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + + com.codenameone + codenameone-javase + compile + + + + + + com.codenameone + codenameone-maven-plugin + + + + + cn1-tests + test + + test + + + + + + + + + + + + debug-simulator + + javase + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + compile + + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + + + + + org.codehaus.mojo + exec-maven-plugin + + ${basedir}/../common + + java + true + + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=n,address=${jpda.address} + -Xmx1024M + -Xmx1024M + + + + + -Dcef.dir=${cef.dir} + + + -Dcodename1.designer.jar=${codename1.designer.jar} + + + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + + + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + + + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + -classpath + + ${exec.mainClass} + ${codename1.mainClass} + + + + + run-in-simulator + verify + + exec + + + + + + + + + + + debug-eclipse + + javase + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + compile + + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + + + + + org.codehaus.mojo + exec-maven-plugin + + ${basedir}/../common + + java + true + + + -Xdebug + -Xrunjdwp:transport=dt_socket,server=y,address=${jpda.address},suspend=y + -Xmx1024M + -Xmx1024M + + + + + -Dcef.dir=${cef.dir} + + + -Dcodename1.designer.jar=${codename1.designer.jar} + + + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + + + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + + + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + -classpath + + ${exec.mainClass} + ${codename1.mainClass} + + + + + run-in-simulator + verify + + exec + + + + + + + + + + simulator + + javase + com.codename1.impl.javase.Simulator + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + compile + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + prepare-simulator-classpath + + + + + + + org.codehaus.mojo + exec-maven-plugin + + ${basedir}/../common + + java + true + + -Xmx1024M + + + -Dcef.dir=${cef.dir} + + + -Dcodename1.designer.jar=${codename1.designer.jar} + + + -Dcodename1.css.compiler.args.input=${codename1.css.compiler.args.input} + + + -Dcodename1.css.compiler.args.output=${codename1.css.compiler.args.output} + + + -Dcodename1.css.compiler.args.merge=${codename1.css.compiler.args.merge} + ${codename1.exec.args.debug} + ${codename1.exec.args.runjdwp.transport} + -classpath + + ${exec.mainClass} + ${codename1.mainClass} + + + + + run-in-simulator + verify + + exec + + + + + + + + + + idea-simulator + + javase + com.codename1.impl.javase.Simulator + true + + + + com.codenameone + codenameone-core + compile + + + com.codenameone + codenameone-javase + compile + + + + + + + com.codenameone + codenameone-maven-plugin + + + prepare-simulator-environment + initialize + + + prepare-simulator-classpath + + + + + + + org.codehaus.mojo + exec-maven-plugin + + + + ${basedir}/../common + + true + + ${codename1.mainClass} + + + + + + cef.dir + ${cef.dir} + + + + codename1.designer.jar + ${codename1.designer.jar} + + + + codename1.css.compiler.args.input + ${codename1.css.compiler.args.input} + + + + codename1.css.compiler.args.output + ${codename1.css.compiler.args.output} + + + + codename1.css.compiler.args.merge + ${codename1.css.compiler.args.merge} + + + + + cn1.class.path + ${cn1.class.path} + + + + + + + + run-in-simulator-idea + verify + + java + + + + + + + + + + + + diff --git a/scripts/fidelity-app/mvnw b/scripts/fidelity-app/mvnw new file mode 100755 index 0000000000..19529ddf8c --- /dev/null +++ b/scripts/fidelity-app/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/scripts/fidelity-app/mvnw.cmd b/scripts/fidelity-app/mvnw.cmd new file mode 100644 index 0000000000..b150b91ed5 --- /dev/null +++ b/scripts/fidelity-app/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/scripts/fidelity-app/pom.xml b/scripts/fidelity-app/pom.xml new file mode 100644 index 0000000000..1be714e08f --- /dev/null +++ b/scripts/fidelity-app/pom.xml @@ -0,0 +1,146 @@ + + 4.0.0 + com.codenameone.fidelity + fidelity-app + 1.0-SNAPSHOT + pom + fidelity-app + Codename One native-theme fidelity test app + https://www.codenameone.com + + + GPL v2 With Classpath Exception + https://openjdk.java.net/legal/gplv2+ce.html + repo + A business-friendly OSS license + + + + common + + + 8.0-SNAPSHOT + 8.0-SNAPSHOT + UTF-8 + 17 + 17 + 3.8.0 + 17 + 17 + 17 + 17 + fidelity-app + + + + + com.codenameone + java-runtime + ${cn1.version} + + + com.codenameone + codenameone-core + ${cn1.version} + + + com.codenameone + codenameone-javase + ${cn1.version} + + + com.codenameone + codenameone-buildclient + ${cn1.version} + system + ${user.home}/.codenameone/CodeNameOneBuildClient.jar + + + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven-compiler-plugin.version} + + + org.codehaus.mojo + exec-maven-plugin + 3.0.0 + + + maven-antrun-plugin + org.apache.maven.plugins + 3.1.0 + + + + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + com.codenameone + codenameone-maven-plugin + ${cn1.plugin.version} + + + + + + + + + + ios + + + codename1.platform + ios + + + + ios + + + + android + + + codename1.platform + android + + + + android + + + + javase + + + codename1.platform + javase + + true + + + javase + + + + diff --git a/scripts/fidelity-app/tools/fidelity-stats.py b/scripts/fidelity-app/tools/fidelity-stats.py new file mode 100644 index 0000000000..a658465793 --- /dev/null +++ b/scripts/fidelity-app/tools/fidelity-stats.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Summarize native-fidelity baselines into a Markdown report. + +Reads the committed per-platform baseline JSON files +(baseline/-fidelity-baseline.json, each {"pairs": {name: percent}}) and +prints a Markdown "where we stand" report: per-component means, light/dark splits, +and the sorted improvement backlog. + +Usage: fidelity-stats.py [baseline.json ...] (defaults to the committed ones) +""" +import json +import os +import statistics as st +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +APP = os.path.dirname(HERE) +DEFAULTS = [ + ("Android (Material 3)", os.path.join(APP, "baseline", "android-fidelity-baseline.json")), + ("iOS (Modern, Metal)", os.path.join(APP, "baseline", "ios-metal-fidelity-baseline.json")), +] + + +def load(path): + with open(path) as fh: + return json.load(fh).get("pairs", {}) + + +def component(name): + return name.split("_")[0] + + +def report(label, pairs): + if not pairs: + print(f"### {label}\n\n_No baseline recorded yet._\n") + return + vals = list(pairs.values()) + light = [v for k, v in pairs.items() if k.endswith("_light")] + dark = [v for k, v in pairs.items() if k.endswith("_dark")] + comps = {} + for k, v in pairs.items(): + comps.setdefault(component(k), []).append(v) + print(f"### {label}\n") + print(f"- **Pairs:** {len(vals)} ") + print(f"- **Overall mean:** {st.mean(vals):.1f}% median: {st.median(vals):.1f}% ") + if light: + print(f"- **Light mean:** {st.mean(light):.1f}% ", end="") + if dark: + print(f"**Dark mean:** {st.mean(dark):.1f}% ") + else: + print() + print(f"- **At/above 95%:** {sum(1 for v in vals if v >= 95)}/{len(vals)} " + f"**below 60%:** {sum(1 for v in vals if v < 60)}/{len(vals)}\n") + print("| Component | Mean fidelity |") + print("| --- | --- |") + for c in sorted(comps, key=lambda c: st.mean(comps[c])): + print(f"| {c} | {st.mean(comps[c]):.1f}% |") + worst = sorted(pairs.items(), key=lambda kv: kv[1])[:8] + print("\n**Lowest-fidelity pairs (improvement backlog):**\n") + for k, v in worst: + print(f"- `{k}` -- {v:.2f}%") + print() + + +def main(argv): + targets = [] + if len(argv) > 1: + for p in argv[1:]: + targets.append((os.path.basename(p), p)) + else: + targets = DEFAULTS + print("# Native theme fidelity -- where we stand\n") + print("Each score is the visual similarity between Codename One's render of a " + "component (under the native theme) and the REAL native OS widget, both " + "rendered in the same environment. 100% = pixel-identical.\n") + for label, path in targets: + if os.path.isfile(path): + report(label, load(path)) + else: + print(f"### {label}\n\n_No baseline file at {path}._\n") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/scripts/ios/screenshots-watch/CheckBoxRadioTheme_dark.png b/scripts/ios/screenshots-watch/CheckBoxRadioTheme_dark.png index c781df44d4..728daffd05 100644 Binary files a/scripts/ios/screenshots-watch/CheckBoxRadioTheme_dark.png and b/scripts/ios/screenshots-watch/CheckBoxRadioTheme_dark.png differ diff --git a/scripts/ios/screenshots-watch/CheckBoxRadioTheme_light.png b/scripts/ios/screenshots-watch/CheckBoxRadioTheme_light.png index 78ac4308c7..c6fc6871b3 100644 Binary files a/scripts/ios/screenshots-watch/CheckBoxRadioTheme_light.png and b/scripts/ios/screenshots-watch/CheckBoxRadioTheme_light.png differ diff --git a/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png b/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png index 2babf37fd3..6a05cbfd61 100644 Binary files a/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png and b/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png differ diff --git a/scripts/ios/screenshots-watch/ShowcaseTheme_light.png b/scripts/ios/screenshots-watch/ShowcaseTheme_light.png index 381c57b795..7ea92d17dd 100644 Binary files a/scripts/ios/screenshots-watch/ShowcaseTheme_light.png and b/scripts/ios/screenshots-watch/ShowcaseTheme_light.png differ diff --git a/scripts/ios/screenshots-watch/SwitchTheme_dark.png b/scripts/ios/screenshots-watch/SwitchTheme_dark.png index d769647656..20ab1f2a83 100644 Binary files a/scripts/ios/screenshots-watch/SwitchTheme_dark.png and b/scripts/ios/screenshots-watch/SwitchTheme_dark.png differ diff --git a/scripts/ios/screenshots-watch/SwitchTheme_light.png b/scripts/ios/screenshots-watch/SwitchTheme_light.png index 48dfa73deb..9ee0a3fecd 100644 Binary files a/scripts/ios/screenshots-watch/SwitchTheme_light.png and b/scripts/ios/screenshots-watch/SwitchTheme_light.png differ diff --git a/scripts/javascript/screenshots/ButtonTheme_dark.png b/scripts/javascript/screenshots/ButtonTheme_dark.png index d904d10cce..a7042b752d 100644 Binary files a/scripts/javascript/screenshots/ButtonTheme_dark.png and b/scripts/javascript/screenshots/ButtonTheme_dark.png differ diff --git a/scripts/javascript/screenshots/ButtonTheme_light.png b/scripts/javascript/screenshots/ButtonTheme_light.png index 42c8c49e9e..6ff85b6dc7 100644 Binary files a/scripts/javascript/screenshots/ButtonTheme_light.png and b/scripts/javascript/screenshots/ButtonTheme_light.png differ diff --git a/scripts/javascript/screenshots/ChatInput_dark.png b/scripts/javascript/screenshots/ChatInput_dark.png index 5ab9f03508..41d97d0984 100644 Binary files a/scripts/javascript/screenshots/ChatInput_dark.png and b/scripts/javascript/screenshots/ChatInput_dark.png differ diff --git a/scripts/javascript/screenshots/ChatInput_light.png b/scripts/javascript/screenshots/ChatInput_light.png index 5cb1c803b2..097b0f7b93 100644 Binary files a/scripts/javascript/screenshots/ChatInput_light.png and b/scripts/javascript/screenshots/ChatInput_light.png differ diff --git a/scripts/javascript/screenshots/ChatView_dark.png b/scripts/javascript/screenshots/ChatView_dark.png index 9e21999c33..58a59fe345 100644 Binary files a/scripts/javascript/screenshots/ChatView_dark.png and b/scripts/javascript/screenshots/ChatView_dark.png differ diff --git a/scripts/javascript/screenshots/ChatView_light.png b/scripts/javascript/screenshots/ChatView_light.png index 7dbfed0071..098ef017ad 100644 Binary files a/scripts/javascript/screenshots/ChatView_light.png and b/scripts/javascript/screenshots/ChatView_light.png differ diff --git a/scripts/javascript/screenshots/CheckBoxRadioTheme_dark.png b/scripts/javascript/screenshots/CheckBoxRadioTheme_dark.png index 7b891f703f..f09900f8bc 100644 Binary files a/scripts/javascript/screenshots/CheckBoxRadioTheme_dark.png and b/scripts/javascript/screenshots/CheckBoxRadioTheme_dark.png differ diff --git a/scripts/javascript/screenshots/CheckBoxRadioTheme_light.png b/scripts/javascript/screenshots/CheckBoxRadioTheme_light.png index 31fdd8e0b4..e36178304a 100644 Binary files a/scripts/javascript/screenshots/CheckBoxRadioTheme_light.png and b/scripts/javascript/screenshots/CheckBoxRadioTheme_light.png differ diff --git a/scripts/javascript/screenshots/DialogTheme_dark.png b/scripts/javascript/screenshots/DialogTheme_dark.png index 5c0eb2d1d8..ec24819511 100644 Binary files a/scripts/javascript/screenshots/DialogTheme_dark.png and b/scripts/javascript/screenshots/DialogTheme_dark.png differ diff --git a/scripts/javascript/screenshots/DialogTheme_light.png b/scripts/javascript/screenshots/DialogTheme_light.png index 93b7fb18b6..aed1bd3b50 100644 Binary files a/scripts/javascript/screenshots/DialogTheme_light.png and b/scripts/javascript/screenshots/DialogTheme_light.png differ diff --git a/scripts/javascript/screenshots/FloatingActionButtonTheme_dark.png b/scripts/javascript/screenshots/FloatingActionButtonTheme_dark.png index f5d38b9d07..22888e009d 100644 Binary files a/scripts/javascript/screenshots/FloatingActionButtonTheme_dark.png and b/scripts/javascript/screenshots/FloatingActionButtonTheme_dark.png differ diff --git a/scripts/javascript/screenshots/FloatingActionButtonTheme_light.png b/scripts/javascript/screenshots/FloatingActionButtonTheme_light.png index 7083fa9d82..ff26fb0409 100644 Binary files a/scripts/javascript/screenshots/FloatingActionButtonTheme_light.png and b/scripts/javascript/screenshots/FloatingActionButtonTheme_light.png differ diff --git a/scripts/javascript/screenshots/ListTheme_dark.png b/scripts/javascript/screenshots/ListTheme_dark.png index 8e53ff1698..03914f0dbe 100644 Binary files a/scripts/javascript/screenshots/ListTheme_dark.png and b/scripts/javascript/screenshots/ListTheme_dark.png differ diff --git a/scripts/javascript/screenshots/ListTheme_light.png b/scripts/javascript/screenshots/ListTheme_light.png index 620d731887..931ffc87b7 100644 Binary files a/scripts/javascript/screenshots/ListTheme_light.png and b/scripts/javascript/screenshots/ListTheme_light.png differ diff --git a/scripts/javascript/screenshots/MultiButtonTheme_dark.png b/scripts/javascript/screenshots/MultiButtonTheme_dark.png index 6476e747e2..fba05f81a5 100644 Binary files a/scripts/javascript/screenshots/MultiButtonTheme_dark.png and b/scripts/javascript/screenshots/MultiButtonTheme_dark.png differ diff --git a/scripts/javascript/screenshots/MultiButtonTheme_light.png b/scripts/javascript/screenshots/MultiButtonTheme_light.png index 841fef250c..778a4a3ef6 100644 Binary files a/scripts/javascript/screenshots/MultiButtonTheme_light.png and b/scripts/javascript/screenshots/MultiButtonTheme_light.png differ diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_dark.png b/scripts/javascript/screenshots/PaletteOverrideTheme_dark.png index 6218b8f157..1fd84ba17b 100644 Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_dark.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_dark.png differ diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_light.png b/scripts/javascript/screenshots/PaletteOverrideTheme_light.png index 56882b0872..a3822b9b12 100644 Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_light.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_light.png differ diff --git a/scripts/javascript/screenshots/PickerTheme_dark.png b/scripts/javascript/screenshots/PickerTheme_dark.png index 7f93bcf793..bfbbfb899f 100644 Binary files a/scripts/javascript/screenshots/PickerTheme_dark.png and b/scripts/javascript/screenshots/PickerTheme_dark.png differ diff --git a/scripts/javascript/screenshots/PickerTheme_light.png b/scripts/javascript/screenshots/PickerTheme_light.png index 68fb97146f..ceb22e2d0b 100644 Binary files a/scripts/javascript/screenshots/PickerTheme_light.png and b/scripts/javascript/screenshots/PickerTheme_light.png differ diff --git a/scripts/javascript/screenshots/ShowcaseTheme_dark.png b/scripts/javascript/screenshots/ShowcaseTheme_dark.png index 91263d297d..d2eaae5808 100644 Binary files a/scripts/javascript/screenshots/ShowcaseTheme_dark.png and b/scripts/javascript/screenshots/ShowcaseTheme_dark.png differ diff --git a/scripts/javascript/screenshots/ShowcaseTheme_light.png b/scripts/javascript/screenshots/ShowcaseTheme_light.png index 0e99fa5c30..45e98f6436 100644 Binary files a/scripts/javascript/screenshots/ShowcaseTheme_light.png and b/scripts/javascript/screenshots/ShowcaseTheme_light.png differ diff --git a/scripts/javascript/screenshots/SpanLabelTheme_dark.png b/scripts/javascript/screenshots/SpanLabelTheme_dark.png index aff6e3e22a..0fc669f98c 100644 Binary files a/scripts/javascript/screenshots/SpanLabelTheme_dark.png and b/scripts/javascript/screenshots/SpanLabelTheme_dark.png differ diff --git a/scripts/javascript/screenshots/SpanLabelTheme_light.png b/scripts/javascript/screenshots/SpanLabelTheme_light.png index 85909f6c1b..72a1d41036 100644 Binary files a/scripts/javascript/screenshots/SpanLabelTheme_light.png and b/scripts/javascript/screenshots/SpanLabelTheme_light.png differ diff --git a/scripts/javascript/screenshots/SwitchTheme_dark.png b/scripts/javascript/screenshots/SwitchTheme_dark.png index 9db37343b8..be044d1749 100644 Binary files a/scripts/javascript/screenshots/SwitchTheme_dark.png and b/scripts/javascript/screenshots/SwitchTheme_dark.png differ diff --git a/scripts/javascript/screenshots/SwitchTheme_light.png b/scripts/javascript/screenshots/SwitchTheme_light.png index cde93e2d1c..fdf7812785 100644 Binary files a/scripts/javascript/screenshots/SwitchTheme_light.png and b/scripts/javascript/screenshots/SwitchTheme_light.png differ diff --git a/scripts/javascript/screenshots/TabsTheme_dark.png b/scripts/javascript/screenshots/TabsTheme_dark.png index 29dac67ea3..dbc32519c1 100644 Binary files a/scripts/javascript/screenshots/TabsTheme_dark.png and b/scripts/javascript/screenshots/TabsTheme_dark.png differ diff --git a/scripts/javascript/screenshots/TabsTheme_light.png b/scripts/javascript/screenshots/TabsTheme_light.png index fb280de0bf..cb31cea99f 100644 Binary files a/scripts/javascript/screenshots/TabsTheme_light.png and b/scripts/javascript/screenshots/TabsTheme_light.png differ diff --git a/scripts/javascript/screenshots/TextFieldTheme_dark.png b/scripts/javascript/screenshots/TextFieldTheme_dark.png index 8f63689ab3..54f8867b58 100644 Binary files a/scripts/javascript/screenshots/TextFieldTheme_dark.png and b/scripts/javascript/screenshots/TextFieldTheme_dark.png differ diff --git a/scripts/javascript/screenshots/TextFieldTheme_light.png b/scripts/javascript/screenshots/TextFieldTheme_light.png index 09b3be4eeb..7b03e6ad17 100644 Binary files a/scripts/javascript/screenshots/TextFieldTheme_light.png and b/scripts/javascript/screenshots/TextFieldTheme_light.png differ diff --git a/scripts/javascript/screenshots/ToolbarTheme_dark.png b/scripts/javascript/screenshots/ToolbarTheme_dark.png index 9939f6cba1..e587569fa6 100644 Binary files a/scripts/javascript/screenshots/ToolbarTheme_dark.png and b/scripts/javascript/screenshots/ToolbarTheme_dark.png differ diff --git a/scripts/javascript/screenshots/ToolbarTheme_light.png b/scripts/javascript/screenshots/ToolbarTheme_light.png index f190439f44..2690263b22 100644 Binary files a/scripts/javascript/screenshots/ToolbarTheme_light.png and b/scripts/javascript/screenshots/ToolbarTheme_light.png differ diff --git a/scripts/javase/screenshots/javase-multi-landscape.png b/scripts/javase/screenshots/javase-multi-landscape.png index 0bd93c04da..0db5eb7c96 100644 Binary files a/scripts/javase/screenshots/javase-multi-landscape.png and b/scripts/javase/screenshots/javase-multi-landscape.png differ diff --git a/scripts/javase/screenshots/javase-multi-window.png b/scripts/javase/screenshots/javase-multi-window.png index 554f1c5ab5..a106274166 100644 Binary files a/scripts/javase/screenshots/javase-multi-window.png and b/scripts/javase/screenshots/javase-multi-window.png differ diff --git a/scripts/javase/screenshots/javase-single-component-inspector.png b/scripts/javase/screenshots/javase-single-component-inspector.png index d3fdd7f66e..11b57b43dc 100644 Binary files a/scripts/javase/screenshots/javase-single-component-inspector.png and b/scripts/javase/screenshots/javase-single-component-inspector.png differ diff --git a/scripts/javase/screenshots/javase-single-landscape.png b/scripts/javase/screenshots/javase-single-landscape.png index 62861b956a..7bda7c5897 100644 Binary files a/scripts/javase/screenshots/javase-single-landscape.png and b/scripts/javase/screenshots/javase-single-landscape.png differ diff --git a/scripts/javase/screenshots/javase-single-native-theme-android-material.png b/scripts/javase/screenshots/javase-single-native-theme-android-material.png index c9d6e99b7e..5002ca72a6 100644 Binary files a/scripts/javase/screenshots/javase-single-native-theme-android-material.png and b/scripts/javase/screenshots/javase-single-native-theme-android-material.png differ diff --git a/scripts/javase/screenshots/javase-single-native-theme-ios-modern.png b/scripts/javase/screenshots/javase-single-native-theme-ios-modern.png index f990150efe..1d34b2d7c2 100644 Binary files a/scripts/javase/screenshots/javase-single-native-theme-ios-modern.png and b/scripts/javase/screenshots/javase-single-native-theme-ios-modern.png differ diff --git a/scripts/javase/screenshots/javase-single-network-monitor.png b/scripts/javase/screenshots/javase-single-network-monitor.png index 6ed75e355b..418e666bfb 100644 Binary files a/scripts/javase/screenshots/javase-single-network-monitor.png and b/scripts/javase/screenshots/javase-single-network-monitor.png differ diff --git a/scripts/javase/screenshots/javase-single-window.png b/scripts/javase/screenshots/javase-single-window.png index 25253c8ae8..54821f662d 100644 Binary files a/scripts/javase/screenshots/javase-single-window.png and b/scripts/javase/screenshots/javase-single-window.png differ diff --git a/scripts/lib/cn1ss.sh b/scripts/lib/cn1ss.sh index 0cae9027c2..ef0c9c0b21 100644 --- a/scripts/lib/cn1ss.sh +++ b/scripts/lib/cn1ss.sh @@ -5,6 +5,9 @@ : "${CN1SS_PROCESS_CLASS:=ProcessScreenshots}" : "${CN1SS_RENDER_CLASS:=RenderScreenshotReport}" : "${CN1SS_POST_COMMENT_CLASS:=PostPrComment}" +: "${CN1SS_FIDELITY_RENDER_CLASS:=RenderFidelityReport}" +: "${CN1SS_FIDELITY_GATE_CLASS:=FidelityGate}" +: "${CN1SS_FIDELITY_COMPOSITE_CLASS:=FidelityComposite}" CN1SS_INITIALIZED=0 CN1SS_JAVA_BIN="" @@ -505,3 +508,147 @@ cn1ss_process_and_report() { return $comment_rc } + +# Native-fidelity counterpart to cn1ss_process_and_report. Instead of asserting +# pixel-equality against a stored CN1 golden, it measures how close each CN1 +# component render is to the committed NATIVE widget golden of the same name and +# applies the one-way ratchet gate (FidelityGate): a change may only keep or +# improve fidelity, never regress it below the recorded baseline (minus epsilon). +# +# cn1ss_process_fidelity TITLE COMPARE_JSON SUMMARY COMMENT GOLDENS_DIR \ +# PREVIEW_DIR ARTIFACTS_DIR BASELINE_JSON [name=path ...] +# +# Goldens live under GOLDENS_DIR as ".png" (the native widget); each actual +# entry is the CN1 render "=". Behaviour switches on env: +# FIDELITY_UPDATE_BASELINE=1 -> rewrite BASELINE_JSON from the current scores +# and SKIP gating (loud, must be reviewed in PR). +# CN1SS_FIDELITY_EPSILON -> allowed fidelity drop before failing (def 0.5). +# CN1SS_FAIL_ON_MISMATCH=1 -> let the gate's exit code fail the run. +# The native goldens themselves are (re)generated by the runner script from the +# device-delivered "_native.png" frames, not here. +cn1ss_process_fidelity() { + local platform_title="$1" + local compare_json_out="$2" + local summary_out="$3" + local comment_out="$4" + local goldens_dir="$5" + local preview_dir="$6" + local artifacts_dir="$7" + local baseline_file="$8" + shift 8 + local actual_entries=("$@") + + # 1) Compare CN1 renders against native goldens (fidelity scoring). + local -a compare_args=("--mode" "fidelity" "--reference-dir" "$goldens_dir" "--emit-base64" "--preview-dir" "$preview_dir") + # Glass tiles are composited over a shared gradient backdrop; pass it so the + # comparator can mask the backdrop out and score only the widget. When unset the + # comparator falls back to the canonical path relative to the goldens dir. + if [ -n "${CN1SS_FIDELITY_BACKDROP:-}" ] && [ -f "${CN1SS_FIDELITY_BACKDROP}" ]; then + compare_args+=("--backdrop" "${CN1SS_FIDELITY_BACKDROP}") + fi + local entry + for entry in "${actual_entries[@]}"; do + compare_args+=("--actual" "$entry") + done + cn1ss_log "STAGE:FIDELITY_COMPARE -> Scoring CN1 renders against native widget goldens" + if ! cn1ss_java_run "$CN1SS_PROCESS_CLASS" "${compare_args[@]}" > "$compare_json_out"; then + cn1ss_log "FATAL: Fidelity comparison helper failed" + return 13 + fi + + # 2) Render the fidelity report (summary + PR comment markdown). + cn1ss_log "STAGE:FIDELITY_REPORT -> Rendering fidelity summary and PR comment" + local -a render_args=( + --title "$platform_title" + --compare-json "$compare_json_out" + --comment-out "$comment_out" + --summary-out "$summary_out" + ) + if [ -n "${baseline_file:-}" ] && [ -f "$baseline_file" ]; then + render_args+=(--baseline "$baseline_file") + fi + if [ -n "${CN1SS_FIDELITY_ASPIRATIONAL:-}" ]; then + render_args+=(--aspirational "$CN1SS_FIDELITY_ASPIRATIONAL") + fi + if ! cn1ss_java_run "$CN1SS_FIDELITY_RENDER_CLASS" "${render_args[@]}"; then + cn1ss_log "FATAL: Failed to render fidelity summary/comment" + return 14 + fi + + # Persist artifacts: the comparison JSON, the rendered comment, and a copy of + # every CN1 render the summary flagged (copyFlag is always 1 in fidelity mode). + cp -f "$compare_json_out" "$artifacts_dir/fidelity-compare.json" 2>/dev/null || true + if [ -s "$comment_out" ]; then + cp -f "$comment_out" "$artifacts_dir/fidelity-comment.md" 2>/dev/null || true + fi + local cn1_dir="" + if [ -s "$summary_out" ]; then + while IFS='|' read -r status test message copy_flag path fidelity; do + [ -n "${test:-}" ] || continue + cn1ss_log "Fidelity '${test}': ${message}" + if [ "$copy_flag" = "1" ] && [ -n "${path:-}" ] && [ -f "$path" ]; then + cp -f "$path" "$artifacts_dir/${test}_cn1.png" 2>/dev/null || true + [ -z "$cn1_dir" ] && cn1_dir="$(dirname "$path")" + fi + done < "$summary_out" + fi + + # Render the visual fidelity guide: one "card" per component+state showing the + # native widget (left) next to the CN1 render (right) for each appearance, with + # the fidelity percentage beside each pair, plus a single overview contact + # sheet. ref_dir holds the same-run native references (".png"); cn1_dir + # holds the CN1 renders ("_cn1.png"). + if [ -n "$cn1_dir" ]; then + cn1ss_log "STAGE:FIDELITY_CARDS -> Rendering visual native-vs-CN1 comparison cards" + if cn1ss_java_run "$CN1SS_FIDELITY_COMPOSITE_CLASS" \ + --native-dir "$goldens_dir" \ + --cn1-dir "$cn1_dir" \ + --compare-json "$compare_json_out" \ + --title "${platform_title}" \ + --out "$artifacts_dir/cards"; then + cn1ss_log " -> Wrote comparison cards to $artifacts_dir/cards (overview: fidelity-overview.png)" + else + cn1ss_log " -> WARNING: failed to render comparison cards (non-fatal)" + fi + fi + + # 3) Post the PR comment (best-effort; never the gating signal). + cn1ss_log "STAGE:FIDELITY_COMMENT_POST -> Submitting fidelity feedback" + local comment_rc=0 + if [ "${CN1SS_SKIP_COMMENT:-0}" = "1" ]; then + cn1ss_log "Skipping PR comment as requested (CN1SS_SKIP_COMMENT=1)" + elif ! cn1ss_post_pr_comment "$comment_out" "$preview_dir"; then + comment_rc=$? + fi + + # 4) Baseline update OR ratchet gate. + local -a gate_args=(--compare-json "$compare_json_out") + if [ -n "${baseline_file:-}" ] && [ -f "$baseline_file" ]; then + gate_args+=(--baseline "$baseline_file") + fi + if [ -n "${CN1SS_FIDELITY_EPSILON:-}" ]; then + gate_args+=(--epsilon "$CN1SS_FIDELITY_EPSILON") + fi + if [ "${FIDELITY_UPDATE_BASELINE:-0}" = "1" ]; then + cn1ss_log "WARNING: FIDELITY_UPDATE_BASELINE=1 -- recording current fidelity as the new baseline (gate BYPASSED)." + if ! cn1ss_java_run "$CN1SS_FIDELITY_GATE_CLASS" "${gate_args[@]}" --update-baseline "$baseline_file"; then + cn1ss_log "FATAL: Failed to update fidelity baseline" + return 14 + fi + return $comment_rc + fi + + cn1ss_log "STAGE:FIDELITY_GATE -> Enforcing the fidelity ratchet against the baseline" + if cn1ss_java_run "$CN1SS_FIDELITY_GATE_CLASS" "${gate_args[@]}"; then + cn1ss_log "Fidelity gate passed." + else + local gate_rc=$? + if [ "${CN1SS_FAIL_ON_MISMATCH:-0}" = "1" ]; then + cn1ss_log "FATAL: Fidelity gate failed (rc=$gate_rc, CN1SS_FAIL_ON_MISMATCH=1)" + return 15 + fi + cn1ss_log "WARNING: Fidelity gate reported regressions (rc=$gate_rc) but CN1SS_FAIL_ON_MISMATCH is not set; not failing." + fi + + return $comment_rc +} diff --git a/scripts/run-android-fidelity-tests.sh b/scripts/run-android-fidelity-tests.sh new file mode 100755 index 0000000000..64d1609330 --- /dev/null +++ b/scripts/run-android-fidelity-tests.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Run the native-fidelity suite on a booted Android emulator: launch the +# fidelity app (it auto-runs the suite), collect the per-tile PNGs over the +# CN1SS WebSocket, (re)generate native goldens from the "_native" frames, then +# score the "_cn1" renders against the goldens and apply the ratchet gate. +# +# Usage: run-android-fidelity-tests.sh +# Assumes an emulator is already booted (adb device online) and the app APK is +# built. Honors FIDELITY_UPDATE_GOLDENS=1 (refresh committed goldens from this +# run) and FIDELITY_UPDATE_BASELINE=1 (record current fidelity as baseline). +set -euo pipefail + +rf_log() { echo "[run-android-fidelity-tests] $1"; } + +if [ $# -lt 1 ]; then + rf_log "Usage: $0 " >&2 + exit 2 +fi +GRADLE_PROJECT_DIR="$1" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +APP_DIR="${CN1_APP_DIR:-scripts/fidelity-app}" +GOLDENS_DIR="$APP_DIR/goldens/android" +BASELINE_FILE="$APP_DIR/baseline/android-fidelity-baseline.json" +mkdir -p "$GOLDENS_DIR" "$(dirname "$BASELINE_FILE")" + +CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" +source "$SCRIPT_DIR/lib/cn1ss.sh" +cn1ss_log() { rf_log "$1"; } + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts/android-fidelity}" +mkdir -p "$ARTIFACTS_DIR" +TMPDIR="${TMPDIR:-/tmp}"; TMPDIR="${TMPDIR%/}" +WORK_DIR="$(mktemp -d "${TMPDIR}/cn1ss-fid-XXXXXX")" +WS_RAW_DIR="$WORK_DIR/ws"; mkdir -p "$WS_RAW_DIR" +PREVIEW_DIR="$WORK_DIR/previews"; mkdir -p "$PREVIEW_DIR" + +# Toolchain (JAVA17 runs the host helpers). +TARGET_JAVA_HOME="${JDK_HOME:-${JAVA17_HOME:-$JAVA_HOME}}" +TARGET_JAVA_BIN="$TARGET_JAVA_HOME/bin/java" +[ -x "$TARGET_JAVA_BIN" ] || { rf_log "java not found at $TARGET_JAVA_BIN"; exit 3; } +cn1ss_setup "$TARGET_JAVA_BIN" "$CN1SS_HELPER_SOURCE_DIR" + +command -v adb >/dev/null 2>&1 || { rf_log "adb not on PATH"; exit 3; } +adb wait-for-device + +APK_PATH="$(find "$GRADLE_PROJECT_DIR" -path "*/outputs/apk/debug/*.apk" | head -n1 || true)" +[ -n "$APK_PATH" ] || { rf_log "APK not found under $GRADLE_PROJECT_DIR"; exit 4; } +PACKAGE_NAME="$(sed -n 's/.*package="\([^"]*\)".*/\1/p' "$GRADLE_PROJECT_DIR/app/src/main/AndroidManifest.xml" | head -n1)" +MAIN_NAME="$(sed -n 's/^codename1.mainName=//p' "$APP_DIR/common/codenameone_settings.properties" | head -n1)" +rf_log "package=$PACKAGE_NAME launcher=${MAIN_NAME}Stub apk=$APK_PATH" + +# Start the host WS server (emulator reaches it via 10.0.2.2:8765). +if ! cn1ss_start_ws_server "$WS_RAW_DIR"; then + rf_log "FATAL: WebSocket screenshot server did not start"; exit 5 +fi +trap 'cn1ss_stop_ws_server || true' EXIT + +rf_log "Installing APK" +adb install -r -g "$APK_PATH" >/dev/null 2>&1 || adb install -r "$APK_PATH" +adb logcat -G 16M >/dev/null 2>&1 || true +adb logcat -c || true +TEST_LOG="$ARTIFACTS_DIR/logcat.txt" +adb logcat -v threadtime > "$TEST_LOG" 2>&1 & +LOGCAT_PID=$! +trap 'kill "$LOGCAT_PID" >/dev/null 2>&1 || true; cn1ss_stop_ws_server || true' EXIT +sleep 1 + +rf_log "Launching $PACKAGE_NAME/.${MAIN_NAME}Stub" +adb shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true +adb shell am start -n "$PACKAGE_NAME/.${MAIN_NAME}Stub" >/dev/null 2>&1 || \ + adb shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true + +END_MARKER="CN1SS:SUITE:FINISHED" +TIMEOUT_SECONDS="${CN1SS_FIDELITY_TIMEOUT:-300}" +START_TIME="$(date +%s)" +rf_log "Waiting up to ${TIMEOUT_SECONDS}s for $END_MARKER" +while true; do + if grep -q "$END_MARKER" "$TEST_LOG" 2>/dev/null; then rf_log "Suite finished"; break; fi + NOW="$(date +%s)" + if [ $(( NOW - START_TIME )) -ge "$TIMEOUT_SECONDS" ]; then + rf_log "TIMEOUT waiting for suite completion"; break + fi + sleep 3 +done +sleep 2 +cn1ss_stop_ws_server || true +kill "$LOGCAT_PID" >/dev/null 2>&1 || true + +rf_log "CN1SS log lines:"; (grep "CN1SS:" "$TEST_LOG" || true) | sed 's/^/ /' | tail -60 + +# Split delivered PNGs. The fidelity comparison uses the SAME-RUN native render +# as the reference, not the committed golden: both the CN1 and native tiles are +# produced in the *same* environment this run, so the score is robust to the +# subtle rendering differences between environments (emulator GPU, OS version, +# font hinting) that would otherwise make a golden captured elsewhere mismatch. +# The committed goldens are a re-seedable drift artifact for human review. +NATIVE_REF_DIR="$WORK_DIR/native-ref"; mkdir -p "$NATIVE_REF_DIR" +NATIVE_COUNT=0; CN1_COUNT=0 +shopt -s nullglob +for png in "$WS_RAW_DIR"/*_native.png; do + base="$(basename "$png" .png)"; name="${base%_native}" + cp -f "$png" "$NATIVE_REF_DIR/$name.png" # same-run reference (comparison) + if [ "${FIDELITY_UPDATE_GOLDENS:-0}" = "1" ] || [ ! -f "$GOLDENS_DIR/$name.png" ]; then + cp -f "$png" "$GOLDENS_DIR/$name.png" # committed drift artifact (re-seedable) + fi + NATIVE_COUNT=$(( NATIVE_COUNT + 1 )) +done +declare -a COMPARE_ENTRIES=() +for png in "$WS_RAW_DIR"/*_cn1.png; do + base="$(basename "$png" .png)"; name="${base%_cn1}" + dest="$WORK_DIR/${name}_cn1.png"; cp -f "$png" "$dest" + COMPARE_ENTRIES+=("${name}=${dest}") + CN1_COUNT=$(( CN1_COUNT + 1 )) +done +shopt -u nullglob +rf_log "Delivered: ${NATIVE_COUNT} native, ${CN1_COUNT} cn1. Same-run native ref: $NATIVE_REF_DIR; committed goldens: $GOLDENS_DIR" + +if [ "$CN1_COUNT" -eq 0 ]; then + rf_log "FATAL: no CN1 renders delivered over WebSocket" + exit 12 +fi +if [ "$NATIVE_COUNT" -eq 0 ]; then + rf_log "FATAL: no native references delivered (NativeWidgetFactory unavailable?)" + exit 12 +fi + +export CN1SS_COMMENT_MARKER="" +export CN1SS_PREVIEW_SUBDIR="android-fidelity" +COMPARE_JSON="$WORK_DIR/fidelity-compare.json" +SUMMARY_FILE="$WORK_DIR/fidelity-summary.txt" +COMMENT_FILE="$WORK_DIR/fidelity-comment.md" + +cn1ss_process_fidelity \ + "Native fidelity (Android, Material 3)" \ + "$COMPARE_JSON" "$SUMMARY_FILE" "$COMMENT_FILE" \ + "$NATIVE_REF_DIR" "$PREVIEW_DIR" "$ARTIFACTS_DIR" "$BASELINE_FILE" \ + "${COMPARE_ENTRIES[@]}" +rc=$? +cp -f "$COMMENT_FILE" "$ARTIFACTS_DIR/fidelity-comment.md" 2>/dev/null || true +rf_log "Done (rc=$rc). Artifacts in $ARTIFACTS_DIR" +exit $rc diff --git a/scripts/run-ios-fidelity-tests.sh b/scripts/run-ios-fidelity-tests.sh new file mode 100755 index 0000000000..0f4260ba6e --- /dev/null +++ b/scripts/run-ios-fidelity-tests.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Run the native-fidelity suite on a booted iOS simulator (Metal pipeline). +# Installs + launches the prebuilt fidelity .app, collects the per-tile PNGs over +# the CN1SS WebSocket, then scores the CN1 renders against the SAME-RUN native +# UIKit references and applies the ratchet gate. +# +# Usage: run-ios-fidelity-tests.sh [simulator_udid] +# is the built *.app for the iphonesimulator SDK. +# Honors FIDELITY_UPDATE_GOLDENS=1 and FIDELITY_UPDATE_BASELINE=1. +set -euo pipefail + +rf_log() { echo "[run-ios-fidelity-tests] $1"; } + +if [ $# -lt 1 ]; then + rf_log "Usage: $0 [simulator_udid]" >&2 + exit 2 +fi +APP_BUNDLE="$1" +SIM_UDID="${2:-}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +APP_DIR="${CN1_APP_DIR:-scripts/fidelity-app}" +GOLDENS_DIR="$APP_DIR/goldens/ios-metal" +BASELINE_FILE="$APP_DIR/baseline/ios-metal-fidelity-baseline.json" +mkdir -p "$GOLDENS_DIR" "$(dirname "$BASELINE_FILE")" + +CN1SS_HELPER_SOURCE_DIR="$SCRIPT_DIR/common/java" +source "$SCRIPT_DIR/lib/cn1ss.sh" +cn1ss_log() { rf_log "$1"; } + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts/ios-fidelity}" +mkdir -p "$ARTIFACTS_DIR" +TMPDIR="${TMPDIR:-/tmp}"; TMPDIR="${TMPDIR%/}" +WORK_DIR="$(mktemp -d "${TMPDIR}/cn1ss-fid-ios-XXXXXX")" +WS_RAW_DIR="$WORK_DIR/ws"; mkdir -p "$WS_RAW_DIR" +PREVIEW_DIR="$WORK_DIR/previews"; mkdir -p "$PREVIEW_DIR" + +[ -d "$APP_BUNDLE" ] || { rf_log "App bundle not found: $APP_BUNDLE"; exit 4; } +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_BUNDLE/Info.plist" 2>/dev/null || echo "com.codenameone.fidelity")" +rf_log "app=$APP_BUNDLE bundle=$BUNDLE_ID" + +# Host helpers run under a JDK (11-25). Prefer JAVA17_HOME, else JAVA_HOME, else java on PATH. +TARGET_JAVA_HOME="${JAVA17_HOME:-${JAVA_HOME:-}}" +if [ -n "$TARGET_JAVA_HOME" ] && [ -x "$TARGET_JAVA_HOME/bin/java" ]; then + TARGET_JAVA_BIN="$TARGET_JAVA_HOME/bin/java" +else + TARGET_JAVA_BIN="$(command -v java)" +fi +[ -x "$TARGET_JAVA_BIN" ] || { rf_log "java not found"; exit 3; } +cn1ss_setup "$TARGET_JAVA_BIN" "$CN1SS_HELPER_SOURCE_DIR" + +# Pick a booted simulator if none was given. +if [ -z "$SIM_UDID" ]; then + SIM_UDID="$(xcrun simctl list devices booted 2>/dev/null | grep -Eo '[0-9A-F-]{36}' | head -n1 || true)" +fi +if [ -z "$SIM_UDID" ]; then + rf_log "No booted simulator and none specified; booting iPhone 16" + SIM_UDID="$(xcrun simctl list devices available | grep -E 'iPhone 16 \(' | grep -Eo '[0-9A-F-]{36}' | head -n1)" + xcrun simctl boot "$SIM_UDID" + xcrun simctl bootstatus "$SIM_UDID" -b +fi +rf_log "Using simulator $SIM_UDID" + +if ! cn1ss_start_ws_server "$WS_RAW_DIR"; then + rf_log "FATAL: WebSocket screenshot server did not start"; exit 5 +fi +LOG_PID=0 +cleanup() { [ "$LOG_PID" -ne 0 ] && kill "$LOG_PID" >/dev/null 2>&1 || true; cn1ss_stop_ws_server || true; } +trap cleanup EXIT + +TEST_LOG="$ARTIFACTS_DIR/simctl-log.txt" +xcrun simctl spawn "$SIM_UDID" log stream --level debug --predicate 'eventMessage CONTAINS "CN1SS"' > "$TEST_LOG" 2>&1 & +LOG_PID=$! +sleep 1 + +rf_log "Installing app" +xcrun simctl install "$SIM_UDID" "$APP_BUNDLE" +rf_log "Launching $BUNDLE_ID (Metal pipeline)" +xcrun simctl terminate "$SIM_UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true +# Metal layer validation forwarded into the app process (mirrors the CN1SS iOS job). +SIMCTL_CHILD_MTL_DEBUG_LAYER=1 xcrun simctl launch "$SIM_UDID" "$BUNDLE_ID" >/dev/null 2>&1 || \ + xcrun simctl launch "$SIM_UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true + +END_MARKER="CN1SS:SUITE:FINISHED" +TIMEOUT_SECONDS="${CN1SS_FIDELITY_TIMEOUT:-600}" +START_TIME="$(date +%s)" +rf_log "Waiting up to ${TIMEOUT_SECONDS}s for $END_MARKER" +while true; do + if grep -q "$END_MARKER" "$TEST_LOG" 2>/dev/null; then rf_log "Suite finished"; break; fi + NOW="$(date +%s)" + if [ $(( NOW - START_TIME )) -ge "$TIMEOUT_SECONDS" ]; then rf_log "TIMEOUT"; break; fi + sleep 3 +done +sleep 2 +cn1ss_stop_ws_server || true +[ "$LOG_PID" -ne 0 ] && kill "$LOG_PID" >/dev/null 2>&1 || true + +rf_log "CN1SS log tail:"; (grep "CN1SS:" "$TEST_LOG" || true) | sed 's/^/ /' | tail -40 + +# The iOS native references are generated OFFLINE by the standalone native-ref +# app (scripts/build-ios-native-ref.sh -> committed goldens), NOT same-run: a real +# UIWindow renders the UIKit widgets correctly, unlike the off-screen factory +# render that produced blank nav/tab bars and point-sized (tiny) widgets. So the +# CN1 suite here only renders the CN1 side and diffs it against the committed +# goldens. The CN1 app may still deliver factory native renders; they are ignored. +CN1_COUNT=0 +shopt -s nullglob +declare -a COMPARE_ENTRIES=() +for png in "$WS_RAW_DIR"/*_cn1.png; do + base="$(basename "$png" .png)"; name="${base%_cn1}" + dest="$WORK_DIR/${name}_cn1.png"; cp -f "$png" "$dest" + COMPARE_ENTRIES+=("${name}=${dest}") + CN1_COUNT=$(( CN1_COUNT + 1 )) +done +GOLDEN_COUNT=$(ls "$GOLDENS_DIR"/*.png 2>/dev/null | wc -l | tr -d ' ') +shopt -u nullglob +rf_log "Delivered: ${CN1_COUNT} cn1; committed native goldens: ${GOLDEN_COUNT}" +[ "$CN1_COUNT" -gt 0 ] || { rf_log "FATAL: no CN1 renders delivered"; exit 12; } +[ "$GOLDEN_COUNT" -gt 0 ] || { rf_log "FATAL: no committed iOS goldens (run scripts/build-ios-native-ref.sh)"; exit 12; } + +export CN1SS_COMMENT_MARKER="" +export CN1SS_PREVIEW_SUBDIR="ios-fidelity" +cn1ss_process_fidelity \ + "Native fidelity (iOS Modern, Metal)" \ + "$WORK_DIR/fidelity-compare.json" "$WORK_DIR/fidelity-summary.txt" "$WORK_DIR/fidelity-comment.md" \ + "$GOLDENS_DIR" "$PREVIEW_DIR" "$ARTIFACTS_DIR" "$BASELINE_FILE" \ + "${COMPARE_ENTRIES[@]}" +rc=$? +cp -f "$WORK_DIR/fidelity-comment.md" "$ARTIFACTS_DIR/fidelity-comment.md" 2>/dev/null || true +rf_log "Done (rc=$rc). Artifacts in $ARTIFACTS_DIR" +exit $rc