diff --git a/CodenameOne/src/com/codename1/ui/Dialog.java b/CodenameOne/src/com/codename1/ui/Dialog.java index 4b44a101743..441783ea8ff 100644 --- a/CodenameOne/src/com/codename1/ui/Dialog.java +++ b/CodenameOne/src/com/codename1/ui/Dialog.java @@ -973,6 +973,54 @@ public void removeComponent(Component cmp) { dialogContentPane.removeComponent(cmp); } + /// Refreshing the theme reinstalls the menu bar, and installing it moves the form's title + /// component into the title area. A dialog's title component is its own title label, and a + /// dialog keeps the form title area hidden -- so without putting the label back where it + /// belongs, refreshing the theme loses the dialog's title altogether, along with the space + /// it occupied in the centered-title layout. + /// + /// {@inheritDoc} + @Override + public void refreshTheme(boolean merge) { + super.refreshTheme(merge); + restoreDisplacedTitle(); + } + + /// Puts the title label back where this dialog's layout wants it, and touches nothing else. + /// + /// Rebuilding the whole layout would remove and re-add the content pane, which deinitializes + /// that subtree -- taking the form's focus with it when the focused component is in there, + /// and closing an editor the user is typing in. Only the label moved, so only the label is + /// moved back. + private void restoreDisplacedTitle() { + Container root = super.getContentPane(); + Container target = titleCentered ? centeredTitleArea : root; + if (target == null) { + updateTitleLayout(); + revalidate(); + return; + } + if (titleCentered && centeredTitleArea != null) { + // The area's UIID comes from a theme constant, and a refresh is exactly when that + // constant can have changed. Restoring the label without it would leave a centered + // dialog wearing the previous theme's title styling. + centeredTitleArea.setUIID(getUIManager().getThemeConstant( + "dlgCenteredTitleUIID", "Container")); + } + if (dialogTitle.getParent() == target) { //NOPMD CompareObjectsWithEquals + return; + } + if (dialogTitle.getParent() != null) { + dialogTitle.remove(); + } + if (titleCentered) { + target.addComponent(BorderLayout.CENTER, dialogTitle); + } else { + target.addComponent(BorderLayout.NORTH, dialogTitle); + } + revalidate(); + } + /// {@inheritDoc} @Override public Label getTitleComponent() { diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index 5fb2956ca11..7b9fc4e9a13 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -1622,12 +1622,14 @@ void setCurrent(final Form newForm, boolean reverse) { if (edt == null) { throw new IllegalStateException("Initialize must be invoked before setCurrent!"); } - if (!isEdt()) { // when not running callSerially executes synchronously and would recurse here forever (#4811) if (!codenameOneRunning) { throw new IllegalStateException("Display.setCurrent must be invoked after Codename One has started running. Call it from start() or via callSerially."); } + // The direction is not recorded here: this call comes back round on the EDT and + // records it there. Doing it in both places would leave one entry behind for a + // later navigation to the same form to take. callSerially(new RunnableWrapper(newForm, null, reverse)); return; } @@ -1657,6 +1659,7 @@ void setCurrent(final Form newForm, boolean reverse) { case SHOW_DURING_EDIT_IGNORE: return; case SHOW_DURING_EDIT_SET_AS_NEXT: + newForm.setShownWithReverse(reverse); impl.setCurrentForm(newForm); return; default: @@ -1664,6 +1667,12 @@ void setCurrent(final Form newForm, boolean reverse) { } } + // Recorded once the call is known to be going through: on the EDT, with the form + // changing, and past the cases that decline to show anything while text is being + // edited. Recording it any earlier would queue a direction that no arrival takes, and a + // later navigation to the same form would take that stale one instead of its own. + newForm.setShownWithReverse(reverse); + if (current != null) { if (current.isInitialized()) { current.deinitializeImpl(); @@ -1713,6 +1722,18 @@ void setCurrent(final Form newForm, boolean reverse) { } } current = current.getPreviousForm(); + if (current != null) { + // Coming out of a menu back to the form underneath it, which is a backward + // move: without saying so, this arrival takes whatever direction that form + // has left over and a port that keeps browser history in step reads it as a + // step forward, pushing an entry for a form that was already behind. + // + // At the head of the queue, because this arrival comes first: when the form + // being revealed is also the one being shown, its own direction is already + // waiting, and appending would have this restoration take that one and leave + // this one for the show. + current.insertShownWithReverse(true); + } impl.setCurrentForm(current); } diff --git a/CodenameOne/src/com/codename1/ui/Form.java b/CodenameOne/src/com/codename1/ui/Form.java index 27a85c75e93..9c65fc054ef 100644 --- a/CodenameOne/src/com/codename1/ui/Form.java +++ b/CodenameOne/src/com/codename1/ui/Form.java @@ -1402,6 +1402,52 @@ Form getPreviousForm() { return previousForm; } + /// Directions of the displays asked for on this form that have not arrived yet, oldest + /// first. A transition defers a form change, so two navigations to the same form can be in + /// flight at once -- showBack() to it, then show() while the first is still animating -- and + /// a single field would give both arrivals the direction of the later one. + private ArrayList pendingReverse; + + /// The direction of the last arrival, for a form change with nothing queued behind it. + private boolean lastShownWithReverse; + + void setShownWithReverse(boolean value) { + if (pendingReverse == null) { + pendingReverse = new ArrayList(); + } + // Every entry is a display that is going to arrive, so none of them is dropped: a + // handful of shows and showBacks can be in flight at once when each is waiting on a + // transition, and clearing the queue to make room would hand the first arrival the last + // caller's direction. A display that changes nothing -- showing the form already up -- + // returns before it records anything, so nothing accumulates here unspent. + // + // The bound is a leak guard rather than a policy: a hundred displays of one form waiting + // at once is not navigation, it is something stuck. + if (pendingReverse.size() >= 100) { + pendingReverse.remove(0); + } + pendingReverse.add(Boolean.valueOf(value)); + } + + /// Puts a direction at the head of the queue, for a form change that happens before + /// anything already waiting -- a menu folding away to reveal this form, which arrives before + /// the show that asked for it. + void insertShownWithReverse(boolean value) { + if (pendingReverse == null) { + pendingReverse = new ArrayList(); + } + pendingReverse.add(0, Boolean.valueOf(value)); + } + + /// Takes the direction belonging to the form change that is arriving now. + boolean consumeShownWithReverse() { + if (pendingReverse == null || pendingReverse.isEmpty()) { + return lastShownWithReverse; + } + lastShownWithReverse = pendingReverse.remove(0).booleanValue(); + return lastShownWithReverse; + } + void setPreviousForm(Form previousForm) { this.previousForm = previousForm; } diff --git a/Ports/JavaScriptPort/STATUS.md b/Ports/JavaScriptPort/STATUS.md index 51cc571af56..8ad90452cad 100644 --- a/Ports/JavaScriptPort/STATUS.md +++ b/Ports/JavaScriptPort/STATUS.md @@ -17,6 +17,108 @@ routing. This document is the handoff for whoever picks up the branch next. Read it before you re-attempt switching initializr over. +Web-native overlay layer +------------------------ + +The port renders to a canvas, but text, semantics and OS state no longer come +from it. Two DOM layers sit above the canvas, which is itself marked +`role=presentation` / `aria-hidden`: + +- `#cn1-text-layer` (`JavaScriptTextLayer`) carries the **visible text**. + `BufferedGraphics.drawString` -- the display graphics -- hands each run to the + layer instead of the canvas, so the text on screen is real DOM text that can be + selected, found with the browser's find-in-page, and rasterized by the browser + rather than by canvas. Codename One remains the sole layout authority: a run + arrives already broken and placed, so it is emitted as one `white-space:pre` + element at an absolute coordinate and the browser cannot wrap or reflow it. + Text measurement is unchanged and stays on the worker's `OffscreenCanvas`. +- `#cn1-accessibility-tree` (`JavaScriptSemanticOverlay`) carries the ARIA + projection of `AccessibilityTreeSnapshot`. It is updated incrementally -- + elements are keyed by semantic node id and reused -- because the previous + rebuild-per-invalidation discarded DOM focus and text selection on every + `CHANGE_BOUNDS`, which is raised by every `setX/setY/setWidth/setHeight`. + +The layer takes no pointer events: the canvas owns hit testing, so a drag across +a label does not start a native selection. Find-in-page, the browser's own text +handling and assistive technology all reach the text; pointer selection would +mean teaching the port's input path to tell a selection drag from an application +drag, which is a change to input rather than to this layer. + +Text that stays on the canvas, by design: + +- offscreen targets (transition buffers, `paintLock`, `ComponentImage`, + `Display.screenshot`) -- they use plain `HTML5Graphics`, so the gate is + structural rather than a check; +- cell renderers, which are one component instance stamped at N positions and so + cannot key a pooled element per row; +- anything outside the displayed form, because the layer sits above the canvas as + a whole and nothing painted afterwards can occlude it (a modal dialog paints + the form beneath it as its own backdrop); +- decorated runs -- underline, strike-through, overline -- whose lines are drawn + after the glyphs and over them, which a promoted glyph would cover; +- shape clips and non-identity transforms; +- anything a later canvas draw covers. The layer is above the canvas as a whole, + so an image, fill or shape drawn over a promoted run cannot hide it the way it + would have hidden canvas text. Such a run is put back on the canvas and its + component stays there -- a sheet's scrim, a tab bar's composited lens, a + clipped rotation over a title. Who is drawing decides: a component painting its + own background, or a container painting behind children it is about to paint, + covers text it draws again a moment later and hides nothing. What the draw + reaches decides too: an empty clip or a fully transparent one reaches nothing, + the report is clipped to the graphics clip -- to the clip's own outline when it + is a shape, since text the clip protects is not covered by a draw it culls -- + and a shape, polygon, arc, radial gradient or rounded rectangle is asked + whether its outline meets the glyphs rather than whether its bounding rectangle + does. Curves are walked rather than replaced by their control points. + +Bitmap fonts need no exclusion: `Graphics.drawString` renders a `CustomFont` +itself and never reaches the implementation. + +**Consequence for the screenshot suite:** every test in the suite reads the screen +back to capture it, and a pixel read returns text to the canvas for good -- the +two representations cannot both be authoritative, and an application that reads +pixels is saying which one it needs. The goldens are therefore canvas-text at the +display's real pixel ratio, and have been rebaselined from a CI run. The DOM +layer is covered by `scripts/verify-javascript-web-overlay.mjs`, which asserts it +directly instead of through pixels. + +Known gaps in this area: + +- An editable field is reached through a SET_TEXT control in the actions region rather + than by typing into the semantic node itself, which is a div over a canvas. A native + input is still what appears once editing starts. +- Drag-selection is not enabled. The layer takes no pointer events so the canvas + keeps hit testing; find-in-page and assistive technology do not need hit + testing, but selection does. Enabling it requires the pointer-routing rework. +- Vertical placement uses `fontHeight()` as the line box, which matches Codename + One's own layout metric but is approximate against the browser's font metrics + to about a pixel. A text-parity harness comparing `getBoundingClientRect()` + against the Codename One width/position is the way to tighten this. +- Occlusion within a form is not handled; only cross-form occlusion is, along with + the glass pane, a dragged component and transitions. A `Sheet`, an + `InteractionDialog`, or an opaque sibling in a `LayeredLayout` painting over a + text-bearing component will not cover its promoted text, because the layer is + above the canvas as a whole. Whether a later sibling covers an earlier one is + not known at the time the earlier one paints, so the only general answer is to + promote the covering content too -- which is the DOM renderer this deliberately + is not. + +Main-thread browser state +------------------------- + +Several features were written when the port ran on the main thread and silently +did nothing once it moved into a worker, because the objects they reach for do +not exist there. `history.pushState` was a `@JSBody` and threw on every form +change (the port logged that the back command would not work), and every +`matchMedia` query -- dark mode, reduced motion, forced colors, contrast, +reduced transparency -- answered `false` unconditionally. Both now go through +the host bindings so they run on the main thread; media query results are cached +and refreshed from a `change` listener. + +The rule this illustrates is worth keeping in mind for any future port work: +**a `@JSBody` runs in the worker.** Anything touching `window`, `document`, +`history`, `matchMedia` or `navigator` has to go through a host binding instead. + Build ----- diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/browser/MediaQueryList.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/browser/MediaQueryList.java new file mode 100644 index 00000000000..aef1fd59ee7 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/browser/MediaQueryList.java @@ -0,0 +1,36 @@ +/* + * 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.html5.js.browser; + +import com.codename1.html5.js.JSObject; + +/** + * Interface for the JavaScript MediaQueryList object. + * https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList + */ +public interface MediaQueryList extends JSObject { + boolean getMatches(); + String getMedia(); + void addEventListener(String type, Object listener); + void removeEventListener(String type, Object listener); +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/browser/Window.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/browser/Window.java index e9e53383586..746670439d8 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/browser/Window.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/browser/Window.java @@ -33,6 +33,24 @@ * https://developer.mozilla.org/en-US/docs/Web/API/Window */ public interface Window extends JSObject { + /** + * Evaluates a CSS media query on the main thread. The worker has no matchMedia, so every + * OS-level preference -- dark mode, reduced motion, forced colors -- has to be read through + * this binding rather than from worker script. + * + * @param query the media query text + * @return the live query list, or null where the browser does not support matchMedia + */ + MediaQueryList matchMedia(String query); + + /** + * The display's scale factor, read from the main thread. The worker's copy is forwarded + * once at start-up and does not follow zoom or a move between displays. + * + * @return devicePixelRatio + */ + double getDevicePixelRatio(); + static Window current() { return null; // Native implementation } diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/dom/PopStateEvent.java b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/dom/PopStateEvent.java new file mode 100644 index 00000000000..c869cca2bc2 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/html5/js/dom/PopStateEvent.java @@ -0,0 +1,38 @@ +/* + * 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.html5.js.dom; + +/** + * Interface for the JavaScript PopStateEvent object. + * https://developer.mozilla.org/en-US/docs/Web/API/PopStateEvent + * + *

The state is read because popstate fires for forward traversal as well as backward, and + * the two are only distinguishable by comparing the state the browser restored against the one + * currently displayed. It is typed as Object rather than String: history state is any + * structured-cloneable value, and a page that embeds this canvas may keep its router's own + * object there. Binding it as a String would put that entry through a conversion it cannot + * satisfy, when all the port needs is to see that the value is not one of its own.

+ */ +public interface PopStateEvent extends Event { + Object getState(); +} 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 c2b77958776..88048d889cc 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 @@ -71,6 +71,7 @@ public class BufferedGraphics extends HTML5Graphics { private GeneralPath clipShape = new GeneralPath(); private boolean isClipShape; + private boolean promotionSuspended; // True when the current clip encloses no area. Tracked reliably via // clipBoundsTracker (a clamped user-space rect intersection) because the // projected clip bounds are unreliable for an empty clip on the shape path @@ -130,20 +131,778 @@ private void addOp(ExecutableOp operation) { upcoming.add(operation); } + /** + * Image draws report the rectangle they land in. + * + *

Review asked for the source alpha to be taken into account, so that an image which is + * transparent where the glyphs are does not send them back to the canvas. There is no way to + * ask that question here without reading the image's pixels, and pixel reads are what this + * port cannot do: every one is a round trip that parks the worker on a main-thread answer, + * and CI has a lint whose whole purpose is to keep them out of the drawing path -- per draw, + * per frame, it would be ruinous.

+ * + *

So the rectangle stands, and the error it can make is the cheaper of the two. Reporting + * a draw that turned out to be transparent costs that component its promotion: its text is + * drawn on the canvas instead of the DOM, still in the right place, still saying the same + * thing, no longer selectable. Not reporting one that turned out to be opaque leaves text + * floating above an image that should have hidden it -- a frame showing something the + * application did not draw.

+ */ @Override public void drawImage(Object img, int x, int y) { // An empty clip must cull every draw; a degenerate empty-clip path on // the host leaks image blits, so cull here. Issue #5263. if (clipEmpty) { return; } - imageTransformRenderAdapter.drawImage((NativeImage)img, x, y); + NativeImage image = (NativeImage) img; + noteCanvasCover(x, y, image == null ? 0 : image.getWidth(), image == null ? 0 : image.getHeight()); + imageTransformRenderAdapter.drawImage(image, x, y); } @Override public void drawImage(Object img, int x, int y, int w, int h) { if (clipEmpty) { return; } + noteCanvasCover(x, y, w, h); imageTransformRenderAdapter.drawImage((NativeImage)img, x, y, w, h); } + /** + * Reports the bounds of a filled shape as covering, so promoted text underneath goes back to + * the canvas where the shape can actually paint over it. + */ + private void noteCanvasCover(final Shape shape) { + if (shape == null) { + return; + } + com.codename1.ui.geom.Rectangle bounds = shape.getBounds(); + if (bounds == null) { + return; + } + if (shape.isRectangle()) { + noteCanvasCover(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight()); + return; + } + // A shape reaches what it encloses, not what its bounding rectangle encloses: a filled + // triangle drawn around a label reaches none of it, while a triangle over one corner of + // that label reaches part of it -- and any part is enough, because the canvas would have + // painted over that part and the DOM run cannot be painted over at all. So the question + // asked of the shape is whether it meets the text anywhere. + final float[][] outline = outlineOf(shape); + final int winding = windingOf(shape); + noteCanvasCover(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight(), + new JavaScriptTextLayer.CoverTest() { + @Override + public boolean covers(int x, int y, int w, int h) { + return outlineMeetsRect(outline, winding, x, y, w, h); + } + }); + } + + /** + * Reports a stroked draw. A stroke paints along its line and nowhere else, so what it covers + * is where that line runs, widened by the stroke it is drawn with. + */ + private void noteStrokeCover(final float[][] outline, int strokeWidth) { + if (outline == null || outline.length == 0) { + return; + } + final int reach = Math.max(1, strokeWidth) / 2 + 1; + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + for (int p = 0; p + 1 < outline.length; p += 2) { + for (int i = 0; i < outline[p].length; i++) { + minX = Math.min(minX, outline[p][i]); + maxX = Math.max(maxX, outline[p][i]); + minY = Math.min(minY, outline[p + 1][i]); + maxY = Math.max(maxY, outline[p + 1][i]); + } + } + if (maxX < minX || maxY < minY) { + return; + } + noteCanvasCover((int) Math.floor(minX) - reach, (int) Math.floor(minY) - reach, + (int) Math.ceil(maxX - minX) + 2 * reach, (int) Math.ceil(maxY - minY) + 2 * reach, + new JavaScriptTextLayer.CoverTest() { + @Override + public boolean covers(int x, int y, int w, int h) { + return outlineMeetsRect(outline, + com.codename1.ui.geom.PathIterator.WIND_NON_ZERO, false, + x - reach, y - reach, w + 2 * reach, h + 2 * reach); + } + }); + } + + /** + * The active shape clip as a coverage test, or null when the clip is a plain rectangle -- + * already handled exactly by the bounds intersection -- or when it cannot be compared in the + * coordinates the text is in. + */ + private JavaScriptTextLayer.CoverTest clipShapeCoverTest() { + if (!isClipShape) { + return null; + } + // Text is only promoted under an identity transform, so the clip is only comparable to + // it while it too is untransformed. Anything else keeps the bounding-rectangle answer. + if (clipTransform != null && !clipTransform.isIdentity()) { + return null; + } + if (transform != null && !transform.isIdentity()) { + return null; + } + final float[][] outline = outlineOf(clipShape); + if (outline == null || outline.length == 0) { + return null; + } + final int winding = windingOf(clipShape); + return new JavaScriptTextLayer.CoverTest() { + @Override + public boolean covers(int x, int y, int w, int h) { + return outlineMeetsRect(outline, winding, true, x, y, w, h); + } + }; + } + + /** + * A draw covers text only where both the draw's own outline and the clip reach it. + * + *

Each is asked about the part of the text the clip's bounds can reach rather than about + * the whole run, so a clip whose bounds fall short of the glyphs answers no on its own and + * neither outline is consulted about text neither could touch.

+ * + *

What is left is that both can answer yes about the same rectangle while touching + * different parts of it -- two wedges crossing one line of text without meeting over it. + * The exact answer is the intersection of the two regions, which means a polygon + * intersection, and getting one wrong in the other direction is the expensive mistake: + * reporting less than was painted leaves glyphs above a draw that covered them, which is + * wrong on screen, while reporting more only sends a component's text back to the canvas -- + * where every case this layer does not handle already lives, and where it still renders. So + * the conjunction stays, deliberately, on the side that cannot draw the wrong picture.

+ */ + private static JavaScriptTextLayer.CoverTest bothCover(final JavaScriptTextLayer.CoverTest a, + final JavaScriptTextLayer.CoverTest b, final int clipX, final int clipY, + final int clipW, final int clipH) { + return new JavaScriptTextLayer.CoverTest() { + @Override + public boolean covers(int x, int y, int w, int h) { + int left = Math.max(x, clipX); + int top = Math.max(y, clipY); + int right = Math.min(x + w, clipX + clipW); + int bottom = Math.min(y + h, clipY + clipH); + if (right <= left || bottom <= top) { + return false; + } + return a.covers(left, top, right - left, bottom - top) + && b.covers(left, top, right - left, bottom - top); + } + }; + } + + private static float[][] segmentOutline(int x1, int y1, int x2, int y2) { + return new float[][] { new float[] { x1, x2 }, new float[] { y1, y2 } }; + } + + private static float[][] rectOutline(int x, int y, int width, int height) { + // The first point again at the end: a rectangle is closed, and that is how a closed + // outline says so now that the tests no longer close one for it. + return new float[][] { + new float[] { x, x + width, x + width, x, x }, + new float[] { y, y, y + height, y + height, y } + }; + } + + /** + * A coverage test for a rounded rectangle, whose corners are cut away. + */ + private static JavaScriptTextLayer.CoverTest roundRectCoverTest(int x, int y, int width, + int height, int arcWidth, int arcHeight) { + final float[][] outline = roundRectOutline(x, y, width, height, arcWidth, arcHeight); + if (outline == null) { + return null; + } + return new JavaScriptTextLayer.CoverTest() { + @Override + public boolean covers(int rectX, int rectY, int rectW, int rectH) { + return outlineMeetsRect(outline, rectX, rectY, rectW, rectH); + } + }; + } + + /** + * The perimeter of a rounded rectangle, traced once round, clockwise on screen, each corner a + * quarter ellipse that ends where the next one begins: top-right from its top point to its + * right point, down the right side, and so on. The straight sides are the lines between one + * corner's last point and the next corner's first. Walking the corners in any other order + * joins points that are not neighbours and folds the outline across itself. + * + * @return the outline, or null when there is no rounding to speak of + */ + private static float[][] roundRectOutline(int x, int y, int width, int height, + int arcWidth, int arcHeight) { + // The renderer rounds both axes by max(arcWidth, arcHeight) -- DrawRoundRect and + // FillRoundRect both do -- so the outline has to be built from the same radius, or it + // would describe a corner the draw does not have and miss text the draw reaches. + int radius = Math.max(0, Math.max(arcWidth, arcHeight)) / 2; + int rx = Math.min(radius, width / 2); + int ry = Math.min(radius, height / 2); + if (rx <= 0 || ry <= 0) { + return null; + } + int perCorner = 6; + // One more than the corners produce, for the first point repeated at the end: the + // perimeter closes, and a closed outline now says so rather than being closed for it. + int points = 4 * (perCorner + 1) + 1; + float[][] outline = new float[][] { new float[points], new float[points] }; + int at = 0; + int[][] corners = new int[][] { + { x + width - rx, y + ry, 90 }, + { x + width - rx, y + height - ry, 0 }, + { x + rx, y + height - ry, -90 }, + { x + rx, y + ry, -180 } + }; + for (int c = 0; c < corners.length; c++) { + double cx = corners[c][0]; + double cy = corners[c][1]; + double from = corners[c][2]; + for (int i = 0; i <= perCorner; i++) { + // Angles run counter-clockwise from three o'clock while y grows downwards, so + // sweeping the angle down walks the perimeter clockwise on screen. + double radians = Math.toRadians(from - (90.0 * i) / perCorner); + outline[0][at] = (float) (cx + rx * Math.cos(radians)); + outline[1][at] = (float) (cy - ry * Math.sin(radians)); + at++; + } + } + outline[0][at] = outline[0][0]; + outline[1][at] = outline[1][0]; + return outline; + } + + private static float[][] polygonOutline(int[] xPoints, int[] yPoints, int nPoints) { + if (xPoints == null || yPoints == null || nPoints <= 0) { + return null; + } + int count = Math.min(nPoints, Math.min(xPoints.length, yPoints.length)); + // Closed, with the first point repeated: drawPolygon joins the last vertex back to the + // first, so that edge is one the draw really has. + float[][] outline = new float[][] { new float[count + 1], new float[count + 1] }; + for (int i = 0; i < count; i++) { + outline[0][i] = xPoints[i]; + outline[1][i] = yPoints[i]; + } + outline[0][count] = xPoints[0]; + outline[1][count] = yPoints[0]; + return outline; + } + + /** + * A coverage test for the sector a filled arc actually paints. + * + *

The bounding rectangle of an arc holds a good deal the arc never reaches -- the corners + * of a full ellipse's box, and everything outside the wedge of a partial one. The sector is + * traced as a closed outline and asked the same question as any other shape.

+ */ + private static JavaScriptTextLayer.CoverTest arcCoverTest(int x, int y, int width, int height, + int startAngle, int arcAngle) { + final float[][] outline = arcOutline(x, y, width, height, startAngle, arcAngle, true); + if (outline == null) { + return null; + } + return new JavaScriptTextLayer.CoverTest() { + @Override + public boolean covers(int rectX, int rectY, int rectW, int rectH) { + return outlineMeetsRect(outline, rectX, rectY, rectW, rectH); + } + }; + } + + /** + * The outline of an arc: the sector a fill paints, or just the curve a stroke follows. + * + *

The bounding rectangle of an arc holds a good deal the arc never reaches -- the corners + * of a full ellipse's box, and everything outside the wedge of a partial one.

+ * + * @param wedge true to close the sector through its centre, as a fill does + * @return the outline, or null for a degenerate arc + */ + private static float[][] arcOutline(int x, int y, int width, int height, + int startAngle, int arcAngle, boolean wedge) { + if (width <= 0 || height <= 0) { + return null; + } + double cx = x + width / 2.0; + double cy = y + height / 2.0; + double rx = width / 2.0; + double ry = height / 2.0; + int span = Math.abs(arcAngle) >= 360 ? 360 : Math.abs(arcAngle); + int steps = Math.max(8, span / 4); + boolean whole = span >= 360; + boolean centre = wedge && !whole; + // Closed when it comes back to where it started -- a wedge returns to its centre and a + // whole ellipse to its first point -- and open otherwise, which is what a partial + // drawArc paints: the curve, and nothing across its ends. + boolean closed = centre || whole; + int points = steps + 1 + (centre ? 1 : 0) + (closed ? 1 : 0); + float[][] outline = new float[][] { new float[points], new float[points] }; + int at = 0; + if (centre) { + // The straight edges of a wedge run from the centre out to each end of the arc. + outline[0][at] = (float) cx; + outline[1][at] = (float) cy; + at++; + } + double from = arcAngle < 0 ? startAngle + arcAngle : startAngle; + for (int i = 0; i <= steps; i++) { + double degrees = from + (span * (double) i) / steps; + double radians = Math.toRadians(degrees); + // Angles run counter-clockwise from three o'clock, while y grows downwards. + outline[0][at] = (float) (cx + rx * Math.cos(radians)); + outline[1][at] = (float) (cy - ry * Math.sin(radians)); + at++; + } + if (closed) { + outline[0][at] = outline[0][0]; + outline[1][at] = outline[1][0]; + } + return outline; + } + + /** + * Flattens a shape's outline into one array of x coordinates and one of y coordinates per + * subpath, so it can be asked whether it meets a rectangle. + * + *

Curves are walked rather than replaced by their control points. A control polygon runs + * wide of the curve it describes -- a quadratic through (0,0), (50,100), (100,0) passes + * through (50,50), which neither of its control segments goes near -- so a stroke following + * the curve would have been judged to miss text it runs straight through.

+ */ + private static final int CURVE_STEPS = 8; + + private static float[][] outlineOf(Shape shape) { + java.util.List xs = new java.util.ArrayList(); + java.util.List ys = new java.util.ArrayList(); + java.util.List curX = new java.util.ArrayList(); + java.util.List curY = new java.util.ArrayList(); + float[] coords = new float[6]; + com.codename1.ui.geom.PathIterator it = shape.getPathIterator(); + while (it != null && !it.isDone()) { + int type = it.currentSegment(coords); + if (type == com.codename1.ui.geom.PathIterator.SEG_MOVETO) { + if (curX.size() > 1) { + xs.add(toArray(curX)); + ys.add(toArray(curY)); + } + curX.clear(); + curY.clear(); + curX.add(Float.valueOf(coords[0])); + curY.add(Float.valueOf(coords[1])); + } else if (type == com.codename1.ui.geom.PathIterator.SEG_LINETO) { + curX.add(Float.valueOf(coords[0])); + curY.add(Float.valueOf(coords[1])); + } else if (type == com.codename1.ui.geom.PathIterator.SEG_CLOSE + && !curX.isEmpty()) { + // The subpath returns to where it began, and a stroke really does paint that + // edge. A subpath without it stays open, so no chord is tested across its ends. + curX.add(curX.get(0)); + curY.add(curY.get(0)); + } else if (type == com.codename1.ui.geom.PathIterator.SEG_QUADTO + && !curX.isEmpty()) { + float fromX = curX.get(curX.size() - 1).floatValue(); + float fromY = curY.get(curY.size() - 1).floatValue(); + for (int i = 1; i <= CURVE_STEPS; i++) { + double t = (double) i / CURVE_STEPS; + double u = 1 - t; + curX.add(Float.valueOf((float) (u * u * fromX + + 2 * u * t * coords[0] + t * t * coords[2]))); + curY.add(Float.valueOf((float) (u * u * fromY + + 2 * u * t * coords[1] + t * t * coords[3]))); + } + } else if (type == com.codename1.ui.geom.PathIterator.SEG_CUBICTO + && !curX.isEmpty()) { + float fromX = curX.get(curX.size() - 1).floatValue(); + float fromY = curY.get(curY.size() - 1).floatValue(); + for (int i = 1; i <= CURVE_STEPS; i++) { + double t = (double) i / CURVE_STEPS; + double u = 1 - t; + curX.add(Float.valueOf((float) (u * u * u * fromX + + 3 * u * u * t * coords[0] + 3 * u * t * t * coords[2] + + t * t * t * coords[4]))); + curY.add(Float.valueOf((float) (u * u * u * fromY + + 3 * u * u * t * coords[1] + 3 * u * t * t * coords[3] + + t * t * t * coords[5]))); + } + } + it.next(); + } + if (curX.size() > 1) { + xs.add(toArray(curX)); + ys.add(toArray(curY)); + } + float[][] out = new float[xs.size() * 2][]; + for (int i = 0; i < xs.size(); i++) { + out[i * 2] = xs.get(i); + out[i * 2 + 1] = ys.get(i); + } + return out; + } + + /** + * The rule a shape's fill uses to decide what is inside it, which is what says whether a hole + * is a hole. + */ + private static int windingOf(Shape shape) { + try { + com.codename1.ui.geom.PathIterator it = shape.getPathIterator(); + if (it != null) { + return it.getWindingRule(); + } + } catch (Throwable ignored) { + // Treated as non-zero below, which is what a plain outline fills as. + } + return com.codename1.ui.geom.PathIterator.WIND_NON_ZERO; + } + + private static float[] toArray(java.util.List values) { + float[] out = new float[values.size()]; + for (int i = 0; i < out.length; i++) { + out[i] = values.get(i).floatValue(); + } + return out; + } + + /** + * Whether a flattened outline meets a rectangle at all -- a vertex inside it, a corner of it + * inside the outline, or an edge crossing one of its sides. + */ + private static boolean outlineMeetsRect(float[][] outline, int x, int y, int w, int h) { + return outlineMeetsRect(outline, com.codename1.ui.geom.PathIterator.WIND_NON_ZERO, true, + x, y, w, h); + } + + private static boolean outlineMeetsRect(float[][] outline, int winding, + int x, int y, int w, int h) { + return outlineMeetsRect(outline, winding, true, x, y, w, h); + } + + /** + * @param filled true for a draw that paints what the outline encloses, false for one that + * paints only along it -- a stroke, which leaves what it surrounds untouched + */ + private static boolean outlineMeetsRect(float[][] outline, int winding, boolean filled, + int x, int y, int w, int h) { + if (outline == null || outline.length == 0) { + // Nothing to go by, so treat the draw as reaching the text: leaving a run above a + // draw that covered it is the error that shows on screen. + return true; + } + float right = x + Math.max(0, w); + float bottom = y + Math.max(0, h); + for (int p = 0; p + 1 < outline.length; p += 2) { + float[] px = outline[p]; + float[] py = outline[p + 1]; + for (int i = 0; i < px.length; i++) { + if (px[i] >= x && px[i] <= right && py[i] >= y && py[i] <= bottom) { + return true; + } + } + for (int i = 1; i < px.length; i++) { + if (segmentMeetsRect(px[i - 1], py[i - 1], px[i], py[i], x, y, right, bottom)) { + return true; + } + } + // A fill closes what it is given: context.fill() joins the last point back to the + // first and paints that edge, so it is tested. A stroke does not -- drawArc over a + // 90-degree sweep paints the curve and nothing across its ends -- so an outline that + // ends where it started says so by repeating its first point, and one that does not + // is left open. Testing a chord no stroke draws would detach text beside the curve + // and cost its component the DOM for the rest of the form. + if (filled && px.length > 2 && segmentMeetsRect(px[px.length - 1], py[px.length - 1], + px[0], py[0], x, y, right, bottom)) { + return true; + } + } + if (!filled) { + // Nothing crosses the rectangle, and a stroke paints only where its line runs -- a + // rectangle drawn around a label leaves the label alone. + return false; + } + // No boundary passes through the rectangle, so the rectangle is either entirely painted + // or entirely not. Which one is decided across every subpath at once, under the rule the + // fill uses: a rectangle sitting in a hole is not painted, however far inside the outer + // loop it lies. + return pointInOutline(outline, winding, x, y) + || pointInOutline(outline, winding, right, y) + || pointInOutline(outline, winding, x, bottom) + || pointInOutline(outline, winding, right, bottom); + } + + private static boolean pointInOutline(float[][] outline, int winding, float x, float y) { + int crossings = 0; + for (int p = 0; p + 1 < outline.length; p += 2) { + float[] px = outline[p]; + float[] py = outline[p + 1]; + for (int i = 0, j = px.length - 1; i < px.length; j = i++) { + if ((py[i] > y) == (py[j] > y)) { + continue; + } + double crossing = (double) (px[j] - px[i]) * (y - py[i]) + / (double) (py[j] - py[i]) + px[i]; + if (x >= crossing) { + continue; + } + if (winding == com.codename1.ui.geom.PathIterator.WIND_NON_ZERO) { + crossings += py[i] > py[j] ? 1 : -1; + } else { + crossings++; + } + } + } + return winding == com.codename1.ui.geom.PathIterator.WIND_NON_ZERO + ? crossings != 0 + : (crossings & 1) == 1; + } + + private static boolean segmentMeetsRect(float x1, float y1, float x2, float y2, + float left, float top, float right, float bottom) { + return segmentsMeet(x1, y1, x2, y2, left, top, right, top) + || segmentsMeet(x1, y1, x2, y2, right, top, right, bottom) + || segmentsMeet(x1, y1, x2, y2, right, bottom, left, bottom) + || segmentsMeet(x1, y1, x2, y2, left, bottom, left, top); + } + + private static boolean segmentsMeet(float ax1, float ay1, float ax2, float ay2, + float bx1, float by1, float bx2, float by2) { + double d1 = side(bx1, by1, bx2, by2, ax1, ay1); + double d2 = side(bx1, by1, bx2, by2, ax2, ay2); + double d3 = side(ax1, ay1, ax2, ay2, bx1, by1); + double d4 = side(ax1, ay1, ax2, ay2, bx2, by2); + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) + && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { + return true; + } + return (d1 == 0 && between(bx1, by1, bx2, by2, ax1, ay1)) + || (d2 == 0 && between(bx1, by1, bx2, by2, ax2, ay2)) + || (d3 == 0 && between(ax1, ay1, ax2, ay2, bx1, by1)) + || (d4 == 0 && between(ax1, ay1, ax2, ay2, bx2, by2)); + } + + private static double side(float x1, float y1, float x2, float y2, float px, float py) { + return (double) (x2 - x1) * (py - y1) - (double) (y2 - y1) * (px - x1); + } + + private static boolean between(float x1, float y1, float x2, float y2, float px, float py) { + return px >= Math.min(x1, x2) && px <= Math.max(x1, x2) + && py >= Math.min(y1, y2) && py <= Math.max(y1, y2); + } + + /** + * Reports the bounds of a filled polygon as covering. + */ + private void noteCanvasCover(final int[] xPoints, final int[] yPoints, final int nPoints) { + if (xPoints == null || yPoints == null || nPoints <= 0) { + return; + } + int minX = xPoints[0]; + int maxX = xPoints[0]; + int minY = yPoints[0]; + int maxY = yPoints[0]; + for (int i = 1; i < nPoints && i < xPoints.length && i < yPoints.length; i++) { + minX = Math.min(minX, xPoints[i]); + maxX = Math.max(maxX, xPoints[i]); + minY = Math.min(minY, yPoints[i]); + maxY = Math.max(maxY, yPoints[i]); + } + // Like a filled shape, a polygon reaches what it encloses rather than what its bounding + // rectangle does -- and any part of the text it reaches is enough to send that text back + // to the canvas. + final float[][] outline = polygonOutline(xPoints, yPoints, nPoints); + noteCanvasCover(minX, minY, maxX - minX, maxY - minY, + new JavaScriptTextLayer.CoverTest() { + @Override + public boolean covers(int x, int y, int w, int h) { + return outlineMeetsRect(outline, x, y, w, h); + } + }); + } + + /** + * Tells the text layer that something landed on the canvas, so it can put back any text it + * promoted this frame that the draw would have covered. + * + *

Text promoted into the DOM sits above the whole canvas. Anything drawn over it in the + * original renderer would have hidden it -- an image, a fill, a shape -- and here it cannot, + * so the promotion has to be given up rather than leave the frame showing something the + * application did not draw.

+ */ + private void noteCanvasCover(int x, int y, int w, int h) { + noteCanvasCover(x, y, w, h, null); + } + + /** + * Reports a region a draw rewrites without consulting the graphics alpha -- an erase, which + * takes pixels away, or a backdrop effect, which samples what is there and paints the result. + * Those count however transparent the graphics happens to be. + */ + private void noteAlphaIndependentRegion(int x, int y, int w, int h, + JavaScriptTextLayer.CoverTest test) { + noteCanvasCover(x, y, w, h, test, true); + } + + private void noteCanvasCover(int x, int y, int w, int h, JavaScriptTextLayer.CoverTest test) { + noteCanvasCover(x, y, w, h, test, false); + } + + private void noteCanvasCover(int x, int y, int w, int h, JavaScriptTextLayer.CoverTest test, + boolean alphaIndependent) { + JavaScriptTextLayer layer = impl == null ? null : impl.textLayer; + if (layer == null || w <= 0 || h <= 0) { + return; + } + // An empty clip culls the draw entirely -- addOp drops it -- so nothing is covered. + if (clipEmpty) { + return; + } + // A fully transparent draw leaves what is underneath exactly as it was, which is no + // reason to take text off the layer. An erase and a backdrop effect are different: they + // rewrite pixels without consulting the alpha, so they count either way. + if (!alphaIndependent && getAlpha() <= 0) { + return; + } + // The clip and the draw are compared where the canvas applies them: on screen. The clip + // is fixed in the coordinates it was installed in and the draw is in the coordinates in + // force now, and those need not be the same -- clip screen x=0..50, translate by 100, + // then fill x=-100..50 and the canvas paints screen x=0..50. Intersecting the two as + // though they shared a space put that report at screen x=100..150: text the fill really + // did cover was left above it, which is the mistake that shows on screen. + float[] drawRect = projectRect(x, y, w, h, transform); + float[] clipRect = projectRect(clipBoundsTracker.getX(), clipBoundsTracker.getY(), + clipBoundsTracker.getWidth(), clipBoundsTracker.getHeight(), clipTransform); + if (drawRect == null || clipRect == null) { + // A transform the platform will not project. The draw is treated as covering what it + // was asked to cover, which is the wider answer and the safe one. + layer.noteCanvasCover(x, y, w, h, null); + return; + } + float left = Math.max(drawRect[0], clipRect[0]); + float top = Math.max(drawRect[1], clipRect[1]); + float right = Math.min(drawRect[2], clipRect[2]); + float bottom = Math.min(drawRect[3], clipRect[3]); + if (right <= left || bottom <= top) { + return; + } + // A shape clip has been reduced to its bounding rectangle above, and a fill of that + // rectangle would then be read as covering text the clip actually protects -- text + // inside the bounding box but outside the shape. Detaching a run costs its component + // the DOM for the rest of the form's life, so the clip's own outline gets a say. + JavaScriptTextLayer.CoverTest clipTest = clipShapeCoverTest(); + if (clipTest != null) { + test = test == null ? clipTest + : bothCover(test, clipTest, clipBoundsTracker.getX(), clipBoundsTracker.getY(), + clipBoundsTracker.getWidth(), clipBoundsTracker.getHeight()); + } + int reportX = (int) Math.floor(left); + int reportY = (int) Math.floor(top); + int reportW = (int) Math.ceil(right - left); + int reportH = (int) Math.ceil(bottom - top); + if (transform == null || transform.isIdentity()) { + layer.noteCanvasCover(reportX, reportY, reportW, reportH, test); + return; + } + Transform inverse = null; + try { + inverse = getInverseTransform(); + } catch (Throwable ignored) { + // A transform with no inverse -- degenerate, or one the platform will not invert -- + // leaves the projected bounding box as the whole answer. + } + layer.noteCanvasCover(reportX, reportY, reportW, reportH, unprojected(test, inverse)); + } + + /** + * A rectangle in the coordinates a transform maps to the screen, as left, top, right, bottom. + * + * @param x the rectangle's x in the transform's own coordinates + * @param y its y + * @param w its width + * @param h its height + * @param t the transform those coordinates are in, or null for none + * @return the enclosing screen rectangle, or null if the transform will not project + */ + private static float[] projectRect(float x, float y, float w, float h, Transform t) { + if (t == null || t.isIdentity()) { + return new float[] { x, y, x + w, y + h }; + } + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + float[] corner = new float[2]; + float[] out = new float[2]; + for (int i = 0; i < 4; i++) { + corner[0] = (i == 0 || i == 3) ? x : x + w; + corner[1] = (i < 2) ? y : y + h; + try { + t.transformPoint(corner, out); + } catch (Throwable ignored) { + return null; + } + minX = Math.min(minX, out[0]); + minY = Math.min(minY, out[1]); + maxX = Math.max(maxX, out[0]); + maxY = Math.max(maxY, out[1]); + } + return new float[] { minX, minY, maxX, maxY }; + } + + /** + * Reads a coverage test written in untransformed coordinates from the transformed side. + * + *

The rectangle the layer asks about is where the text is on screen, while the outline is + * where the draw was asked for, so the rectangle is carried back through the transform + * before the outline is asked about it. Its four corners can land as a rotated quadrilateral, + * and the enclosing rectangle of that is what the outline is tested against -- wider than + * the text, which keeps the answer on the side of saying the draw reached it.

+ * + * @param test the test in untransformed coordinates, or null if there is none + * @param inverse the transform back from screen coordinates, or null if it has none + * @return a test in screen coordinates, or null when it cannot be built + */ + private static JavaScriptTextLayer.CoverTest unprojected( + final JavaScriptTextLayer.CoverTest test, final Transform inverse) { + if (test == null || inverse == null) { + return null; + } + return new JavaScriptTextLayer.CoverTest() { + @Override + public boolean covers(int x, int y, int w, int h) { + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + float[] corner = new float[2]; + float[] out = new float[2]; + for (int i = 0; i < 4; i++) { + corner[0] = (i == 0 || i == 3) ? x : x + w; + corner[1] = (i < 2) ? y : y + h; + try { + inverse.transformPoint(corner, out); + } catch (Throwable ignored) { + // Nothing to go by, so the draw is treated as reaching the text: a run + // left above a draw that covered it is the error that shows on screen. + return true; + } + minX = Math.min(minX, out[0]); + minY = Math.min(minY, out[1]); + maxX = Math.max(maxX, out[0]); + maxY = Math.max(maxY, out[1]); + } + return test.covers((int) Math.floor(minX), (int) Math.floor(minY), + (int) Math.ceil(maxX - minX), (int) Math.ceil(maxY - minY)); + } + }; + } + + /// Buffers a blit of a raw canvas (an offscreen WebGL render target) into the /// display op stream. Used by the GPU compositing path so a RenderView's 3D /// frame is drawn onto the display surface in flushGraphics(), layering with @@ -153,12 +912,17 @@ public void drawCanvas(com.codename1.html5.js.dom.HTMLCanvasElement canvas, int if (canvas == null || w <= 0 || h <= 0) { return; } + // Counted whatever the alpha is: the op is built with 255 and blits the surface + // opaquely, so a blit made with the graphics fully transparent still replaces what was + // underneath -- including text the layer had promoted over it. + noteAlphaIndependentRegion(x, y, w, h, null); upcoming.add(new com.codename1.impl.html5.graphics.DrawCanvas(canvas, x, y, w, h, 255)); } @Override public void tileImage(Object img, int x, int y, int w, int h) { if (clipEmpty) { return; } + noteCanvasCover(x, y, w, h); imageTransformRenderAdapter.tileImage((NativeImage)img, x, y, w, h); } @@ -166,16 +930,27 @@ public void tileImage(Object img, int x, int y, int w, int h) { @Override public void drawArc(int x, int y, int width, int height, int startAngle, int arcAngle) { + // A sweep of nothing draws nothing -- the outline would be a single point repeated. + if (arcAngle != 0) { + noteStrokeCover(arcOutline(x, y, width, height, startAngle, arcAngle, false), 1); + } addOp(new DrawArc(x, y, width, height, startAngle, arcAngle, getColor(), getAlpha())); } @Override public void fillRect(int x, int y, int width, int height) { + noteCanvasCover(x, y, width, height); primitiveRenderAdapter.fillRect(x, y, width, height); } @Override public void blurRegion(int x, int y, int width, int height, float radius, float cornerRadius) { + // A blur rewrites what is under it. Promoted text is not under it -- it is above the + // canvas entirely -- so it would come out unblurred beside everything else. Back to the + // canvas it goes. + noteAlphaIndependentRegion(x, y, width, height, cornerRadius > 0 + ? roundRectCoverTest(x, y, width, height, (int) (cornerRadius * 2), (int) (cornerRadius * 2)) + : null); // Route through addOp (this class's chokepoint) so the empty-clip cull // applies; the base class records into its own immediate context. addOp(new com.codename1.impl.html5.graphics.BlurRegion(x, y, width, height, radius, cornerRadius)); @@ -184,6 +959,12 @@ public void blurRegion(int x, int y, int width, int height, float radius, float @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) { + // Glass samples what is behind it and draws the result. Promoted text is not behind it, + // so the material would be made from a backdrop the text is missing from, while the text + // itself floated over the finished glass. + noteAlphaIndependentRegion(x, y, width, height, cornerRadius > 0 + ? roundRectCoverTest(x, y, width, height, (int) (cornerRadius * 2), (int) (cornerRadius * 2)) + : null); addOp(new com.codename1.impl.html5.graphics.GlassRegion(x, y, width, height, radius, cornerRadius, saturation, scale, offset, refraction, specular)); } @@ -191,12 +972,22 @@ public void glassRegion(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, float aberration, int tintColor, float tintStrength) { + // A lens magnifies, tints and aberrates what is under it. Promoted text is not under it, + // so the selected tab's label would float over the effect untouched instead of being + // drawn through it. Back to the canvas, where the lens can reach it. + noteAlphaIndependentRegion(x, y, width, height, cornerRadius > 0 + ? roundRectCoverTest(x, y, width, height, (int) (cornerRadius * 2), (int) (cornerRadius * 2)) + : null); addOp(new com.codename1.impl.html5.graphics.LensRegion(x, y, width, height, cornerRadius, magnify, aberration, tintColor, tintStrength)); } @Override public void clearRect(int x, int y, int width, int height) { + // Erasing the canvas erases nothing in the layer above it, so text promoted out of this + // region would go on showing over pixels that were wiped. Reported whatever the alpha + // is: a clear takes pixels away rather than painting over them. + noteAlphaIndependentRegion(x, y, width, height, null); primitiveRenderAdapter.clearRect(x, y, width, height); } @@ -204,41 +995,53 @@ public void clearRect(int x, int y, int width, int height) { @Override public void drawRect(int x, int y, int width, int height) { + noteStrokeCover(rectOutline(x, y, width, height), 1); primitiveRenderAdapter.drawRect(x, y, width, height); } @Override public void drawLine(int x1, int y1, int x2, int y2) { + noteStrokeCover(segmentOutline(x1, y1, x2, y2), 1); primitiveRenderAdapter.drawLine(x1, y1, x2, y2); } @Override public void drawRoundRect(int x, int y, int width, int height, int arcWidth, int arcHeight) { + noteStrokeCover(roundRectOutline(x, y, width, height, arcWidth, arcHeight), 1); addOp(new DrawRoundRect(x, y, width, height, arcWidth, arcHeight, getColor(), getAlpha())); } @Override public void fillRoundRect(int x, int y, int width, int height, int arcWidth, int arcHeight) { + noteCanvasCover(x, y, width, height, + roundRectCoverTest(x, y, width, height, arcWidth, arcHeight)); addOp(new FillRoundRect(x, y, width, height, arcWidth, arcHeight, getColor(), getAlpha())); } @Override public void drawPolygon(int[] xPoints, int[] yPoints, int nPoints) { + noteStrokeCover(polygonOutline(xPoints, yPoints, nPoints), 1); addOp(new DrawPolygon(xPoints, yPoints, nPoints, getColor(), getAlpha())); } @Override public void fillPolygon(int[] xPoints, int[] yPoints, int nPoints) { + noteCanvasCover(xPoints, yPoints, nPoints); addOp(new FillPolygon(xPoints, yPoints, nPoints, getColor(), getAlpha())); } @Override public void drawShape(Shape shape, Stroke stroke) { + if (shape != null) { + noteStrokeCover(outlineOf(shape), + stroke == null ? 1 : (int) Math.ceil(stroke.getLineWidth())); + } shapeGradientRenderAdapter.drawShape(shape, stroke); } @Override public void fillShape(Shape shape) { + noteCanvasCover(shape); shapeGradientRenderAdapter.fillShape(shape); } @@ -349,6 +1152,13 @@ public void translateMatrix(double tx, double ty) { @Override public void fillArc(int x, int y, int width, int height, int startAngle, int arcAngle) { + // A sweep of nothing fills a path with no area, so nothing is covered. Reported, it + // would be the line from the centre out to the rim, and text across that line would be + // taken off the layer by a draw that painted no pixels at all. + if (arcAngle != 0) { + noteCanvasCover(x, y, width, height, + arcCoverTest(x, y, width, height, startAngle, arcAngle)); + } addOp(new FillArc(x, y, width, height, startAngle, arcAngle, getColor(), getAlpha())); } @@ -366,9 +1176,57 @@ public void drawRGB(int[] rgbData, int offset, int x, int y, int w, int h, boole @Override public void drawString(String str, int x, int y) { + if (promoteToTextLayer(str, x, y)) { + return; + } primitiveRenderAdapter.drawString(str, x, y); } + /** + * Holds text on the canvas for a run whose caller draws more than the glyphs. + * + *

Underline, strike-through and overline are drawn as lines after the glyphs, over them. + * The DOM layer sits above the whole canvas, so a promoted glyph would cover the line that + * is meant to cross it. A decorated run keeps glyphs and lines together on the canvas, + * where the drawing order still means what it says.

+ * + * @param value true to keep text on the canvas + */ + void setPromotionSuspended(boolean value) { + promotionSuspended = value; + } + + /** + * Offers a text run to the DOM text layer, which renders it as real text above the canvas. + * + *

Only runs this class can reproduce faithfully are offered. A shape clip has no + * {@code overflow:hidden} equivalent, and under a non-identity transform the run would have + * to be re-projected, so both stay on the canvas. Bitmap fonts never reach here at all -- + * {@code Graphics.drawString} renders a {@code CustomFont} itself and never calls the + * implementation.

+ * + *

This override lives on the display graphics only. Offscreen surfaces use plain + * {@link HTML5Graphics}, so text painted into a transition buffer, a paint lock image, a + * {@code ComponentImage} or a screenshot is still rasterized onto its bitmap, which is what + * those callers read back.

+ * + * @return true when the layer took the run and nothing should be drawn on the canvas + */ + private boolean promoteToTextLayer(String str, int x, int y) { + JavaScriptTextLayer layer = impl == null ? null : impl.textLayer; + if (layer == null || clipEmpty || isClipShape || promotionSuspended) { + return false; + } + if (transform != null && !transform.isIdentity()) { + return false; + } + return layer.promote(str, x, y, + clipBoundsTracker.getX(), clipBoundsTracker.getY(), + clipBoundsTracker.getWidth(), clipBoundsTracker.getHeight(), + getRenderState().getColor(), getRenderState().getAlpha(), + getRenderState().getFont(), HTML5Implementation.getDevicePixelRatio()); + } + @Override void setAlpha(int alpha) { getRenderState().setAlpha(alpha); @@ -587,21 +1445,37 @@ public int getClipY() { @Override public void fillLinearGradient(int x, int y, int width, int height, int startColor, int endColor, boolean horizontal) { + noteCanvasCover(x, y, width, height); shapeGradientRenderAdapter.fillLinearGradient(x, y, width, height, startColor, endColor, horizontal); } @Override public void fillRadialGradient(int startColor, int endColor, int x, int y, int width, int height, int startAngle, int arcAngle) { + // A radial gradient fills the oval inscribed in these bounds, or a sector of it -- not + // the bounds themselves, whose corners it never reaches. + // + // Counted whatever the alpha is, because FillRadialGradient paints whatever the alpha + // is: its setGlobalAlpha call is commented out in the renderer, so a fill made with the + // graphics fully transparent still replaces the pixels underneath. Coverage says what + // the canvas does, not what it ought to do -- text left above this would be floating + // over pixels that really were painted over. + if (arcAngle != 0) { + // As with fillArc: a sweep of nothing paints nothing. + noteAlphaIndependentRegion(x, y, width, height, + arcCoverTest(x, y, width, height, startAngle, arcAngle)); + } shapeGradientRenderAdapter.fillRadialGradient(x, y, width, height, startColor, endColor, startAngle, arcAngle); } @Override public void fillRadialGradient(int startColor, int endColor, int x, int y, int width, int height) { + noteAlphaIndependentRegion(x, y, width, height, arcCoverTest(x, y, width, height, 0, 360)); shapeGradientRenderAdapter.fillRadialGradient(x, y, width, height, startColor, endColor, 0, 360); } public void fillRectRadialGradient(int startColor, int endColor, int x, int y, int width, int height, float relativeX, float relativeY, float relativeSize) { + noteCanvasCover(x, y, width, height); shapeGradientRenderAdapter.fillRectRadialGradient(x, y, width, height, startColor, endColor, relativeX, relativeY, relativeSize); } 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 7081d7656f8..3902ee421d6 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 @@ -73,6 +73,7 @@ import com.codename1.teavm.io.BlobUtil; import com.codename1.teavm.jso.io.Blob; import com.codename1.teavm.jso.io.FileList; +import com.codename1.html5.js.browser.MediaQueryList; import com.codename1.teavm.jso.util.EventUtil; import com.codename1.teavm.jso.util.JSDateFormat; import com.codename1.teavm.jso.util.JSNumberFormat; @@ -85,6 +86,7 @@ import static com.codename1.ui.CN.invokeAndBlock; import com.codename1.ui.Component; import com.codename1.ui.ComponentSelector; +import com.codename1.ui.AnimationManager; import com.codename1.ui.Display; import com.codename1.ui.Font; import com.codename1.ui.FontImage; @@ -103,6 +105,7 @@ import com.codename1.ui.TextInputState; import com.codename1.ui.TextSelection; import com.codename1.ui.Transform; +import com.codename1.ui.Command; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import com.codename1.ui.events.ActionSource; @@ -110,14 +113,6 @@ import com.codename1.ui.events.FocusListener; import com.codename1.ui.events.MessageEvent; import com.codename1.ui.geom.Rectangle; -import com.codename1.ui.accessibility.AccessibilityAction; -import com.codename1.ui.accessibility.AccessibilityCheckedState; -import com.codename1.ui.accessibility.AccessibilityCollectionItemInfo; -import com.codename1.ui.accessibility.AccessibilityLiveRegion; -import com.codename1.ui.accessibility.AccessibilityNodeSnapshot; -import com.codename1.ui.accessibility.AccessibilityRange; -import com.codename1.ui.accessibility.AccessibilityRole; -import com.codename1.ui.accessibility.AccessibilityTreeSnapshot; import com.codename1.ui.geom.Shape; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.BoxLayout; @@ -174,6 +169,7 @@ import com.codename1.html5.js.dom.HTMLCanvasElement; import com.codename1.html5.js.dom.HTMLDocument; import com.codename1.html5.js.dom.HTMLElement; +import com.codename1.html5.js.dom.PopStateEvent; import com.codename1.html5.js.dom.HTMLImageElement; import com.codename1.html5.js.dom.HTMLInputElement; import com.codename1.html5.js.dom.HTMLTextAreaElement; @@ -610,6 +606,40 @@ private boolean hitTest(int x, int y) { */ HTMLElement peersContainer; private HTMLElement accessibilityContainer; + + /** + * Projects the component tree into the DOM overlay above the canvas. Created lazily on the + * first semantic invalidation and reused from then on, since it retains the element-per-node + * state the incremental update depends on. + */ + private JavaScriptSemanticOverlay semanticOverlay; + + /** + * Holds the DOM elements carrying the visible text that was promoted off the canvas. + */ + private HTMLElement textLayerContainer; + + /** + * Promotes text runs off the canvas into real DOM text. Non-null once {@code __init()} has + * built its container. + */ + JavaScriptTextLayer textLayer; + + /** + * False when ?cn1TextLayer=0 asked for canvas-only text. + */ + private boolean textLayerEnabled = true; + + /** + * True once the application has read the screen back as pixels, which permanently returns + * text to the canvas so those reads can see it. + */ + private boolean textLayerDisabledByReadback; + + /** + * False when ?cn1Semantics=0 asked for no ARIA projection. + */ + private boolean semanticOverlayEnabled = true; /** @@ -1005,31 +1035,7 @@ public void run() { inputEl.setAttribute("class", "cn1-edit-string"); inputEl.getStyle().setProperty("outline", "none"); // for chrome - String inputType = "text"; - if (ta.isSingleLineTextArea()) { - - switch (ta.getConstraint()) { - case TextArea.PASSWORD: - inputType = "password"; - break; - case TextArea.EMAILADDR: - inputType = "email"; - break; - case TextArea.NUMERIC: - inputType = "number"; - break; - case TextArea.PHONENUMBER: - inputType = "tel"; - break; - case TextArea.URL: - inputType = "url"; - break; - - } - inputEl.setAttribute("type", inputType); - - - } + applyInputConstraints(inputEl, ta); @@ -1281,7 +1287,110 @@ public void beforeComponentPaint(Component c, Graphics g) { NativeOverlay no = (NativeOverlay)overlay; no.updateIfMovedAndFocused(); } - + if (textLayer != null && isDisplayGraphics(g)) { + if (!textLayer.isPainting()) { + updateTextLayerSuspension(); + } + // Whether this paint can see the whole component decides both whether its runs mean + // the full sequence and whether a shorter sequence means the rest are stale. The + // display graphics reports its clip in absolute coordinates, the space component + // bounds are in; Graphics.getClipX() subtracts the translation and would not be. + textLayer.beginComponent(c, coversComponent(c), isEditingText(c), + graphics.getClipWidth() <= 0 || graphics.getClipHeight() <= 0, + graphics.getClipX(), graphics.getClipY(), + graphics.getClipWidth(), graphics.getClipHeight()); + } + } + + /** + * True while the form is running an animation the framework schedules -- a layout + * animation, a component morph, a transition between two states of the same form. + * + * @param f the form to look at, may be null + * @return true while an animation is in flight + */ + private boolean isAnimationRunning(Form f) { + if (f == null) { + return false; + } + AnimationManager animations = f.getAnimationManager(); + return animations != null && animations.isAnimating(); + } + + /** + * Decides whether text promotion is suspended, before any component of the frame paints. + * + *

The decision cannot wait until the frame is flushed. By then the components have + * already painted under the old setting -- their strings either promoted and left off the + * canvas, or rasterized onto it -- so flipping the flag afterwards leaves the frame either + * missing its text or showing it twice.

+ */ + private void updateTextLayerSuspension() { + Form currentForm = Display.getInstance().getCurrent(); + boolean overlayBlocked = com.codename1.ui.Accessor.paintsOverChildren(currentForm); + // While an animation is running the layout moves every frame, and every frame would + // rewrite the style of every run through the bridge -- which slows the animation down + // enough to be seen. The canvas carries the text for the duration, and it comes back to + // the DOM the moment the form is still again. + boolean animating = isAnimationRunning(currentForm); + boolean shouldSuspend = Display.getInstance().isInTransition() || overlayBlocked + || animating; + if (textLayerDisabledByReadback) { + // A pixel read has already claimed the canvas as the source of truth. + return; + } + if (shouldSuspend == textLayer.isSuspended()) { + return; + } + textLayer.setSuspended(shouldSuspend); + // Only the dirty region is repainting, so whatever lies outside it still carries the + // previous representation -- and suspending hides the layer as a whole, so every run on + // the form goes with it, not only the ones the dirty region covers. Ask for a full + // repaint so the whole form agrees, whichever way the switch went. A transition needs no + // help: it painted its buffers offscreen, which always rasterizes text. + if (currentForm != null && (overlayBlocked || animating || !shouldSuspend)) { + currentForm.repaint(); + } + } + + @Override + public void afterComponentPaint(Component c, Graphics g) { + super.afterComponentPaint(c, g); + if (textLayer != null && isDisplayGraphics(g)) { + // The display graphics reports its clip in absolute coordinates, which is the space + // component bounds are in. Graphics.getClipX() subtracts the current translation, so + // it would be component-local and the comparison would almost never hold. + textLayer.endComponent(c); + } + } + + /** + * True when a paint is aimed at the display rather than at an offscreen image. + * + *

These callbacks fire for every component paint, including the ones that render into a + * buffer -- {@code Component.toImage()}, {@code ComponentImage}, a paint lock, a drag image, + * a transition buffer. Those paint through a plain {@link HTML5Graphics}, so no run is + * promoted; opening and closing a text-layer frame around them would make the component + * look like it had stopped drawing any text and release the DOM runs it still has on + * screen. Creating a drag image would then blank the labels under it until the next + * repaint.

+ */ + /** + * True when the display clip contains the whole component. + */ + private boolean coversComponent(Component c) { + if (c == null || graphics == null) { + return false; + } + int x = c.getAbsoluteX(); + int y = c.getAbsoluteY(); + return graphics.getClipX() <= x && graphics.getClipY() <= y + && graphics.getClipX() + graphics.getClipWidth() >= x + c.getWidth() + && graphics.getClipY() + graphics.getClipHeight() >= y + c.getHeight(); + } + + private boolean isDisplayGraphics(Graphics g) { + return graphics != null && com.codename1.ui.Accessor.nativeGraphics(g) == graphics; } @@ -1312,8 +1421,19 @@ public void updateNativeOverlay(Component cmp, Object nativeOverlay) { } } + /** + * The port maps every Codename One cursor onto its CSS equivalent, so applications that ask + * before setting one -- the documented way, via {@code Component.isSetCursorSupported()} -- + * get the right answer. The hover path itself remains gated on + * {@code Form.isEnableCursors()}. + */ + @Override + public boolean isSetCursorSupported() { + return true; + } + private int currCursorType; - + private void setCursor(int cursorType) { if (currCursorType != cursorType) { currCursorType = cursorType; @@ -1530,6 +1650,51 @@ private void __init() { accessibilityContainer.setAttribute("role", "application"); accessibilityContainer.getStyle().setCssText("position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;pointer-events:none;z-index:2147483646;"); outputCanvas.getParentNode().insertBefore(accessibilityContainer, outputCanvas); + // The text layer sits above the canvas but below the semantic tree. It carries the + // visible text, so it is hidden from assistive technology -- the semantic tree is what + // announces content, and without aria-hidden every label would be read twice. + // + // It takes no pointer events, which means a drag across a label does not begin a native + // text selection. Review asked for that to change; it does not, and the reason is that + // the canvas owns hit testing here. Pointer routing decides between the canvas and the + // native peers behind it by probing canvas alpha, and every gesture the application + // reacts to -- a tap on a button, a drag that scrolls a list, a swipe that opens a side + // menu -- arrives as a pointer event on the canvas. A span that answered pointer events + // would swallow the gestures that land on text, which is most of the interactive surface + // of a Codename One form, and forwarding a synthesized copy to the canvas afterwards + // gives the application either a doubled gesture or none, depending on which event is + // cancelled to let the selection through. + // + // What the layer does deliver is real text in the document: find-in-page matches it, + // the browser reads it, assistive technology can select and copy through the semantic + // tree, and it rasterizes as text rather than as pixels. Pointer selection would need + // the port's input path to accept synthesized events and to tell a selection drag from + // an application drag before either has finished -- a change to input, not to this + // layer, and not one to make quietly at the end of a rendering change. + textLayerContainer = (HTMLElement)document.createElement("div"); + textLayerContainer.setAttribute("id", "cn1-text-layer"); + textLayerContainer.setAttribute("aria-hidden", "true"); + textLayerContainer.getStyle().setCssText("position:absolute;left:0;top:0;width:100%;height:100%;overflow:hidden;pointer-events:none;z-index:2147483645;"); + outputCanvas.getParentNode().insertBefore(textLayerContainer, outputCanvas); + // ?cn1TextLayer=0 / ?cn1Semantics=0 turn the two DOM layers off at runtime. Both are + // new behaviour layered onto a canvas renderer, so being able to take one out without + // rebuilding is what makes a rendering or timing regression bisectable. + textLayerEnabled = !"0".equals(getParameterByName("cn1TextLayer")); + semanticOverlayEnabled = !"0".equals(getParameterByName("cn1Semantics")); + if (textLayerEnabled) { + textLayer = new JavaScriptTextLayer(document, textLayerContainer); + } + if (textLayerEnabled) { + // Paint locking caches a component's pixels in an image and serves that image + // instead of painting, returning from Component.paintInternal() before the + // per-component hooks run. A locked component therefore stops reporting its text + // while its DOM runs stay on screen, so the cached image's rasterized text and the + // live DOM text are both visible. The two cannot be reconciled, and the lock is only + // an optimisation, so it is switched off -- but only when there are DOM runs for it + // to conflict with. With the text layer off the canvas-only path keeps the caching + // that Tabs, among others, relies on during a swipe. + Display.getInstance().setProperty("paintLockEnabled", "false"); + } outputCanvas.setAttribute("role", "presentation"); outputCanvas.setAttribute("aria-hidden", "true"); @@ -1587,23 +1752,14 @@ public void dispatchMessage(String message, int code) { } }; + // The one popstate handler. It used to run the back command directly, with no notion of + // which direction the traversal went or of the entries the port itself spends, so it is + // routed through the history logic instead of having a second listener beside it -- + // which would make a single Back run two back commands. final EventListener popstateListener = new EventListener() { @Override public void handleEvent(Event evt) { - JavaScriptBrowserLifecycleCoordinator.handlePopState(new JavaScriptBrowserLifecycleCoordinator.BackNavigationHooks() { - @Override - public void callSerially(Runnable runnable) { - HTML5Implementation.this.callSerially(runnable); - } - - @Override - public void runBackCommand() { - Form f = getCurrentForm(); - if (f != null && f.getBackCommand() != null) { - f.getBackCommand().actionPerformed(new ActionEvent(f, ActionEvent.Type.Other)); - } - } - }); + handlePopStateEvent(evt); } }; @@ -3341,6 +3497,36 @@ public void setGraphicsLocked(boolean locked) { if (frame.isEmpty()) { return false; } + if (textLayer != null) { + // A form transition paints two pre-rendered offscreen buffers instead of painting + // components, so no run is refreshed while it runs. Those buffers carry their own + // rasterized text, so the layer must step aside for the duration or the outgoing + // form's text would float above the animation. + // A buffered transition -- a fade, where areMutableImagesFast() is true -- paints + // only its prebuilt images and never puts a component through the display graphics, + // so the frame-start hook never runs and would leave the outgoing form's DOM text + // fixed above the animation for its whole duration. Catch it here, which does run + // every display frame. + // + // Suspending only, never resuming: the components of this frame have already + // painted, so lifting the suspension now would show runs that this frame's canvas + // also rasterized. Resuming is left to the frame-start hook, which runs before any + // component paints. + if (!textLayer.isSuspended() && Display.getInstance().isInTransition()) { + textLayer.setSuspended(true); + } + // Releases runs whose component has been removed, hidden, or whose form is no + // longer displayed; none of those ever paints again, so nothing else would clean + // them up. + Form displayed = Display.getInstance().getCurrent(); + textLayer.syncToForm(displayed); + if (textLayer.consumeReattachFlag() && displayed != null) { + // A run came back after being detached, so it holds a fresh stacking index + // while everything that did not repaint still holds an older one. One full + // repaint puts the whole form on the same footing. + displayed.repaint(); + } + } // Record the whole frame into the display surface's command buffer (the // display graphics draws onto DISPLAY_SURFACE_ID) and ship it in one // flush. The host replays it onto the output canvas -- the worker never @@ -3402,9 +3588,35 @@ public void setGraphicsLocked(boolean locked) { ClipRect.resetClip(context, graphics.getClipState()); context.restore(); graphics.flush(); + // The batch is on its way, so the sources it blits can be collected again. + blitSourcesInFlight.clear(); return true; } + /** + * Images whose surface a recorded blit refers to, held until the batch carrying it has been + * shipped. + * + *

A blit records a surface id, not the image. An image drawn into and blitted within one + * paint -- which is what a component rendering through a fresh mutable image every frame + * does -- is unreachable the moment that paint returns, so its finalizer can tell the host + * to release the surface while the batch that blits it is still waiting to be replayed. The + * result is a blank where the image should be. Holding the image until the batch is on its + * way closes that window.

+ */ + private final List blitSourcesInFlight = new ArrayList(); + + /** + * Keeps a blit's source image alive until the batch referring to it has been shipped. + * + * @param source the image whose surface a recorded blit refers to + */ + void retainBlitSource(Object source) { + if (source != null) { + blitSourcesInFlight.add(source); + } + } + private void scheduleAnimationFrame() { requestAnimationFrameNative(animationFrameHandler); } @@ -3697,6 +3909,7 @@ public void run() { } private void updateCanvasSize() { + refreshDevicePixelRatio(); JavaScriptCanvasLayout.Dimensions dimensions = JavaScriptCanvasLayout.compute( doc().getBody().getClientWidth(), window.getInnerHeight(), @@ -3716,6 +3929,14 @@ private void updateCanvasSize() { outputCanvas.getStyle().setProperty("height", dimensions.getStyleHeight()); canvas.getStyle().setProperty("width", dimensions.getStyleWidth()); canvas.getStyle().setProperty("height", dimensions.getStyleHeight()); + } else { + // Back to a 1x display, where the backing store is the CSS size and no override is + // wanted. Leaving the HiDPI width/height behind would stretch the new store over the + // old dimensions and put the canvas and the overlays out of alignment. + outputCanvas.getStyle().removeProperty("width"); + outputCanvas.getStyle().removeProperty("height"); + canvas.getStyle().removeProperty("width"); + canvas.getStyle().removeProperty("height"); } } @@ -3731,188 +3952,86 @@ public void revalidate() { @Override public void accessibilityTreeChanged(int changeType) { - if (accessibilityContainer == null) return; - AccessibilityTreeSnapshot tree = getAccessibilityTreeSnapshot(); - accessibilityContainer.setInnerHTML(""); - final Map elements = new HashMap(); - for (AccessibilityNodeSnapshot node : tree.getNodes().values()) { - final long nodeId = node.getId(); - final HTMLElement element = (HTMLElement)document.createElement("div"); - elements.put(Long.valueOf(nodeId), element); - element.setAttribute("data-cn1-accessibility-id", String.valueOf(nodeId)); - applyAria(element, node); - if (node.getParentId() >= 0 && elements.get(Long.valueOf(node.getParentId())) != null) { - elements.get(Long.valueOf(node.getParentId())).appendChild(element); - } else { - accessibilityContainer.appendChild(element); - } - final AccessibilityAction activate = node.getAction(AccessibilityAction.ACTIVATE); - if (activate != null && activate.isEnabled()) { - element.addEventListener("click", new EventListener() { - public void handleEvent(Event event) { - event.preventDefault(); - event.stopPropagation(); - performAccessibilityAction(nodeId, AccessibilityAction.ACTIVATE, null); - } - }); - } - final AccessibilityAction focus = node.getAction(AccessibilityAction.FOCUS); - if (focus != null && focus.isEnabled()) { - element.addEventListener("focus", new EventListener() { - public void handleEvent(Event event) { - performAccessibilityAction(nodeId, AccessibilityAction.FOCUS, null); - } - }); - } - element.addEventListener("keydown", new EventListener() { - public void handleEvent(Event event) { - JSOImplementations.KeyEvent key = (JSOImplementations.KeyEvent)event; - int code = key.getKeyCode(); - if ((code == 13 || code == 32) && activate != null) { - event.preventDefault(); - performAccessibilityAction(nodeId, AccessibilityAction.ACTIVATE, null); - } else if (code == 38 || code == 39) { - performAccessibilityAction(nodeId, AccessibilityAction.INCREMENT, null); - } else if (code == 37 || code == 40) { - performAccessibilityAction(nodeId, AccessibilityAction.DECREMENT, null); - } - } - }); - addWebCustomActions(element, node); + if (accessibilityContainer == null || !semanticOverlayEnabled) { + return; + } + if (semanticOverlay == null) { + semanticOverlay = new JavaScriptSemanticOverlay(document, accessibilityContainer, + new JavaScriptSemanticOverlay.ActionDispatcher() { + @Override + public void performAction(long nodeId, String actionId, Object argument) { + performAccessibilityAction(nodeId, actionId, argument); + } + }); + } + // Always on: the reasoning is recorded in the overlay, but in short a screen reader has + // no other reliable route to an ordinary label, since the visible text layer is + // aria-hidden and a role-less div's aria-label is not dependably announced. + semanticOverlay.setTextContentEnabled(true); + if (deferSemanticsWhileAnimating()) { + return; } + semanticOverlay.update(getAccessibilityTreeSnapshot(), getDevicePixelRatio()); } - @Override - public boolean isAccessibilityTreeSupported() { + /** + * Holds the semantic tree still while an animation runs. + * + *

Every frame of an animation moves components, and every move invalidates the tree -- + * so the overlay would diff and rewrite geometry across the bridge on every frame of it. + * Assistive technology has nothing to gain from following an animation frame by frame, and + * the cost is paid by the animation itself, which runs visibly slower for it. The tree is + * brought up to date once, when the form is still again.

+ * + * @return true when the update was put off + */ + private boolean deferSemanticsWhileAnimating() { + if (!isAnimationRunning(Display.getInstance().getCurrent())) { + return false; + } + if (!semanticRefreshPending) { + semanticRefreshPending = true; + awaitStillForSemantics(System.currentTimeMillis() + SEMANTIC_STILLNESS_TIMEOUT_MILLIS); + } return true; } - private void applyAria(HTMLElement element, AccessibilityNodeSnapshot node) { - String role = ariaRole(node.getRole()); - if (role != null) element.setAttribute("role", role); - if (node.getLabel() != null) element.setAttribute("aria-label", node.getLabel()); - String description = node.getDescription(); - if (node.getHint() != null) description = description == null ? node.getHint() : description + ". " + node.getHint(); - if (node.getValidationError() != null) description = description == null ? node.getValidationError() : description + ". " + node.getValidationError(); - if (description != null) element.setAttribute("aria-description", description); - if (node.getIdentifier() != null) element.setAttribute("id", node.getIdentifier()); - if (node.getRoleDescription() != null) element.setAttribute("aria-roledescription", node.getRoleDescription()); - if (node.getValue() != null) element.setAttribute("aria-valuetext", node.getValue()); - if (node.getSelected() != null) element.setAttribute("aria-selected", String.valueOf(node.getSelected())); - if (node.getExpanded() != null) element.setAttribute("aria-expanded", String.valueOf(node.getExpanded())); - if (node.getEnabled() != null && !node.getEnabled().booleanValue()) element.setAttribute("aria-disabled", "true"); - if (node.getInvalid() != null) element.setAttribute("aria-invalid", String.valueOf(node.getInvalid())); - if (node.getBusy() != null) element.setAttribute("aria-busy", String.valueOf(node.getBusy())); - if (node.getReadOnly() != null) element.setAttribute("aria-readonly", String.valueOf(node.getReadOnly())); - if (node.getRequired() != null) element.setAttribute("aria-required", String.valueOf(node.getRequired())); - if (node.getMultiline() != null) element.setAttribute("aria-multiline", String.valueOf(node.getMultiline())); - if (node.getCurrent() != null && node.getCurrent().booleanValue()) element.setAttribute("aria-current", "true"); - if (node.isModal()) element.setAttribute("aria-modal", "true"); - if (node.getHeadingLevel() > 0) element.setAttribute("aria-level", String.valueOf(node.getHeadingLevel())); - if (node.getChecked() != AccessibilityCheckedState.UNSPECIFIED) { - element.setAttribute("aria-checked", node.getChecked() == AccessibilityCheckedState.MIXED - ? "mixed" : String.valueOf(node.getChecked() == AccessibilityCheckedState.CHECKED)); - } - if (node.getPressed() != null) element.setAttribute("aria-pressed", String.valueOf(node.getPressed())); - if (node.getLiveRegion() != AccessibilityLiveRegion.OFF) { - element.setAttribute("aria-live", node.getLiveRegion() == AccessibilityLiveRegion.ASSERTIVE ? "assertive" : "polite"); - element.setAttribute("aria-atomic", "true"); - } - AccessibilityRange range = node.getRange(); - if (range != null) { - element.setAttribute("aria-valuemin", String.valueOf(range.getMinimum())); - element.setAttribute("aria-valuemax", String.valueOf(range.getMaximum())); - element.setAttribute("aria-valuenow", String.valueOf(range.getCurrent())); - if (range.getText() != null) element.setAttribute("aria-valuetext", range.getText()); - } - AccessibilityCollectionItemInfo item = node.getCollectionItemInfo(); - if (item != null) { - if (item.getPositionInSet() > 0) element.setAttribute("aria-posinset", String.valueOf(item.getPositionInSet())); - if (item.getSetSize() != 0) element.setAttribute("aria-setsize", String.valueOf(item.getSetSize())); - if (item.getLevel() > 0) element.setAttribute("aria-level", String.valueOf(item.getLevel())); - if (item.getRowIndex() >= 0) element.setAttribute("aria-rowindex", String.valueOf(item.getRowIndex() + 1)); - if (item.getColumnIndex() >= 0) element.setAttribute("aria-colindex", String.valueOf(item.getColumnIndex() + 1)); - if (item.getRowSpan() > 1) element.setAttribute("aria-rowspan", String.valueOf(item.getRowSpan())); - if (item.getColumnSpan() > 1) element.setAttribute("aria-colspan", String.valueOf(item.getColumnSpan())); - } - Rectangle bounds = node.getBounds(); - double ratio = getDevicePixelRatio(); - element.getStyle().setCssText("position:absolute;opacity:0.001;pointer-events:none;overflow:hidden;" - + "left:" + bounds.getX() / ratio + "px;top:" + bounds.getY() / ratio + "px;" - + "width:" + Math.max(1, bounds.getWidth()) / ratio + "px;height:" - + Math.max(1, bounds.getHeight()) / ratio + "px;"); - if (node.isFocusable()) element.setTabIndex(0); - else element.setTabIndex(-1); - if (node.getRole() == AccessibilityRole.STATIC_TEXT || node.getRole() == AccessibilityRole.HEADING) { - element.setTextContent(node.getLabel() == null ? "" : node.getLabel()); - } - } - - private void addWebCustomActions(HTMLElement parent, AccessibilityNodeSnapshot node) { - for (final AccessibilityAction action : node.getActions()) { - if (!action.isEnabled() || isStandardWebAction(action.getId())) continue; - final long nodeId = node.getId(); - HTMLElement button = (HTMLElement)document.createElement("button"); - button.setAttribute("type", "button"); - button.setAttribute("aria-label", action.getLabel() == null ? action.getId() : action.getLabel()); - button.setTextContent(action.getLabel() == null ? action.getId() : action.getLabel()); - button.getStyle().setCssText("position:absolute;opacity:0.001;pointer-events:none;width:1px;height:1px;"); - button.addEventListener("click", new EventListener() { - public void handleEvent(Event event) { - event.preventDefault(); - performAccessibilityAction(nodeId, action.getId(), null); + /** + * How long the tree is held still for an animation before it is brought up to date anyway. + * On the clock rather than on turns of the event loop: an idle loop turns many times a + * frame, so a count of turns runs out in a fraction of an animation and starts again on the + * next invalidation -- rebuilding through the animation this is meant to sit out. On the + * clock, an animation that never ends costs one refresh per interval. + */ + private static final long SEMANTIC_STILLNESS_TIMEOUT_MILLIS = 2000; + + private void awaitStillForSemantics(final long deadline) { + callSerially(new Runnable() { + @Override + public void run() { + if (System.currentTimeMillis() < deadline + && isAnimationRunning(Display.getInstance().getCurrent())) { + awaitStillForSemantics(deadline); + return; } - }); - parent.appendChild(button); - } - } - - private boolean isStandardWebAction(String id) { - return AccessibilityAction.ACTIVATE.equals(id) || AccessibilityAction.FOCUS.equals(id) - || AccessibilityAction.INCREMENT.equals(id) || AccessibilityAction.DECREMENT.equals(id) - || AccessibilityAction.SET_TEXT.equals(id) || AccessibilityAction.SCROLL_FORWARD.equals(id) - || AccessibilityAction.SCROLL_BACKWARD.equals(id); - } - - private String ariaRole(AccessibilityRole role) { - switch (role) { - case BUTTON: - case TOGGLE_BUTTON: return "button"; - case CHECKBOX: return "checkbox"; - case RADIO_BUTTON: return "radio"; - case SWITCH: return "switch"; - case HEADING: return "heading"; - case LINK: return "link"; - case IMAGE: return "img"; - case TEXT_FIELD: return "textbox"; - case SEARCH_FIELD: return "searchbox"; - case SLIDER: return "slider"; - case PROGRESS_BAR: return "progressbar"; - case LIST: return "list"; - case LIST_ITEM: return "listitem"; - case GRID: return "grid"; - case ROW: return "row"; - case CELL: return "gridcell"; - case COLUMN_HEADER: return "columnheader"; - case ROW_HEADER: return "rowheader"; - case TAB_LIST: return "tablist"; - case TAB: return "tab"; - case TAB_PANEL: return "tabpanel"; - case DIALOG: return "dialog"; - case ALERT: return "alert"; - case MENU: return "menu"; - case MENU_ITEM: return "menuitem"; - case TOOLBAR: return "toolbar"; - case SCROLL_BAR: return "scrollbar"; - case SPIN_BUTTON: return "spinbutton"; - case COMBO_BOX: return "combobox"; - case TREE: return "tree"; - case TREE_ITEM: return "treeitem"; - case SEPARATOR: return "separator"; - case GENERIC: return "group"; - default: return null; - } + semanticRefreshPending = false; + if (semanticOverlay != null && accessibilityContainer != null) { + semanticOverlay.update(getAccessibilityTreeSnapshot(), getDevicePixelRatio()); + } + } + }); + } + + /** + * True while an update is waiting for the form to stop animating. + */ + private boolean semanticRefreshPending; + + @Override + public boolean isAccessibilityTreeSupported() { + return true; } + public static void setMainClass(Object main) { JavaScriptBootstrapCoordinator.bindMainClass(main, @@ -4425,23 +4544,20 @@ private static boolean isIOS13() { private static native boolean isIPad(); - // Codename One has always preferred to work in CSS pixels (logical - // "real" pixels) end-to-end on the JS port -- we don't auto-scale to - // device pixels. Defaulting ``overridePixelRatio`` to 1 keeps: - // * the canvas backing dimensions equal to CSS dimensions (no - // HiDPI 2x backing surface), - // * pointer-event coordinates flowing through unmultiplied (so a - // click at CSS (574, 455) is delivered to Form.pointerPressed - // as (574, 455), not (1148, 910) on a retina display), - // * scaleCoord / unscaleCoord becoming no-ops. - // Anyone who specifically wants HiDPI rendering can opt in via the - // ``?pixelRatio=2`` URL parameter. + // Codename One addresses DEVICE pixels. The iOS port detects the retina factor and + // multiplies/divides the values it hands the native primitives, so the framework + // draws at the display's real resolution; this port works the same way, with + // scaleCoord/unscaleCoord converting at the DOM boundary (peers, overlays, pointer + // coordinates) and nowhere else. + // + // This used to default to 1, which made the canvas backing store equal to the CSS + // size -- so on any HiDPI display the browser upscaled a 1x bitmap, softening + // everything and text most visibly. ``?pixelRatio=N`` still pins a specific factor + // for the screenshot harness and the skin designer. @JSBody(params={}, script="if (window.overridePixelRatio === undefined) {" + " var ratioStr = getParameterByName('pixelRatio');" + " if (ratioStr != '') {" + " window.overridePixelRatio = parseFloat(ratioStr);" - + " } else {" - + " window.overridePixelRatio = 1;" + " }" + " if (window.cn1ScaleCoord === undefined){ window.cn1ScaleCoord = function(x) { return x===-1?-1:x/(window.overridePixelRatio || window.devicePixelRatio || 1.0);};}" + " if (window.cn1UnscaleCoord === undefined){ window.cn1UnscaleCoord = function(x) { return x===-1?-1:x*(window.overridePixelRatio || window.devicePixelRatio || 1.0);};}" @@ -4453,6 +4569,48 @@ private static boolean isIOS13() { @JSBody(params={"name"}, script="return getParameterByName(name);") static native String getParameterByName(String name); + /** + * Re-reads the display's scale factor from the main thread. + * + *

The cached value is taken once at start-up, but the ratio changes when the page is + * zoomed or the window moves between displays. Left stale, the canvas backing store no + * longer matches the display and the browser rescales it -- the same softening this port + * used to have by rendering at 1x. Read on resize only, which is when it can change and is + * rare enough for a round trip.

+ */ + private void refreshDevicePixelRatio() { + try { + // ?pixelRatio=N pins the factor deliberately -- the screenshot harness and the skin + // designer depend on it -- so the physical ratio must not overwrite it. + String override = getParameterByName("pixelRatio"); + if (override != null && override.length() > 0) { + return; + } + double ratio = window.getDevicePixelRatio(); + if (ratio > 0 && ratio != devicePixelRatio) { + devicePixelRatio = ratio; + // Density, the styles resolved from it and the font sizes derived from those are + // all fixed at the ratio in force when they were resolved. Moving between + // displays would otherwise keep a 36 device-pixel font at 36 CSS pixels, so text + // and controls would roughly double in size. + dDensity = -1; + // Derived from the density, and cached on first use, so it has to go with it -- + // otherwise convertToPixels() keeps answering in the old density's units while + // getDeviceDensity() reports the new one. + ppi = 0; + // Copied from when a font carries no height of its own, so it has to be current + // before any of those copies are taken. + if (defaultFont != null) { + defaultFont.syncDensity(); + } + themeGeneration++; + refreshThemeIfStale(getCurrentForm()); + } + } catch (Throwable ignored) { + // Keep whatever was resolved at start-up. + } + } + static double getDevicePixelRatio() { if (devicePixelRatio < 0) { devicePixelRatio = getDevicePixelRatio_(); @@ -5707,6 +5865,138 @@ public boolean isNativePickerTypeSupported(int pickerType) { private boolean nextEditPending, prevEditPending; + /** + * Configures the native editing element from a text component's constraint. + * + *

The constraint is a base type in the low bits with flags above it, so it has to be + * masked rather than compared whole -- {@code PASSWORD} is {@code 0x10000}, which means a + * field declared {@code PASSWORD | EMAILADDR} previously matched no case at all and was + * edited as clear text.

+ * + *

Beyond the input type this carries the attributes a browser actually reads: + * {@code inputmode} selects the on-screen keyboard, {@code autocomplete} is what lets a + * password manager or address autofill offer a value, and {@code autocapitalize} / + * {@code spellcheck} reproduce what the equivalent constraint does on a native platform. + * An application can override the autocomplete token -- to distinguish a sign-in field + * from a change-password field, say -- with the {@code cn1$autocomplete} client property.

+ */ + private String applyInputConstraints(HTMLInputElement inputEl, TextArea ta) { + return applyTextInputConstraints(inputEl, ta, ta.isSingleLineTextArea()); + } + + /** + * The same constraint metadata, for an element this class did not build. + * + *

The editor is not the only place a field is typed into: the accessibility overlay + * exposes an editable field to a screen reader through a control of its own, and a control + * that skipped this would offer the wrong keyboard and would let prediction and autofill + * reach a field whose constraint forbids them. The shape of that control is decided by the + * caller rather than read from the component -- an obscured field is given a single-line + * masking input whether or not the component is multiline -- so whether to write a + * {@code type} at all is passed in instead of asked of the {@code TextArea}.

+ * + * @param inputEl the element to configure, an input or a textarea + * @param ta the field the element edits + * @param singleLine true when the element is an input and so carries a type + * @return the resolved input type, "text" for anything without one of its own + */ + static String applyTextInputConstraints(HTMLElement inputEl, TextArea ta, boolean singleLine) { + int constraint = ta.getConstraint(); + int base = constraint & 0xffff; + boolean password = (constraint & TextArea.PASSWORD) != 0; + boolean sensitive = (constraint & TextArea.SENSITIVE) != 0 + || (constraint & TextArea.NON_PREDICTIVE) != 0; + boolean username = (constraint & TextArea.USERNAME) != 0; + + String resolvedType = "text"; + if (password) { + resolvedType = "password"; + } else if (base == TextArea.EMAILADDR) { + resolvedType = "email"; + } else if (base == TextArea.NUMERIC) { + resolvedType = "number"; + } else if (base == TextArea.PHONENUMBER) { + resolvedType = "tel"; + } else if (base == TextArea.URL) { + resolvedType = "url"; + } + if (singleLine) { + inputEl.setAttribute("type", resolvedType); + } else { + // A textarea has no type attribute, and callers treat "text" as the plain case. + resolvedType = "text"; + } + + String inputMode = null; + if (!password) { + if (base == TextArea.NUMERIC) { + inputMode = "numeric"; + } else if (base == TextArea.DECIMAL) { + inputMode = "decimal"; + } else if (base == TextArea.PHONENUMBER) { + inputMode = "tel"; + } else if (base == TextArea.EMAILADDR) { + inputMode = "email"; + } else if (base == TextArea.URL) { + inputMode = "url"; + } + } + if (inputMode == null) { + inputEl.removeAttribute("inputmode"); + } else { + inputEl.setAttribute("inputmode", inputMode); + } + + Object override = ta.getClientProperty("cn1$autocomplete"); + String autocomplete; + if (override != null) { + autocomplete = override.toString(); + } else if (sensitive) { + // Checked ahead of the password case on purpose: SENSITIVE asks that the value is + // never retained for predictive or completing schemes, and "current-password" is an + // explicit invitation to offer a stored one. + autocomplete = "off"; + } else if (password) { + autocomplete = "current-password"; + } else if (username) { + // Ahead of the address-style tokens: an application that marks a field as the + // username has said what it is for, and a password manager needs that token to + // pair it with the password field rather than treating the two as unrelated. An + // email address used as a username is still the username here. + autocomplete = "username"; + } else if (base == TextArea.EMAILADDR) { + autocomplete = "email"; + } else if (base == TextArea.PHONENUMBER) { + autocomplete = "tel"; + } else if (base == TextArea.URL) { + autocomplete = "url"; + } else { + autocomplete = "on"; + } + inputEl.setAttribute("autocomplete", autocomplete); + + // A stable name lets a password manager pair a username with a password rather than + // treating each edit as an unrelated field. + String name = ta.getName(); + if (name == null || name.length() == 0) { + inputEl.removeAttribute("name"); + } else { + inputEl.setAttribute("name", name); + } + + String autoCapitalize = "none"; + if ((constraint & TextArea.INITIAL_CAPS_WORD) != 0) { + autoCapitalize = "words"; + } else if ((constraint & TextArea.INITIAL_CAPS_SENTENCE) != 0) { + autoCapitalize = "sentences"; + } + inputEl.setAttribute("autocapitalize", autoCapitalize); + inputEl.setAttribute("spellcheck", + password || sensitive || base == TextArea.EMAILADDR || base == TextArea.URL + ? "false" : "true"); + return resolvedType; + } + @Override public void editString(final Component cmp, int maxSize, int constraint, final String origText, int initiatingKeycode) { if (cmp.getNativeOverlay() != null) { @@ -5981,32 +6271,7 @@ public void run() { inputEl.getStyle().setProperty("margin", "0"); inputEl.getStyle().setProperty("outline", "none"); // for chrome - int cnst = ta.getConstraint(); - String inputType = "text"; - if (ta.isSingleLineTextArea()) { - - switch (cnst) { - case TextArea.PASSWORD: - inputType = "password"; - break; - case TextArea.EMAILADDR: - inputType = "email"; - break; - case TextArea.NUMERIC: - inputType = "number"; - break; - case TextArea.PHONENUMBER: - inputType = "tel"; - break; - case TextArea.URL: - inputType = "url"; - break; - - } - inputEl.setAttribute("type", inputType); - - - } + final String inputType = applyInputConstraints(inputEl, ta); @@ -6466,6 +6731,7 @@ private void finishTextEditing(){ @Override public void flushGraphics(int x, int y, int width, int height) { + displayFlushes++; JavaScriptRenderQueueCoordinator.waitUntilFlushable(new JavaScriptRenderQueueCoordinator.FlushBarrier() { @Override public boolean isGraphicsLocked() { @@ -6519,7 +6785,7 @@ public void flushGraphics() { } @Override - public void screenshot(SuccessCallback callback) { + public void screenshot(final SuccessCallback callback) { if (callback == null) { return; } @@ -6527,6 +6793,138 @@ public void screenshot(SuccessCallback callback) { super.screenshot(callback); return; } + // Reading the screen back as pixels and promoting text into the DOM cannot both be + // authoritative: a surface read cannot see a DOM layer, so a capture taken while text is + // promoted comes back with its labels missing. An application that reads pixels is + // telling us which representation it needs, so promotion stops for good at the first + // such call and the text returns to the canvas, where the reads can see it. + // + // The first capture then has to wait for a frame that was actually painted with + // promotion off. That paint is NOT driven from inside this call: calling paintDirty() + // here re-enters the render queue at a point it is not built for, and it measurably + // breaks rendering -- graphics-draw-gradient-stops came back with its gradients + // unpainted, reproducibly and across a re-run, while every other golden was unchanged. + // The repaint is requested and the read deferred instead, which this API can do because + // it answers through a callback. The read then waits for the painting to actually stop + // rather than for a fixed number of event-thread hops: a form whose paint takes more + // than one flush -- this application's image grid does -- can be caught between them, + // and what comes back is a frame with the last panels still unpainted. + if (readbackRepaintPending) { + // A capture is already waiting for the frame that puts the text back on the canvas. + // Reading now would hand this caller the canvas as it stands, which is the one + // missing every promoted glyph, so this capture waits for the same frame. + pendingReadbacks.add(callback); + return; + } + if (textLayer != null && !textLayer.isSuspended()) { + textLayer.setSuspended(true); + textLayerDisabledByReadback = true; + readbackRepaintPending = true; + // The semantic overlay stops mirroring labels while the text layer renders them and + // starts again once it does not. Nothing else would notice the switch, so ask for a + // semantic refresh here -- otherwise find-in-page would keep finding nothing until + // some unrelated invalidation happened along. + com.codename1.ui.accessibility.AccessibilityManager.getInstance().invalidateAll(); + Form current = getCurrentForm(); + if (current != null) { + current.repaint(); + } + awaitPaintedFrame(new Runnable() { + @Override + public void run() { + readbackRepaintPending = false; + // Whatever else asked for a capture while this one was waiting reads + // the same frame, rather than the one before the text came back. The + // waiting list is taken and emptied before any of them run: a + // callback that throws would otherwise leave the rest of the list + // standing with nothing left to drain it -- the flag that sends a + // capture to the queue is already down, so every later capture would + // take the immediate path and walk straight past them. + List> waiting = + new ArrayList>(pendingReadbacks); + pendingReadbacks.clear(); + deliverReadback(callback); + for (int i = 0; i < waiting.size(); i++) { + deliverReadback(waiting.get(i)); + } + } + }); + return; + } + readDisplaySurface(callback); + } + + /** + * Hands one waiting capture its frame, keeping its failure to itself. + * + *

These callbacks belong to unrelated callers that happened to ask during the same + * frame, so one of them throwing is not a reason for the others to go unanswered.

+ * + * @param callback the capture to satisfy + */ + private void deliverReadback(SuccessCallback callback) { + try { + readDisplaySurface(callback); + } catch (Throwable t) { + Log.e(t); + } + } + + /** + * Runs the given work once the display has stopped painting. + * + *

Counts flushes rather than event-thread hops. A repaint is not one flush: a form can + * paint over several, and a read taken between them returns a frame whose last components + * were never drawn -- blank where the application had put something. Two consecutive checks + * with no flush in between mean the frame is finished.

+ * + *

Bounded, because a form that animates never stops flushing and a capture still has to + * be answered: after enough checks the read goes ahead with whatever is on the canvas, which + * is what it would have done immediately before.

+ * + * @param work what to run once the frame has settled + */ + private void awaitPaintedFrame(final Runnable work) { + final int[] state = new int[] { displayFlushes, 0, 0 }; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + boolean flushed = displayFlushes != state[0]; + state[0] = displayFlushes; + state[1] = flushed ? 0 : state[1] + 1; + state[2]++; + if (state[1] >= 2 || state[2] >= 60) { + work.run(); + return; + } + Display.getInstance().callSerially(this); + } + }); + } + + /** + * How many times the display surface has been flushed. Only ever compared with itself, to + * tell a frame that is still being painted from one that has settled. + */ + private int displayFlushes; + + /** + * True while a capture is waiting for the frame that rasterizes the text the layer had + * promoted. Any capture asked for in the meantime waits for that frame too. + */ + private boolean readbackRepaintPending; + + /** + * Captures asked for while that frame is on its way. + */ + private final List> pendingReadbacks = new ArrayList>(); + + /** + * Reads the display surface back and hands the pixels to the caller. + * + * @param callback receives the captured image + */ + private void readDisplaySurface(SuccessCallback callback) { flushGraphics(); final int width = getDisplayWidth(); final int height = getDisplayHeight(); @@ -8306,6 +8704,7 @@ public void drawShadow(Object graphics, Object image, int x, int y, int offsetX, ctx.drawImage(src.img, parkX, parkY); } else if (src.mutableGraphics != null) { src.mutableGraphics.flush(); + retainBlitSource(src); ((SurfaceCommandRecorder)ctx).blitSurface(src.mutableGraphics.getSurfaceId(), parkX, parkY, -1, -1); } ctx.restore(); @@ -8542,10 +8941,34 @@ public void drawArc(Object graphics, int x, int y, int width, int height, int st g(graphics).drawArc(x,y,width,height,startAngle, arcAngle); } + /** + * Draws text that carries a decoration. + * + *

The lines an underline or a strike-through are made of are drawn after the glyphs and + * over them. Promoting the glyphs into the DOM layer would lift them above the whole + * canvas, and the line meant to cross the text would end up behind it, broken wherever a + * glyph stands. Decorated runs therefore stay on the canvas entirely.

+ */ @Override - public void drawString(Object graphics, String str, int x, int y) { - g(graphics).drawString(str, x, y); - } + public void drawString(Object nativeGraphics, Object nativeFont, String str, int x, int y, + int textDecoration) { + if (textDecoration == 0 || !(g(nativeGraphics) instanceof BufferedGraphics)) { + super.drawString(nativeGraphics, nativeFont, str, x, y, textDecoration); + return; + } + BufferedGraphics buffered = (BufferedGraphics) g(nativeGraphics); + buffered.setPromotionSuspended(true); + try { + super.drawString(nativeGraphics, nativeFont, str, x, y, textDecoration); + } finally { + buffered.setPromotionSuspended(false); + } + } + + @Override + public void drawString(Object graphics, String str, int x, int y) { + g(graphics).drawString(str, x, y); + } @Override public void drawShape(Object graphics, Shape shape, Stroke stroke) { @@ -9121,30 +9544,46 @@ public boolean isTrueTypeSupported() { + "if (density) return parseInt(density); else return 0;") private native static int getDensityOverride(); - @Override - public Object createFont(int face, int style, int size) { - - int height = getBaseFontSize(); - if (height == 0) { - height = 16; - } + /** + * How much a font is scaled for the display's density. + * + *

Font heights are kept in device pixels, and this is the band the base size is scaled + * by when a font is created. Keeping an existing font current as the display changes is a + * different question, answered by the pixel ratio rather than the band -- see + * {@code NativeFont.syncDensity()}.

+ * + * @return the multiplier applied to the base font size + */ + private double fontDensityFactor() { switch (getDeviceDensity()) { case Display.DENSITY_LOW: case Display.DENSITY_VERY_LOW: - height = height/2; break; + return 0.5; case Display.DENSITY_HIGH: - height = height + height/2; break; + return 1.5; case Display.DENSITY_VERY_HIGH: - height = height * 2; break; + return 2; case Display.DENSITY_HD: - height = height * 3; break; + return 3; case Display.DENSITY_560: - height = height * 4; break; + return 4; case Display.DENSITY_2HD: - height = height * 5; break; + return 5; case Display.DENSITY_4K: - height = height * 6; break; + return 6; + default: + return 1; } + } + + @Override + public Object createFont(int face, int style, int size) { + + int height = getBaseFontSize(); + if (height == 0) { + height = 16; + } + height = (int) (height * fontDensityFactor()); int diff = height / 3; switch (size) { @@ -10461,14 +10900,519 @@ public String getBuildVersion() { return buildVersion; } - @JSBody(script="try {history.pushState(\"jibberish\", null, null)} catch (e){console.log('history.pushState not supported. Back command will not work.')}") - private native static void pushHistoryState(); - + /** + * True while a back command is being dispatched in response to popstate, so the form change + * it causes does not push a new entry and trap the user in the app. + */ + private boolean handlingPopState; + + /** + * Pushes a history entry so the browser's Back button has something to pop. + * + *

This used to be a {@code @JSBody}, which is compiled into the worker -- where there is + * no {@code history} object, so it threw on every form change and the port logged that the + * back command would not work. Going through the window binding puts the call on the main + * thread, where the History API actually exists.

+ */ + /** + * Monotonic id stamped into each pushed history entry. popstate fires for forward + * traversal too, and comparing the restored id against this one is what tells the two + * apart. + */ + private int historyIndex; + + /** + * The highest id this session has pushed. + * + *

An entry carrying more than this was not created by this session, whatever it says: the + * entry the page was loaded on keeps the id a previous life of the application left there, + * and a reload starts the counters again from zero. Every entry the port can traverse to + * carries an id it pushed, so anything above this belongs to the page rather than the + * application -- which is what tells a stale id apart from a genuine Forward.

+ */ + private int historyHighWater; + + private void pushHistoryState() { + if (handlingPopState) { + return; + } + if (historyUnavailable) { + return; + } + try { + historyIndex++; + window.getHistory().pushState(HISTORY_STATE_PREFIX + historyIndex, ""); + historyHighWater = Math.max(historyHighWater, historyIndex); + historyEntriesPushed++; + } catch (Throwable ignored) { + // A sandboxed or file:// document can refuse pushState. Back stays inert from here + // on: the entry was never created, and going on as though it had been would have a + // later in-app back traverse history that does not belong to this application -- + // out of the document, rather than back a form. + historyIndex--; + historyUnavailable = true; + } + } + + /** + * Traversals the port itself asked the browser to make, each with the moment it stops being + * expected. A popstate takes the oldest of them rather than clearing a single flag: two + * in-app back navigations can each ask for one before either event arrives, and one flag + * would let the first event answer for both -- leaving the second to be read as the user + * pressing Back and a form leaving that the user never asked to leave. + */ + private final List suppressedTraversals = new ArrayList(); + + /** + * How long a traversal the port asked for is given to raise its popstate before it stops + * being expected. On the clock rather than on turns of the event loop: the event comes from + * the main thread and an idle loop can turn many times while it is on its way. + */ + private static final long SUPPRESSION_TIMEOUT_MILLIS = 1000; + + /** + * Asks the browser to traverse history on the port's own behalf, and expects the popstate + * that follows to be ignored. + * + *

The traversal may not happen at all -- asking to go further back than the session has + * entries does nothing, and raises no popstate. The expectation is therefore given up after a + * bounded wait: left standing it would swallow the user's next real Back and leave the + * application on a form the browser has already moved away from.

+ * + * @param delta entries to move, negative for backwards + */ + private void requestSuppressedTraversal(int delta) { + if (delta == 0 || historyUnavailable) { + return; + } + suppressedTraversals.add(Long.valueOf(System.currentTimeMillis() + SUPPRESSION_TIMEOUT_MILLIS)); + try { + window.getHistory().go(delta); + } catch (Throwable ignored) { + suppressedTraversals.remove(suppressedTraversals.size() - 1); + return; + } + awaitSuppressionConsumed(); + } + + /** + * True when this popstate belongs to a traversal the port asked for, in which case it is that + * traversal's and no form should move for it. + */ + private boolean consumeSuppressedTraversal() { + if (suppressedTraversals.isEmpty()) { + return false; + } + suppressedTraversals.remove(0); + return true; + } + + private void awaitSuppressionConsumed() { + callSerially(new Runnable() { + @Override + public void run() { + long now = System.currentTimeMillis(); + while (!suppressedTraversals.isEmpty() + && suppressedTraversals.get(0).longValue() <= now) { + // Never arrived -- a traversal the browser had nowhere to make. Giving it up + // keeps it from swallowing the user's next real Back. + suppressedTraversals.remove(0); + } + if (!suppressedTraversals.isEmpty()) { + awaitSuppressionConsumed(); + } + } + }); + } + + /** + * Handles a history traversal. + * + *

popstate fires for Forward as well as Back. Without telling them apart, pressing + * Forward would run the form's back command and immediately undo the navigation the user + * asked for.

+ */ + private void handlePopStateEvent(Event evt) { + final int restored = parseHistoryIndex(((PopStateEvent) evt).getState()); + final int previous = historyIndex; + final boolean backward = restored < previous; + if (restored > historyHighWater) { + // An id this session never pushed. The entry the page loaded on keeps whatever a + // previous life of the application wrote there while these counters start again at + // zero, so an id above everything pushed since is that entry -- not a step forward + // through this session's history, which can only reach entries it created. The + // numbering is left where it was: nothing in it has been traversed. + consumeSuppressedTraversal(); + return; + } + historyIndex = restored; + if (consumeSuppressedTraversal()) { + // The port asked for this one, to spend an entry belonging to a form an in-app back + // command had already left. + return; + } + if (restored == previous) { + // Neither direction, as far as this port can tell: the entry carries no id of ours + // and the application is on its root, which reads the same way. It belongs to + // whatever hosts this page -- a site that navigates around the canvas -- and the + // user asked for it. Stepping anywhere from here would take the page off the entry + // they chose. + return; + } + if (!backward) { + // Forward traversal. The port cannot replay it -- it has no way to know which form + // an entry stood for, and re-showing one would need the application's own + // navigation -- so rather than leave the browser sitting on an entry the app is not + // on, step back to the entry that does match. Forward is inert, which is the honest + // degradation. + // + // The whole way back, not one step: the Forward menu can jump several entries at + // once, and returning only one would leave the browser somewhere in between, out + // of step with the form on screen for every Back after it. + requestSuppressedTraversal(previous - restored); + return; + } + // A traversal can cross more than one entry at once -- the Back button's history menu, + // or history.go(-N) -- so the distance decides how many forms to leave, not one. + final int distance = Math.max(1, previous - restored); + historyEntriesPushed = Math.max(0, historyEntriesPushed - distance); + // The browser has already crossed every entry of the jump in this one traversal, so + // the count of what the application still owes is the state the replay works from -- + // and what a back command that skips forms draws down as it goes. + // + // Added to rather than assigned: a second Back pressed while a transition is still + // running arrives here before the first has landed, and overwriting would lose the + // entries the first one was still working through -- the browser would end up several + // entries ahead of the application, for good. + pendingTraversalEntries += distance; + if (traversalActive) { + // A replay is already walking the forms; it reads the count after each one lands, + // so this traversal joins the one in flight rather than starting a second that + // would run the same form's back command twice. + return; + } + // Which entries were crossed is read from the event here, synchronously, because the + // event does not outlive the call. Running the back command belongs on the EDT, and + // that is the coordinator's shape. + JavaScriptBrowserLifecycleCoordinator.handlePopState( + new JavaScriptBrowserLifecycleCoordinator.BackNavigationHooks() { + @Override + public void callSerially(Runnable runnable) { + HTML5Implementation.this.callSerially(runnable); + } + + @Override + public void runBackCommand() { + replayTraversal(); + } + }); + } + + /** + * Marks a history entry as this port's. A page can be running its own navigation before + * Codename One starts -- a host application embedding the canvas, for instance -- and its + * states are commonly plain numbers too. Without something to tell them apart, returning to + * one of those entries would read as a Codename One id and be taken for a Forward, which + * bounces the browser straight back out again without ever running the form's back command. + */ + private static final String HISTORY_STATE_PREFIX = "cn1-history:"; + + /** + * True once the document has refused to take an entry. Nothing this port pushed is there to + * traverse, so it stops asking. + */ + private boolean historyUnavailable; + + /** + * Reads the id stamped into a history entry. Entries this port did not push -- the document + * entry the app started on, or anything the host page pushed -- carry no id of ours and + * read as being before everything. + */ + private int parseHistoryIndex(Object state) { + // Anything that is not one of this port's own stamps reads as being before everything, + // and a host page's router object is exactly that: not a string, and not ours. + if (!(state instanceof String)) { + return 0; + } + String text = (String) state; + if (!text.startsWith(HISTORY_STATE_PREFIX)) { + return 0; + } + try { + return Integer.parseInt(text.substring(HISTORY_STATE_PREFIX.length())); + } catch (NumberFormatException e) { + return 0; + } + } + + /** + * Entries a browser traversal has already crossed that the application has not yet + * followed. A back command can leave more than one form at a time, so the replay draws + * this down by however many forms each step actually left rather than by one. + */ + private int pendingTraversalEntries; + + /** + * True while the replay is walking forms. Further traversals add to the count it reads + * rather than starting a walk of their own. + */ + private boolean traversalActive; + + /** + * Runs one step of a browser traversal and, once it lands, whatever is still outstanding. + * + *

The steps cannot be run in a loop: a transition defers the form change, so a second + * back command issued straight away would read the form the first one was still leaving and + * run its command again -- two entries would be spent while the application moved one. + * Each step waits for the form to actually change before the next is issued.

+ */ + /** + * How long a back command is given to land before it is taken as refused. Long enough for a + * form transition, which the framework runs before installing the destination. + */ + private static final long BACK_OUTCOME_TIMEOUT_MILLIS = 2000; + + private void replayTraversal() { + if (pendingTraversalEntries <= 0) { + traversalActive = false; + return; + } + final Form current = Display.getInstance().getCurrent(); + if (current == null) { + pendingTraversalEntries = 0; + traversalActive = false; + return; + } + Command back = current.getBackCommand(); + if (back == null) { + pendingTraversalEntries = 0; + traversalActive = false; + leaveDocument(); + return; + } + traversalActive = true; + // Held until the form change actually lands, not just until the command returns: a + // transition defers the change, and if the flag were already clear by then the change + // would look like an ordinary showBack() and traverse the entry this gesture had + // already spent. + handlingPopState = true; + // Read before the command runs. With transitions off the form changes inside + // dispatchCommand, and the count is drawn down before this line -- so reading it + // afterwards would report nothing outstanding and the wait would take the change for a + // form it was not unwinding towards. + int outstanding = pendingTraversalEntries; + current.dispatchCommand(back, new ActionEvent(back)); + awaitBackOutcome(current, outstanding, + System.currentTimeMillis() + BACK_OUTCOME_TIMEOUT_MILLIS); + // Nothing is pushed to replace the entry that was just popped. Every form show pushes + // one, so the history depth already tracks the navigation depth: popping one entry and + // going back one form keeps them in step. Re-pushing here left a dead entry behind, so + // from the root form the first Back did nothing and only the second left the app. + } + + /** + * Waits for a back command to either navigate or turn out to have been refused. + * + * @param before the form that was displayed when the command was dispatched + * @param outstanding entries owed when the command was dispatched, read before it ran + * @param deadline when to stop waiting and take the command as refused + */ + private void awaitBackOutcome(final Form before, final int outstanding, final long deadline) { + callSerially(new Runnable() { + @Override + public void run() { + if (Display.getInstance().getCurrent() != before) { + handlingPopState = false; + if (pendingTraversalEntries >= outstanding) { + // The form that appeared was not one this traversal was unwinding + // towards, so nothing was drawn down. Count the step anyway: the + // application has moved and the replay has to end somewhere. Never below + // nothing owed -- a negative count would have to be climbed back through + // before the next Back was replayed at all. + pendingTraversalEntries = Math.max(0, outstanding - 1); + } + replayTraversal(); + return; + } + if (System.currentTimeMillis() < deadline) { + // Waited on the clock rather than on a number of turns of the event loop. A + // form transition takes a couple of hundred milliseconds and the framework + // does not install the destination until it ends, while forty turns of an + // idle loop can pass in a fraction of that -- and reading that as a refusal + // pushes browser history the wrong way for a navigation that was about to + // land. + awaitBackOutcome(before, outstanding, deadline); + return; + } + // The same form is still showing, so a pop guard refused the navigation. The + // browser has already moved across every entry still outstanding, so all of + // them are returned, not only this step's. The steps after it are dropped: + // they would ask the same guard again on the same form, and one of the later + // answers could navigate even though the traversal was refused. + handlingPopState = false; + traversalActive = false; + int owed = pendingTraversalEntries; + pendingTraversalEntries = 0; + restoreTraversal(owed); + } + }); + } + + /** + * Returns the browser to the entry a refused traversal started from. + * + *

Going forward rather than pushing: the entries the gesture moved across are still + * there to return to, while a push would replace them and lose everything the user could + * still have reached with Forward.

+ * + * @param steps how many entries the traversal crossed without the application following + */ + private void restoreTraversal(int steps) { + if (steps <= 0) { + return; + } + historyEntriesPushed += steps; + requestSuppressedTraversal(steps); + } + + /** + * Carries a Back gesture outwards, out of the document. + * + *

That is what Back means on a form offering no back action: the form was shown + * normally so it has an entry, but popping that entry would look like a press that did + * nothing and the user would have to press again.

+ */ + private void leaveDocument() { + if (historyUnavailable) { + return; + } + // Every entry this port pushed has to go, not just one: a form reached through several + // others still has theirs above the document, and stepping over a single one would + // leave the browser inside a history the application has no forms for. + // + // The document's own entry counts too. The gesture that got here has already spent the + // entry belonging to the form being left, so what remains above the document is the + // rest of the pushed entries -- traversing only those would land on the document with + // the same form still showing, and the user would have to press Back again to leave. + int remaining = historyEntriesPushed + 1; + int available = 0; + try { + available = window.getHistory().getLength() - 1; + } catch (Throwable ignored) { + // No length to go by; ask for the full distance and let the browser stop where its + // history does. + } + if (available > 0 && remaining > available) { + remaining = available; + } + requestSuppressedTraversal(-remaining); + } + + /** + * True once a form has been shown, so the very first one does not push an entry. + */ + private boolean historyRootShown; + + /** + * The forms shown so far, newest last, used to recognise a backward navigation. The + * implementation is not told the direction of a form change, so it is inferred from whether + * the incoming form is the one behind the current entry. + * + *

Bounded: an app that never navigates back would otherwise accumulate an entry per + * screen for the life of the session. Losing the oldest entries only means a very deep + * unwind stops being recognised, which degrades to the old behaviour of pushing.

+ */ + private final java.util.List
historyStack = new java.util.ArrayList(); + + private static final int HISTORY_STACK_LIMIT = 32; + + /** + * How many entries this port has pushed and not yet spent. + * + *

Counted separately from the form chain because that chain is bounded -- it only has to + * be long enough to recognise a backward jump -- while the browser keeps every entry. Using + * the chain's length to leave the document would land inside the history rather than out of + * it once an application had navigated more times than the chain holds.

+ */ + private int historyEntriesPushed; + @Override public void setCurrentForm(Form f) { super.setCurrentForm(f); + // A form built before the last scheme or density change still holds the styles it + // resolved then, so it is brought up to date as it appears rather than coming back in + // the old palette. + refreshThemeIfStale(f); + // Taken for every arrival, whatever is done with it: the framework queues one direction + // per display asked for, and a queue read only on some paths would drift out of step + // with what is still waiting. + boolean navigatingBack = com.codename1.ui.Accessor.isNavigatingBack(f); + if (!historyRootShown) { + // The first form has nothing behind it. Pushing for it left a dead entry that the + // first Back popped without navigating anywhere, so leaving the app from the root + // form took two presses. + historyRootShown = true; + historyStack.add(f); + return; + } + int depth = historyStack.size(); + int previousIndex = f == null ? -1 : historyStack.lastIndexOf(f); + if (previousIndex >= 0 && previousIndex == depth - 1) { + // The form already on screen. One navigation can arrive here twice: dismissing a + // menu restores the form underneath it and then shows it again, and when the form + // being shown is the one being restored both arrivals name the same form. Nothing + // moved, so nothing is pushed -- an entry here is one the user has to press Back + // through to get out of a screen they never left. + return; + } + // The direction comes from the framework rather than from which form appeared: an + // application can legitimately show an earlier form again as forward navigation -- + // A, B, then A again -- and treating that as a back would spend entries the user can + // still reach. + if (previousIndex >= 0 && previousIndex < depth - 1 + && (handlingPopState || navigatingBack)) { + // Backward navigation, from a toolbar back command or showBack(). Keeping the whole + // chain rather than a single predecessor is what lets consecutive unwinds -- C to B + // to A -- each be recognised. + // + // The browser entry has to be spent as well, not just the Java one: leaving it in + // place means the next browser Back pops an entry that no longer corresponds to a + // form, finds no back command, and appears to do nothing. Going back fires popstate, + // which is why the next one is marked as already accounted for. + // A back command can skip forms -- A, B, C then showBack() to A -- so every form + // above the one being shown is being left, and one entry per form has to go with + // them. Recognising only a single step left the extra entries behind for later + // Back presses to pop without any form change. + int steps = depth - 1 - previousIndex; + for (int i = 0; i < steps; i++) { + historyStack.remove(historyStack.size() - 1); + } + if (handlingPopState) { + // The gesture has already crossed entries the application had not yet + // followed, so those are what this form change settles first. Only forms left + // beyond them still have an entry standing above the displayed form, and only + // those need a traversal of their own -- going back for the ones the user's + // own gesture already crossed would leave the document early, or spend an + // entry the replay was about to account for. + int settled = Math.min(steps, pendingTraversalEntries); + pendingTraversalEntries -= settled; + int extra = steps - settled; + if (extra > 0) { + historyEntriesPushed = Math.max(0, historyEntriesPushed - extra); + requestSuppressedTraversal(-extra); + } + return; + } + historyEntriesPushed = Math.max(0, historyEntriesPushed - steps); + // One traversal for the whole jump, so it raises a single popstate. + requestSuppressedTraversal(-steps); + return; + } + historyStack.add(f); + if (historyStack.size() > HISTORY_STACK_LIMIT) { + historyStack.remove(0); + } pushHistoryState(); - } @@ -11012,6 +11956,7 @@ public void drawMutableSurface(int drawX, int drawY, int drawWidth, int drawHeig // (so the host has its pixels) then record a blit by id onto // the target surface. No canvas host-ref crosses. mutableGraphics.flush(); + retainBlitSource(NativeImage.this); ((SurfaceCommandRecorder)ctx).blitSurface(mutableGraphics.getSurfaceId(), drawX, drawY, drawWidth, drawHeight); } }, x, y, width, height); @@ -11148,8 +12093,45 @@ public class NativeFont { double height; String fileName; String fontName; + // The display's pixel ratio when this font's height was worked out. A height is in + // device pixels and everything drawn with it is divided by the ratio to reach CSS + // pixels, so the height only means anything alongside the ratio it was sized against. + double ratioBasis = getDevicePixelRatio(); + // The height this font was created with, which never changes. Identity has to be built + // from something that does not move: a font can be a key in a map when the display's + // ratio changes, and a hash that changes underneath a stored key loses it. + double identityHeight = Double.NaN; String cssCached_, cssFontFamilyCached__; + + /** + * Brings the height up to the current display density. + * + *

A browser zoom or a move to another screen changes the density under a font that + * has already been created and handed to a style. Left alone, its height stays in the + * old display's pixels while everything drawn with it is divided by the new ratio, so + * text comes out at the wrong size -- half of it, going from a 1x display to a 2x one. + * Styles hold their fonts, and the theme holds the ones it loaded, so there is no + * single place to recreate them: each brings itself up to date as it is used.

+ */ + void syncDensity() { + double current = getDevicePixelRatio(); + if (current <= 0 || ratioBasis <= 0 || current == ratioBasis) { + return; + } + // Scaled by the ratio itself rather than by the density band it falls in. A ratio + // can change without changing bands -- a desktop window moved to a 1.5x display -- + // and the band would then report no change while every coordinate drawn with this + // font is divided by the new ratio: text would come out two thirds of its size. + double scale = current / ratioBasis; + if (Double.isNaN(identityHeight)) { + identityHeight = height; + } + height = height * scale; + ascent = (int) Math.round(ascent * scale); + ratioBasis = current; + cssCached_ = null; + } public int fontLeading() { return (int)Math.ceil(determineFontLeading(getCSS())); @@ -11276,6 +12258,7 @@ private String fontWeight() { } public String getCSS(){ + syncDensity(); if (cssCached_ == null) { StringBuilder sb = new StringBuilder(); //sb.append(height).append("px "); @@ -11299,7 +12282,7 @@ public String getCSS(){ } public String getScaledCSS(){ - + syncDensity(); StringBuilder sb = new StringBuilder(); //sb.append(height).append("px "); //if ((style & Font.STYLE_ITALIC) != 0) { @@ -11338,6 +12321,16 @@ public String toString(){ return getCSS()+" (Face: "+face+" style "+style+" size "+size+")"; } + /** + * The height this font is identified by, which is the one it was created with even after + * the display's pixel ratio has moved the height it draws at. A font can be a key in a + * map, and a key whose hash changes while it is stored is a key that cannot be found + * again. + */ + private double identity() { + return Double.isNaN(identityHeight) ? height : identityHeight; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -11350,7 +12343,7 @@ public boolean equals(Object obj) { return face == other.face && style == other.style && size == other.size - && Double.doubleToLongBits(height) == Double.doubleToLongBits(other.height) + && Double.doubleToLongBits(identity()) == Double.doubleToLongBits(other.identity()) && java.util.Objects.equals(fileName, other.fileName) && java.util.Objects.equals(fontName, other.fontName); } @@ -11361,7 +12354,7 @@ public int hashCode() { hash = 31 * hash + face; hash = 31 * hash + style; hash = 31 * hash + size; - long heightBits = Double.doubleToLongBits(height); + long heightBits = Double.doubleToLongBits(identity()); hash = 31 * hash + (int)(heightBits ^ (heightBits >>> 32)); hash = 31 * hash + (fileName != null ? fileName.hashCode() : 0); hash = 31 * hash + (fontName != null ? fontName.hashCode() : 0); @@ -12649,16 +13642,117 @@ public boolean requiresHeavyButtonForCopyToClipboard() { - @JSBody(params={}, script="return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)") - private static native boolean isDarkMode_(); - + private static final String DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; + + /** + * Bumped whenever something invalidates resolved styles for every form, not just the one on + * screen -- a colour-scheme switch, a change of display density. + */ + private int themeGeneration; + + private static final String THEME_GENERATION_PROPERTY = "cn1$themeGeneration"; + + /** + * Re-resolves a form's styles if they predate the current generation. + * + *

A form that is not displayed still holds the Style instances it resolved, and nothing + * refreshes them when it is shown again, so without this a cached form would come back + * wearing the palette that was in force when it was last built.

+ */ + private void refreshThemeIfStale(Form f) { + if (f == null) { + return; + } + Object seen = f.getClientProperty(THEME_GENERATION_PROPERTY); + int generation = seen instanceof Integer ? ((Integer) seen).intValue() : 0; + if (generation == themeGeneration) { + return; + } + f.putClientProperty(THEME_GENERATION_PROPERTY, Integer.valueOf(themeGeneration)); + if (generation != 0 || themeGeneration != 0) { + f.refreshTheme(); + } + } + @Override public Boolean isDarkMode() { - return isDarkMode_(); + return Boolean.valueOf(matchesMediaQuery(DARK_SCHEME_QUERY)); } - @JSBody(params={"query"}, script="return !!(window.matchMedia && window.matchMedia(query).matches);") - private static native boolean matchesMediaQuery(String query); + /** + * Last known value of each media query, keyed by query text. + */ + private final Map mediaQueryCache = new HashMap(); + + /** + * Evaluates an OS-level preference media query. + * + *

These used to be {@code @JSBody} scripts calling {@code window.matchMedia}. That script + * is compiled into the worker, which has no {@code matchMedia}, so every query silently + * answered false: dark mode was never detected, and neither was reduced motion or forced + * colors. The query is now evaluated on the main thread through the window binding.

+ * + *

The result is cached and refreshed by a change listener rather than re-read per call, + * because reading it crosses the worker boundary and callers such as the theme layer ask + * repeatedly.

+ * + * @param query the media query text + * @return true when the query currently matches + */ + private boolean matchesMediaQuery(final String query) { + Boolean cached = mediaQueryCache.get(query); + if (cached != null) { + return cached.booleanValue(); + } + boolean matches = false; + try { + MediaQueryList list = window.matchMedia(query); + if (list != null) { + matches = list.getMatches(); + list.addEventListener("change", new EventListener() { + @Override + public void handleEvent(Event evt) { + onMediaQueryChanged(query); + } + }); + } + } catch (Throwable ignored) { + // An old browser without matchMedia keeps the platform default of "not matching". + } + mediaQueryCache.put(query, Boolean.valueOf(matches)); + return matches; + } + + private void onMediaQueryChanged(final String query) { + callSerially(new Runnable() { + @Override + public void run() { + try { + MediaQueryList list = window.matchMedia(query); + if (list != null) { + mediaQueryCache.put(query, Boolean.valueOf(list.getMatches())); + } + } catch (Throwable ignored) { + return; + } + Form current = Display.getInstance().getCurrent(); + if (current == null) { + return; + } + if (DARK_SCHEME_QUERY.equals(query)) { + // Components hold the Style instances they resolved, and the dark variant is + // chosen at resolution time, so repainting alone would redraw the old + // colours. Forms that are not on screen hold theirs too, and refreshing only + // this one would bring the old palette back when the user navigated to a + // cached form -- so the generation is bumped and every form refreshes as it + // is shown. + themeGeneration++; + refreshThemeIfStale(current); + } + current.repaint(); + } + }); + } @Override public boolean isHighContrastEnabled() { diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptCanvasLayout.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptCanvasLayout.java index 4bfe50a80a5..c75cb9ffe0f 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptCanvasLayout.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptCanvasLayout.java @@ -75,8 +75,11 @@ public static Dimensions compute(int viewportWidth, int viewportHeight, double d int backingHeight = cssHeight; String styleWidth = null; String styleHeight = null; - int hidpiWidth = (int) (cssWidth * devicePixelRatio); - int hidpiHeight = (int) (cssHeight * devicePixelRatio); + // Rounded, not truncated: at a fractional ratio such as 1.5 or 2.5 a truncated + // backing store no longer matches cssSize * ratio, so the browser rescales the + // canvas by a hair and every glyph edge softens. + int hidpiWidth = (int) Math.round(cssWidth * devicePixelRatio); + int hidpiHeight = (int) Math.round(cssHeight * devicePixelRatio); if (cssWidth != hidpiWidth) { backingWidth = hidpiWidth; backingHeight = hidpiHeight; diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptSemanticOverlay.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptSemanticOverlay.java new file mode 100644 index 00000000000..493f1c87926 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptSemanticOverlay.java @@ -0,0 +1,1480 @@ +/* + * 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; + +import com.codename1.html5.js.dom.CSSStyleDeclaration; +import com.codename1.html5.js.dom.Event; +import com.codename1.html5.js.dom.EventListener; +import com.codename1.html5.js.dom.HTMLDocument; +import com.codename1.html5.js.dom.HTMLElement; +import com.codename1.html5.js.dom.HTMLInputElement; +import com.codename1.html5.js.dom.HTMLTextAreaElement; +import com.codename1.ui.Component; +import com.codename1.ui.TextArea; +import com.codename1.ui.accessibility.AccessibilityAction; +import com.codename1.ui.accessibility.AccessibilityCheckedState; +import com.codename1.ui.accessibility.AccessibilityCollectionInfo; +import com.codename1.ui.accessibility.AccessibilityCollectionItemInfo; +import com.codename1.ui.accessibility.AccessibilityLiveRegion; +import com.codename1.ui.accessibility.AccessibilityNodeSnapshot; +import com.codename1.ui.accessibility.AccessibilityRange; +import com.codename1.ui.accessibility.AccessibilityRole; +import com.codename1.ui.accessibility.AccessibilityTreeSnapshot; +import com.codename1.ui.geom.Rectangle; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Maintains the DOM overlay that mirrors the Codename One component tree above the + * rendering canvas. + * + *

The overlay is the port's projection of {@link AccessibilityTreeSnapshot}: one element + * per semantic node, carrying the ARIA role/state, the node's screen rectangle, a tab index + * and the activation listeners. Assistive technology, browser find-in-page and native focus + * traversal all operate against this tree rather than against the canvas, which is marked + * {@code role=presentation}.

+ * + *

Updates are incremental. Elements are keyed by the stable node id and reused + * across invalidations; only attributes, geometry and child ordering that actually changed + * are written back to the DOM, and event listeners are registered exactly once per element. + * This matters for more than throughput: {@code CHANGE_BOUNDS} is raised by every + * {@code setX/setY/setWidth/setHeight}, so a rebuild-per-invalidation would discard DOM focus + * and any in-progress text selection on every scroll step, and would re-marshal the whole tree + * across the worker bridge each time.

+ * + *

The class never reads back from the DOM. Structure, ordering and previously applied + * attribute values are tracked worker-side so that every bridge call remains a fire-and-forget + * write, preserving the port's no-barrier-reads invariant.

+ * + * @author Codename One + */ +public final class JavaScriptSemanticOverlay { + + /** + * Receives activation, focus and value-adjustment requests originating from the overlay. + */ + public interface ActionDispatcher { + /** + * Dispatches a semantic action onto the Codename One event thread. + * + * @param nodeId the semantic node the action targets + * @param actionId one of the {@link AccessibilityAction} identifiers + * @param argument optional action argument, may be null + */ + void performAction(long nodeId, String actionId, Object argument); + } + + private static final String ATTRIBUTE_NODE_ID = "data-cn1-accessibility-id"; + + /** + * Marks a SET_TEXT control while it holds focus, so a snapshot arriving mid-edit does + * not overwrite what is being typed. + */ + private static final String ATTRIBUTE_EDITING = "data-cn1-editing"; + + /** + * Records whether a SET_TEXT control was built for an obscured field, so a field that + * changes between masked and revealed is noticed on the next snapshot. + */ + private static final String ATTRIBUTE_OBSCURED = "data-cn1-obscured"; + + /** + * Marks a SET_TEXT control built as a textarea, since an element cannot change tag: a field + * that becomes multiline needs its control built again rather than reconfigured. + */ + private static final String ATTRIBUTE_MULTILINE = "data-cn1-multiline"; + + /** + * Retained state for a single semantic node. Everything the diff needs to decide whether a + * DOM write is required lives here, so no property is ever read back from the host. + */ + private static final class Entry { + private final long id; + private final HTMLElement element; + private final String tag; + private final Map attributes = new HashMap(); + // Held for the same reason as in the text layer: getStyle() is a round trip that parks + // the worker, and geometry is rewritten on every CHANGE_BOUNDS. + private final CSSStyleDeclaration style; + private final List childOrder = new ArrayList(); + private Map customActions; + private Map customActionLabels; + // The document id the action controls were pointed at. An application can change a + // node's identifier, and a control left pointing at the old one refers to nothing. + private String actionOwnerId; + private HTMLElement textNode; + private String geometry; + private String text; + private long parentId = -1; + private boolean listenersBound; + private boolean activateEnabled; + private boolean incrementEnabled; + private boolean decrementEnabled; + private int tabIndex = Integer.MIN_VALUE; + + Entry(long id, HTMLElement element, String tag) { + this.id = id; + this.element = element; + this.tag = tag; + this.style = element.getStyle(); + } + } + + private final HTMLDocument document; + private final HTMLElement container; + private final ActionDispatcher dispatcher; + private final Map entries = new HashMap(); + private final List rootOrder = new ArrayList(); + private HTMLElement actionsContainer; + private boolean textContentEnabled = true; + private long focusedNodeId = -1; + + private Entry pendingFocus; + + /** + * Creates an overlay bound to a container element that is already attached to the document. + * + * @param document the host document used to create elements + * @param container the overlay root, typically {@code #cn1-accessibility-tree} + * @param dispatcher receives actions triggered from the overlay + */ + public JavaScriptSemanticOverlay(HTMLDocument document, HTMLElement container, + ActionDispatcher dispatcher) { + this.document = document; + this.container = container; + this.dispatcher = dispatcher; + } + + /** + * Controls whether this overlay puts label text into the document. + * + *

It should not when something else already renders that text visibly: the overlay's + * copy is nearly transparent but still findable, so browser find-in-page would report two + * matches and could navigate to the invisible one. Assistive technology is unaffected -- + * the label reaches it through {@code aria-label} either way.

+ * + * @param value true to mirror labels as text nodes + */ + public void setTextContentEnabled(boolean value) { + textContentEnabled = value; + } + + /** + * Reconciles the overlay against a freshly captured semantic tree. + * + *

Nodes are matched by id, so elements survive across calls together with their DOM + * focus and any text selection they carry. Only differences are written.

+ * + * @param tree the current semantic tree + * @param devicePixelRatio the ratio used to convert Codename One device pixels to CSS pixels + */ + public void update(AccessibilityTreeSnapshot tree, double devicePixelRatio) { + if (tree == null) { + return; + } + double ratio = devicePixelRatio <= 0 ? 1 : devicePixelRatio; + Map nodes = tree.getNodes(); + Set live = new HashSet(); + + // Walk from the roots so that ordering is derived from getChildIds() rather than from + // map iteration order, and so a child is never visited before its parent exists. + List roots = tree.getRootIds(); + for (int i = 0; i < roots.size(); i++) { + visit(nodes, roots.get(i), -1, live, ratio); + } + + pruneRemovedNodes(live); + reconcileChildOrder(container, rootOrder, roots, live); + if (actionsContainer != null) { + // Moved behind the tree every time it is rebuilt. The region is created the first + // time a node needs a control, which happens while the tree is still being walked -- + // before the roots are attached -- so left where it was made it would sit ahead of + // the whole form, and a keyboard or screen-reader user would meet "Set text" before + // reaching the field it writes to. appendChild moves it rather than copying it. + container.appendChild(actionsContainer); + } + + if (pendingFocus != null) { + // Recorded before the call, and that record is what tells the focus event apart + // afterwards. A guard held only for the duration of the call would be useless: a + // void call across the bridge is queued for the main thread, so it returns long + // before the browser dispatches the event it causes. + focusedNodeId = pendingFocus.id; + // A field edited through a SET_TEXT control no longer has a tab stop of its own, so + // its semantic element is not where focus is useful: the control is the half that + // takes keystrokes. Focusing the element instead would announce the field and then + // drop every key the user typed. + HTMLElement control = editingControl(pendingFocus); + HTMLElement target = control == null ? pendingFocus.element : control; + pendingFocus = null; + target.focus(); + } + } + + /** + * Removes every element from the overlay and drops the retained state. + */ + public void clear() { + // Emptying the container detaches the whole subtree in one write. Walking the entries + // and removing each from its parent would not be equivalent here, because a parent may + // already have been dropped from the map by the time its children are visited. + container.setInnerHTML(""); + actionsContainer = null; + entries.clear(); + rootOrder.clear(); + focusedNodeId = -1; + pendingFocus = null; + } + + private void visit(Map nodes, Long id, long parentId, + Set live, double ratio) { + AccessibilityNodeSnapshot node = nodes.get(id); + if (node == null || !live.add(id)) { + return; + } + AccessibilityNodeSnapshot parent = parentId == -1 + ? null : nodes.get(Long.valueOf(parentId)); + Entry entry = obtain(node); + applyParent(entry, parentId); + applyAttributes(entry, node, nodes, parent); + applyGeometry(entry, node, parent, ratio); + applyText(entry, node); + applyListeners(entry, node); + applyCustomActions(entry, node); + applyFocus(entry, node); + + List children = node.getChildIds(); + for (int i = 0; i < children.size(); i++) { + visit(nodes, children.get(i), node.getId(), live, ratio); + } + reconcileChildOrder(entry.element, entry.childOrder, children, live); + } + + private Entry obtain(AccessibilityNodeSnapshot node) { + Long key = Long.valueOf(node.getId()); + String tag = tagFor(node); + Entry entry = entries.get(key); + if (entry != null && entry.tag.equals(tag)) { + return entry; + } + if (entry != null) { + // The role changed in a way that needs a different element type, so the old + // element cannot be reused. Unlink it so the ordering model no longer claims the + // slot is filled -- otherwise the reconcile pass would see the id already in place + // and never attach the replacement element. + detach(entry); + releaseCustomActions(entry); + unlinkFromParentOrder(entry); + entries.remove(key); + } + HTMLElement element = document.createElement(tag); + element.setAttribute(ATTRIBUTE_NODE_ID, String.valueOf(node.getId())); + Entry created = new Entry(node.getId(), element, tag); + entries.put(key, created); + return created; + } + + private String tagFor(AccessibilityNodeSnapshot node) { + // A real anchor gives the browser affordances an ARIA role cannot: status-bar URL + // preview, middle-click and modifier-click to open in a new tab, and the native + // context menu. + if (node.getRole() == AccessibilityRole.LINK) { + return "a"; + } + return "div"; + } + + private void applyParent(Entry entry, long parentId) { + if (entry.parentId == parentId) { + return; + } + // Vacate the slot under the old parent. The element itself does not need an explicit + // removal: appendChild/insertBefore under the new parent moves it. + unlinkFromParentOrder(entry); + entry.parentId = parentId; + } + + private void applyAttributes(Entry entry, AccessibilityNodeSnapshot node, + Map nodes, AccessibilityNodeSnapshot parent) { + Map desired = describe(node, nodes, parent); + for (Iterator> it = desired.entrySet().iterator(); it.hasNext();) { + Map.Entry attribute = it.next(); + String name = attribute.getKey(); + String value = attribute.getValue(); + if (!value.equals(entry.attributes.get(name))) { + entry.element.setAttribute(name, value); + entry.attributes.put(name, value); + } + } + for (Iterator> it = entry.attributes.entrySet().iterator(); it.hasNext();) { + String name = it.next().getKey(); + if (!desired.containsKey(name)) { + entry.element.removeAttribute(name); + it.remove(); + } + } + // A field edited through a SET_TEXT control gives up its own tab stop to it. The + // control is the half that can be typed into -- it carries the input handlers, and the + // semantic element carries none -- so leaving both tabbable would stop a keyboard user + // on a textbox that announces the field and then refuses every keystroke, before + // reaching the one that works. + int tabIndex = node.isFocusable() && !hasEditingControl(node) ? 0 : -1; + if (entry.tabIndex != tabIndex) { + entry.element.setTabIndex(tabIndex); + entry.tabIndex = tabIndex; + } + } + + /** + * Whether this node is edited through a control of its own rather than through its element. + * + *

Read from the snapshot rather than from the entry's controls because the controls are + * built after the attributes are applied, so on the pass that first exposes a field the + * entry does not have one yet -- and that is exactly the pass that decides its tab stop. + * The test matches what {@link #applyCustomActions} will do with the same snapshot.

+ * + * @param node the node being described + * @return true when a SET_TEXT control represents this node + */ + private boolean hasEditingControl(AccessibilityNodeSnapshot node) { + if (node.getEnabled() != null && !node.getEnabled().booleanValue()) { + return false; + } + List actions = node.getActions(); + for (int i = 0; i < actions.size(); i++) { + AccessibilityAction action = actions.get(i); + if (action.isEnabled() && AccessibilityAction.SET_TEXT.equals(action.getId())) { + return true; + } + } + return false; + } + + private void applyGeometry(Entry entry, AccessibilityNodeSnapshot node, + AccessibilityNodeSnapshot parent, double ratio) { + Rectangle bounds = node.getBounds(); + // The snapshot reports absolute screen coordinates, but an entry is positioned inside + // its parent entry, which is itself absolutely positioned -- so the parent's offset + // would be counted twice, pushing descendants away from their component and, with the + // parent clipping its content, often out of sight entirely. + int originX = parent == null ? 0 : parent.getBounds().getX(); + int originY = parent == null ? 0 : parent.getBounds().getY(); + StringBuilder css = new StringBuilder( + "position:absolute;opacity:0.001;pointer-events:none;overflow:hidden;"); + css.append("left:").append((bounds.getX() - originX) / ratio).append("px;"); + css.append("top:").append((bounds.getY() - originY) / ratio).append("px;"); + css.append("width:").append(Math.max(1, bounds.getWidth()) / ratio).append("px;"); + css.append("height:").append(Math.max(1, bounds.getHeight()) / ratio).append("px;"); + String geometry = css.toString(); + if (!geometry.equals(entry.geometry)) { + entry.style.setCssText(geometry); + entry.geometry = geometry; + } + } + + private void applyText(Entry entry, AccessibilityNodeSnapshot node) { + String text = null; + boolean obscured = node.getObscured() != null && node.getObscured().booleanValue(); + // Labels are mirrored whether or not the text layer is rendering them. + // + // Review has pulled both ways here. Mirroring means a browser find can match a label + // twice, once in the visible layer and once in this near-transparent copy. Not + // mirroring means a screen reader loses ordinary labels entirely: the visible layer is + // aria-hidden, STATIC_TEXT has no ARIA role of its own, and an aria-label on a role-less + // div is not reliably announced -- nor does it work as live-region content. A duplicate + // find match is an annoyance; silent labels are a broken screen reader, so the mirror + // stays. + if (textContentEnabled && !obscured && isLabelVisibleAsText(node.getRole())) { + text = node.getLabel() == null ? "" : node.getLabel(); + } + if (text == null && !obscured && isTextEntryRole(node.getRole())) { + // A textbox's value is its content: ARIA has no attribute that carries it -- + // aria-valuetext is defined for range roles and is not read as a textbox's value -- + // so a field holding only a label announces its name over an empty value however + // much the user has typed into it. This is the field's value rather than a second + // copy of a label, so unlike the mirror above it does not depend on that switch. + text = textEntryValue(node); + } + if (text == null) { + if (entry.textNode != null) { + entry.textNode.setTextContent(""); + entry.text = null; + } + return; + } + if (text.equals(entry.text)) { + return; + } + // The text goes into a child of its own rather than through setTextContent on the node. + // setTextContent replaces every child, which would silently detach this node's semantic + // children and its custom-action buttons while the retained ordering still claimed they + // were attached -- so the reconcile pass would skip re-adding them and those controls + // would vanish until an unrelated structural change rebuilt them. + if (entry.textNode == null) { + entry.textNode = document.createElement("span"); + HTMLElement first = firstChildElement(entry); + if (first == null) { + entry.element.appendChild(entry.textNode); + } else { + entry.element.insertBefore(entry.textNode, first); + } + } + entry.textNode.setTextContent(text); + entry.text = text; + } + + /** + * True for roles a user types into, whose value the document has to carry as content. + */ + private boolean isTextEntryRole(AccessibilityRole role) { + return role == AccessibilityRole.TEXT_FIELD || role == AccessibilityRole.SEARCH_FIELD; + } + + /** + * The text a typing field currently holds. + * + * @param node the field's semantic node + * @return its contents, never null + */ + private String textEntryValue(AccessibilityNodeSnapshot node) { + if (node.getValue() != null) { + return node.getValue(); + } + // A field with no separately associated label has its label inferred from its own text, + // and in that case the value is left unset -- so the component is the only place the + // contents can be read from. + Component owner = node.getComponent(); + if (owner instanceof TextArea) { + String value = ((TextArea) owner).getText(); + if (value != null) { + return value; + } + } + return ""; + } + + /** + * True for roles whose label is text the user reads on screen. + */ + private boolean isLabelVisibleAsText(AccessibilityRole role) { + switch (role) { + case STATIC_TEXT: + case HEADING: + case BUTTON: + case TOGGLE_BUTTON: + case CHECKBOX: + case RADIO_BUTTON: + case SWITCH: + case LINK: + case TAB: + case MENU_ITEM: + case LIST_ITEM: + case TREE_ITEM: + case CELL: + case COLUMN_HEADER: + case ROW_HEADER: + return true; + default: + return false; + } + } + + /** + * Returns the element currently occupying the first child slot, so a newly created text + * node can be placed ahead of the node's children and read before them. + */ + private HTMLElement firstChildElement(Entry entry) { + if (!entry.childOrder.isEmpty()) { + Entry child = entries.get(entry.childOrder.get(0)); + if (child != null) { + return child.element; + } + } + return null; + } + + private void applyListeners(Entry entry, AccessibilityNodeSnapshot node) { + // A node reported as disabled takes no action from here, whatever its actions say about + // themselves: a slider builds its increment and decrement without asking the component + // whether it is enabled, and an arrow key would otherwise move something the application + // has switched off -- and which is announced as switched off. + boolean usable = node.getEnabled() == null || node.getEnabled().booleanValue(); + AccessibilityAction activate = node.getAction(AccessibilityAction.ACTIVATE); + entry.activateEnabled = usable && activate != null && activate.isEnabled(); + AccessibilityAction increment = node.getAction(AccessibilityAction.INCREMENT); + entry.incrementEnabled = usable && increment != null && increment.isEnabled(); + AccessibilityAction decrement = node.getAction(AccessibilityAction.DECREMENT); + entry.decrementEnabled = usable && decrement != null && decrement.isEnabled(); + if (entry.listenersBound) { + return; + } + entry.listenersBound = true; + final long nodeId = entry.id; + final Entry bound = entry; + entry.element.addEventListener("click", new EventListener() { + @Override + public void handleEvent(Event event) { + if (!bound.activateEnabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + dispatcher.performAction(nodeId, AccessibilityAction.ACTIVATE, null); + } + }); + entry.element.addEventListener("focus", new EventListener() { + @Override + public void handleEvent(Event event) { + if (focusedNodeId == nodeId) { + // The framework already has focus here -- either it reported this node as + // focused and this overlay mirrored it, or the user focused a node the + // framework was already on. Either way, telling the framework about it + // would be asking it to act on its own state. + return; + } + focusedNodeId = nodeId; + dispatcher.performAction(nodeId, AccessibilityAction.FOCUS, null); + } + }); + entry.element.addEventListener("keydown", new EventListener() { + @Override + public void handleEvent(Event event) { + JSOImplementations.KeyEvent key = (JSOImplementations.KeyEvent) event; + int code = key.getKeyCode(); + String action = null; + if ((code == 13 || code == 32) && bound.activateEnabled) { + action = AccessibilityAction.ACTIVATE; + } else if ((code == 38 || code == 39) && bound.incrementEnabled) { + action = AccessibilityAction.INCREMENT; + } else if ((code == 37 || code == 40) && bound.decrementEnabled) { + action = AccessibilityAction.DECREMENT; + } + // Arrows are only taken when the node actually offers the action. Consuming them + // on an ordinary focusable node would stop them reaching the window-level key + // handler that performs directional focus traversal, trapping the keyboard. + if (action == null) { + return; + } + // The event would otherwise bubble to the window-level key handler and drive + // the same component a second time: Enter would fire a button's action through + // the semantic action and again through its pressed/released path, and an arrow + // key would step a slider twice. + event.preventDefault(); + event.stopPropagation(); + dispatcher.performAction(nodeId, action, null); + } + }); + } + + /** + * Moves DOM focus to follow the framework's. + * + *

Elements are retained across invalidations, so the browser's focus stays where it was + * unless it is moved deliberately. When the application moves focus itself -- requestFocus(), + * keyboard traversal -- a screen reader would otherwise keep announcing the previous element, + * and Enter or Space would activate a component that is no longer focused.

+ * + *

Tracked by id rather than by asking the document what is focused: reading the active + * element would be a round trip to the main thread.

+ */ + private void applyFocus(Entry entry, AccessibilityNodeSnapshot node) { + if (!node.isFocused()) { + if (focusedNodeId == entry.id) { + // The framework has taken focus off this node -- an application calling + // setFocused(null), or an editor closing. Letting the browser keep focus here + // would leave assistive technology announcing a focus the application does not + // have, and Enter, Space or an arrow key would still reach this element's + // listeners and act on a component the framework considers unfocused. + focusedNodeId = -1; + // Whichever of the two holds it: blur on an element that is not focused does + // nothing, and after the tab-stop change the control is the likelier holder. + HTMLElement control = editingControl(entry); + if (control != null) { + control.blur(); + } + entry.element.blur(); + } + return; + } + if (focusedNodeId == entry.id) { + return; + } + // Recorded, not applied: a node appearing for the first time -- the first snapshot, or + // the first after a form change -- has not been attached yet, and a browser ignores + // focus on an element that is not in the document. Applied once the tree is in place. + pendingFocus = entry; + } + + /** + * The control focus belongs on for a node, when one stands in for it. + * + * @param entry the node's entry + * @return its SET_TEXT control, or null when focus belongs on the element itself + */ + private HTMLElement editingControl(Entry entry) { + if (entry.customActions == null) { + return null; + } + return entry.customActions.get(AccessibilityAction.SET_TEXT); + } + + private void applyCustomActions(Entry entry, AccessibilityNodeSnapshot node) { + Set desired = null; + // A node the framework reports as disabled offers nothing to activate. An action does not + // have to know its component is disabled -- most are built without asking -- so a control + // for one would let a screen reader work a node it announces as unavailable. + boolean usable = node.getEnabled() == null || node.getEnabled().booleanValue(); + String owner = ownerId(entry); + if (entry.customActions != null && !owner.equals(entry.actionOwnerId)) { + // The node's identifier changed, so every control still pointing at the old one + // has lost its association with what it acts on -- and its description with it. + for (Iterator it = entry.customActions.values().iterator(); it.hasNext();) { + HTMLElement button = it.next(); + button.setAttribute("aria-controls", owner); + button.setAttribute("aria-describedby", owner); + } + } + entry.actionOwnerId = owner; + List actions = node.getActions(); + for (int i = 0; i < actions.size(); i++) { + AccessibilityAction action = actions.get(i); + if (!usable || !action.isEnabled() || isStandardWebAction(action.getId())) { + continue; + } + if (desired == null) { + desired = new HashSet(); + } + desired.add(action.getId()); + if (entry.customActions == null) { + entry.customActions = new HashMap(); + } + String label = controlLabel(action, node); + HTMLElement existing = entry.customActions.get(action.getId()); + if (existing != null && staleControlShape(existing, action.getId(), node)) { + // An input cannot become a textarea, so a field that turned multiline is given a + // control that can hold the line breaks its value now has. + actionsContainer().removeChild(existing); + entry.customActions.remove(action.getId()); + if (entry.customActionLabels != null) { + entry.customActionLabels.remove(action.getId()); + } + existing = null; + } + if (existing != null) { + // An application can replace an action with the same id and a new label -- an + // Expand that becomes a Collapse. Without this the retained button keeps + // announcing the old wording until the action is removed entirely. + if (!label.equals(entry.customActionLabels.get(action.getId()))) { + existing.setAttribute("aria-label", label); + if (!AccessibilityAction.SET_TEXT.equals(action.getId())) { + // A control that carries a value is named by aria-label alone: its text + // content IS its value for a textarea, and writing the label there would + // replace what the field holds with the name of the control. + existing.setTextContent(label); + } + entry.customActionLabels.put(action.getId(), label); + } + syncSetTextControl(existing, action.getId(), node); + continue; + } + if (entry.customActionLabels == null) { + entry.customActionLabels = new HashMap(); + } + entry.customActions.put(action.getId(), createCustomAction(entry, node, action, label)); + entry.customActionLabels.put(action.getId(), label); + } + if (entry.customActions == null) { + return; + } + for (Iterator> it = entry.customActions.entrySet().iterator(); + it.hasNext();) { + Map.Entry existing = it.next(); + if (desired == null || !desired.contains(existing.getKey())) { + actionsContainer().removeChild(existing.getValue()); + if (entry.customActionLabels != null) { + entry.customActionLabels.remove(existing.getKey()); + } + it.remove(); + } + } + } + + private HTMLElement createCustomAction(Entry entry, AccessibilityNodeSnapshot node, + final AccessibilityAction action, String label) { + if (AccessibilityAction.SET_TEXT.equals(action.getId())) { + return createSetTextControl(entry, node, action, label); + } + final long nodeId = entry.id; + HTMLElement button = document.createElement("button"); + button.setAttribute("type", "button"); + button.setAttribute("aria-label", label); + // Names the node this acts on as the button's description, since the button no longer + // sits inside it: "Delete" on its own says nothing about what would be deleted. + String owner = ownerId(entry); + button.setAttribute("aria-controls", owner); + button.setAttribute("aria-describedby", owner); + button.setTextContent(label); + button.getStyle().setCssText( + "position:absolute;opacity:0.001;pointer-events:none;width:1px;height:1px;"); + button.addEventListener("click", new EventListener() { + @Override + public void handleEvent(Event event) { + event.preventDefault(); + // The overlay root carries handlers of its own, and this is not one of the + // node's own actions -- it must not read as a click on anything above it. + event.stopPropagation(); + dispatcher.performAction(nodeId, action.getId(), null); + } + }); + // Held apart from the semantic tree rather than inside the node. Accessibility APIs + // treat everything inside a widget role -- button, checkbox, switch -- as presentational, + // so a button nested there may never be exposed at all; and a role=list accepts only + // list items as children, which the scroll actions were breaking. + actionsContainer().appendChild(button); + return button; + } + + /** + * What a custom-action control announces itself as. + * + *

An action the application named is announced by that name. One the framework added has + * only an id -- "setText" tells a screen-reader user nothing about which field it writes to, + * so it is named after the field instead, and after what it does when the field has no name + * of its own. A name inferred from the field's own contents is not used: that is its value, + * and it would have the control announce the text it is meant to replace.

+ * + * @param action the action being exposed + * @param node the node it acts on + * @return the control's label, never null + */ + private String controlLabel(AccessibilityAction action, AccessibilityNodeSnapshot node) { + if (action.getLabel() != null) { + return action.getLabel(); + } + if (!AccessibilityAction.SET_TEXT.equals(action.getId())) { + return action.getId(); + } + boolean obscured = node.getObscured() != null && node.getObscured().booleanValue(); + String name = node.getLabel(); + if (obscured || isDerivedFromContent(node, name)) { + name = node.getHint(); + } + return name == null || name.length() == 0 ? "Set text" : name; + } + + /** + * The control for SET_TEXT: an input, because the action takes the text to set. + * + *

A button cannot carry a value, so a field only ever reached through one could be + * cleared, never written. This is a real input, so a screen reader in forms mode types into + * it and the typed value is what reaches the framework -- the same handler a native editor + * would have called.

+ * + * @param entry the node the control acts on + * @param node that node's snapshot, read for the value the field already holds + * @param action the SET_TEXT action being exposed + * @param label the action's label, which names the control + * @return the control, already attached to the actions region + */ + private HTMLElement createSetTextControl(Entry entry, AccessibilityNodeSnapshot node, + final AccessibilityAction action, String label) { + final long nodeId = entry.id; + boolean obscured = node.getObscured() != null && node.getObscured().booleanValue(); + // A multiline field's value has line breaks in it, and an input cannot hold one: what a + // screen-reader user typed would arrive with its lines run together. An obscured field + // is single-line by nature and keeps the masking input. + boolean multiline = !obscured && node.getMultiline() != null + && node.getMultiline().booleanValue(); + final HTMLElement control = document.createElement(multiline ? "textarea" : "input"); + if (multiline) { + control.setAttribute(ATTRIBUTE_MULTILINE, "1"); + } else { + control.setAttribute("type", obscured ? "password" : "text"); + if (obscured) { + control.setAttribute(ATTRIBUTE_OBSCURED, "1"); + } + } + control.setAttribute("aria-label", label); + String owner = ownerId(entry); + control.setAttribute("aria-controls", owner); + control.setAttribute("aria-describedby", owner); + applyMaxLength(control, node); + applyTextConstraints(control, node, !multiline, obscured); + if (!obscured) { + setControlValue(control, textEntryValue(node)); + } + control.getStyle().setCssText( + "position:absolute;opacity:0.001;pointer-events:none;width:1px;height:1px;"); + EventListener commit = new EventListener() { + @Override + public void handleEvent(Event event) { + // The overlay root carries handlers of its own, and this is not one of the + // node's own actions -- it must not read as input on anything above it. + event.stopPropagation(); + dispatcher.performAction(nodeId, action.getId(), controlValue(control)); + } + }; + // change fires when the user leaves the field, which is when a screen reader in forms + // mode has finished; input covers assistive technology that sets the value outright and + // never sends a change. + control.addEventListener("change", commit); + control.addEventListener("input", commit); + // Marked on the element rather than compared against document.activeElement, which this + // port's document binding does not expose. + control.addEventListener("focus", new EventListener() { + @Override + public void handleEvent(Event event) { + control.setAttribute(ATTRIBUTE_EDITING, "1"); + if (focusedNodeId == nodeId) { + return; + } + // The field has no tab stop of its own any more, so this is the only way a + // keyboard or screen-reader user reaches it -- and without telling the + // framework, the browser would edit this field while Codename One still + // considered the previously focused component active, styling it, sending it + // focus events and navigating on from it. Recorded before the dispatch: the + // snapshot that comes back reporting this node focused then finds the overlay + // already agreeing, so it does not pull focus onto the semantic element and + // take it off this control mid-edit. + focusedNodeId = nodeId; + dispatcher.performAction(nodeId, AccessibilityAction.FOCUS, null); + } + }); + final boolean masked = obscured; + control.addEventListener("blur", new EventListener() { + @Override + public void handleEvent(Event event) { + control.removeAttribute(ATTRIBUTE_EDITING); + if (masked || control.getAttribute(ATTRIBUTE_OBSCURED) != null) { + // Every keystroke has already reached the framework, so the control has + // nothing left to hold -- and what it holds is a secret. type="password" + // only stops it being read off the screen; the value is still in the + // document for a script or an inspector, so it does not stay there past + // the edit. The sync pass will not do it: it leaves a masked field alone + // precisely so it never writes the secret back. + setControlValue(control, ""); + } + } + }); + actionsContainer().appendChild(control); + return control; + } + + /** + * Holds a SET_TEXT control to the same length limit as the field it writes to. + * + *

Without it the control is the long way round an application's own limit, and not just + * for the one edit: TextArea.setText() raises maxSize to fit whatever it is given, so a value + * typed past the limit here moves the limit permanently.

+ * + * @param element the control + * @param node the field's snapshot, whose component carries the limit + */ + /** + * Gives a SET_TEXT control the same input metadata the editor would have given the field. + * + *

Editing through this control is still editing that field: it has to offer the keyboard + * the constraint asks for, and it has to withhold prediction, capitalization and autofill + * from a field that forbids them. Sharing the editor's own routine rather than restating it + * here is the point -- a constraint added to one would otherwise never reach the other.

+ * + *

Masking stays this class's decision. The constraint's {@code PASSWORD} bit is only the + * default for the snapshot's obscured flag, which an application can set either way, so a + * type resolved from the constraint alone would unmask a field the application asked to + * hide -- or keep masking one it has just revealed.

+ * + * @param element the control + * @param node the field's snapshot, whose component carries the constraint + * @param singleLine true when the control is an input, so it carries a type + * @param obscured whether the field is to be masked + */ + private void applyTextConstraints(HTMLElement element, AccessibilityNodeSnapshot node, + boolean singleLine, boolean obscured) { + Component owner = node.getComponent(); + if (!(owner instanceof TextArea)) { + return; + } + String resolved = HTML5Implementation.applyTextInputConstraints( + element, (TextArea) owner, singleLine); + if (!singleLine) { + return; + } + if (obscured) { + element.setAttribute("type", "password"); + } else if ("password".equals(resolved)) { + // The application revealed a field the constraint still marks as a password. + element.setAttribute("type", "text"); + } + } + + private void applyMaxLength(HTMLElement element, AccessibilityNodeSnapshot node) { + Component owner = node.getComponent(); + int max = owner instanceof TextArea ? ((TextArea) owner).getMaxSize() : 0; + if (max > 0) { + element.setAttribute("maxlength", String.valueOf(max)); + } else { + // No limit to keep -- and an attribute left over from a field that used to have one + // would be a limit the application no longer asks for. + element.removeAttribute("maxlength"); + } + } + + /** + * True when a retained SET_TEXT control no longer has the shape its field needs. + * + *

Only the tag matters here: masking is an attribute an input can be given, but an input + * cannot become a textarea, so that change means building the control again.

+ * + * @param element the retained control + * @param actionId the action it performs + * @param node the field's current snapshot + * @return true when the control has to be replaced + */ + private boolean staleControlShape(HTMLElement element, String actionId, + AccessibilityNodeSnapshot node) { + if (!AccessibilityAction.SET_TEXT.equals(actionId)) { + return false; + } + boolean obscured = node.getObscured() != null && node.getObscured().booleanValue(); + boolean multiline = !obscured && node.getMultiline() != null + && node.getMultiline().booleanValue(); + return multiline != (element.getAttribute(ATTRIBUTE_MULTILINE) != null); + } + + /** + * Reads a SET_TEXT control's value, whichever element it was built as. + * + * @param element the control + * @return what it holds, never null + */ + private static String controlValue(HTMLElement element) { + // Which element this is was recorded when it was built, rather than asked of the object: + // the interop types are interfaces over the same host object, so a type test says + // nothing about which tag was created. + String value = element.getAttribute(ATTRIBUTE_MULTILINE) != null + ? ((HTMLTextAreaElement) element).getValue() + : ((HTMLInputElement) element).getValue(); + return value == null ? "" : value; + } + + /** + * Writes a SET_TEXT control's value, whichever element it was built as. + * + * @param element the control + * @param value the value to show + */ + private static void setControlValue(HTMLElement element, String value) { + if (element.getAttribute(ATTRIBUTE_MULTILINE) != null) { + ((HTMLTextAreaElement) element).setValue(value); + } else { + ((HTMLInputElement) element).setValue(value); + } + } + + /** + * Brings a SET_TEXT control back in step with the field it writes to. + * + *

Skipped while the control has focus: overwriting what someone is in the middle of + * typing is worse than showing a value one keystroke behind.

+ * + * @param element the control, which is only an input for SET_TEXT + * @param actionId the action the control performs + * @param node the field's current snapshot + */ + private void syncSetTextControl(HTMLElement element, String actionId, + AccessibilityNodeSnapshot node) { + if (!AccessibilityAction.SET_TEXT.equals(actionId)) { + return; + } + // Before anything that returns early. The limit belongs to every control, masked or + // mid-edit: an application that lowers setMaxSize() while a password field is on screen + // would otherwise leave this one accepting the old length, and a value typed past the + // new limit raises it again on the way in. + applyMaxLength(element, node); + boolean obscured = node.getObscured() != null && node.getObscured().booleanValue(); + // Also before the early returns, and for the same reason: an application can change a + // field's constraint while it is on screen, and a control left as it was would go on + // offering the keyboard, prediction and autofill the field no longer asks for. This + // writes the type, so what follows only has to track the masking marker. + applyTextConstraints(element, node, element.getAttribute(ATTRIBUTE_MULTILINE) == null, + obscured); + if (obscured != (element.getAttribute(ATTRIBUTE_OBSCURED) != null) + && element.getAttribute(ATTRIBUTE_MULTILINE) == null) { + // A field can be masked and revealed while it stays in the tree -- the eye button on + // a password field. A control left as it was would either keep typing in the clear + // into a field that is now a secret, or go on masking one that no longer is. + if (obscured) { + element.setAttribute(ATTRIBUTE_OBSCURED, "1"); + // Cleared even mid-edit: what it holds became a secret, and a secret does not + // stay in the document waiting for focus to leave. + setControlValue(element, ""); + } else { + element.removeAttribute(ATTRIBUTE_OBSCURED); + } + } + if (obscured) { + // A masked field's contents are never written into the document; the control exists + // to set a new value, not to carry the old one. + return; + } + if (element.getAttribute(ATTRIBUTE_EDITING) != null) { + return; + } + String value = textEntryValue(node); + if (!value.equals(controlValue(element))) { + setControlValue(element, value); + } + } + + /** + * The region holding custom-action controls, created on first use. + * + * @return the container element + */ + private HTMLElement actionsContainer() { + if (actionsContainer == null) { + actionsContainer = document.createElement("div"); + actionsContainer.setAttribute("id", "cn1-accessibility-actions"); + actionsContainer.getStyle().setCssText( + "position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;"); + container.appendChild(actionsContainer); + } + return actionsContainer; + } + + /** + * The document id a node's element currently carries. + * + * @param entry the node + * @return the id an action control points at + */ + private String ownerId(Entry entry) { + String owner = entry.attributes.get("id"); + return owner == null ? elementId(entry.id) : owner; + } + + /** + * The document id given to a node's element when the application supplied none. + * + * @param nodeId the accessibility node id + * @return a stable element id + */ + private String elementId(long nodeId) { + return "cn1-a11y-" + nodeId; + } + + private void pruneRemovedNodes(Set live) { + for (Iterator> it = entries.entrySet().iterator(); it.hasNext();) { + Map.Entry existing = it.next(); + if (live.contains(existing.getKey())) { + continue; + } + Entry entry = existing.getValue(); + detach(entry); + releaseCustomActions(entry); + unlinkFromParentOrder(entry); + it.remove(); + } + } + + /** + * Drops a node's custom-action controls, which live outside it and would otherwise stay in + * the document after the node they act on has gone. + * + * @param entry the node being removed + */ + private void releaseCustomActions(Entry entry) { + if (entry.customActions == null) { + return; + } + for (Iterator it = entry.customActions.values().iterator(); it.hasNext();) { + actionsContainer().removeChild(it.next()); + } + entry.customActions = null; + entry.customActionLabels = null; + } + + private void unlinkFromParentOrder(Entry entry) { + if (focusedNodeId == entry.id) { + // Detaching the element moves browser focus to the document, and accessibility ids + // are stable per component, so without this the node would be considered still + // focused when it comes back and focus would never be restored to it. + focusedNodeId = -1; + } + if (pendingFocus == entry) { + pendingFocus = null; + } + Long key = Long.valueOf(entry.id); + if (entry.parentId == -1) { + rootOrder.remove(key); + return; + } + Entry parent = entries.get(Long.valueOf(entry.parentId)); + if (parent != null) { + parent.childOrder.remove(key); + } + } + + private void detach(Entry entry) { + HTMLElement parent = entry.parentId == -1 ? container : parentElement(entry.parentId); + if (parent != null) { + parent.removeChild(entry.element); + } + } + + private HTMLElement parentElement(long parentId) { + Entry parent = entries.get(Long.valueOf(parentId)); + return parent == null ? null : parent.element; + } + + /** + * Brings the DOM child order of {@code parent} in line with {@code desired}, mutating + * {@code current} in lockstep so the retained model never drifts from the document. + * + *

Nodes already in the right slot are left untouched, which is what keeps a focused or + * selected element from being moved -- moving an element in the DOM drops both.

+ */ + private void reconcileChildOrder(HTMLElement parent, List current, List desired, + Set live) { + int slot = 0; + for (int i = 0; i < desired.size(); i++) { + Long id = desired.get(i); + if (!live.contains(id)) { + continue; + } + Entry entry = entries.get(id); + if (entry == null) { + continue; + } + Long occupant = slot < current.size() ? current.get(slot) : null; + if (id.equals(occupant)) { + slot++; + continue; + } + Entry occupantEntry = occupant == null ? null : entries.get(occupant); + if (occupantEntry == null) { + parent.appendChild(entry.element); + } else { + parent.insertBefore(entry.element, occupantEntry.element); + } + current.remove(id); + current.add(slot, id); + slot++; + } + } + + /** + * Actions the browser already provides a way to perform, so the overlay does not add a + * control for them. + * + *

The scroll actions are NOT included: the projection is a div, not a native scroll + * container, so nothing else would perform them, and a list exposes only the items it is + * currently showing -- without a control for them there is no way to reach the rest. They + * take no argument, so a custom control dispatches them correctly.

+ * + *

SET_TEXT is not included either, and the reason it once was is worth recording. A + * button can only dispatch with a null argument, which for SET_TEXT means replacing the + * field's contents with nothing, so it was left to the native input the port puts over a + * field being edited. That input is created on demand and only where + * useNativeOverlaysForTextFields() holds, which the default configuration does not, so a + * screen reader was left with a field it could focus and never change. SET_TEXT now gets a + * control that can carry a value -- a real input, not a button -- which is what an action + * taking an argument needed all along.

+ */ + private boolean isStandardWebAction(String id) { + return AccessibilityAction.ACTIVATE.equals(id) || AccessibilityAction.FOCUS.equals(id) + || AccessibilityAction.INCREMENT.equals(id) || AccessibilityAction.DECREMENT.equals(id); + } + + /** + * Builds the full ARIA attribute set for a node. The result is diffed against the last + * applied set, so this method describes rather than writes. + */ + private Map describe(AccessibilityNodeSnapshot node, + Map nodes, AccessibilityNodeSnapshot parent) { + Map out = new LinkedHashMap(); + out.put(ATTRIBUTE_NODE_ID, String.valueOf(node.getId())); + String role = ariaRole(node.getRole()); + // A list whose items carry a selection is a listbox, and its items are options: a + // listitem has no selected state in ARIA, and a list has no multi-selectable state, so a + // framework list projected as a plain list reaches a screen reader as structure with no + // indication of what is chosen. + if (node.getRole() == AccessibilityRole.LIST && isSelectableCollection(node, nodes)) { + role = "listbox"; + } else if (node.getRole() == AccessibilityRole.LIST_ITEM && parent != null + && parent.getRole() == AccessibilityRole.LIST + && isSelectableCollection(parent, nodes)) { + // Asked of the list rather than of the item: an option belongs to a listbox, so a + // selected item whose list stayed a plain list would be an option with no listbox + // over it, and an unselected item beside a selected one has to be an option too -- + // a listbox's children are all options, and one that is not breaks the group a + // screen reader reads the selection from. + role = "option"; + } + if (role != null) { + out.put("role", role); + } + // An obscured field with no separately associated label has its label derived from the + // text it contains, so writing that through would put the secret into the page DOM and + // have a screen reader read it out as the field's name. Name it from the hint instead, + // and never from its content. + boolean obscured = node.getObscured() != null && node.getObscured().booleanValue(); + String accessibleName = node.getLabel(); + // Only a label that IS the secret is withheld. A field with no separately associated + // label has one derived from its own text, which must never be published; an explicit + // name set through setAccessibilityText(), setLabelForComponent() or the semantics + // object is not secret and is the only thing naming the field for a screen reader. + // The same applies to a plain field: a label inferred from its contents is its value, + // not its name, and now that the value is published as the element's content, naming + // the field with it too would have a screen reader read the typed text twice. + if ((obscured || isTextEntryRole(node.getRole())) && isDerivedFromContent(node, accessibleName)) { + accessibleName = node.getHint(); + } + if (accessibleName == null) { + // A dialog is normally named by its pane title -- inferred from the form title -- + // rather than by a label, and without this the element would have no accessible + // name at all and be announced as an unnamed dialog. + accessibleName = node.getPaneTitle(); + } + if (accessibleName != null) { + out.put("aria-label", accessibleName); + } + String description = node.getDescription(); + if (node.getHint() != null) { + description = description == null ? node.getHint() : description + ". " + node.getHint(); + } + if (node.getValidationError() != null) { + description = description == null ? node.getValidationError() + : description + ". " + node.getValidationError(); + } + if (description != null) { + out.put("aria-description", description); + } + // Always an id, generated when the application did not give one: a custom action is + // rendered outside the node it acts on, and aria-controls is how the two are tied back + // together -- which needs the node to be referable. + out.put("id", node.getIdentifier() != null ? node.getIdentifier() : elementId(node.getId())); + if (node.getRoleDescription() != null) { + out.put("aria-roledescription", node.getRoleDescription()); + } + if (node.getValue() != null && !obscured && !isTextEntryRole(node.getRole())) { + out.put("aria-valuetext", node.getValue()); + } + if (node.getSelected() != null) { + if (node.getRole() == AccessibilityRole.TOGGLE_BUTTON && node.getPressed() == null) { + // A toggle button's state is aria-pressed. The framework infers "selected" for + // one, and aria-selected on a button says nothing a screen reader will read out, + // so it would be announced as an ordinary button with no state at all. + out.put("aria-pressed", String.valueOf(node.getSelected())); + } else { + out.put("aria-selected", String.valueOf(node.getSelected())); + } + } + if (node.getExpanded() != null) { + out.put("aria-expanded", String.valueOf(node.getExpanded())); + } + if (node.getEnabled() != null && !node.getEnabled().booleanValue()) { + out.put("aria-disabled", "true"); + } + if (node.getInvalid() != null) { + out.put("aria-invalid", String.valueOf(node.getInvalid())); + } + if (node.getBusy() != null) { + out.put("aria-busy", String.valueOf(node.getBusy())); + } + if (node.getReadOnly() != null) { + out.put("aria-readonly", String.valueOf(node.getReadOnly())); + } + if (node.getRequired() != null) { + out.put("aria-required", String.valueOf(node.getRequired())); + } + if (node.getMultiline() != null) { + out.put("aria-multiline", String.valueOf(node.getMultiline())); + } + if (node.getCurrent() != null && node.getCurrent().booleanValue()) { + out.put("aria-current", "true"); + } + if (node.isModal()) { + out.put("aria-modal", "true"); + } + if (node.getHeadingLevel() > 0) { + out.put("aria-level", String.valueOf(node.getHeadingLevel())); + } + if (node.getChecked() != AccessibilityCheckedState.UNSPECIFIED) { + out.put("aria-checked", node.getChecked() == AccessibilityCheckedState.MIXED + ? "mixed" : String.valueOf(node.getChecked() == AccessibilityCheckedState.CHECKED)); + } + if (node.getPressed() != null) { + out.put("aria-pressed", String.valueOf(node.getPressed())); + } + if (node.getLiveRegion() != AccessibilityLiveRegion.OFF) { + out.put("aria-live", node.getLiveRegion() == AccessibilityLiveRegion.ASSERTIVE + ? "assertive" : "polite"); + out.put("aria-atomic", "true"); + } + AccessibilityRange range = node.getRange(); + if (range != null && !obscured) { + out.put("aria-valuemin", String.valueOf(range.getMinimum())); + out.put("aria-valuemax", String.valueOf(range.getMaximum())); + out.put("aria-valuenow", String.valueOf(range.getCurrent())); + if (range.getText() != null) { + out.put("aria-valuetext", range.getText()); + } + } + AccessibilityCollectionInfo collection = node.getCollectionInfo(); + if (collection != null) { + if (collection.getRowCount() >= 0) { + out.put("aria-rowcount", String.valueOf(collection.getRowCount())); + } + if (collection.getColumnCount() >= 0) { + out.put("aria-colcount", String.valueOf(collection.getColumnCount())); + } + if (collection.getSelectionMode() == AccessibilityCollectionInfo.SELECTION_MULTIPLE) { + out.put("aria-multiselectable", "true"); + } + } + AccessibilityCollectionItemInfo item = node.getCollectionItemInfo(); + if (item != null) { + if (item.getPositionInSet() > 0) { + out.put("aria-posinset", String.valueOf(item.getPositionInSet())); + } + if (item.getSetSize() != 0) { + out.put("aria-setsize", String.valueOf(item.getSetSize())); + } + if (item.getLevel() > 0) { + out.put("aria-level", String.valueOf(item.getLevel())); + } + if (item.getRowIndex() >= 0) { + out.put("aria-rowindex", String.valueOf(item.getRowIndex() + 1)); + } + if (item.getColumnIndex() >= 0) { + out.put("aria-colindex", String.valueOf(item.getColumnIndex() + 1)); + } + if (item.getRowSpan() > 1) { + out.put("aria-rowspan", String.valueOf(item.getRowSpan())); + } + if (item.getColumnSpan() > 1) { + out.put("aria-colspan", String.valueOf(item.getColumnSpan())); + } + } + return out; + } + + /** + * True when a node's label is the text the component holds, rather than a name given to it. + * + *

A field with no separately associated label has its label inferred from its own + * contents. For an obscured field that content is the secret, so the inferred label must + * never be published -- but a name set explicitly must survive. The value is not a reliable + * comparison on its own: an inferred label is taken from the component's text while the + * node's value is left unset.

+ */ + private boolean isDerivedFromContent(AccessibilityNodeSnapshot node, String label) { + if (label == null) { + return false; + } + if (label.equals(node.getValue())) { + return true; + } + Component owner = node.getComponent(); + return owner instanceof TextArea && label.equals(((TextArea) owner).getText()); + } + + /** + * True when a collection reports a selection mode, or holds an item that knows whether it is + * selected -- either way the user picks from it rather than merely reading it. + * + * @param node the collection node + * @return true when the collection is one the user selects within + */ + private boolean isSelectableCollection(AccessibilityNodeSnapshot node, + Map nodes) { + AccessibilityCollectionInfo collection = node.getCollectionInfo(); + if (collection != null + && collection.getSelectionMode() != AccessibilityCollectionInfo.SELECTION_NONE) { + return true; + } + if (node.getSelected() != null) { + return true; + } + // A list built by hand can carry the selection on its items and describe neither a + // collection nor a selected state of its own. Its items still have to be options, and an + // option under a plain list is a hierarchy assistive technology may refuse to read the + // selection from, so the items are what decides. + List children = node.getChildIds(); + for (int i = 0; i < children.size(); i++) { + AccessibilityNodeSnapshot child = nodes.get(children.get(i)); + if (child != null && child.getRole() == AccessibilityRole.LIST_ITEM + && child.getSelected() != null) { + return true; + } + } + return false; + } + + private String ariaRole(AccessibilityRole role) { + switch (role) { + case BUTTON: + case TOGGLE_BUTTON: return "button"; + case CHECKBOX: return "checkbox"; + case RADIO_BUTTON: return "radio"; + case SWITCH: return "switch"; + case HEADING: return "heading"; + case LINK: return "link"; + case IMAGE: return "img"; + case TEXT_FIELD: return "textbox"; + case SEARCH_FIELD: return "searchbox"; + case SLIDER: return "slider"; + case PROGRESS_BAR: return "progressbar"; + case LIST: return "list"; + case LIST_ITEM: return "listitem"; + case GRID: return "grid"; + case ROW: return "row"; + case CELL: return "gridcell"; + case COLUMN_HEADER: return "columnheader"; + case ROW_HEADER: return "rowheader"; + case TAB_LIST: return "tablist"; + case TAB: return "tab"; + case TAB_PANEL: return "tabpanel"; + case DIALOG: return "dialog"; + case ALERT: return "alert"; + case MENU: return "menu"; + case MENU_ITEM: return "menuitem"; + case TOOLBAR: return "toolbar"; + case SCROLL_BAR: return "scrollbar"; + case SPIN_BUTTON: return "spinbutton"; + case COMBO_BOX: return "combobox"; + case TREE: return "tree"; + case TREE_ITEM: return "treeitem"; + case SEPARATOR: return "separator"; + case GENERIC: return "group"; + default: return null; + } + } +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptTextLayer.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptTextLayer.java new file mode 100644 index 00000000000..abe3d79a766 --- /dev/null +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/JavaScriptTextLayer.java @@ -0,0 +1,824 @@ +/* + * 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; + +import com.codename1.html5.js.dom.CSSStyleDeclaration; +import com.codename1.html5.js.dom.HTMLDocument; +import com.codename1.html5.js.dom.HTMLElement; +import com.codename1.impl.html5.HTML5Implementation.NativeFont; +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Renders Codename One text as real DOM text above the canvas. + * + *

Canvas text is a bitmap: it cannot be selected, copied, or found with the browser's + * find-in-page, and it rasterizes differently from the browser's own text. This layer + * intercepts each {@code drawString} destined for the display surface and emits a positioned + * DOM element instead, so the text the user sees is real text.

+ * + *

Codename One remains the sole layout authority. By the time a run reaches this class the + * framework has already broken the line and fixed its position, so each run is emitted as a + * single {@code white-space:pre} element at an absolute coordinate. The browser is therefore + * unable to wrap or reflow it, which is what keeps a metrics disagreement between + * {@code measureText} and DOM text layout to a sub-pixel rendering difference rather than a + * clipped or overflowing label.

+ * + *

Each run is wrapped in an element clipped to the graphics clip that was in force when the + * run was drawn. That reproduces the canvas behaviour for text inside a scrolled container -- + * the clip Codename One computed already accounts for every ancestor -- without this layer + * needing to know anything about the component hierarchy.

+ * + *

The layer is marked {@code aria-hidden}: assistive technology reads the semantic overlay + * maintained by {@link JavaScriptSemanticOverlay}, so the same words are never announced + * twice. Find-in-page and selection operate on the rendered text regardless.

+ * + *

Runs are pooled per owning component and reused across repaints, because a scrolling list + * repaints continuously and creating an element per run per frame would swamp the worker + * bridge. Nothing is ever read back from the DOM.

+ * + * @author Codename One + */ +public final class JavaScriptTextLayer { + + /** + * A single pooled text run: an element clipped to the graphics clip, holding the text. + */ + private static final class Run { + private final HTMLElement clip; + private final HTMLElement text; + // Held rather than re-fetched. getStyle() is a property read, which crosses to the main + // thread and parks the worker until it answers; doing that twice per run per frame was + // enough to wedge the VM on a screen that repaints continuously. + private final CSSStyleDeclaration clipStyle; + private final CSSStyleDeclaration textStyle; + private String clipCss; + private String textCss; + private String content; + private boolean attached; + private boolean everAttached; + private int lastY = Integer.MIN_VALUE; + private int lastX = Integer.MIN_VALUE; + // The clip this run was last given, in Codename One pixels. A repaint of part of the + // screen hands the same run a clip narrowed to the dirty region, which is not the clip + // the run is subject to -- see promote(). + private int clipX = Integer.MIN_VALUE; + private int clipY; + private int clipW; + private int clipH; + // The stacking index this run carries. A repaint of part of the screen leaves it alone: + // the run keeps the place in the order it already had. + private int zIndex; + private int claimedPass; + // Where the run landed, in Codename One pixels, and the draw order it was promoted at. + // Both are needed to tell whether something drawn on the canvas afterwards covers it. + private int coverX; + private int coverY; + private int coverW; + private int coverH; + + Run(HTMLElement clip, HTMLElement text) { + this.clip = clip; + this.text = text; + this.clipStyle = clip.getStyle(); + this.textStyle = text.getStyle(); + } + } + + /** + * The pool of runs belonging to one component. + */ + private static final class ComponentRuns { + private final List runs = new ArrayList(); + } + + /** + * One entry of the paint stack. Containers recurse into their children between their own + * before/after callbacks, so the component that owns the run being drawn is the innermost + * open frame rather than a single "current" component. + */ + private static final class Frame { + private Component component; + private ComponentRuns runs; + private int sequence; + private int paintPass; + private boolean cellRenderer; + private boolean promotable; + private boolean covering; + private boolean clipEmpty; + private int clipX; + private int clipY; + private int clipW; + private int clipH; + } + + private final HTMLDocument document; + private final HTMLElement container; + private final Map byComponent = new HashMap(); + private final List stack = new ArrayList(); + private int depth; + private int cellRendererDepth; + private boolean suspended; + private int drawSequence; + private int paintPass; + /** + * Components whose text has been found underneath something drawn on the canvas. The layer + * is above the canvas as a whole, so a promoted run cannot be covered by a later canvas draw + * the way canvas text would have been -- the only faithful answer is to leave that + * component's text on the canvas from then on. + */ + private final Set canvasOnly = new HashSet(); + /** + * The region the frame being painted was asked to repaint, in Codename One pixels. + */ + private int frameDirtyX; + private int frameDirtyY; + private int frameDirtyW = Integer.MAX_VALUE; + private int frameDirtyH = Integer.MAX_VALUE; + + private boolean reattachedThisFrame; + + /** + * Creates a text layer. + * + * @param document the host document used to create elements + * @param container the layer root, already attached to the document + */ + public JavaScriptTextLayer(HTMLDocument document, HTMLElement container) { + this.document = document; + this.container = container; + } + + /** + * Suspends promotion. While suspended every run stays on the canvas and the layer is + * hidden, which is what keeps form transitions coherent: a transition paints two + * pre-rendered offscreen buffers rather than painting components, so the text baked into + * those buffers must be the only text on screen. + * + * @param value true to route text back to the canvas + */ + public void setSuspended(boolean value) { + if (suspended == value) { + return; + } + suspended = value; + container.getStyle().setProperty("display", value ? "none" : "block"); + } + + /** + * Returns true while promotion is suspended. + * + * @return true when text is being left on the canvas + */ + public boolean isSuspended() { + return suspended; + } + + /** + * Reports, and clears, whether a run was attached after having been detached. + * + *

Only repainted runs get a fresh stacking index, so after a re-attach the frame holds a + * mix of old and new indices. The caller repaints the whole form to put them back in + * agreement.

+ * + * @return true when a run was re-attached since the last call + */ + public boolean consumeReattachFlag() { + boolean value = reattachedThisFrame; + reattachedThisFrame = false; + return value; + } + + /** + * Returns true while a component paint is open. + * + *

Callers use this to recognise the start of a frame, which is the only point at which + * suspension may change: flipping it once painting has begun would apply to part of a frame + * only.

+ * + * @return true when at least one component paint is in progress + */ + public boolean isPainting() { + return depth > 0; + } + + /** + * Marks the start of a component's paint. Runs are keyed by component and by their order + * within that component's paint, which is what lets them be reused across repaints instead + * of being recreated. + * + * @param component the component about to paint + */ + public void beginComponent(Component component, boolean covering, boolean editing, + boolean clipEmpty, int clipX, int clipY, int clipW, int clipH) { + if (depth == 0) { + // The outermost paint of a frame carries the region the framework decided to + // repaint. Every clip inside the frame is that region intersected with whatever the + // components impose, which is what lets a clip narrowed by the region be told from + // one narrowed by a container that really did get smaller. + frameDirtyX = clipX; + frameDirtyY = clipY; + frameDirtyW = clipW; + frameDirtyH = clipH; + } + while (stack.size() <= depth) { + stack.add(new Frame()); + } + Frame frame = stack.get(depth); + frame.component = component; + frame.sequence = 0; + // Identifies this paint, so a run matched by one line of it cannot be matched again by + // the next line of the same paint. + frame.paintPass = ++paintPass; + frame.runs = component == null ? null : byComponent.get(component); + frame.cellRenderer = component != null && component.isCellRenderer(); + // Resolved here rather than at flush time: painting happens before the frame is + // drained, so a value cached per drain would not be set yet on the very first paint and + // that frame's text would fall back to the canvas and then be promoted on top of itself. + // Peers are painted behind the canvas and pointer routing decides between them by + // probing the canvas alpha, so a glyph moved into the DOM stops contributing hit-test + // pixels: a transparent link or label over a video would let clicks through to the peer + // beneath it. While a form has peers its text stays on the canvas. + frame.promotable = component != null && !editing + && com.codename1.ui.Accessor.getActivePeerCount() == 0 + && component.getComponentForm() == Display.getInstance().getCurrent(); + frame.covering = covering; + frame.clipEmpty = clipEmpty; + frame.clipX = clipX; + frame.clipY = clipY; + frame.clipW = clipW; + frame.clipH = clipH; + if (frame.cellRenderer) { + cellRendererDepth++; + } + depth++; + } + + /** + * Marks the end of a component's paint and releases any runs it no longer draws. + * + * @param component the component that finished painting + */ + public void endComponent(Component component) { + if (depth == 0) { + return; + } + depth--; + Frame frame = stack.get(depth); + if (frame.cellRenderer) { + cellRendererDepth--; + frame.cellRenderer = false; + } + frame.promotable = false; + // Only when the paint could see the whole component does drawing fewer runs mean the + // rest are stale. A partial repaint hands it a clip that covers part of it -- or none of + // it, since the hooks still run when nothing intersects -- and releasing the tail then + // would detach text outside the dirty region whose pixels were never repainted, making + // unrelated labels, or the other lines of a multiline component, disappear. + if (frame.runs != null && frame.component == component) { + if (frame.clipEmpty) { + // Scrolled out of its container, so it drew nothing and will not draw again + // until it returns. Its text has to go now or it would hang outside the + // container it belongs to. + releaseFrom(frame.runs, 0); + } else if (frame.covering) { + releaseFrom(frame.runs, frame.sequence); + } else { + releaseStaleWithin(frame.runs, frame.paintPass, + frame.clipX, frame.clipY, frame.clipW, frame.clipH); + } + } + frame.covering = false; + frame.clipEmpty = false; + frame.component = null; + frame.runs = null; + frame.sequence = 0; + } + + /** + * Promotes a text run to the DOM. + * + * @param str the text to draw + * @param x the absolute x coordinate of the left edge, in Codename One pixels + * @param y the absolute y coordinate of the top of the text, in Codename One pixels + * @param clipX clip rectangle x, in Codename One pixels + * @param clipY clip rectangle y, in Codename One pixels + * @param clipW clip rectangle width, in Codename One pixels + * @param clipH clip rectangle height, in Codename One pixels + * @param color the text colour as a packed RGB value + * @param alpha the text alpha, 0 to 255 + * @param font the resolved font, may be null + * @param ratio device pixel ratio used to convert to CSS pixels + * @return true when the run was taken over by this layer and must not be drawn on canvas + */ + public boolean promote(String str, int x, int y, int clipX, int clipY, int clipW, int clipH, + int color, int alpha, NativeFont font, double ratio) { + if (suspended || depth == 0 || font == null || str == null || str.length() == 0) { + return false; + } + if (alpha <= 0) { + // Drawn with nothing to show. On the canvas that leaves no mark; in the document it + // would leave a span that find-in-page, selection and the inspector can all reach, + // which is text the application never put on screen. + return false; + } + // A cell renderer is one component instance stamped at many positions, so runs cannot be + // keyed by it: every row would overwrite the previous row's element and only the last + // would survive. Renderer subtrees keep their text on the canvas. + if (cellRendererDepth > 0) { + return false; + } + Frame frame = stack.get(depth - 1); + if (!frame.promotable) { + // The layer sits above the canvas as a whole, so nothing drawn on the canvas after a + // run can cover it. A modal dialog paints the form beneath it as its own backdrop; + // promoting that form's text would float it over the dialog. Anything outside the + // displayed form therefore stays on the canvas, where paint order still applies. + return false; + } + if (clipW <= 0 || clipH <= 0) { + return false; + } + if (canvasOnly.contains(frame.component)) { + return false; + } + if (frame.runs == null) { + frame.runs = new ComponentRuns(); + byComponent.put(frame.component, frame.runs); + } + // A paint that can see the whole component draws its runs in order, so position in that + // order identifies them. A clipped paint draws only the lines the clip reaches, so the + // same position would mean a different line -- a repaint reaching only the second line + // of a text area would write it into the first line's slot. There the run is matched by + // what it says instead, which is stable across partial paints, and the run is still + // updated so a component scrolling across the viewport edge keeps up rather than + // freezing at its last fully visible position. + Run run = frame.covering ? obtain(frame.runs, frame.sequence) + : obtainClipped(frame.runs, str, x, y, frame.paintPass); + run.claimedPass = frame.paintPass; + frame.sequence++; + + double scale = ratio <= 0 ? 1 : ratio; + // A repaint of part of the screen redraws whatever the dirty region touches, through a + // clip narrowed to that region. The run is not subject to that clip -- it is subject to + // whatever its container imposes -- and narrowing the element to the dirty rectangle + // would cut off every glyph outside it, in an area nothing repainted. So a clip that + // sits inside the one this run already has is only taken when the run has moved: a + // component scrolling out of view narrows its clip for real, and that always moves it. + int useClipX = clipX; + int useClipY = clipY; + int useClipW = clipW; + int useClipH = clipH; + boolean regionNarrowed = run.clipX != Integer.MIN_VALUE && run.lastX == x && run.lastY == y + && isRegionNarrowing(run, clipX, clipY, clipW, clipH); + if (regionNarrowed) { + useClipX = run.clipX; + useClipY = run.clipY; + useClipW = run.clipW; + useClipH = run.clipH; + } + run.clipX = useClipX; + run.clipY = useClipY; + run.clipW = useClipW; + run.clipH = useClipH; + // pointer-events stays off so the layer cannot intercept input destined for the canvas, + // which still owns all hit testing. Find-in-page and assistive technology do not depend + // on hit testing; drag-selection does, and is deliberately not enabled here. + StringBuilder clipCss = new StringBuilder( + "position:absolute;overflow:hidden;pointer-events:none;"); + clipCss.append("left:").append(useClipX / scale).append("px;"); + clipCss.append("top:").append(useClipY / scale).append("px;"); + clipCss.append("width:").append(useClipW / scale).append("px;"); + clipCss.append("height:").append(useClipH / scale).append("px;"); + // Stacking follows draw order rather than DOM insertion order, so a run that is hidden + // and shown again does not jump to the top of the stack. + // + // The counter is monotonic and is NOT reset per frame. Resetting cannot order a partial + // repaint correctly: only the components that repainted would be renumbered, so a dirty + // component starting again from 1 could fall beneath untouched runs still carrying + // higher numbers from an earlier full frame. Left monotonic, every index remains + // comparable with every other, and the most recently painted run is on top -- which is + // what the canvas would have done. + if (regionNarrowed && run.zIndex > 0) { + // Redrawn only because the region being repainted happens to touch it, under the + // clip it already had. Its place in the order was settled when the frame that drew + // it ran, and taking a new index here would lift it over everything that has not + // repainted -- over a component drawn on top of it, outside the region anything was + // repainted in. + clipCss.append("z-index:").append(run.zIndex).append(";"); + } else { + drawSequence++; + if (drawSequence == Integer.MAX_VALUE) { + // Unreachable in practice; renumber from a clean slate rather than wrap. + drawSequence = 1; + reattachedThisFrame = true; + } + run.zIndex = drawSequence; + clipCss.append("z-index:").append(drawSequence).append(";"); + } + String clipDeclaration = clipCss.toString(); + if (!clipDeclaration.equals(run.clipCss)) { + run.clipStyle.setCssText(clipDeclaration); + run.clipCss = clipDeclaration; + } + + // The run is positioned relative to its clip element, so the two move together and a + // scroll only has to rewrite coordinates rather than restructure anything. + StringBuilder textCss = new StringBuilder("position:absolute;white-space:pre;"); + textCss.append("left:").append((x - useClipX) / scale).append("px;"); + textCss.append("top:").append((y - useClipY) / scale).append("px;"); + // The font shorthand carries its own line-height ("18.9px/1.0"), so it has to be + // written before the explicit line-height or it would reset it. + textCss.append("font:").append(font.getScaledCSS()).append(";"); + // Codename One lays text out against fontHeight(), so using it as the line box keeps + // the DOM run on the same vertical rhythm as the canvas text it replaces. + textCss.append("line-height:").append(font.fontHeight() / scale).append("px;"); + textCss.append("color:").append(HTML5Graphics.color(color)).append(";"); + if (alpha < 255) { + textCss.append("opacity:").append(alpha / 255.0).append(";"); + } + // Comparing against the last applied declaration keeps a repaint that changes nothing -- + // the common case while scrolling a list whose rows are unchanged -- from writing + // anything at all across the bridge. + String textDeclaration = textCss.toString(); + if (!textDeclaration.equals(run.textCss)) { + run.textStyle.setCssText(textDeclaration); + run.textCss = textDeclaration; + } + + if (!str.equals(run.content)) { + run.text.setTextContent(str); + run.content = str; + if (regionNarrowed) { + // The canvas would have changed only the part of this line the repaint reached; + // a DOM run changes as a whole. The rest of the line now shows text nothing + // repainted, so the frame is brought back into agreement by repainting all of + // it -- which is what the reattach flag asks for at flush time. + reattachedThisFrame = true; + } + } + run.lastY = y; + run.lastX = x; + // The glyphs, not the clip: a component that draws text and then an image somewhere + // else inside the same clip would otherwise look like it had covered its own text, and + // the text would be dropped for nothing. The font measures worker-side from a cache, so + // asking costs nothing on the bridge. + int textWidth = font.stringWidth(str); + int textHeight = font.fontHeight(); + int coverLeft = Math.max(x, useClipX); + int coverTop = Math.max(y, useClipY); + int coverRight = Math.min(x + textWidth, useClipX + useClipW); + int coverBottom = Math.min(y + textHeight, useClipY + useClipH); + run.coverX = coverLeft; + run.coverY = coverTop; + run.coverW = Math.max(0, coverRight - coverLeft); + run.coverH = Math.max(0, coverBottom - coverTop); + if (!run.attached) { + container.appendChild(run.clip); + run.attached = true; + if (run.everAttached) { + // Previously attached, so other runs are still carrying stacking indices from + // an earlier frame. Ask for a full repaint to bring them all into one pass. + reattachedThisFrame = true; + } + run.everAttached = true; + } + return true; + } + + /** + * Records which form is on screen and drops the runs that no longer belong on it. + * + *

Text cannot outlive the component that drew it: a component that has been removed never + * paints again, so nothing else would ever release its elements. The same applies to a form + * that is no longer displayed -- its text goes back to the canvas, so any elements it left + * behind have to go.

+ * + * @param form the form currently displayed, may be null + */ + public void syncToForm(Form form) { + for (Iterator> it = byComponent.entrySet().iterator(); + it.hasNext();) { + Map.Entry entry = it.next(); + Component component = entry.getKey(); + // Still on the displayed form AND still able to paint. Hiding a component -- or any + // ancestor of it -- stops it painting without detaching it, so its runs would never + // be refreshed or released again and would sit above the canvas indefinitely, even + // though the parent's repaint has already cleared the pixels underneath. + if (component.getComponentForm() == form && form != null + && com.codename1.ui.Accessor.isDisplayable(component)) { + continue; + } + releaseFrom(entry.getValue(), 0); + it.remove(); + } + // The ban outlives the runs, so it has to be let go of here as well: a component that + // has left the form will never paint again, and holding it would keep its whole subtree + // alive for as long as the layer exists. + for (Iterator it = canvasOnly.iterator(); it.hasNext();) { + Component component = it.next(); + if (component.getComponentForm() != form || form == null + || !com.codename1.ui.Accessor.isDisplayable(component)) { + it.remove(); + } + } + } + + /** + * Records that something opaque was drawn straight onto the canvas. + * + *

The canvas cannot cover this layer. Where the original renderer would have drawn an + * image over a label and hidden it, a promoted run would keep showing through -- as it did + * for a tab bar that draws its selected tab through a composited lens, leaving the plain + * text of the pass before it floating over the finished result. A run caught underneath goes + * back to the canvas, and its component stays there: the alternative is a frame that + * disagrees with itself, or one that oscillates between the two.

+ * + * @param x left edge of what was drawn, in Codename One pixels + * @param y top edge + * @param w width + * @param h height + */ + /** + * Decides whether a draw really reaches a rectangle, for a draw whose shape is not the + * rectangle it reports. + */ + public interface CoverTest { + /** + * @param x left edge of the rectangle in question, in Codename One pixels + * @param y top edge + * @param w width + * @param h height + * @return true when the draw covers all of it + */ + boolean covers(int x, int y, int w, int h); + } + + public void noteCanvasCover(int x, int y, int w, int h) { + noteCanvasCover(x, y, w, h, null); + } + + public void noteCanvasCover(int x, int y, int w, int h, CoverTest test) { + if (suspended || w <= 0 || h <= 0 || byComponent.isEmpty()) { + return; + } + // Who is drawing decides what the draw means for text already in the DOM. A component + // painting its own background covers the text it is about to promote again a moment + // later, and so does a container painting behind the children it is about to paint -- + // neither is hiding anything. A component painting over text that belongs somewhere else + // on the form is, and that text has to go back to the canvas where it can be covered. + Component painter = depth == 0 ? null : stack.get(depth - 1).component; + int painterPass = depth == 0 ? -1 : stack.get(depth - 1).paintPass; + List covered = null; + for (Iterator> it = byComponent.entrySet().iterator(); + it.hasNext();) { + Map.Entry entry = it.next(); + ComponentRuns runs = entry.getValue(); + for (int i = 0; i < runs.runs.size(); i++) { + Run run = runs.runs.get(i); + if (!run.attached) { + continue; + } + // Promoted by the paint that is drawing right now, so this draw really does come + // after it -- a component covering its own text, which the canvas would have + // shown and the DOM cannot. + // At or after the painter's own pass: either the painter drew this text itself + // and is now drawing over it, or a child of it did during this same paint and the + // painter has come back to draw on top -- a container that calls super.paint(g) + // and then paints over its children. Both are covered on the canvas, so both + // belong here. A run from an earlier pass predates this paint and will be drawn + // again by it. + boolean drawnThisPaint = painterPass >= 0 && run.claimedPass >= painterPass; + if (!drawnThisPaint && painter != null + && isSelfOrDescendant(entry.getKey(), painter)) { + continue; + } + if (run.coverW <= 0 || run.coverH <= 0 + || run.coverX + run.coverW <= x || x + w <= run.coverX + || run.coverY + run.coverH <= y || y + h <= run.coverY) { + continue; + } + if (test != null && !test.covers(run.coverX, run.coverY, run.coverW, run.coverH)) { + // The draw's own outline does not reach this text, whatever its bounding + // rectangle says -- a filled triangle around a label. Nothing is hidden, so + // the text stays where it is. + continue; + } + if (covered == null) { + covered = new ArrayList(); + } + covered.add(entry.getKey()); + break; + } + } + if (covered == null) { + return; + } + for (int i = 0; i < covered.size(); i++) { + Component component = covered.get(i); + canvasOnly.add(component); + ComponentRuns runs = byComponent.remove(component); + if (runs != null) { + releaseFrom(runs, 0); + } + } + // The text has to be drawn again, and this time on the canvas, so the whole form is + // asked for rather than the dirty region: the flush already carries the reattach flag + // to the same place. + reattachedThisFrame = true; + } + + /** + * True when a component is the one painting, or somewhere inside it -- in which case the + * paint in progress is about to draw it again. + * + * @param component the component owning a promoted run + * @param painter the component whose paint is drawing + * @return true when the run will be repainted by this paint + */ + private boolean isSelfOrDescendant(Component component, Component painter) { + Component walk = component; + while (walk != null) { + if (walk == painter) { + return true; + } + walk = walk.getParent(); + } + return false; + } + + /** + * Detaches every run and drops the pool. + */ + public void clear() { + container.setInnerHTML(""); + byComponent.clear(); + canvasOnly.clear(); + stack.clear(); + depth = 0; + cellRendererDepth = 0; + drawSequence = 0; + reattachedThisFrame = false; + } + + /** + * True when a clip is exactly what the frame's repaint region makes of the clip this run + * already has -- in other words, the run is subject to the same clip as before and only the + * region being repainted is narrower. + * + *

A container that really did get smaller produces a different rectangle: its own clip + * intersected with the region, which is smaller than the run's old clip intersected with it. + * That one has to be taken, or promoted text would stay visible outside the container it + * belongs to.

+ */ + private boolean isRegionNarrowing(Run run, int clipX, int clipY, int clipW, int clipH) { + int left = Math.max(run.clipX, frameDirtyX); + int top = Math.max(run.clipY, frameDirtyY); + long right = Math.min((long) run.clipX + run.clipW, (long) frameDirtyX + frameDirtyW); + long bottom = Math.min((long) run.clipY + run.clipH, (long) frameDirtyY + frameDirtyH); + return clipX == left && clipY == top + && (long) clipX + clipW == right && (long) clipY + clipH == bottom; + } + + /** + * Finds the run already showing this text, or adds one. + * + *

Used when the paint is clipped and ordering cannot be trusted.

+ */ + private Run obtainClipped(ComponentRuns runs, String content, int x, int y, int pass) { + // A run already matched by an earlier line of this same paint is off limits. Lines move + // as a block when a multiline component scrolls, so the new position of one line is + // routinely the old position of the line before it: without this the second line would + // match -- and overwrite -- the run the first line had just taken, leaving the displaced + // run attached with stale text and duplicating a line on screen. + for (int i = 0; i < runs.runs.size(); i++) { + Run candidate = runs.runs.get(i); + // Position first: a line keeps its baseline when its text changes, so this is what + // matches a clipped repaint of new content to the run showing the old. Both + // coordinates, not just the baseline: a component can draw two strings side by side + // on one line, and matching by baseline alone would move the first one's element + // onto the second -- leaving the first nowhere and the second twice over. + if (candidate.lastY == y && candidate.lastX == x && candidate.claimedPass != pass) { + return candidate; + } + } + // Then the baseline alone, for a run whose text has moved horizontally -- a label that + // re-centres when its text changes -- but only where nothing else on that line is in + // doubt, so a side-by-side pair is never confused for one another. + Run onBaseline = null; + for (int i = 0; i < runs.runs.size(); i++) { + Run candidate = runs.runs.get(i); + if (candidate.lastY == y && candidate.claimedPass != pass) { + if (onBaseline != null) { + onBaseline = null; + break; + } + onBaseline = candidate; + } + } + if (onBaseline != null) { + return onBaseline; + } + // Then content: a line keeps its text when it scrolls, which is what moves the baseline. + for (int i = 0; i < runs.runs.size(); i++) { + Run candidate = runs.runs.get(i); + if (content.equals(candidate.content) && candidate.claimedPass != pass) { + return candidate; + } + } + return obtain(runs, runs.runs.size()); + } + + private Run obtain(ComponentRuns runs, int index) { + while (runs.runs.size() <= index) { + HTMLElement clip = document.createElement("div"); + HTMLElement text = document.createElement("span"); + clip.appendChild(text); + runs.runs.add(new Run(clip, text)); + } + return runs.runs.get(index); + } + + /** + * Releases the runs a partial repaint stopped drawing. + * + *

A repaint of part of a component redraws whatever its clip reaches, so a run inside + * that clip which this paint did not draw again is text the component no longer shows -- + * the canvas lost those glyphs when the region was repainted, and the layer has to lose + * them too. Its own background paint will not do it: coverage deliberately ignores runs + * belonging to the component that is painting, since a component covers its own text on + * every ordinary repaint and draws it again a moment later.

+ * + *

Runs outside the clip are left alone. Nothing repainted there, so their glyphs are + * still what the screen shows -- releasing them is what would make the other lines of a + * multiline component disappear.

+ * + * @param runs the component's runs + * @param paintPass the pass this paint claimed its runs with + * @param x the repainted region's x, in Codename One pixels + * @param y the repainted region's y + * @param w the repainted region's width + * @param h the repainted region's height + */ + private void releaseStaleWithin(ComponentRuns runs, int paintPass, int x, int y, int w, int h) { + if (w <= 0 || h <= 0) { + return; + } + for (int i = 0; i < runs.runs.size(); i++) { + Run run = runs.runs.get(i); + if (!run.attached || run.claimedPass >= paintPass) { + continue; + } + // A run with nothing recorded cannot be placed against the region, and guessing + // would take text off the screen that may be nowhere near it. + if (run.coverW <= 0 || run.coverH <= 0) { + continue; + } + if (run.coverX + run.coverW <= x || x + w <= run.coverX + || run.coverY + run.coverH <= y || y + h <= run.coverY) { + continue; + } + container.removeChild(run.clip); + run.attached = false; + } + } + + private void releaseFrom(ComponentRuns runs, int from) { + for (int i = from; i < runs.runs.size(); i++) { + Run run = runs.runs.get(i); + if (run.attached) { + container.removeChild(run.clip); + run.attached = false; + } + } + } + +} diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/DrawArc.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/DrawArc.java index 8dcf7b560fe..53dc2602556 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/DrawArc.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/DrawArc.java @@ -70,7 +70,9 @@ public static void execute(CanvasRenderingContext2D context, int x, int y, int w context.setStrokeStyle(HTML5Graphics.color(color)); context.setGlobalAlpha(((double)alpha)/255.0); context.beginPath(); - context.arc(cx, cy, rx, -startRad, -endRad, true); + // See FillArc: the sweep is counter-clockwise on the canvas exactly when the + // arcAngle is positive, so a negative arcAngle needs the other direction. + context.arc(cx, cy, rx, -startRad, -endRad, arcAngle >= 0); context.setLineWidth(1); context.stroke(); context.restore(); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/FillArc.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/FillArc.java index 7ce1061a14d..b2c5be1aa41 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/FillArc.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/FillArc.java @@ -73,7 +73,12 @@ public static void execute(CanvasRenderingContext2D context, int x, int y, int w context.scale(rx, ry); context.moveTo(1, 1); context.lineTo(1 + Math.cos(-startRad), 1 + Math.sin(-startRad)); - context.arc(1, 1, 1, -startRad, -endRad, false); + // A positive arcAngle sweeps counter-clockwise, and negating the angles for the + // y-down canvas turns that into a counter-clockwise canvas sweep -- which the canvas + // calls "anticlockwise". Passing false here made fillArc(.., 0, 90) traverse the long + // way round and paint the 270-degree complement of the wedge it was asked for, which + // is also why it disagreed with drawArc over the same angles. + context.arc(1, 1, 1, -startRad, -endRad, arcAngle >= 0); context.closePath(); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/FillRadialGradient.java b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/FillRadialGradient.java index a6580dc537c..b973af2f7a7 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/FillRadialGradient.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/impl/html5/graphics/FillRadialGradient.java @@ -68,7 +68,10 @@ public static void execute(CanvasRenderingContext2D context, int x, int y, int w context.scale(rx, ry); context.moveTo(1, 1); context.lineTo(1 + Math.cos(-startRad), 1 + Math.sin(-startRad)); - context.arc(1, 1, 1, -startRad, -endRad, false); + // Anticlockwise exactly when the arcAngle is positive -- see FillArc, which had the + // same fixed direction and painted the complement of every partial sweep it was asked + // for. fillRadialGradient(.., 20, 200) drew the other 160 degrees. + context.arc(1, 1, 1, -startRad, -endRad, arcAngle >= 0); context.closePath(); diff --git a/Ports/JavaScriptPort/src/main/java/com/codename1/ui/Accessor.java b/Ports/JavaScriptPort/src/main/java/com/codename1/ui/Accessor.java index ea7ee472c45..c5900cd4bc5 100644 --- a/Ports/JavaScriptPort/src/main/java/com/codename1/ui/Accessor.java +++ b/Ports/JavaScriptPort/src/main/java/com/codename1/ui/Accessor.java @@ -31,5 +31,82 @@ public class Accessor { public static int getActivePeerCount() { return Form.activePeerCount; } - + + /** + * Returns true when the form change in progress is a backward navigation. + * + *

The direction cannot be inferred from which form is appearing: an application may show + * an earlier form again as ordinary forward navigation, and treating that as a back would + * spend history entries the user can still reach.

+ * + * @param f the form being displayed + * @return true when the change came from showBack() + */ + public static boolean isNavigatingBack(Form f) { + return f != null && f.consumeShownWithReverse(); + } + + /** + * Returns the implementation-level graphics a {@link Graphics} is drawing through. + * + *

The port needs this to tell a paint aimed at the display from one aimed at an + * offscreen image. Both raise the same per-component paint callbacks, but only the + * display paint may touch the DOM text layer.

+ * + * @param g the graphics to unwrap, may be null + * @return the native graphics object, or null + */ + public static Object nativeGraphics(Graphics g) { + return g == null ? null : g.getGraphics(); + } + + /** + * Returns true when the form paints something over its children that the DOM text layer + * would end up on top of. + * + *

The text layer sits above the output canvas as a whole, so anything the canvas draws + * after a component -- the glass pane, the image of a dragged component -- cannot cover + * promoted text. While either is present the layer stands down and text goes back to the + * canvas, where paint order still decides what is on top.

+ * + *

Review asked for this to cover overlapping siblings too -- a LayeredLayout where an + * opaque component paints over a text-bearing one. That case is real and is a known + * limitation, recorded in STATUS.md, but it cannot be answered from here: whether a later + * sibling will cover an earlier one is not known when the earlier one paints, and the only + * general answer is to promote the covering content as well, which is the DOM renderer this + * change deliberately does not build. What is detectable is handled -- another form's + * content, a glass pane, a dragged image, a transition -- and the rest is honest about being + * a limitation rather than papered over with a guess.

+ * + * @param f the form to test, may be null + * @return true when text promotion must be suspended for this form + */ + public static boolean paintsOverChildren(Form f) { + return f != null && (f.getGlassPane() != null || f.getDraggedComponent() != null); + } + + /** + * Returns true when a component and its whole ancestor chain are visible. + * + *

A hidden component stops painting without being detached, so this is what tells the + * text layer that runs it is still holding can never be refreshed again.

+ * + * @param c the component to test, may be null + * @return true when the component would be painted by its form + */ + public static boolean isDisplayable(Component c) { + if (c == null || c.isHidden(true)) { + return false; + } + // isVisible() reports the component's own flag and isHidden(true) walks the ancestors + // for the separate zero-preferred-size hidden state, so neither notices a parent that + // was simply made invisible. Walk the chain: a child of an invisible parent does not + // paint, and its runs would otherwise be kept after the parent's repaint cleared them. + for (Component current = c; current != null; current = current.getParent()) { + if (!current.isVisible()) { + return false; + } + } + return true; + } } diff --git a/Ports/JavaScriptPort/src/main/webapp/port.js b/Ports/JavaScriptPort/src/main/webapp/port.js index 10740350322..dbdab2709b4 100644 --- a/Ports/JavaScriptPort/src/main/webapp/port.js +++ b/Ports/JavaScriptPort/src/main/webapp/port.js @@ -1710,20 +1710,19 @@ bindNative(["cn1_com_codename1_impl_html5_HTML5Implementation_getParameterByName }); bindNative(["cn1_com_codename1_impl_html5_HTML5Implementation_getDevicePixelRatio__R_double", "cn1_com_codename1_impl_html5_HTML5Implementation_getDevicePixelRatio___R_double"], function() { - // Default to 1: Codename One's JS port works end-to-end in CSS - // ("real") pixels and skips HiDPI auto-scaling of the canvas / - // pointer events. Use ``?pixelRatio=2`` to opt back in. + // Report the display's real scale factor. Codename One addresses DEVICE pixels -- + // the iOS port detects the retina factor and scales the values it hands the native + // primitives, so the framework draws at native resolution rather than rendering at + // 1x. Pinning this to 1 made the browser upscale a 1x canvas on every HiDPI display, + // which is why canvas text looked soft next to DOM text. + // + // ``?pixelRatio=N`` still forces a specific factor, which the screenshot harness and + // the skin designer rely on. const ratioOverride = getQueryParameter("pixelRatio"); const win = global.window || global; if (ratioOverride != null && ratioOverride !== "") { const parsed = Number(ratioOverride); - if (!isNaN(parsed) && parsed > 0) { - win.overridePixelRatio = parsed; - } else { - win.overridePixelRatio = 1; - } - } else if (typeof win.overridePixelRatio === "undefined") { - win.overridePixelRatio = 1; + win.overridePixelRatio = (!isNaN(parsed) && parsed > 0) ? parsed : 1; } if (typeof win.cn1ScaleCoord === "undefined") { win.cn1ScaleCoord = function(x) { diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java index 031704dacbb..c5e04ee1e3b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java @@ -96,6 +96,58 @@ void centeredTitleSupportsThemeDefaultAndRuntimeToggle() { assertTrue(new Dialog("Runtime default").isTitleCentered()); } + @FormTest + void refreshThemeKeepsTheTitleInTheDialog() { + Dialog dialog = new Dialog("Kept", new BorderLayout()); + dialog.setTitleCentered(true); + dialog.getContentPane().addComponent(BorderLayout.CENTER, new Label("Body")); + + Label title = dialog.getTitleComponent(); + Container centeredTitleArea = title.getParent(); + assertNotNull(centeredTitleArea, "the centered title starts inside the dialog"); + + // Refreshing a form reinstalls its menu bar, which moves the form's title component + // into the title area. A dialog's title component is its own label and it keeps that + // area hidden, so without restoring the layout the title would vanish along with the + // space it held. + dialog.refreshTheme(false); + + assertSame(centeredTitleArea, title.getParent(), + "refreshing the theme must leave the title where the dialog put it"); + assertEquals("Kept", dialog.getTitle()); + assertTrue(dialog.isTitleCentered()); + + dialog.setTitleCentered(false); + Container root = dialog.getDialogComponent(); + dialog.refreshTheme(false); + + assertSame(root, title.getParent()); + assertEquals(BorderLayout.NORTH, + ((BorderLayout) root.getLayout()).getComponentConstraint(title)); + } + + @FormTest + void refreshThemeKeepsFocusInsideTheDialog() { + Dialog dialog = new Dialog("Focus", new BorderLayout()); + dialog.setTitleCentered(true); + Button inside = new Button("Inside"); + dialog.getContentPane().addComponent(BorderLayout.CENTER, inside); + dialog.showModeless(); + + Form form = Display.getInstance().getCurrent(); + form.getAnimationManager().flush(); + inside.requestFocus(); + assertSame(inside, dialog.getFocused(), "the button starts focused"); + + // Nothing is displaced, so nothing is rebuilt: removing and re-adding the content pane + // would deinitialize it and take the form's focus with it. + dialog.refreshTheme(false); + + assertSame(inside, dialog.getFocused(), + "refreshing the theme must not take focus out of the dialog"); + dialog.dispose(); + } + @FormTest void disposeWhenPointerOutOfBoundsClosesDialog() { implementation.setBuiltinSoundsEnabled(false); diff --git a/scripts/javascript/screenshots/AdsScreen.png b/scripts/javascript/screenshots/AdsScreen.png index 8e417b0cf91..4006f817c80 100644 Binary files a/scripts/javascript/screenshots/AdsScreen.png and b/scripts/javascript/screenshots/AdsScreen.png differ diff --git a/scripts/javascript/screenshots/AnimateHierarchyScreenshotTest.png b/scripts/javascript/screenshots/AnimateHierarchyScreenshotTest.png index 7a9f3bbe9bf..1897d80cc7e 100644 Binary files a/scripts/javascript/screenshots/AnimateHierarchyScreenshotTest.png and b/scripts/javascript/screenshots/AnimateHierarchyScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/AnimateLayoutScreenshotTest.png b/scripts/javascript/screenshots/AnimateLayoutScreenshotTest.png index cb908256da6..361ad531937 100644 Binary files a/scripts/javascript/screenshots/AnimateLayoutScreenshotTest.png and b/scripts/javascript/screenshots/AnimateLayoutScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/AnimateUnlayoutScreenshotTest.png b/scripts/javascript/screenshots/AnimateUnlayoutScreenshotTest.png index 1ccb504fe32..6c5d523b0e4 100644 Binary files a/scripts/javascript/screenshots/AnimateUnlayoutScreenshotTest.png and b/scripts/javascript/screenshots/AnimateUnlayoutScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/AppReviewDialog.png b/scripts/javascript/screenshots/AppReviewDialog.png index 8e745e4319c..bb505a1c26c 100644 Binary files a/scripts/javascript/screenshots/AppReviewDialog.png and b/scripts/javascript/screenshots/AppReviewDialog.png differ diff --git a/scripts/javascript/screenshots/ButtonTheme_dark.png b/scripts/javascript/screenshots/ButtonTheme_dark.png index 32590614371..70ca8245b61 100644 Binary files a/scripts/javascript/screenshots/ButtonTheme_dark.png and b/scripts/javascript/screenshots/ButtonTheme_dark.png differ diff --git a/scripts/javascript/screenshots/ButtonTheme_ios_dark.png b/scripts/javascript/screenshots/ButtonTheme_ios_dark.png index 7c5b3aea25f..68519104a3e 100644 Binary files a/scripts/javascript/screenshots/ButtonTheme_ios_dark.png and b/scripts/javascript/screenshots/ButtonTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ButtonTheme_ios_light.png b/scripts/javascript/screenshots/ButtonTheme_ios_light.png index d3eb686b5fb..d1e7de23d11 100644 Binary files a/scripts/javascript/screenshots/ButtonTheme_ios_light.png and b/scripts/javascript/screenshots/ButtonTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/ButtonTheme_light.png b/scripts/javascript/screenshots/ButtonTheme_light.png index 71cc2966442..f78d30938be 100644 Binary files a/scripts/javascript/screenshots/ButtonTheme_light.png and b/scripts/javascript/screenshots/ButtonTheme_light.png differ diff --git a/scripts/javascript/screenshots/CenteredDialogTitle_dark.png b/scripts/javascript/screenshots/CenteredDialogTitle_dark.png index 872ea062ac5..d08e38a270d 100644 Binary files a/scripts/javascript/screenshots/CenteredDialogTitle_dark.png and b/scripts/javascript/screenshots/CenteredDialogTitle_dark.png differ diff --git a/scripts/javascript/screenshots/CenteredDialogTitle_ios_dark.png b/scripts/javascript/screenshots/CenteredDialogTitle_ios_dark.png index be648a3f5d5..83261c372a4 100644 Binary files a/scripts/javascript/screenshots/CenteredDialogTitle_ios_dark.png and b/scripts/javascript/screenshots/CenteredDialogTitle_ios_dark.png differ diff --git a/scripts/javascript/screenshots/CenteredDialogTitle_ios_light.png b/scripts/javascript/screenshots/CenteredDialogTitle_ios_light.png index 05d0a6e94a3..f74cc280177 100644 Binary files a/scripts/javascript/screenshots/CenteredDialogTitle_ios_light.png and b/scripts/javascript/screenshots/CenteredDialogTitle_ios_light.png differ diff --git a/scripts/javascript/screenshots/CenteredDialogTitle_light.png b/scripts/javascript/screenshots/CenteredDialogTitle_light.png index fbd9e54386a..67b59ac3a5b 100644 Binary files a/scripts/javascript/screenshots/CenteredDialogTitle_light.png and b/scripts/javascript/screenshots/CenteredDialogTitle_light.png differ diff --git a/scripts/javascript/screenshots/CenteredInteractionDialogTitle_dark.png b/scripts/javascript/screenshots/CenteredInteractionDialogTitle_dark.png index 9a447cf6416..faf1116f2ad 100644 Binary files a/scripts/javascript/screenshots/CenteredInteractionDialogTitle_dark.png and b/scripts/javascript/screenshots/CenteredInteractionDialogTitle_dark.png differ diff --git a/scripts/javascript/screenshots/CenteredInteractionDialogTitle_ios_dark.png b/scripts/javascript/screenshots/CenteredInteractionDialogTitle_ios_dark.png index 4ae139416c2..c40364e1651 100644 Binary files a/scripts/javascript/screenshots/CenteredInteractionDialogTitle_ios_dark.png and b/scripts/javascript/screenshots/CenteredInteractionDialogTitle_ios_dark.png differ diff --git a/scripts/javascript/screenshots/CenteredInteractionDialogTitle_ios_light.png b/scripts/javascript/screenshots/CenteredInteractionDialogTitle_ios_light.png index bd8d80a1666..2414989d704 100644 Binary files a/scripts/javascript/screenshots/CenteredInteractionDialogTitle_ios_light.png and b/scripts/javascript/screenshots/CenteredInteractionDialogTitle_ios_light.png differ diff --git a/scripts/javascript/screenshots/CenteredInteractionDialogTitle_light.png b/scripts/javascript/screenshots/CenteredInteractionDialogTitle_light.png index 1960f45faa1..ab3e110b3ab 100644 Binary files a/scripts/javascript/screenshots/CenteredInteractionDialogTitle_light.png and b/scripts/javascript/screenshots/CenteredInteractionDialogTitle_light.png differ diff --git a/scripts/javascript/screenshots/ChatInput_dark.png b/scripts/javascript/screenshots/ChatInput_dark.png index 41d97d09842..acf506c1051 100644 Binary files a/scripts/javascript/screenshots/ChatInput_dark.png and b/scripts/javascript/screenshots/ChatInput_dark.png differ diff --git a/scripts/javascript/screenshots/ChatInput_ios_dark.png b/scripts/javascript/screenshots/ChatInput_ios_dark.png index 127d0f64d59..743b0156953 100644 Binary files a/scripts/javascript/screenshots/ChatInput_ios_dark.png and b/scripts/javascript/screenshots/ChatInput_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ChatInput_ios_light.png b/scripts/javascript/screenshots/ChatInput_ios_light.png index f0579700c6e..dec6e6d6d38 100644 Binary files a/scripts/javascript/screenshots/ChatInput_ios_light.png and b/scripts/javascript/screenshots/ChatInput_ios_light.png differ diff --git a/scripts/javascript/screenshots/ChatInput_light.png b/scripts/javascript/screenshots/ChatInput_light.png index 097b0f7b93d..6bc20ad1b66 100644 Binary files a/scripts/javascript/screenshots/ChatInput_light.png and b/scripts/javascript/screenshots/ChatInput_light.png differ diff --git a/scripts/javascript/screenshots/ChatView_dark.png b/scripts/javascript/screenshots/ChatView_dark.png index dfed073d35f..b0e2580efe4 100644 Binary files a/scripts/javascript/screenshots/ChatView_dark.png and b/scripts/javascript/screenshots/ChatView_dark.png differ diff --git a/scripts/javascript/screenshots/ChatView_ios_dark.png b/scripts/javascript/screenshots/ChatView_ios_dark.png index 2408d73dbaf..eb5a505913e 100644 Binary files a/scripts/javascript/screenshots/ChatView_ios_dark.png and b/scripts/javascript/screenshots/ChatView_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ChatView_ios_light.png b/scripts/javascript/screenshots/ChatView_ios_light.png index fc3606a9bce..5fd858275cd 100644 Binary files a/scripts/javascript/screenshots/ChatView_ios_light.png and b/scripts/javascript/screenshots/ChatView_ios_light.png differ diff --git a/scripts/javascript/screenshots/ChatView_light.png b/scripts/javascript/screenshots/ChatView_light.png index e15ab6abe85..469af3c5e1e 100644 Binary files a/scripts/javascript/screenshots/ChatView_light.png and b/scripts/javascript/screenshots/ChatView_light.png differ diff --git a/scripts/javascript/screenshots/CheckBoxRadioTheme_dark.png b/scripts/javascript/screenshots/CheckBoxRadioTheme_dark.png index f09900f8bc1..c381cabe2e2 100644 Binary files a/scripts/javascript/screenshots/CheckBoxRadioTheme_dark.png and b/scripts/javascript/screenshots/CheckBoxRadioTheme_dark.png differ diff --git a/scripts/javascript/screenshots/CheckBoxRadioTheme_ios_dark.png b/scripts/javascript/screenshots/CheckBoxRadioTheme_ios_dark.png index 5489378a41d..56a1aa5d068 100644 Binary files a/scripts/javascript/screenshots/CheckBoxRadioTheme_ios_dark.png and b/scripts/javascript/screenshots/CheckBoxRadioTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/CheckBoxRadioTheme_ios_light.png b/scripts/javascript/screenshots/CheckBoxRadioTheme_ios_light.png index 0289943b900..0ebdccade23 100644 Binary files a/scripts/javascript/screenshots/CheckBoxRadioTheme_ios_light.png and b/scripts/javascript/screenshots/CheckBoxRadioTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/CheckBoxRadioTheme_light.png b/scripts/javascript/screenshots/CheckBoxRadioTheme_light.png index e36178304a7..9216c06ccd8 100644 Binary files a/scripts/javascript/screenshots/CheckBoxRadioTheme_light.png and b/scripts/javascript/screenshots/CheckBoxRadioTheme_light.png differ diff --git a/scripts/javascript/screenshots/CodeEditor.png b/scripts/javascript/screenshots/CodeEditor.png index 369ce2a9a9b..84b7ee61dc1 100644 Binary files a/scripts/javascript/screenshots/CodeEditor.png and b/scripts/javascript/screenshots/CodeEditor.png differ diff --git a/scripts/javascript/screenshots/ComponentReplaceFadeScreenshotTest.png b/scripts/javascript/screenshots/ComponentReplaceFadeScreenshotTest.png index c8e9ea87d80..bf74a4dc012 100644 Binary files a/scripts/javascript/screenshots/ComponentReplaceFadeScreenshotTest.png and b/scripts/javascript/screenshots/ComponentReplaceFadeScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/ComponentReplaceFlipScreenshotTest.png b/scripts/javascript/screenshots/ComponentReplaceFlipScreenshotTest.png index f0b7adc4dcc..1a6db69f5e3 100644 Binary files a/scripts/javascript/screenshots/ComponentReplaceFlipScreenshotTest.png and b/scripts/javascript/screenshots/ComponentReplaceFlipScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/ComponentReplaceSlideScreenshotTest.png b/scripts/javascript/screenshots/ComponentReplaceSlideScreenshotTest.png index 6a9f44636d0..0f4713550e1 100644 Binary files a/scripts/javascript/screenshots/ComponentReplaceSlideScreenshotTest.png and b/scripts/javascript/screenshots/ComponentReplaceSlideScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/CoverHorizontalTransitionTest.png b/scripts/javascript/screenshots/CoverHorizontalTransitionTest.png index 38904c1cabb..f422a6e84fd 100644 Binary files a/scripts/javascript/screenshots/CoverHorizontalTransitionTest.png and b/scripts/javascript/screenshots/CoverHorizontalTransitionTest.png differ diff --git a/scripts/javascript/screenshots/DesktopMode.png b/scripts/javascript/screenshots/DesktopMode.png index 71686d3d93a..26881db3cbf 100644 Binary files a/scripts/javascript/screenshots/DesktopMode.png and b/scripts/javascript/screenshots/DesktopMode.png differ diff --git a/scripts/javascript/screenshots/DialogTheme_dark.png b/scripts/javascript/screenshots/DialogTheme_dark.png index 45cc579c878..f91b7f0e946 100644 Binary files a/scripts/javascript/screenshots/DialogTheme_dark.png and b/scripts/javascript/screenshots/DialogTheme_dark.png differ diff --git a/scripts/javascript/screenshots/DialogTheme_ios_dark.png b/scripts/javascript/screenshots/DialogTheme_ios_dark.png index ac8da3822a0..3f57724bd4f 100644 Binary files a/scripts/javascript/screenshots/DialogTheme_ios_dark.png and b/scripts/javascript/screenshots/DialogTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/DialogTheme_ios_light.png b/scripts/javascript/screenshots/DialogTheme_ios_light.png index 657e86584f9..ba199b4f570 100644 Binary files a/scripts/javascript/screenshots/DialogTheme_ios_light.png and b/scripts/javascript/screenshots/DialogTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/DialogTheme_light.png b/scripts/javascript/screenshots/DialogTheme_light.png index f553b3bad43..c1d220a87ee 100644 Binary files a/scripts/javascript/screenshots/DialogTheme_light.png and b/scripts/javascript/screenshots/DialogTheme_light.png differ diff --git a/scripts/javascript/screenshots/FadeTransitionTest.png b/scripts/javascript/screenshots/FadeTransitionTest.png index 4e830e4e868..fa49f15cf3d 100644 Binary files a/scripts/javascript/screenshots/FadeTransitionTest.png and b/scripts/javascript/screenshots/FadeTransitionTest.png differ diff --git a/scripts/javascript/screenshots/FlipTransitionTest.png b/scripts/javascript/screenshots/FlipTransitionTest.png index 8337f25f684..d3093c3bf4e 100644 Binary files a/scripts/javascript/screenshots/FlipTransitionTest.png and b/scripts/javascript/screenshots/FlipTransitionTest.png differ diff --git a/scripts/javascript/screenshots/FloatingActionButtonTheme_dark.png b/scripts/javascript/screenshots/FloatingActionButtonTheme_dark.png index 22888e009d1..d218aba7308 100644 Binary files a/scripts/javascript/screenshots/FloatingActionButtonTheme_dark.png and b/scripts/javascript/screenshots/FloatingActionButtonTheme_dark.png differ diff --git a/scripts/javascript/screenshots/FloatingActionButtonTheme_ios_dark.png b/scripts/javascript/screenshots/FloatingActionButtonTheme_ios_dark.png index 6f977d916a1..d905b6f7285 100644 Binary files a/scripts/javascript/screenshots/FloatingActionButtonTheme_ios_dark.png and b/scripts/javascript/screenshots/FloatingActionButtonTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/FloatingActionButtonTheme_ios_light.png b/scripts/javascript/screenshots/FloatingActionButtonTheme_ios_light.png index 06c89c05fa4..e5cfabea4d4 100644 Binary files a/scripts/javascript/screenshots/FloatingActionButtonTheme_ios_light.png and b/scripts/javascript/screenshots/FloatingActionButtonTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/FloatingActionButtonTheme_light.png b/scripts/javascript/screenshots/FloatingActionButtonTheme_light.png index ff26fb04098..4c259cd059a 100644 Binary files a/scripts/javascript/screenshots/FloatingActionButtonTheme_light.png and b/scripts/javascript/screenshots/FloatingActionButtonTheme_light.png differ diff --git a/scripts/javascript/screenshots/Gpu3DAnimation.png b/scripts/javascript/screenshots/Gpu3DAnimation.png index e0602915ad2..5d47576d036 100644 Binary files a/scripts/javascript/screenshots/Gpu3DAnimation.png and b/scripts/javascript/screenshots/Gpu3DAnimation.png differ diff --git a/scripts/javascript/screenshots/Gpu3DCube.png b/scripts/javascript/screenshots/Gpu3DCube.png index 775e74509c7..dfd5a7eae33 100644 Binary files a/scripts/javascript/screenshots/Gpu3DCube.png and b/scripts/javascript/screenshots/Gpu3DCube.png differ diff --git a/scripts/javascript/screenshots/Gpu3DModel.png b/scripts/javascript/screenshots/Gpu3DModel.png index 82e7cc0d716..b2abfb9b4f5 100644 Binary files a/scripts/javascript/screenshots/Gpu3DModel.png and b/scripts/javascript/screenshots/Gpu3DModel.png differ diff --git a/scripts/javascript/screenshots/Gpu3DTexturedCube.png b/scripts/javascript/screenshots/Gpu3DTexturedCube.png index f60533035a5..faa00c2e334 100644 Binary files a/scripts/javascript/screenshots/Gpu3DTexturedCube.png and b/scripts/javascript/screenshots/Gpu3DTexturedCube.png differ diff --git a/scripts/javascript/screenshots/ImageViewerNavigationModes.png b/scripts/javascript/screenshots/ImageViewerNavigationModes.png index 952fe8b65cb..37591d0f61d 100644 Binary files a/scripts/javascript/screenshots/ImageViewerNavigationModes.png and b/scripts/javascript/screenshots/ImageViewerNavigationModes.png differ diff --git a/scripts/javascript/screenshots/LightweightPickerButtons.png b/scripts/javascript/screenshots/LightweightPickerButtons.png index d0943ae8bc1..f75b1ba4004 100644 Binary files a/scripts/javascript/screenshots/LightweightPickerButtons.png and b/scripts/javascript/screenshots/LightweightPickerButtons.png differ diff --git a/scripts/javascript/screenshots/ListTheme_dark.png b/scripts/javascript/screenshots/ListTheme_dark.png index 03914f0dbe0..bb2681bc7c2 100644 Binary files a/scripts/javascript/screenshots/ListTheme_dark.png and b/scripts/javascript/screenshots/ListTheme_dark.png differ diff --git a/scripts/javascript/screenshots/ListTheme_ios_dark.png b/scripts/javascript/screenshots/ListTheme_ios_dark.png index 3ac3b7062e1..b62e4806293 100644 Binary files a/scripts/javascript/screenshots/ListTheme_ios_dark.png and b/scripts/javascript/screenshots/ListTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ListTheme_ios_light.png b/scripts/javascript/screenshots/ListTheme_ios_light.png index eea8cdaf059..85525e275c0 100644 Binary files a/scripts/javascript/screenshots/ListTheme_ios_light.png and b/scripts/javascript/screenshots/ListTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/ListTheme_light.png b/scripts/javascript/screenshots/ListTheme_light.png index 931ffc87b7b..0c1e9d52bb7 100644 Binary files a/scripts/javascript/screenshots/ListTheme_light.png and b/scripts/javascript/screenshots/ListTheme_light.png differ diff --git a/scripts/javascript/screenshots/LottieAnimatedScreenshotTest.png b/scripts/javascript/screenshots/LottieAnimatedScreenshotTest.png index c475626ac2f..945bac9ad7a 100644 Binary files a/scripts/javascript/screenshots/LottieAnimatedScreenshotTest.png and b/scripts/javascript/screenshots/LottieAnimatedScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/MainActivity.png b/scripts/javascript/screenshots/MainActivity.png index 6f5417dc667..f98afac6fad 100644 Binary files a/scripts/javascript/screenshots/MainActivity.png and b/scripts/javascript/screenshots/MainActivity.png differ diff --git a/scripts/javascript/screenshots/Media360Panorama.png b/scripts/javascript/screenshots/Media360Panorama.png index 781d2ef1691..db62401ff76 100644 Binary files a/scripts/javascript/screenshots/Media360Panorama.png and b/scripts/javascript/screenshots/Media360Panorama.png differ diff --git a/scripts/javascript/screenshots/MediaPlayback.png b/scripts/javascript/screenshots/MediaPlayback.png index 7b6f7efd5e0..73b2f20d8fa 100644 Binary files a/scripts/javascript/screenshots/MediaPlayback.png and b/scripts/javascript/screenshots/MediaPlayback.png differ diff --git a/scripts/javascript/screenshots/MorphElementMorphScreenshotTest.png b/scripts/javascript/screenshots/MorphElementMorphScreenshotTest.png index 36b2b80c1a2..26f3bdcb3ca 100644 Binary files a/scripts/javascript/screenshots/MorphElementMorphScreenshotTest.png and b/scripts/javascript/screenshots/MorphElementMorphScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/MorphTransitionScrolledSourceTest.png b/scripts/javascript/screenshots/MorphTransitionScrolledSourceTest.png index 1c8e6288786..51b419bbb70 100644 Binary files a/scripts/javascript/screenshots/MorphTransitionScrolledSourceTest.png and b/scripts/javascript/screenshots/MorphTransitionScrolledSourceTest.png differ diff --git a/scripts/javascript/screenshots/MorphTransitionScrubScreenshotTest.png b/scripts/javascript/screenshots/MorphTransitionScrubScreenshotTest.png index c9ecb4aaeb1..cf996087f34 100644 Binary files a/scripts/javascript/screenshots/MorphTransitionScrubScreenshotTest.png and b/scripts/javascript/screenshots/MorphTransitionScrubScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/MorphTransitionSnapshotTest.png b/scripts/javascript/screenshots/MorphTransitionSnapshotTest.png index a82a940d5aa..ae6022d7dfd 100644 Binary files a/scripts/javascript/screenshots/MorphTransitionSnapshotTest.png and b/scripts/javascript/screenshots/MorphTransitionSnapshotTest.png differ diff --git a/scripts/javascript/screenshots/MorphTransitionTest.png b/scripts/javascript/screenshots/MorphTransitionTest.png index 83cf7122eee..22423fe95f8 100644 Binary files a/scripts/javascript/screenshots/MorphTransitionTest.png and b/scripts/javascript/screenshots/MorphTransitionTest.png differ diff --git a/scripts/javascript/screenshots/MotionShowcaseScreenshotTest.png b/scripts/javascript/screenshots/MotionShowcaseScreenshotTest.png index 5fd3cb7d88e..19c4885d53e 100644 Binary files a/scripts/javascript/screenshots/MotionShowcaseScreenshotTest.png and b/scripts/javascript/screenshots/MotionShowcaseScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/MultiButtonTheme_dark.png b/scripts/javascript/screenshots/MultiButtonTheme_dark.png index fba05f81a5f..0cadd77e041 100644 Binary files a/scripts/javascript/screenshots/MultiButtonTheme_dark.png and b/scripts/javascript/screenshots/MultiButtonTheme_dark.png differ diff --git a/scripts/javascript/screenshots/MultiButtonTheme_ios_dark.png b/scripts/javascript/screenshots/MultiButtonTheme_ios_dark.png index e43c99e171a..c11b09f42ad 100644 Binary files a/scripts/javascript/screenshots/MultiButtonTheme_ios_dark.png and b/scripts/javascript/screenshots/MultiButtonTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/MultiButtonTheme_ios_light.png b/scripts/javascript/screenshots/MultiButtonTheme_ios_light.png index da13b9636b6..d5420638522 100644 Binary files a/scripts/javascript/screenshots/MultiButtonTheme_ios_light.png and b/scripts/javascript/screenshots/MultiButtonTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/MultiButtonTheme_light.png b/scripts/javascript/screenshots/MultiButtonTheme_light.png index 778a4a3ef61..78b801602c6 100644 Binary files a/scripts/javascript/screenshots/MultiButtonTheme_light.png and b/scripts/javascript/screenshots/MultiButtonTheme_light.png differ diff --git a/scripts/javascript/screenshots/NativeMapFallback.png b/scripts/javascript/screenshots/NativeMapFallback.png index e6e41322d9f..d48b275c809 100644 Binary files a/scripts/javascript/screenshots/NativeMapFallback.png and b/scripts/javascript/screenshots/NativeMapFallback.png differ diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_dark.png b/scripts/javascript/screenshots/PaletteOverrideTheme_dark.png index a11399e430d..fb600097fea 100644 Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_dark.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_dark.png differ diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png index 8d88b78e753..d85430afc54 100644 Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png index d41be536653..c0398db46db 100644 Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/PaletteOverrideTheme_light.png b/scripts/javascript/screenshots/PaletteOverrideTheme_light.png index 8e2ccee0b30..c0fb298797c 100644 Binary files a/scripts/javascript/screenshots/PaletteOverrideTheme_light.png and b/scripts/javascript/screenshots/PaletteOverrideTheme_light.png differ diff --git a/scripts/javascript/screenshots/PickerTheme_dark.png b/scripts/javascript/screenshots/PickerTheme_dark.png index bfbbfb899fd..fb1ddd2878b 100644 Binary files a/scripts/javascript/screenshots/PickerTheme_dark.png and b/scripts/javascript/screenshots/PickerTheme_dark.png differ diff --git a/scripts/javascript/screenshots/PickerTheme_ios_dark.png b/scripts/javascript/screenshots/PickerTheme_ios_dark.png index d23ceba9e06..d4a39e66fad 100644 Binary files a/scripts/javascript/screenshots/PickerTheme_ios_dark.png and b/scripts/javascript/screenshots/PickerTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/PickerTheme_ios_light.png b/scripts/javascript/screenshots/PickerTheme_ios_light.png index 0ecb46070bc..7ce6acf7da9 100644 Binary files a/scripts/javascript/screenshots/PickerTheme_ios_light.png and b/scripts/javascript/screenshots/PickerTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/PickerTheme_light.png b/scripts/javascript/screenshots/PickerTheme_light.png index ceb22e2d0b9..d64905d5c81 100644 Binary files a/scripts/javascript/screenshots/PickerTheme_light.png and b/scripts/javascript/screenshots/PickerTheme_light.png differ diff --git a/scripts/javascript/screenshots/PullToRefreshSpinnerScreenshotTest.png b/scripts/javascript/screenshots/PullToRefreshSpinnerScreenshotTest.png index 60426b3090f..e121cd262c5 100644 Binary files a/scripts/javascript/screenshots/PullToRefreshSpinnerScreenshotTest.png and b/scripts/javascript/screenshots/PullToRefreshSpinnerScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/PureEditors.png b/scripts/javascript/screenshots/PureEditors.png index 7293a640ffb..a28f057e67f 100644 Binary files a/scripts/javascript/screenshots/PureEditors.png and b/scripts/javascript/screenshots/PureEditors.png differ diff --git a/scripts/javascript/screenshots/RealOsmVector.png b/scripts/javascript/screenshots/RealOsmVector.png index ce10e8c440a..feede96746a 100644 Binary files a/scripts/javascript/screenshots/RealOsmVector.png and b/scripts/javascript/screenshots/RealOsmVector.png differ diff --git a/scripts/javascript/screenshots/RichTextArea.png b/scripts/javascript/screenshots/RichTextArea.png index eed40b602a2..56d20c0f4d9 100644 Binary files a/scripts/javascript/screenshots/RichTextArea.png and b/scripts/javascript/screenshots/RichTextArea.png differ diff --git a/scripts/javascript/screenshots/SVGAnimatedScreenshotTest.png b/scripts/javascript/screenshots/SVGAnimatedScreenshotTest.png index 2d5d2fb86f7..8a09c718d32 100644 Binary files a/scripts/javascript/screenshots/SVGAnimatedScreenshotTest.png and b/scripts/javascript/screenshots/SVGAnimatedScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/SVGStatic.png b/scripts/javascript/screenshots/SVGStatic.png index 6ad85ecfbfc..74cd6e19a68 100644 Binary files a/scripts/javascript/screenshots/SVGStatic.png and b/scripts/javascript/screenshots/SVGStatic.png differ diff --git a/scripts/javascript/screenshots/Sheet.png b/scripts/javascript/screenshots/Sheet.png index de040aa57c8..7068f80ca96 100644 Binary files a/scripts/javascript/screenshots/Sheet.png and b/scripts/javascript/screenshots/Sheet.png differ diff --git a/scripts/javascript/screenshots/SheetSlideUpAnimationScreenshotTest.png b/scripts/javascript/screenshots/SheetSlideUpAnimationScreenshotTest.png index c22dff86010..95350c1d111 100644 Binary files a/scripts/javascript/screenshots/SheetSlideUpAnimationScreenshotTest.png and b/scripts/javascript/screenshots/SheetSlideUpAnimationScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/ShowcaseTheme_dark.png b/scripts/javascript/screenshots/ShowcaseTheme_dark.png index 23d047580aa..027cefb3b85 100644 Binary files a/scripts/javascript/screenshots/ShowcaseTheme_dark.png and b/scripts/javascript/screenshots/ShowcaseTheme_dark.png differ diff --git a/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png b/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png index 54aa582b2ab..5ae3935fc93 100644 Binary files a/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png and b/scripts/javascript/screenshots/ShowcaseTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png b/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png index 7c56e9122e6..28e69648d20 100644 Binary files a/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png and b/scripts/javascript/screenshots/ShowcaseTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/ShowcaseTheme_light.png b/scripts/javascript/screenshots/ShowcaseTheme_light.png index f63b18fe6b7..40e4fc18348 100644 Binary files a/scripts/javascript/screenshots/ShowcaseTheme_light.png and b/scripts/javascript/screenshots/ShowcaseTheme_light.png differ diff --git a/scripts/javascript/screenshots/SlideFadeTitleTransitionTest.png b/scripts/javascript/screenshots/SlideFadeTitleTransitionTest.png index 06673fb2f16..5e9468baddd 100644 Binary files a/scripts/javascript/screenshots/SlideFadeTitleTransitionTest.png and b/scripts/javascript/screenshots/SlideFadeTitleTransitionTest.png differ diff --git a/scripts/javascript/screenshots/SlideHorizontalBackTransitionTest.png b/scripts/javascript/screenshots/SlideHorizontalBackTransitionTest.png index 83be8027f36..b8d05ee1736 100644 Binary files a/scripts/javascript/screenshots/SlideHorizontalBackTransitionTest.png and b/scripts/javascript/screenshots/SlideHorizontalBackTransitionTest.png differ diff --git a/scripts/javascript/screenshots/SlideHorizontalTransitionTest.png b/scripts/javascript/screenshots/SlideHorizontalTransitionTest.png index 9bf789f4aab..8e93bb93c81 100644 Binary files a/scripts/javascript/screenshots/SlideHorizontalTransitionTest.png and b/scripts/javascript/screenshots/SlideHorizontalTransitionTest.png differ diff --git a/scripts/javascript/screenshots/SlideVerticalTransitionTest.png b/scripts/javascript/screenshots/SlideVerticalTransitionTest.png index 20238ce5d3a..58d5b76e540 100644 Binary files a/scripts/javascript/screenshots/SlideVerticalTransitionTest.png and b/scripts/javascript/screenshots/SlideVerticalTransitionTest.png differ diff --git a/scripts/javascript/screenshots/SmoothScrollScreenshotTest.png b/scripts/javascript/screenshots/SmoothScrollScreenshotTest.png index 5660caa104d..d91518f5b15 100644 Binary files a/scripts/javascript/screenshots/SmoothScrollScreenshotTest.png and b/scripts/javascript/screenshots/SmoothScrollScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/SpanLabelTheme_dark.png b/scripts/javascript/screenshots/SpanLabelTheme_dark.png index 4e6fa0207c8..c5fb2330b14 100644 Binary files a/scripts/javascript/screenshots/SpanLabelTheme_dark.png and b/scripts/javascript/screenshots/SpanLabelTheme_dark.png differ diff --git a/scripts/javascript/screenshots/SpanLabelTheme_ios_dark.png b/scripts/javascript/screenshots/SpanLabelTheme_ios_dark.png index c362d2c1f2d..17f4fcce42d 100644 Binary files a/scripts/javascript/screenshots/SpanLabelTheme_ios_dark.png and b/scripts/javascript/screenshots/SpanLabelTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/SpanLabelTheme_ios_light.png b/scripts/javascript/screenshots/SpanLabelTheme_ios_light.png index de2809c89be..87bcfdc54ab 100644 Binary files a/scripts/javascript/screenshots/SpanLabelTheme_ios_light.png and b/scripts/javascript/screenshots/SpanLabelTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/SpanLabelTheme_light.png b/scripts/javascript/screenshots/SpanLabelTheme_light.png index 8b0e491ac1b..705f60d9147 100644 Binary files a/scripts/javascript/screenshots/SpanLabelTheme_light.png and b/scripts/javascript/screenshots/SpanLabelTheme_light.png differ diff --git a/scripts/javascript/screenshots/StatusBarTapDiagnosticScreenshotTest.png b/scripts/javascript/screenshots/StatusBarTapDiagnosticScreenshotTest.png index 87372d0504a..a7713c4057b 100644 Binary files a/scripts/javascript/screenshots/StatusBarTapDiagnosticScreenshotTest.png and b/scripts/javascript/screenshots/StatusBarTapDiagnosticScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/StickyHeaderFadeTransitionScreenshotTest.png b/scripts/javascript/screenshots/StickyHeaderFadeTransitionScreenshotTest.png index 187112029fc..cce2fe5aebc 100644 Binary files a/scripts/javascript/screenshots/StickyHeaderFadeTransitionScreenshotTest.png and b/scripts/javascript/screenshots/StickyHeaderFadeTransitionScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/StickyHeaderScreenshotTest.png b/scripts/javascript/screenshots/StickyHeaderScreenshotTest.png index d0c20f5c9e4..e82a9bb7335 100644 Binary files a/scripts/javascript/screenshots/StickyHeaderScreenshotTest.png and b/scripts/javascript/screenshots/StickyHeaderScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/StickyHeaderSlideTransitionScreenshotTest.png b/scripts/javascript/screenshots/StickyHeaderSlideTransitionScreenshotTest.png index 09124c4fac8..3da5dae0b91 100644 Binary files a/scripts/javascript/screenshots/StickyHeaderSlideTransitionScreenshotTest.png and b/scripts/javascript/screenshots/StickyHeaderSlideTransitionScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/SurfacesRasterizer.png b/scripts/javascript/screenshots/SurfacesRasterizer.png index 030481b8c4c..b11f24f5c9b 100644 Binary files a/scripts/javascript/screenshots/SurfacesRasterizer.png and b/scripts/javascript/screenshots/SurfacesRasterizer.png differ diff --git a/scripts/javascript/screenshots/SwitchTheme_dark.png b/scripts/javascript/screenshots/SwitchTheme_dark.png index eca72170cf0..36e29d48dbf 100644 Binary files a/scripts/javascript/screenshots/SwitchTheme_dark.png and b/scripts/javascript/screenshots/SwitchTheme_dark.png differ diff --git a/scripts/javascript/screenshots/SwitchTheme_ios_dark.png b/scripts/javascript/screenshots/SwitchTheme_ios_dark.png index e1a41d2227d..a9cdd6b345e 100644 Binary files a/scripts/javascript/screenshots/SwitchTheme_ios_dark.png and b/scripts/javascript/screenshots/SwitchTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/SwitchTheme_ios_light.png b/scripts/javascript/screenshots/SwitchTheme_ios_light.png index aaec8b82637..226c1ede65c 100644 Binary files a/scripts/javascript/screenshots/SwitchTheme_ios_light.png and b/scripts/javascript/screenshots/SwitchTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/SwitchTheme_light.png b/scripts/javascript/screenshots/SwitchTheme_light.png index 6087aacd8ca..8cedd71eb0d 100644 Binary files a/scripts/javascript/screenshots/SwitchTheme_light.png and b/scripts/javascript/screenshots/SwitchTheme_light.png differ diff --git a/scripts/javascript/screenshots/TabsAnimatedIndicatorScreenshotTest.png b/scripts/javascript/screenshots/TabsAnimatedIndicatorScreenshotTest.png index 70cd13ae7ad..67761d27adb 100644 Binary files a/scripts/javascript/screenshots/TabsAnimatedIndicatorScreenshotTest.png and b/scripts/javascript/screenshots/TabsAnimatedIndicatorScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/TabsBehavior.png b/scripts/javascript/screenshots/TabsBehavior.png index 71e94e4fdc8..6751b9a0173 100644 Binary files a/scripts/javascript/screenshots/TabsBehavior.png and b/scripts/javascript/screenshots/TabsBehavior.png differ diff --git a/scripts/javascript/screenshots/TabsTheme_dark.png b/scripts/javascript/screenshots/TabsTheme_dark.png index c0a20ff22bb..1a4c89c94b3 100644 Binary files a/scripts/javascript/screenshots/TabsTheme_dark.png and b/scripts/javascript/screenshots/TabsTheme_dark.png differ diff --git a/scripts/javascript/screenshots/TabsTheme_ios_dark.png b/scripts/javascript/screenshots/TabsTheme_ios_dark.png index 20c774f8b3c..aa5833bf1e9 100644 Binary files a/scripts/javascript/screenshots/TabsTheme_ios_dark.png and b/scripts/javascript/screenshots/TabsTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/TabsTheme_ios_light.png b/scripts/javascript/screenshots/TabsTheme_ios_light.png index e149988a5ff..64e155caa11 100644 Binary files a/scripts/javascript/screenshots/TabsTheme_ios_light.png and b/scripts/javascript/screenshots/TabsTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/TabsTheme_light.png b/scripts/javascript/screenshots/TabsTheme_light.png index 52b8770acbb..fc98a508f7b 100644 Binary files a/scripts/javascript/screenshots/TabsTheme_light.png and b/scripts/javascript/screenshots/TabsTheme_light.png differ diff --git a/scripts/javascript/screenshots/TensileBounceScreenshotTest.png b/scripts/javascript/screenshots/TensileBounceScreenshotTest.png index 7c00e7bfb16..5d8d4b6653f 100644 Binary files a/scripts/javascript/screenshots/TensileBounceScreenshotTest.png and b/scripts/javascript/screenshots/TensileBounceScreenshotTest.png differ diff --git a/scripts/javascript/screenshots/TextAreaAlignmentStates.png b/scripts/javascript/screenshots/TextAreaAlignmentStates.png index c7057394464..ba8019a9a9e 100644 Binary files a/scripts/javascript/screenshots/TextAreaAlignmentStates.png and b/scripts/javascript/screenshots/TextAreaAlignmentStates.png differ diff --git a/scripts/javascript/screenshots/TextFieldTheme_dark.png b/scripts/javascript/screenshots/TextFieldTheme_dark.png index 8bf127b0e36..1a84130fd49 100644 Binary files a/scripts/javascript/screenshots/TextFieldTheme_dark.png and b/scripts/javascript/screenshots/TextFieldTheme_dark.png differ diff --git a/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png b/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png index ae04b36caef..02f495dcff7 100644 Binary files a/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png and b/scripts/javascript/screenshots/TextFieldTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/TextFieldTheme_ios_light.png b/scripts/javascript/screenshots/TextFieldTheme_ios_light.png index f1f8b1daed2..1f3741c1ff7 100644 Binary files a/scripts/javascript/screenshots/TextFieldTheme_ios_light.png and b/scripts/javascript/screenshots/TextFieldTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/TextFieldTheme_light.png b/scripts/javascript/screenshots/TextFieldTheme_light.png index 7d6d3ae70bf..c454818e993 100644 Binary files a/scripts/javascript/screenshots/TextFieldTheme_light.png and b/scripts/javascript/screenshots/TextFieldTheme_light.png differ diff --git a/scripts/javascript/screenshots/ToastBarTopPosition.png b/scripts/javascript/screenshots/ToastBarTopPosition.png index f6946dfaece..126fc5a8fe6 100644 Binary files a/scripts/javascript/screenshots/ToastBarTopPosition.png and b/scripts/javascript/screenshots/ToastBarTopPosition.png differ diff --git a/scripts/javascript/screenshots/ToolbarTheme_dark.png b/scripts/javascript/screenshots/ToolbarTheme_dark.png index e587569fa6b..8050d09d60f 100644 Binary files a/scripts/javascript/screenshots/ToolbarTheme_dark.png and b/scripts/javascript/screenshots/ToolbarTheme_dark.png differ diff --git a/scripts/javascript/screenshots/ToolbarTheme_ios_dark.png b/scripts/javascript/screenshots/ToolbarTheme_ios_dark.png index 5410577ddf2..0de0b1e3713 100644 Binary files a/scripts/javascript/screenshots/ToolbarTheme_ios_dark.png and b/scripts/javascript/screenshots/ToolbarTheme_ios_dark.png differ diff --git a/scripts/javascript/screenshots/ToolbarTheme_ios_light.png b/scripts/javascript/screenshots/ToolbarTheme_ios_light.png index 5110387c69d..23ff71ec59d 100644 Binary files a/scripts/javascript/screenshots/ToolbarTheme_ios_light.png and b/scripts/javascript/screenshots/ToolbarTheme_ios_light.png differ diff --git a/scripts/javascript/screenshots/ToolbarTheme_light.png b/scripts/javascript/screenshots/ToolbarTheme_light.png index 2690263b226..b7e8e006d0e 100644 Binary files a/scripts/javascript/screenshots/ToolbarTheme_light.png and b/scripts/javascript/screenshots/ToolbarTheme_light.png differ diff --git a/scripts/javascript/screenshots/UncoverHorizontalTransitionTest.png b/scripts/javascript/screenshots/UncoverHorizontalTransitionTest.png index c48033ea6fb..64ec541c422 100644 Binary files a/scripts/javascript/screenshots/UncoverHorizontalTransitionTest.png and b/scripts/javascript/screenshots/UncoverHorizontalTransitionTest.png differ diff --git a/scripts/javascript/screenshots/VRStereoScene.png b/scripts/javascript/screenshots/VRStereoScene.png index 2e22a162f1a..7156f15d6fd 100644 Binary files a/scripts/javascript/screenshots/VRStereoScene.png and b/scripts/javascript/screenshots/VRStereoScene.png differ diff --git a/scripts/javascript/screenshots/ValidatorLightweightPicker.png b/scripts/javascript/screenshots/ValidatorLightweightPicker.png index 63b79e46503..6d58b9b709b 100644 Binary files a/scripts/javascript/screenshots/ValidatorLightweightPicker.png and b/scripts/javascript/screenshots/ValidatorLightweightPicker.png differ diff --git a/scripts/javascript/screenshots/VectorMapDarkStyle.png b/scripts/javascript/screenshots/VectorMapDarkStyle.png index 1288dbafed3..df43832a05a 100644 Binary files a/scripts/javascript/screenshots/VectorMapDarkStyle.png and b/scripts/javascript/screenshots/VectorMapDarkStyle.png differ diff --git a/scripts/javascript/screenshots/VectorMapMarkers.png b/scripts/javascript/screenshots/VectorMapMarkers.png index 2d4b4959507..457a881b6e5 100644 Binary files a/scripts/javascript/screenshots/VectorMapMarkers.png and b/scripts/javascript/screenshots/VectorMapMarkers.png differ diff --git a/scripts/javascript/screenshots/VectorMapShapes.png b/scripts/javascript/screenshots/VectorMapShapes.png index 3842f9d8ada..0b24df4fc4a 100644 Binary files a/scripts/javascript/screenshots/VectorMapShapes.png and b/scripts/javascript/screenshots/VectorMapShapes.png differ diff --git a/scripts/javascript/screenshots/VideoIODecodedFrames.png b/scripts/javascript/screenshots/VideoIODecodedFrames.png index 049b6c81df2..eaa2ca910c3 100644 Binary files a/scripts/javascript/screenshots/VideoIODecodedFrames.png and b/scripts/javascript/screenshots/VideoIODecodedFrames.png differ diff --git a/scripts/javascript/screenshots/chart-bar-stacked.png b/scripts/javascript/screenshots/chart-bar-stacked.png index 76ffbab7aa8..805e370309a 100644 Binary files a/scripts/javascript/screenshots/chart-bar-stacked.png and b/scripts/javascript/screenshots/chart-bar-stacked.png differ diff --git a/scripts/javascript/screenshots/chart-bar.png b/scripts/javascript/screenshots/chart-bar.png index 695e7817df7..29afbed4a4e 100644 Binary files a/scripts/javascript/screenshots/chart-bar.png and b/scripts/javascript/screenshots/chart-bar.png differ diff --git a/scripts/javascript/screenshots/chart-bubble.png b/scripts/javascript/screenshots/chart-bubble.png index bad22ec8a70..51e5db26e08 100644 Binary files a/scripts/javascript/screenshots/chart-bubble.png and b/scripts/javascript/screenshots/chart-bubble.png differ diff --git a/scripts/javascript/screenshots/chart-combined-xy.png b/scripts/javascript/screenshots/chart-combined-xy.png index a41b82eb9dd..ad9a157e638 100644 Binary files a/scripts/javascript/screenshots/chart-combined-xy.png and b/scripts/javascript/screenshots/chart-combined-xy.png differ diff --git a/scripts/javascript/screenshots/chart-cubic-line.png b/scripts/javascript/screenshots/chart-cubic-line.png index 66eb80b27b1..1fa5c02afc8 100644 Binary files a/scripts/javascript/screenshots/chart-cubic-line.png and b/scripts/javascript/screenshots/chart-cubic-line.png differ diff --git a/scripts/javascript/screenshots/chart-doughnut.png b/scripts/javascript/screenshots/chart-doughnut.png index cacb5b35b49..275e03f6da7 100644 Binary files a/scripts/javascript/screenshots/chart-doughnut.png and b/scripts/javascript/screenshots/chart-doughnut.png differ diff --git a/scripts/javascript/screenshots/chart-line.png b/scripts/javascript/screenshots/chart-line.png index f553e2be8c2..5364f68a797 100644 Binary files a/scripts/javascript/screenshots/chart-line.png and b/scripts/javascript/screenshots/chart-line.png differ diff --git a/scripts/javascript/screenshots/chart-pie.png b/scripts/javascript/screenshots/chart-pie.png index b92c3ba586c..8f82f064a5d 100644 Binary files a/scripts/javascript/screenshots/chart-pie.png and b/scripts/javascript/screenshots/chart-pie.png differ diff --git a/scripts/javascript/screenshots/chart-radar.png b/scripts/javascript/screenshots/chart-radar.png index af70357f557..cfa70aeabbe 100644 Binary files a/scripts/javascript/screenshots/chart-radar.png and b/scripts/javascript/screenshots/chart-radar.png differ diff --git a/scripts/javascript/screenshots/chart-range-bar.png b/scripts/javascript/screenshots/chart-range-bar.png index 556af35c613..b3a95aa85a9 100644 Binary files a/scripts/javascript/screenshots/chart-range-bar.png and b/scripts/javascript/screenshots/chart-range-bar.png differ diff --git a/scripts/javascript/screenshots/chart-rotated-pie.png b/scripts/javascript/screenshots/chart-rotated-pie.png index 60976366dd4..a66eb0755b5 100644 Binary files a/scripts/javascript/screenshots/chart-rotated-pie.png and b/scripts/javascript/screenshots/chart-rotated-pie.png differ diff --git a/scripts/javascript/screenshots/chart-scatter.png b/scripts/javascript/screenshots/chart-scatter.png index e1dc514d4c2..b5a50b04747 100644 Binary files a/scripts/javascript/screenshots/chart-scatter.png and b/scripts/javascript/screenshots/chart-scatter.png differ diff --git a/scripts/javascript/screenshots/chart-time.png b/scripts/javascript/screenshots/chart-time.png index fbc09c10bd9..d977befb074 100644 Binary files a/scripts/javascript/screenshots/chart-time.png and b/scripts/javascript/screenshots/chart-time.png differ diff --git a/scripts/javascript/screenshots/chart-transform.png b/scripts/javascript/screenshots/chart-transform.png index bede727b13d..03d18bd908c 100644 Binary files a/scripts/javascript/screenshots/chart-transform.png and b/scripts/javascript/screenshots/chart-transform.png differ diff --git a/scripts/javascript/screenshots/css-gradients.png b/scripts/javascript/screenshots/css-gradients.png index f8bd445a227..9deeb978503 100644 Binary files a/scripts/javascript/screenshots/css-gradients.png and b/scripts/javascript/screenshots/css-gradients.png differ diff --git a/scripts/javascript/screenshots/graphics-affine-scale.png b/scripts/javascript/screenshots/graphics-affine-scale.png index a1c56b51f1e..4277b3ac0dd 100644 Binary files a/scripts/javascript/screenshots/graphics-affine-scale.png and b/scripts/javascript/screenshots/graphics-affine-scale.png differ diff --git a/scripts/javascript/screenshots/graphics-clip-under-rotation.png b/scripts/javascript/screenshots/graphics-clip-under-rotation.png index a455af54ab2..ec5422776ca 100644 Binary files a/scripts/javascript/screenshots/graphics-clip-under-rotation.png and b/scripts/javascript/screenshots/graphics-clip-under-rotation.png differ diff --git a/scripts/javascript/screenshots/graphics-clip.png b/scripts/javascript/screenshots/graphics-clip.png index dba76088b46..a13de941eb3 100644 Binary files a/scripts/javascript/screenshots/graphics-clip.png and b/scripts/javascript/screenshots/graphics-clip.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-arc.png b/scripts/javascript/screenshots/graphics-draw-arc.png index 1bc684397b8..c48c2dd3892 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-arc.png and b/scripts/javascript/screenshots/graphics-draw-arc.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-gradient-stops.png b/scripts/javascript/screenshots/graphics-draw-gradient-stops.png index 2a06518d55f..b7cc3508a4b 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-gradient-stops.png and b/scripts/javascript/screenshots/graphics-draw-gradient-stops.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-gradient.png b/scripts/javascript/screenshots/graphics-draw-gradient.png index 72612a53f6c..ddbb94c62d7 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-gradient.png and b/scripts/javascript/screenshots/graphics-draw-gradient.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-image-rect.png b/scripts/javascript/screenshots/graphics-draw-image-rect.png index 2e28033ab07..6d36c38668e 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-image-rect.png and b/scripts/javascript/screenshots/graphics-draw-image-rect.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-line.png b/scripts/javascript/screenshots/graphics-draw-line.png index 7c438504bae..49a1c134ea0 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-line.png and b/scripts/javascript/screenshots/graphics-draw-line.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-rect.png b/scripts/javascript/screenshots/graphics-draw-rect.png index 4d9c7f524bc..45d8d479fd4 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-rect.png and b/scripts/javascript/screenshots/graphics-draw-rect.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-round-rect.png b/scripts/javascript/screenshots/graphics-draw-round-rect.png index 2d543617f40..2577c593485 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-round-rect.png and b/scripts/javascript/screenshots/graphics-draw-round-rect.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-shape.png b/scripts/javascript/screenshots/graphics-draw-shape.png index 17a8ae593b5..8f5bf2f1f32 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-shape.png and b/scripts/javascript/screenshots/graphics-draw-shape.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-string-decorated.png b/scripts/javascript/screenshots/graphics-draw-string-decorated.png index 8543f109b63..86d6ac4237f 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-string-decorated.png and b/scripts/javascript/screenshots/graphics-draw-string-decorated.png differ diff --git a/scripts/javascript/screenshots/graphics-draw-string.png b/scripts/javascript/screenshots/graphics-draw-string.png index 94da53439a5..f70942a4376 100644 Binary files a/scripts/javascript/screenshots/graphics-draw-string.png and b/scripts/javascript/screenshots/graphics-draw-string.png differ diff --git a/scripts/javascript/screenshots/graphics-empty-clip.png b/scripts/javascript/screenshots/graphics-empty-clip.png index e6114d79ece..28b2df96c8e 100644 Binary files a/scripts/javascript/screenshots/graphics-empty-clip.png and b/scripts/javascript/screenshots/graphics-empty-clip.png differ diff --git a/scripts/javascript/screenshots/graphics-fill-arc.png b/scripts/javascript/screenshots/graphics-fill-arc.png index b0c4f3a71cf..3464dfa025f 100644 Binary files a/scripts/javascript/screenshots/graphics-fill-arc.png and b/scripts/javascript/screenshots/graphics-fill-arc.png differ diff --git a/scripts/javascript/screenshots/graphics-fill-polygon.png b/scripts/javascript/screenshots/graphics-fill-polygon.png index 1d5c38d1bef..86348ac2647 100644 Binary files a/scripts/javascript/screenshots/graphics-fill-polygon.png and b/scripts/javascript/screenshots/graphics-fill-polygon.png differ diff --git a/scripts/javascript/screenshots/graphics-fill-rect.png b/scripts/javascript/screenshots/graphics-fill-rect.png index d52ecd12650..7c7d4b060da 100644 Binary files a/scripts/javascript/screenshots/graphics-fill-rect.png and b/scripts/javascript/screenshots/graphics-fill-rect.png differ diff --git a/scripts/javascript/screenshots/graphics-fill-round-rect.png b/scripts/javascript/screenshots/graphics-fill-round-rect.png index 3537b008a17..a1ade9c111c 100644 Binary files a/scripts/javascript/screenshots/graphics-fill-round-rect.png and b/scripts/javascript/screenshots/graphics-fill-round-rect.png differ diff --git a/scripts/javascript/screenshots/graphics-fill-shape.png b/scripts/javascript/screenshots/graphics-fill-shape.png index 247e7415674..e48d9c4b5ab 100644 Binary files a/scripts/javascript/screenshots/graphics-fill-shape.png and b/scripts/javascript/screenshots/graphics-fill-shape.png differ diff --git a/scripts/javascript/screenshots/graphics-fill-triangle.png b/scripts/javascript/screenshots/graphics-fill-triangle.png index 9312369c168..31d754a16dd 100644 Binary files a/scripts/javascript/screenshots/graphics-fill-triangle.png and b/scripts/javascript/screenshots/graphics-fill-triangle.png differ diff --git a/scripts/javascript/screenshots/graphics-gaussian-blur.png b/scripts/javascript/screenshots/graphics-gaussian-blur.png index 3bafb5e80cb..686f4e2cf67 100644 Binary files a/scripts/javascript/screenshots/graphics-gaussian-blur.png and b/scripts/javascript/screenshots/graphics-gaussian-blur.png differ diff --git a/scripts/javascript/screenshots/graphics-inscribed-triangle-grid.png b/scripts/javascript/screenshots/graphics-inscribed-triangle-grid.png index a4342a31e58..7dd15260bfe 100644 Binary files a/scripts/javascript/screenshots/graphics-inscribed-triangle-grid.png and b/scripts/javascript/screenshots/graphics-inscribed-triangle-grid.png differ diff --git a/scripts/javascript/screenshots/graphics-large-stroke-dirty-clip.png b/scripts/javascript/screenshots/graphics-large-stroke-dirty-clip.png index a3bb11c8715..85090b016cc 100644 Binary files a/scripts/javascript/screenshots/graphics-large-stroke-dirty-clip.png and b/scripts/javascript/screenshots/graphics-large-stroke-dirty-clip.png differ diff --git a/scripts/javascript/screenshots/graphics-partial-flush-clip-escape.png b/scripts/javascript/screenshots/graphics-partial-flush-clip-escape.png index ef4f91ffb29..6b81a8d9578 100644 Binary files a/scripts/javascript/screenshots/graphics-partial-flush-clip-escape.png and b/scripts/javascript/screenshots/graphics-partial-flush-clip-escape.png differ diff --git a/scripts/javascript/screenshots/graphics-rotate.png b/scripts/javascript/screenshots/graphics-rotate.png index f431e3fe8b4..cf88fde9bbd 100644 Binary files a/scripts/javascript/screenshots/graphics-rotate.png and b/scripts/javascript/screenshots/graphics-rotate.png differ diff --git a/scripts/javascript/screenshots/graphics-scale.png b/scripts/javascript/screenshots/graphics-scale.png index cf5f9c9aab5..b0c71fcbd72 100644 Binary files a/scripts/javascript/screenshots/graphics-scale.png and b/scripts/javascript/screenshots/graphics-scale.png differ diff --git a/scripts/javascript/screenshots/graphics-stroke-test.png b/scripts/javascript/screenshots/graphics-stroke-test.png index 467c0c9092e..7a99f23ee19 100644 Binary files a/scripts/javascript/screenshots/graphics-stroke-test.png and b/scripts/javascript/screenshots/graphics-stroke-test.png differ diff --git a/scripts/javascript/screenshots/graphics-tile-image.png b/scripts/javascript/screenshots/graphics-tile-image.png index e2ccc5b1117..0d25e61f10b 100644 Binary files a/scripts/javascript/screenshots/graphics-tile-image.png and b/scripts/javascript/screenshots/graphics-tile-image.png differ diff --git a/scripts/javascript/screenshots/graphics-transform-camera.png b/scripts/javascript/screenshots/graphics-transform-camera.png index e75f8877c56..4095b08b5e1 100644 Binary files a/scripts/javascript/screenshots/graphics-transform-camera.png and b/scripts/javascript/screenshots/graphics-transform-camera.png differ diff --git a/scripts/javascript/screenshots/graphics-transform-perspective.png b/scripts/javascript/screenshots/graphics-transform-perspective.png index 3f1c78efa3e..9d5888f4e6d 100644 Binary files a/scripts/javascript/screenshots/graphics-transform-perspective.png and b/scripts/javascript/screenshots/graphics-transform-perspective.png differ diff --git a/scripts/javascript/screenshots/graphics-transform-rotation.png b/scripts/javascript/screenshots/graphics-transform-rotation.png index c37ece74b26..c846e237e82 100644 Binary files a/scripts/javascript/screenshots/graphics-transform-rotation.png and b/scripts/javascript/screenshots/graphics-transform-rotation.png differ diff --git a/scripts/javascript/screenshots/graphics-transform-translation.png b/scripts/javascript/screenshots/graphics-transform-translation.png index 163363a0a74..20d0dee1993 100644 Binary files a/scripts/javascript/screenshots/graphics-transform-translation.png and b/scripts/javascript/screenshots/graphics-transform-translation.png differ diff --git a/scripts/javascript/screenshots/kotlin.png b/scripts/javascript/screenshots/kotlin.png index 40f8c71332d..a90dc08657d 100644 Binary files a/scripts/javascript/screenshots/kotlin.png and b/scripts/javascript/screenshots/kotlin.png differ diff --git a/scripts/javascript/screenshots/landscape.png b/scripts/javascript/screenshots/landscape.png index c91e9463108..f3784251cc8 100644 Binary files a/scripts/javascript/screenshots/landscape.png and b/scripts/javascript/screenshots/landscape.png differ diff --git a/scripts/run-javascript-headless-browser.mjs b/scripts/run-javascript-headless-browser.mjs index 3f9604cb0ad..a2b65f5813a 100755 --- a/scripts/run-javascript-headless-browser.mjs +++ b/scripts/run-javascript-headless-browser.mjs @@ -27,6 +27,59 @@ const SUITE_FINISHED_MARKER = 'CN1SS:SUITE:FINISHED'; let suiteFinished = false; +// The app captures its own screenshot by reading back the canvas and ships the bytes over +// the shared cn1ss WebSocket transport. That transport is fine, but a canvas readback is no +// longer the whole picture: the port promotes text into a DOM layer above the canvas, so a +// canvas-only capture is missing every label on screen. +// +// Rather than fork the transport that iOS, Android, watch and TV also use, supply the image +// to the port and let it travel that transport: browser_bridge.js awaits this hook inside the +// screenshot host call and returns what it produces as the screenshot, so the cn1ss server +// remains the only writer of the PNG. Writing the file here instead would be overwritten +// moments later by the canvas-only bytes the same call goes on to send. +// +// Being awaited inside the host call is also what makes the capture safe: the worker is +// blocked on that call, so the suite cannot advance while the screenshot is being taken. +// Driving it from a console marker would race the next test's form onto the screen. +let capturedCount = 0; +const CAPTURE_TIMEOUT_MS = Number(process.env.CN1_JS_CAPTURE_TIMEOUT_MS || '4000'); + +async function installCompositeCapture(page) { + if (process.env.CN1_JS_DISABLE_COMPOSITE_CAPTURE === '1') { + return; + } + await page.exposeFunction('__cn1CompositeCapture', async () => { + try { + // `animations: 'disabled'` is what makes the capture reproducible: it settles animations + // and waits for fonts, and without it the screenshot races the canvas presentation -- + // dropping it turned 59 of 181 goldens into mismatches, the static graphics and chart + // tests among them. + // + // It can also wait too long on a screen that never settles, and the suite is blocked on + // this very promise, so the timeout and the race are the backstop: always resolve + // promptly. Falling back to the canvas readback costs the promoted text in one golden; + // hanging costs the whole test. + const shot = page.screenshot({ animations: 'disabled', timeout: CAPTURE_TIMEOUT_MS }); + const buffer = await Promise.race([ + shot, + new Promise(resolve => setTimeout(() => resolve(null), CAPTURE_TIMEOUT_MS)) + ]); + if (!buffer) { + shot.catch(() => {}); + append('screenshot:timeout'); + return null; + } + capturedCount++; + return `data:image/png;base64,${buffer.toString('base64')}`; + } catch (err) { + // Fall back to the canvas readback rather than losing the test entirely. + append(`screenshot:failed:${String(err)}`); + return null; + } + }); + append('screenshot:composited:hook-installed'); +} + function append(line) { const text = `[playwright] ${line}\n`; if (logFile) { @@ -154,6 +207,8 @@ try { deviceScaleFactor: 2 }); + await installCompositeCapture(page); + page.on('console', msg => { const text = msg.text(); append(`console:${msg.type()}:${text}`); @@ -261,5 +316,6 @@ try { } await finalizeProfile(); } finally { + append(`screenshot:composited:total=${capturedCount}`); await browser.close(); } diff --git a/scripts/verify-javascript-web-overlay.mjs b/scripts/verify-javascript-web-overlay.mjs new file mode 100755 index 00000000000..77116bff895 --- /dev/null +++ b/scripts/verify-javascript-web-overlay.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node +// Verifies the JavaScript port's web-native overlay layers against a running build. +// +// Canvas rendering could only ever be checked by comparing pixels. Once text and +// semantics live in the DOM they can be asserted directly, which is both faster and +// diagnostic: a failure names what is wrong instead of reporting a pixel delta. +// +// Usage: +// node scripts/verify-javascript-web-overlay.mjs [--channel chrome] +// +// Serve an unpacked bundle first, e.g. +// (cd dist/MyApp-js && python3 -m http.server 8099) +// node scripts/verify-javascript-web-overlay.mjs http://localhost:8099/index.html +// +// Exits non-zero when a check fails. + +let chromium; +try { + ({ chromium } = await import('playwright')); +} catch (playwrightError) { + try { + ({ chromium } = await import('@playwright/test')); + } catch (playwrightTestError) { + console.error('Unable to load Playwright. Install either "playwright" or "@playwright/test".'); + console.error('Import from "playwright" failed:', String(playwrightError)); + console.error('Import from "@playwright/test" failed:', String(playwrightTestError)); + process.exit(3); + } +} + +const args = process.argv.slice(2); +const url = args.find(a => !a.startsWith('--')); +if (!url) { + console.error('Usage: node scripts/verify-javascript-web-overlay.mjs [--channel chrome]'); + process.exit(2); +} +const channelIndex = args.indexOf('--channel'); +const channel = channelIndex >= 0 ? args[channelIndex + 1] : process.env.CN1_JS_BROWSER_CHANNEL; +const bootTimeoutMs = Number(process.env.CN1_JS_TIMEOUT_SECONDS || 120) * 1000; + +const results = []; +function check(name, pass, detail) { + results.push({ name, pass }); + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ' :: ' + detail : ''}`); +} + +const browser = await chromium.launch(channel ? { channel } : {}); +// A dark color scheme is emulated so the prefers-color-scheme path is exercised: that +// query is evaluated on the main thread, because the worker has no matchMedia. +const page = await browser.newPage({ + viewport: { width: 375, height: 667 }, + deviceScaleFactor: 2, + colorScheme: 'dark' +}); + +const logs = []; +page.on('console', m => logs.push(m.text())); +page.on('pageerror', e => logs.push('pageerror:' + String(e))); + +await page.goto(url, { waitUntil: 'domcontentloaded' }); + +// Sampled before the app has had a chance to navigate, so growth can be attributed to it. +const initialHistoryLength = await page.evaluate(() => history.length); + +// The app boots through a worker, so poll rather than assuming it is up. +let snap = { runs: 0 }; +const deadline = Date.now() + bootTimeoutMs; +while (Date.now() < deadline) { + snap = await page.evaluate(() => { + const layer = document.getElementById('cn1-text-layer'); + const tree = document.getElementById('cn1-accessibility-tree'); + const canvas = document.getElementById('codenameone-canvas'); + return { + hasLayer: !!layer, + hasTree: !!tree, + layerAriaHidden: layer ? layer.getAttribute('aria-hidden') : null, + layerPointerEvents: layer ? layer.style.pointerEvents : null, + canvasAriaHidden: canvas ? canvas.getAttribute('aria-hidden') : null, + runs: layer ? layer.querySelectorAll('span').length : 0, + semanticNodes: tree ? tree.querySelectorAll('[data-cn1-accessibility-id]').length : 0, + text: layer ? layer.innerText.replace(/\s+/g, ' ').trim() : '' + }; + }); + if (snap.runs > 0 && snap.semanticNodes > 0) break; + await page.waitForTimeout(1000); +} + +check('text layer present', snap.hasLayer); +check('text promoted to real DOM text', snap.runs > 0, `${snap.runs} run(s)`); +check('promoted text is readable', snap.text.length > 0, JSON.stringify(snap.text.slice(0, 100))); +check('text layer hidden from assistive tech', snap.layerAriaHidden === 'true', + `aria-hidden=${snap.layerAriaHidden}`); +check('text layer does not take pointer events', snap.layerPointerEvents === 'none', + `pointer-events=${snap.layerPointerEvents}`); +check('semantic overlay populated', snap.semanticNodes > 0, `${snap.semanticNodes} node(s)`); +check('canvas hidden from assistive tech', snap.canvasAriaHidden === 'true'); + +// The overlay must reuse elements across invalidations. Rebuilding would drop DOM focus +// and any in-progress text selection on every CHANGE_BOUNDS, which every setX/setY raises. +let identity = null; +for (let attempt = 0; attempt < 25 && !identity; attempt++) { + identity = await page.evaluate(async () => { + const tree = document.getElementById('cn1-accessibility-tree'); + if (!tree) return null; + const before = Array.from(tree.querySelectorAll('[data-cn1-accessibility-id]')); + if (before.length === 0) return null; + before.forEach((el, i) => { el.__cn1probe = 'probe-' + i; }); + const ids = before.map(el => el.getAttribute('data-cn1-accessibility-id')); + await new Promise(r => setTimeout(r, 250)); + const present = ids + .map(id => tree.querySelector(`[data-cn1-accessibility-id="${id}"]`)) + .filter(Boolean); + if (present.length === 0) return null; + return { + present: present.length, + reused: present.filter(el => typeof el.__cn1probe === 'string').length + }; + }); + if (!identity) await page.waitForTimeout(200); +} +if (identity) { + check('semantic nodes reused across invalidations', identity.reused === identity.present, + `${identity.reused}/${identity.present} kept identity`); +} else { + check('semantic nodes reused across invalidations', false, 'no stable sample available'); +} + +// history.pushState is a main-thread API. When it was compiled into the worker it threw on +// every form change and the port logged that the back command would not work. +check('no "pushState not supported" warning', + !logs.some(l => l.includes('history.pushState not supported'))); +// The root form deliberately pushes nothing -- it has nothing to go back to -- so a single +// form app sits at length 1 legitimately. Only assert growth when the app actually navigated; +// otherwise report it rather than failing a valid overlay. +const historyLength = await page.evaluate(() => history.length); +if (historyLength > initialHistoryLength) { + check('history entries pushed on navigation', true, + `${initialHistoryLength} -> ${historyLength}`); +} else { + console.log(`SKIP history entries pushed on navigation :: no navigation observed ` + + `(history.length=${historyLength})`); +} + +// An editable field is reachable through the overlay only if something can carry the text to +// set. A button cannot -- it dispatches with no argument -- so SET_TEXT gets an input, and this +// asserts the field and its control are actually connected. +const setText = await page.evaluate(() => { + const tree = document.getElementById('cn1-accessibility-tree'); + const actions = document.getElementById('cn1-accessibility-actions'); + if (!tree) return null; + const boxes = Array.from(tree.querySelectorAll('[role="textbox"],[role="searchbox"]')); + if (boxes.length === 0) return { fields: 0 }; + const ids = boxes.map(b => b.getAttribute('id')).filter(Boolean); + // A multiline field's control is a textarea, not an input -- looking only for inputs would + // report a screen of TextAreas as having no way to set text. + const inputs = actions ? Array.from(actions.querySelectorAll('input,textarea')) : []; + const wired = inputs.filter(i => ids.includes(i.getAttribute('aria-controls'))); + // A field is one tab stop, not two. The control carries the input handlers, so it is the + // half that must be tabbable; the semantic element it is wired to gives its own stop up + // rather than stopping a keyboard user on a textbox that refuses every keystroke. + const wiredIds = wired.map(i => i.getAttribute('aria-controls')); + const doubled = boxes.filter(b => wiredIds.includes(b.getAttribute('id')) + && b.getAttribute('tabindex') !== null && b.getAttribute('tabindex') !== '-1'); + // Editing through the control is still editing that field, so it has to carry the same + // input metadata the ordinary editor applies -- the keyboard the constraint asks for, and + // the prediction and autofill it withholds. + const configured = wired.filter(i => i.getAttribute('autocomplete') !== null + && i.getAttribute('autocapitalize') !== null && i.getAttribute('spellcheck') !== null); + return { + fields: boxes.length, inputs: inputs.length, wired: wired.length, + doubled: doubled.length, configured: configured.length + }; +}); +if (!setText || setText.fields === 0) { + console.log('SKIP editable fields expose a control that can set text :: no field on screen'); +} else { + check('editable fields expose a control that can set text', setText.wired > 0, + `${setText.wired} control(s) for ${setText.fields} field(s)`); +} +if (!setText || !setText.wired) { + console.log('SKIP an editable field is a single tab stop :: no wired control on screen'); +} else { + check('an editable field is a single tab stop', setText.doubled === 0, + `${setText.doubled} field(s) tabbable alongside their control`); + check('a set-text control carries the field input constraints', + setText.configured === setText.wired, + `${setText.configured}/${setText.wired} control(s) configured`); +} + +// A reload keeps the entry it happened on, and with it any id this port stamped there before +// the reload -- while the port's own counters start again from zero. An id above everything the +// new session pushed is that entry rather than a step forward, and the port has to read it that +// way WITHOUT rewriting the entry: the state on it belongs to whatever page hosts the canvas, +// and a host router that finds its own state replaced loses its navigation. +if (historyLength > initialHistoryLength) { + const page2 = await browser.newPage({ viewport: { width: 375, height: 667 }, deviceScaleFactor: 2 }); + await page2.addInitScript(() => { + window.__cn1Replaced = 0; + const original = history.replaceState.bind(history); + history.replaceState = function (...args) { + window.__cn1Replaced++; + return original(...args); + }; + }); + await page2.goto(url, { waitUntil: 'domcontentloaded' }); + let booted2 = false; + const bootDeadline = Date.now() + bootTimeoutMs; + while (Date.now() < bootDeadline) { + booted2 = await page2.evaluate(() => { + const layer = document.getElementById('cn1-text-layer'); + return !!layer && layer.childElementCount > 0; + }); + if (booted2) break; + await page2.waitForTimeout(200); + } + const rewrites = await page2.evaluate(() => window.__cn1Replaced); + check('the port never rewrites a history entry', booted2 && rewrites === 0, + `replaceState calls: ${rewrites}`); + + // Back is deliberately not asserted here: backing out of the root form is meant to leave the + // document, so "still on the page" is not a property this can hold the port to. + await page2.close(); +} else { + console.log('SKIP history entry checks :: no navigation observed'); +} + +console.log('\n--- summary ---'); +const failed = results.filter(r => !r.pass); +console.log(`${results.length - failed.length}/${results.length} checks passed`); +if (failed.length) { + console.log('failed: ' + failed.map(f => f.name).join(', ')); + console.log('\n--- recent console ---'); + console.log(logs.slice(-10).join('\n')); +} + +await browser.close(); +process.exit(failed.length ? 1 : 0); diff --git a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js index 6b7491ff82c..108fd4fc369 100644 --- a/vm/ByteCodeTranslator/src/javascript/browser_bridge.js +++ b/vm/ByteCodeTranslator/src/javascript/browser_bridge.js @@ -5027,15 +5027,82 @@ return runAttempt(index + 1); }); } + // The port promotes text into a DOM layer above the canvas, so a canvas readback is no + // longer the whole frame. When a test harness installs a composited capture hook, let it + // supply the image instead and return THAT as the screenshot: the bytes then travel the + // normal path to the cn1ss server, so there is exactly one writer of the PNG and no + // ordering to get wrong. Writing the composite to disk from the harness instead would be + // overwritten moments later by the canvas-only bytes this call is about to return. + // + // The hook is awaited inside this host call, and the worker is blocked on the call, so the + // suite cannot advance to the next test while the screenshot is being taken. Driving it + // from a console marker instead would race the next test's form onto the screen. + // A frame reaches the canvas as a batch of commands replayed on the main thread, and the + // page screenshot below reads whatever is on it at that instant -- including a frame that + // is only half replayed. That is how a tab lens came out as the plain rectangle of its + // backdrop, captured after the rectangle was drawn and before the rounded shape that masks + // it. Waiting for the canvas to go quiet puts the capture on a frame boundary. It is + // bounded: a screen that never stops drawing is still captured, as it was before. + function awaitQuietCanvas() { + return new Promise(function(resolve) { + if (typeof global.requestAnimationFrame !== 'function') { + resolve(); + return; + } + var seen = -1; + var quiet = 0; + var frames = 0; + function tick() { + var seq = canvasOpSeq | 0; + if (seq === seen) { + quiet++; + } else { + quiet = 0; + seen = seq; + } + frames++; + // A good many quiet frames, not one or two: a screen is often painted over a series + // of frames with pauses between them -- a grid of gradients or images drawn cell by + // cell, an EDT tick apart -- and a capture taken in one of those pauses records a + // half-finished screen. The pauses seen in practice run to a tenth of a second, so + // the wait has to outlast them. Still bounded, so a screen that never stops drawing + // is captured as it was before. + if (quiet >= 12 || frames >= 120) { + resolve(); + return; + } + global.requestAnimationFrame(tick); + } + global.requestAnimationFrame(tick); + }); + } + + function withCompositedCapture(makeResult) { + var hook = global.__cn1CompositeCapture; + if (typeof hook !== 'function') { + return makeResult(null); + } + return awaitQuietCanvas().then(hook).then(function(dataUrl) { + var composited = (typeof dataUrl === 'string' + && dataUrl.indexOf('data:image/') === 0) ? dataUrl : null; + return makeResult(composited); + }, function() { + return makeResult(null); + }); + } return runAttempt(0).then(function(result) { if (!result || !result.dataUrl) { global.__cn1LastCaptureMeta = null; - return includeMeta ? { - dataUrl: '', - canvasScore: -1, - canvasLastPaintSeq: baselinePaintSeq | 0, - canvasPaintedSinceStart: 0 - } : ''; + return withCompositedCapture(function(composited) { + // A composited capture stands on its own: the canvas readback found nothing usable, + // but the page still has a frame worth recording. + return includeMeta ? { + dataUrl: composited || '', + canvasScore: composited ? 0 : -1, + canvasLastPaintSeq: baselinePaintSeq | 0, + canvasPaintedSinceStart: 0 + } : (composited || ''); + }); } global.__cn1LastScreenshotSignature = result.canvasSignature || ''; diag('SCREENSHOT_START', 'canvasCount', result.canvasCount); @@ -5071,10 +5138,16 @@ canvasPaintedSinceStart: result.paintedSinceStart ? 1 : 0, canvasSignature: result.canvasSignature || 'none' }; - if (includeMeta) { - return global.__cn1LastCaptureMeta; - } - return String(result.dataUrl || ''); + return withCompositedCapture(function(composited) { + if (composited) { + global.__cn1LastCaptureMeta.dataUrl = composited; + diag('SCREENSHOT_START', 'compositedLen', composited.length); + } + if (includeMeta) { + return global.__cn1LastCaptureMeta; + } + return String(global.__cn1LastCaptureMeta.dataUrl || ''); + }); }); });