diff --git a/.github/workflows/scripts-ios.yml b/.github/workflows/scripts-ios.yml index 6a3697f9a7..a855265ad3 100644 --- a/.github/workflows/scripts-ios.yml +++ b/.github/workflows/scripts-ios.yml @@ -387,6 +387,15 @@ jobs: # the Metal backend will not be pixel-identical to the GL # baselines (especially once CoreText glyph rendering lands). SCREENSHOT_REF_DIR: ${{ github.workspace }}/scripts/ios/screenshots-metal + # The matrix-translation flag is on by default in this job + # (HelloCodenameOne.kt reads codename1.arg.matrixTranslation, + # build-ios-app.sh passes the MATRIX_TRANSLATION env through). + # Overlay the matrix-mode-specific goldens on top of the base set + # so the few tests that diverge between modes (inscribed-triangle, + # translate-then-scale, etc.) check against their matrix-mode + # reference while every other test still compares against the + # single shared base golden. + SCREENSHOT_REF_OVERLAY_DIR: ${{ github.workspace }}/scripts/ios/screenshots-metal-matrix # Override the PR comment marker / preview path / title so this # job posts its own comment instead of overwriting the GL job's # comment (both jobs ran with the same default marker before, so @@ -453,3 +462,218 @@ jobs: path: artifacts if-no-files-found: warn retention-days: 14 + + build-ios-metal-legacy: + # Companion job to build-ios-metal: runs the same iOS Metal screenshot + # suite with Graphics.useMatrixTranslation off (codename1.arg.matrixTranslation=false) + # so any regression that legacy callers would see on Metal gets caught + # before the opt-in flag is enabled in real apps. The base golden set + # in scripts/ios/screenshots-metal/ tracks legacy-mode renders; the + # matrix-mode job overlays the per-test differences from + # scripts/ios/screenshots-metal-matrix/ on top. + continue-on-error: true + needs: build-port + permissions: + contents: read + pull-requests: write + issues: write + runs-on: macos-15 + timeout-minutes: 45 + concurrency: + group: mac-ci-${{ github.workflow }}-metal-legacy-${{ github.ref_name }} + cancel-in-progress: true + + env: + GITHUB_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + GH_TOKEN: ${{ secrets.CN1SS_GH_TOKEN }} + MATRIX_TRANSLATION: 'false' + + steps: + - uses: actions/checkout@v4 + + - name: Cache CocoaPods and user gems + uses: actions/cache@v4 + with: + path: | + ~/.gem + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-v1-${{ hashFiles('scripts/setup-workspace.sh') }} + restore-keys: | + ${{ runner.os }}-pods-v1- + + - name: Ensure CocoaPods tooling + run: | + mkdir -p ~/.codenameone + cp maven/UpdateCodenameOne.jar ~/.codenameone/ + set -euo pipefail + if ! command -v ruby >/dev/null; then + echo "ruby not found"; exit 1 + fi + GEM_USER_DIR="$(ruby -e 'print Gem.user_dir')" + export PATH="$GEM_USER_DIR/bin:$PATH" + if ! command -v pod >/dev/null 2>&1; then + gem install cocoapods xcodeproj --no-document --user-install + fi + pod --version + + - name: Compute setup-workspace hash + id: setup_hash + run: | + set -euo pipefail + echo "hash=$(shasum -a 256 scripts/setup-workspace.sh | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Compute CN1 source hash + id: src_hash + run: | + set -euo pipefail + SRC_HASH=$(find CodenameOne/src Ports/iOSPort vm/JavaAPI vm/ByteCodeTranslator Themes native-themes \ + -type f \( -name '*.java' -o -name '*.m' -o -name '*.h' -o -name '*.xml' -o -name '*.properties' -o -name '*.css' \) 2>/dev/null \ + | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') + POM_HASH=$(find . -name 'pom.xml' -not -path './scripts/*' 2>/dev/null \ + | sort | xargs shasum -a 256 | shasum -a 256 | awk '{print $1}') + SCRIPT_HASH=$(shasum -a 256 \ + scripts/setup-workspace.sh \ + scripts/build-ios-port.sh \ + scripts/build-native-themes.sh \ + .github/workflows/_build-ios-port.yml \ + | shasum -a 256 | awk '{print $1}') + echo "hash=${SRC_HASH:0:16}-${POM_HASH:0:16}-${SCRIPT_HASH:0:16}" >> "$GITHUB_OUTPUT" + + - name: Set TMPDIR + run: echo "TMPDIR=${{ runner.temp }}" >> $GITHUB_ENV + + - name: Cache codenameone-tools + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/codenameone-tools + key: ${{ runner.os }}-cn1-tools-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + ${{ runner.os }}-cn1-tools- + + - name: Cache Maven repository + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-m2- + + - name: Restore cn1-binaries cache + uses: actions/cache@v4 + with: + path: ../cn1-binaries + key: cn1-binaries-${{ runner.os }}-${{ steps.setup_hash.outputs.hash }} + restore-keys: | + cn1-binaries-${{ runner.os }}- + + - name: Restore built CN1 + iOS port artifacts + uses: actions/cache/restore@v4 + with: + path: | + ~/.m2/repository/com/codenameone + Themes + Ports/iOSPort/nativeSources + key: ${{ needs.build-port.outputs.cn1_built_cache_key }} + fail-on-cache-miss: true + + - name: Install Metal Toolchain + run: | + set -euo pipefail + XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [ ! -x "$XCODE_APP/Contents/Developer/usr/bin/xcodebuild" ]; then + echo "Xcode 26 not found under /Applications. Cannot install Metal Toolchain." >&2 + exit 1 + fi + echo "Using $XCODE_APP" + export DEVELOPER_DIR="$XCODE_APP/Contents/Developer" + "$DEVELOPER_DIR/usr/bin/xcodebuild" -downloadComponent MetalToolchain + timeout-minutes: 10 + + - name: Enable Metal backend for hellocodenameone + run: | + set -euo pipefail + SETTINGS=scripts/hellocodenameone/common/codenameone_settings.properties + if grep -q '^codename1\.arg\.ios\.metal=' "$SETTINGS"; then + sed -i '' 's|^codename1.arg.ios.metal=.*|codename1.arg.ios.metal=true|' "$SETTINGS" + else + awk ' + /^codename1\.arg\.ios\.applicationQueriesSchemes=/ { + print + print "codename1.arg.ios.metal=true" + next + } + { print } + ' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS" + fi + echo "--- codenameone_settings.properties (ios.* keys after patch) ---" + grep -n 'codename1\.arg\.ios' "$SETTINGS" || true + + - name: Build sample iOS app and compile workspace (Metal, legacy mode) + id: build-ios-app + run: ./scripts/build-ios-app.sh -q -DskipTests + timeout-minutes: 30 + + - name: Run iOS UI screenshot tests (Metal, legacy mode) + env: + ARTIFACTS_DIR: ${{ github.workspace }}/artifacts/ios-ui-tests-metal-legacy + # Legacy mode reads against the base Metal goldens with no + # matrix-mode overlay -- the goal here is to catch regressions + # in the legacy code path that the opt-in flag should leave + # untouched. + SCREENSHOT_REF_DIR: ${{ github.workspace }}/scripts/ios/screenshots-metal + CN1SS_COMMENT_MARKER: '' + CN1SS_PREVIEW_SUBDIR: ios-metal-legacy + CN1SS_REPORT_TITLE: 'iOS Metal (legacy mode) screenshot updates' + CN1SS_SUCCESS_MESSAGE: '✅ Native iOS Metal screenshot tests passed in legacy mode.' + CN1SS_COMMENT_LOG_PREFIX: '[run-ios-device-tests-metal-legacy]' + run: | + set -euo pipefail + mkdir -p "${ARTIFACTS_DIR}" + + echo "workspace='${{ steps.build-ios-app.outputs.workspace }}'" + echo "scheme='${{ steps.build-ios-app.outputs.scheme }}'" + echo "reference dir='${SCREENSHOT_REF_DIR}'" + + ./scripts/run-ios-ui-tests.sh \ + "${{ steps.build-ios-app.outputs.workspace }}" \ + "" \ + "${{ steps.build-ios-app.outputs.scheme }}" + timeout-minutes: 30 + + - name: Publish Metal-legacy screenshot summary + if: always() + env: + COMPARE_JSON: ${{ github.workspace }}/artifacts/ios-ui-tests-metal-legacy/screenshot-compare.json + COMMENT_MD: ${{ github.workspace }}/artifacts/ios-ui-tests-metal-legacy/screenshot-comment.md + run: | + set -eu + { + echo "## iOS Metal screenshot comparison (legacy mode)" + echo + echo "Ran with \`codename1.arg.matrixTranslation=false\` so the legacy code path is exercised under iOS Metal." + echo "Golden images: \`scripts/ios/screenshots-metal/\` (no matrix overlay)." + echo + if [ -s "$COMPARE_JSON" ]; then + python3 scripts/ci/metal-screenshot-summary.py --markdown "$COMPARE_JSON" + elif [ -s "$COMMENT_MD" ]; then + cat "$COMMENT_MD" + else + echo "_No screenshot comparison artifact was produced. See the upload step output for details._" + fi + } >> "$GITHUB_STEP_SUMMARY" + if [ -s "$COMPARE_JSON" ]; then + NOTICE="$(python3 scripts/ci/metal-screenshot-summary.py --headline "$COMPARE_JSON" || true)" + if [ -n "$NOTICE" ]; then + echo "::notice title=Metal legacy-mode screenshot comparison::${NOTICE}" + fi + fi + + - name: Upload iOS Metal legacy artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-ui-tests-metal-legacy + path: artifacts + if-no-files-found: warn + retention-days: 14 diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index a9cc64b2b8..cb546d57d0 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -801,7 +801,12 @@ public void paintDirty() { continue; } paintQueueTemp[iter] = null; - wrapper.translate(-wrapper.getTranslateX(), -wrapper.getTranslateY()); + // Snapshot-reset uses matrixFrameworkTranslateX (returns + // the framework anchor in both modes) so the wrapper + // graphics starts each queued paint at identity in matrix + // mode too; bare getTranslateX returns zero there and the + // translate would no-op. + wrapper.translate(-wrapper.matrixFrameworkTranslateX(), -wrapper.matrixFrameworkTranslateY()); wrapper.resetAffine(); wrapper.setClip(0, 0, dwidth, dheight); if (ani instanceof Component) { diff --git a/CodenameOne/src/com/codename1/maps/MapComponent.java b/CodenameOne/src/com/codename1/maps/MapComponent.java index 61e7921332..4964e9f82b 100644 --- a/CodenameOne/src/com/codename1/maps/MapComponent.java +++ b/CodenameOne/src/com/codename1/maps/MapComponent.java @@ -33,6 +33,7 @@ import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.ImageFactory; +import com.codename1.ui.Transform; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import com.codename1.ui.geom.Dimension; @@ -269,11 +270,23 @@ public void paintBackground(Graphics g) { tx = -tx * (float) (scaleX - getWidth()) / sx; float ty = (float) zoomCenterY / (float) getHeight(); ty = -ty * (float) (scaleY - getHeight()) / sy; - g.translate((int) tx, (int) ty); - g.scale(sx, sy); - paintmap(g); - g.resetAffine(); - g.translate(-(int) tx, -(int) ty); + if (Graphics.useMatrixTranslation) { + // Matrix mode: resetAffine wipes the impl matrix to + // identity, which destroys the framework painting-chain + // translates the matrix carries. Use save/restore the + // impl matrix around the scale + user-translate instead. + Transform savedMatrix = g.getTransform(); + g.translate((int) tx, (int) ty); + g.scale(sx, sy); + paintmap(g); + g.setTransform(savedMatrix); + } else { + g.translate((int) tx, (int) ty); + g.scale(sx, sy); + paintmap(g); + g.resetAffine(); + g.translate(-(int) tx, -(int) ty); + } } else { g.translate(-translateX, -translateY); paintmap(g); diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index 368e0ec7e3..5388bac3e4 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -3031,8 +3031,8 @@ void internalPaintImpl(Graphics g, boolean paintIntersects) { public void paintIntersectingComponentsAbove(Graphics g) { Container parent = getParent(); Component component = this; - int tx = g.getTranslateX(); - int ty = g.getTranslateY(); + int tx = g.matrixFrameworkTranslateX(); + int ty = g.matrixFrameworkTranslateY(); g.translate(-tx, -ty); int x1 = getAbsoluteX() + getScrollX(); @@ -3344,8 +3344,8 @@ private void drawPainters(Graphics g, Component par, Component c, paintBackgroundImpl(tg); putClientProperty("$FLAT", i); } - int tx = g.getTranslateX(); - int ty = g.getTranslateY(); + int tx = g.matrixFrameworkTranslateX(); + int ty = g.matrixFrameworkTranslateY(); g.translate(-tx + absX, -ty + absY); g.drawImage(i, 0, 0); g.translate(tx - absX, ty - absY); diff --git a/CodenameOne/src/com/codename1/ui/Container.java b/CodenameOne/src/com/codename1/ui/Container.java index 00e5d163be..393ae3e220 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -2186,8 +2186,8 @@ public void paint(Graphics g) { paintElevatedPane(g); } - int tx = g.getTranslateX(); - int ty = g.getTranslateY(); + int tx = g.matrixFrameworkTranslateX(); + int ty = g.matrixFrameworkTranslateY(); g.translate(-tx, -ty); if (sidemenuBarTranslation > 0) { g.translate(sidemenuBarTranslation, 0); diff --git a/CodenameOne/src/com/codename1/ui/FontImage.java b/CodenameOne/src/com/codename1/ui/FontImage.java index e1bf2b0c7a..22d4c7e4b6 100644 --- a/CodenameOne/src/com/codename1/ui/FontImage.java +++ b/CodenameOne/src/com/codename1/ui/FontImage.java @@ -7796,8 +7796,8 @@ protected void drawImage(Graphics g, Object nativeGraphics, int x, int y) { g.concatenateAlpha(fgAlpha); } if (rotated != 0) { - int tX = g.getTranslateX(); - int tY = g.getTranslateY(); + int tX = g.matrixFrameworkTranslateX(); + int tY = g.matrixFrameworkTranslateY(); g.translate(-tX, -tY); g.rotate((float) Math.toRadians(rotated % 360), tX + x + width / 2, tY + y + height / 2); g.drawString(text, tX + x + width / 2 - w / 2, tY + y + height / 2 - h / 2); @@ -7837,8 +7837,8 @@ protected void drawImage(Graphics g, Object nativeGraphics, int x, int y, int w, } //int paddingPixels = Display.getInstance().convertToPixels(padding, true); if (rotated != 0) { - int tX = g.getTranslateX(); - int tY = g.getTranslateY(); + int tX = g.matrixFrameworkTranslateX(); + int tY = g.matrixFrameworkTranslateY(); g.translate(-tX, -tY); g.rotate((float) Math.toRadians(rotated % 360), tX + x + w / 2, tY + y + h / 2); g.drawString(text, tX + x + w / 2 - ww / 2, tY + y); diff --git a/CodenameOne/src/com/codename1/ui/Form.java b/CodenameOne/src/com/codename1/ui/Form.java index b2137c6785..06c58bafad 100644 --- a/CodenameOne/src/com/codename1/ui/Form.java +++ b/CodenameOne/src/com/codename1/ui/Form.java @@ -1117,8 +1117,8 @@ void paintGlassImpl(Graphics g) { return; } if (glassPane != null) { - int tx = g.getTranslateX(); - int ty = g.getTranslateY(); + int tx = g.matrixFrameworkTranslateX(); + int ty = g.matrixFrameworkTranslateY(); g.translate(-tx, -ty); glassPane.paint(g, getBounds()); g.translate(tx, ty); diff --git a/CodenameOne/src/com/codename1/ui/Graphics.java b/CodenameOne/src/com/codename1/ui/Graphics.java index 9224ead05c..0e35d1d18e 100644 --- a/CodenameOne/src/com/codename1/ui/Graphics.java +++ b/CodenameOne/src/com/codename1/ui/Graphics.java @@ -50,6 +50,31 @@ public final class Graphics { /// /// - #getRenderingHints() public static final int RENDERING_HINT_FAST = 1; + /// When true, `#translate(int, int)` composes the translation onto the + /// impl-side affine matrix (via `#translateMatrix(float, float)`) instead + /// of accumulating it in a per-Graphics integer offset that's added to + /// every draw coordinate. Off by default for backwards compatibility with + /// apps that relied on the legacy "translate-then-scale multiplies the + /// translate" behavior. When on, `g.translate(...)` composes into the + /// same matrix as `g.scale` / `g.rotate`, so the container/component + /// painting chain (status bar offset, title-area offset, scroll position, + /// etc.) flows through the matrix and a user-issued `g.scale(...)` no + /// longer stretches those component-positioning translates. + /// + /// The flag is global because translates are issued by the framework + /// painting pipeline before user paint() runs; setting it per-Graphics + /// would race with that. Flip it once during app init. + /// + /// Only honored on ports where `CodenameOneImplementation + /// #isTranslateMatrixSupported()` returns true (iOS, Android, JavaSE, + /// HTML5 today). On ports that opt out, `g.translate` keeps using the + /// integer accumulator regardless of this flag. + /// + /// #### See also + /// + /// - #translateMatrix(float, float) + /// - #isTranslateMatrixSupported() + public static boolean useMatrixTranslation; private final CodenameOneImplementation impl; /// Flag that specifies that native peers are rendered "behind" the this /// graphics context. The main difference is that drawPeerComponent() will @@ -67,6 +92,23 @@ public final class Graphics { /// of any prior g.translate(). getTransform() returns this original /// (un-conjugated) matrix. private Transform userTransform; + /// Matrix-mode shadow of the framework painting-chain's accumulated + /// translate. Maintained by `#translate(int, int)` so `#setTransform` + /// can conjugate the user transform around the framework origin -- + /// `impl matrix = T(matrixFrameworkX, matrixFrameworkY) * userTransform` + /// -- matching the legacy `T(xt) * M * T(-xt)` recipe. Snapshot-reset + /// callers (Form glass paint, Container glass/tensile, FontImage + /// rotation, etc.) read it via `#matrixFrameworkTranslateX()` to do + /// the translate-based "bring impl matrix to identity then restore" + /// dance the legacy code does with `getTranslateX()`. + /// + /// Intentionally NOT exposed via `#getTranslateX()` -- bypass-Graphics + /// fast paths (Label.drawLabelComponent, BGPainter + /// .paintComponentBackground) read getTranslateX to compute coords for + /// direct impl calls, and in matrix mode the impl matrix already + /// encodes the translate so any addition would double-shift. + private int matrixFrameworkX; + private int matrixFrameworkY; private GeneralPath tmpClipShape; /// A buffer shape to use when we need to transform a shape private int color; @@ -104,6 +146,29 @@ private Transform translation() { return translation; } + /// True iff the global `#useMatrixTranslation` opt-in is on *and* this + /// Graphics' impl actually routes `#translate(int, int)` through the + /// matrix. Cached at zero cost; both reads are simple field/virtual + /// dispatches. All call sites that decide between "matrix already has + /// the translate, don't shift coords" and "legacy accumulator path, + /// shift coords by xTranslate" gate on this. + private boolean matrixMode() { + return useMatrixTranslation && impl.isTranslateMatrixSupported(); + } + + /// Returns the x coordinate to pass to impl draw primitives. In matrix + /// mode the impl-side affine already encodes the translate, so we hand + /// the impl the raw local-coord x. In legacy mode we add the shadow + /// xTranslate accumulator the way every drawing method historically has. + private int tx(int x) { + return matrixMode() ? x : (xTranslate + x); + } + + /// Y-axis counterpart to `#tx(int)`. + private int ty(int y) { + return matrixMode() ? y : (yTranslate + y); + } + private GeneralPath tmpClipShape() { if (tmpClipShape == null) { tmpClipShape = new GeneralPath(); @@ -142,22 +207,73 @@ void setGraphics(Object g) { public void translate(int x, int y) { if (impl.isTranslationSupported()) { impl.translate(nativeGraphics, x, y); - } else { - xTranslate += x; - yTranslate += y; - // The conjugation in setTransform() depends on the current - // xTranslate/yTranslate. If the user accumulated more - // translation after setting a non-identity transform, - // re-conjugate so the impl-side matrix stays in sync. + return; + } + if (matrixMode()) { + // Matrix-only: compose T(x, y) onto the impl-side matrix. The + // xTranslate/yTranslate integer accumulator stays at zero in + // matrix mode; matrixFrameworkX/Y shadows the impl-matrix + // translate so setTransform's conjugation has a consistent + // reference and getTranslateX/Y can keep returning the right + // anchor for legacy snapshot-reset callsites. + matrixFrameworkX += x; + matrixFrameworkY += y; + impl.translateMatrix(nativeGraphics, x, y); if (userTransform != null) { - Transform composed = Transform.makeTranslation(xTranslate, yTranslate); + Transform composed = Transform.makeTranslation(matrixFrameworkX, matrixFrameworkY); composed.concatenate(userTransform); - composed.translate(-xTranslate, -yTranslate); impl.setTransform(nativeGraphics, composed); } + return; + } + xTranslate += x; + yTranslate += y; + // The conjugation in setTransform() depends on the current + // xTranslate/yTranslate. If the user accumulated more + // translation after setting a non-identity transform, + // re-conjugate so the impl-side matrix stays in sync. + if (userTransform != null) { + Transform composed = Transform.makeTranslation(xTranslate, yTranslate); + composed.concatenate(userTransform); + composed.translate(-xTranslate, -yTranslate); + impl.setTransform(nativeGraphics, composed); } } + /// Framework-internal accessor returning the current accumulated + /// framework painting-chain translate, in BOTH modes. Legacy mode + /// returns the `xTranslate` integer accumulator (which equals the + /// framework anchor in legacy semantics). Matrix mode returns the + /// `matrixFrameworkX` shadow that mirrors the impl-matrix translate. + /// Either way, callers can do the translate-based + /// `g.translate(-tx, -ty); ...; g.translate(tx, ty);` reset and have + /// the impl matrix end up at identity for the snapshot-reset paint + /// block. + /// + /// `getTranslateX()` is intentionally NOT matrix-aware -- bypass- + /// Graphics fast paths (Label.drawLabelComponent, BGPainter + /// .paintComponentBackground, etc.) read getTranslateX to compute the + /// "amount drawing primitives currently shift by" and add it to + /// coords passed straight to impl draw calls. In matrix mode the impl + /// matrix already encodes the framework translate; making + /// getTranslateX return matrixFrameworkX there would double-shift + /// those direct-impl callers. Snapshot-reset patterns use this + /// separate getter and the two semantics stay distinct. + public int matrixFrameworkTranslateX() { + if (matrixMode()) { + return matrixFrameworkX; + } + return getTranslateX(); + } + + /// Y-axis counterpart to `#matrixFrameworkTranslateX()`. + public int matrixFrameworkTranslateY() { + if (matrixMode()) { + return matrixFrameworkY; + } + return getTranslateY(); + } + /// Returns the current x translate value /// /// #### Returns @@ -166,9 +282,15 @@ public void translate(int x, int y) { public int getTranslateX() { if (impl.isTranslationSupported()) { return impl.getTranslateX(nativeGraphics); - } else { - return xTranslate; } + // Matrix mode keeps `xTranslate` at zero on purpose -- the impl + // matrix is the single source of truth for the framework anchor + // there, and bypass-Graphics fast paths add this value to coords + // passed directly to impl draws, which would double-shift if it + // leaked the matrix anchor. Callers that want the framework + // anchor for snapshot-reset semantics use + // `#matrixFrameworkTranslateX()` instead. + return xTranslate; } /// Returns the current y translate value @@ -179,9 +301,8 @@ public int getTranslateX() { public int getTranslateY() { if (impl.isTranslationSupported()) { return impl.getTranslateY(nativeGraphics); - } else { - return yTranslate; } + return yTranslate; } /// Returns the current color @@ -287,6 +408,14 @@ public void setFont(Font font) { /// /// the x clipping position public int getClipX() { + if (matrixMode()) { + // In matrix mode the impl pulls the clip's inverse-transformed + // bounds (see iOS NativeGraphics.loadClipBounds), which is + // already in matrix-local user coords. The legacy '- xTranslate' + // would double-subtract the translate that's now baked into the + // impl matrix. + return impl.getClipX(nativeGraphics); + } return impl.getClipX(nativeGraphics) - xTranslate; } @@ -366,7 +495,11 @@ public void setClip(int[] clip) { /// /// - #isShapeClipSupported public void setClip(Shape shape) { - if (xTranslate != 0 || yTranslate != 0) { + // Matrix mode: impl.setClip(Shape) applies the current impl transform + // to the shape itself (iOS NativeGraphics.setClip stores + // clip.setShape(newClip, transform)), so a manual pre-translate here + // would double-apply the translate. + if (!matrixMode() && (xTranslate != 0 || yTranslate != 0)) { GeneralPath p = tmpClipShape(); p.setShape(shape, translation()); shape = p; @@ -380,6 +513,9 @@ public void setClip(Shape shape) { /// /// the y clipping position public int getClipY() { + if (matrixMode()) { + return impl.getClipY(nativeGraphics); + } return impl.getClipY(nativeGraphics) - yTranslate; } @@ -414,7 +550,7 @@ public int getClipHeight() { /// /// - `height`: the height of the rectangle to intersect the clip with public void clipRect(int x, int y, int width, int height) { - impl.clipRect(nativeGraphics, xTranslate + x, yTranslate + y, width, height); + impl.clipRect(nativeGraphics, tx(x), ty(y), width, height); } /// Updates the clipping region to match the given region exactly @@ -429,7 +565,7 @@ public void clipRect(int x, int y, int width, int height) { /// /// - `height`: the height of the new clip rectangle. public void setClip(int x, int y, int width, int height) { - impl.setClip(nativeGraphics, xTranslate + x, yTranslate + y, width, height); + impl.setClip(nativeGraphics, tx(x), ty(y), width, height); } /// Pushes the current clip onto the clip stack. It can later be restored @@ -455,7 +591,7 @@ public void popClip() { /// /// - `y2`: second y position public void drawLine(int x1, int y1, int x2, int y2) { - impl.drawLine(nativeGraphics, xTranslate + x1, yTranslate + y1, xTranslate + x2, yTranslate + y2); + impl.drawLine(nativeGraphics, tx(x1), ty(y1), tx(x2), ty(y2)); } @@ -472,14 +608,14 @@ public void drawLine(int x1, int y1, int x2, int y2) { /// /// - `height`: the height of the rectangle to be filled. public void fillRect(int x, int y, int width, int height) { - impl.fillRect(nativeGraphics, xTranslate + x, yTranslate + y, width, height); + impl.fillRect(nativeGraphics, tx(x), ty(y), width, height); } /// #### Deprecated /// /// this method should have been internals public void drawShadow(Image img, int x, int y, int offsetX, int offsetY, int blurRadius, int spreadRadius, int color, float opacity) { - impl.drawShadow(nativeGraphics, img.getImage(), xTranslate + x, yTranslate + y, offsetX, offsetY, blurRadius, spreadRadius, color, opacity); + impl.drawShadow(nativeGraphics, img.getImage(), tx(x), ty(y), offsetX, offsetY, blurRadius, spreadRadius, color, opacity); } /// Clears rectangular area of the graphics context. This will remove any color @@ -504,7 +640,7 @@ public void drawShadow(Image img, int x, int y, int offsetX, int offsetY, int bl /// /// - `height`: The height of the box to clear. public void clearRect(int x, int y, int width, int height) { - clearRectImpl(xTranslate + x, yTranslate + y, width, height); + clearRectImpl(tx(x), ty(y), width, height); } /// Clears rectangular area of the graphics context. This will remove any color @@ -544,7 +680,7 @@ private void clearRectImpl(int x, int y, int width, int height) { /// /// - `height`: the height of the rectangle to be drawn. public void drawRect(int x, int y, int width, int height) { - impl.drawRect(nativeGraphics, xTranslate + x, yTranslate + y, width, height); + impl.drawRect(nativeGraphics, tx(x), ty(y), width, height); } /// Draws a rectangle in the given coordinates with the given thickness @@ -561,7 +697,7 @@ public void drawRect(int x, int y, int width, int height) { /// /// - `thickness`: the thickness in pixels public void drawRect(int x, int y, int width, int height, int thickness) { - impl.drawRect(nativeGraphics, xTranslate + x, yTranslate + y, width, height, thickness); + impl.drawRect(nativeGraphics, tx(x), ty(y), width, height, thickness); } /// Draws a rounded corner rectangle in the given coordinates with the arcWidth/height @@ -581,7 +717,7 @@ public void drawRect(int x, int y, int width, int height, int thickness) { /// /// - `arcHeight`: the vertical diameter of the arc at the four corners. public void drawRoundRect(int x, int y, int width, int height, int arcWidth, int arcHeight) { - impl.drawRoundRect(nativeGraphics, xTranslate + x, yTranslate + y, width, height, arcWidth, arcHeight); + impl.drawRoundRect(nativeGraphics, tx(x), ty(y), width, height, arcWidth, arcHeight); } /// Makes the current color slightly lighter, this is useful for many visual effects @@ -636,7 +772,7 @@ public void darkerColor(int factor) { /// /// - #drawRoundRect public void fillRoundRect(int x, int y, int width, int height, int arcWidth, int arcHeight) { - impl.fillRoundRect(nativeGraphics, xTranslate + x, yTranslate + y, width, height, arcWidth, arcHeight); + impl.fillRoundRect(nativeGraphics, tx(x), ty(y), width, height, arcWidth, arcHeight); } /// Fills a circular or elliptical arc based on the given angles and bounding @@ -683,7 +819,7 @@ public void fillArc(int x, int y, int width, int height, int startAngle, int arc if (width < 1 || height < 1) { throw new IllegalArgumentException("Width & Height of fillAsrc must be greater than 0"); } - impl.fillArc(nativeGraphics, xTranslate + x, yTranslate + y, width, height, startAngle, arcAngle); + impl.fillArc(nativeGraphics, tx(x), ty(y), width, height, startAngle, arcAngle); } /// Draws a circular or elliptical arc based on the given angles and bounding @@ -703,7 +839,7 @@ public void fillArc(int x, int y, int width, int height, int startAngle, int arc /// /// - `arcAngle`: the angular extent of the arc, relative to the start angle. public void drawArc(int x, int y, int width, int height, int startAngle, int arcAngle) { - impl.drawArc(nativeGraphics, xTranslate + x, yTranslate + y, width, height, startAngle, arcAngle); + impl.drawArc(nativeGraphics, tx(x), ty(y), width, height, startAngle, arcAngle); } /// Draw a string using the current font and color in the x,y coordinates. The font is drawn @@ -731,7 +867,7 @@ public void drawString(String str, int x, int y, int textDecoration) { if (current instanceof CustomFont) { current.drawString(this, str, x, y); } else { - impl.drawString(nativeGraphics, nativeFont, str, x + xTranslate, y + yTranslate, textDecoration); + impl.drawString(nativeGraphics, nativeFont, str, tx(x), ty(y), textDecoration); } } @@ -869,17 +1005,17 @@ public void drawImage(Image img, int x, int y, int w, int h) { void drawImageWH(Object nativeImage, int x, int y, int w, int h) { - impl.drawImage(nativeGraphics, nativeImage, x + xTranslate, y + yTranslate, w, h); + impl.drawImage(nativeGraphics, nativeImage, tx(x), ty(y), w, h); } void drawImage(Object img, int x, int y) { - impl.drawImage(nativeGraphics, img, x + xTranslate, y + yTranslate); + impl.drawImage(nativeGraphics, img, tx(x), ty(y)); } /// Draws an image with a MIDP trasnform for fast rotation void drawImage(Object img, int x, int y, int transform) { if (transform != 0) { - impl.drawImageRotated(nativeGraphics, img, x + xTranslate, y + yTranslate, transform); + impl.drawImageRotated(nativeGraphics, img, tx(x), ty(y), transform); } else { drawImage(img, x, y); } @@ -939,7 +1075,9 @@ void drawImage(Object img, int x, int y, int transform) { /// - #isShapeSupported public void drawShape(Shape shape, Stroke stroke) { if (isShapeSupported()) { - if (xTranslate != 0 || yTranslate != 0) { + // Matrix mode: impl applies the affine to shape vertices, so the + // legacy pre-translate would double-shift. + if (!matrixMode() && (xTranslate != 0 || yTranslate != 0)) { GeneralPath p = tmpClipShape(); p.setShape(shape, translation()); shape = p; @@ -1003,7 +1141,7 @@ public void fillShape(Shape shape) { int clipH = getClipHeight(); setClip(shape); clipRect(clipX, clipY, clipW, clipH); - if (xTranslate != 0 || yTranslate != 0) { + if (!matrixMode() && (xTranslate != 0 || yTranslate != 0)) { GeneralPath p = tmpClipShape(); p.setShape(shape, translation()); shape = p; @@ -1014,7 +1152,7 @@ public void fillShape(Shape shape) { return; } - if (xTranslate != 0 || yTranslate != 0) { + if (!matrixMode() && (xTranslate != 0 || yTranslate != 0)) { GeneralPath p = tmpClipShape(); p.setShape(shape, translation()); shape = p; @@ -1126,8 +1264,18 @@ public Transform getTransform() { if (userTransform != null) { return userTransform.copy(); } + if (matrixMode()) { + // Return identity, NOT impl.getTransform() -- the impl matrix + // in matrix mode carries T(matrixFrameworkX), and returning it + // here would cause snapshot/restore patterns + // (saved = getTransform(); setTransform(saved)) to feed the + // framework translates back into the conjugation step, double- + // translating on restore. Legacy returns impl.getTransform() + // which is identity by default since the framework chain lives + // in xTranslate -- same effective answer. + return Transform.makeIdentity(); + } return impl.getTransform(nativeGraphics); - } /// Sets the transformation `com.codename1.ui.geom.Matrix` to apply to drawing in this graphics context. @@ -1167,6 +1315,38 @@ public void setTransform(Transform transform) { // off-screen. Conjugate the user's matrix with T(xTranslate, // yTranslate) so its effect is independent of any prior g.translate // call, matching Android Skia / JavaSE Graphics2D semantics. + if (matrixMode()) { + // Matrix-mode conjugation: in legacy mode the impl matrix is + // T(xt) * M * T(-xt) so M applies in local coords around xt; + // in matrix mode the impl matrix already encodes the framework + // chain AND drawing primitives don't pre-shift coords, so the + // equivalent composition is just T(matrixFrameworkX) * M (no + // terminal T(-matrixFrameworkX)). Drawing at local (lx, ly) + // then lands at T(matrixFrameworkX) * M * (lx, ly) -- same + // screen result as legacy. + // + // setTransform(null) / setTransform(identity) restores the + // framework matrix T(matrixFrameworkX, matrixFrameworkY), NOT + // a true identity -- otherwise subsequent paint calls would + // land at screen origin instead of the component's screen + // position. The legacy snapshot-reset idiom + // `g.translate(-getTranslateX(), -getTranslateY())` brings the + // matrix to identity in both modes since getTranslateX/Y now + // returns matrixFrameworkX/Y in matrix mode. + if (transform == null || transform.isIdentity()) { + userTransform = null; + impl.resetAffine(nativeGraphics); + if (matrixFrameworkX != 0 || matrixFrameworkY != 0) { + impl.translateMatrix(nativeGraphics, matrixFrameworkX, matrixFrameworkY); + } + } else { + userTransform = transform.copy(); + Transform composed = Transform.makeTranslation(matrixFrameworkX, matrixFrameworkY); + composed.concatenate(transform); + impl.setTransform(nativeGraphics, composed); + } + return; + } if (transform != null && !transform.isIdentity() && (xTranslate != 0 || yTranslate != 0)) { userTransform = transform.copy(); @@ -1190,6 +1370,11 @@ public void getTransform(Transform t) { t.setTransform(userTransform); return; } + if (matrixMode()) { + // See `#getTransform()` -- return identity, not impl matrix. + t.setIdentity(); + return; + } impl.getTransform(nativeGraphics, t); } @@ -1213,7 +1398,7 @@ public void getTransform(Transform t) { /// /// - `y3`: the y coordinate of the third vertex of the triangle public void fillTriangle(int x1, int y1, int x2, int y2, int x3, int y3) { - impl.fillTriangle(nativeGraphics, xTranslate + x1, yTranslate + y1, xTranslate + x2, yTranslate + y2, xTranslate + x3, yTranslate + y3); + impl.fillTriangle(nativeGraphics, tx(x1), ty(y1), tx(x2), ty(y2), tx(x3), ty(y3)); } /// Draws the RGB values based on the MIDP API of a similar name. Renders a @@ -1246,7 +1431,7 @@ public void fillTriangle(int x1, int y1, int x2, int y2, int x3, int y3) { /// - `processAlpha`: @param processAlpha true if rgbData has an alpha channel, false if /// all pixels are fully opaque void drawRGB(int[] rgbData, int offset, int x, int y, int w, int h, boolean processAlpha) { - impl.drawRGB(nativeGraphics, rgbData, offset, x + xTranslate, y + yTranslate, w, h, processAlpha); + impl.drawRGB(nativeGraphics, rgbData, offset, tx(x), ty(y), w, h, processAlpha); } /// Draws a radial gradient in the given coordinates with the given colors, @@ -1268,7 +1453,7 @@ void drawRGB(int[] rgbData, int offset, int x, int y, int w, int h, boolean proc /// /// - `height`: the height of the region to be filled public void fillRadialGradient(int startColor, int endColor, int x, int y, int width, int height) { - impl.fillRadialGradient(nativeGraphics, startColor, endColor, x + xTranslate, y + yTranslate, width, height); + impl.fillRadialGradient(nativeGraphics, startColor, endColor, tx(x), ty(y), width, height); } /// Draws a radial gradient in the given coordinates with the given colors, @@ -1294,7 +1479,7 @@ public void fillRadialGradient(int startColor, int endColor, int x, int y, int w /// /// - `arcAngle`: the angular extent of the arc, relative to the start angle. Positive angles are counter-clockwise. public void fillRadialGradient(int startColor, int endColor, int x, int y, int width, int height, int startAngle, int arcAngle) { - impl.fillRadialGradient(nativeGraphics, startColor, endColor, x + xTranslate, y + yTranslate, width, height, startAngle, arcAngle); + impl.fillRadialGradient(nativeGraphics, startColor, endColor, tx(x), ty(y), width, height, startAngle, arcAngle); } /// Draws a radial gradient in the given coordinates with the given colors, @@ -1330,7 +1515,7 @@ public void fillRectRadialGradient(int startColor, int endColor, int x, int y, i fillRect(x, y, width, height, (byte) 0xff); return; } - impl.fillRectRadialGradient(nativeGraphics, startColor, endColor, x + xTranslate, y + yTranslate, width, height, relativeX, relativeY, relativeSize); + impl.fillRectRadialGradient(nativeGraphics, startColor, endColor, tx(x), ty(y), width, height, relativeX, relativeY, relativeSize); } /// Draws a linear gradient in the given coordinates with the given colors, @@ -1358,7 +1543,7 @@ public void fillLinearGradient(int startColor, int endColor, int x, int y, int w fillRect(x, y, width, height, (byte) 0xff); return; } - impl.fillLinearGradient(nativeGraphics, startColor, endColor, x + xTranslate, y + yTranslate, width, height, horizontal); + impl.fillLinearGradient(nativeGraphics, startColor, endColor, tx(x), ty(y), width, height, horizontal); } /// Fills the rectangle (x, y, width, height) with the given multi-stop @@ -1408,7 +1593,7 @@ public boolean blurRegion(int x, int y, int width, int height, float radius) { /// /// - `alpha`: the alpha values specify semitransparency public void fillRect(int x, int y, int w, int h, byte alpha) { - impl.fillRect(nativeGraphics, x + xTranslate, y + yTranslate, w, h, alpha); + impl.fillRect(nativeGraphics, tx(x), ty(y), w, h, alpha); } /// Fills a closed polygon defined by arrays of x and y coordinates. @@ -1426,7 +1611,8 @@ public void fillPolygon(int[] xPoints, int nPoints) { int[] cX = xPoints; int[] cY = yPoints; - if ((!impl.isTranslationSupported()) && (xTranslate != 0 || yTranslate != 0)) { + if ((!impl.isTranslationSupported()) && !matrixMode() + && (xTranslate != 0 || yTranslate != 0)) { cX = new int[nPoints]; cY = new int[nPoints]; System.arraycopy(xPoints, 0, cX, 0, nPoints); @@ -1473,7 +1659,8 @@ void drawImageArea(Image img, int x, int y, int imageX, int imageY, int imageWid public void drawPolygon(int[] xPoints, int[] yPoints, int nPoints) { int[] cX = xPoints; int[] cY = yPoints; - if ((!impl.isTranslationSupported()) && (xTranslate != 0 || yTranslate != 0)) { + if ((!impl.isTranslationSupported()) && !matrixMode() + && (xTranslate != 0 || yTranslate != 0)) { cX = new int[nPoints]; cY = new int[nPoints]; System.arraycopy(xPoints, 0, cX, 0, nPoints); @@ -1625,9 +1812,25 @@ public boolean isAffineSupported() { return impl.isAffineSupported(); } - /// Resets the affine transform to the default value + /// Resets the affine transform to the default value. + /// + /// In matrix-translation mode this wipes the impl matrix to identity, + /// including the framework painting-chain translates the matrix carries. + /// Callers that need to preserve those translates across a transform + /// reset (e.g. user paint snippets in MapComponent / Scene / + /// CommonTransitions / FontImage rotation) must save the impl matrix + /// via `#getTransform(com.codename1.ui.Transform)` first and restore + /// it via `#setTransform(com.codename1.ui.Transform)` after. public void resetAffine() { impl.resetAffine(nativeGraphics); + if (matrixMode() && (matrixFrameworkX != 0 || matrixFrameworkY != 0)) { + // Preserve the framework painting-chain anchor that the impl + // matrix carries in matrix mode -- otherwise subsequent draws + // land at screen origin instead of the component's position. + // Equivalent to legacy semantics where resetAffine wipes the + // impl matrix to identity while xTranslate stays. + impl.translateMatrix(nativeGraphics, matrixFrameworkX, matrixFrameworkY); + } scaleX = 1; scaleY = 1; userTransform = null; @@ -1777,6 +1980,14 @@ public void rotate(float angle, int pivotX, int pivotY) { /// /// 6.0 public void rotateRadians(float angle, int pivotX, int pivotY) { + if (matrixMode()) { + // Matrix mode: the impl matrix already encodes the translate, so + // the pivot lives in matrix-local coords. The legacy '+ + // xTranslate' offset compensated for the per-Graphics integer + // accumulator, which doesn't apply here. + impl.rotate(nativeGraphics, angle, pivotX, pivotY); + return; + } impl.rotate(nativeGraphics, angle, pivotX + xTranslate, pivotY + yTranslate); } @@ -1814,9 +2025,11 @@ public Object beginNativeGraphicsAccess() { a = Boolean.TRUE; } + int tx = matrixFrameworkTranslateX(); + int ty = matrixFrameworkTranslateY(); nativeGraphicsState = new Object[]{ - Integer.valueOf(getTranslateX()), - Integer.valueOf(getTranslateY()), + Integer.valueOf(tx), + Integer.valueOf(ty), Integer.valueOf(getColor()), Integer.valueOf(getAlpha()), Integer.valueOf(getClipX()), @@ -1825,7 +2038,7 @@ public Object beginNativeGraphicsAccess() { Integer.valueOf(getClipHeight()), a, b }; - translate(-getTranslateX(), -getTranslateY()); + translate(-tx, -ty); setAlpha(255); setClip(0, 0, Display.getInstance().getDisplayWidth(), Display.getInstance().getDisplayHeight()); return nativeGraphics; @@ -1888,7 +2101,7 @@ public void tileImage(Image img, int x, int y, int w, int h) { } setClip(clipX, clipY, clipW, clipH); } else { - impl.tileImage(nativeGraphics, img.getImage(), x + xTranslate, y + yTranslate, w, h); + impl.tileImage(nativeGraphics, img.getImage(), tx(x), ty(y), w, h); } } @@ -1920,7 +2133,23 @@ public float getScaleY() { /// - `peer`: The peer component to be drawn. void drawPeerComponent(PeerComponent peer) { if (paintPeersBehind) { - clearRectImpl(peer.getAbsoluteX(), peer.getAbsoluteY(), peer.getWidth(), peer.getHeight()); + // clearRectImpl forwards to impl.clearRect which honours the + // impl matrix on iOS (applyTransform before clearing). In + // matrix mode the matrix encodes the framework painting-chain + // translates -- passing screen-absolute peer coords would + // double-translate the punch-hole, so the native peer would + // appear as a solid filled rect over the canvas at the wrong + // screen position. Subtract matrixFrameworkX so the impl + // matrix lands the cleared rect at the peer's screen coords. + // Legacy mode keeps the raw absolute coords (matrix is + // identity, no shift applied). + int absX = peer.getAbsoluteX(); + int absY = peer.getAbsoluteY(); + if (matrixMode()) { + absX -= matrixFrameworkX; + absY -= matrixFrameworkY; + } + clearRectImpl(absX, absY, peer.getWidth(), peer.getHeight()); } } diff --git a/CodenameOne/src/com/codename1/ui/LinearGradientPaint.java b/CodenameOne/src/com/codename1/ui/LinearGradientPaint.java index 1e50fa0b5d..ffdc8acd5e 100644 --- a/CodenameOne/src/com/codename1/ui/LinearGradientPaint.java +++ b/CodenameOne/src/com/codename1/ui/LinearGradientPaint.java @@ -200,8 +200,8 @@ private void paint(Graphics g, double w, double h, boolean processCycles) { if (getTransform() != null) { t2.concatenate(getTransform()); } - int tx = g.getTranslateX(); - int ty = g.getTranslateY(); + int tx = g.matrixFrameworkTranslateX(); + int ty = g.matrixFrameworkTranslateY(); g.translate(-tx, -ty); t2.translate((float) (startX + tx), (float) (startY + ty)); diff --git a/CodenameOne/src/com/codename1/ui/TextSelection.java b/CodenameOne/src/com/codename1/ui/TextSelection.java index c3edab883e..20f70b7c78 100644 --- a/CodenameOne/src/com/codename1/ui/TextSelection.java +++ b/CodenameOne/src/com/codename1/ui/TextSelection.java @@ -1316,8 +1316,12 @@ public void paint(Graphics g) { g.setColor(0x0000ff); int alph = g.getAlpha(); g.setAlpha(50); - int tx = g.getTranslateX(); - int ty = g.getTranslateY(); + // Snapshot-reset translate so subsequent translate(originX, + // originY) lands at selectionRoot's screen-absolute origin in + // both modes (matrixFrameworkTranslateX returns the right + // anchor under Graphics.useMatrixTranslation). + int tx = g.matrixFrameworkTranslateX(); + int ty = g.matrixFrameworkTranslateY(); g.translate(-tx, -ty); int originX = selectionRoot.getAbsoluteX(); int originY = selectionRoot.getAbsoluteY(); diff --git a/CodenameOne/src/com/codename1/ui/scene/Scene.java b/CodenameOne/src/com/codename1/ui/scene/Scene.java index c93de4062b..45a0cb49be 100644 --- a/CodenameOne/src/com/codename1/ui/scene/Scene.java +++ b/CodenameOne/src/com/codename1/ui/scene/Scene.java @@ -25,6 +25,7 @@ import com.codename1.properties.Property; import com.codename1.ui.Container; import com.codename1.ui.Graphics; +import com.codename1.ui.Transform; /// A scene graph. Supports 3D on platforms where `com.codename1.ui.Transform#isPerspectiveSupported()` is true (iOS and Android currently). /// @@ -64,7 +65,17 @@ public void setRoot(Node root) { public void paint(Graphics g) { super.paint(g); if (root != null) { - g.resetAffine(); + // In matrix mode resetAffine would wipe the framework painting- + // chain translates that the impl matrix carries. Save/restore + // the impl matrix around the render call instead; the legacy + // path keeps using resetAffine since the integer accumulator + // already preserves the framework translate. + Transform savedMatrix = null; + if (Graphics.useMatrixTranslation) { + savedMatrix = g.getTransform(); + } else { + g.resetAffine(); + } int clipX = g.getClipX(); int clipY = g.getClipY(); int clipW = g.getClipWidth(); @@ -73,7 +84,11 @@ public void paint(Graphics g) { g.setAntiAliased(true); root.render(g); g.translate(-getX(), -getY()); - g.resetAffine(); + if (Graphics.useMatrixTranslation) { + g.setTransform(savedMatrix); + } else { + g.resetAffine(); + } g.setClip(clipX, clipY, clipW, clipH); } } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java b/Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java index d0f7798dbc..5ff28962f6 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidAsyncView.java @@ -759,6 +759,40 @@ public String toString() { }); } + @Override + public void translateMatrix(final float x, final float y) { + // Mirror the scale / rotate / setTransform overrides above: + // update the AsyncGraphics local transform AND queue an op so + // the underlying canvas's matrix is updated at playback time. + // Without this override, Graphics.useMatrixTranslation routes + // every framework g.translate(absX, absY) through + // impl.translateMatrix -- which inherits AndroidGraphics's + // local-mutation-only translateMatrix -- so the queued draw + // ops paint with the underlying canvas matrix that never saw + // any of the framework painting-chain translates, leaving the + // form chrome (title bar, theme buttons, etc.) painting at + // screen origin with no offset. + getTransform().translate(x, y); + transformDirty = true; + inverseTransformDirty = true; + clipFresh = false; + pendingRenderingOperations.add(new AsyncOp(clip, clipP, clipIsPath) { + @Override + public void execute(AndroidGraphics underlying) { + underlying.translateMatrix(x, y); + } + + @Override + public void executeWithClip(AndroidGraphics underlying) { + execute(underlying); + } + + public String toString() { + return "translateMatrix"; + } + }); + } + @Override public void resetAffine() { getTransform().setIdentity(); diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index be2d4688c2..05d11368f4 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -4626,8 +4626,8 @@ public void paint(final Graphics g) { lp = (AndroidAsyncView.LayoutParams) o; if (lp == null) { lp = new AndroidAsyncView.LayoutParams( - getX() + g.getTranslateX(), - getY() + g.getTranslateY(), + getX() + g.matrixFrameworkTranslateX(), + getY() + g.matrixFrameworkTranslateY(), getWidth(), getHeight(), AndroidPeer.this); final AndroidAsyncView.LayoutParams finalLp = lp; @@ -4639,8 +4639,8 @@ public void run() { }); lp.dirty = true; } else { - int x = getX() + g.getTranslateX(); - int y = getY() + g.getTranslateY(); + int x = getX() + g.matrixFrameworkTranslateX(); + int y = getY() + g.matrixFrameworkTranslateY(); int w = getWidth(); int h = getHeight(); if (x != lp.x || y != lp.y || w != lp.w || h != lp.h) { @@ -4653,8 +4653,8 @@ public void run() { } } else { final AndroidAsyncView.LayoutParams finalLp = new AndroidAsyncView.LayoutParams( - getX() + g.getTranslateX(), - getY() + g.getTranslateY(), + getX() + g.matrixFrameworkTranslateX(), + getY() + g.matrixFrameworkTranslateY(), getWidth(), getHeight(), AndroidPeer.this); activity.runOnUiThread(new Runnable() { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java index 536b76553a..68d09c8cfe 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/BufferedGraphics.java @@ -238,6 +238,29 @@ public void scale(double sx, double sy) { applyTransform(); } + @Override + public void translateMatrix(double tx, double ty) { + // Master added Graphics.translateMatrix in commit 826d60f32 / the + // InscribedTriangleGrid test; the framework dispatches to + // HTML5Implementation.translateMatrix which delegates to + // ((HTML5Graphics) graphics).translateMatrix(...). Without this + // override BufferedGraphics inherits HTML5Graphics's translateMatrix, + // which mutates the parent class's `transform` field -- a + // *different* field from the one BufferedGraphics's own + // scale/rotate/etc. overrides use. The result: translateMatrix on + // the form's graphics silently no-ops as far as queued ops are + // concerned, which under the new matrix-mode Graphics layer + // collapses every framework painting-chain translate to (0,0) on + // the form's main canvas -- Toolbar titles, chart-pie etc. + // Override here so the BufferedGraphics-side `transform` field + // receives the composition and the next applyTransform() submits + // a SetTransform op carrying the right matrix. + if (transform == null) transform = Transform.makeIdentity(); + transform.translate((float)tx, (float)ty); + setTransformChanged(); + applyTransform(); + } + //@Override //public void shear(double shx, double shy) { // setTransform(JSAffineTransform.Factory.getShearInstance(shx, shy), false); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java index f776937442..7d3732ae1c 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Implementation.java @@ -2183,6 +2183,22 @@ public void setGraphicsLocked(boolean locked) { } CanvasRenderingContext2D context = (CanvasRenderingContext2D)outputCanvas.getContext("2d"); context.save(); + // Reset to identity BEFORE the crop clip is set. Without this, if + // a prior drain ended with a non-identity transform on the canvas + // state (e.g. ClipShape's setTransform leftover that the outer + // save/restore preserves across drains, or the matrix-mode + // T(framework_anchor) the new Graphics path now stashes there), + // the `rect(cropX, cropY, cropW, cropH); clip();` below evaluates + // under that leaked transform -- the resulting clip is a + // rotated/scaled/translated polygon, not the intended axis- + // aligned crop. Ops in this drain then paint UNDER the leaked + // transform AND through the wrong clip, producing missing + // Toolbar/title regions (the chart-line, chart-pie and chart- + // rotated-pie JS regressions). Force identity now; the per-op + // SetTransform queue inside this drain still sets the per-paint + // transform, and the outer `restore()` pops back to whatever + // pre-drain state was active. + context.setTransform(1, 0, 0, 1, 0, 0); context.beginPath(); context.rect(frame.getCropX(), frame.getCropY(), frame.getCropW(), frame.getCropH()); context.clip(); diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m index ec09e2d8df..648ebf6db5 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m @@ -1248,6 +1248,18 @@ void CN1MetalDrawAlphaMaskRadial(id texture, static int savedScreenFw = 0; static int savedScreenFh = 0; static uint32_t savedScreenStencilReference = 0; +// currentTransform is a GLOBAL set by every SetTransform ExecutableOp's +// execute() (via CN1MetalSetTransform). When draining a mutable +// side-trip, the mutable target's SetTransform ops mutate this same +// global; without saving/restoring it here, subsequent screen-target +// ops on the drain queue read the mutable's last transform instead of +// the screen's own framework_anchor. This shows up dramatically once +// matrix-mode g.translate routes every framework translate through +// SetTransform: Switches, FABs and other components whose first paint +// lazily builds a mutable image drift off-screen, become invisible, +// or stack at the wrong y because the screen draw that follows the +// mutable build uses the mutable's coord system. +static simd_float4x4 savedScreenTransform; static BOOL savedScreenStateValid = NO; // Build a Y-down ortho projection for an offscreen (w x h) framebuffer. @@ -1456,6 +1468,7 @@ BOOL CN1MetalBeginMutableImageDraw(GLUIImage *image) { savedScreenFw = currentFramebufferWidth; savedScreenFh = currentFramebufferHeight; savedScreenStencilReference = currentStencilReference; + savedScreenTransform = currentTransform; savedScreenStateValid = YES; activeEncoder = enc; @@ -1494,6 +1507,13 @@ void CN1MetalEndMutableImageDraw(GLUIImage *image) { // pre-detour polygon clip's writes are still distinguishable // from a fresh post-detour clip (see Begin's note). currentStencilReference = savedScreenStencilReference; + // Restore the screen-side render transform. The mutable side- + // trip's SetTransform ops mutated this global; leaving it as the + // mutable's last transform would make every screen-target draw + // queued after this Begin/End pair (e.g. Switch's + // g.drawImage(track,...) following its lazy track-image build) + // pick up the mutable's coord system instead of the screen's. + currentTransform = savedScreenTransform; savedScreenEncoder = nil; savedScreenStateValid = NO; } diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 5513a36ad0..b2b6370ba6 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -238,6 +238,27 @@ MFMessageComposeViewControllerDelegate, CLLocationManagerDelegate, AVAudioRecord -(BOOL)isPaintFinished; -(void)flushBuffer:(UIImage *)buff x:(int)x y:(int)y width:(int)width height:(int)height; +#ifdef CN1_USE_METAL +// Drain only the ExecutableOps queued against `image` (target == image), +// leaving every other op untouched in the upcoming queue. Opens a fresh +// mutable encoder for the image, executes the extracted ops against it, +// and commits -- so a follow-up CN1MetalFlushMutableImageSync(image) call +// can waitUntilCompleted on the buffer and read back actual pixels. +// +// Why not just call flushBuffer here? flushBuffer drains the *entire* +// upcoming queue, including the form's SetTransform / Draw ops, then +// drawFrame's CN1MetalBeginFrame resets the global currentTransform to +// identity at the start of the *next* drawFrame. The form's +// NativeGraphics.transformApplied flag stays true through that round- +// trip, so the next form draw queues with no preceding SetTransform op +// and executes against currentTransform=identity at the next drain -- +// every Switch / FAB / etc. whose first paint blurs a mutable thumb +// lands at screen (local_x, local_y). The targeted drain keeps the +// form's ops in the queue exactly where they were, so they drain in +// order against the correct currentTransform. +-(void)flushOpsForMutableImage:(GLUIImage*)image; +#endif + -(void)drawString:(int)color alpha:(int)alpha font:(UIFont*)font str:(NSString*)str x:(int)x y:(int)y; - (void)drawScreen; - (void)drawFrame:(CGRect)rect; diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index e2bca4ecb0..6efa746060 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -3708,6 +3708,72 @@ -(void)flushBuffer:(UIImage *)buff x:(int)x y:(int)y width:(int)width height:(in }*/ } +#ifdef CN1_USE_METAL +-(void)flushOpsForMutableImage:(GLUIImage*)image { + if (image == nil) return; + dispatch_sync(dispatch_get_main_queue(), ^{ + // Partition the upcoming queue: ops targeting `image` get + // extracted (they're what we need to execute right now so a + // follow-up readback sees the painted pixels); everything else + // -- the form's SetTransform / Draw ops, ops for OTHER mutable + // images, ClipShape state -- stays in the queue and drains at + // the next normal drawFrame. The global flushBuffer used to + // drain everything; that left the form's NativeGraphics. + // transformApplied flag stale relative to the Metal currentTransform + // (which CN1MetalBeginFrame resets to identity each frame), so + // the form's follow-up drawImage / drawString lacked a preceding + // SetTransform op and rendered at currentTransform=identity at + // the next drain. Visible: Switch's track/thumb lands at screen + // (local_x, local_y) in matrix mode. + NSMutableArray *opsForImage = [[NSMutableArray alloc] init]; + NSMutableArray *remainingOps = [[NSMutableArray alloc] init]; + @synchronized([CodenameOne_GLViewController instance]) { + for (ExecutableOp *op in self->upcomingTarget) { + if ([op target] == image) { + [opsForImage addObject:op]; + } else { + [remainingOps addObject:op]; + } + } + [self->upcomingTarget setArray:remainingOps]; + } + if ([opsForImage count] == 0) { +#ifndef CN1_USE_ARC + [opsForImage release]; + [remainingOps release]; +#endif + return; + } + // Open a fresh mutable encoder for this image. Begin saves the + // current screen state (encoder/projection/framebuffer/stencil/ + // transform) so the followup End can restore -- so even though + // we're mid-paint and currentTransform may carry a leftover + // form/mutable matrix from a prior drain, the side-trip ends + // with currentTransform back where it was. + BOOL encoderOpen = CN1MetalBeginMutableImageDraw(image); + if (!encoderOpen) { +#ifndef CN1_USE_ARC + [opsForImage release]; + [remainingOps release]; +#endif + return; + } + for (ExecutableOp *op in opsForImage) { + [op executeWithClipping]; + } + CN1MetalEndMutableImageDraw(image); + // Now the mutable's MTLCommandBuffer is committed; the caller + // (typically gausianBlurImage in IOSNative.m) can call + // CN1MetalFlushMutableImageSync(image) to waitUntilCompleted + // before reading back the texture. +#ifndef CN1_USE_ARC + [opsForImage release]; + [remainingOps release]; +#endif + }); +} +#endif + -(void)drawString:(int)color alpha:(int)alpha font:(UIFont*)font str:(NSString*)str x:(int)x y:(int)y { POOL_BEGIN(); UIColor* col = UIColorFromRGB(color,alpha); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 28117fdfbf..4efd276010 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -809,9 +809,27 @@ JAVA_LONG com_codename1_impl_ios_IOSNative_gausianBlurImage___long_float(CN1_THR UIImage* original = nil; #ifdef CN1_USE_METAL if ([glu mtlMutableTexture] != nil) { - extern int displayWidth; - extern int displayHeight; - [[CodenameOne_GLViewController instance] flushBuffer:nil x:0 y:0 width:displayWidth height:displayHeight]; + // Drain any pending ExecutableOps targeting THIS specific mutable + // image so the GPU executes the shadow-ring fillArc calls before + // CN1MetalReadMutableImageAsUIImage samples the texture. + // + // Previously this called the global flushBuffer, which drained the + // entire upcoming queue (form ops too) and triggered a full + // drawFrame mid-paint. That worked for the readback but left + // form-side NativeGraphics state (transformApplied / clipApplied) + // stale relative to the iOS Metal globals, so the next form draw + // queued without a preceding SetTransform op and rendered at + // currentTransform=identity at the *next* drawFrame -- + // visible as Switch's track/thumb landing at screen (local_x, + // local_y) instead of the component's screen position + // (matrix-mode SwitchTheme / kotlin failures). + // + // flushOpsForMutableImage only walks the queue and executes the + // ops whose target == glu, leaving form ops in place. The form's + // SetTransform / Draw ops stay in upcomingTarget so the next + // drawFrame drains them in their original order against the + // correct currentTransform. + [[CodenameOne_GLViewController instance] flushOpsForMutableImage:glu]; original = CN1MetalReadMutableImageAsUIImage(glu); } if (original == nil) { diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index acfc0cca06..1356d1deee 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -112,6 +112,25 @@ + + + + + + + + + + + +