diff --git a/.github/workflows/scripts-javascript.yml b/.github/workflows/scripts-javascript.yml index a7156e0ae83..22196585d8b 100644 --- a/.github/workflows/scripts-javascript.yml +++ b/.github/workflows/scripts-javascript.yml @@ -13,6 +13,7 @@ on: - 'scripts/run-javascript-headless-browser.mjs' - 'scripts/run-javascript-lifecycle-tests.mjs' - 'scripts/run-javascript-lifecycle-tests.sh' + - 'scripts/verify-javascript-lens-parity.mjs' - 'scripts/build-javascript-port-hellocodenameone.sh' - 'scripts/javascript_browser_harness.py' - 'scripts/javascript/screenshots/**' @@ -35,6 +36,7 @@ on: - 'scripts/run-javascript-headless-browser.mjs' - 'scripts/run-javascript-lifecycle-tests.mjs' - 'scripts/run-javascript-lifecycle-tests.sh' + - 'scripts/verify-javascript-lens-parity.mjs' - 'scripts/build-javascript-port-hellocodenameone.sh' - 'scripts/javascript_browser_harness.py' - 'scripts/javascript/screenshots/**' @@ -141,6 +143,9 @@ jobs: with: node-version: '20' + - name: Verify JavaScript glass-tab lens parity + run: node scripts/verify-javascript-lens-parity.mjs + - name: Cache npm modules for scripts/ uses: actions/cache@v5 with: diff --git a/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java b/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java index dd5272ee2e3..b1fab8a9227 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java +++ b/CodenameOne/src/com/codename1/ui/plaf/RoundBorder.java @@ -688,18 +688,28 @@ private void fillShape(Graphics g, int color, int opacity, int width, int height return; } if (stroke && this.stroke != null && strokeGradient && strokeAngle == 360 && g.isShapeClipSupported()) { - // Gradient-stroked circle: fill the full circle with the stroke - // gradient, then lay the background over an inset circle so only - // a `sw`-wide gradient ring (the stroke) shows at the edge. - int sw = (int) Math.ceil(this.stroke.getLineWidth()); + // Gradient-stroked circle, in three passes that hold on every + // port: (1) the background fills the FULL circle, (2) the + // translucent gradient paints over it clipped to the same + // circle, (3) the interior is restored clipped to the inset + // circle. Passes 2 and 3 go through the same clip+paint + // rasterizer, so the leftover `sw`-wide gradient ring has + // registration-stable edges even at hairline widths (the old + // gradient-fill-then-plain-inner-fill pair mixed two different + // rasterizers and read as a broken, muddy stroke), and because + // the background lies beneath it the ring glints over the fill + // instead of the backdrop. A multi-contour winding ring is NOT + // portable: the iOS/Metal clip unions subpaths. + int sw = Math.max(1, (int) Math.ceil(this.stroke.getLineWidth())); GeneralPath outer = new GeneralPath(); outer.arc(x, y, size, size, 0, 2 * Math.PI); - GeneralPath innerBg = new GeneralPath(); - innerBg.arc(x + sw, y + sw, size - sw * 2, size - sw * 2, 0, 2 * Math.PI); + g.fillShape(outer); g.setColor(makeStrokePaint(width, height)); g.setAlpha(strokeOpacity); g.fillShape(outer); - g.setColor(color); + GeneralPath innerBg = new GeneralPath(); + innerBg.arc(x + sw, y + sw, size - sw * 2, size - sw * 2, 0, 2 * Math.PI); + g.setColor(new SolidPaint(color)); g.setAlpha(opacity); g.fillShape(innerBg); } else if (stroke && this.stroke != null) { @@ -720,15 +730,17 @@ private void fillShape(Graphics g, int color, int opacity, int width, int height } else { float sw = (stroke && this.stroke != null) ? this.stroke.getLineWidth() : 0; if (stroke && this.stroke != null && strokeGradient && g.isShapeClipSupported()) { - // Gradient-stroked pill: fill the full pill with the stroke - // gradient, then lay the background over an inset pill so only a - // `sw`-wide gradient ring (the stroke) shows at the edge. + // Gradient-stroked pill; see the circle branch above for why + // this is three passes (background, gradient over it, interior + // restored) with the last two on the same clip+paint rasterizer + // rather than a multi-contour winding ring. GeneralPath outer = createPillPath(width, height, 0); - GeneralPath innerBg = createPillPath(width, height, sw); + g.fillShape(outer); g.setColor(makeStrokePaint(width, height)); g.setAlpha(strokeOpacity); g.fillShape(outer); - g.setColor(color); + GeneralPath innerBg = createPillPath(width, height, Math.max(1f, sw)); + g.setColor(new SolidPaint(color)); g.setAlpha(opacity); g.fillShape(innerBg); return; @@ -784,6 +796,29 @@ private Paint makeStrokePaint(float width, float height) { MultipleGradientPaint.ColorSpaceType.SRGB, null); } + /// A solid-colour Paint. Filling through the clip+paint mechanism (rather + /// than a plain path fill) keeps the gradient stroke's inner edge on the + /// SAME rasterizer as the gradient pass, so the hairline ring between them + /// stays registration-stable on every port. + private static final class SolidPaint implements Paint { + private final int solidColor; + + SolidPaint(int solidColor) { + this.solidColor = solidColor; + } + + @Override + public void paint(Graphics g, com.codename1.ui.geom.Rectangle2D bounds) { + paint(g, bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight()); + } + + @Override + public void paint(Graphics g, double x, double y, double w, double h) { + g.setColor(solidColor); + g.fillRect((int) x, (int) y, (int) Math.ceil(w), (int) Math.ceil(h)); + } + } + @Override public boolean isBackgroundPainter() { return true; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 9a77893b38b..f64b2378e50 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -9128,6 +9128,25 @@ public void componentHidden(ComponentEvent e) { Display.getInstance().setDefaultVirtualKeyboard(null); } + // Deterministic visual probes sometimes need to render at the physical + // density of a reference device while keeping the Simulator window small + // enough to fit the host display. A skin normally supplies this value via + // ppi/pixelRatio; this opt-in override decouples density from window chrome + // and is intentionally applied after skin/desktop-mode initialization. + String forcedPixelMilliRatio = System.getProperty("cn1.javase.pixelMilliRatio"); + if (forcedPixelMilliRatio != null) { + try { + double ratio = Double.parseDouble(forcedPixelMilliRatio.trim()); + if (ratio > 0) { + pixelMilliRatio = Double.valueOf(ratio); + setDefaultPixelMilliRatio(pixelMilliRatio); + } + } catch (NumberFormatException err) { + System.err.println("Ignoring invalid cn1.javase.pixelMilliRatio=" + + forcedPixelMilliRatio); + } + } + float factor = ((float) getDisplayHeight()) / 480.0f; if (factor > 0 && autoAdjustFontSize && getSkin() != null) { // set a reasonable default font size @@ -17200,7 +17219,180 @@ public boolean blurRegion(Object graphics, int x, int y, int width, int height, return false; } } - + + /// The full iOS 26 "Liquid Glass" material for backdrop-filter surfaces (the + /// floating tab-bar pill, glass buttons). Mirrors IOSImplementation.glassRegion's + /// offscreen branch step for step: read the backdrop PADDED and edge-replicated, + /// apply the affine colour material (saturation/scale/offset), Gaussian-blur, + /// then apply the rounded-shape OPTICS (edge refraction + specular rim + AA mask) + /// and composite the pill-shaped patch back. Without this the simulator fell + /// back to a plain blur, leaving the bar a flat grey where the device renders a + /// bright frost -- every glass-tab comparison read "murky" purely because of the + /// missing material under the lens. + @Override + public boolean glassRegion(Object graphics, int x, int y, int width, int height, float radius, + float cornerRadius, float sat, float scaleParam, float offset, float refract, float specular) { + if (radius <= 0f || width <= 0 || height <= 0) { + return true; + } + try { + BufferedImage dest; + double scale; + if (graphics instanceof NativeScreenGraphics) { + NativeScreenGraphics ng = (NativeScreenGraphics) graphics; + if (ng.sourceImage != null) { + dest = ng.sourceImage; + scale = 1.0; + } else if (canvas != null && canvas.edtBuffer != null) { + dest = canvas.edtBuffer; + scale = (double) dest.getWidth() / getDisplayWidthImpl(); + } else { + return false; + } + } else if (mutableImageGraphics.containsKey(graphics)) { + dest = mutableImageGraphics.get(graphics); + scale = 1.0; + } else { + return false; + } + 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(); + 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; + } + float scaledRadius = (float) (radius * scale); + float scaledCorner = cornerRadius < 0 ? -1f : (float) (cornerRadius * scale); + // Pad by 3*radius of edge-replicated backdrop so the blur's edge fade is + // fully contained outside the component (see the iOS port commentary). + int pad = (int) Math.ceil(scaledRadius) * 3 + 1; + int bw = rw + 2 * pad, bh = rh + 2 * pad; + int ax0 = Math.max(0, rx - pad), ay0 = Math.max(0, ry - pad); + int ax1 = Math.min(dw, rx + rw + pad), ay1 = Math.min(dh, ry + rh + pad); + int aw = ax1 - ax0, ah = ay1 - ay0; + int[] avail = dest.getRGB(ax0, ay0, aw, ah, null, 0, aw); + int[] prgb = new int[bw * bh]; + for (int by = 0; by < bh; by++) { + int ay = (ry - pad + by) - ay0; + if (ay < 0) ay = 0; else if (ay >= ah) ay = ah - 1; + int arow = ay * aw, brow = by * bw; + for (int bx = 0; bx < bw; bx++) { + int ax = (rx - pad + bx) - ax0; + if (ax < 0) ax = 0; else if (ax >= aw) ax = aw - 1; + prgb[brow + bx] = avail[arow + ax]; + } + } + glassMaterialInPlace(prgb, sat, scaleParam, offset); + BufferedImage padded = new BufferedImage(bw, bh, BufferedImage.TYPE_INT_ARGB); + padded.setRGB(0, 0, bw, bh, prgb, 0, bw); + BufferedImage blurredPadded = new GaussianFilter(scaledRadius).filter(padded, null); + int[] pbargb = blurredPadded.getRGB(0, 0, bw, bh, null, 0, bw); + int[] out = new int[rw * rh]; + applyGlassOptics(pbargb, bw, bh, pad, out, rw, rh, scaledCorner, refract, specular); + BufferedImage patch = new BufferedImage(rw, rh, BufferedImage.TYPE_INT_ARGB); + patch.setRGB(0, 0, rw, rh, out, 0, rw); + Graphics2D dg = dest.createGraphics(); + dg.drawImage(patch, rx, ry, null); + dg.dispose(); + return true; + } catch (Throwable t) { + return false; + } + } + + /// The reverse-engineered iOS Liquid Glass colour material; mirrors + /// IOSImplementation.glassMaterialInPlace (validated <1 LSB against a real + /// UIVisualEffectView): c' = clamp((lum + (c - lum) * sat) * scale + offset). + private static void glassMaterialInPlace(int[] argb, float sat, float scale, float offset) { + for (int i = 0; i < argb.length; i++) { + int p = argb[i]; + int a = p & 0xff000000; + float r = (p >> 16) & 0xff, g = (p >> 8) & 0xff, b = p & 0xff; + float lum = 0.2126f * r + 0.7152f * g + 0.0722f * b; + r = (lum + (r - lum) * sat) * scale + offset; + g = (lum + (g - lum) * sat) * scale + offset; + b = (lum + (b - lum) * sat) * scale + offset; + int ri = r < 0 ? 0 : (r > 255 ? 255 : (int) r); + int gi = g < 0 ? 0 : (g > 255 ? 255 : (int) g); + int bi = b < 0 ? 0 : (b > 255 ? 255 : (int) b); + argb[i] = a | (ri << 16) | (gi << 8) | bi; + } + } + + /// Liquid Glass optics over the blurred material; mirrors + /// IOSImplementation.applyGlassOptics: rounded-rect SDF drives an edge + /// refraction (quarter-circle displacement toward the centre), a specular rim + /// glint (brightest at the top) and the anti-aliased shape mask. + private static void applyGlassOptics(int[] src, int bw, int bh, int pad, int[] out, + int rw, int rh, float cornerRadius, float refract, float specular) { + float hw = rw / 2f, hh = rh / 2f; + float r = cornerRadius < 0f ? Math.min(hw, hh) : Math.min(cornerRadius, Math.min(hw, hh)); + if (r < 0f) r = 0f; + float band = Math.min(hw, hh) * 0.6f; // refraction active in the outer 60% + float rimW = 3.0f; // specular rim width (px) + for (int y = 0; y < rh; y++) { + float py = y + 0.5f; + for (int x = 0; x < rw; x++) { + float px = x + 0.5f; + float dx = Math.abs(px - hw) - (hw - r); + float dy = Math.abs(py - hh) - (hh - r); + float ax = dx > 0 ? dx : 0, ay = dy > 0 ? dy : 0; + float outside = (float) Math.sqrt(ax * ax + ay * ay); + float inside = Math.min(Math.max(dx, dy), 0f); + float sdf = outside + inside - r; + float depth = -sdf; + if (depth <= 0f) { out[y * rw + x] = 0; continue; } + float alpha = depth >= 1f ? 1f : depth; + float sx = x, sy = y; + if (refract > 0f && band > 0f && depth < band) { + float t = 1f - depth / band; + float distortion = 1f - (float) Math.sqrt(Math.max(0f, 1f - t * t)); + sx = x - (px - hw) * distortion * refract; + sy = y - (py - hh) * distortion * refract; + } + int col = glassSampleBilinear(src, bw, bh, sx + pad, sy + pad); + int rr = (col >> 16) & 0xff, gg = (col >> 8) & 0xff, bb = col & 0xff; + if (specular > 0f && depth < rimW) { + float rim = 1f - depth / rimW; + float topBias = 0.55f + 0.45f * (1f - py / rh); + int add = (int) (specular * rim * topBias * 70f); + rr = rr + add > 255 ? 255 : rr + add; + gg = gg + add > 255 ? 255 : gg + add; + bb = bb + add > 255 ? 255 : bb + add; + } + int a = (int) (alpha * 255f); + out[y * rw + x] = (a << 24) | (rr << 16) | (gg << 8) | bb; + } + } + } + + /// Bilinear ARGB sample with edge clamping; mirrors IOSImplementation.sampleBilinear. + private static int glassSampleBilinear(int[] buf, int w, int h, float fx, float fy) { + if (fx < 0f) fx = 0f; else if (fx > w - 1) fx = w - 1; + if (fy < 0f) fy = 0f; else if (fy > h - 1) fy = h - 1; + int x0 = (int) fx, y0 = (int) fy; + int x1 = x0 + 1 < w ? x0 + 1 : x0, y1 = y0 + 1 < h ? y0 + 1 : y0; + float tx = fx - x0, ty = fy - y0; + int p00 = buf[y0 * w + x0], p10 = buf[y0 * w + x1]; + int p01 = buf[y1 * w + x0], p11 = buf[y1 * w + x1]; + int rr = glassBilerp((p00 >> 16) & 0xff, (p10 >> 16) & 0xff, (p01 >> 16) & 0xff, (p11 >> 16) & 0xff, tx, ty); + int gg = glassBilerp((p00 >> 8) & 0xff, (p10 >> 8) & 0xff, (p01 >> 8) & 0xff, (p11 >> 8) & 0xff, tx, ty); + int bb = glassBilerp(p00 & 0xff, p10 & 0xff, p01 & 0xff, p11 & 0xff, tx, ty); + return (rr << 16) | (gg << 8) | bb; + } + + private static int glassBilerp(int c00, int c10, int c01, int c11, float tx, float ty) { + float top = c00 + (c10 - c00) * tx; + float bot = c01 + (c11 - c01) * tx; + return (int) (top + (bot - top) * ty + 0.5f); + } + // iOS-26 tab selection-DROP lens constants (shared shape with METALView.m // glassApplyLens). Uniform magnification in the central MAG_FLAT fraction of the // radius, smooth falloff to 1.0 at the rim; chromatic aberration at the rim only; 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 e86b2b9bd28..c2b77958776 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 @@ -181,11 +181,18 @@ public void blurRegion(int x, int y, int width, int height, float radius, float addOp(new com.codename1.impl.html5.graphics.BlurRegion(x, y, width, height, radius, cornerRadius)); } + @Override + public void glassRegion(int x, int y, int width, int height, float radius, float cornerRadius, + float saturation, float scale, float offset, float refraction, float specular) { + addOp(new com.codename1.impl.html5.graphics.GlassRegion(x, y, width, height, + radius, cornerRadius, saturation, scale, offset, refraction, specular)); + } + @Override public void lensRegion(int x, int y, int width, int height, float cornerRadius, float magnify, - int tintColor, float tintStrength) { + float aberration, int tintColor, float tintStrength) { addOp(new com.codename1.impl.html5.graphics.LensRegion(x, y, width, height, cornerRadius, - magnify, tintColor, tintStrength)); + magnify, aberration, tintColor, tintStrength)); } @Override diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java index 3a01664d4b2..67952773511 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/HTML5Graphics.java @@ -456,14 +456,24 @@ public void blurRegion(int x, int y, int width, int height, float radius, float dispatchOp(new com.codename1.impl.html5.graphics.BlurRegion(x, y, width, height, radius, cornerRadius)); } + /** + * In-place Liquid Glass material; see + * {@link com.codename1.impl.html5.graphics.GlassRegion}. + */ + public void glassRegion(int x, int y, int width, int height, float radius, float cornerRadius, + float saturation, float scale, float offset, float refraction, float specular) { + dispatchOp(new com.codename1.impl.html5.graphics.GlassRegion(x, y, width, height, + radius, cornerRadius, saturation, scale, offset, refraction, specular)); + } + /** * In-place selection-drop lens (magnify + tint); see * {@link com.codename1.impl.html5.graphics.LensRegion}. */ public void lensRegion(int x, int y, int width, int height, float cornerRadius, float magnify, - int tintColor, float tintStrength) { + float aberration, int tintColor, float tintStrength) { dispatchOp(new com.codename1.impl.html5.graphics.LensRegion(x, y, width, height, cornerRadius, - magnify, tintColor, tintStrength)); + magnify, aberration, tintColor, tintStrength)); } public void clearRect(int x, int y, int width, int height) { 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 4bc1a656805..789b7a3679e 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 @@ -6414,21 +6414,26 @@ public boolean blurRegion(Object graphics, int x, int y, int width, int height, @Override public boolean glassRegion(Object graphics, int x, int y, int width, int height, float radius, float cornerRadius, float sat, float scale, float offset, float refract, float specular) { - // Plain backdrop blur clipped to the glass shape. The full Liquid Glass - // colour transform (saturation/scale/offset/refraction/specular) is the - // iOS Metal port's material; the browser gets the honest frost, which - // is what backdrop-filter delivers on the web anyway. - g(graphics).blurRegion(x, y, width, height, radius, cornerRadius); + // Preserve the complete named GlassRecipe. The host bridge applies the + // same affine colour material and rounded-shape optics as iOS around + // the browser's native Gaussian blur. Dropping these parameters made + // the tab-bar pill far more transparent than native. + g(graphics).glassRegion(x, y, width, height, radius, cornerRadius, + sat, scale, offset, refract, specular); return true; } @Override public boolean lensRegion(Object graphics, int x, int y, int width, int height, float cornerRadius, float magnify, float aberration, int tintColor, float tintStrength) { - // The iOS-26 tab selection drop: magnify the content bulge + accent - // tint. Chromatic aberration (the iOS Metal shader's extra) is dropped - // on the web; magnify + tint carries the recognisable effect. - g(graphics).lensRegion(x, y, width, height, cornerRadius, magnify, tintColor, tintStrength); + // The host-side canvas bridge runs the same per-pixel lens as JavaSE: + // centre magnification, rim refraction, chromatic aberration, luminance- + // keyed accent tint and the subtle glass highlights/shadows. Keeping the + // complete parameter set here is important -- the old browser fallback + // reduced this to a uniform zoom plus a blue rectangle, which made every + // in-flight frame visibly harsher than the Simulator. + g(graphics).lensRegion(x, y, width, height, cornerRadius, magnify, + aberration, tintColor, tintStrength); return true; } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/GlassRegion.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/GlassRegion.java new file mode 100644 index 00000000000..9b0aea225d0 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/GlassRegion.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codename1.impl.html5.graphics; + +import com.codename1.html5.js.canvas.CanvasRenderingContext2D; + +/** + * Records a complete named Liquid Glass material for ordered host-side replay. + * The host applies the native affine colour transform, browser Gaussian blur, + * rounded shape mask, edge refraction and specular rim to the surface's own + * already-painted pixels. + */ +public final class GlassRegion implements ExecutableOp { + private final int x, y, width, height; + private final float radius, cornerRadius, saturation, scale, offset, refraction, specular; + + public GlassRegion(int x, int y, int width, int height, float radius, float cornerRadius, + float saturation, float scale, float offset, float refraction, float specular) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.radius = radius; + this.cornerRadius = cornerRadius; + this.saturation = saturation; + this.scale = scale; + this.offset = offset; + this.refraction = refraction; + this.specular = specular; + } + + @Override + public void execute(CanvasRenderingContext2D context) { + if (!(context instanceof SurfaceCommandRecorder)) { + return; + } + ((SurfaceCommandRecorder) context).glassSelfRegion(x, y, width, height, + radius, cornerRadius, saturation, scale, offset, refraction, specular); + } + + @Override + public String getDescription() { + return "GlassRegion"; + } +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/LensRegion.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/LensRegion.java index 93977d56d66..8da4ebec0e0 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/LensRegion.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/LensRegion.java @@ -28,28 +28,28 @@ /** * In-place iOS 26 selection-drop lens for the Tabs glass indicator. Records * {@link SurfaceCommandRecorder#OP_LENS_SELF_REGION}: the host clips to the - * region, magnifies the surface's own pixels there (the content "bulge"), - * then optionally washes them toward the accent tint. + * region and applies the same per-pixel droplet shader as the JavaSE + * Simulator: centre magnification, rim refraction, chromatic aberration, + * luminance-keyed accent tint, saturation and subtle glass lighting. * - *

The native iOS port renders this with a Metal fragment shader that also - * applies chromatic aberration; the web keeps the magnify + tint, which is - * the readable part of the drop. Nothing crosses the worker<->host - * barrier - the magnify uses a self-referential {@code drawImage} during - * host-side command replay.

+ *

Nothing crosses the worker<->host barrier: the worker records the lens + * parameters and the host applies them to the destination canvas during + * ordered command replay.

*/ public class LensRegion implements ExecutableOp { final int x, y, w, h; - final float cornerRadius, magnify, tintStrength; + final float cornerRadius, magnify, aberration, tintStrength; final int tintColor; public LensRegion(int x, int y, int w, int h, float cornerRadius, float magnify, - int tintColor, float tintStrength) { + float aberration, int tintColor, float tintStrength) { this.x = x; this.y = y; this.w = w; this.h = h; this.cornerRadius = cornerRadius; this.magnify = magnify; + this.aberration = aberration; this.tintColor = tintColor; this.tintStrength = tintStrength; } @@ -59,25 +59,8 @@ public void execute(CanvasRenderingContext2D context) { if (!(context instanceof SurfaceCommandRecorder)) { return; } - String tintCss = null; - if (tintStrength > 0) { - int r = (tintColor >> 16) & 0xff; - int g = (tintColor >> 8) & 0xff; - int b = tintColor & 0xff; - // The model's tintStrength assumes the native lens's luminance - // KEYING (only dark glyph pixels drift to accent; the bright frost - // is untouched). On canvas we can't key without a forbidden pixel - // read, so a flat fill at the full strength paints a solid accent - // blob over the whole drop and buries the glyph. Scale it right - // down so the fill reads as a translucent blue CAST over the still- - // visible magnified content instead of covering it. - double a = Math.min(0.30, Math.max(0.0, tintStrength) * 0.32); - tintCss = "rgba(" + r + "," + g + "," + b + "," + a + ")"; - } - // magnify < ~1 would shrink; clamp to at least 1 so the drop only ever - // bulges (the model can pass values slightly under 1 at rest). - double mag = Math.max(1.0, magnify); - ((SurfaceCommandRecorder) context).lensSelfRegion(x, y, w, h, cornerRadius, mag, tintCss); + ((SurfaceCommandRecorder) context).lensSelfRegion(x, y, w, h, cornerRadius, + magnify, aberration, tintColor, tintStrength); } @Override diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java index 7eafb9043a1..365eb3e35a7 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java @@ -135,14 +135,17 @@ public final class SurfaceCommandRecorder implements CanvasRenderingContext2D { // side-channel host call would race the flush. public static final int OP_BLUR_SELF_REGION = 80; // 6 nums (x, y, w, h, sigmaPx, cornerRadius) - // In-place iOS 26 selection-DROP lens: MAGNIFY the surface's own pixels in - // the region (content bulge) then optionally wash them toward an accent - // tint. The full chromatic aberration of the native Metal shader is - // dropped on the web; magnify + tint carries the recognisable "drop". - // 6 nums (x, y, w, h, cornerRadius, magnify) + 1 obj (tint css color or - // null when tintStrength<=0). + // In-place iOS 26 selection-DROP lens. The host mirrors JavaSE's per-pixel + // lens, including aberration and luminance-keyed tint. + // 9 nums: x, y, w, h, cornerRadius, magnify, aberration, tintColor, + // tintStrength. public static final int OP_LENS_SELF_REGION = 81; + // Complete named Liquid Glass material: affine colour transform + blur + + // rounded shape optics. 11 nums: x, y, w, h, blurRadius, cornerRadius, + // saturation, scale, offset, refraction, specular. + public static final int OP_GLASS_SELF_REGION = 82; + private int[] ops = new int[64]; private int opCount; private double[] nums = new double[256]; @@ -296,9 +299,21 @@ public void blurSelfRegion(double x, double y, double w, double h, double sigmaP op(OP_BLUR_SELF_REGION); num(x); num(y); num(w); num(h); num(sigmaPx); num(cornerRadius); } - /** Records an in-place selection-drop lens; see OP_LENS_SELF_REGION. tintCss null = no tint. */ - public void lensSelfRegion(double x, double y, double w, double h, double cornerRadius, double magnify, String tintCss) { - op(OP_LENS_SELF_REGION); num(x); num(y); num(w); num(h); num(cornerRadius); num(magnify); obj(tintCss); + /** Records an in-place selection-drop lens; see OP_LENS_SELF_REGION. */ + public void lensSelfRegion(double x, double y, double w, double h, double cornerRadius, + double magnify, double aberration, int tintColor, double tintStrength) { + op(OP_LENS_SELF_REGION); + num(x); num(y); num(w); num(h); num(cornerRadius); num(magnify); + num(aberration); num(tintColor); num(tintStrength); + } + + /** Records a complete in-place Liquid Glass material; see OP_GLASS_SELF_REGION. */ + public void glassSelfRegion(double x, double y, double w, double h, double blurRadius, + double cornerRadius, double saturation, double scale, double offset, + double refraction, double specular) { + op(OP_GLASS_SELF_REGION); + num(x); num(y); num(w); num(h); num(blurRadius); num(cornerRadius); + num(saturation); num(scale); num(offset); num(refraction); num(specular); } @Override public String getFilter() { return "none"; } @Override public void clearRect(double x, double y, double width, double height) { diff --git a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java index 3582aa114bf..a52111d04d7 100644 --- a/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java +++ b/scripts/cn1playground/common/src/main/java/com/codenameone/playground/PlaygroundExamples.java @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + package com.codenameone.playground; final class PlaygroundExamples { @@ -214,18 +237,18 @@ public void start() { Form form = new Form("Tabs", new BorderLayout()); Tabs tabs = new Tabs(); tabs.addTab("Home", - FontImage.createMaterial(FontImage.MATERIAL_HOME, "Tab", 4), + FontImage.MATERIAL_HOME, 4, BoxLayout.encloseY( new SpanLabel("Latest activity"), new Label("3 new notifications"), new Button("Open inbox"))); tabs.addTab("Search", - FontImage.createMaterial(FontImage.MATERIAL_SEARCH, "Tab", 4), + FontImage.MATERIAL_SEARCH, 4, BoxLayout.encloseY( new TextField("", "Search anything"), new SpanLabel("Results appear here"))); tabs.addTab("Profile", - FontImage.createMaterial(FontImage.MATERIAL_PERSON, "Tab", 4), + FontImage.MATERIAL_PERSON, 4, BoxLayout.encloseY( new Label("Ada Lovelace"), new Label("ada@analytical.engine"), diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/README.md b/scripts/fidelity-app/goldens/ios-26-metal-anim/README.md new file mode 100644 index 00000000000..d575d32ced3 --- /dev/null +++ b/scripts/fidelity-app/goldens/ios-26-metal-anim/README.md @@ -0,0 +1,28 @@ +# Animation references — how they are captured and what they answer + +- **`native-tabs-*.mov`** — the REAL `UITabBar` Liquid Glass selection morph, + recorded from the NativeRef app on the iOS 26 simulator runtime named by + this golden set (`scripts/record-ios-native-anim.sh tabs `). + + **The recording MUST be tap-driven.** UIKit plays the full Liquid Glass + morph — the frosted refracting drop that bulges past the bar with chromatic + rims — only for genuine touch-driven selection on a `UITabBarController`; + programmatic `selectedIndex`/`selectedItem` changes play a flat simplified + platter slide, and a bare `UITabBar` never shows the full effect at all. An + earlier reference was mis-recorded through the programmatic path, and its + flat drop briefly led the CN1 tuning astray. The recording script now drives + real taps through the XCUITest bundle in + `../../ios-native-ref/tap-driver/` (requires `xcodegen`, like the + input-validation iOS driver). + +- **`cn1-tabs-*.mov`** — the Codename One morph in motion, rendered + deterministically from the JavaSE simulator at the reference density + (60 fps, the theme's 480 ms `tabsAnimatedIndicatorDurationInt` timeline, + 1088x290 tile). The CN1 side of the same motion, at the same tile the + `../ios-26-metal-frames/TabsMorph_*` goldens use. + +- **`native-switch-*.mov`** — the real `UISwitch` toggle; the switch morph is + not interaction-gated, so the self-animating recording path is fine there. + +The four lens/glass implementations (Metal shader, iOS CPU reference, JavaSE, +JavaScript) are held together by `scripts/verify-javascript-lens-parity.mjs`. diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-dark.mov b/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-dark.mov new file mode 100644 index 00000000000..bfcc65a1989 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-dark.mov differ diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-light.mov b/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-light.mov new file mode 100644 index 00000000000..8a0059df154 Binary files /dev/null and b/scripts/fidelity-app/goldens/ios-26-metal-anim/cn1-tabs-light.mov differ diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-dark.mov b/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-dark.mov index cdbf0b70067..25e601e14b3 100644 Binary files a/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-dark.mov and b/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-dark.mov differ diff --git a/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-light.mov b/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-light.mov index c4c7918998c..e3be1da86f3 100644 Binary files a/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-light.mov and b/scripts/fidelity-app/goldens/ios-26-metal-anim/native-tabs-light.mov differ diff --git a/scripts/fidelity-app/ios-native-ref/NativeRef.swift b/scripts/fidelity-app/ios-native-ref/NativeRef.swift index 35cd0bd73a9..c8f91ac70e7 100644 --- a/scripts/fidelity-app/ios-native-ref/NativeRef.swift +++ b/scripts/fidelity-app/ios-native-ref/NativeRef.swift @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + // 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 @@ -442,25 +465,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } // Default: the tab bar selection morph, first tab -> last tab and back, // mirroring the deterministic CN1 TabsMorph frames (travel 0 -> last). - let wPt = 60.0 * PT_PER_MM - let hPt = 16.0 * PT_PER_MM - let bar = UITabBar(frame: CGRect(x: 0, y: 0, width: wPt, height: hPt)) - 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 - let ap = UITabBarAppearance() - ap.configureWithDefaultBackground() - bar.standardAppearance = ap - if #available(iOS 15.0, *) { bar.scrollEdgeAppearance = ap } - bar.center = CGPoint(x: host.bounds.midX, y: host.bounds.midY) - host.addSubview(bar) + // + // This MUST be a real UITabBarController, not a bare UITabBar with a + // custom UITabBarAppearance: the full iOS 26 Liquid Glass selection + // morph (the frosted refracting drop the Files app shows) is rendered + // only by the controller-managed bar with the DEFAULT appearance. The + // earlier bare-bar + configureWithDefaultBackground() version fell back + // to a flat compact platter, which mis-recorded the native reference + // and (in an earlier PR iteration) led the CN1 tuning astray. + guard let window = self.window else { return } + let grey = UIColor(white: 0.5, alpha: 1) + func page(_ title: String, _ systemImage: String, _ tag: Int) -> UIViewController { + let vc = UIViewController() + vc.view.backgroundColor = grey + vc.tabBarItem = UITabBarItem(title: title, + image: UIImage(systemName: systemImage), tag: tag) + return vc + } + let tbc = UITabBarController() + tbc.viewControllers = [ + page("Featured", "star.fill", 0), + page("Search", "magnifyingglass", 1), + page("More", "ellipsis", 2) + ] + tbc.overrideUserInterfaceStyle = appearance == "dark" ? .dark : .light + window.rootViewController = tbc print("NATIVEREF:ANIMATING tabs \(appearance)") - var toLast = true - Timer.scheduledTimer(withTimeInterval: 1.4, repeats: true) { _ in - bar.selectedItem = toLast ? c : a - toLast = !toLast + // UIKit plays the FULL Liquid Glass selection morph (the frosted + // refracting drop) only for genuine touch-driven selection -- the drop + // lifts under the finger. Programmatic selectedIndex changes play a + // flat, simplified platter slide, which mis-recorded the native + // reference. The recording harness drives REAL taps via an XCUITest + // runner (record-ios-native-anim.sh); NATIVEREF_TAPS=0 restores the + // legacy self-animating timer for manual eyeballing. + if ProcessInfo.processInfo.environment["NATIVEREF_TAPS"] == "0" { + var toLast = true + Timer.scheduledTimer(withTimeInterval: 1.4, repeats: true) { _ in + tbc.selectedIndex = toLast ? 2 : 0 + toLast = !toLast + } } } diff --git a/scripts/fidelity-app/ios-native-ref/tap-driver/HostStub/App.swift b/scripts/fidelity-app/ios-native-ref/tap-driver/HostStub/App.swift new file mode 100644 index 00000000000..3e76caf7bbd --- /dev/null +++ b/scripts/fidelity-app/ios-native-ref/tap-driver/HostStub/App.swift @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +// Minimal host application for the UI-test bundle. XCUITest requires a host +// app target; the actual work happens in the test, which launches and taps +// the NativeRef app. +import UIKit + +@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() + window = w + return true + } +} diff --git a/scripts/fidelity-app/ios-native-ref/tap-driver/Tests/TapDriverTests.swift b/scripts/fidelity-app/ios-native-ref/tap-driver/Tests/TapDriverTests.swift new file mode 100644 index 00000000000..79358612e7b --- /dev/null +++ b/scripts/fidelity-app/ios-native-ref/tap-driver/Tests/TapDriverTests.swift @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +// Drives REAL taps on the NativeRef app's tab bar while the recording host +// captures the simulator screen. Genuine touches are what make UIKit play the +// full Liquid Glass selection morph -- see the project.yml commentary. +import XCTest + +final class TapDriverTests: XCTestCase { + func testDriveTabs() { + let env = ProcessInfo.processInfo.environment + let app = XCUIApplication(bundleIdentifier: "com.codenameone.fidelity.nativeref") + app.launchEnvironment["NATIVEREF_MODE"] = "animate" + app.launchEnvironment["NATIVEREF_ANIM"] = "tabs" + app.launchEnvironment["NATIVEREF_APPEARANCE"] = env["TAPDRIVER_APPEARANCE"] ?? "light" + app.launch() + sleep(2) + let bar = app.tabBars.firstMatch + XCTAssertTrue(bar.waitForExistence(timeout: 10), "NativeRef tab bar not found") + let buttons = bar.buttons + XCTAssertTrue(buttons.count >= 2, "NativeRef tab bar has too few items") + // First tab -> last tab and back, matching the deterministic CN1 + // TabsMorph frames (travel 0 -> last), with a settle pause between. + for _ in 0..<3 { + buttons.element(boundBy: buttons.count - 1).tap() + Thread.sleep(forTimeInterval: 1.4) + buttons.element(boundBy: 0).tap() + Thread.sleep(forTimeInterval: 1.4) + } + } +} diff --git a/scripts/fidelity-app/ios-native-ref/tap-driver/project.yml b/scripts/fidelity-app/ios-native-ref/tap-driver/project.yml new file mode 100644 index 00000000000..d987730bc74 --- /dev/null +++ b/scripts/fidelity-app/ios-native-ref/tap-driver/project.yml @@ -0,0 +1,31 @@ +# XCUITest tap driver for the native animation recordings. +# +# UIKit plays the FULL iOS 26 Liquid Glass tab-selection morph (the frosted +# refracting drop) only for genuine touch-driven selection; programmatic +# selectedIndex changes play a flat simplified platter slide. The recording +# therefore drives REAL taps on the NativeRef app's UITabBarController via +# this tiny UI-test bundle (record-ios-native-anim.sh generates the Xcode +# project with xcodegen -- same dependency as the input-validation driver). +name: NativeRefTapDriver +options: + bundleIdPrefix: com.codenameone.fidelity.tapdriver +targets: + HostStub: + type: application + platform: iOS + deploymentTarget: "15.0" + sources: [HostStub] + settings: + PRODUCT_BUNDLE_IDENTIFIER: com.codenameone.fidelity.tapdriver.hoststub + GENERATE_INFOPLIST_FILE: YES + NativeRefTapDriverUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "15.0" + sources: [Tests] + dependencies: + - target: HostStub + settings: + PRODUCT_BUNDLE_IDENTIFIER: com.codenameone.fidelity.tapdriver.uitests + GENERATE_INFOPLIST_FILE: YES + TEST_TARGET_NAME: HostStub diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 9482f98386a..f43afa00217 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -151,6 +151,7 @@ private static int testTimeoutMs(BaseTest testClass) { new MorphTransitionScrubScreenshotTest(), new MorphElementMorphScreenshotTest(), new TabsAnimatedIndicatorScreenshotTest(), + new TabsLiquidGlassAnimationScreenshotTest(), new PullToRefreshSpinnerScreenshotTest(), new AnimateLayoutScreenshotTest(), new AnimateHierarchyScreenshotTest(), diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsLiquidGlassAnimationScreenshotTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsLiquidGlassAnimationScreenshotTest.java new file mode 100644 index 00000000000..28414fce920 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsLiquidGlassAnimationScreenshotTest.java @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.io.Util; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.FontImage; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.Tabs; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; + +import java.io.IOException; +import java.io.InputStream; + +/// Mac JavaSE frame probe for the iOS 26 UITabBar Liquid Glass selection morph. +/// +/// The native reference video is 1178x2556. Its 60mm x 16mm tab tile is the +/// centred 1088x290 crop at (45,1133). This test renders that exact tile size and +/// emits every distinct frame from the first complete native last-to-first +/// transition, including the long ease-out/settle tail, rather than scaling six +/// samples into a contact sheet. +/// +/// Native source frames (seconds): +/// 1.163333 through 1.760000. Relative times are normalized to the recorded +/// 350ms travel; the remaining frames deliberately hold the settled state so a +/// lingering optical/shape effect cannot disappear from the comparison. Each +/// emitted PNG remains full resolution so magnification, refraction, rim light, +/// glyph lift, and antialiasing can be compared one frame at a time. +public class TabsLiquidGlassAnimationScreenshotTest extends BaseTest { + private static final int TILE_WIDTH = 1088; + private static final int TILE_HEIGHT = 290; + private static final int[] FRAME_MS = { + 0, 17, 35, 53, 70, 88, 103, 118, + 137, 155, 170, 188, 202, 220, 237, 253, + 268, 287, 302, 320, 335, 353, 370, 387, + 402, 457, 468, 487, 522, 538, 553, 597 + }; + private static final int[] FRAME_PROGRESS = { + 0, 5, 10, 15, 20, 25, 29, 34, + 39, 44, 49, 54, 58, 63, 68, 72, + 77, 82, 86, 91, 96, 100, 100, 100, + 100, 100, 100, 100, 100, 100, 100, 100 + }; + + private Form layoutHost; + private Container tile; + private Tabs tabs; + + @Override + public boolean runTest() { + if (!"SE".equals(Display.getInstance().getProperty("OS", ""))) { + System.out.println("CN1SS:INFO:test=TabsLiquidGlassAnimation" + + " status=SKIPPED reason=mac-javase-reference-only platform=" + + Display.getInstance().getPlatformName()); + done(); + return true; + } + if (!installIosModernTheme()) { + fail("Unable to load /iOSModernTheme.res for the Liquid Glass frame probe"); + return false; + } + System.out.println("CN1SS:INFO:test=TabsLiquidGlassAnimation" + + " display=" + Display.getInstance().getDisplayWidth() + "x" + + Display.getInstance().getDisplayHeight() + + " pxPerMm=" + Display.getInstance().convertToPixels(1f) + + " platform=" + Display.getInstance().getPlatformName()); + markCaptureStarted(); + renderAppearance(false, "light", 0); + return true; + } + + private void renderAppearance(boolean dark, String appearance, int frameIndex) { + if (frameIndex == 0) { + Display.getInstance().setDarkMode(Boolean.valueOf(dark)); + UIManager.getInstance().refreshTheme(); + buildTile(dark); + } + if (frameIndex >= FRAME_PROGRESS.length) { + if (!dark) { + renderAppearance(true, "dark", 0); + } else { + restoreAppTheme(); + done(); + } + return; + } + + int progress = FRAME_PROGRESS[frameIndex]; + tabs.setMorphTestState(2, 0, progress); + Image frame = Image.createImage(TILE_WIDTH, TILE_HEIGHT, 0xff808080); + Graphics g = frame.getGraphics(); + tile.paintComponent(g, true); + String name = "TabsLiquidGlassAnimation_" + appearance + + "_ms" + pad3(FRAME_MS[frameIndex]) + + "_p" + pad3(progress); + final int next = frameIndex + 1; + Cn1ssDeviceRunnerHelper.emitImage(frame, name, + () -> renderAppearance(dark, appearance, next)); + } + + private void buildTile(boolean dark) { + // No title bar: the native reference crop begins at UITabBar y=0. + // A titled offscreen Form shifts its content pane down by the title's + // 104px preferred height even though only the tile itself is painted. + layoutHost = new Form(new BorderLayout()); + layoutHost.setWidth(TILE_WIDTH); + layoutHost.setHeight(TILE_HEIGHT); + layoutHost.setVisible(true); + + // Match the native UITabBar's component frame, not the JavaSE Tabs + // preferred size. The visible floating pill is inset by the iOS theme, + // but UIKit gives the tab bar the full 60mm x 16mm capture tile. Letting + // FlowLayout shrink Tabs to JavaSE's preferred size produced a 260px toy + // pill and made every lens comparison meaningless. + tile = new Container(new BorderLayout()); + Style tileStyle = tile.getAllStyles(); + tileStyle.setBgColor(0x808080); + tileStyle.setBgTransparency(255); + tileStyle.setPadding(0, 0, 0, 0); + tileStyle.setMargin(0, 0, 0, 0); + + tabs = new Tabs(Component.TOP); + tabs.setTabTextPosition(Component.BOTTOM); + Style iconStyle = new Style(); + iconStyle.setFgColor(dark ? 0xebebf5 : 0x2c2c2e); + iconStyle.setBgTransparency(0); + Image star = FontImage.createSFOrMaterial(FontImage.MATERIAL_STAR, iconStyle, 4.1f); + Image search = FontImage.createSFOrMaterial(FontImage.MATERIAL_SEARCH, iconStyle, 4.1f); + Image more = FontImage.createSFOrMaterial(FontImage.MATERIAL_MORE_HORIZ, iconStyle, 4.1f); + tabs.addTab("Featured", star, star, new Container()); + tabs.addTab("Search", search, search, new Container()); + tabs.addTab("More", more, more, new Container()); + tabs.setSelectedIndex(0); + + // UIKit's bar frame starts at the top edge of this crop. NORTH keeps + // the production Tabs preferred height while preserving the full tile + // width, which lands the themed pill at the native y=0 position. + tile.add(BorderLayout.NORTH, tabs); + layoutHost.add(BorderLayout.CENTER, tile); + layoutHost.layoutContainer(); + tile.layoutContainer(); + tabs.layoutContainer(); + tabs.getTabsContainer().layoutContainer(); + System.out.println("CN1SS:INFO:test=TabsLiquidGlassAnimation geometry=" + + "tile=" + tile.getX() + "," + tile.getY() + "," + + tile.getWidth() + "x" + tile.getHeight() + + " tabs=" + tabs.getX() + "," + tabs.getY() + "," + + tabs.getWidth() + "x" + tabs.getHeight() + + " bar=" + tabs.getTabsContainer().getX() + "," + + tabs.getTabsContainer().getY() + "," + + tabs.getTabsContainer().getWidth() + "x" + + tabs.getTabsContainer().getHeight() + + " tab0=" + tabs.getTabsContainer().getComponentAt(0).getX() + "," + + tabs.getTabsContainer().getComponentAt(0).getY() + "," + + tabs.getTabsContainer().getComponentAt(0).getWidth() + "x" + + tabs.getTabsContainer().getComponentAt(0).getHeight()); + } + + private boolean installIosModernTheme() { + InputStream in = Display.getInstance().getResourceAsStream( + TabsLiquidGlassAnimationScreenshotTest.class, "/iOSModernTheme.res"); + if (in == null) { + in = TabsLiquidGlassAnimationScreenshotTest.class.getResourceAsStream("/iOSModernTheme.res"); + } + if (in == null) { + return false; + } + try { + Resources resources = Resources.open(in); + String[] names = resources.getThemeResourceNames(); + if (names == null || names.length == 0) { + return false; + } + UIManager.getInstance().setThemeProps(resources.getTheme(names[0])); + UIManager.getInstance().refreshTheme(); + return UIManager.getInstance().isThemeConstant("glassMaterialBool", false) + && UIManager.getInstance().isThemeConstant("tabsSelectionCapsuleBool", false); + } catch (IOException ex) { + System.out.println("CN1SS:ERR:test=TabsLiquidGlassAnimation theme=" + ex); + return false; + } finally { + Util.cleanup(in); + } + } + + private void restoreAppTheme() { + tabs.setMorphTestState(2, 0, -1); + layoutHost = null; + tile = null; + tabs = null; + Display.getInstance().setDarkMode(null); + UIManager.initFirstTheme("/theme"); + UIManager.getInstance().refreshTheme(); + } + + private static String pad3(int value) { + if (value < 10) { + return "00" + value; + } + if (value < 100) { + return "0" + value; + } + return String.valueOf(value); + } +} diff --git a/scripts/ios/screenshots-watch/ButtonTheme_dark.png b/scripts/ios/screenshots-watch/ButtonTheme_dark.png index 4a6cecac6bc..b9ef60ede17 100644 Binary files a/scripts/ios/screenshots-watch/ButtonTheme_dark.png and b/scripts/ios/screenshots-watch/ButtonTheme_dark.png differ diff --git a/scripts/ios/screenshots-watch/ButtonTheme_light.png b/scripts/ios/screenshots-watch/ButtonTheme_light.png index 1ff4d997514..146c1b4055d 100644 Binary files a/scripts/ios/screenshots-watch/ButtonTheme_light.png and b/scripts/ios/screenshots-watch/ButtonTheme_light.png differ diff --git a/scripts/ios/screenshots-watch/ChatInput_dark.png b/scripts/ios/screenshots-watch/ChatInput_dark.png index 942de8cd3d0..3d640ac84da 100644 Binary files a/scripts/ios/screenshots-watch/ChatInput_dark.png and b/scripts/ios/screenshots-watch/ChatInput_dark.png differ diff --git a/scripts/ios/screenshots-watch/ChatInput_light.png b/scripts/ios/screenshots-watch/ChatInput_light.png index 577977b6a4a..760996f679f 100644 Binary files a/scripts/ios/screenshots-watch/ChatInput_light.png and b/scripts/ios/screenshots-watch/ChatInput_light.png differ diff --git a/scripts/ios/screenshots-watch/PaletteOverrideTheme_dark.png b/scripts/ios/screenshots-watch/PaletteOverrideTheme_dark.png index bed00c1b428..b774ea80b94 100644 Binary files a/scripts/ios/screenshots-watch/PaletteOverrideTheme_dark.png and b/scripts/ios/screenshots-watch/PaletteOverrideTheme_dark.png differ diff --git a/scripts/ios/screenshots-watch/PaletteOverrideTheme_light.png b/scripts/ios/screenshots-watch/PaletteOverrideTheme_light.png index 1ea9f4fb526..c3093fba905 100644 Binary files a/scripts/ios/screenshots-watch/PaletteOverrideTheme_light.png and b/scripts/ios/screenshots-watch/PaletteOverrideTheme_light.png differ diff --git a/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png b/scripts/ios/screenshots-watch/ShowcaseTheme_dark.png index 7cbde7bc0a1..6cdbf91df01 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 c88a6d971d5..84052bd794f 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/ButtonTheme_dark.png b/scripts/ios/screenshots/ButtonTheme_dark.png index a140fd991c6..848f0051aa6 100644 Binary files a/scripts/ios/screenshots/ButtonTheme_dark.png and b/scripts/ios/screenshots/ButtonTheme_dark.png differ diff --git a/scripts/ios/screenshots/ButtonTheme_light.png b/scripts/ios/screenshots/ButtonTheme_light.png index 1995fe403e6..2854439e25d 100644 Binary files a/scripts/ios/screenshots/ButtonTheme_light.png and b/scripts/ios/screenshots/ButtonTheme_light.png differ diff --git a/scripts/ios/screenshots/PaletteOverrideTheme_dark.png b/scripts/ios/screenshots/PaletteOverrideTheme_dark.png index 86e7e84a5a6..10fe70e92cd 100644 Binary files a/scripts/ios/screenshots/PaletteOverrideTheme_dark.png and b/scripts/ios/screenshots/PaletteOverrideTheme_dark.png differ diff --git a/scripts/ios/screenshots/PaletteOverrideTheme_light.png b/scripts/ios/screenshots/PaletteOverrideTheme_light.png index 8529a165829..8aa97ff612a 100644 Binary files a/scripts/ios/screenshots/PaletteOverrideTheme_light.png and b/scripts/ios/screenshots/PaletteOverrideTheme_light.png differ diff --git a/scripts/javascript/screenshots/ButtonTheme_ios_dark.png b/scripts/javascript/screenshots/ButtonTheme_ios_dark.png index 407d4d9a499..7c5b3aea25f 100644 Binary files a/scripts/javascript/screenshots/ButtonTheme_ios_dark.png and b/scripts/javascript/screenshots/ButtonTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ButtonTheme_ios_light.png b/scripts/javascript/screenshots/ButtonTheme_ios_light.png index bad43fb6532..d3eb686b5fb 100644 Binary files a/scripts/javascript/screenshots/ButtonTheme_ios_light.png and b/scripts/javascript/screenshots/ButtonTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png index 30b2f5f7f4f..8d88b78e753 100644 Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png index 4f45fbb6cd5..d41be536653 100644 Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/PickerTheme_ios_dark.png b/scripts/javascript/screenshots/PickerTheme_ios_dark.png index f40c1295ae5..d23ceba9e06 100644 Binary files a/scripts/javascript/screenshots/PickerTheme_ios_dark.png and b/scripts/javascript/screenshots/PickerTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/PickerTheme_ios_light.png b/scripts/javascript/screenshots/PickerTheme_ios_light.png index d9d0a514374..0ecb46070bc 100644 Binary files a/scripts/javascript/screenshots/PickerTheme_ios_light.png and b/scripts/javascript/screenshots/PickerTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png b/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png index b3112746308..54aa582b2ab 100644 Binary files a/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png and b/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png b/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png index f05eecc1f89..7c56e9122e6 100644 Binary files a/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png and b/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/TabsTheme_ios_dark.png b/scripts/javascript/screenshots/TabsTheme_ios_dark.png index 43a1f72151f..20c774f8b3c 100644 Binary files a/scripts/javascript/screenshots/TabsTheme_ios_dark.png and b/scripts/javascript/screenshots/TabsTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/TabsTheme_ios_light.png b/scripts/javascript/screenshots/TabsTheme_ios_light.png index 4bbf3070320..e149988a5ff 100644 Binary files a/scripts/javascript/screenshots/TabsTheme_ios_light.png and b/scripts/javascript/screenshots/TabsTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png b/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png index 6faac71b51d..ae04b36caef 100644 Binary files a/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png and b/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/TextFieldTheme_ios_light.png b/scripts/javascript/screenshots/TextFieldTheme_ios_light.png index 6649c2cc74f..f1f8b1daed2 100644 Binary files a/scripts/javascript/screenshots/TextFieldTheme_ios_light.png and b/scripts/javascript/screenshots/TextFieldTheme_ios_light.png differ diff --git a/scripts/record-ios-native-anim.sh b/scripts/record-ios-native-anim.sh index e17e055060e..84e66a47b9f 100755 --- a/scripts/record-ios-native-anim.sh +++ b/scripts/record-ios-native-anim.sh @@ -30,21 +30,58 @@ NATIVEREF_BUILD_ONLY=1 "$ROOT/scripts/build-ios-native-ref.sh" "$UDID" mkdir -p "$OUT_DIR" xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true -log "Launching $ANIM/$APPEARANCE animation" -SIMCTL_CHILD_NATIVEREF_MODE=animate \ -SIMCTL_CHILD_NATIVEREF_ANIM="$ANIM" \ -SIMCTL_CHILD_NATIVEREF_APPEARANCE="$APPEARANCE" \ -xcrun simctl launch "$UDID" "$BUNDLE_ID" >/dev/null -sleep 2 -log "Recording ${SECONDS_TO_RECORD}s -> $OUT" -rm -f "$OUT" -xcrun simctl io "$UDID" recordVideo --codec h264 --force "$OUT" & -REC_PID=$! -sleep "$SECONDS_TO_RECORD" -kill -INT "$REC_PID" 2>/dev/null || true -wait "$REC_PID" 2>/dev/null || true -xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true +if [ "$ANIM" = "tabs" ]; then + # UIKit plays the FULL Liquid Glass tab-selection morph (the frosted + # refracting drop) only for genuine touch-driven selection; programmatic + # selectedIndex changes play a flat simplified platter slide. Drive the + # NativeRef tab bar with REAL taps via the committed XCUITest tap driver + # (needs xcodegen, like the input-validation iOS driver). + if ! command -v xcodegen >/dev/null 2>&1; then + log "xcodegen not on PATH (brew install xcodegen) -- required for tap-driven tab recording" >&2 + exit 3 + fi + DRIVER_SRC="$ROOT/scripts/fidelity-app/ios-native-ref/tap-driver" + DRIVER_TMP="$(mktemp -d)/tap-driver" + cp -R "$DRIVER_SRC" "$DRIVER_TMP" + (cd "$DRIVER_TMP" && xcodegen generate >/dev/null) + log "Building tap driver (xcodebuild build-for-testing)" + xcodebuild build-for-testing \ + -project "$DRIVER_TMP/NativeRefTapDriver.xcodeproj" \ + -scheme NativeRefTapDriverUITests \ + -destination "id=$UDID" >/dev/null 2>&1 + log "Recording tap-driven $ANIM/$APPEARANCE morph -> $OUT" + rm -f "$OUT" + xcrun simctl io "$UDID" recordVideo --codec h264 --force "$OUT" & + REC_PID=$! + TEST_RUNNER_TAPDRIVER_APPEARANCE="$APPEARANCE" \ + xcodebuild test-without-building \ + -project "$DRIVER_TMP/NativeRefTapDriver.xcodeproj" \ + -scheme NativeRefTapDriverUITests \ + -destination "id=$UDID" >/dev/null 2>&1 || { + kill -INT "$REC_PID" 2>/dev/null || true + log "FATAL: tap-driver test run failed"; exit 4 + } + kill -INT "$REC_PID" 2>/dev/null || true + wait "$REC_PID" 2>/dev/null || true + xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true +else + log "Launching $ANIM/$APPEARANCE animation" + SIMCTL_CHILD_NATIVEREF_MODE=animate \ + SIMCTL_CHILD_NATIVEREF_ANIM="$ANIM" \ + SIMCTL_CHILD_NATIVEREF_APPEARANCE="$APPEARANCE" \ + xcrun simctl launch "$UDID" "$BUNDLE_ID" >/dev/null + sleep 2 + + log "Recording ${SECONDS_TO_RECORD}s -> $OUT" + rm -f "$OUT" + xcrun simctl io "$UDID" recordVideo --codec h264 --force "$OUT" & + REC_PID=$! + sleep "$SECONDS_TO_RECORD" + kill -INT "$REC_PID" 2>/dev/null || true + wait "$REC_PID" 2>/dev/null || true + xcrun simctl terminate "$UDID" "$BUNDLE_ID" >/dev/null 2>&1 || true +fi if [ -s "$OUT" ]; then log "Wrote $(du -h "$OUT" | cut -f1) reference video: $OUT" diff --git a/scripts/verify-javascript-lens-parity.mjs b/scripts/verify-javascript-lens-parity.mjs new file mode 100644 index 00000000000..df90cc286d9 --- /dev/null +++ b/scripts/verify-javascript-lens-parity.mjs @@ -0,0 +1,190 @@ +import fs from 'node:fs'; +import vm from 'node:vm'; + +// Pixel-parity guard for the complete iOS-26 glass-tab effect. This evaluates +// the implementation that browser_bridge.js actually ships (not a copied test +// implementation) against checksums produced by JavaSEPort.applyLensBuffer() +// and IOSImplementation's material/optics routines. A one-channel/one-pixel +// drift changes the checksum. +// +// The glass material/optics pins below are shared by THREE CPU +// implementations: IOSImplementation.{glassMaterialInPlace,applyGlassOptics}, +// JavaSEPort.{glassMaterialInPlace,applyGlassOptics} (the simulator's +// backdrop-filter glass) and the browser bridge functions checked here. When +// one of them changes, regenerate with a reflection probe over the SAME +// glassPattern inputs (see the GlassParityDump/LensParityDump probes in the +// PR description) and update all backends together. +const bridgePath = new URL('../vm/ByteCodeTranslator/src/javascript/browser_bridge.js', import.meta.url); +const bridge = fs.readFileSync(bridgePath, 'utf8'); +const javaSource = fs.readFileSync(new URL( + '../Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java', import.meta.url), 'utf8'); +const metalReference = fs.readFileSync(new URL( + '../Ports/iOSPort/nativeSources/METALView.m', import.meta.url), 'utf8'); +const metalShader = fs.readFileSync(new URL( + '../Ports/iOSPort/nativeSources/CN1MetalShaders.metal', import.meta.url), 'utf8'); +const start = bridge.indexOf(' var LENS_MAG_FLAT ='); +const end = bridge.indexOf(' // Replay one command stream', start); +if (start < 0 || end < 0) { + throw new Error('Unable to locate the lens implementation in browser_bridge.js'); +} +const sandbox = { Math, Uint8ClampedArray }; +vm.runInNewContext(bridge.substring(start, end) + + '\nthis.applyLens = applyLensSelfRegion;' + + '\nthis.applyMaterial = glassMaterialInPlace;' + + '\nthis.applyOptics = applyGlassOptics;', sandbox); + +// The same foreground lens is implemented four times because each backend has +// a different pixel API. Fail before the CRC probe if a tuning constant drifts. +const lensConstants = [ + 'LENS_MAG_FLAT', 'LENS_TINT_HI', 'LENS_TINT_LO', 'LENS_LIFT_COEF', + 'LENS_GLARE', 'LENS_RIM', 'LENS_RIM_W', 'LENS_REFRACT', + 'LENS_EDGE_SHADOW', 'LENS_RIM_SCALE', 'LENS_GLASS_TINT_STR', + 'LENS_SAT_BOOST' +]; + +function constant(source, pattern, name, backend) { + const match = source.match(new RegExp(pattern.replace('%s', name))); + if (!match) { + throw new Error(`Missing ${name} in ${backend} lens implementation`); + } + return Number(match[1]); +} + +for (const name of lensConstants) { + const values = { + javascript: constant(bridge, `var\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, name, 'JavaScript'), + javase: constant(javaSource, + `private\\s+static\\s+final\\s+double\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, name, 'JavaSE'), + metalReference: constant(metalReference, + `#define\\s+%s\\s+([0-9.]+)f`, name, 'Metal CPU reference'), + metalShader: constant(metalShader, + `constant\\s+float\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, name, 'Metal shader') + }; + if (new Set(Object.values(values)).size !== 1) { + throw new Error(`Lens constant drift for ${name}: ${JSON.stringify(values)}`); + } +} + +// The liquid-layer gate (glassAmt = smoothstep(1.085, 1.25, magnify)) is a +// named pair only in the JavaScript port; the other backends inline the +// literals. Pin all four so the gate cannot drift silently either. +const gateStart = constant(bridge, `var\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, 'LENS_GLASS_START', 'JavaScript'); +const gateFull = constant(bridge, `var\\s+%s\\s*=\\s*([0-9.]+)\\s*;`, 'LENS_GLASS_FULL', 'JavaScript'); +if (gateStart !== 1.085 || gateFull !== 1.25) { + throw new Error(`JavaScript liquid gate drift: ${gateStart}..${gateFull}, expected 1.085..1.25`); +} +for (const [backend, source, pattern] of [ + ['JavaSE', javaSource, /lensSmoothstep\(1\.085,\s*1\.25,\s*magnify\)/], + ['Metal CPU reference', metalReference, /glassSmoothstep\(1\.085f,\s*1\.25f,\s*magnify\)/], + ['Metal shader', metalShader, /cn1_lens_smoothstep\(1\.085,\s*1\.25,\s*magnify\)/] +]) { + if (!pattern.test(source)) { + throw new Error(`Liquid gate smoothstep(1.085, 1.25) not found in ${backend}`); + } +} +console.log(`lens constant parity PASS backends=4 constants=${lensConstants.length + 2}`); + +function crc32(bytes) { + let crc = 0xffffffff; + for (const value of bytes) { + crc ^= value; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function rgbaToArgb(rgba) { + const argb = new Uint8Array(rgba.length); + for (let i = 0; i < rgba.length / 4; i++) { + argb[i * 4] = rgba[i * 4 + 3]; + argb[i * 4 + 1] = rgba[i * 4]; + argb[i * 4 + 2] = rgba[i * 4 + 1]; + argb[i * 4 + 3] = rgba[i * 4 + 2]; + } + return argb; +} + +function glassPattern(width, height) { + const source = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const offset = (y * width + x) * 4; + source[offset] = (x * 17 + y * 3) & 0xff; + source[offset + 1] = (x * 5 + y * 19) & 0xff; + source[offset + 2] = (x * 11 + y * 7) & 0xff; + source[offset + 3] = (x * 7 + y * 13 + 31) & 0xff; + } + } + return source; +} + +// magnify/aberration sampled from the shipped "ios26" preset (rest 1.08x, +// peak 1.18x, peak aberration 0.02) at representative flight envelopes. +// Expected CRCs are produced by JavaSEPort.applyLensBuffer over the same +// synthetic source (see the LensParityDump probe in the PR description). +const frames = [ + { progress: 0, width: 100, height: 40, magnify: 1.08, aberration: 0, expected: 0xda50e7bd }, + { progress: 10, width: 100, height: 40, magnify: 1.1726, aberration: 0.01852, expected: 0xfb9082d1 }, + { progress: 25, width: 100, height: 40, magnify: 1.18, aberration: 0.02, expected: 0x766a3feb }, + { progress: 50, width: 100, height: 40, magnify: 1.18, aberration: 0.02, expected: 0x766a3feb }, + { progress: 75, width: 98, height: 40, magnify: 1.13, aberration: 0.01, expected: 0x4fe082c7 }, + { progress: 90, width: 99, height: 40, magnify: 1.08, aberration: 0, expected: 0xd0b8b264 }, + { progress: 100, width: 100, height: 40, magnify: 1.08, aberration: 0, expected: 0xda50e7bd } +]; + +for (const frame of frames) { + const { width, height } = frame; + const source = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const offset = (y * width + x) * 4; + source[offset] = (x * 17 + y * 3) & 0xff; + source[offset + 1] = (x * 5 + y * 19) & 0xff; + source[offset + 2] = (x * 11 + y * 7) & 0xff; + source[offset + 3] = 0xff; + } + } + let output; + const context = { + canvas: { width, height }, + getTransform() { return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; }, + getImageData() { return { data: new Uint8ClampedArray(source) }; }, + createImageData(w, h) { return { data: new Uint8ClampedArray(w * h * 4) }; }, + putImageData(image) { output = image.data; } + }; + sandbox.applyLens(context, 0, 0, width, height, -1, + frame.magnify, frame.aberration, 0x0a84ff, 1); + + // JavaSE's reference buffer is packed ARGB; Canvas ImageData is RGBA. + const actual = crc32(rgbaToArgb(output)); + if (actual !== frame.expected && process.env.CN1_UPDATE_LENS_PARITY !== '1') { + throw new Error(`Lens parity failed at ${frame.progress}%: ` + + `expected ${frame.expected.toString(16)}, got ${actual.toString(16)}`); + } + const status = process.env.CN1_UPDATE_LENS_PARITY === '1' ? 'UPDATE' : 'PASS'; + console.log(`lens parity ${status} t=${frame.progress}% size=${width}x${height} crc=${actual.toString(16)}`); +} + +for (const recipe of [ + { name: 'pill-light', saturation: 1.8, scale: 1, offset: 108, expected: 0x5d960bce }, + { name: 'pill-dark', saturation: 2.5, scale: 0.3, offset: 13, expected: 0x836a16cf } +]) { + const material = glassPattern(64, 40); + sandbox.applyMaterial(material, recipe.saturation, recipe.scale, recipe.offset); + const actual = crc32(rgbaToArgb(material)); + if (actual !== recipe.expected) { + throw new Error(`Glass material parity failed for ${recipe.name}: ` + + `expected ${recipe.expected.toString(16)}, got ${actual.toString(16)}`); + } + console.log(`glass material parity PASS recipe=${recipe.name} crc=${actual.toString(16)}`); +} + +const optics = sandbox.applyOptics(glassPattern(52, 32), 52, 32, 6, + 40, 20, -1, 0.4, 0.5); +const opticsCrc = crc32(rgbaToArgb(optics)); +if (opticsCrc !== 0x8057dd7a) { + throw new Error(`Glass optics parity failed: expected 8057dd7a, got ${opticsCrc.toString(16)}`); +} +console.log(`glass optics parity PASS crc=${opticsCrc.toString(16)}`); diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index e86883d1df8..769d8e79725 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + (function(global) { function shouldEnableDiag() { if (global.__parparDiagEnabled != null) { @@ -932,7 +955,7 @@ CREATE_PATTERN_SURFACE: 57, DRAW_IMAGE_XY: 60, DRAW_IMAGE_XYWH: 61, DRAW_IMAGE_SRCDST: 62, BLIT_SURFACE_XY: 70, BLIT_SURFACE_XYWH: 71, BLIT_SURFACE_SRCDST: 72, - BLUR_SELF_REGION: 80, LENS_SELF_REGION: 81 + BLUR_SELF_REGION: 80, LENS_SELF_REGION: 81, GLASS_SELF_REGION: 82 }; // The display surface id. Mirrors HTML5Implementation.DISPLAY_SURFACE_ID. var SURF_DISPLAY_ID = 1; @@ -979,6 +1002,436 @@ return s; } + // iOS-26 tab selection lens. Keep these values and the math in lock-step + // with JavaSEPort.applyLensBuffer(): the Simulator is the browser renderer's + // pixel reference for the glass-tab animation. + var LENS_MAG_FLAT = 0.75; + var LENS_TINT_HI = 150; + var LENS_TINT_LO = 55; + var LENS_LIFT_COEF = 0.40; + var LENS_GLARE = 0.09; + var LENS_RIM = 0.06; + var LENS_RIM_W = 0.06; + var LENS_REFRACT = 0.16; + var LENS_EDGE_SHADOW = 0.12; + var LENS_RIM_SCALE = 0.84; + var LENS_GLASS_TINT = 0xbcd8ff; + var LENS_GLASS_TINT_STR = 0.10; + var LENS_SAT_BOOST = 1.32; + var LENS_GLASS_START = 1.085; + var LENS_GLASS_FULL = 1.25; + + function lensSmoothstep(a, b, x) { + var t = (x - a) / (b - a); + t = t < 0 ? 0 : (t > 1 ? 1 : t); + return t * t * (3 - 2 * t); + } + + function lensBilinear(a, b, c, d, tx, ty) { + var top = a + (b - a) * tx; + var bottom = c + (d - c) * tx; + return (top + (bottom - top) * ty) | 0; + } + + function lensSample(data, width, height, fx, fy, channel) { + if (fx < 0) { fx = 0; } else if (fx > width - 1) { fx = width - 1; } + if (fy < 0) { fy = 0; } else if (fy > height - 1) { fy = height - 1; } + var x0 = fx | 0, y0 = fy | 0; + var x1 = Math.min(x0 + 1, width - 1), y1 = Math.min(y0 + 1, height - 1); + var tx = fx - x0, ty = fy - y0; + var row0 = y0 * width, row1 = y1 * width; + return lensBilinear( + data[(row0 + x0) * 4 + channel], data[(row0 + x1) * 4 + channel], + data[(row1 + x0) * 4 + channel], data[(row1 + x1) * 4 + channel], + tx, ty + ); + } + + function lensDeviceRect(ctx, x, y, width, height) { + var tr = ctx.getTransform ? ctx.getTransform() : null; + if (!tr) { + return { x: Math.round(x), y: Math.round(y), w: Math.round(width), h: Math.round(height), scale: 1 }; + } + // Tabs paint under translation/uniform scaling only. A pixel read ignores + // Canvas transforms, so resolve that axis-aligned transform explicitly. + // A rotated/sheared lens is outside the v1 contract and safely no-ops. + if (Math.abs(tr.b) > 1e-9 || Math.abs(tr.c) > 1e-9) { + return null; + } + var x0 = tr.a * x + tr.e, x1 = tr.a * (x + width) + tr.e; + var y0 = tr.d * y + tr.f, y1 = tr.d * (y + height) + tr.f; + return { + x: Math.round(Math.min(x0, x1)), + y: Math.round(Math.min(y0, y1)), + w: Math.round(Math.abs(x1 - x0)), + h: Math.round(Math.abs(y1 - y0)), + scale: Math.min(Math.abs(tr.a), Math.abs(tr.d)) + }; + } + + function applyLensSelfRegion(ctx, x, y, width, height, cornerRadius, + magnify, aberration, tintColor, tintStrength) { + if (!ctx.canvas || width <= 0 || height <= 0) { + return; + } + var rect = lensDeviceRect(ctx, x, y, width, height); + if (!rect || rect.w <= 0 || rect.h <= 0) { + return; + } + var rx = rect.x, ry = rect.y, rw = rect.w, rh = rect.h; + var canvasWidth = ctx.canvas.width | 0, canvasHeight = ctx.canvas.height | 0; + if (rx < 0) { rw += rx; rx = 0; } + if (ry < 0) { rh += ry; ry = 0; } + if (rx + rw > canvasWidth) { rw = canvasWidth - rx; } + if (ry + rh > canvasHeight) { rh = canvasHeight - ry; } + if (rw <= 0 || rh <= 0) { + return; + } + + var source = ctx.getImageData(rx, ry, rw, rh); + var src = source.data; + var result = ctx.createImageData(rw, rh); + var out = result.data; + var hw = rw / 2.0, hh = rh / 2.0; + var scaledCorner = cornerRadius * rect.scale; + var radius = scaledCorner < 0 ? Math.min(hw, hh) + : Math.min(scaledCorner, Math.min(hw, hh)); + if (radius < 0) { radius = 0; } + var tr = (tintColor >> 16) & 0xff; + var tg = (tintColor >> 8) & 0xff; + var tb = tintColor & 0xff; + var liftMax = LENS_LIFT_COEF * (magnify - 1.0) * hh; + // The 3D-glass cues (edge refraction / edge shadow / glare) belong to the + // MORPH droplet, not the settled pill (rest 1.08x, peak 1.18x): they fade + // in above rest so a resting selection stays a flat subtle pill. + var glassAmount = lensSmoothstep(LENS_GLASS_START, LENS_GLASS_FULL, magnify); + + for (var yy = 0; yy < rh; yy++) { + var py = yy + 0.5 - hh; + for (var xx = 0; xx < rw; xx++) { + var px = xx + 0.5 - hw; + var index = (yy * rw + xx) * 4; + var dxe = Math.abs(px) - (hw - radius); + var dye = Math.abs(py) - (hh - radius); + var axx = Math.max(dxe, 0), ayy = Math.max(dye, 0); + var outside = Math.sqrt(axx * axx + ayy * ayy); + var inside = Math.min(Math.max(dxe, dye), 0); + var depth = -(outside + inside - radius); + if (depth <= 0) { + out[index] = src[index]; + out[index + 1] = src[index + 1]; + out[index + 2] = src[index + 2]; + out[index + 3] = src[index + 3]; + continue; + } + + var alpha = Math.min(depth, 1.0); + var rd = Math.min(1.0, Math.sqrt((px * px) / (hw * hw) + (py * py) / (hh * hh))); + var edge = lensSmoothstep(LENS_MAG_FLAT, 1.0, rd); + var rimScale = 1.0 + (LENS_RIM_SCALE - 1.0) * glassAmount; + var mag = magnify + (rimScale - magnify) * edge; + if (mag < 0.2) { mag = 0.2; } + var abr = aberration * edge; + var magR = mag * (1 - abr), magB = mag * (1 + abr); + if (magR < 0.05) { magR = 0.05; } + if (magB < 0.05) { magB = 0.05; } + var lift = liftMax * (1 - rd * rd); + var refract = 1.0 + LENS_REFRACT * glassAmount * lensSmoothstep(0.70, 1.0, rd); + var sampleYR = hh + (py / magR) * refract + lift; + var sampleYG = hh + (py / mag) * refract + lift; + var sampleYB = hh + (py / magB) * refract + lift; + var sr = lensSample(src, rw, rh, hw + (px / magR) * refract, sampleYR, 0); + var sg = lensSample(src, rw, rh, hw + (px / mag) * refract, sampleYG, 1); + var sb = lensSample(src, rw, rh, hw + (px / magB) * refract, sampleYB, 2); + var lum = 0.2126 * sr + 0.7152 * sg + 0.0722 * sb; + var tint = tintStrength * lensSmoothstep(LENS_TINT_HI, LENS_TINT_LO, lum); + var fr = sr + (tr - sr) * tint; + var fg = sg + (tg - sg) * tint; + var fb = sb + (tb - sb) * tint; + var glassTint = LENS_GLASS_TINT_STR * glassAmount; + fr += (((LENS_GLASS_TINT >> 16) & 0xff) - fr) * glassTint; + fg += (((LENS_GLASS_TINT >> 8) & 0xff) - fg) * glassTint; + fb += ((LENS_GLASS_TINT & 0xff) - fb) * glassTint; + var saturationLum = 0.2126 * fr + 0.7152 * fg + 0.0722 * fb; + fr = saturationLum + (fr - saturationLum) * LENS_SAT_BOOST; + fg = saturationLum + (fg - saturationLum) * LENS_SAT_BOOST; + fb = saturationLum + (fb - saturationLum) * LENS_SAT_BOOST; + var gx = px / hw, gy = (py + 0.42 * hh) / hh; + var glare = LENS_GLARE * glassAmount * Math.exp(-(gx * gx * 1.15 + gy * gy * 2.6) * 2.1); + var rimWidth = Math.max(2.0, LENS_RIM_W * hh); + var rim = depth < rimWidth ? (1.0 - depth / rimWidth) * LENS_RIM : 0; + var bright = glare + rim; + if (bright > 0) { + fr += bright * (255 - fr); + fg += bright * (255 - fg); + fb += bright * (255 - fb); + } + var edgeShadowWidth = Math.max(2.0, 0.13 * Math.min(hw, hh)); + if (depth < edgeShadowWidth) { + var edgeShadow = (1.0 - depth / edgeShadowWidth) * LENS_EDGE_SHADOW * glassAmount; + fr *= 1 - edgeShadow; + fg *= 1 - edgeShadow; + fb *= 1 - edgeShadow; + } + fr = fr < 0 ? 0 : (fr > 255 ? 255 : fr | 0); + fg = fg < 0 ? 0 : (fg > 255 ? 255 : fg | 0); + fb = fb < 0 ? 0 : (fb > 255 ? 255 : fb | 0); + out[index] = (src[index] + (fr - src[index]) * alpha) | 0; + out[index + 1] = (src[index + 1] + (fg - src[index + 1]) * alpha) | 0; + out[index + 2] = (src[index + 2] + (fb - src[index + 2]) * alpha) | 0; + out[index + 3] = src[index + 3]; + } + } + ctx.putImageData(result, rx, ry); + } + + function glassFloatMul(a, b) { + return Math.fround(Math.fround(a) * Math.fround(b)); + } + + function glassFloatAdd(a, b) { + return Math.fround(Math.fround(a) + Math.fround(b)); + } + + // Mirrors IOSImplementation.glassMaterialInPlace(). Math.fround preserves + // the native float evaluation points so the material does not drift by a + // channel value merely because JavaScript normally evaluates as double. + function glassMaterialInPlace(data, saturation, scale, offset) { + var sat = Math.fround(saturation), scl = Math.fround(scale), off = Math.fround(offset); + var lr = Math.fround(0.2126), lg = Math.fround(0.7152), lb = Math.fround(0.0722); + for (var i = 0; i < data.length; i += 4) { + var r = Math.fround(data[i]), g = Math.fround(data[i + 1]), b = Math.fround(data[i + 2]); + var lum = glassFloatAdd(glassFloatAdd(glassFloatMul(lr, r), glassFloatMul(lg, g)), + glassFloatMul(lb, b)); + r = glassFloatAdd(glassFloatMul(glassFloatAdd(lum, + glassFloatMul(glassFloatAdd(r, -lum), sat)), scl), off); + g = glassFloatAdd(glassFloatMul(glassFloatAdd(lum, + glassFloatMul(glassFloatAdd(g, -lum), sat)), scl), off); + b = glassFloatAdd(glassFloatMul(glassFloatAdd(lum, + glassFloatMul(glassFloatAdd(b, -lum), sat)), scl), off); + data[i] = r < 0 ? 0 : (r > 255 ? 255 : r | 0); + data[i + 1] = g < 0 ? 0 : (g > 255 ? 255 : g | 0); + data[i + 2] = b < 0 ? 0 : (b > 255 ? 255 : b | 0); + } + } + + function glassBilinear(a, b, c, d, tx, ty) { + var ftx = Math.fround(tx), fty = Math.fround(ty); + var top = glassFloatAdd(a, glassFloatMul(glassFloatAdd(b, -a), ftx)); + var bottom = glassFloatAdd(c, glassFloatMul(glassFloatAdd(d, -c), ftx)); + return glassFloatAdd(glassFloatAdd(top, + glassFloatMul(glassFloatAdd(bottom, -top), fty)), 0.5) | 0; + } + + function glassSample(data, width, height, fx, fy, channel) { + var x = Math.fround(fx), y = Math.fround(fy); + if (x < 0) { x = 0; } else if (x > width - 1) { x = width - 1; } + if (y < 0) { y = 0; } else if (y > height - 1) { y = height - 1; } + var x0 = x | 0, y0 = y | 0; + var x1 = x0 + 1 < width ? x0 + 1 : x0; + var y1 = y0 + 1 < height ? y0 + 1 : y0; + var tx = Math.fround(x - x0), ty = Math.fround(y - y0); + var row0 = y0 * width, row1 = y1 * width; + return glassBilinear( + data[(row0 + x0) * 4 + channel], data[(row0 + x1) * 4 + channel], + data[(row1 + x0) * 4 + channel], data[(row1 + x1) * 4 + channel], + tx, ty + ); + } + + // Mirrors IOSImplementation.applyGlassOptics(). The returned patch retains + // the native shape alpha and is composited with drawImage below, just like + // IOSImplementation draws its generated ARGB image back onto the target. + function applyGlassOptics(blurred, bufferWidth, bufferHeight, pad, + width, height, cornerRadius, refraction, specular) { + var result = new Uint8ClampedArray(width * height * 4); + var hw = Math.fround(width / 2), hh = Math.fround(height / 2); + var radius = cornerRadius < 0 ? Math.min(hw, hh) + : Math.min(cornerRadius, Math.min(hw, hh)); + if (radius < 0) { radius = 0; } + var band = glassFloatMul(Math.min(hw, hh), Math.fround(0.6)); + var rimWidth = Math.fround(3.0); + var refract = Math.fround(refraction), spec = Math.fround(specular); + for (var yy = 0; yy < height; yy++) { + var py = Math.fround(yy + 0.5); + for (var xx = 0; xx < width; xx++) { + var px = Math.fround(xx + 0.5); + var dx = Math.fround(Math.abs(Math.fround(px - hw)) - Math.fround(hw - radius)); + var dy = Math.fround(Math.abs(Math.fround(py - hh)) - Math.fround(hh - radius)); + var ax = dx > 0 ? dx : 0, ay = dy > 0 ? dy : 0; + var outside = Math.fround(Math.sqrt(glassFloatAdd(glassFloatMul(ax, ax), glassFloatMul(ay, ay)))); + var inside = Math.min(Math.max(dx, dy), 0); + var sdf = glassFloatAdd(glassFloatAdd(outside, inside), -radius); + var depth = Math.fround(-sdf); + if (depth <= 0) { + continue; + } + var coverage = depth >= 1 ? 1 : depth; + var sx = Math.fround(xx), sy = Math.fround(yy); + if (refract > 0 && band > 0 && depth < band) { + var edgeT = Math.fround(1 - Math.fround(depth / band)); + var root = Math.fround(Math.sqrt(Math.max(0, + glassFloatAdd(1, -glassFloatMul(edgeT, edgeT))))); + var distortion = glassFloatAdd(1, -root); + sx = Math.fround(xx - glassFloatMul(glassFloatMul(Math.fround(px - hw), distortion), refract)); + sy = Math.fround(yy - glassFloatMul(glassFloatMul(Math.fround(py - hh), distortion), refract)); + } + var red = glassSample(blurred, bufferWidth, bufferHeight, + Math.fround(sx + pad), Math.fround(sy + pad), 0); + var green = glassSample(blurred, bufferWidth, bufferHeight, + Math.fround(sx + pad), Math.fround(sy + pad), 1); + var blue = glassSample(blurred, bufferWidth, bufferHeight, + Math.fround(sx + pad), Math.fround(sy + pad), 2); + if (spec > 0 && depth < rimWidth) { + var rim = Math.fround(1 - Math.fround(depth / rimWidth)); + var topBias = Math.fround(0.55 + glassFloatMul(0.45, + Math.fround(1 - Math.fround(py / height)))); + var add = glassFloatMul(glassFloatMul(glassFloatMul(spec, rim), topBias), 70) | 0; + red = Math.min(255, red + add); + green = Math.min(255, green + add); + blue = Math.min(255, blue + add); + } + var index = (yy * width + xx) * 4; + result[index] = red; + result[index + 1] = green; + result[index + 2] = blue; + result[index + 3] = glassFloatMul(coverage, 255) | 0; + } + } + return result; + } + + function createGlassScratchCanvas(width, height) { + if (typeof global.OffscreenCanvas === 'function') { + return new global.OffscreenCanvas(width, height); + } + var doc = global.document || (global.window && global.window.document); + if (!doc || !doc.createElement) { + return null; + } + var canvas = doc.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; + } + + function applyBlurSelfRegion(ctx, x, y, width, height, sigma, cornerRadius) { + if (width <= 0 || height <= 0 || !ctx.canvas) { + return; + } + ctx.save(); + try { + ctx.beginPath(); + if (cornerRadius) { + var round = cornerRadius < 0 ? Math.min(width, height) / 2 + : Math.min(cornerRadius, Math.min(width, height) / 2); + ctx.moveTo(x + round, y); + ctx.arcTo(x + width, y, x + width, y + height, round); + ctx.arcTo(x + width, y + height, x, y + height, round); + ctx.arcTo(x, y + height, x, y, round); + ctx.arcTo(x, y, x + width, y, round); + ctx.closePath(); + } else { + ctx.rect(x, y, width, height); + } + ctx.clip(); + ctx.filter = 'blur(' + sigma + 'px)'; + ctx.drawImage(ctx.canvas, 0, 0); + } finally { + ctx.restore(); + } + } + + function applyGlassSelfRegion(ctx, x, y, width, height, blurRadius, cornerRadius, + saturation, scale, offset, refraction, specular) { + if (!ctx.canvas || width <= 0 || height <= 0) { + return; + } + var rect = lensDeviceRect(ctx, x, y, width, height); + if (!rect || rect.w <= 0 || rect.h <= 0) { + return; + } + var rx = rect.x, ry = rect.y, rw = rect.w, rh = rect.h; + var canvasWidth = ctx.canvas.width | 0, canvasHeight = ctx.canvas.height | 0; + if (rx < 0) { rw += rx; rx = 0; } + if (ry < 0) { rh += ry; ry = 0; } + if (rx + rw > canvasWidth) { rw = canvasWidth - rx; } + if (ry + rh > canvasHeight) { rh = canvasHeight - ry; } + if (rw <= 0 || rh <= 0) { + return; + } + + // CSS blur() accepts Gaussian sigma while the CN1/iOS recipe carries the + // calibrated blur radius. Keep the same radius-to-sigma conversion used + // by BlurRegion; the material transform is the missing opacity, not a + // stronger blur. + var sigma = Math.max(1, blurRadius * rect.scale / 2); + var pad = Math.ceil(sigma) * 3 + 1; + var bufferWidth = rw + pad * 2, bufferHeight = rh + pad * 2; + var ax0 = Math.max(0, rx - pad), ay0 = Math.max(0, ry - pad); + var ax1 = Math.min(canvasWidth, rx + rw + pad), ay1 = Math.min(canvasHeight, ry + rh + pad); + var availableWidth = ax1 - ax0, availableHeight = ay1 - ay0; + if (availableWidth <= 0 || availableHeight <= 0) { + return; + } + var available = ctx.getImageData(ax0, ay0, availableWidth, availableHeight).data; + var padded = new Uint8ClampedArray(bufferWidth * bufferHeight * 4); + for (var by = 0; by < bufferHeight; by++) { + var sourceY = ry - pad + by - ay0; + if (sourceY < 0) { sourceY = 0; } + if (sourceY >= availableHeight) { sourceY = availableHeight - 1; } + for (var bx = 0; bx < bufferWidth; bx++) { + var sourceX = rx - pad + bx - ax0; + if (sourceX < 0) { sourceX = 0; } + if (sourceX >= availableWidth) { sourceX = availableWidth - 1; } + var sourceIndex = (sourceY * availableWidth + sourceX) * 4; + var targetIndex = (by * bufferWidth + bx) * 4; + padded[targetIndex] = available[sourceIndex]; + padded[targetIndex + 1] = available[sourceIndex + 1]; + padded[targetIndex + 2] = available[sourceIndex + 2]; + padded[targetIndex + 3] = available[sourceIndex + 3]; + } + } + glassMaterialInPlace(padded, saturation, scale, offset); + + var materialCanvas = createGlassScratchCanvas(bufferWidth, bufferHeight); + var blurredCanvas = createGlassScratchCanvas(bufferWidth, bufferHeight); + if (!materialCanvas || !blurredCanvas) { + applyBlurSelfRegion(ctx, x, y, width, height, sigma, cornerRadius); + return; + } + var materialContext = materialCanvas.getContext('2d'); + var blurredContext = blurredCanvas.getContext('2d'); + if (!materialContext || !blurredContext) { + applyBlurSelfRegion(ctx, x, y, width, height, sigma, cornerRadius); + return; + } + var materialImage = materialContext.createImageData(bufferWidth, bufferHeight); + materialImage.data.set(padded); + materialContext.putImageData(materialImage, 0, 0); + blurredContext.filter = 'blur(' + sigma + 'px)'; + blurredContext.drawImage(materialCanvas, 0, 0); + var blurred = blurredContext.getImageData(0, 0, bufferWidth, bufferHeight).data; + var scaledCorner = cornerRadius * rect.scale; + var output = applyGlassOptics(blurred, bufferWidth, bufferHeight, pad, + rw, rh, scaledCorner, refraction, specular); + var outputCanvas = createGlassScratchCanvas(rw, rh); + var outputContext = outputCanvas && outputCanvas.getContext('2d'); + if (!outputContext) { + return; + } + var result = outputContext.createImageData(rw, rh); + result.data.set(output); + outputContext.putImageData(result, 0, 0); + ctx.save(); + try { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.drawImage(outputCanvas, rx, ry); + } finally { + ctx.restore(); + } + } + // Replay one command stream (opcodes + nums + objs) onto ``ctx``. function replaySurfaceCommands(ctx, ops, opCount, nums, objs) { var ni = 0; // num cursor @@ -1097,69 +1550,38 @@ var _bsig = nums[ni++], _bcr = nums[ni++]; if (_bw > 0 && _bh > 0 && ctx.canvas) { try { - ctx.save(); - ctx.beginPath(); - if (_bcr) { - var _brr = _bcr < 0 ? Math.min(_bw, _bh) / 2 - : Math.min(_bcr, Math.min(_bw, _bh) / 2); - ctx.moveTo(_bx + _brr, _by); - ctx.arcTo(_bx + _bw, _by, _bx + _bw, _by + _bh, _brr); - ctx.arcTo(_bx + _bw, _by + _bh, _bx, _by + _bh, _brr); - ctx.arcTo(_bx, _by + _bh, _bx, _by, _brr); - ctx.arcTo(_bx, _by, _bx + _bw, _by, _brr); - ctx.closePath(); - } else { - ctx.rect(_bx, _by, _bw, _bh); - } - ctx.clip(); - ctx.filter = 'blur(' + _bsig + 'px)'; - ctx.drawImage(ctx.canvas, 0, 0); + applyBlurSelfRegion(ctx, _bx, _by, _bw, _bh, _bsig, _bcr); } catch (_ebr) { - } finally { - try { ctx.restore(); } catch (_ebr2) {} } } break; } case SURF.LENS_SELF_REGION: { - // iOS-26 selection DROP: magnify this surface's own pixels in the - // region (content bulge), then optionally wash them toward an accent - // tint. drawImage(self) snapshots the source bitmap per the HTML - // spec. cornerRadius: 0 = rect, -1 = capsule, >0 = rounded px. + // iOS-26 selection DROP: run the Simulator-equivalent per-pixel lens + // over this surface's own pixels. cornerRadius: 0 = rect, -1 = + // capsule, >0 = rounded px. var _lx = nums[ni++], _ly = nums[ni++], _lw = nums[ni++], _lh = nums[ni++]; var _lcr = nums[ni++], _lmag = nums[ni++]; - var _ltint = objs[oi++]; + var _lab = nums[ni++], _ltint = nums[ni++] | 0, _ltintStrength = nums[ni++]; if (_lw > 0 && _lh > 0 && ctx.canvas) { try { - ctx.save(); - ctx.beginPath(); - if (_lcr) { - var _lrr = _lcr < 0 ? Math.min(_lw, _lh) / 2 - : Math.min(_lcr, Math.min(_lw, _lh) / 2); - ctx.moveTo(_lx + _lrr, _ly); - ctx.arcTo(_lx + _lw, _ly, _lx + _lw, _ly + _lh, _lrr); - ctx.arcTo(_lx + _lw, _ly + _lh, _lx, _ly + _lh, _lrr); - ctx.arcTo(_lx, _ly + _lh, _lx, _ly, _lrr); - ctx.arcTo(_lx, _ly, _lx + _lw, _ly, _lrr); - ctx.closePath(); - } else { - ctx.rect(_lx, _ly, _lw, _lh); - } - ctx.clip(); - // Magnify: draw the region's own pixels scaled about its centre. - // Source rect = region shrunk by 1/mag around centre; dest = the - // full region -> the content bulges outward like the native lens. - var _lm = _lmag > 1 ? _lmag : 1; - var _lsw = _lw / _lm, _lsh = _lh / _lm; - var _lsx = _lx + (_lw - _lsw) / 2, _lsy = _ly + (_lh - _lsh) / 2; - ctx.drawImage(ctx.canvas, _lsx, _lsy, _lsw, _lsh, _lx, _ly, _lw, _lh); - if (_ltint) { - ctx.fillStyle = _ltint; - ctx.fillRect(_lx, _ly, _lw, _lh); - } + applyLensSelfRegion(ctx, _lx, _ly, _lw, _lh, _lcr, + _lmag, _lab, _ltint, _ltintStrength); } catch (_elr) { - } finally { - try { ctx.restore(); } catch (_elr2) {} + } + } + break; + } + case SURF.GLASS_SELF_REGION: { + var _gx = nums[ni++], _gy = nums[ni++], _gw = nums[ni++], _gh = nums[ni++]; + var _gblur = nums[ni++], _gcr = nums[ni++], _gsat = nums[ni++]; + var _gscale = nums[ni++], _goffset = nums[ni++], _grefract = nums[ni++]; + var _gspecular = nums[ni++]; + if (_gw > 0 && _gh > 0 && ctx.canvas) { + try { + applyGlassSelfRegion(ctx, _gx, _gy, _gw, _gh, _gblur, _gcr, + _gsat, _gscale, _goffset, _grefract, _gspecular); + } catch (_egr) { } } break; diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java index 462e7dbfbb8..bb3f1f2e3bb 100644 --- a/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java +++ b/vm/tests/src/test/java/com/codename1/tools/translator/JavascriptTargetIntegrationTest.java @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + package com.codename1.tools.translator; import org.junit.jupiter.params.ParameterizedTest; @@ -14,6 +37,7 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class JavascriptTargetIntegrationTest { @@ -90,6 +114,21 @@ void generatesBrowserBundleForJavascriptTarget(CompilerHelper.CompilerConfig con "Generated host page should use the JavaScript port browser shell assets"); assertTrue(browserBridge.contains("cn1HostBridge") && browserBridge.contains("host-callback") && browserBridge.contains("host-call"), "Browser bridge should expose host-call plumbing for the JavaScript port shell"); + assertTrue(browserBridge.contains("function applyLensSelfRegion") + && browserBridge.contains("ctx.getImageData(rx, ry, rw, rh)") + && browserBridge.contains("LENS_GLASS_TINT_STR") + && browserBridge.contains("_lab = nums[ni++]") + && browserBridge.contains("_ltintStrength = nums[ni++]"), + "Browser bridge should ship the Simulator-equivalent per-pixel glass-tab lens"); + assertTrue(browserBridge.contains("GLASS_SELF_REGION: 82") + && browserBridge.contains("function glassMaterialInPlace") + && browserBridge.contains("function applyGlassOptics") + && browserBridge.contains("function applyGlassSelfRegion") + && browserBridge.contains("_goffset = nums[ni++]") + && browserBridge.contains("ctx.drawImage(outputCanvas, rx, ry)"), + "Browser bridge should preserve and render the complete native glass recipe"); + assertFalse(browserBridge.contains("var _lsw = _lw / _lm"), + "Glass-tab lens must not regress to the coarse uniform-zoom fallback"); assertTrue(protocolDoc.contains("Version: 1") && protocolDoc.contains("host-call") && protocolDoc.contains("host-callback")