From d21bb3c5bf66f8a61930cfee130ed2c0d68398b0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:50:44 +0300 Subject: [PATCH 01/13] Fix JavaScript glass tab lens rendering --- .github/workflows/scripts-javascript.yml | 5 + .../impl/html5/BufferedGraphics.java | 4 +- .../codename1/impl/html5/HTML5Graphics.java | 4 +- .../impl/html5/HTML5Implementation.java | 12 +- .../impl/html5/graphics/LensRegion.java | 39 +--- .../graphics/SurfaceCommandRecorder.java | 19 +- scripts/verify-javascript-lens-parity.mjs | 77 +++++++ .../src/javascript/browser_bridge.js | 217 +++++++++++++++--- .../JavascriptTargetIntegrationTest.java | 9 + 9 files changed, 308 insertions(+), 78 deletions(-) create mode 100644 scripts/verify-javascript-lens-parity.mjs 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/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..2217b085e89 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 @@ -183,9 +183,9 @@ public void blurRegion(int x, int y, int width, int height, float radius, float @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..bdd53dbb3da 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 @@ -461,9 +461,9 @@ public void blurRegion(int x, int y, int width, int height, float radius, float * {@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..ab7ae779c06 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 @@ -6425,10 +6425,14 @@ public boolean glassRegion(Object graphics, int x, int y, int width, int height, @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/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..cfa27586702 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,12 +135,10 @@ 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; private int[] ops = new int[64]; @@ -296,9 +294,12 @@ 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); } @Override public String getFilter() { return "none"; } @Override public void clearRect(double x, double y, double width, double height) { diff --git a/scripts/verify-javascript-lens-parity.mjs b/scripts/verify-javascript-lens-parity.mjs new file mode 100644 index 00000000000..ccf45622188 --- /dev/null +++ b/scripts/verify-javascript-lens-parity.mjs @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import vm from 'node:vm'; + +// Pixel-parity guard for the iOS-26 glass-tab selection lens. This evaluates +// the implementation that browser_bridge.js actually ships (not a copied test +// implementation) against checksums produced by JavaSEPort.applyLensBuffer() +// for the same deterministic source image at every frozen animation-timing +// checkpoint. A one-channel/one-pixel drift changes the checksum. +const bridgePath = new URL('../vm/ByteCodeTranslator/src/javascript/browser_bridge.js', import.meta.url); +const bridge = fs.readFileSync(bridgePath, '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;', sandbox); + +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; +} + +const frames = [ + { progress: 0, width: 80, height: 40, magnify: 1.08, aberration: 0, expected: 0x9d4a06ea }, + { progress: 10, width: 105, height: 54, magnify: 1.1726, aberration: 0.0185, expected: 0x66a714e8 }, + { progress: 25, width: 105, height: 56, magnify: 1.18, aberration: 0.02, expected: 0x25e7edb0 }, + { progress: 50, width: 105, height: 56, magnify: 1.18, aberration: 0.02, expected: 0x25e7edb0 }, + { progress: 75, width: 90, height: 55, magnify: 1.13, aberration: 0.01, expected: 0x471ccf1d }, + { progress: 90, width: 80, height: 42, magnify: 1.08, aberration: 0, expected: 0x0f8060ba }, + { progress: 100, width: 80, height: 40, magnify: 1.08, aberration: 0, expected: 0x9d4a06ea } +]; + +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 argb = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + argb[i * 4] = output[i * 4 + 3]; + argb[i * 4 + 1] = output[i * 4]; + argb[i * 4 + 2] = output[i * 4 + 1]; + argb[i * 4 + 3] = output[i * 4 + 2]; + } + const actual = crc32(argb); + if (actual !== frame.expected) { + throw new Error(`Lens parity failed at ${frame.progress}%: ` + + `expected ${frame.expected.toString(16)}, got ${actual.toString(16)}`); + } + console.log(`lens parity PASS t=${frame.progress}% size=${width}x${height} crc=${actual.toString(16)}`); +} diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index e86883d1df8..6e9a6af8d06 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -979,6 +979,184 @@ 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; + + 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; + var glassAmount = lensSmoothstep(1.085, 1.25, 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); + } + // Replay one command stream (opcodes + nums + objs) onto ``ctx``. function replaySurfaceCommands(ctx, ops, opCount, nums, objs) { var ni = 0; // num cursor @@ -1122,44 +1300,17 @@ 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; 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..2b9bb1ee85f 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 @@ -14,6 +14,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 +91,14 @@ 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"); + 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") From 4e978bc4c5fbe525c46bf908ec1c9b78cd83d300 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:45:11 +0300 Subject: [PATCH 02/13] Match JavaScript glass material to native --- .../impl/html5/BufferedGraphics.java | 7 + .../codename1/impl/html5/HTML5Graphics.java | 10 + .../impl/html5/HTML5Implementation.java | 11 +- .../impl/html5/graphics/GlassRegion.java | 66 ++++ .../graphics/SurfaceCommandRecorder.java | 14 + scripts/verify-javascript-lens-parity.mjs | 67 +++- .../src/javascript/browser_bridge.js | 306 ++++++++++++++++-- .../JavascriptTargetIntegrationTest.java | 30 ++ 8 files changed, 474 insertions(+), 37 deletions(-) create mode 100644 Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/GlassRegion.java 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 2217b085e89..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,6 +181,13 @@ 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, float aberration, int tintColor, float tintStrength) { 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 bdd53dbb3da..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,6 +456,16 @@ 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}. 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 ab7ae779c06..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,11 +6414,12 @@ 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; } 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/SurfaceCommandRecorder.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/SurfaceCommandRecorder.java index cfa27586702..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 @@ -141,6 +141,11 @@ public final class SurfaceCommandRecorder implements CanvasRenderingContext2D { // 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]; @@ -301,6 +306,15 @@ public void lensSelfRegion(double x, double y, double w, double h, double corner 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) { // Cull provable no-ops: a zero/negative-area rect paints nothing, so do diff --git a/scripts/verify-javascript-lens-parity.mjs b/scripts/verify-javascript-lens-parity.mjs index ccf45622188..41fc4a218cb 100644 --- a/scripts/verify-javascript-lens-parity.mjs +++ b/scripts/verify-javascript-lens-parity.mjs @@ -1,11 +1,11 @@ import fs from 'node:fs'; import vm from 'node:vm'; -// Pixel-parity guard for the iOS-26 glass-tab selection lens. This evaluates +// 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() -// for the same deterministic source image at every frozen animation-timing -// checkpoint. A one-channel/one-pixel drift changes the checksum. +// and IOSImplementation's material/optics routines. A one-channel/one-pixel +// drift changes the checksum. const bridgePath = new URL('../vm/ByteCodeTranslator/src/javascript/browser_bridge.js', import.meta.url); const bridge = fs.readFileSync(bridgePath, 'utf8'); const start = bridge.indexOf(' var LENS_MAG_FLAT ='); @@ -14,7 +14,10 @@ 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;', sandbox); +vm.runInNewContext(bridge.substring(start, end) + + '\nthis.applyLens = applyLensSelfRegion;' + + '\nthis.applyMaterial = glassMaterialInPlace;' + + '\nthis.applyOptics = applyGlassOptics;', sandbox); function crc32(bytes) { let crc = 0xffffffff; @@ -27,6 +30,31 @@ function crc32(bytes) { 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; +} + const frames = [ { progress: 0, width: 80, height: 40, magnify: 1.08, aberration: 0, expected: 0x9d4a06ea }, { progress: 10, width: 105, height: 54, magnify: 1.1726, aberration: 0.0185, expected: 0x66a714e8 }, @@ -61,17 +89,32 @@ for (const frame of frames) { frame.magnify, frame.aberration, 0x0a84ff, 1); // JavaSE's reference buffer is packed ARGB; Canvas ImageData is RGBA. - const argb = new Uint8Array(width * height * 4); - for (let i = 0; i < width * height; i++) { - argb[i * 4] = output[i * 4 + 3]; - argb[i * 4 + 1] = output[i * 4]; - argb[i * 4 + 2] = output[i * 4 + 1]; - argb[i * 4 + 3] = output[i * 4 + 2]; - } - const actual = crc32(argb); + const actual = crc32(rgbaToArgb(output)); if (actual !== frame.expected) { throw new Error(`Lens parity failed at ${frame.progress}%: ` + `expected ${frame.expected.toString(16)}, got ${actual.toString(16)}`); } console.log(`lens parity PASS 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 6e9a6af8d06..ca22ecb6d97 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; @@ -1157,6 +1180,253 @@ 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 @@ -1275,26 +1545,8 @@ 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; @@ -1315,6 +1567,20 @@ } 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; + } case SURF.BLIT_SURFACE_XY: { var b1 = surfaceTable[nums[ni++]]; if (b1 && b1.canvas) { ctx.drawImage(b1.canvas, nums[ni++], nums[ni++]); } else { ni += 2; } 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 2b9bb1ee85f..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; @@ -97,6 +120,13 @@ void generatesBrowserBundleForJavascriptTarget(CompilerHelper.CompilerConfig con && 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") From 001b7fcb2f5357701202047ac824c6f299d283af Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:32:08 +0300 Subject: [PATCH 03/13] Match liquid glass tabs to native animation frames --- .../src/com/codename1/ui/Graphics.java | 8 +- .../com/codename1/ui/TabSelectionMorph.java | 126 ++++++---- CodenameOne/src/com/codename1/ui/Tabs.java | 68 ++--- .../com/codename1/impl/javase/JavaSEPort.java | 82 +++++-- .../nativeSources/CN1MetalShaders.metal | 37 +-- Ports/iOSPort/nativeSources/METALView.m | 46 ++-- .../iOSPort/nativeSources/iOSModernTheme.res | Bin 155611 -> 156252 bytes Themes/iOSModernTheme.res | Bin 156565 -> 156252 bytes .../codename1/ui/TabSelectionMorphTest.java | 150 +++++++---- native-themes/ios-modern/theme.css | 43 ++-- .../tests/Cn1ssDeviceRunner.java | 1 + ...absLiquidGlassAnimationScreenshotTest.java | 232 ++++++++++++++++++ scripts/verify-javascript-lens-parity.mjs | 64 ++++- .../src/javascript/browser_bridge.js | 42 ++-- 14 files changed, 686 insertions(+), 213 deletions(-) create mode 100644 scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/TabsLiquidGlassAnimationScreenshotTest.java diff --git a/CodenameOne/src/com/codename1/ui/Graphics.java b/CodenameOne/src/com/codename1/ui/Graphics.java index 388a8dd793a..b37d540a29a 100644 --- a/CodenameOne/src/com/codename1/ui/Graphics.java +++ b/CodenameOne/src/com/codename1/ui/Graphics.java @@ -1526,9 +1526,11 @@ public boolean glassRegion(int x, int y, int width, int height, float radius, fl /// Applies the iOS 26 selection "drop" LENS to the contents already painted into /// the region (the bar + glyphs UNDER it): radial magnification, edge chromatic - /// aberration, and a luminance-keyed dark->accent tint so dark glyphs read in - /// the accent colour only where the lens covers them. Unlike glassRegion this is - /// drawn OVER the content. cornerRadius<0 = capsule. tintColor is 0xRRGGBB. + /// aberration, and a luminance-keyed accent tint so glyphs read in the accent + /// colour only where the lens covers them. Positive tintStrength keys dark + /// glyphs on a light surface; negative tintStrength keys light glyphs on a dark + /// surface. Unlike glassRegion this is drawn OVER the content. + /// cornerRadius<0 = capsule. tintColor is 0xRRGGBB. public boolean lensRegion(int x, int y, int width, int height, float cornerRadius, float magnify, float aberration, int tintColor, float tintStrength) { if (width <= 0 || height <= 0) { return true; diff --git a/CodenameOne/src/com/codename1/ui/TabSelectionMorph.java b/CodenameOne/src/com/codename1/ui/TabSelectionMorph.java index e48edd97779..53f680d3a6c 100644 --- a/CodenameOne/src/com/codename1/ui/TabSelectionMorph.java +++ b/CodenameOne/src/com/codename1/ui/TabSelectionMorph.java @@ -36,6 +36,19 @@ /// the tokens + geometry from the theme/component and then paints from the returned model. final class TabSelectionMorph { + // At 60fps the 350ms native transition advances about 5% per frame. JavaSE can + // occasionally coalesce repaints and hand the next animation tick a completed + // wall-clock Motion; cap the visible jump so at least the characteristic liquid + // frames are painted instead of snapping directly from 0 to 100. + private static final int MAX_VISIBLE_FRAME_STEP = 14; + + static int paceVisibleFrame(int previous, int wallClockValue) { + int prior = previous < 0 ? 0 : (previous > 100 ? 100 : previous); + int raw = wallClockValue < prior ? prior : (wallClockValue > 100 ? 100 : wallClockValue); + int capped = prior + MAX_VISIBLE_FRAME_STEP; + return raw > capped ? capped : raw; + } + /// The morph's tuning tokens. Themes do not set these individually: a NAMED /// PRESET ({@link #preset}) supplies the full envelope set, and the theme /// exposes only high-level controls -- the preset name (tabsMorphPreset), @@ -58,45 +71,54 @@ static final class Tokens { float peakAb; // mid-flight chromatic aberration (fraction) float tintStrength; // lens accent-tint strength 0..1 int barGrowPct; // whole-bar grow pulse percent (0 = off) - float spring = 1f; // settle-overshoot scale (1 = preset amount, 0 = none) + float spring = 1f; // settle-deformation scale (0 = no stop squash/swell) /// The named motion presets. "ios26" (the default, and any unknown name) - /// is the measured iOS 26 Liquid Glass morph; "subtle" halves the - /// deformation and optics for a calmer selection change. + /// is bounded from the recorded iOS 26 UITabBar transition: the travelling + /// drop stays close to one cell, grows only a few percent vertically, and + /// uses barely-visible chromatic separation. "subtle" reduces those cues + /// further for applications that want an almost-flat selection change. static Tokens preset(String name) { Tokens tk = new Tokens(); if ("subtle".equals(name)) { - tk.stretch = 0.16f; - tk.squashW = 0.08f; - tk.grow = 0.07f; - tk.squashH = 0.09f; - tk.liftMm = 0.25f; - tk.bubbleWidthPct = 96; - tk.overflowPct = 12; - tk.downBiasMm = 0.15f; - tk.restMag = 1.04f; - tk.peakMag = 1.09f; - tk.peakAb = 0.01f; + tk.stretch = 0f; + tk.squashW = 0.03f; + tk.grow = 0.005f; + tk.squashH = 0.025f; + tk.liftMm = 0.03f; + tk.bubbleWidthPct = 100; + tk.overflowPct = 0; + tk.downBiasMm = 0f; + tk.restMag = 1.01f; + tk.peakMag = 1.05f; + tk.peakAb = 0.00025f; tk.tintStrength = 1f; tk.barGrowPct = 0; + tk.spring = 0.20f; return tk; } // "ios26" -- the shipped iOS Modern tuning. - tk.stretch = 0.32f; - tk.squashW = 0.16f; - tk.grow = 0.14f; - tk.squashH = 0.18f; - tk.liftMm = 0.5f; - // Wider than the cell: the native iOS 26 selected pill overlaps its - // neighbour cells (276px over a 253px cell on the @3x reference bar). - tk.bubbleWidthPct = 109; - tk.overflowPct = 18; - tk.downBiasMm = 0.3f; - tk.restMag = 1.08f; - tk.peakMag = 1.18f; - tk.peakAb = 0.02f; + // The recorded native drop keeps essentially one cell's width while + // it travels. Its liquid character comes from refraction across the + // moving edge, not from stretching across the neighbouring glyphs. + // A wider lens duplicated two tabs at once and looked like distortion + // instead of the compact UIKit bubble. + tk.stretch = 0f; + tk.squashW = 0.05f; + tk.grow = 0.01f; + tk.squashH = 0.04f; + tk.liftMm = 0.05f; + tk.bubbleWidthPct = 100; + tk.overflowPct = 0; + tk.downBiasMm = 0f; + tk.restMag = 1.02f; + tk.peakMag = 1.08f; + tk.peakAb = 0.0005f; tk.tintStrength = 1f; tk.barGrowPct = 0; + // Native keeps a restrained stop deformation without positional + // overshoot; scale the width/height settle envelope to 35%. + tk.spring = 0.35f; return tk; } @@ -145,24 +167,42 @@ static float smooth(float a, float b, float x) { return t * t * (3 - 2 * t); } - /// Position easing: an even ease-in-out travel reaching the target ~t=0.78, then a - /// small damped overshoot that settles by t=1 (the "stop" bounce). The - /// springiness scales the overshoot amplitude: 1 = the preset 0.09, 0 = no - /// overshoot (a plain ease-in-out stop), 2 = double the bounce. - static float springEase(float t, float springiness) { + // Measured centres of the travelling native selection drop, normalized from + // the first visible frame through its 350ms settle. UIKit's curve accelerates + // more quickly than smoothstep, then spends the last third easing into place; + // approximating it with a short 200ms ease made the CN1 drop look like a jump. + private static final float[] NATIVE_POSITION_TIME = { + 0f, 0.049f, 0.100f, 0.151f, 0.200f, 0.251f, 0.294f, + 0.337f, 0.391f, 0.443f, 0.486f, 0.537f, 0.577f, + 0.629f, 0.677f, 0.723f, 0.766f, 0.820f, 0.863f, 0.914f, 1f + }; + private static final float[] NATIVE_POSITION_VALUE = { + 0f, 0.032f, 0.099f, 0.219f, 0.288f, 0.394f, 0.492f, + 0.582f, 0.678f, 0.728f, 0.770f, 0.804f, 0.876f, + 0.907f, 0.932f, 0.951f, 0.966f, 0.981f, 0.996f, 1f, 1f + }; + + /// Position easing sampled from the recorded iOS 26 UITabBar transition. + /// The path is monotonic: the native drop deforms at the stop but does not + /// shoot past the end of the bar and snap back. + static float springEase(float t) { if (t <= 0f) { return 0f; } if (t >= 1f) { return 1f; } - float travelEnd = 0.78f; - if (t <= travelEnd) { - float u = t / travelEnd; - return u * u * (3 - 2 * u); + for (int i = 1; i < NATIVE_POSITION_TIME.length; i++) { + if (t <= NATIVE_POSITION_TIME[i]) { + float ta = NATIVE_POSITION_TIME[i - 1]; + float tb = NATIVE_POSITION_TIME[i]; + float u = (t - ta) / (tb - ta); + float va = NATIVE_POSITION_VALUE[i - 1]; + float vb = NATIVE_POSITION_VALUE[i]; + return va + (vb - va) * u; + } } - float u = (t - travelEnd) / (1f - travelEnd); - return 1f + 0.09f * springiness * (float) (Math.sin(u * Math.PI) * (1f - u)); + return 1f; } /// Computes one morph frame. @@ -183,7 +223,7 @@ static TabSelectionMorph compute(float t, int fromX, int fromW, int toX, int toW TabSelectionMorph m = new TabSelectionMorph(); float tp = t < 0 ? 0 : (t > 1 ? 1 : t); - float pos = springEase(tp, tk.spring); + float pos = springEase(tp); int x = fromX + (int) ((toX - fromX) * pos); int w = fromW + (int) ((toW - fromW) * pos); @@ -192,6 +232,10 @@ static TabSelectionMorph compute(float t, int fromX, int fromW, int toX, int toW float moving = smooth(0f, 0.08f, tp) * (1f - smooth(0.88f, 1f, tp)); float sd = (tp - 0.80f) / 0.14f; float squash = (sd > -1f && sd < 1f) ? (1f - sd * sd) * (1f - sd * sd) : 0f; // settle bump + // Springiness controls the stop DEFORMATION, not positional overshoot. + // The native centre path above is monotonic; the liquid cue is its brief + // width compression/height swell as it comes to rest. + squash *= tk.spring; float grow = smooth(0f, 0.10f, tp) * (1f - smooth(0.20f, 0.42f, tp)); // early whole-bar swell // horizontal elongation while moving, then width compression at the stop @@ -206,8 +250,8 @@ static TabSelectionMorph compute(float t, int fromX, int fromW, int toX, int toW capX += (w - bubbleW) / 2; w = bubbleW; - // The drop may be wider than its cell (bubbleWidthPct > 100) and the - // spring overshoots the end cells -- but it must never leave the bar: + // The drop may be wider than its cell (bubbleWidthPct > 100), but it + // must never leave the bar: // the native pill overlaps NEIGHBOUR cells, not the backdrop. if (capX < barLeftX) { w -= barLeftX - capX; diff --git a/CodenameOne/src/com/codename1/ui/Tabs.java b/CodenameOne/src/com/codename1/ui/Tabs.java index 21fc3bd9c67..8435bedaa90 100644 --- a/CodenameOne/src/com/codename1/ui/Tabs.java +++ b/CodenameOne/src/com/codename1/ui/Tabs.java @@ -157,12 +157,15 @@ public class Tabs extends Container { private int animatedIndicatorDurationMs = 200; private int animatedIndicatorThicknessMm = 1; // 1mm-tall underline private Motion indicatorAnimMotion; + // Last progress actually presented. This is paced separately from the + // wall-clock Motion so a coalesced/slow repaint cannot skip the whole morph. + private int indicatorAnimValue; // The tab index the indicator morph is currently travelling TO. Lets a re-entrant // setSelectedIndex (e.g. the content-slide finishing) recognise that a morph to the // same tab is already in flight and NOT restart it mid-travel. private int indicatorTargetIndex = -1; // TEST-ONLY: when >=0, paintSelectionCapsule renders the morph at this fixed - // 0..120 progress instead of the live motion (for the JavaSE capture probe). + // 0..100 progress instead of the live motion (for the JavaSE capture probe). private int morphTestValue = -1; // Tab bounds at the start of the indicator animation. private int indicatorFromX; @@ -398,7 +401,10 @@ public boolean animate() { // slide motion control deregistration; the indicator motion is // cheap enough to run alongside without coordination. if (indicatorAnimMotion != null) { - if (indicatorAnimMotion.isFinished()) { + int wallClockValue = indicatorAnimMotion.getValue(); + indicatorAnimValue = TabSelectionMorph.paceVisibleFrame( + indicatorAnimValue, wallClockValue); + if (indicatorAnimValue >= 100) { indicatorAnimMotion = null; indicatorTargetIndex = -1; // Paint ONE more frame now that the morph is over so the SETTLED capsule @@ -469,7 +475,7 @@ void deregisterAnimatedInternal() { // indicator morph are done. Previously a finished 200ms slide deregistered the // animation while the 550ms morph was still in flight, freezing the drop mid-travel. if ((slideToDestMotion == null || slideToDestMotion.isFinished()) - && (indicatorAnimMotion == null || indicatorAnimMotion.isFinished())) { + && indicatorAnimMotion == null) { Form f = getComponentForm(); if (f != null) { f.deregisterAnimatedInternal(this); @@ -1474,7 +1480,7 @@ private void startIndicatorAnimation(int fromIndex, int toIndex) { // If a motion is already in flight, start from the *current* // interpolated position, not from the previous tab -- otherwise // rapid double-clicks jump back to a stale baseline. - if (indicatorAnimMotion != null && !indicatorAnimMotion.isFinished()) { + if (indicatorAnimMotion != null && indicatorAnimValue < 100) { if (toIndex == indicatorTargetIndex) { // A morph to this SAME tab is already running. The content-slide finishing // re-invokes setSelectedIndex(active) to finalise the selection; restarting @@ -1482,7 +1488,7 @@ private void startIndicatorAnimation(int fromIndex, int toIndex) { // Let the in-flight morph run to completion instead. return; } - int v = indicatorAnimMotion.getValue(); + int v = indicatorAnimValue; indicatorFromX = indicatorFromX + ((indicatorToX - indicatorFromX) * v / 100); indicatorFromW = indicatorFromW + ((indicatorToW - indicatorFromW) * v / 100); } else { @@ -1493,11 +1499,12 @@ private void startIndicatorAnimation(int fromIndex, int toIndex) { indicatorToW = to[1]; indicatorTargetIndex = toIndex; // LINEAR-TIME motion: the value is the morph timeline 0..100 and - // paintSelectionCapsule derives the spring position (springEaseTabs, an - // ease-out-back overshoot) AND the height/squash envelopes from it -- so the - // bubble stays tall while travelling and compresses at the stop. (The non-glass + // paintSelectionCapsule derives the recorded native position curve AND the + // height/squash envelopes from it -- so the bubble stays tall while travelling + // and compresses at the stop. (The non-glass // Material underline path reads the same value as a plain position fraction.) indicatorAnimMotion = Motion.createLinearMotion(0, 100, animatedIndicatorDurationMs); + indicatorAnimValue = 0; indicatorAnimMotion.start(); Form f = getComponentForm(); if (f != null) { @@ -1538,7 +1545,7 @@ void paintBottomDivider(Graphics g) { g.setAlpha(oldAlpha); } - // Position easing (springEaseTabs) + smoothstep (lensSmooth) now live in the pure + // Position easing + smoothstep now live in the pure // TabSelectionMorph model (springEase / smooth) so the morph math is unit-testable. /// The thin frost rim left around the selection capsule (tabSelInsetMm, default a hair). @@ -1590,7 +1597,7 @@ private void capsuleCellBounds(int index, int inset, int[] out) { } /// TEST-ONLY hook: render the selection morph frozen at a fixed progress - /// (`value` 0..120, where 100 is the target and >100 is the settle overshoot) + /// (`value` 0..100, where 100 is the settled target) /// travelling from `fromIndex` to `toIndex`, so a JavaSE probe can capture exact /// frames of the animation without racing the real-time motion. Pass `value` < 0 /// to clear and resume normal behaviour. @@ -1617,18 +1624,20 @@ public void setMorphTestState(int fromIndex, int toIndex, int value) { /// The iOS "selected cell" background: a subtle grey capsule kept at bar height /// (the lens drop, drawn over it, bulges taller). Travels + elongates with the /// drop. systemFill grey so it reads neutral, not blue. Alpha via tabSelPillAlphaInt. - private void drawSelectionPill(Graphics g, int capX, int capY, int w, int capH, float bump) { + private void drawSelectionPill(Graphics g, int capX, int capY, int w, int capH, + boolean dark) { int pillInset = capH * 7 / 100; // pill a hair shorter than the lens int py = capY + pillInset; int ph = capH - 2 * pillInset; if (ph <= 0) { return; } - // FADE the grey pill out as the drop travels: the settled "selected cell" - // background is grey, but MID-FLIGHT the bubble is pure transparent glass - // (otherwise the grey shows through the gap between tabs as an empty blob). + // The native frame sequence keeps the system-fill body visible for the + // entire trip (#b4 light / #78 dark on the flat reference backdrop). + // Fading 85% of it away in flight left only a thin lens outline, which is + // why the foreground liquid bubble disappeared in JavaSE/JavaScript. int baseAlpha = getUIManager().getThemeConstant("tabSelPillAlphaInt", 34); - int alpha = (int) (baseAlpha * (1f - 0.85f * bump)); + int alpha = baseAlpha; if (alpha <= 0) { return; } @@ -1636,7 +1645,9 @@ private void drawSelectionPill(Graphics g, int capX, int capY, int w, int capH, int oldA = g.getAlpha(); boolean aa = g.isAntiAliased(); g.setAntiAliased(true); - g.setColor(getUIManager().getThemeConstant("tabSelPillColorInt", 0x767680)); + g.setColor(getUIManager().getThemeConstant(dark + ? "tabSelPillDarkColorInt" : "tabSelPillColorInt", + dark ? 0xc9c9ce : 0x767680)); g.setAlpha(alpha); g.fillRoundRect(capX, py, w, ph, ph, ph); g.setAntiAliased(aa); @@ -1684,7 +1695,7 @@ void paintSelectionCapsule(Graphics g) { int toW; float t; if (morphTestValue >= 0 || indicatorAnimMotion != null) { - int v = morphTestValue >= 0 ? morphTestValue : indicatorAnimMotion.getValue(); + int v = morphTestValue >= 0 ? morphTestValue : indicatorAnimValue; t = (v < 0 ? 0 : (v > 100 ? 100 : v)) / 100f; fromX = indicatorFromX; fromW = indicatorFromW; @@ -1733,15 +1744,14 @@ void paintSelectionCapsule(Graphics g) { g.lensRegion(m.barGrowX, m.barGrowY, m.barGrowW, m.barGrowH, -1f, m.barGrowMag, 0f, 0x000000, 0f); } - drawSelectionPill(g, m.capX, m.capY, m.capW, m.capH, m.flight); - // Accent supplied by the lens in LIGHT mode only: the keying tints DARK - // pixels toward the accent, which is right over a light frost (the - // deliberately-dark glyphs turn blue) but floods a dark bar solid blue, - // because everything under the drop is dark there. On dark bars the - // glyphs carry the accent directly (theme) and the lens keeps only its - // magnify/aberration optics. + drawSelectionPill(g, m.capX, m.capY, m.capW, m.capH, dark); + // The sign selects the luminance key: positive tints dark glyphs on the + // light bar, negative tints light glyphs on the dark bar. This keeps the + // accent attached to the travelling drop in both appearances instead of + // painting the destination tab blue before the bubble reaches it. int tint = getUIManager().getThemeConstant("tabSelLensTintColorInt", 0x0a84ff); - g.lensRegion(m.lensX, m.lensY, m.lensW, m.lensH, -1f, m.magnify, m.aberration, tint, dark ? 0f : m.tintStrength); + g.lensRegion(m.lensX, m.lensY, m.lensW, m.lensH, -1f, m.magnify, + m.aberration, tint, dark ? -m.tintStrength : m.tintStrength); return; } // Non-glass platforms: a translucent rounded capsule. @@ -1761,8 +1771,8 @@ void paintSelectionCapsule(Graphics g) { /// only (review: fewer, coherent morph knobs): tabsMorphPreset picks a named /// envelope set inside the motion model ("ios26" default / "subtle"), /// tabsMorphLensIntensityPct scales the lens optics around the preset (100 = - /// as authored) and tabsMorphSpringPct scales the settle overshoot (100 = - /// preset bounce, 0 = plain stop). Duration remains + /// as authored) and tabsMorphSpringPct scales the preset's settle deformation + /// (100 = preset deformation, 0 = plain stop). Duration remains /// tabsAnimatedIndicatorDurationInt. The mm lengths carried by the preset /// are converted to px here so the model stays Display-free. private TabSelectionMorph.Tokens morphTokens() { @@ -1770,7 +1780,7 @@ private TabSelectionMorph.Tokens morphTokens() { TabSelectionMorph.Tokens tk = TabSelectionMorph.Tokens.preset( uim.getThemeConstant("tabsMorphPreset", "ios26")); tk.scaleLensIntensity(uim.getThemeConstant("tabsMorphLensIntensityPct", 100) / 100f); - tk.spring = uim.getThemeConstant("tabsMorphSpringPct", 100) / 100f; + tk.spring *= uim.getThemeConstant("tabsMorphSpringPct", 100) / 100f; tk.liftPx = Display.getInstance().convertToPixels(tk.liftMm); tk.downBiasPx = Display.getInstance().convertToPixels(tk.downBiasMm); return tk; @@ -1785,7 +1795,7 @@ void paintAnimatedIndicator(Graphics g) { int x; int w; if (indicatorAnimMotion != null) { - int v = indicatorAnimMotion.getValue(); // 0..100 + int v = indicatorAnimValue; // paced visible progress, 0..100 x = indicatorFromX + ((indicatorToX - indicatorFromX) * v / 100); w = indicatorFromW + ((indicatorToW - indicatorFromW) * v / 100); } else { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 9a77893b38b..a4c7567d6a6 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 @@ -17208,29 +17227,33 @@ public boolean blurRegion(Object graphics, int x, int y, int width, int height, private static final double LENS_MAG_FLAT = 0.75; private static final double LENS_TINT_HI = 150; private static final double LENS_TINT_LO = 55; + private static final double LENS_LIGHT_KEY_LO = 150; + private static final double LENS_LIGHT_KEY_HI = 220; // The drop LIFTS the content under it upward (like a magnifying droplet pulling the // glyph up) -- this is the "tabs grow/rise" in the native morph, not just magnify. // Lift is proportional to (magnify-1) so it tracks the travel bump, peaks at centre. - private static final double LENS_LIFT_COEF = 0.40; + private static final double LENS_LIFT_COEF = 0.10; // The drop is a 3D GLASS droplet: a soft specular GLARE (light sheen) near the top, // and a bright EDGE RIM that defines the glass boundary. Without these it reads as a // flat tinted pill, not glass. - private static final double LENS_GLARE = 0.09; // specular sheen strength - private static final double LENS_RIM = 0.06; // edge-rim brightness - private static final double LENS_RIM_W = 0.06; // rim band width (fraction of half-height) - private static final double LENS_REFRACT = 0.16; // edge lensing: content bends at the rim - private static final double LENS_EDGE_SHADOW = 0.12; // soft dark band at the inner edge (glass depth) - // The periphery (bar / other tabs seen through the drop) is slightly SHRUNK while the - // central glyph stays enlarged -- mag falls from `magnify` at the centre to RIM_SCALE - // (<1) at the rim. And the glass carries a faint cool TINT inside it. - private static final double LENS_RIM_SCALE = 0.84; - private static final int LENS_GLASS_TINT = 0xbcd8ff; // faint cool-blue glass cast - private static final double LENS_GLASS_TINT_STR = 0.10; + private static final double LENS_GLARE = 0.07; // broad moving specular sheen + private static final double LENS_RIM = 0.11; // directional top/left rim light + private static final double LENS_RIM_W = 0.10; // rim band width (fraction of half-height) + private static final double LENS_REFRACT = 0.015; // native barely displaces content at the rim + private static final double LENS_EDGE_SHADOW = 0.07; // directional bottom/right depth + // Magnification falls gently from `magnify` at the centre to 1 at the rim. The + // previous 0.84 rim scale visibly crushed neighbouring glyphs and read as a lens + // distortion rather than the native tab's shallow convex surface. + private static final double LENS_RIM_SCALE = 1.0; + private static final int LENS_GLASS_TINT = 0xbcd8ff; + private static final double LENS_GLASS_TINT_STR = 0.0; // native rim is neutral white, not a blue outline // The selected glyph seen through the drop reads MORE vivid/saturated than a flat // tint -- native's selected blue is punchy. Boost chroma around the pixel luminance: // coloured pixels (the blue glyph) push further from grey; neutral greys (the bar) // are barely touched, so only the glyph gets more saturated. - private static final double LENS_SAT_BOOST = 1.32; + private static final double LENS_SAT_BOOST = 1.12; + private static final double LENS_GLASS_START = 1.025; + private static final double LENS_GLASS_FULL = 1.075; @Override public boolean lensRegion(Object graphics, int x, int y, int width, int height, @@ -17295,7 +17318,11 @@ private static void applyLensBuffer(int[] src, int[] out, int rw, int rh, // The 3D-glass cues (edge refraction / edge shadow / glare) belong to the MORPH // droplet, not the settled pill -- scale them by how magnified the drop is so a // resting selection stays a flat subtle pill. - double glassAmt = lensSmoothstep(1.085, 1.25, magnify); + // The bounded iOS-26 morph rests at 1.02x and peaks at 1.08x. Keep the + // liquid foreground completely absent at rest, but let its rim/glare/ + // refraction reach full strength during flight. The previous 1.085x + // lower edge sat above the new peak, silently disabling the liquid layer. + double glassAmt = lensSmoothstep(LENS_GLASS_START, LENS_GLASS_FULL, magnify); for (int yy = 0; yy < rh; yy++) { double py = yy + 0.5 - hh; for (int xx = 0; xx < rw; xx++) { @@ -17310,9 +17337,8 @@ private static void applyLensBuffer(int[] src, int[] out, int rw, int rh, double alpha = Math.min(depth, 1.0); double rd = Math.min(1.0, Math.sqrt((px * px) / (hw * hw) + (py * py) / (hh * hh))); double edge = lensSmoothstep(LENS_MAG_FLAT, 1.0, rd); - // Centre ENLARGES (magnify); periphery SHRINKS (mag -> RIM_SCALE < 1) so the - // bar / other tabs seen through the drop read slightly minified, while the - // central glyph stays big. No shrink when settled (scaled by glassAmt). + // Centre enlarges gently; the rim returns to 1:1 instead of minifying + // neighbouring content. This keeps the native shallow-lens character. double rimScale = 1.0 + (LENS_RIM_SCALE - 1.0) * glassAmt; double mag = magnify + (rimScale - magnify) * edge; if (mag < 0.2) mag = 0.2; @@ -17330,7 +17356,9 @@ private static void applyLensBuffer(int[] src, int[] out, int rw, int rh, int sg = (lensSample(src, rw, rh, hw + (px / mag) * refr, hh + (py / mag) * refr + lift) >> 8) & 0xff; int sb = lensSample(src, rw, rh, hw + (px / magB) * refr, hh + (py / magB) * refr + lift) & 0xff; double lum = 0.2126 * sr + 0.7152 * sg + 0.0722 * sb; - double t = tintStrength * lensSmoothstep(LENS_TINT_HI, LENS_TINT_LO, lum); + double t = tintStrength < 0 + ? -tintStrength * lensSmoothstep(LENS_LIGHT_KEY_LO, LENS_LIGHT_KEY_HI, lum) + : tintStrength * lensSmoothstep(LENS_TINT_HI, LENS_TINT_LO, lum); double fr = sr + (tr - sr) * t; double fg = sg + (tg - sg) * t; double fb = sb + (tb - sb) * t; @@ -17347,15 +17375,17 @@ private static void applyLensBuffer(int[] src, int[] out, int rw, int rh, fr = sl + (fr - sl) * LENS_SAT_BOOST; fg = sl + (fg - sl) * LENS_SAT_BOOST; fb = sl + (fb - sl) * LENS_SAT_BOOST; - // 3D GLASS: a soft specular GLARE near the top (an elliptical sheen) plus - // a bright EDGE RIM (small `depth` = near the boundary) so the drop reads - // as a raised glass droplet, not a flat tint. Both lift the colour toward - // white. + // 3D GLASS: native depth is a directional highlight/shadow surface, not + // large content displacement. Light the top-left rim and shade the + // bottom-right rim so the travelling capsule reads as a convex volume. double gx = px / hw, gy = (py + 0.42 * hh) / hh; double glare = LENS_GLARE * glassAmt * Math.exp(-(gx * gx * 1.15 + gy * gy * 2.6) * 2.1); double rimW = Math.max(2.0, LENS_RIM_W * hh); - double rim = depth < rimW ? (1.0 - depth / rimW) * LENS_RIM : 0; - double bright = glare + rim; + double rim = depth < rimW ? (1.0 - depth / rimW) * LENS_RIM + * (0.35 + 0.65 * glassAmt) : 0; + double lightDir = Math.max(0.2, Math.min(1.0, + 0.68 - 0.28 * (py / hh) - 0.12 * (px / hw))); + double bright = glare + rim * lightDir; if (bright > 0) { fr += bright * (255 - fr); fg += bright * (255 - fg); @@ -17365,7 +17395,9 @@ private static void applyLensBuffer(int[] src, int[] out, int rw, int rh, // shadow at its edge) -- gives the droplet depth instead of a flat cutout. double esW = Math.max(2.0, 0.13 * Math.min(hw, hh)); if (depth < esW) { - double es = (1.0 - depth / esW) * LENS_EDGE_SHADOW * glassAmt; + double shadowDir = Math.max(0.1, Math.min(1.0, + 0.55 + 0.35 * (py / hh) + 0.10 * (px / hw))); + double es = (1.0 - depth / esW) * LENS_EDGE_SHADOW * glassAmt * shadowDir; fr *= (1 - es); fg *= (1 - es); fb *= (1 - es); diff --git a/Ports/iOSPort/nativeSources/CN1MetalShaders.metal b/Ports/iOSPort/nativeSources/CN1MetalShaders.metal index a22e2f92fa6..d5c332a1a2d 100644 --- a/Ports/iOSPort/nativeSources/CN1MetalShaders.metal +++ b/Ports/iOSPort/nativeSources/CN1MetalShaders.metal @@ -342,16 +342,20 @@ fragment float4 cn1_fs_multistop_gradient( constant float LENS_MAG_FLAT = 0.75; constant float LENS_TINT_HI = 150.0; constant float LENS_TINT_LO = 55.0; -constant float LENS_LIFT_COEF = 0.40; -constant float LENS_GLARE = 0.09; -constant float LENS_RIM = 0.06; -constant float LENS_RIM_W = 0.06; -constant float LENS_REFRACT = 0.16; -constant float LENS_EDGE_SHADOW = 0.12; -constant float LENS_RIM_SCALE = 0.84; +constant float LENS_LIGHT_KEY_LO = 150.0; +constant float LENS_LIGHT_KEY_HI = 220.0; +constant float LENS_LIFT_COEF = 0.10; +constant float LENS_GLARE = 0.07; +constant float LENS_RIM = 0.11; +constant float LENS_RIM_W = 0.10; +constant float LENS_REFRACT = 0.015; +constant float LENS_EDGE_SHADOW = 0.07; +constant float LENS_RIM_SCALE = 1.0; constant float3 LENS_GLASS_TINT = float3(188.0, 216.0, 255.0); // 0xbcd8ff, 0..255 -constant float LENS_GLASS_TINT_STR = 0.10; -constant float LENS_SAT_BOOST = 1.32; +constant float LENS_GLASS_TINT_STR = 0.0; +constant float LENS_SAT_BOOST = 1.12; +constant float LENS_GLASS_START = 1.025; +constant float LENS_GLASS_FULL = 1.075; static inline float cn1_lens_smoothstep(float a, float b, float x) { float t = clamp((x - a) / (b - a), 0.0, 1.0); @@ -389,7 +393,7 @@ fragment float4 cn1_fs_lens( float rd = min(1.0, sqrt((px * px) / (hw * hw) + (py * py) / (hh * hh))); float liftMax = LENS_LIFT_COEF * (magnify - 1.0) * hh; - float glassAmt = cn1_lens_smoothstep(1.085, 1.25, magnify); + float glassAmt = cn1_lens_smoothstep(LENS_GLASS_START, LENS_GLASS_FULL, magnify); float edge = cn1_lens_smoothstep(LENS_MAG_FLAT, 1.0, rd); float rimScale = 1.0 + (LENS_RIM_SCALE - 1.0) * glassAmt; @@ -408,7 +412,9 @@ fragment float4 cn1_fs_lens( float sb = src.sample(smp, cB).b * 255.0; float lum = 0.2126 * sr + 0.7152 * sg + 0.0722 * sb; - float t = tintStrength * cn1_lens_smoothstep(LENS_TINT_HI, LENS_TINT_LO, lum); + float t = tintStrength < 0.0 + ? -tintStrength * cn1_lens_smoothstep(LENS_LIGHT_KEY_LO, LENS_LIGHT_KEY_HI, lum) + : tintStrength * cn1_lens_smoothstep(LENS_TINT_HI, LENS_TINT_LO, lum); float fr = sr + (tintc.r - sr) * t; float fg = sg + (tintc.g - sg) * t; float fb = sb + (tintc.b - sb) * t; @@ -426,15 +432,18 @@ fragment float4 cn1_fs_lens( float gx = px / hw, gy = (py + 0.42 * hh) / hh; float glare = LENS_GLARE * glassAmt * exp(-(gx * gx * 1.15 + gy * gy * 2.6) * 2.1); float rimW = max(2.0, LENS_RIM_W * hh); - float rim = depth < rimW ? (1.0 - depth / rimW) * LENS_RIM : 0.0; - float bright = glare + rim; + float rim = depth < rimW ? (1.0 - depth / rimW) * LENS_RIM + * (0.35 + 0.65 * glassAmt) : 0.0; + float lightDir = clamp(0.68 - 0.28 * (py / hh) - 0.12 * (px / hw), 0.2, 1.0); + float bright = glare + rim * lightDir; fr += bright * (255.0 - fr); fg += bright * (255.0 - fg); fb += bright * (255.0 - fb); float esW = max(2.0, 0.13 * min(hw, hh)); if (depth < esW) { - float es = (1.0 - depth / esW) * LENS_EDGE_SHADOW * glassAmt; + float shadowDir = clamp(0.55 + 0.35 * (py / hh) + 0.10 * (px / hw), 0.1, 1.0); + float es = (1.0 - depth / esW) * LENS_EDGE_SHADOW * glassAmt * shadowDir; fr *= (1.0 - es); fg *= (1.0 - es); fb *= (1.0 - es); } fr = clamp(fr, 0.0, 255.0); fg = clamp(fg, 0.0, 255.0); fb = clamp(fb, 0.0, 255.0); diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 6fdbe77259e..ea6f9bfce59 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -214,16 +214,20 @@ static inline float glassSmoothstep(float a, float b, float x) { #define LENS_MAG_FLAT 0.75f // uniform-magnify fraction of the (elliptical) radius #define LENS_TINT_HI 150.0f // luminance >= HI: no tint #define LENS_TINT_LO 55.0f // luminance <= LO: full dark->accent tint -#define LENS_LIFT_COEF 0.40f // upward pull of the content under the drop -#define LENS_GLARE 0.09f // specular sheen strength -#define LENS_RIM 0.06f // edge-rim brightness -#define LENS_RIM_W 0.06f // rim band width (fraction of half-height) -#define LENS_REFRACT 0.16f // edge lensing: content bends at the rim -#define LENS_EDGE_SHADOW 0.12f // soft dark band just inside the rim -#define LENS_RIM_SCALE 0.84f // periphery shrinks (< 1) while the centre enlarges +#define LENS_LIGHT_KEY_LO 150.0f // dark bar/pill remain neutral +#define LENS_LIGHT_KEY_HI 220.0f // light glyph becomes full accent +#define LENS_LIFT_COEF 0.10f // restrained upward pull under the drop +#define LENS_GLARE 0.07f // broad moving specular sheen +#define LENS_RIM 0.11f // directional top/left rim light +#define LENS_RIM_W 0.10f // rim band width (fraction of half-height) +#define LENS_REFRACT 0.015f // native barely displaces content at the rim +#define LENS_EDGE_SHADOW 0.07f // directional bottom/right depth +#define LENS_RIM_SCALE 1.0f // no funhouse minification at the periphery #define LENS_GLASS_TINT 0xbcd8ff /* faint cool-blue cast through the whole glass */ -#define LENS_GLASS_TINT_STR 0.10f -#define LENS_SAT_BOOST 1.32f // push the tinted blue glyph more vivid +#define LENS_GLASS_TINT_STR 0.0f +#define LENS_SAT_BOOST 1.12f // restrained blue saturation +#define LENS_GLASS_START 1.025f +#define LENS_GLASS_FULL 1.075f __attribute__((unused)) static void glassApplyLens(uint32_t *src, int rw, int rh, uint32_t *out, @@ -240,7 +244,9 @@ static void glassApplyLens(uint32_t *src, int rw, int rh, uint32_t *out, float liftMax = LENS_LIFT_COEF * (magnify - 1.0f) * hh; // The 3D-glass cues belong to the morph droplet, not the settled pill -- fade them // out by how magnified the drop is, so a resting selection is a flat subtle pill. - float glassAmt = glassSmoothstep(1.085f, 1.25f, magnify); + // The bounded tab morph rests at 1.02x and peaks at 1.08x. Fade the + // foreground optics in above rest and reach full liquid character in flight. + float glassAmt = glassSmoothstep(LENS_GLASS_START, LENS_GLASS_FULL, magnify); for (int y = 0; y < rh; y++) { float py = (y + 0.5f) - hh; for (int x = 0; x < rw; x++) { @@ -256,9 +262,8 @@ static void glassApplyLens(uint32_t *src, int rw, int rh, uint32_t *out, float alpha = depth >= 1.0f ? 1.0f : depth; float rd = sqrtf((px * px) / (hw * hw) + (py * py) / (hh * hh)); // elliptical 0..1 if (rd > 1.0f) rd = 1.0f; - // Centre ENLARGES (magnify); periphery SHRINKS toward RIM_SCALE (< 1) so the - // bar/other tabs seen through the drop read minified while the central glyph - // stays big. No shrink when settled (rimScale -> 1 as glassAmt -> 0). + // Centre enlarges gently; the rim returns to 1:1 instead of minifying + // neighbouring content. This keeps the native shallow-lens character. float edge = glassSmoothstep(LENS_MAG_FLAT, 1.0f, rd); float rimScale = 1.0f + (LENS_RIM_SCALE - 1.0f) * glassAmt; float mag = magnify + (rimScale - magnify) * edge; @@ -273,7 +278,9 @@ static void glassApplyLens(uint32_t *src, int rw, int rh, uint32_t *out, int sg = (glassSampleBilinear(src, rw, rh, hw + (px / mag) * refr, hh + (py / mag) * refr + lift) >> 8) & 0xff; int sb = glassSampleBilinear(src, rw, rh, hw + (px / magB) * refr, hh + (py / magB) * refr + lift) & 0xff; float lum = 0.2126f * sr + 0.7152f * sg + 0.0722f * sb; - float t = tintStrength * glassSmoothstep(LENS_TINT_HI, LENS_TINT_LO, lum); + float t = tintStrength < 0.0f + ? -tintStrength * glassSmoothstep(LENS_LIGHT_KEY_LO, LENS_LIGHT_KEY_HI, lum) + : tintStrength * glassSmoothstep(LENS_TINT_HI, LENS_TINT_LO, lum); float fr = sr + (tr - sr) * t; float fg = sg + (tg - sg) * t; float fb = sb + (tb - sb) * t; @@ -291,8 +298,11 @@ static void glassApplyLens(uint32_t *src, int rw, int rh, uint32_t *out, float gx = px / hw, gy = (py + 0.42f * hh) / hh; float glare = LENS_GLARE * glassAmt * expf(-(gx * gx * 1.15f + gy * gy * 2.6f) * 2.1f); float rimW = LENS_RIM_W * hh; if (rimW < 2.0f) rimW = 2.0f; - float rim = depth < rimW ? (1.0f - depth / rimW) * LENS_RIM : 0.0f; - float bright = glare + rim; + float rim = depth < rimW ? (1.0f - depth / rimW) * LENS_RIM + * (0.35f + 0.65f * glassAmt) : 0.0f; + float lightDir = fmaxf(0.2f, fminf(1.0f, + 0.68f - 0.28f * (py / hh) - 0.12f * (px / hw))); + float bright = glare + rim * lightDir; if (bright > 0.0f) { fr += bright * (255.0f - fr); fg += bright * (255.0f - fg); @@ -301,7 +311,9 @@ static void glassApplyLens(uint32_t *src, int rw, int rh, uint32_t *out, // soft dark band just inside the rim (glass depth), morph-only float esW = 0.13f * minhh; if (esW < 2.0f) esW = 2.0f; if (depth < esW) { - float es = (1.0f - depth / esW) * LENS_EDGE_SHADOW * glassAmt; + float shadowDir = fmaxf(0.1f, fminf(1.0f, + 0.55f + 0.35f * (py / hh) + 0.10f * (px / hw))); + float es = (1.0f - depth / esW) * LENS_EDGE_SHADOW * glassAmt * shadowDir; fr *= (1.0f - es); fg *= (1.0f - es); fb *= (1.0f - es); diff --git a/Ports/iOSPort/nativeSources/iOSModernTheme.res b/Ports/iOSPort/nativeSources/iOSModernTheme.res index 4909edc72674610ee440f9537426b3765e22daca..98a82ad9bcee356ca6517cacde6c6dcd7d67f5a6 100644 GIT binary patch delta 2172 zcmZWrdr*{B6yI}Q_W7~Ac3D{34Nw!4hd_kmOgwBRguw=Uq-ZEK=_rDVp@lD2qbX-p z#DjBPP~4`bFio`2`tlL0ljAcL#m47=nKq&bSW}}-YIk?!n0<7(lj6AXF*?V2G%8V>Bc%V!>E$#R3mAMfzliMJW4BX2e)8!ko^O0iDRJSid4U~?0=D}f{p zcyw4Tg}J0D-i0VP^fh2BTV@`c)J?ozeCnVjKvPB_Nu}xPG+M$y$mg%=-a7wLSxZ|k zm}!VsDH9&`c4pY*qqwcv=&b_Im}oRvU?1ZeN&(M7+q0HF@1hT20=Z@KmV{De1jlqb zyE~c;-m-KA^k++yTei9XENx~uWO5AV1j1XJZ_jC$3sF)te3Wkf2z11bT*fNbnp0oP zWoKN`ouWhy*Xy&G`ykI}iujc`4-0`VoJ#A!T)`422GQCk1H`0b^xaR0Ekp7_#S0~u z@t&Ac<6}8wE@KN<<1q0#l_pz_-%6O0G`w;Z{>KG~jjl~+qRxeD4N_J!5?})Wi5nU) zTDf+vfsgDH$v8(BnI&6`F1Yi%Ux5Le@OuuKfx{Ce#DYrUZyHn~QgnTs?;ZCrHt{(= zOoSG6IW@is&)_)i%8urQ4k{-Gw@%!-XwKOL6thy-sMAL+R-iSYx(mW%l7c%4IJhnw zH(9B&pt`dk7f`vUMoVRk-M!46Cv^~w#KtJ%eGRxOj% js(>rpT zs>e5CbrU8rQsQ5DMlUQ1sK5h;(92;NY&a#9unos25s-uR`8x<7H4@)&RJNV5Pn$)8 z4j(tj*;4{@ B*OD~KYW)>PUy(F$*N8gW2+0~OyDj=JsuiVi zV^>fmL1NPQb)PA|W1|n-=pMMx59K-FA=OkW*>$A1v%L1QM$O^H2eyFJoA>Lx?!w4; z9Q6mBt~fRkW=UzZ C6n%rJ`67;e~%UZLQVS_%=@s-X+ptA5YG5Hw%XXuGv5 z31bK>u=h$q^TyQvHs2mqYLYv8E5~JkRJONTJOCG_=FjVW6?ccFl?h~5U2~K%lb>Gc zR}r;UNNdKIc1U=^0?|oGy6z>n5?vkj{h2k_-9ZjXJtle^5~<62x;9qq>=_ug2J`!a zS>sdr^LmA2F}Jq1((K~HvH$3i8A&HtO=D(dSKWl9PTt1ty$r!T9BE2qKeI8j#5>q$ zYZd-AJ^aITX9CIYIUX%h8Tyt;b^5#BhvPR2=Ea-o@lp7Ucqo@P+1V~Y zxx@)sRD{GploSG?M%m3^`L)}T*6!tV5((Ac*WRYTv7NsA>_k`Cfl)dqqtGeH*HkIV z7?*)~dRa76rGnci%JoV=hEV$$>NMaFTy33|;PVyZAFRFVb=p?&O7jXazup}AWghlz z8Xf+ciWt4YIte_o))DHuXdA`cUapxSa*F9w_iWCbghtuD!8zHoisrC?5 5`&Kb) fBgmPeRtOlRcD7Tc`dc9GpjqKP*z=l(>^r*2z z;vsMmV6v38`7aKI5&+` DZneRt!;Ae2?A^#cr97dlEXlA12>nUhM`*~XX%+hp(V*CcKh>qI_T%vG 3ZBx$J`~Xuv-z%4I~4FcLwoYF)uCh?-oq zo+OI{#6-N*%vzYKTg_UC|6mZC$jVYg(}q%jMV^uf0)XYdR!Sn2BkUgR2LRS)aG*~L z#O~SBYZb3gW~wQSR{`Z>?CJ#oMSq#$0p%FFNPK=J2t4RsK-!9OPNlBVGh*Jy2LLDw zei 9hX?>L5`Agug)%Ip%7iS^Wgzwctp*5S*6Y_0cmm+(iI;!yl3C8c z`IADbWWVea021rV15+sHhZqtq$>+Z-Dm3_wSIzSWpe*z+qxgRok$(jMaPE9*k$`ea z|0Vz10g#d}C6Q1@ m8b{+mS!a7!J&19<)= zeZ@Tam-K(c{8FC%FA#xH#?}7~0>~j$E5IONegc3_{=wwJ|GzM~bpL@V{fC{)@V_wF zLYb`p3-kAS148+ip!EMFIQ|FWZ*h=-a-IJd0Z%B?{XYqedJh?>e_IX!q51N{6v|Ec zABa=fIj `LzQ|6ek%|2G7xStxV+KOpFK9#|xx;hdRZ+lJKa)IA23 zZ7YJ_NkU?8)BZs sJU0|=#tJN+O#~e1+R~?2XUX{s4!mxT5GpajRvwGIoN}Bls zi$8=^^xoZpk(+ !hUq51d9>gJtOV883$X{f!3MxivGz;scvQ$CsxSew+u15+@j!cK;nqIl1Oy z@={+A3`PiK5r{&_2u>%%fr~s|q$=NDEwdT#3Nok!T#fLs`CDE^FY8qnlj7C6&7?Lp z5U1KqsLDa{bXx)#Ydk&COJEQAC&HMH;@R>csi)LgIj$yt-f$fDe({JEbG`JtDz z-ceAWWtKArXNcbf)srz4^zH*vs%q4Bq9irxt?}(<0u0i{{6kp5O{Fjc>znRe9x!jm z{RX}2pN`S-e5g5>)9o_vOSZJ;mSrjaNVds!ZB$&o#H})r7*b_pD!KG0C M`TCwofyEl-oR2B^z1 mHGhk01G#F`@|YiZmc4JvnGoqU!YMV65{WR1l~oa9+*$L7;C*zK%&WoM&W|F$Pny zQF|1*{rutv)4t61+g`B0=~Q{s5slj)2FbET=ti(YU=@l$k6jn+mZ$mC#Wg)-R5-47 z3asINJ=N FV;2Rnpoj_!CPeC*Ua(tG60?yk+(F} Z(FtG!NrYbs%NzbOtWPU;&XoUQf-v9D zozY=-E8>m#2V%7`79K0}#AD~BpG}8`P6tO>UEz0IHoseow#nLFt@qrD#=yvZQhg$e z30|(A-K70bUPmSzXv!kJpAu(>h4EpyTS*wgMKv1Ggvm+FHTplJ^~4V-sVm_H?=$YH zxMqQqNJZ8N6e^~2#-nq3j%Gf}X-W4bG0<;`5a#O;>!;zH#GTn9dS9Eb0?E2?$ijch zx_Pcj;-jeeyGy!us3Vm}#ZrhiI0O%2Kt07$3Ar(>1@QN5F=5%*cpcs09>~J5RVqz((t$ZanQZtMhJ9j {qxU>A9=JBo+W;o h8RC!#TD3mW}knNl{~hh(I+yF7`TL zdf9omBdu!Gd2PvzaX+;lrcaYNH#@cm-4i^AAYA4RvcCQl!cO1(qF+bcvqfUD5OkzL zN6wWkO+y@v@RY5R4p$26QM~2(5FBr3hRqucv44l(%6ZT)TPDeWXzn-Le5h_^zMmiO zC2gsdfMLy%)VUf-?p$9$x3|WQoa7p(4*2QZmqU|2XyWfj{UZ8<$AUPhey}7`!8n7a zBB5{JMog-r&KfMKr9{ZINl;kqC?ct6--{6&9~q-H=(rT?JJCS(w)}<^DvtcP40{~l z#iVuJ0!uHmsI0soi&+#{lHGA&zS1S|6RU;vhZ@m3C`n)9hOl2NyT8wVt7!}uDz`jV zvnS*}#j)6GO6(urbAMdOQW|u4VSJ@|KiIgjB0&avL>!N0b6k%IT#1y|!lU~?>ELrH zBK_7pRWP2keP{8kT^-kEdow 3Pyy &p$sLaq{g-6hXZRKvJQkpi%BhUpP zwlMcZ;pjR_>8WB=qe@q2a5Uml(3!G@IAvxzDxuebWCP+uWqn{#9yB(QAO}(pNbodh zkmi%m`JiZ*!|LC04l`die?*fp7{^Oi3D0+`(9WS%L5R#%jlT31I+)rcH7dl(wW!QB z`-bc(_VFCF0*}#vFm7L|1_2k2$rh*uCI-oT#-L+KrU}f$aOX#4DA3(rE(0Aw&5_F6 zxtTj7 9=Z;=L4ZoGjMKJIlVy_wlm?^gWoRQ-5Wwlc2xO_vp4 zl@{3Fc|^ULckmmw&GHb|F2%Z$b2hu-c6r!cDyYEUGxsMa#-ap2EUR0w-ayicizb7v zPaM{9dp5O`r@*8LFxVWTMu5*fHMBZKH+u#hK*7bxmPnNc!k>0sXK_}}fzuB0)WmbO zpzlK{Qm||SH!UE+K#OJ8aB0FH7GlS1$RkO8`@urAM%$&5%t=;H#5F}f0j)oY&$PWF z$I#P$X)ain6kjV4Ipnu8 _S3mJljktwM|2O-035HJX@xVSuCL)!D|*xkcp!{{t3}OF> aEI_kT1NoM5g69@NTMnyptP$DzK6I2IM^ 8YD3_dWSuNO;_-dcs@!O-m)_T_q5X$h%92W#_g%4_?Y^bl$_Gb`o` z>>BZKa+|PaeDL{LiJd7Xeip1uu7LFSxdg~M&~qi-3}`dLC{Tw?7_Y6mHrNysMn3P^ zJRrf;c0TdL-nmPqlW4wmJFrBV7)g^`aM?5zVch!rPLQSphEobu0;7Y?U@4t5Padge zhwov#iJJI_+_T@pe5{mIFBj$bvSxKEk_58@q$X?@Dm&Q#A-J{ zftUzii_;I!oXk;ntMSrUY$@O!O65yl5q})}7W*R6S3;$~f;Z+4EhR$x>kM26ES?=S zw;Q)=u&j?404@62IlyM}Bm2b&C2JEVM@RgEsz5Y!;l(t64a5rJP55=23n3X447)%O z<_NuLKC72Vbn5+nu)obo5RKG~BYG9d7t4p(rP1^@go1h8W42JKYHpIzBW(t}+A|s0 z>wrP~RC5EjVleIF*In6DX6;+ZJowP@v^|#;^a4_hOO+yi@Cq95%8a5P 3hp9a3R@zes^@kK6%ap zhLo5Xbwniit; zQUz81_RB&O_0RgaN%WOHvpX3V`Ex^B;J6$PS9u$Ce2ThuL2pO7RrHE#$IK66w}(?8 zY$PNaySRi=6FeiB<&nai((RJ@N3vuhe~|46PP|ZbU#DcQ{l|_>7K!`o4bt``_S_t* z#$KgD04JMgWmW(W;<(23oSn_^8eQ`uLG#uo%p@`Y$M+udd=1)UqbiK3 !ApZ#S}z?0e=H!AlV>*H;^t;gq^RKvA-7?xE1*}Tcjcx9-TMJ5)p z%3$C(9SnZY-$&oc!dUh(O2D86N+LY+#dE&_(Y)e7amwPnBpn{I*q3UWoDZ*`tD!H{ zg)#z-xy&@*UuK3EZ)L|d7Awme(pn @}x^#!V+-ooEQ^sIpGy}+>5Y`|pZ)H{!U!ldS9#zpcIUuw?fX;!Ns`>DMDa*i! zK#YLIcmr#U6CMhq;w3Y{iuF-@goC0c?${Q3td;dvgX=tN;7m7iKky8|b03 ~*~vgn<1%B;;A#?16X>8a*+}OJDCJuF^;F1nzUD zY$9$qbW3(^dx7o;hL5XtPP+gyN1yo4 A1x~@ zd&WH1 ebkp%J0%x}wgKw}4S{Mt-+i1?=Ex3@z(*?!DjU6!xdi-vD z5(Y@%%%EuN=06=eAB}*gxNJZzK^Y`Of=@)!bA)twc!chT@gG1s7pi|Uk*CpHEa$Q& z30zUfdu+)Jo$n@qC{zVWiuD62k>=eyM7dGI{_bB9j#Fb5HNtbi;_AOVYb#%i;u@E` zMm2V6E1kUqopu}EHjF?4%L|v2?&tNpjN*nUs3WR*dk=&{7GBL5v$E`jPQg;8`w=;G z6| 0z$PNwp_i*2Ax9RE4#bi-tN?6g z2=fyZbkC1_U|yk^B#@*VPh>Dusmd;jV7D YIix7v+spKc@4Q$Sk5SDebuDK6LXdGWK-}SC1yT8@B0;8>b ztH-FgN!a0SWZ*@-22!QgmA-o+1Ku0Z?t;dftaZd|lty>B`UwB+8Ssdj?mTjN9`b!` zYI$8-Qgs;>O@V}AHq5ltZvEqvWJ<{dWo^^nWu+eOopJcF4o~@GAL2GO@#?yMe_{cd zHBzeeQ10l@di5Na%t=zu+NSg$OlMx{g@ia`4@Ff9oVX_l*%^0!ZE>=teAQ7xI?kCf zhSJBXwwFt)5f^Aa-PAO}Zq?vrQ{hB6Q4tu8zCL!b?l(OgJtH5uSQcWxZ~^+5f(`IE zYV*gP5f+Vp*JO&iZ`>t8*fqM;3VjDKuRg1Nz8B~&CB)pzk9S8;%qnc&i@)}Nwm%lE zTC) v7>q6U+Opf2!GT|{Ctr%4#f*W2Uc~xq_VL|?W?o#$eDnt1N zWN~z4JAA`Hc#V{jy>j2VY?MHadhZPk `bUl>wgzG~Ct=w%DUA zZVM<^AMOhM6;x5_Q+ucTQB!L6+A$tep)DiQ)SDoZ(&e3TO2*b(Ev^owH9!s{cCx8% zCREaOnYE4K*(jB30VVR`DKl}kMS>;;iQ(m6aOkdGXm
ek>UsYEq4=i&K7 z<_vZjsR&5E)g?a_C;$g~Gm4or6+N~5P_?Y?2#cI>rU^v^oWHEL#@{|X9nQQ(k44rq zcCk9x#O~)6jT-|GyM7}kfy U?>|&JAJ1|1(*jeDEs4ly1nu+FPV!0E {Shv9X7jD8XJMB4c?><}xSyq;phP!Zpx|Fa3(!tvyFqe1f4 znjti1-UC)}dv1lH5|`R+DmmDucRx|U5VRN{T2>FJh0y@Iw*e~CBX0r^` xv3)uG{pSVPS@N58nJVgTnbt{#o z@YR{UvN{jG)5*#-M0!kvoT5!yR`6un*uAo+yuEhKfKL(z&8}!TFrj`VwGDQYyv@mo zpSGZ)z-reLg%)?CK>U?DOPbC7igGDwkNIgS#7se8wiH=b#}MQDN=?uXMcmqz_tb=} zhgx>bZKFaEl~W))Nbps&Kgx`zszc>Bg*;9mp1eP5u!6a xADM`SmCky(f*;-uzbItlGi0y2Zs( zb#)rRyEgm!BqGcO`P0aLxtxzZKG~o=HM#&F+cVmxp{(e;#$!N0q7>k`Jcn~TpR1|` zPncyS-BdkA0Z+M7)b rj$lT31R$n(+e z+m7`hAdm!!-ou-vNHq{z$`*CgdPuy2LmDX!aU>7VZemG4y?5O=w&N#&%W@zjjt-Il z+=+~*x%U!wq3krQAl3p((^{?Z>!GB%bT~vkN7uX=N@K}hWaUQk%r0_+)w^ZGOU0*3 zXL^B3Siv>evpux7$Uj~x6jW5dGXobTK8!A3Qs4}^;N(+Crs+&1$qrkDoWeZ2I^0sh zLF@vTm&gd|8r#}JK hK-z_+K}^xq4^@_*GE}~g+{>X~ zWCWkHbFvGVrhfBJmC1&Llzjq%&_h7iHM=g3m-Ax#C@=nywAV2dBk?|ktbU{Igam$e z2{njE`*8KYg1V$goZ%S-vR Ssz$BZ;b5PBa3jN!&au^Hgde zaL6S?Q6;dAzh@P7G8qUS^v8Da`rrL}(cW`q7XIEU^G%cklE~;s+co3JX6jx9iOTXH za(X3%mG(&_5*lMe _adrdq{$_;IVE7m zA`vMc@zV%rPPh&SNXW9YP$#WnM~3X|m4Fn6Y)JLsP2I&mk-^9u&`c#K^WEyv)Z(oP z+cE0f3;2#2`=8)13Pu16SN^$81e>P3{8ME*7pariNu~@>4|6`bZw;WSBGwpiXX WMwS+5^$T@4nKaY-M54#kA4`;_fZ0cSf0pIUY0l@SC0K{*h9HFl#EPep9 zXMH@$49L(|Dl4*{F>|cxC{!mm>782><)Grkhpa+J?ApvZ+$CGmsAehBrg`pD_RuS8 zsoK%zA`y6sIE@~YP$DX&IF(OHi6RIjsUkR)L=&IKalo&lS-Zq-s{-ERQaJ|%#OrI+ zJ|E(H$3&}CeK~FIN|i`v9|2F=mME$`UKF3q$rYXajF;gs_zZ 9c0 zIgJWDAP3myRFDdguEjz}W#p2>-ag(7r=xtWF)}?3%&TWaqkVq21Z5Tkr@jhr#w;D+ zPYl+-iC9P!UACq2>z-UxGwsuf91^V_yIJkseC!0IM8`P*!j*?e>l)G1R<@S%%tfY0 zT1B7%sg{hN=^`60F*W!&_>t@uLWy!>^+RS0YY#ta8eqKsNRhJ8XGZII*Y-&BG)>?I zTBEL(K^hqPTX0sc!Sg? &EFtzdvP+#6KCEx2zGa%jw~uYmYl ojHF7VJ|-)(RF|DCiFu+qmQ@+rn(|%gBS+wmkk<~V)A^i zpChklXWGV-gOOcWa)GQlx?#;@uAScs9cw||^=u_pcN&n3nx_hnf-Elgg21(mLVh~4 zHffuzv(~UH*9o3W^#(Vstb~8e3LQ)8p0u(2wMP|8_Egw76pFD8qzqWyg>T@KjC&0c zTlZd1XxjnkyQ@|-uYPDz4~lG{jR$VU^Knb)`G^->SJKRUyg^&}* YqGbT+i%&FDWp0BM?r%-pD%VrjG&_bf?V8$ORX+RT!?SA^*u z{pKzalbik-#Dq&g*Aqb89V8TcM^;QKJ{WF^$xnF_X8%K%XA(b40JR-w&KS+Hg|ZBb zrs=TZiD`?of2@s-3@`_D;hpK`Xo~cpP_-PXW%Nn6Krk!hL4F%@LoPUAzqnqq=a^e9 zs^J~L_6wH--AC^G)iSr$b*|F&ENB;AIA3Lv5(sD1RECZ16~^+^R!v+MJ&nE`d?jQ_ zC{`IssIYIS)H8C*{O$l}Mwg)&PJ})1?uyxP_xj;yY%s }phtShc@ zKRW-3mM=!v2?JJ8*36RlQzJYr!QgtCm7~BNXqTCODKheisnaoye2819qSib@6-G+(`|vU=h{r9c(D+I$X&?t3=t@j zCa_&<@H6TNMRH%$tR@$Q^^M#cW}3i9mhRo!pv7UQ-O!JOcDBvJPjhXCP35qUr4GD= zgNd&vnhDXQBC#c|si{ug+4d#W9T?X>6lji8h4aQ{uTXvyHWP7bhD=YIW|e`)E}pX8 z6&GM$vzprBs8bS0O~^W3`VC^_4BG=$8DjiLOn%n97IFY;*-SAUH?c&JlNgKFaRS_$ z3@t9up~TmxH51AK-;G+n^TYK9W!gINTcpV2DmTox imm&{uxMayWqII+eCOAJ-h;-U!>epF|7c2(}nBdu${bLg^b{N=U{4F{- zSeG|yd}xWy^gEjQc!dW7Z!M-@-*BUNh|;bt`M8qoEsl>vL^ZeJd9}>sh7r<7lFo)@ zjh{Ezx>ZB>hB5J*1C5T0;$N|2xL896W~Eirh_$!zF}2$J0U`3x6+U!mnYx^LI+n9n z9P)?n1BQ@>=WYWHMhV1C*QSG~Z729Q4qM!^`6Tq~m`R4Ip5LN=a `(fbI0(0aF{$o@+r!S`p?VPRg5wY;Za4NWwDPM2 zQ?hM}N2KGkgx%{8ADa=ENB#5hhmr5oWGvG&cWB~+r2I6!$;=pRZ3k^iXs?Dmp}e(G zc@GsXn$(9p ypZF6?KmDezJ1& z4DO%+$xK9mnub_hzn@(HeE?OprTqlM*Le5j1L1x!UU~X7dV+&R=M(?Q&je(hk1H4~ z{*}meQa5Rb_5ocD&pu#&I&^@zYSu*1H;TD@60elNXPY9@#*;4|?B=I@N5^fy3(XP+ z$;yTfsX0*6rrFz&AzY^}*dz^yVX?`C7;TuQ5%+!ZaAfW(PX$|m_=g8T-uI?_;gv*F zyMQdeiy2_CiQjY*@m;eBZ(iRo01{{l008|3lw JIX%#Pmz3$UW5Z_(?syKv=Nd;0Hy Bm9)>ZQa8+z)$dx@i+6IzxBpUUuGjDDCjtm<3F zzc^b{qWj%@NuVOzVm=+AO5^!ekQKtLNOtIZinbgdnvr-mB|2Z| $fL%6p)0W@s}peSZc+36Brafmq>otU{$>Y-);lUjdwXZ&gl6gcxyaNqmF2s7rXPj zJDMyKn!mzX)?9*9&V(pl6l9WSUg%<(4IllTC<+?fLVfT3q^N6qo^|p;B9VUzbrQc+ z(%(C$u3R{!p!_YC*S;Ri=Y~OVD?{icB7$?J08|q!mEX4wV(S?ONy05mb|MSB7OUTa zV(b&fn!sGnJI&{KS1_AZJm@zknz>dasvwu!w~K;lnou}7M(ouI;>$D-dNuZkWm7r5 z&EW9!DB`p{PO?Z#Nz5N&B+eAGctd_`UVCRRyLT*<`mp17A@;4|RnLCN49Zp}{d *_<^#A%T<8XQBs@ mvYDr`%pI_Rl5@P1Pe;Dt~I63}J)dT%n?clK~?C*cFgo0=_K z_aP WYBp$Y>ZF7i@iTDUa_fjoRZ&4oGN=uZQb78&3R#Al4xDxo{{~QTIqs%C!<3& zL#JM{a-Il&nEK_^0H;D8r2=}pyNKO`B{|>Urbz4iKESDNQ9dj`eDPh^M8)UV4JbZU zeV?EC=>awEVhTRv;iC8;BOpuRxt; +dOktPP-(tG7Wil5;dngAf#7P1&hx5a z2w$*rx&KF{3GN|~Vf_E?)*QSWZbf`%fr)*}l`mdDHf{1*Yjm6P-%UX`*uFTN4v+9t z#TKXe)$a%(TZ2CX1jn6LhX7=p0rlP1SDDh}^fmRO=7ccZ3;r>x0Y-JJ_9VT3DT~$G zk;LzPK$oR1Vu;5noc98TYRKL%(~3pYJ}saRtT?qrfu<${@+O(@W0=7E0(MA;W@dGd z3#R^>3%p%T#aQ&_AkuoaC;*$@aJmMQA}!Rd?>IHx-Eh@;U5~*y+U$ivOgB^PG>rGQ zqgU`*mQgcG5>sRy8NNCdb}sJN2Q@MwXZfU+Z?YCwWYaZ};g>g_XR)~D755f%9q>fl zWN69*VAstpkQ$hO4T_>_>aU8Ni{E$Z-*un|GVOO9mUBiZS73Zyb*R~Z#5L<3OTaE7 zA7A}6@`IH|0GkAlzkt4^_OECstU+JtkZPNHo-BJ9z+>49rO5#}F4qk0V{gxBFyB`P z8Cn&5U2lICgxA-XQ*Z;xE|fb#fJ;$Wh{ *YlIZOj-u7Jace1r8we0?W$pS=$So0m(Hqo;*y* zQrI800p0IZs1GHdM6t!WVs7806u*8heIVXVHNU~b)WDEaq9)%&a5^|u>##-9GJNDs z^|sY6r+IbBDU3$$qHJ>gWtZ+%^>YsMzG0MB^|*NE5k9qWuMUK27TEWiw^};M(ke_T zOwBFe0MCY^irQGtAElDL $kl` zmPoEt758ZAYVv^H!Ajv&(V0eRLZPR3agjPSU|ISoYco`jH1MUL7at%dga5)9hsU$Q zDDS)bnFCIyD>v$>sUU@_8@m uN^7gp7O`u#^_>3-Skag z*>d&V!u0zqO);_%Y^t6Gqe5yd!{`z#9)sQTWLc^<{u0UQ1yI6`;kIWOS~u(6<;(e3 z#P%%!N(Rkn_kF;7+YSj%@UCl9CFxtI87Vy-ZvLJ{f!@Jh!~n%ZZT>aHMY{SDn2ZdY z29vp6@>>sGRi`rM@OOiQEKw>oKV;wluIk`&FG1>`s!i9hCfHoM#Ov4Foc))8&WXC8 zyKnRDoAOPd7L!_sdMtF4=cL-L**Z^VLsAJQT4p^?hR4@_WcJH!Pgly43&)fKSkT+j zSz@M_46EO;%a+3wYR5o8M~;UUwhc9|I%tismhs2*scokr% OVU>prB-|D=fXDt)e;CShLHNN2}rw`-BxZq(_W$? zeTU;Cec@`xk05*_NxO(Mr))2F)r-@M(ko9RNn+2dn2{nYtZ^ic?yF~E@;qgJpS-d! znE;67+jlQrh+%IL#WSe@0g2<*iX$BcmyYjo{G_fg3mPZ9PA->=V5v1!%QxZminoEs z$H;B;E@J#>8`I@fCmL(0V!=j_j;&CIYq^A%oDZqa7?JnbGBjYT^zkc({qd$UD;j6q zJ?MC&X3s-qM_XG&Jo?u6qaO u*IRsFX55&I;yBQD3#XrMuTwWFIKok|{7yFaJ5?Xd)J-Lu_RBTA zC?cD@)tG{d9qJkr4ZkmFzV6~3Q$p(Vs0NylB5?8%)H)i*PM-Jy`q_S9Ui`hbhSP1> zJLU_N-*LKRVPdm4Too>pbcKSRXLr9abdnbxynrwM>{$E8j3b}7bdPYUlhM~zN{RvC zF{tzEipf^8n*zV6kR+S<*}`_Xy*K!P3j{o1KZxo5%6_0gHo_lBnM4RSHx=T2A5y*Z zis7Q@nB@{u%nI=ej{!G!5*TAD$(~_;?_87bdv1+4gn{qc!qFPh>n-}9+<`im&~|Ya z4z;-9jgP3;gHo*!q0YkSic}5sXawR6qi(*f6GD)pA&>Ooi?S~yD(mW#)KL~X-eSsC zmlYwnCisP5!E&7ZJ7jL~%h-|df2~6G6|lc~*|>`$ry6HU@{{JK{wed1d3aeG&`|Z! zvOq;+Wo&{IKFZm6 ~sX3o4l2hO`3Gx#FUmqS@Oa1XTmEZ6szJ zPqePtRJa_XVBAqt`QYL^KelA;;yhRbt1Sa_w|zrA5SiQauTXe3*{ym{(iV* dPMR5$e;eU?Xb zY4ITw(T=UkL0%VL#L*pa#6MIpUo{hAKd^HtRB7wsiq<-v3FWWC3zV9yq4;n^fM*az z$+3#kB%zK8p){=eLp|jA5{U^V>jx+HKBF=PGC+Mqu=$qkeCs1`!7rHF3UBuqQYKw2 z8e(~u8fa)=(B80LmAA()o#hFb-eSdH1S4jn7eEZtvYwhwKkasaAM77st?aGzGY}X* zdSc+62|zS6htlB;;-sEjEXsp+S9ZpAS3O05Aa8BFS&rA4!HaKuo`Wi3W<|0BsY@t= zP@GDi{R~|QA$}#x;eGh);C1^N^WQL$Ba3EZCgmkaS;#IT4OCY12T>moy~+2v)!S>o zHg2X=+D=pgofrWa^auIAJwh8;l+)c6ieKWo!bAUpnqhFTkA->@-a?N|to-w)ZmL_q zuf5{CBGo*=Zd03udN0=ysSO<3lx<6isJ*w {clmUfjWwF&jfCssJ(411)inhNTH?xc#Ui1{>aO~qxc`vroRka%IOI+EZz&^nsn zn~nR<1)GTlXp6eIHZIRO?LU$n_{{e+3g@!wH!)zfE()!?w~1o?@Yh@6=?1hhE#{mB z#6CH|RX08|dJssX-E;~*`r@X`4CSc9A2HMMf0e|aibz$^E?n5@Dk*08{?#&%T}e-W z$ALW0mRG3Gb@sb`QWwlZk4NC=3Fg==l*?G7rYwe5x)0Qxw#d0`ef5)_9!{jx^7yIa zou702uKWnvvX%CdWuHAl9Y;S*_ IqF+My}cIQ7d*hY1s$dms+E|m2PgFXKuw#`OS4pH z5- M`q=3rWUqhEWwiOqo zvB1iCb *>R(WvHRvAu^IVYLXZw_m=H8KQOKCn9uA1EH4cuMIH*mbO z`u6=hm^?as!bMzI?Dr^j(wYZCz@%Bt0L_h_C2XzQR3VIWFO&j-$v$!_HI=shdnws5 z_IYWsS0RGGDhu*{rzf$tew_HJ6sdiC*Z+^(Xuh`~7l-0N+8rOjf3ZQqzul}Y!MQ^Z zP%GA4TUw2xXmCCDj7&6=A|D)-YXa!z#=$8~6rJEKCQ=bzf%kE{=-0?Ivs=aD4I2&{ zY7)m#XwD#{M 4EL*3JvET>}aeZ z9`vIKnHo8TAJtLpLx9%*?K`)~4GE7~ho}oVdC3b?^B>(x)V9z~fr_F_`u0P8kzajC zCb7LOf73chLrc?KJDvBl+H=LS2aglY=+bow3_qwsvL7NudK|#c2cc4ER7eLoxGRVq zYscBZJdLyPk*%e2S}04L t@EVj z!5$?**UAebZQYj^g$$*%CMo`z=3euFM4i?}Yn4pXW%mYeR#wE+zn(@49o#sS ih8=HWI!s z*AYM^;@~e=6bmwbS$@kk>o6zVd t^0;!$dRtSp&*t>Vrr<^jM=DpHiqx!l`*M^zTeJ4{nh+p0pQXEk|@u(i U^KDWA#BK_Ydl~6E` ^)|AE1X zbW6qJ217 z`C!_&LAnOf11 YeUNdJ7vlTf4U}hG@GKcu}ihK$T;d*k^?h88!W?Kn8*C5e0{ z-NdgbTy2JbtLd9Ftr@#UDhz6_hYF=U+5_?RtdsW7MdP$Su#%i>T2<#KtI$d(Uz;2Q zFL8uyy|)$busSQ%lxA^(y1;3FlqK1^6nc(^)W^k1uy=x&dx+B0w~cPHlvr~(u>K{(PZHabsfvlFcO_3-C;jpO`xT#CynmWXJcT|jD%Qj0C zr}*TI)1P-Bl-lE7D2zJHd1UzLIHME1RY4GFDfoT#4_w+OV-J3jKu`vgO|WoxZI#L0 zy)ya;*VlN#CxubLj32$w%4-TOIP9 BYFAvLZ!wb)GuP$(Q-@dv3N%c3B>c&~@E)AQ8`mxJKZTVW9L? z`2hwkQ3C%FebXb*X3X2PWRndk?CQ`DLKSPwH7u9E+&aq%--Ac>GV2EbfJhR^D3REQ z3s33``cIZ@`5(QZuTw|s=p_ZYaWMcY>{yVmV|yKKUK>EwqvXMPweSYG^`@I`T78O; z3AGrXfC@c0!nM`n7R>wa+XdpP>LAU#AqaZ9aTcqu5TZToc!^{p9D8shY*Yp5IvUOb zi9~KJ!T`+T(82Ki_>n;oD^H?Y)g=scX)Gb=5={m@4yU9w`~;QyY7?iQ!;G_OKM0oI zv#6g@vf@$UbD(S2+wud5RLDgddG4xUouT?`7F$+atJ!2Db >@C<6k= zrMkz!adUg2t(}Sv9Yk`ph4zqYV@KD&Av*PH-i|Up2b2;S#Bt-N{~ptrrG(|LQ+bdq z6vZRCgbAKxM!L{1i M|Nw1UP>4v*I&d7g~2G4?} z^i}e6m5{OGjdmj*& |EEbT^>{@gt6q^rvMG#PvXsA4$1*vUx%qvi`*VD|p%& zyBC$I!rvbFyT0sM#Yw4YwLQvZ n+Va%?16z%6R^=kDa&I7mXsXt+$^|6t@qGawB{^Hy*k4TQX3ycC0pXH}W z_0i0RDw>mxojdP`h)rH`n%s0|Wmj_swa@L~l8IJ(w`is!okUr aP>|oq!pmlMGc!T%rfHMXWE1V;i?oP2 +Fr$XUGTuN*s8dza!_<1xCADWK_@6cOx^EMhKBxPAULc8Us7=dYPi6k_fW}*X zS#U)xa={V8z+by&23?1>wBJgJ^ryJ3OK#X!YnK}+1dS(nno%=qY>nOd3K&uDBs-Nu zW9~Hx?^d>Skef+#>}i-{f}a!@lSStcFKDT|8%x*h&5${f(cR&hTyl;|KBU71CKCup z=_7zmWp2G7LV%LLl@jP{>=U!JpOFEHoGuq4k2dWTF1JwEkyU56N-CsL-U1VCZLpM_ z2K+AuRYQE#5#9Z-9<%~am3^oL#&z(#QZhmVbe~dF>t{O tV^Iskbm99^MQ$L(0 QJu-OaBEH$R5q6y!Rv)gQjZdF!pQwZr; z@~VI^e2;R0M&X|-bUW%-nBTeObj@}_?idL1wzG{iYR6hZkM%3yrUeNTO!MfS1Uhf? zWkJWZIM% r1CQ)@mZ?#v8kV=Ig^51x|)c= 0SOb75xgjx56ALV!XsT z$}>O#2MPyMhPqt+4*=62hedyxYK}d)Q+`qte9+vG=K>El+tmBF(~*tg5w+O&85~4~ z >rm3Z22w zP8{Sh6X{-NZACmGL_5wVO(0&HB!M~1SQg jsHm3u17h&kJK0} z?BE2As4YBJ&Ge>4XU>Rx^y{*z(jI*jCc;lFOQF^ULmK4N%fK_&K|8l=4zZ|uSQEvq z8QT_|$UvXB)t_rz*pDx^^x`Xg@Z-S_M07ach3%v$6mH#G1(@@V+EIF`cQmSG{yCKI zt4PVKeG9*&r-FN%g!@ aSFWT2IOx4MDApc3Fc4zh13$rWkrM+VSDTY2cSEpRZGOaj1BJ;}}@x9ddFt?IY zh^-i40CBa3W8%V&=yY=lr8u3H1IS4iFh$);`yau{c$fe!hJO)~0sw;_x8axF#cniG zE%~JRMGKTj53|~(W8 dTn+N^e~~vQPNJX4gm2I{V{GOaR4{g z9oFV)u%@d)H2AE6+h-%|3%t{yvr=dh4*7|t#fP5Wa2}NorjhVxq0L`Q?eU+#yosSu z%yaMGTXMtcG+0h55Y2>{YlP0p3BCr1@?7@3-8pMm+JVHdKY|n@$wUp>+WIGJxRHX@ zWVP2EX}q(eV#_36lkeVQgf*I3T79c$hWWB3!FRTftiP|wQ-d#P9Ap)tz#nr{o1^WL z?)jZAuUykIPc)fs8u2SlBKA1+qwjTPj$*S*OIr*&fM6sMOFEyJ=c8m4x*{V14Tatm z)Q=iRf0L|L1TdiV_Ek 9b+jGksLIyZ zMknez9!9JpXw~!p7kHF-?Z l)X=!4p7;0Tk^$^#B zNWlF6tRZgR6F;8fkOSaB9<)3qpwmq{LGpuUM1`bQfdnXuJwitb*M0l~oH_YXyA#$| zVuY)KowZtNCS$*^UI7eGR`4rXhe}Ou{-%v7p;=sM$MxYSnw?;sCuy>);G^gre!eX} z93t1FYTb+TB$2ivWzK9W@blPSBHT1VTIpYzX)X&1Vz9QUM8`FqYY7@k^ zwyvLH%>AOf$ME2)ni2y20Sc!vIT1CNtv-CqHGe_E*^ney95QNpL?M_g=ylyJF=?V7 zx_DF!h8;I!aLGxNec;ATX2O~H@FCcG+@kQ)F$zL(aA|`GR$-E*+dg1(@H^1M zyklUDfm|5hy?WgxNA1 COh&}RrQmtDF{8a@WMfy7yQ$N*im{Sj;o^%R(=1y%n3JOWGHkR% zc*%&CCI=E ?d` zz@ZgttLgTX%7}{6z~T5d{LO%+j}Z3Ps|m^Ba>6-Z?ZXYy#7uqAmD(1qY|LYwdW!}7 zc*GrdBY)aX%YZpl?Udrm(?%ah#l5u*l7*_G8WnG+H?g=Yil-y=r}*l@myDm!#ut@I zdX;Zf9@4tl$F8C@GF<(LkHECL04`fxhW2>$G4|we7TPjgHL9bNE7Z)dKBEV6Z`Uu! zA_;#@!m*Asa(uw3Z41k9N(!*IIsJ9bf>ld4J;Bca^U;MhVDiH90_>L^fTEZ+8l%jW z;q+Zx?~H$2I7#f-@mkQ+H@Ux2O=?_p@#|U;Z#gk=P(1bGXgXxX=5jgAd=0WZfZtjp ziJRwsFo^pLJP~nOgB8~WXD397jr`1?N>lMMApms35xAVo$cl^ks`sY>$ YtyH23R$T+Lb&{U4AWYUmBTSlD{HRamyBG9uSW*Plwwe-w+`<+LF~Wb+;EOTTt{s zq(X|M5|yEZe=^~iuf*FKBdkGU*Sw*qnE3&o?vBq0FZQsUT8P`t1l7v~Dh@6<&-{Q= zo}@U#(TI1z60${2H9cNon|GM_vlTxDU0U31WdBd%6)%u+_Ww3c{r?)LEddRNx6xoQ zY<0e^FFJoNy>1^g>ln$we85Az<9$72fT;Ypv=)4u#7i Sx*kPCXXy(fVxH=yJ-q%s@b-zS2eCUugf4gLxhVoHX7eD(};M3@@elOvEVGoxu zAEQQs)WeE5N;P$f^&%*`a+tbgd!|6x_XR0mHk4mSRP&OkOk!f3vrNnp%Q0PIgKu;H zqDHZj?w5;7K!u`$+OFi7z-$|{aqEiqQ_mbJHlab!{>f+0huI|aEaVL`hS0W&$O-Vq zgUYD+5xyL)=HUGmeT1;1q_ML8T)~XEE8xuT ar%D&=W!ufc--0 zxOx~S_WTWi`)O;Lu#>{=St*+sY6P8PHx4*a^|tYFxIV#y+TLGN$ol6Gd&3x5;;SG4 zz@01ZIt{Y>R`6-tuR0TkxnGlmqU$~`W6-{g +|2g1-ovju**ezKHV~yC9R}ibV}jyy|Qxv`iI|KziB5 zRlH6`5t%i->Zt`D#>xD#qU`Ej>r1?D^}5>|8R^=8i&%_nb{D$7G90yb6S3x6*!t{N zUtLXWQG)5&2VE-cHKTSv7ZbhMlreu|l_icVU@sESMiEGM+ {W z?qU2w4&j6h#xdfk9$0?}BpF(*^>#P$5}doKsg4@E7{z)XK5UIlSy?@PBq~txLmNON zw+<$LpS!fx<5NaWl^LtsdWMt4Kh-n<7&<@sek`#d`O;q@cqsh3 sLkCnoXQPfbd5azWt-mmrg}sr-|K}uH@-H@z0ZbjP`9W1n5eCk7fX;m zz9+)M7a$I=1z;8W8YC_KOpd0FX1;FO8(6s6YbvzXjsCn8d8SQqF9aU9j6$Yzx*x8C znTA{LXbE5|cH|F65SVRW&j|rR2|L`O%X|ESA M9FMD7VQ7^t6{`3ZEQm$I7Uy!`?LZcU-RV07bR}XF?eYWJZ8D#Y *RYNUEI6;%7l`pEo T{B}mXA*cvZ10VXb4<-~k zO{2I_Axj{|s;_Qr2UcDnt5Zqeo5cU$0002K z2xLP3@1unJAI)q1pf<0i@t Clwx`Yk2zkkVE)~C|w4gYka zcxdwsK# @vJc$14G)mCnu|l+too(PvNoY0rCXlz1q5O1x991 zdABET`N!j+Mt>sR6AZCD)i8t1?Kn3ZU?LsTC&w88qUnC5xU8gfaN$^D88+bEw9j!A zYr>UkrRS -*+Odv}qDs+_ zL-d02i|!XI168 xqm$2fSxdAurUW}%`O5rBg3Pxq&8maTX+XXA;p9ssx-9C$X7>3P;}gElC#Qf z?jeDU46sL$9$U-tTdY)TOn;p8m^+Rx(BiwA{mXa+?i9$R{C{s=%?Gu4|6E3Vc6t!d zBx{3wLZmHbhDuL!-qj-OOt3o$%_+c*XAkCZoqWG3$#T?Jae4{agB2L%vjCw37`xdJ zRm7SLd?Oa#4Ptuwv{h8SehuCCm-qRQA}nkE#eINku!MeflW2!CHpB0rY@N>E2S?sa zyedoJ(N|gI{I<7QNuPYC?eUB)_2mFb$33C%SoF)Gl!tjgvpcmkT2-i<(z)VD!_FJ^ zvu9!o@GEhtPVsV4rR=X1X72W$E`z^r{b7x?xfZ?U_=M$c%W{<0{@jLXG(Kb?s!P7V zcw3!~OF6?QGP-V?)|Gf`Qe9Z{za!xvFRGJ*wDoX;y6`Sc7W@k?k#8UONaq#$HcdO$ zaZVroV6kPo*wEgkXcr*|3)S}K>OD)hN3{rYsss +G?nO9$QZ>qJh(VnbqgNb>}}6|VvBUsr4oT* zO508oj&+p;vgC^Ym|3~H;QYil>aIqS;+B+!ty7?5@3DwGF>Oy!FBW$t89n?2e}@P_ z`}JzTJv7Gy{N#@^V%WI?66%t;ec!BM@E$8j1O!8NPjSCv>VF0;-<8S;^vv2?I4#j8 zMtPR%A1@OMe%yXUm2w0J2d+6@w@A}~A~oK3l&qr;=(7!&=kN>hw%K#@{T}03zjTTF z3BCNbIZCt}m|e3u&!g#Y$dh!UL+3~6s7RiNr63y|D;wdQg3Xa5Pj`5g1T@%@I7-*> z1JN32J$`8dVrEaD<
Xp6&@qHLEZZ1kVeH{I6(;e z+-yt_(yvJs`Ft^27AwehbX>$`u 9rK{Nubhg=r-P25)M%&pNBkRZ z)#2*8`6nzjaQw$enLr*>g7sm3xP1~O>3h_<8-K}xywwnNn}%a(4>H7U%d>Zx#tyUp zeY5!fgZD&TBm>g=$~NHyHGS)iAO{BKoQYFP9mEIyH`F?)yRVpwJ5Yx~%CU*KmJPLm zneFX-%Sn>m(UYc(TZ5gqG7EMzJymecyP6Cns=3}jl9~z*9h*Lyx$Pua)pjFi17_=w z>kv+^cw;D~vY~VL5_T?(a}CM*RD}}G%_Mu`Q@KH}F9PUHpA(mJ;&_A}005YaK&JG6 z 0M1!_1O%oZv$a;eKa56cVah;f$^kHE=)`T3*nl50;>zBnIVLb0sq^=%JP z_R6Nq C1Tf3O=74xGvJI(Vr z3Gi0a)Xb2t@+xmG_>uyq6!4K^CTGai2=hu34y O4|rd`L(px9^5 zOqvR#+9AGwKf;yOGqvu-jOvHhDxU#grh(!gMyRCl=Z!e;s?>?y4SS)TTCzRph}sYT zwtp8H^cg&j5|T5|u^mgbx0o?kgY=B&XX_ERy;f(y7BmhYzX=`kPpAAd#I2-SP%8XP zy)*_frg2t(C~M;*TCcrC3DQ!cD;~TxN;c%0f3Td`5}?D1cNx_Nw-?^a;rR#d;mi9L zc{!H&jqeW|k+HYe-}Cm(X$AwoiyP9VE@V2wW?}B*Q12a#&nd|U!-D9YQ%)c!fNB_+ ziPyYT!1W9=f)QdD?e?*GAU;*Gve?1RJ8+j?U?=08Ffe5i-BkpDHS~jo`?0gCJ3(2M zD3q9?4p%St!8E54W|V;z-3$y)55t#Eu%})gFHs)?;w(Wk`hG9Ag?fCZP%W(^Zx9dp z6`&fb7Mn^!&P#^l39x5}B)Y)%mtu#%D-gwq^Z9m~c=C~t;IWkYl^Rfw)A0#NWXoKa zmI do;cSb6oP6PV8$GDRCkr1!@e zYRci3jng>Rfh>ef06(r oGPsB;jm~;LNS;|W))RYB`w4S zAgQWqyOGt|-x&{lnl|LzNGVf5@P^uheJq?EAqz0iD)y3BEwl&CT;XJSMNun;<6h0? z&%NE({e02oQyz7T7F{X?i-cpV49^H0<+2^VIIY9?DiB+vCc>;PYu274iwkaj-V%30 zCaAC4G5Re?r#LLb&34)m+ZR_O?G66W?7j+cQc-em`cGb2N7peg`*tZ5SY`evg2&~* z=1dzG#y0Z=b{&{!lY60Qk(1Z;d&G+hnJ8|`m_Rx`hp*I+mdem9xo;UleqLUC>pm8~ zU-Wh(v2JRk3k+`ZkJvDlc$uV73U~=Lq-6HTP!8~t s`tF!{*AQzu7umb25P`I3iWo{v1v+}teyJvu*@af%V z=v6d}`=Hv*wx6*cXT_UQ`+%zF`nHbR+6dh=nv3~x8q^BfcAW}p?3&2L+2B+Lo|Ibc zsBh8`?~6iKDJ>at#j-u;pdNqpr1@G^OS-Qgg($|Kb|XY?Wp%v{_tPh3vMljUM_MC? z_*1D8#h3OV$v2C6)+wkgnD7cPy{?>@I Ro<&( zF))K+<0(U18fng_sHc9HtZoT|&ljP)Qv-0ZlL)ssbB@0L1IG}+T&sUpHNa$tYj(&q zQRiXUSLAN!#nYf 9f H5?c8?fjUfp;&P5Nwjf^}js!MpEKY zL}D`N^Vj(+XjK`jAiV~jf(ga|Db>C>{d9%mF^K1`_735c#o0=u?MZon)G=zhJinQ} z@~!q$DQuUS0iBpUJT3&2w4S%7vJlLg<)Ddc21n4eUpAZ2b|O6l6Yu$~#YTmV9xudy z3I_dL6kkHQ0#d^& lxj+0)%_%8K)3dDKkT>>R1w8>RO=_6tmFY0%zz>Sn zC8~jyx=YgOZ5UTLMJLz2!lLps!*3A4Oo7UO;tQ^Af6x3Ew=AI6Zg<|53oIAt4 bY*4e@WrnHQ9Fp4>BjfzpfCnR%}1?7uYzhY5Ndm*^& bX(hduuC|4Pd9D&;$Jv@uvPA!=|FK6u4X!=j6s;K$N58UG$`2tvVcayz`| z`<*j;B@8Siy!JA27mqWpIJ1i{To0SkKkw-JL??L6VZ^|3-1yHY7MDEQ@9s3CHwo>I zHHUgC6R^A2JoJh1xUM@<3fXTSyM?xi;4aNjyJ+~VD-*of|E)?^__>rGc~3Q}tX510 zM6&1qwY}L>jZXUc-9aKHaDgVx>R=_?aEEa4f;=5z#JmEx?C%XLS+n)6#A8|=fz*zs zi~H>&vhrGpvqOp_a}N1u{E9zSuk{C?IewV!Fo*9+ai$)ln6%!*;Z>6!fw_E_m 3boi)8!((< j zaMu!}^Q7q!-H?VStJjo?Z5Q901?q{da*2gn l2`CkV7zX&MqW2i00)N43Ttb$uhcA*Pb6!)+q@nnd3ENHW#^)=H4 z%!S`=!ioC3oZD36$#5CKr?p0Fqz`5`wd9g=r`}=*7A?=%iI?GUk~qszG2r(L2 !K=>s{8edPxAMm22PmGI7i@QATsLykj?(j4kEMPo;081V$vCg zmYHq}9XM?aQimcQU=4)XCCPl|zPldnVpCP;0TQhP=P499%pgsOIoYm3pl*k}BBAvD z_P$a$h30Fui6X7*FA6Pk;X@Z~Ryp^1W;f?%&eAf@nx?T)V$a?p_P+>7)nt >Lp9{!&8URjY&&I1OP3+~ )t{%G%?E--x_3Y0u}6)|dw*f5!T6eXe6_tpmvK%hCMvXLY%@_FYsdotj}5 z6%3DSXHLxI%kJI=ai_lyfRoir>C9^ xWEWn~Y;25 nAy*qdunrFI&@76*KMdgP)R; z?WDCwJDo)mMHcM&Ni5@vx yewG4`WIp>}Ng<8HaZ=c@=tR&@h%lcS@M<|OcbFm8C| >(_Fbj*pZT$=}MQvc IJ)xh>44()MeY@a*E{69?k^(!mikPhm{@Gv`$J4 zo`9VRz5UMXN-i)SFEhUF!;WlR%ACfiJu#wkJ BQt_j32X)NNC`WMgPjxO=tmqp)XgmeD*Kw6vlz>ApW9d1q;!NJ&UL#yC kGxs|^3BL0jot3xaAw8B=Gso_TpO)#2A zt!pIrQT5HZjkRdCTM6N6)m(cS3-$-NlZIB+2z=btMfp27g=C7+fW%0c$a%ntedo|B zsW3{~lgHqEnwvA;e7%i#^<^q30uL*Uu;`qRt^aH-yxj0sk!(6i28{#2Sp5_7ra}JJ z=~`PClSFo;cC6X|q?Vb&{n9prX$5q_zw_KHIt?@m{0vx<`4@zIVR4uR|I1aTi2Hhg zL@IvPj7TtpbQX%2*vd=H{sP8wYtr4?7JPXw!MDa6^c!ugr ?_xqWnhlUps^R}y*43CVks!{7ut{F?-W|^)O z^2J#Exv{B_YaU7OxwH^EkEMKEg8WEzkj(K*jPxW#kMf3!Oz_CMznt=M)oC9$vZW}F z$4}AA1ob{j{zz;BuSSkbSKk5q#u4ZjvoDS~^JgZX&2QSrl~e!*Pp^a*(DlQY+OF U4ICQCN`glyx+fwUmZh6oDHrPE?=$jJ(g_y` )gfit|vpO%d% z+^M!DYK7ZSi)s6F>um^CYDP)>-hid8py% fg0A zh6_`^Mo&Vo;-iW3VE|S{LeU*v-0E<3=tQ=o=src&9x1>ww3QqF4_166waX{OqoGiz z)*C9=8`{?nP(gv*{*&K86TnsYbxv~&QDi^{vsRJ~S?Ik)U%7p}cH=-lIPy #|yTBEq^_N-p5^3s(n~-t0?6p5SyI&-SG6^48GZFbT+vb&f(~a;FUAD^r z Bso`Ml1RklI@#d}yy$WALw9cKJ zxaS*b!Nw$^gEexq&@{h~;L4TY0lfc*xpxc`>`A_a%eHOXw(Tz4W>;00ZM(W`+vu`w z+qS(m^PBx^p4pv!KfT}Yz0b+Wh!c4uGvg@bv5OSFb5Z>f-FQ_{8Mt$iWMoh`R%sx| z#Ll(pcju-j3r`PJaW1S6cdw^OP2$hxl_nRIPCRN-1%H9t4DdJRU Se=D+rFl+p&zJ0nd8U-l zC5N=hxSOZdY+$ZpYs8K@5l1WrbI#SQ+a}swDw6Q}Xi)|o5O=PWy^R_USm-YFbVAbo z&Ar*b-5_gc {|K4D}hW>M}U}Ltjp9IeocqTDP z4*venTtxA?M8Zdogm}1k*AO)m$dSQ7?FEjT8Kd^Oba(69FuPmeq~2;8%4;|J>inbZ z$ovTmhtSkxd<8@|o3!yz7mWmXE+>59h+u;2v5UHkV>C4aU8;Dz05Z{a!&?zkd-Jp5 zB4~b*HEOzeh(~s14C%uSUc>vms>2gzVRRK(2o$zFvxp)lb2S>QALjn63;Q_W7*=5o zU^txcpC`P&k+?wFv$K92;EO aXfnTST~YiXxqMv?S(NXYC1r#Pth zO(Zs+v}8x9W8B1MzxqXEa-O}n8i^CV^oH~>O;~rr8c(?nOt4jXL_ndEqK@M~Lza$K zS4F~$DNZ!drqIK(LjLq EF#}PS zTLisl@R(Zu+P>?Cu-LoL#?4%0hxpQ)cOh)mjKm5Gr`gp)4_z4$ItdjB2^~kUMa3#e z#~u@50Ox9iI&;Shhn=g`4~C|4(_$zWV%epB>-t>=7VCcY8qKrXH-mRXx>eF%LrcW5 zE6pjRZ$DcwC^wL{RTefAKaJT=MV^I6d6LFp5+IR76d*RO+%^Yw)bfdOIvY}xwkv>q z*LMfk9{e+f;>obzQFS1A+cM;ji@JKIM5F$I ^g*DMPDg^EHN&jSdi?j+Vo&F{MY{$?pPy56dLX%2Z@ zz<#33bQdA0`5;uM+pY!K 9Y1@2IT@hcVrh z 5Bpdz^4t1BD-!rFv&d=x}pP9_()G(JQ*6$+f6+ge>w6=G3HrW7ns=R;jC+0kTx zwd-PDGlBQ4WbjOA6q aVLhmH<_ zCIY3iH-}2xnj)u5mH|1SMYNt4AI}Af7!ca#FVwomQPwU)*G=Zc!EjFt`1jVWeR;kW z@8geKk?V}M@IQ#)#wv(4c6J*1V;G7MO0X7EVwI%i9{N1)WC(%gn$yH>oQX?opo*=o z?z)??N=1W5*o4X@zt20g0%k}3txlle+rBn7lf`yg;#p)=ek{T$M1(Yu+0!{YymtF3 z8Tf|OXw6QxI=Knrzng5eDB?{N7z5Q%hvfg_sb1=gJ1N-~956x!oc>lnd@>%m9lZ@4 zH^Wr~tNR@1y*pjVlerjaP15D^=nw+(1vtpOnKh>QzzjNGZ|Sa@ect9KbvPtIsjk3} z>dX%by_1l6*>s|nv-ihG)Ug`*zM2DrqBD><)T|vL==gCsMF65%+>?{nu`sZP!|Kdg zP%-;i{&lmFM>^mQvb|~aN^|B}_D!xknOSuyoUWjekm7y!+AK@D(yRrlym%@9<);Ls zYX~X320oty9C~keJ%N4@qk8QYU5~o%mZmD*V3?;u^=V{BS51cyLKXR-%o3I 460nWwp%9@p+^HhDTVQVUnj_%9LZ zK>?bL-cn&Hv~Qbdlu0K7rc{uim6VYEYJB-Yu&v~IVu|6S(p4el>gg$&wk(C{JxN}y z0rGVY54?wH_+C~Jw}hyGrh{IjdGfCkWZr|s0tr)EZz}ahMY76cGhKYB_K%l+rdo4q z2t5AyLl?vAv1lP4K568of%a1QL+Fje*_cE-unxlOKpSk;dkmqz45cR@(6i`__qd64 zmYTA#V;4{l@udIx0gG3T%8y&psoH~2;_ezGzytzJ5fiJ1*B;~Ba~QN5s$M24%3^%Z zGEnNoq1O}v1XZ|=Fm%?ltR9)m1P|B{hVNSZ+z${gba+q(iF11QiZ^M5#1|v0AtZMe zn;MY?AL?@FCDaneX)Jtn3UXYqQMBL0Huex(hnJ77+}NJ@qZG-ho$a?I((Mq};~_(c zpfkFU^u5A!_;KDl$~Nb&&DIfxPq4)r=Gcu}ghVxdr&}S*Zpvwq1#mv5*VTw1x86Ps z^r!Jmhrjc2DvM7Qo`_5(AH=Fc*mVw`aAN%K2PRj96QhT-#v8>oPyum{% mMaT@iu`bl$%e+|P`}?)_4Df$o$aOt0(u!}(oEkd27w?!|H} z($SxvCCa7niX@680Y~wNUdjd13ztJl9G-;0WRUOTGK{9rdEH_Jp{bMcGEbo4uhvTr zYgxAQ%jZI?_}zQn!Hs71fSXbf%{N8=%#H{7Jcm--{dmPWGV`8)&S$g;A! iAn&5L`ppyTU)jEby#iGN$n$`mx9X)m*A$uVR`nqIXO?nwaL`YrkNs)mx5+JSK zyT;wu5Q)8{{($p}hO%0rq>Rmya_gMp_7e^97LfaUdrd(wJ?5|7arD27A0lL@0pYL* z3HTSy&8RSKV1 Qg?+c }=&JKH0!C!*@ftwREza>D`&{WUkI8zJ~Ko%=!*MGG{Lob+0>+ z$*+(2w0LcLE cXKy>#@SiBi z`Zer~US)0T5HI*M{wK?&`E<0oMQf1pnFeD%_u>8dUMpIi7}}?ml)Q>p 4g)mc|9 zuJO~|`5K!y1;_nbfKCTkC&j{9goPI (O7^Dg{sj674bN(l5;9vxSR$_dJvSmRiU40#)cw8H3RsYg^5iCwcvPUj#sxrm zxR2y s zs*)o>d{@xVq4h;{8cvMbN&79*3zj4eAr4W6^T74eiF9!Mkiw^ErmF aJCcCOml6A6oCWDwtiWZVeJ4^D!c7hvp}AV1z)%DyO6MkAI#9 -%dK#ATiN{W(c)eVm>E-Cj( 6xcE8$gH%U z?hBeO#(^0u_l1`KC`_g$!V9UmrvaH(p?Ez6eQ5II04_6wgp{eE)0xyywC0%Hl^z-> zkQ4s+l2Bf)j&c3p9<@bxTv%F8Ro*sk8Bm5N_oHj2*-$h#H3kIKx1u|fEsI-u(2*{vIV`)h5 zu#HUm<_eULxBn)U_)5}KJC(lPop?IoWaNB2{N*;{r7b@nC%8zF)Dbd(8$ew*_C*;* z>MJTfb;UfC>v2#r?#X~$e6S^T?DD{ytlOU8eybac(QFy?^)v@&f_0mzS)tWP60B*r zf?-6V1dP9K9%eoFy#KvqG2bc5CZ*e$lCQep(!!A(Z|MXuv&%Nt9y;yx{546aw<-xg zfS4h#kp!e=EC2|w%{$I?PYD?w+h>_&r0s_-+|;j?w@j~H)bE_~B$!_RUvmn4b%)f5 zo{n`=)`F=uCxfkTs#Rj*Fp;mlNyJ-0%kPxbilqe@D*lz{Jk)_eoVsah=;J{QrbB5c z5ozols-6 jv89x?6KSvcj$kIG>b247;p!)MS3!3TrS !|mAQ(p z0|u^)RO21V2~3(+ztTf)MJ^jwY4b*oVYDj~Yg@c1uCqPN74}YQ3{?D8=tm(VHDSLp z>5zkl8B$nq^w4)H9F@MJA(~a@3_%XdqDn&<+!$Rg ;+Gx(ri4+H<&a(6hrX8(slTzld)CzOmD(&bA5sY8oZTT3GZ5rx& z2H8w`pw*b!J!#f?1G+ZIXX2)gBw4kh$0i%=Vfso(gzt SOc~Ab01*8u1nQ`%fVysJ%BS??Qm1gsn`*In5lq}P zunXQ=y%9ig3eY$Sm4YsUtT-Bvb{BXdq}XyQPLc_FXjPp_0gVmdeqQ8NTqKy{ 3dfTK6a#-y2YKepF45z~c9G46qv%1P>)$Qski?te7mdfsZ+fU}v_(aAMjxO631j zn9F+QklSrdc&pRwQt;eMZPgQbb3ZbD1~zws!gY;qi@qeR&`-nWE2~ck$j^3W^bCsB zPO&?peD9!kI%dKO7gHCngLEX7X5cqg401-0)l=>Wym!wbU1o5F2x2}A@f51J`&!gm zUH~+B$9be=Mv`ULxV`W?=Ovh{a)VSgs-J>^_=;pCUJ|HJd^tfx^&K*7HCS1)(J8F? zBi_@x#`ZKtnGELP@wg#E gj6Fe86;BE#UEtYF*srWsiGm^$1%0(Uc&5O}Sdk=$L z>SIHgZCK72JNK49QG_S%FZx0g4dEi#hmIaxtwD)LU{T|y8qt(7Y(3CgAx_~icDPB? zzQS73;-ZYd;~Ky>Mj#dUzTS6z(HFA4lgdt-x?87IyB(}>nlK2GI|AFWf>;jMRd3W! z{78wI(n+($idb PIwsp)g+JOvU|zOuYiY*4+o%F)hN0^%)fOaZ}9rjr?3&JJk?) z%>9TGJ6Lyt;<3OQES? VQ+QCG(>i%%fkc;QsYJl2z3^|!ew$&s}51kO2 zt*myAn30UIougKPo(Qi@OEU&Hk6BEkpMJK67;nHUn=)^3fF-lg8#+Rx(5)_KkCrqp zkSq%SipMIf)Y?dxs}`lTXM^s=^womk>(c) 7zWr`L%W@Z3=OlZs6vDWGr-ivsn*oM(He9SGWHr-bohFBg32v@g-oblL; zz9-rS&AWDYI@is6cUo`ta$1WtwOLDOQs9GPqXnP~XL$zU15G7mo@4j}w33X9KeSn; z+7Kfe_%D4 LI=o3}9gT+SGiSIrOSZLv+y^72W95`zua6H>!<- KHF zPFxQ0)JFLz6j}=%zsJ_ro{}3f0bF6lZU5?E9z_d8mWAy}Tekll&7Uq46=-20Ul3j+ z6*zUo5a!HaCjlxpd&gyF&-Dl}F`A1-mh(AOalqn6iF`KGPM!Q`kL2;W>gh;(i@pN} z 167_%08aS(kUN1ITEs2nQag7HO4_dycbad} zMjFBF8Bs)@Z)tcM$yuzaq6c^>^Ufn99uJ@h)ZFF%7;mC0Icz6WrWPYtK;pRoC@Z1O zXLxNt#q!8nB1TWwh5bCok~Jo|DDKQBqJ(oH{{z=f_{xjv^5HC4s1%O)hIaQsdWQM7 zCIgoP1ooqTt ){ZI(rOyjr2d-%Je=1_OfCO9)Lr$qH@39 zhGCHCR1TyU;I2L>Op)|5KWQW`)XCOUma9Z{UvKg5uW$(vh{JndwYws@RMtV)Q}*-8 z3vaRcOxOtO=NjxYn*hP5jXrpWNhLStmW8$ZrcKy6?5d_!nEBjsmQ;QjV)r#Vf?u9i zpv4U)VZ*uKXiQhR &|w<2f~c