diff --git a/CodenameOne/src/com/codename1/charts/ChartComponent.java b/CodenameOne/src/com/codename1/charts/ChartComponent.java index d5d9a3245a3..424ff1a16b5 100644 --- a/CodenameOne/src/com/codename1/charts/ChartComponent.java +++ b/CodenameOne/src/com/codename1/charts/ChartComponent.java @@ -29,7 +29,6 @@ import com.codename1.charts.views.XYChart; import com.codename1.ui.Component; import com.codename1.ui.Display; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Transform; import com.codename1.ui.animations.Animation; @@ -992,6 +991,13 @@ private interface IZoomTransition { } private class ZoomTransition implements Animation, IZoomTransition { + /// The top level this transition registered on, so cleanup removes it from + /// that one rather than from wherever the chart resolves to when the motion + /// ends. A chart removed or reparented in between leaves the animation on the + /// original for good: its hasAnimations() stays true, the event dispatch thread + /// never sleeps, and the finished branch runs every frame. + private com.codename1.ui.TopLevelContainer animationHost; + private final Rectangle currentViewPort; private final Rectangle newViewPort; private final Transform origTransform; @@ -1012,8 +1018,9 @@ private class ZoomTransition implements Animation, IZoomTransition { @Override public void start() { - Form f = ChartComponent.this.getComponentForm(); + com.codename1.ui.TopLevelContainer f = ChartComponent.this.getTopLevelContainer(); if (f != null) { + animationHost = f; f.registerAnimated(this); this.motion.start(); } else { @@ -1022,9 +1029,9 @@ public void start() { } public void cleanup() { - Form f = ChartComponent.this.getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); + if (animationHost != null) { + animationHost.deregisterAnimated(this); + animationHost = null; } } @@ -1087,6 +1094,13 @@ public void paint(Graphics g) { } private class ZoomTransitionXY implements Animation, IZoomTransition { + /// The top level this transition registered on, so cleanup removes it from + /// that one rather than from wherever the chart resolves to when the motion + /// ends. A chart removed or reparented in between leaves the animation on the + /// original for good: its hasAnimations() stays true, the event dispatch thread + /// never sleeps, and the finished branch runs every frame. + private com.codename1.ui.TopLevelContainer animationHost; + private final BBox currentViewPort; private final BBox newViewPort; private final Motion motion; @@ -1101,8 +1115,9 @@ private class ZoomTransitionXY implements Animation, IZoomTransition { @Override public void start() { - Form f = ChartComponent.this.getComponentForm(); + com.codename1.ui.TopLevelContainer f = ChartComponent.this.getTopLevelContainer(); if (f != null) { + animationHost = f; f.registerAnimated(this); this.motion.start(); } else { @@ -1111,9 +1126,9 @@ public void start() { } public void cleanup() { - Form f = ChartComponent.this.getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); + if (animationHost != null) { + animationHost.deregisterAnimated(this); + animationHost = null; } } diff --git a/CodenameOne/src/com/codename1/charts/transitions/SeriesTransition.java b/CodenameOne/src/com/codename1/charts/transitions/SeriesTransition.java index e997396ad75..9fb01d69877 100644 --- a/CodenameOne/src/com/codename1/charts/transitions/SeriesTransition.java +++ b/CodenameOne/src/com/codename1/charts/transitions/SeriesTransition.java @@ -39,6 +39,10 @@ /// @author shannah public abstract class SeriesTransition implements Animation { + /// The top level this transition registered on, so it is removed from that one + /// rather than from wherever the chart resolves to when the motion ends. + private com.codename1.ui.TopLevelContainer animationHost; + public static final int EASING_LINEAR = 1; public static final int EASING_IN = 2; @@ -119,7 +123,10 @@ protected void cleanup() { public boolean animate() { if (finished) { cleanup(); - chart.getComponentForm().deregisterAnimated(this); + if (animationHost != null) { + animationHost.deregisterAnimated(this); + animationHost = null; + } return false; } else if (motion.isFinished()) { finished = true; @@ -194,7 +201,13 @@ public void setEasing(int easing) { /// current animation settings. public void animateChart() { initTransition(); - chart.getComponentForm().registerAnimated(this); + // The chart's top level rather than its form: getComponentForm() is null by + // design inside a Window, so a chart transition threw there. + com.codename1.ui.TopLevelContainer top = chart.getTopLevelContainer(); + if (top != null) { + animationHost = top; + top.registerAnimated(this); + } } diff --git a/CodenameOne/src/com/codename1/components/Ads.java b/CodenameOne/src/com/codename1/components/Ads.java index f81813cdd9d..d8505b13173 100644 --- a/CodenameOne/src/com/codename1/components/Ads.java +++ b/CodenameOne/src/com/codename1/components/Ads.java @@ -28,7 +28,6 @@ import com.codename1.ui.Component; import com.codename1.ui.Container; import com.codename1.ui.Display; -import com.codename1.ui.Form; import com.codename1.ui.Label; import com.codename1.ui.TextArea; import com.codename1.ui.TextField; @@ -42,6 +41,7 @@ import com.codename1.ui.html.HTMLElement; import com.codename1.ui.html.IOCallback; import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.TopLevelContainer; import java.util.Vector; @@ -127,7 +127,13 @@ public void actionPerformed(ActionEvent evt) { }); if (refreshAd) { - getComponentForm().registerAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.registerAnimated(this); + } } else { requestAd(); } @@ -138,7 +144,13 @@ public void actionPerformed(ActionEvent evt) { @Override protected void deinitialize() { if (refreshAd) { - getComponentForm().deregisterAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.deregisterAnimated(this); + } } } @@ -150,8 +162,11 @@ private void requestAd() { /// {@inheritDoc} @Override public boolean animate() { - Form parent = getComponentForm(); - if (parent == null || !parent.isVisible()) { + // The top level's visibility rather than an enclosing Form's: getComponentForm() + // is null by design inside a Window, so this returned immediately there and the + // refresh the registration had just been fixed to enable never actually ran. + TopLevelContainer parent = getTopLevelContainer(); + if (parent == null || !parent.asContainer().isVisible()) { return false; } long t = System.currentTimeMillis(); diff --git a/CodenameOne/src/com/codename1/components/AudioRecorderComponent.java b/CodenameOne/src/com/codename1/components/AudioRecorderComponent.java index 760a90bdfcd..2ac1443044e 100644 --- a/CodenameOne/src/com/codename1/components/AudioRecorderComponent.java +++ b/CodenameOne/src/com/codename1/components/AudioRecorderComponent.java @@ -42,6 +42,7 @@ import com.codename1.ui.layouts.FlowLayout; import com.codename1.ui.layouts.LayeredLayout; import com.codename1.ui.util.EventDispatcher; +import com.codename1.ui.TopLevelContainer; import java.io.IOException; @@ -532,12 +533,24 @@ public void actionPerformed(ActionEvent evt) { @Override protected void initComponent() { super.initComponent(); - getComponentForm().registerAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.registerAnimated(this); + } } @Override protected void deinitialize() { - getComponentForm().deregisterAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.deregisterAnimated(this); + } super.deinitialize(); } diff --git a/CodenameOne/src/com/codename1/components/FloatingActionButton.java b/CodenameOne/src/com/codename1/components/FloatingActionButton.java index 820a2c0ff9f..abcfda43050 100644 --- a/CodenameOne/src/com/codename1/components/FloatingActionButton.java +++ b/CodenameOne/src/com/codename1/components/FloatingActionButton.java @@ -352,8 +352,11 @@ public Container bindFabToContainer(Component cnt, int orientation, int valign) FlowLayout flow = new FlowLayout(orientation); flow.setValign(valign); - Form f = cnt.getComponentForm(); - if (f != null && (f.getContentPane() == cnt || f == cnt)) { //NOPMD CompareObjectsWithEquals + // The top level rather than the form: getComponentForm() is null by design in a + // Window, so binding to a window's content pane fell through to the wrapper + // below and returned it unattached -- the button simply never appeared. + com.codename1.ui.TopLevelContainer f = cnt.getTopLevelContainer(); + if (f != null && (f.getContentPane() == cnt || f.asContainer() == cnt)) { //NOPMD CompareObjectsWithEquals // special case for content pane installs the button directly on the content pane Container layers = f.getLayeredPane(getClass(), true); layers.setSafeArea(true); @@ -386,9 +389,18 @@ public void setText(String text) { @Override protected void fireActionEvent(int x, int y) { - Form current = Display.getInstance().getCurrent(); - if (current instanceof Dialog) { - ((Dialog) current).dispose(); + // This button's own top level, not the process-wide current form. A button in a + // secondary window would otherwise dispose a dialog showing on the main window + // -- activating a window does not change Display.getCurrent(), so the dialog it + // closed had nothing to do with the click. + com.codename1.ui.TopLevelContainer top = getTopLevelContainer(); + if (top instanceof Dialog) { + ((Dialog) top).dispose(); + } else if (top == null || top instanceof Form) { + Form current = Display.getInstance().getCurrent(); + if (current instanceof Dialog) { + ((Dialog) current).dispose(); + } } super.fireActionEvent(x, y); } @@ -404,6 +416,15 @@ public void released(int x, int y) { } //if this fab has sub fab's display them if (subMenu != null) { + // The submenu is shown in a Dialog, which is not top-level-aware: inside a + // Window getComponentForm() is null by design, and the tint calls below + // dereferenced it without checking, so releasing a sub-menu FAB in a window + // threw out of the button press. There is nothing to show there until + // Dialog itself becomes window-aware, but a standard component must not + // throw to say so -- see the unsupported list in the desktop windows guide. + if (getComponentForm() == null) { + return; + } final Container con = createPopupContent(subMenu); Dialog d = new Dialog(); d.setDialogUIID("Container"); diff --git a/CodenameOne/src/com/codename1/components/FloatingHint.java b/CodenameOne/src/com/codename1/components/FloatingHint.java index 8b68b06d8cf..d9b4a8a9369 100644 --- a/CodenameOne/src/com/codename1/components/FloatingHint.java +++ b/CodenameOne/src/com/codename1/components/FloatingHint.java @@ -33,6 +33,8 @@ import com.codename1.ui.events.FocusListener; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.TopLevelContainer; +import com.codename1.ui.Window; /// A floating hint is similar to a text field with a hint. However, when the text field has text in it the hint appears /// above the text field instead including an animation when focus hits the text field see @@ -107,17 +109,27 @@ public void focusLost(Component cmp) { tf.addFocusListener(fl); } + /// Revalidates the surface a component lives in, whether that is a form or a + /// window. + private static void revalidateTopLevel(Component c) { + TopLevelContainer top = c.getTopLevelContainer(); + if (top != null) { + top.asContainer().revalidate(); + } + } + private void focusGainedImpl() { if (isInitializedImpl()) { hintButton.setFocus(true); if (!hintButton.isVisible()) { hintButton.setVisible(true); - if (getComponentForm().grabAnimationLock()) { + TopLevelContainer top = getTopLevelContainer(); + if (top != null && top.grabAnimationLock()) { morphAndWait(hintLabel, hintButton, 150); - getComponentForm().releaseAnimationLock(); + top.releaseAnimationLock(); } hintLabel.setVisible(false); - tf.getComponentForm().revalidate(); + revalidateTopLevel(tf); tf.setEditable(true); tf.startEditingAsync(); } else { @@ -132,8 +144,23 @@ private void focusGainedImpl() { } } + /// True when this hint is attached to the surface currently on screen. + /// + /// Comparing `Display#getCurrent()` -- which only ever names a `Form` -- against + /// `getComponentForm()`, null inside a `Window`, was false for every floating hint + /// in a window, so the animated hint silently degraded to the plain branch there. private boolean isInitializedImpl() { - return isInitialized() && getComponentForm() == Display.getInstance().getCurrent(); //NOPMD CompareObjectsWithEquals + if (!isInitialized()) { + return false; + } + TopLevelContainer top = getTopLevelContainer(); + if (top == null) { + return false; + } + if (top instanceof Window) { + return ((Window) top).isWindowShowing(); + } + return Display.getInstance().getCurrent() == top; //NOPMD CompareObjectsWithEquals } private void focusLostImpl() { @@ -141,12 +168,13 @@ private void focusLostImpl() { hintButton.setFocus(false); if (tf.getText().length() == 0) { hintLabel.setVisible(true); - if (getComponentForm().grabAnimationLock()) { + TopLevelContainer top = getTopLevelContainer(); + if (top != null && top.grabAnimationLock()) { morphAndWait(hintButton, hintLabel, 150); - getComponentForm().releaseAnimationLock(); + top.releaseAnimationLock(); } hintButton.setVisible(false); - tf.getComponentForm().revalidate(); + revalidateTopLevel(tf); revalidate(); tf.setEditable(false); } diff --git a/CodenameOne/src/com/codename1/components/ImageViewer.java b/CodenameOne/src/com/codename1/components/ImageViewer.java index 699f412521a..514015b60a2 100644 --- a/CodenameOne/src/com/codename1/components/ImageViewer.java +++ b/CodenameOne/src/com/codename1/components/ImageViewer.java @@ -27,7 +27,6 @@ import com.codename1.ui.Display; import com.codename1.ui.Font; import com.codename1.ui.FontImage; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.ImageFactory; @@ -40,6 +39,7 @@ import com.codename1.ui.list.ListModel; import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.TopLevelContainer; /// ImageViewer allows zooming/panning an image and potentially flicking between multiple images /// within a list of images. @@ -367,7 +367,7 @@ public void initComponent() { image.lock(); } if (image.isAnimation()) { - getComponentForm().registerAnimated(this); + registerForAnimation(); } eagerLock(); } @@ -448,7 +448,12 @@ public void pointerPressed(int x, int y) { pointerPressedThumbnailIndex = getThumbnailIndexAt(x, y); currentZoom = zoom; delegatingDragToParent = false; - getComponentForm().addComponentAwaitingRelease(this); + // Resolved through the top level so this works inside a Window, where + // getComponentForm() is null and this line threw. + TopLevelContainer viewerTop = getTopLevelContainer(); + if (viewerTop != null) { + viewerTop.addComponentAwaitingRelease(this); + } } private Container findScrollableYAncestor() { @@ -807,6 +812,16 @@ protected Dimension calcPreferredSize() { if (image != null) { return new Dimension(image.getWidth(), image.getHeight()); } + // The surface this viewer lives on, not the main display: inside a Window, + // getDisplayWidth() is the main window's and an empty viewer asked for the + // whole screen. + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + Container c = top.asContainer(); + if (c.getWidth() > 0 && c.getHeight() > 0) { + return new Dimension(c.getWidth(), c.getHeight()); + } + } return new Dimension(Display.getInstance().getDisplayWidth(), Display.getInstance().getDisplayHeight()); } @@ -830,7 +845,7 @@ public boolean animate() { if (motion.isFinished()) { zooming = false; if (!result) { - getComponentForm().deregisterAnimated(this); + deregisterFromAnimation(); } } repaint(); @@ -1104,9 +1119,12 @@ public final void setImage(Image image) { updatePositions(); repaint(); if (image.isAnimation()) { - Form f = getComponentForm(); - if (f != null) { - f.registerAnimated(this); + // The top level, matching every other registration here: inside a + // Window getComponentForm() is null, so swapping in an animated image + // silently stopped animating it. + TopLevelContainer swapTop = getTopLevelContainer(); + if (swapTop != null) { + swapTop.registerAnimated(this); } } } @@ -1269,7 +1287,7 @@ public void setZoom(float zoom) { float initZoom = this.zoom; motion = Motion.createEaseInOutMotion((int) (initZoom * 10000), (int) (zoom * 10000), 200); motion.start(); - getComponentForm().registerAnimated(this); + registerForAnimation(); } else { this.zoom = zoom; updatePositions(); @@ -1306,7 +1324,7 @@ public void setZoom(float zoom, float panPositionX, float panPositionY) { float initZoom = this.zoom; motion = Motion.createEaseInOutMotion((int) (initZoom * 10000), (int) (zoom * 10000), 200); motion.start(); - getComponentForm().registerAnimated(this); + registerForAnimation(); } else { this.zoom = zoom; updatePositions(); @@ -1495,7 +1513,11 @@ private void paint(Graphics g, int imageWidth, int imageHeight) { } + class AnimatePanX implements Animation { + /// The top level this animation was registered on, so it is removed from that + /// one rather than from wherever the viewer has since moved. + private TopLevelContainer host; private final Motion motion; private final Image replaceImage; private final int updatePos; @@ -1505,7 +1527,15 @@ public AnimatePanX(float destPan, Image replaceImage, int updatePos) { motion.start(); this.replaceImage = replaceImage; this.updatePos = updatePos; - Display.getInstance().getCurrent().registerAnimated(this); + // This viewer's own top level, not whatever form happens to be current. + // Display.getCurrent() only ever names a Form: in a window-only application + // it is null and this threw, and with a main form present it registered the + // animation against the wrong surface and later deregistered from it. + TopLevelContainer panTop = getTopLevelContainer(); + if (panTop != null) { + host = panTop; + panTop.registerAnimated(this); + } } @Override @@ -1547,7 +1577,10 @@ public boolean animate() { getImageRight().unlock(); } } - Display.getInstance().getCurrent().deregisterAnimated(this); + if (host != null) { + host.deregisterAnimated(this); + host = null; + } } repaint(); return false; diff --git a/CodenameOne/src/com/codename1/components/InfiniteProgress.java b/CodenameOne/src/com/codename1/components/InfiniteProgress.java index 9c53379e4cb..aa8efd8c3d0 100644 --- a/CodenameOne/src/com/codename1/components/InfiniteProgress.java +++ b/CodenameOne/src/com/codename1/components/InfiniteProgress.java @@ -38,6 +38,7 @@ import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.WeakHashMap; +import com.codename1.ui.TopLevelContainer; /// Shows a "Washing Machine" infinite progress indication animation, to customize the image you can either /// use the infiniteImage theme constant or the `setAnimation` method. The image is rotated @@ -194,6 +195,26 @@ public Dialog showInfiniteBlocking() { return d; } + /// True when this spinner's own top level is the one on screen. + /// + /// A `Window` is on screen in its own right, so comparing it against + /// `Display#getCurrent()` -- which only ever names a `Form` -- reported false for + /// every spinner in a window and stopped it animating and painting. + /// + /// #### Returns + /// + /// true when the surface holding this component is displayed + private boolean isOnDisplayedTopLevel() { + TopLevelContainer top = getTopLevelContainer(); + if (top == null) { + return false; + } + if (top instanceof com.codename1.ui.Window) { + return ((com.codename1.ui.Window) top).isWindowShowing(); + } + return Display.getInstance().getCurrent() == top; //NOPMD CompareObjectsWithEquals + } + /// {@inheritDoc} @Override protected void initComponent() { @@ -201,20 +222,25 @@ protected void initComponent() { if (animation == null) { animation = UIManager.getInstance().getThemeImageConstant("infiniteImage"); } - Form f = getComponentForm(); - if (f != null) { - f.registerAnimated(this); - } + registerForAnimation(); } /// {@inheritDoc} @Override protected void deinitialize() { - Form f = getComponentForm(); - if (f == null) { - f = Display.getInstance().getCurrent(); + // The fallback to the current form existed because deinitialize can run after + // the component has left its hierarchy. It threw outright in a window-only + // application, where there is no current form either -- and that threw during + // Window.dispose(), before the native peer and paint surface were released. + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + top.deregisterAnimated(this); + } else { + Form current = Display.getInstance().getCurrent(); + if (current != null) { + current.deregisterAnimated(this); + } } - f.deregisterAnimated(this); super.deinitialize(); } @@ -242,7 +268,7 @@ public boolean animate() { /// True if it animated and should be repainted. /// public boolean animate(boolean force) { - if (!force && (!isVisible() || Display.getInstance().getCurrent() != this.getComponentForm())) { // PMD Fix: CollapsibleIfStatements merged visibility checks //NOPMD CompareObjectsWithEquals + if (!force && (!isVisible() || !isOnDisplayedTopLevel())) { // PMD Fix: CollapsibleIfStatements merged visibility checks return false; } // reduce repaint thrushing of the UI from the infinite progress @@ -303,7 +329,7 @@ protected Dimension calcPreferredSize() { /// {@inheritDoc} @Override public void paint(Graphics g) { - if (this.getComponentForm() != null && Display.getInstance().getCurrent() != this.getComponentForm()) { //NOPMD CompareObjectsWithEquals + if (getTopLevelContainer() != null && !isOnDisplayedTopLevel()) { return; } super.paint(g); diff --git a/CodenameOne/src/com/codename1/components/InteractionDialog.java b/CodenameOne/src/com/codename1/components/InteractionDialog.java index 65b6e90287f..b687b09793e 100644 --- a/CodenameOne/src/com/codename1/components/InteractionDialog.java +++ b/CodenameOne/src/com/codename1/components/InteractionDialog.java @@ -31,7 +31,8 @@ import com.codename1.ui.Container; import com.codename1.ui.Display; import com.codename1.ui.Dialog; -import com.codename1.ui.Form; +import com.codename1.ui.TopLevelContainer; +import com.codename1.ui.Window; import com.codename1.ui.Image; import com.codename1.ui.Label; import com.codename1.ui.events.ActionEvent; @@ -328,7 +329,85 @@ private int resolveAnimationSpeed() { return getUIManager().getThemeConstant("interactionDialogSpeedInt", 400); } - private void cleanupLayer(Form f) { + /// The top level this dialog appears on when it is shown. + /// + /// A dialog is not attached to anything at the moment `#show(int, int, int, int)` + /// runs, so it cannot resolve its own host the way an attached component can. Left + /// unset it uses the current `Form`, which is the historical behaviour and the + /// right answer for an application with one window. Set it to put the dialog on a + /// `com.codename1.ui.Window` instead: without it the dialog is added to the main + /// form's layered pane, so it appears on the main window while the window that + /// asked for it is merely dimmed -- and in an application with no form at all + /// there is nothing to resolve and showing it fails. + /// + /// #### Parameters + /// + /// - `host`: the top level to show on, or null for the current form + public void setTopLevelHost(TopLevelContainer host) { + this.hostTopLevel = host; + // An explicit choice replaces an inferred one outright, and there is no longer + // an earlier host worth restoring. + this.hostTopLevelInferred = false; + this.hostTopLevelBeforeInference = null; + } + + /// Returns the top level set with `#setTopLevelHost(TopLevelContainer)`. + /// + /// #### Returns + /// + /// the explicit host, or null when none was set + public TopLevelContainer getTopLevelHost() { + return hostTopLevel; + } + + /// The top level to operate on: the explicit host, else the one this dialog is + /// already attached to, else the current form. + /// + /// #### Returns + /// + /// the host top level, or null when there is none + private TopLevelContainer resolveHost() { + if (hostTopLevel != null) { + return hostTopLevel; + } + TopLevelContainer attached = getTopLevelContainer(); + if (attached != null) { + return attached; + } + return Display.getInstance().getCurrent(); + } + + private TopLevelContainer hostTopLevel; + + /// A timeout set before the dialog was shown, waiting for a host to bind to. + private long pendingTimeout; + + /// True while `#hostTopLevel` holds a host worked out from a popup's anchor rather + /// than one the application asked for. Such a host belongs to that one showing: it + /// is the anchor's top level, and the next showing may well be somewhere else. + private boolean hostTopLevelInferred; + + /// The host that was in force before a popup inferred one, put back when the popup + /// goes away. + private TopLevelContainer hostTopLevelBeforeInference; + + /// Drops a host inferred from a popup's anchor and restores whatever was set before + /// it. + /// + /// Without this the inferred host outlived the popup in the same field the explicit + /// API writes to, so showing the same dialog again through `#show(int, int, int, + /// int)` put it back on the window the popup happened to be anchored in. If that + /// window had since been disposed the dialog went into a hierarchy attached to + /// nothing and simply never appeared. + private void releaseInferredHost() { + if (hostTopLevelInferred) { + hostTopLevel = hostTopLevelBeforeInference; + hostTopLevelBeforeInference = null; + hostTopLevelInferred = false; + } + } + + private void cleanupLayer(TopLevelContainer f) { if (stackable) { // Stackable mode: several InteractionDialogs can share the class // layer at once (layered by show() order). Tearing the whole layer @@ -352,7 +431,7 @@ private void cleanupLayer(Form f) { } } - private Container getLayeredPane(Form f) { + private Container getLayeredPane(TopLevelContainer f) { //return f.getLayeredPane(); Container c; if (formMode) { @@ -372,13 +451,13 @@ private Container getLayeredPane(Form f) { protected void deinitialize() { super.deinitialize(); if (disposed) { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { if (pressedListener != null) { - f.removePointerPressedListener(pressedListener); + f.asContainer().removePointerPressedListener(pressedListener); } if (releasedListener != null) { - f.removePointerReleasedListener(releasedListener); + f.asContainer().removePointerReleasedListener(releasedListener); } Container pp = getLayeredPane(f); Container p = getParent(); @@ -395,7 +474,10 @@ protected void deinitialize() { public void resize(final int top, final int bottom, final int left, final int right) { if (!disposed) { - final Form f = Display.getInstance().getCurrent(); + final TopLevelContainer f = resolveHost(); + if (f == null) { + return; + } Style unselectedStyle = getUnselectedStyle(); @@ -437,8 +519,12 @@ public void resize(final int top, final int bottom, final int left, final int ri public void show(int top, int bottom, int left, int right) { getUnselectedStyle().setOpacity(255); disposed = false; - Form f = Display.getInstance().getCurrent(); + TopLevelContainer f = resolveHost(); + if (f == null) { + return; + } shownInFormMode = formMode; + startPendingTimeout(); Style unselectedStyle = getUnselectedStyle(); unselectedStyle.setMargin(TOP, top); @@ -460,8 +546,8 @@ public void show(int top, int bottom, int left, int right) { if (showAnimationSetup != null) { showAnimationSetup.run(); } else if (repositionAnimation) { - int x = left + (f.getWidth() - right - left) / 2; - int y = top + (f.getHeight() - bottom - top) / 2; + int x = left + (f.asContainer().getWidth() - right - left) / 2; + int y = top + (f.asContainer().getHeight() - bottom - top) / 2; getParent().setX(x); getParent().setY(y); getParent().setWidth(1); @@ -477,7 +563,7 @@ public void show(int top, int bottom, int left, int right) { getLayeredPane(f).animateLayout(resolveAnimationSpeed()); } else { //getLayeredPane(f).revalidate(); - f.revalidateWithAnimationSafety(); + f.asContainer().revalidateWithAnimationSafety(); } /* Form f = Display.getInstance().getCurrent(); @@ -506,9 +592,10 @@ public void show(int top, int bottom, int left, int right) { @Override public void dispose() { disposed = true; + releaseInferredHost(); Container p = getParent(); if (p != null) { - Form f = p.getComponentForm(); + TopLevelContainer f = p.getTopLevelContainer(); if (f != null) { if (animateShow) { if (disposeAnimationSetup != null) { @@ -541,7 +628,7 @@ public void dispose() { // pixels on screen until something else (scroll, hover) // forces a redraw (#5067). Force a form-level revalidate // so the next paint cycle clears those pixels. - f.revalidateWithAnimationSafety(); + f.asContainer().revalidateWithAnimationSafety(); } else { p.remove(); } @@ -610,9 +697,10 @@ private void disposeTo(int direction) { private void disposeTo(int direction, final Runnable onFinish) { disposed = true; + releaseInferredHost(); final Container p = getParent(); if (p != null) { - final Form f = p.getComponentForm(); + final TopLevelContainer f = p.getTopLevelContainer(); if (f != null) { switch (direction) { case Component.LEFT: @@ -622,10 +710,14 @@ private void disposeTo(int direction, final Runnable onFinish) { setY(-getHeight()); break; case Component.RIGHT: - setX(Display.getInstance().getDisplayWidth()); + // Off the edge of the host, not of the main display. A window + // larger than the main surface left this target still inside + // the window, so the dialog sat there until it was removed + // outright instead of animating out. + setX(f.asContainer().getWidth()); break; case Component.BOTTOM: - setY(Display.getInstance().getDisplayHeight()); + setY(f.asContainer().getHeight()); break; default: break; @@ -819,7 +911,7 @@ public void setDisposeAnimationSetup(Runnable disposeAnimationSetup) { private void installPointerOutOfBoundsListeners() { - final Form f = getComponentForm(); + final TopLevelContainer f = getTopLevelContainer(); if (f != null) { if (pressedListener == null) { pressedListener = new ActionListener() { @@ -827,8 +919,8 @@ private void installPointerOutOfBoundsListeners() { @Override public void actionPerformed(ActionEvent evt) { if (disposed) { - f.removePointerPressedListener(pressedListener); - f.removePointerReleasedListener(releasedListener); + f.asContainer().removePointerPressedListener(pressedListener); + f.asContainer().removePointerReleasedListener(releasedListener); return; } pressedOutOfBounds = disposeWhenPointerOutOfBounds && @@ -846,8 +938,8 @@ public void actionPerformed(ActionEvent evt) { @Override public void actionPerformed(ActionEvent evt) { if (disposed) { - f.removePointerPressedListener(pressedListener); - f.removePointerReleasedListener(releasedListener); + f.asContainer().removePointerPressedListener(pressedListener); + f.asContainer().removePointerReleasedListener(releasedListener); return; } if (disposeWhenPointerOutOfBounds && @@ -855,15 +947,15 @@ public void actionPerformed(ActionEvent evt) { !getContentPane().containsOrOwns(evt.getX(), evt.getY()) && !getTitleComponent().containsOrOwns(evt.getX(), evt.getY())) { evt.consume(); - f.removePointerPressedListener(pressedListener); - f.removePointerReleasedListener(releasedListener); + f.asContainer().removePointerPressedListener(pressedListener); + f.asContainer().removePointerReleasedListener(releasedListener); dispose(); } } }; } - f.addPointerPressedListener(pressedListener); - f.addPointerReleasedListener(releasedListener); + f.asContainer().addPointerPressedListener(pressedListener); + f.asContainer().addPointerReleasedListener(releasedListener); } } @@ -896,10 +988,23 @@ public void showPopupDialog(Component c, boolean bias) { if (c == null) { throw new IllegalArgumentException("Component cannot be null"); } - Form f = c.getComponentForm(); // PMD Fix: BrokenNullCheck + TopLevelContainer f = c.getTopLevelContainer(); // PMD Fix: BrokenNullCheck if (f != null && !formMode && !f.getContentPane().contains(c)) { setFormMode(true); } + // The popup is anchored to c, and the rectangle below is in c's top level's + // coordinate space, so that top level is the surface it has to appear on -- + // this overrides any host set earlier rather than deferring to it. Without it + // the delegation below resolved the current form, so a popup requested for a + // component in a window opened over the main window instead, at coordinates + // that mean nothing there. + if (f != null) { + if (!hostTopLevelInferred) { + hostTopLevelBeforeInference = hostTopLevel; + } + hostTopLevel = f; + hostTopLevelInferred = true; + } disposed = false; getUnselectedStyle().setOpacity(255); Rectangle componentPos = c.getSelectedRect(); @@ -941,7 +1046,11 @@ private void showPopupDialogImpl(Rectangle rect, boolean bias) { if (rect == null) { throw new IllegalArgumentException("rect cannot be null"); } - Form f = Display.getInstance().getCurrent(); + TopLevelContainer f = resolveHost(); + if (f == null) { + return; + } + startPendingTimeout(); Rectangle origRect = rect; rect = new Rectangle(rect); rect.setX(rect.getX() - getLayeredPane(f).getAbsoluteX()); @@ -1029,7 +1138,13 @@ private void showPopupDialogImpl(Rectangle rect, boolean bias) { int x = 0; int y = 0; - boolean showPortrait = bias; + // A window has no device orientation, so its shape is what decides which + // placement algorithm applies. Taking Display.isPortrait() there measured the + // main surface and could open the popup on the wrong side of its anchor. A + // Form keeps the device orientation it was given. + boolean showPortrait = f instanceof Window + ? f.asContainer().getHeight() >= f.asContainer().getWidth() + : bias; // if we don't have enough space then disregard device orientation if (showPortrait) { @@ -1439,9 +1554,32 @@ public void setDefaultCommand(Command defaultCommand) { @Override public void setTimeout(long timeout) { if (timeout <= 0) { + pendingTimeout = 0; + return; + } + // Recorded and started when the dialog is shown, not here. A timeout set before + // showing has no host to bind to yet: resolveHost() answers the current form, + // which is the wrong one for a popup that later resolves to a window -- and if + // that form is replaced its animations stop, so the dialog never times out. In + // an application with no form at all it answers null, which threw. + pendingTimeout = timeout; + if (isShowing()) { + startPendingTimeout(); + } + } + + /// Binds the pending timeout to the host the dialog is actually on. + private void startPendingTimeout() { + if (pendingTimeout <= 0) { + return; + } + TopLevelContainer host = resolveHost(); + if (host == null) { return; } - UITimer.timer((int) timeout, false, Display.getInstance().getCurrent(), new Runnable() { + int millis = (int) pendingTimeout; + pendingTimeout = 0; + UITimer.timer(millis, false, host, new Runnable() { @Override public void run() { dispose(); @@ -1452,8 +1590,16 @@ public void run() { /// Shows this interaction dialog and blocks until it is disposed. @Override public Command showDialog() { - int width = Display.getInstance().getDisplayWidth(); - int height = Display.getInstance().getDisplayHeight(); + // The host's dimensions, not the display's. These margins centre the dialog, + // and show() below places it on the host -- so measuring the main surface + // centred it in the wrong coordinate space, and on a window smaller than the + // display the margins could exceed the host outright and leave the dialog + // clipped or off screen. + TopLevelContainer host = resolveHost(); + int width = host == null + ? Display.getInstance().getDisplayWidth() : host.asContainer().getWidth(); + int height = host == null + ? Display.getInstance().getDisplayHeight() : host.asContainer().getHeight(); revalidate(); int prefWidth = Math.min(width, getPreferredW()); int prefHeight = Math.min(height, getPreferredH()); diff --git a/CodenameOne/src/com/codename1/components/MediaPlayer.java b/CodenameOne/src/com/codename1/components/MediaPlayer.java index 713e23a996e..9545dcb3c79 100644 --- a/CodenameOne/src/com/codename1/components/MediaPlayer.java +++ b/CodenameOne/src/com/codename1/components/MediaPlayer.java @@ -268,7 +268,11 @@ protected void initComponent() { private void checkProgressSlider() { if (progressUpdater == null) { - progressUpdater = UITimer.timer(50, true, getComponentForm(), + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, and UITimer dereferences what it is bound to -- so + // starting playback threw on the event dispatch thread after the media had + // already begun. + progressUpdater = UITimer.timer(50, true, getTopLevelContainer(), new Runnable() { @Override public void run() { diff --git a/CodenameOne/src/com/codename1/components/OnOffSwitch.java b/CodenameOne/src/com/codename1/components/OnOffSwitch.java index 77229b44b1b..650f6c9a0fc 100644 --- a/CodenameOne/src/com/codename1/components/OnOffSwitch.java +++ b/CodenameOne/src/com/codename1/components/OnOffSwitch.java @@ -25,7 +25,6 @@ import com.codename1.ui.CheckBox; import com.codename1.ui.Container; import com.codename1.ui.Display; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.animations.Animation; @@ -38,6 +37,7 @@ import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.EventDispatcher; +import com.codename1.ui.TopLevelContainer; import java.util.Collection; import java.util.Vector; @@ -365,7 +365,14 @@ private void animateTo(final boolean value, final int position) { final Motion current = Motion.createEaseInOutMotion(Math.abs(position), switchMaskImage.getWidth() - 2 * switchButtonPadInt, 100); current.start(); deltaX = position; - getComponentForm().registerAnimated(new Animation() { + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so the switch threw instead of animating there. + final TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel == null) { + setValue(value); + return; + } + topLevel.registerAnimated(new Animation() { @Override public boolean animate() { deltaX = current.getValue(); @@ -375,10 +382,14 @@ public boolean animate() { dragged = true; if (current.isFinished()) { dragged = false; - Form f = getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); - } + // Deregistered from the top level that registered it, not + // from wherever this component is now. A switch removed or + // reparented mid-animation resolves to null or to a different + // top level, so the original one kept the animation for good: + // its hasAnimations() stays true, the event dispatch thread + // never sleeps, and this branch runs again on every frame -- + // firing the change listener each time. + topLevel.deregisterAnimated(this); OnOffSwitch.this.setValue(value); } repaint(); diff --git a/CodenameOne/src/com/codename1/components/ScaleImageButton.java b/CodenameOne/src/com/codename1/components/ScaleImageButton.java index 5c53c349fe5..b29d391aa85 100644 --- a/CodenameOne/src/com/codename1/components/ScaleImageButton.java +++ b/CodenameOne/src/com/codename1/components/ScaleImageButton.java @@ -25,7 +25,6 @@ import com.codename1.ui.Button; import com.codename1.ui.Display; -import com.codename1.ui.Form; import com.codename1.ui.Image; import com.codename1.ui.geom.Dimension; import com.codename1.ui.plaf.Style; @@ -153,12 +152,7 @@ protected void initComponent() { void checkAnimation(Image icon) { if (icon != null && icon.isAnimation()) { - Form parent = getComponentForm(); - if (parent != null) { - // animations are always running so the internal animation isn't - // good enough. We never want to stop this sort of animation - parent.registerAnimated(this); - } + registerForAnimation(); } } diff --git a/CodenameOne/src/com/codename1/components/ScaleImageLabel.java b/CodenameOne/src/com/codename1/components/ScaleImageLabel.java index 230639bc96e..e6cc5ced9dd 100644 --- a/CodenameOne/src/com/codename1/components/ScaleImageLabel.java +++ b/CodenameOne/src/com/codename1/components/ScaleImageLabel.java @@ -24,11 +24,11 @@ package com.codename1.components; import com.codename1.ui.Display; -import com.codename1.ui.Form; import com.codename1.ui.Image; import com.codename1.ui.Label; import com.codename1.ui.geom.Dimension; import com.codename1.ui.plaf.Style; +import com.codename1.ui.TopLevelContainer; /// Label that simplifies the usage of scale to fill/fit. This is effectively equivalent to just setting the style image /// on a label but more convenient for some special circumstances. One major difference is that preferred size @@ -104,7 +104,13 @@ protected Dimension calcPreferredSize() { if (i == null) { return new Dimension(); } - int dw = Display.getInstance().getDisplayWidth(); + // The surface this label lives on rather than the main display, so the + // oversized-preferred-width clamp below is measured against the window the + // label is actually in. + TopLevelContainer scaleTop = getTopLevelContainer(); + int dw = scaleTop != null && scaleTop.asContainer().getWidth() > 0 + ? scaleTop.asContainer().getWidth() + : Display.getInstance().getDisplayWidth(); int iw = i.getWidth(); int ih = i.getHeight(); @@ -131,12 +137,7 @@ protected void initComponent() { void checkAnimation(Image icon) { if (icon != null && icon.isAnimation()) { - Form parent = getComponentForm(); - if (parent != null) { - // animations are always running so the internal animation isn't - // good enough. We never want to stop this sort of animation - parent.registerAnimated(this); - } + registerForAnimation(); } } diff --git a/CodenameOne/src/com/codename1/components/SignatureComponent.java b/CodenameOne/src/com/codename1/components/SignatureComponent.java index f422b82d122..8f5ad1226c9 100644 --- a/CodenameOne/src/com/codename1/components/SignatureComponent.java +++ b/CodenameOne/src/com/codename1/components/SignatureComponent.java @@ -44,6 +44,7 @@ import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.EventDispatcher; +import com.codename1.ui.TopLevelContainer; /// A component to allow a user to enter their signature. This is just a button that, when pressed, @@ -242,13 +243,25 @@ protected void fireActionEvent() { @Override protected void initComponent() { super.initComponent(); - getComponentForm().registerAnimated(iconAnimation); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.registerAnimated(iconAnimation); + } } /// Overridden to deregister the icon animation when the field is removed from the form. @Override protected void deinitialize() { - getComponentForm().deregisterAnimated(iconAnimation); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.deregisterAnimated(iconAnimation); + } super.deinitialize(); } diff --git a/CodenameOne/src/com/codename1/components/SplitPane.java b/CodenameOne/src/com/codename1/components/SplitPane.java index 0a753a7c080..f28af13e003 100644 --- a/CodenameOne/src/com/codename1/components/SplitPane.java +++ b/CodenameOne/src/com/codename1/components/SplitPane.java @@ -29,7 +29,6 @@ import com.codename1.ui.Container; import com.codename1.ui.Display; import com.codename1.ui.FontImage; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.Label; @@ -41,6 +40,7 @@ import com.codename1.ui.layouts.LayeredLayout.LayeredLayoutConstraint; import com.codename1.ui.layouts.LayeredLayout.LayeredLayoutConstraint.Inset; import com.codename1.ui.plaf.Border; +import com.codename1.ui.TopLevelContainer; import java.util.HashSet; import java.util.Iterator; @@ -1095,7 +1095,7 @@ protected boolean isStickyDrag() { @Override protected void initComponent() { super.initComponent(); - Form form = getComponentForm(); + TopLevelContainer form = getTopLevelContainer(); if (form != null) { form.setEnableCursors(true); } @@ -1105,11 +1105,17 @@ protected void initComponent() { @Override protected Dimension calcPreferredSize() { Display d = Display.getInstance(); + // Spanning the surface this divider is on, not the main display: in a + // Window the divider asked for the whole screen's width or height. + TopLevelContainer top = getTopLevelContainer(); + Container c = top == null ? null : top.asContainer(); + int spanW = c != null && c.getWidth() > 0 ? c.getWidth() : d.getDisplayWidth(); + int spanH = c != null && c.getHeight() > 0 ? c.getHeight() : d.getDisplayHeight(); switch (orientation) { case VERTICAL_SPLIT: - return new Dimension(d.getDisplayWidth(), d.convertToPixels(dividerThicknessMM)); + return new Dimension(spanW, d.convertToPixels(dividerThicknessMM)); default: - return new Dimension(d.convertToPixels(dividerThicknessMM), d.getDisplayHeight()); + return new Dimension(d.convertToPixels(dividerThicknessMM), spanH); } } diff --git a/CodenameOne/src/com/codename1/components/Switch.java b/CodenameOne/src/com/codename1/components/Switch.java index 54af1ea2756..a6204f68d22 100644 --- a/CodenameOne/src/com/codename1/components/Switch.java +++ b/CodenameOne/src/com/codename1/components/Switch.java @@ -26,11 +26,11 @@ import com.codename1.ui.Component; import com.codename1.ui.Display; import com.codename1.ui.Font; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.ImageFactory; import com.codename1.ui.ReleasableComponent; +import com.codename1.ui.TopLevelContainer; import com.codename1.ui.animations.Animation; import com.codename1.ui.animations.Motion; import com.codename1.ui.events.ActionEvent; @@ -172,9 +172,11 @@ public class Switch extends Component implements ActionSource, ReleasableCompone private final ActionListener pointerPressed = new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { - Form f = getComponentForm(); - if (f != null) { - f.addComponentAwaitingRelease(Switch.this); + // The top level rather than the Form, so this still registers inside a + // Window where getComponentForm() is null. + TopLevelContainer t = getTopLevelContainer(); + if (t != null) { + t.addComponentAwaitingRelease(Switch.this); } dragged = false; dragStartTime = System.currentTimeMillis(); @@ -1033,7 +1035,15 @@ private void animateTo(final boolean value, final int deltaStart, final int delt if (animDuration > 0) { current.start(); deltaX = deltaStart; - getComponentForm().registerAnimated(new Animation() { + // Resolved through the top level rather than the form: getComponentForm() + // is null by design inside a Window, so a switch hosted in one threw + // instead of toggling. + final TopLevelContainer top = getTopLevelContainer(); + if (top == null) { + setValue(value, true); + return; + } + top.registerAnimated(new Animation() { @Override public boolean animate() { deltaX = current.getValue(); @@ -1042,10 +1052,14 @@ public boolean animate() { dragged = false; deltaX = 0; deltaY = 0; - Form f = getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); - } + // Deregistered from the top level that registered it, not + // from wherever this component is now. A switch removed or + // reparented mid-animation resolves to null or to a different + // top level, so the original one kept the animation for good: + // its hasAnimations() stays true, the event dispatch thread + // never sleeps, and this branch runs again on every frame -- + // firing the change listener each time. + top.deregisterAnimated(this); Switch.this.setValue(value, true); } repaint(); diff --git a/CodenameOne/src/com/codename1/gaming/GameView.java b/CodenameOne/src/com/codename1/gaming/GameView.java index 31d705fc891..c218a44af2b 100644 --- a/CodenameOne/src/com/codename1/gaming/GameView.java +++ b/CodenameOne/src/com/codename1/gaming/GameView.java @@ -25,10 +25,10 @@ import com.codename1.gpu.GraphicsDevice; import com.codename1.gpu.Light; import com.codename1.gpu.RenderView; -import com.codename1.ui.Form; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.TopLevelContainer; /// A GPU accelerated game surface: a `com.codename1.gpu.RenderView` that hosts a /// `SpriteRenderer` over a `Scene` and calls your `#update(double)` once per frame. @@ -248,7 +248,7 @@ private void addFormPointerListeners() { if (formListenersAdded) { return; } - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f == null) { return; } @@ -272,9 +272,9 @@ public void actionPerformed(ActionEvent e) { } }; } - f.addPointerPressedListener(pressListener); - f.addPointerDraggedListener(dragListener); - f.addPointerReleasedListener(releaseListener); + f.asContainer().addPointerPressedListener(pressListener); + f.asContainer().addPointerDraggedListener(dragListener); + f.asContainer().addPointerReleasedListener(releaseListener); formListenersAdded = true; } @@ -282,11 +282,11 @@ private void removeFormPointerListeners() { if (!formListenersAdded) { return; } - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null && pressListener != null) { - f.removePointerPressedListener(pressListener); - f.removePointerDraggedListener(dragListener); - f.removePointerReleasedListener(releaseListener); + f.asContainer().removePointerPressedListener(pressListener); + f.asContainer().removePointerDraggedListener(dragListener); + f.asContainer().removePointerReleasedListener(releaseListener); } formListenersAdded = false; } @@ -338,7 +338,7 @@ private void relayoutControls() { int sy = 0; int sw = getWidth(); int sh = getHeight(); - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { Rectangle safe = f.getSafeArea(); if (safe != null && safe.getWidth() > 0 && safe.getHeight() > 0) { diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 1453207d5ad..60126424ea0 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -23,6 +23,7 @@ */ package com.codename1.impl; +import com.codename1.ui.Desktop; import com.codename1.annotations.Concrete; import com.codename1.capture.VideoCaptureConstraints; import com.codename1.codescan.CodeScanner; @@ -157,8 +158,7 @@ public abstract class CodenameOneImplementation { private final Hashtable builtinSounds = new Hashtable(); /// For use inside paintDirty() so that we don't have to instantiate /// a rectangle each time it is called. - private final Rectangle paintDirtyTmpRect = new Rectangle(); - private Object displayLock; + Object displayLock; private boolean bidi; private Object lightweightClipboard; private Hashtable linearGradientCache; @@ -166,14 +166,36 @@ public abstract class CodenameOneImplementation { private boolean builtinSoundEnabled = true; private boolean dragStarted = false; private int dragActivationCounter = 0; + /// How many windows may have a drag gesture in flight at once. A touchscreen can + /// have a contact down in two windows at the same time, and the framework already + /// keys press targets and drag histories per window rather than globally. + /// Drops any drag-activation state held for a window. + /// + /// A window can be disposed or lose the native pointer while a press is still + /// down, and then no release ever arrives to end the gesture. The next press in a + /// window reset for reuse would otherwise continue the old one, which reads as a + /// drag already in progress. The framework calls this from its own window input + /// cancellation, which until now cleared only its own records. + /// + /// #### Parameters + /// + /// - `windowId`: the window's id; zero -- the main surface -- keeps its state in + /// fields on this class and is not affected + public void releaseWindowInputState(int windowId) { + if (windowId > 0) { + PointerDragActivation act = Desktop.getInstance().windowDragActivation(windowId); + if (act != null) { + act.reset(); + } + } + } + private int dragActivationX = 0; private int dragActivationY = 0; private int dragStartPercentage = 3; private Form currentForm; - private Animation[] paintQueue = new Animation[200]; - private Animation[] paintQueueTemp = new Animation[200]; - private int paintQueueFill = 0; - private Graphics codenameOneGraphics; + private final PaintSurface mainSurface = new PaintSurface(this, null); + private ArrayList windowSurfaces; private String packageName; private Component editingText; private String appArg; @@ -579,9 +601,12 @@ public final void editStringImpl(Component cmp, int maxSize, int constraint, Str public void setFocusedEditingText(Component cmp) { editingText = cmp; if (cmp != null) { - Form form = cmp.getComponentForm(); - if (form != null) { - form.setFocused(cmp); + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so focus was silently never moved to the component being + // edited there. + com.codename1.ui.TopLevelContainer top = cmp.getTopLevelContainer(); + if (top != null) { + top.setFocused(cmp); } } } @@ -748,7 +773,54 @@ public void saveTextEditingState() { /// /// false by default public boolean hasPendingPaints() { - return paintQueueFill != 0; + if (mainSurface.hasPendingPaints()) { + return true; + } + if (windowSurfaces != null) { + int len = windowSurfaces.size(); + for (int iter = 0; iter < len; iter++) { + if (windowSurfaces.get(iter).hasPendingPaints()) { + return true; + } + } + } + return false; + } + + /// Creates the paint surface backing a native window and registers it, so that + /// the sweeps over every surface -- pending paints, cancelled repaints -- see it. + /// + /// This is the whole of the window painting API on this class: everything else a + /// surface can do is a method on the surface itself. + /// + /// #### Parameters + /// + /// - `nativeWindow`: the window peer the surface draws into + /// + /// #### Returns + /// + /// the new surface + public final PaintSurface createPaintSurface(Object nativeWindow) { + PaintSurface surface = new PaintSurface(this, nativeWindow); + synchronized (displayLock) { + if (windowSurfaces == null) { + windowSurfaces = new ArrayList(); + } + windowSurfaces.add(surface); + } + return surface; + } + + /// Unregisters a surface being disposed. Called by `PaintSurface#dispose()` + /// under the display lock, which is why it does not take it again. + /// + /// #### Parameters + /// + /// - `surface`: the surface to forget + void forgetWindowSurface(PaintSurface surface) { + if (windowSurfaces != null) { + windowSurfaces.remove(surface); + } } /// Return the number of alpha levels supported by the implementation. @@ -813,7 +885,7 @@ protected void paintOverlay(Graphics g) { /// - `c`: The component whose paintable bounds we are interested in. /// /// - `out`: A rectangle to return the bounds in. - private void getPaintableBounds(Component c, Rectangle out) { + void getPaintableBounds(Component c, Rectangle out) { int x = c.getAbsoluteX() + c.getScrollX(); int y = c.getAbsoluteY() + c.getScrollY(); int x2 = x + c.getWidth(); @@ -833,85 +905,10 @@ private void getPaintableBounds(Component c, Rectangle out) { } - /// Invoked by the EDT to paint the dirty regions + /// Invoked by the EDT to paint the dirty regions of the application's main + /// surface. public void paintDirty() { - int size = 0; - synchronized (displayLock) { - size = paintQueueFill; - Animation[] array = paintQueue; - paintQueue = paintQueueTemp; - paintQueueTemp = array; - paintQueueFill = 0; - } - if (size > 0) { - Graphics wrapper = getCodenameOneGraphics(); - int dwidth = getDisplayWidth(); - int dheight = getDisplayHeight(); - int topX = dwidth; - int topY = dheight; - int bottomX = 0; - int bottomY = 0; - for (int iter = 0; iter < size; iter++) { - Animation ani = paintQueueTemp[iter]; - - // might happen due to paint queue removal - if (ani == null) { - continue; - } - paintQueueTemp[iter] = null; - wrapper.translate(-wrapper.getTranslateX(), -wrapper.getTranslateY()); - wrapper.resetAffine(); - // Reset the flush-region hint to the full screen before the - // full-screen clip below so neither it nor a previous - // component's tighter region wrongly clamps this reset (#5273). - setPaintDirtyRegionClip(0, 0, dwidth, dheight); - wrapper.setClip(0, 0, dwidth, dheight); - if (ani instanceof Component) { - Component cmp = (Component) ani; - Rectangle dirty = cmp.getDirtyRegion(); - if (dirty != null) { - Dimension d = dirty.getSize(); - wrapper.setClip(dirty.getX(), dirty.getY(), d.getWidth(), d.getHeight()); - cmp.setDirtyRegion(null); - } - // Confine any clip this component sets while painting to its - // flushed region on immediate-mode ports. Use the paintable - // bounds -- the region retained ports clamp to via the - // flushGraphics call below -- NOT the dirty region, which - // repaint() nulls (Component.repaint), in which case it would - // fall back to the full screen and the clip could still escape - // (#5273). Computed before paintComponent (paint does not move - // the component) so the clip set during paint can be clamped. - getPaintableBounds(cmp, paintDirtyTmpRect); - setPaintDirtyRegionClip(paintDirtyTmpRect.getX(), paintDirtyTmpRect.getY(), - paintDirtyTmpRect.getWidth(), paintDirtyTmpRect.getHeight()); - cmp.paintComponent(wrapper); - // Recompute the paintable bounds AFTER paint for the flush - // region below: paintComponent can lay the component out (its - // bounds may change), and the retained ports clamp to / flush - // exactly this rect, so it must match the pre-#5273 value to - // the pixel (the before-paint value above is only the immediate - // -mode clip hint). - getPaintableBounds(cmp, paintDirtyTmpRect); - int cmpAbsX = paintDirtyTmpRect.getX(); - topX = Math.min(cmpAbsX, topX); - bottomX = Math.max(cmpAbsX + paintDirtyTmpRect.getWidth(), bottomX); - int cmpAbsY = paintDirtyTmpRect.getY(); - topY = Math.min(cmpAbsY, topY); - bottomY = Math.max(cmpAbsY + paintDirtyTmpRect.getHeight(), bottomY); - } else { - bottomX = dwidth; - bottomY = dheight; - topX = 0; - topY = 0; - ani.paint(wrapper); - } - } - - paintOverlay(wrapper); - //Log.p("Flushing graphics : "+topX+","+topY+","+bottomX+","+bottomY); - flushGraphics(topX, topY, bottomX - topX, bottomY - topY); - } + mainSurface.paintDirty(getDisplayWidth(), getDisplayHeight()); } /// Reports the clip region that bounds the current component's flush as @@ -966,7 +963,7 @@ public void edtIdle(boolean enter) { /// @return a graphics object, either recycled or new, this object will be /// used on the EDT protected Graphics getCodenameOneGraphics() { - return codenameOneGraphics; + return mainSurface.getGraphics(); } /// Installs the Codename One graphics object into the implementation @@ -975,7 +972,7 @@ protected Graphics getCodenameOneGraphics() { /// /// - `g`: graphics object for use by the implementation public void setCodenameOneGraphics(Graphics g) { - codenameOneGraphics = g; + mainSurface.setGraphics(g); } /// A flag that can be overridden by a platform to indicate that native @@ -1007,13 +1004,17 @@ public void setDisplayLock(Object lock) { /// - `cmp`: the component to public void cancelRepaint(Animation cmp) { synchronized (displayLock) { - for (int iter = 0; iter < paintQueueFill; iter++) { - if (paintQueue[iter] == cmp) { //NOPMD CompareObjectsWithEquals - paintQueue[iter] = null; - return; + if (mainSurface.cancelRepaint(cmp)) { + return; + } + if (windowSurfaces != null) { + int len = windowSurfaces.size(); + for (int iter = 0; iter < len; iter++) { + if (windowSurfaces.get(iter).cancelRepaint(cmp)) { + return; + } } } - } } @@ -1023,33 +1024,7 @@ public void cancelRepaint(Animation cmp) { /// /// - `cmp`: component or animation to push into the paint queue public void repaint(Animation cmp) { - synchronized (displayLock) { - for (int iter = 0; iter < paintQueueFill; iter++) { - Animation ani = paintQueue[iter]; - if (ani == cmp) { //NOPMD CompareObjectsWithEquals - return; - } - //no need to paint a Component if one of its parent is already in the queue - if (ani instanceof Container && cmp instanceof Component) { - Component parent = ((Component) cmp).getParent(); - while (parent != null) { - if (parent == ani) { //NOPMD CompareObjectsWithEquals - return; - } - parent = parent.getParent(); - } - } - } - // overcrowding the queue don't try to grow the array! - if (paintQueueFill >= paintQueue.length) { - System.out.println("Warning paint queue size exceeded, please watch the amount of repaint calls"); - return; - } - - paintQueue[paintQueueFill] = cmp; - paintQueueFill++; - displayLock.notifyAll(); - } + mainSurface.repaint(cmp); } /// Extracts RGB data from the given native image and places it in the given array @@ -2901,6 +2876,141 @@ public boolean isMetaKeyDown() { private int currentPointerModifiers; private boolean currentPointerHovering; + /// Ring of pointer metadata snapshots, one per queued pointer packet. + /// + /// The metadata a port reports is a single mutable record, but a port queues pointer + /// events off the event dispatch thread and the `PointerEvent` is not built until + /// the event is dispatched. A port that drains a burst -- the Win32 pump translates + /// queued messages before returning, and the GTK drain does the same -- therefore + /// overwrote the record several times before any of those events were dispatched, + /// and every one of them came out carrying the *last* packet's button and device + /// type. A secondary window's right click or pen event read as a left mouse click, + /// which is enough to lose a context menu or a stylus callback. + /// + /// A snapshot is taken when the packet is queued and restored when it is + /// dispatched, so each event keeps the metadata that arrived with it. + /// + /// Sized for **two** live buffers, not one. `Display` double buffers the input event + /// stack: the event dispatch thread swaps a full batch out and dispatches it while + /// the native input thread fills the other, so both are live at once. Each is 1000 + /// ints and the smallest pointer packet is three (type, x, y), which puts a ceiling + /// of about 666 queued snapshots -- past a 512 slot ring, which would then wrap onto + /// packets that had not been dispatched yet and hand them the wrong button or device + /// type under a sustained burst. 2048 leaves headroom over that ceiling. + private static final int POINTER_METADATA_SLOTS = 2048; + private final int[] pointerMetadataInts = + new int[POINTER_METADATA_SLOTS * 4]; + private final float[] pointerMetadataFloats = + new float[POINTER_METADATA_SLOTS * 4]; + private final boolean[] pointerMetadataHovering = + new boolean[POINTER_METADATA_SLOTS]; + private int pointerMetadataNext; + + /// Snapshots the current pointer metadata and returns the slot holding it. + /// + /// #### Returns + /// + /// the slot to hand back to `#selectPointerEventMetadata(int)` when the matching + /// packet is dispatched + public int capturePointerEventMetadata() { + int slot; + synchronized (pointerMetadataHovering) { + slot = pointerMetadataNext; + pointerMetadataNext = (pointerMetadataNext + 1) % POINTER_METADATA_SLOTS; + } + return recapturePointerEventMetadata(slot); + } + + /// Overwrites an existing snapshot slot rather than taking a new one. + /// + /// Coalescing is why this exists. A drag that replaces the queued drag packet keeps + /// one packet however many updates arrive, so advancing the ring on each of them + /// would run it forward without bound while the number of live packets stays small + /// -- and the ring would then wrap onto slots belonging to presses and releases + /// that are still queued, which is the very mix-up the snapshot prevents. + /// + /// #### Parameters + /// + /// - `slot`: the slot to overwrite; out of range values are ignored + /// + /// #### Returns + /// + /// the slot that now holds the current metadata + public int recapturePointerEventMetadata(int slot) { + if (slot < 0 || slot >= POINTER_METADATA_SLOTS) { + return capturePointerEventMetadata(); + } + int i = slot * 4; + pointerMetadataInts[i] = currentPointerButton; + pointerMetadataInts[i + 1] = currentPointerButtonMask; + pointerMetadataInts[i + 2] = currentPointerType; + pointerMetadataInts[i + 3] = currentPointerModifiers; + int f = slot * 4; + pointerMetadataFloats[f] = currentPointerPressure; + pointerMetadataFloats[f + 1] = currentPointerTiltX; + pointerMetadataFloats[f + 2] = currentPointerTiltY; + pointerMetadataFloats[f + 3] = currentPointerContactSize; + pointerMetadataHovering[slot] = currentPointerHovering; + return slot; + } + + /// Restores the metadata snapshotted into the given slot, so the event about to be + /// dispatched builds its `PointerEvent` from the values that arrived with it. + /// + /// #### Parameters + /// + /// - `slot`: a slot from `#capturePointerEventMetadata()`, or a negative value to + /// leave the current metadata alone + public void selectPointerEventMetadata(int slot) { + if (slot < 0 || slot >= POINTER_METADATA_SLOTS) { + return; + } + int i = slot * 4; + dispatchPointerButton = pointerMetadataInts[i]; + dispatchPointerButtonMask = pointerMetadataInts[i + 1]; + dispatchPointerType = pointerMetadataInts[i + 2]; + dispatchPointerModifiers = pointerMetadataInts[i + 3]; + int f = slot * 4; + dispatchPointerPressure = pointerMetadataFloats[f]; + dispatchPointerTiltX = pointerMetadataFloats[f + 1]; + dispatchPointerTiltY = pointerMetadataFloats[f + 2]; + dispatchPointerContactSize = pointerMetadataFloats[f + 3]; + dispatchPointerHovering = pointerMetadataHovering[slot]; + dispatchMetadataActive = true; + } + + /// The metadata of the event being dispatched, restored from its snapshot. + /// + /// Deliberately a second set of fields rather than a write back into the + /// `currentPointer*` staging the ports fill in. Those are written on the port's + /// own thread and read when a packet is queued; writing them from the event + /// dispatch thread as well put the two in a race, and the restore could land + /// between a port's `#setPointerEventMetadata` and the capture that follows it -- + /// handing the next packet the previous event's button. Separating the two means + /// the dispatch thread never writes what the port writes. + private int dispatchPointerButton = com.codename1.ui.events.PointerEvent.BUTTON_PRIMARY; + private int dispatchPointerButtonMask = com.codename1.ui.events.PointerEvent.MASK_PRIMARY; + private int dispatchPointerType = com.codename1.ui.events.PointerEvent.TYPE_UNKNOWN; + private float dispatchPointerPressure = 1f; + private float dispatchPointerTiltX; + private float dispatchPointerTiltY; + private float dispatchPointerContactSize; + private int dispatchPointerModifiers; + private boolean dispatchPointerHovering; + /// False until the first packet is dispatched, so the accessors keep answering + /// from the staging fields for a port that sets metadata and builds an event + /// directly, without going through the queue. + private boolean dispatchMetadataActive; + + /// Stops the accessors answering from the last dispatched packet's snapshot. + /// + /// Called when a dispatch batch is finished. Without it the selection latched on + /// and a port that staged fresh metadata and then read it back -- rather than + /// queueing an event -- was answered with the previous event's values. + public void clearPointerEventMetadataSelection() { + dispatchMetadataActive = false; + } + /// Resets the rich pointer metadata back to its defaults. Ports may call this between /// gestures so stale button or pressure values do not leak into unrelated events. public void resetPointerEventMetadata() { @@ -2913,6 +3023,9 @@ public void resetPointerEventMetadata() { currentPointerContactSize = 0; currentPointerModifiers = 0; currentPointerHovering = false; + // The dispatch copy goes with it: a port resetting between gestures means the + // accessors should stop answering from the last dispatched packet. + dispatchMetadataActive = false; } /// Populates all of the rich pointer metadata in one call. Platform ports invoke this from @@ -2969,55 +3082,58 @@ public void setPointerHovering(boolean hovering) { /// The button associated with the current pointer event, one of the `PointerEvent.BUTTON_*` constants. public int getPointerButton() { - return currentPointerButton; + return dispatchMetadataActive ? dispatchPointerButton : currentPointerButton; } /// A bitmask of the buttons currently held, built from the `PointerEvent.MASK_*` constants. public int getPointerButtonMask() { - return currentPointerButtonMask; + return dispatchMetadataActive ? dispatchPointerButtonMask : currentPointerButtonMask; } /// The current pointing device type, one of the `PointerEvent.TYPE_*` constants. public int getPointerType() { - return currentPointerType; + return dispatchMetadataActive ? dispatchPointerType : currentPointerType; } /// The normalized pressure of the current pointer event between `0.0` and `1.0`. public float getPointerPressure() { - return currentPointerPressure; + return dispatchMetadataActive ? dispatchPointerPressure : currentPointerPressure; } /// The stylus tilt across the x axis for the current pointer event, in degrees. public float getPointerTiltX() { - return currentPointerTiltX; + return dispatchMetadataActive ? dispatchPointerTiltX : currentPointerTiltX; } /// The stylus tilt across the y axis for the current pointer event, in degrees. public float getPointerTiltY() { - return currentPointerTiltY; + return dispatchMetadataActive ? dispatchPointerTiltY : currentPointerTiltY; } /// The normalized contact size of the current pointer event between `0.0` and `1.0`. public float getPointerContactSize() { - return currentPointerContactSize; + return dispatchMetadataActive ? dispatchPointerContactSize : currentPointerContactSize; } /// The keyboard modifier mask held during the current pointer event. public int getPointerModifiers() { - return currentPointerModifiers; + return dispatchMetadataActive ? dispatchPointerModifiers : currentPointerModifiers; } /// True if the current pointer event is a hover (no contact with the surface). public boolean isPointerHovering() { - return currentPointerHovering; + return dispatchMetadataActive ? dispatchPointerHovering : currentPointerHovering; } /// Builds an immutable `PointerEvent` snapshot from the current metadata for the given coordinates. /// Used by the framework when it dispatches a pointer event. public com.codename1.ui.events.PointerEvent buildPointerEvent(int x, int y, boolean hovering) { - return new com.codename1.ui.events.PointerEvent(x, y, currentPointerButton, currentPointerButtonMask, - currentPointerType, currentPointerPressure, currentPointerTiltX, currentPointerTiltY, - currentPointerContactSize, currentPointerModifiers, hovering || currentPointerHovering); + // Through the accessors, so this reads the dispatched event's own snapshot + // rather than whatever a port has staged since. + return new com.codename1.ui.events.PointerEvent(x, y, getPointerButton(), + getPointerButtonMask(), getPointerType(), getPointerPressure(), + getPointerTiltX(), getPointerTiltY(), getPointerContactSize(), + getPointerModifiers(), hovering || isPointerHovering()); } /// Subclasses should invoke this method, it delegates the event to the display and into @@ -3048,6 +3164,174 @@ protected void pointerPressed(final int x, final int y) { pointerPressed(xPointerEvent, yPointerEvent); } + /// Delivers a pointer press that happened in one of the additional native + /// windows. Ports call this instead of `#pointerPressed(int, int)` when the + /// event came from a window rather than the main surface; window id zero routes + /// to the main surface, so a port may use this form unconditionally. + /// + /// #### Parameters + /// + /// - `windowId`: the id handed to + /// `WindowManager#createWindow(int, java.lang.String, int, int, int, int, boolean, boolean, java.lang.Object)` + /// + /// - `x`: the position of the event + /// + /// - `y`: the position of the event + protected void windowPointerPressed(int windowId, int x, int y) { + if (windowId > 0) { + // A new gesture in this window starts its own activation filter over, + // leaving any other window's gesture alone. + PointerDragActivation act = Desktop.getInstance().windowDragActivation(windowId); + if (act != null) { + act.reset(); + } + } + if (windowId == 0) { + pointerPressed(x, y); + return; + } + xPointerEvent[0] = x; + yPointerEvent[0] = y; + Desktop.getInstance().windowPointerPressed(windowId, xPointerEvent, yPointerEvent); + } + + /// Delivers a pointer release that happened in one of the additional native + /// windows. + /// + /// #### Parameters + /// + /// - `windowId`: the window's id, or zero for the main surface + /// + /// - `x`: the position of the event + /// + /// - `y`: the position of the event + protected void windowPointerReleased(int windowId, int x, int y) { + if (windowId > 0) { + PointerDragActivation act = Desktop.getInstance().windowDragActivation(windowId); + if (act != null) { + act.reset(); + } + } + if (windowId == 0) { + pointerReleased(x, y); + return; + } + xPointerEvent[0] = x; + yPointerEvent[0] = y; + Desktop.getInstance().windowPointerReleased(windowId, xPointerEvent, yPointerEvent); + } + + /// Delivers a pointer drag that happened in one of the additional native windows. + /// + /// #### Parameters + /// + /// - `windowId`: the window's id, or zero for the main surface + /// + /// - `x`: the position of the event + /// + /// - `y`: the position of the event + /// Multi pointer drag over a specific native window, which is how the desktop + /// simulator plays a pinch gesture. Without the id the second pointer would land + /// on the main form while the press that started the gesture went to the window. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created, or 0 for + /// the application's main surface + /// + /// - `x`: the x positions of the pointers + /// + /// - `y`: the y positions of the pointers + protected void windowPointerDragged(int windowId, final int[] x, final int[] y) { + if (windowId > 0) { + // The same activation filter the main surface applies. Forwarding straight + // through made a pixel of jitter after a press into a drag, which activates + // drag and drop and moves a draggable component on what was meant as a + // click. + PointerDragActivation act = Desktop.getInstance().windowDragActivation(windowId); + if (act == null) { + // No window under this id any more -- disposed with events still in + // flight. Filtering is a refinement, so let the gesture through rather + // than swallow it. + Desktop.getInstance().windowPointerDragged(windowId, x, y); + return; + } + boolean started = false; + if (!act.started) { + try { + started = hasWindowDragStarted(windowId, act, x[0], y[0]); + } catch (Throwable t) { + // Matches the main path: a filter that throws must not take the + // gesture with it. + Log.e(t); + } + } + if (act.started || started) { + act.started = true; + Desktop.getInstance().windowPointerDragged(windowId, x, y); + } + return; + } + pointerDragged(x, y); + } + + protected void windowPointerDragged(int windowId, int x, int y) { + if (windowId == 0) { + pointerDragged(x, y); + return; + } + xPointerEvent[0] = x; + yPointerEvent[0] = y; + // Through the array overload rather than straight to the framework, so this + // path gets the activation filter too. + windowPointerDragged(windowId, xPointerEvent, yPointerEvent); + } + + /// Delivers a key press that happened in one of the additional native windows. + /// + /// #### Parameters + /// + /// - `windowId`: the window's id, or zero for the main surface + /// + /// - `keyCode`: the key code + protected void windowKeyPressed(int windowId, int keyCode) { + if (windowId == 0) { + keyPressed(keyCode); + return; + } + Desktop.getInstance().windowKeyPressed(windowId, keyCode); + } + + /// Delivers a key release that happened in one of the additional native windows. + /// + /// #### Parameters + /// + /// - `windowId`: the window's id, or zero for the main surface + /// + /// - `keyCode`: the key code + protected void windowKeyReleased(int windowId, int keyCode) { + if (windowId == 0) { + keyReleased(keyCode); + return; + } + Desktop.getInstance().windowKeyReleased(windowId, keyCode); + } + + /// Returns the native window peer owning the given component, or null when the + /// component belongs to the main surface. Ports use this to place native peers + /// and native text editors into the right window. + /// + /// #### Parameters + /// + /// - `cmp`: the component to locate + /// + /// #### Returns + /// + /// the owning window's native peer, or null for the main surface + public final Object getWindowPeerForComponent(Component cmp) { + return Desktop.getInstance().getWindowPeerForComponent(cmp); + } + /// Subclasses should invoke this method, it delegates the event to the display and into /// Codename One. /// @@ -3140,6 +3424,26 @@ protected void pointerHover(final int x, final int y) { pointerHover(xPointerEvent, yPointerEvent); } + /// Same as `#pointerHover(int, int)`, for a hover over a specific native window. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created, or 0 for + /// the application's main surface + /// + /// - `x`: the position of the event + /// + /// - `y`: the position of the event + protected void windowPointerHover(final int windowId, final int x, final int y) { + xPointerEvent[0] = x; + yPointerEvent[0] = y; + if (windowId > 0) { + Desktop.getInstance().windowPointerHover(windowId, xPointerEvent, yPointerEvent); + } else { + pointerHover(xPointerEvent, yPointerEvent); + } + } + /// Subclasses should invoke this method, it delegates the event to the display and into /// Codename One. /// @@ -3207,18 +3511,72 @@ protected boolean hasDragStarted(final int x, final int y) { dragActivationCounter++; return false; } - int dragRegion = getCurrentForm().getDragRegionStatus(x, y); - - //send the drag events to the form only after latency of 7 drag events, - //most touch devices are too sensitive and send too many drag events. - //7 is just a latency const number that is pretty good for most devices - //this may be tuned for specific devices. dragActivationCounter++; + if (dragPassedThreshold(getCurrentForm().getDragRegionStatus(x, y), + getDisplayWidth(), getDisplayHeight(), + dragActivationX, dragActivationY, dragActivationCounter, x, y)) { + dragActivationCounter = getDragAutoActivationThreshold() + 1; + return true; + } + return false; + } + + /// The same activation filter, for a drag inside one of the additional native + /// windows. + /// + /// Window drags used to reach the framework unfiltered, so a pixel of jitter after + /// a press was already a drag: drag and drop activated, and a draggable component + /// moved on what the user meant as a click. + /// + /// The state is separate from the main surface's rather than shared, because + /// nothing resets the main one for a window gesture -- window presses and releases + /// go straight to the framework -- so a shared counter would be stale from the + /// first window drag onwards. And the region and the size come from the window: the + /// thresholds are a percentage of the surface, and measuring a window's drag + /// against the display makes a small window nearly undraggable. + /// + /// #### Parameters + /// + /// - `windowId`: the window the drag is happening in + /// + /// - `x`: the position of the current drag event + /// + /// - `y`: the position of the current drag event + /// + /// #### Returns + /// + /// true if the drag should propagate into Codename One + protected boolean hasWindowDragStarted(final int windowId, final PointerDragActivation act, + final int x, final int y) { + int surfaceWidth = Desktop.getInstance().windowWidth(windowId); + int surfaceHeight = Desktop.getInstance().windowHeight(windowId); + if (surfaceWidth <= 0 || surfaceHeight <= 0) { + return false; + } + if (act.counter == 0) { + act.x = x; + act.y = y; + act.counter++; + return false; + } + act.counter++; + if (dragPassedThreshold(Desktop.getInstance().windowDragRegionStatus(windowId, x, y), + surfaceWidth, surfaceHeight, act.x, act.y, act.counter, x, y)) { + act.counter = getDragAutoActivationThreshold() + 1; + return true; + } + return false; + } + + /// Whether a drag has moved far enough, for a given drag region and surface size. + /// Shared by the main surface and by the windows so the two cannot drift. + private boolean dragPassedThreshold(int dragRegion, int surfaceWidth, int surfaceHeight, + int activationX, int activationY, int counter, final int x, final int y) { float startX = getDragStartPercentage(); float startY = startX; switch (dragRegion) { case Component.DRAG_REGION_NOT_DRAGGABLE: - if (dragActivationCounter > getDragAutoActivationThreshold()) { + if (counter > getDragAutoActivationThreshold()) { return true; } startX = Math.max(5, startX); @@ -3267,20 +3625,13 @@ protected boolean hasDragStarted(final int x, final int y) { } // have we passed the motion threshold on the X axis? - if (((float) getDisplayWidth()) / 100.0f * startX <= - Math.abs(dragActivationX - x)) { - dragActivationCounter = getDragAutoActivationThreshold() + 1; + if (((float) surfaceWidth) / 100.0f * startX <= + Math.abs(activationX - x)) { return true; } // have we passed the motion threshold on the Y axis? - if (((float) getDisplayHeight()) / 100.0f * startY <= - Math.abs(dragActivationY - y)) { - dragActivationCounter = getDragAutoActivationThreshold() + 1; - return true; - } - - return false; + return ((float) surfaceHeight) / 100.0f * startY <= Math.abs(activationY - y); } /// This method allows us to manipulate the drag started detection logic. @@ -7516,6 +7867,21 @@ public com.codename1.health.Health getHealth() { return null; } + /// Returns the port-specific window manager, which carries the whole native + /// windowing contract. Default implementation returns {@code null}; the desktop + /// ports override it to return a cached instance. + /// + /// A {@code null} return **is** the capability query --- there is deliberately no + /// separate supported flag that could drift out of step with it. Application code + /// should use {@link com.codename1.ui.Desktop} rather than calling this directly. + /// + /// #### Returns + /// + /// the window manager, or {@code null} when this platform has no windowing system + public WindowManager getWindowManager() { + return null; + } + /// Allows buggy implementations (Android) to release image objects /// /// #### Parameters @@ -8911,6 +9277,35 @@ public void pointerWheelMoved(final int x, final int y, final int scrollX, final /// - `modifiers`: bitmask of the held keyboard modifiers (the `PointerEvent` `MODIFIER_*` constants) public void pointerWheelMoved(final int x, final int y, final int scrollX, final int scrollY, final boolean precise, final int modifiers) { + windowPointerWheelMoved(0, x, y, scrollX, scrollY, precise, modifiers); + } + + /// Same as `#pointerWheelMoved(int, int, int, int, boolean, int)`, for a wheel + /// event that arrived over a specific native window. + /// + /// A port with desktop windows has to say which window the wheel was over: the + /// main form version resolves everything -- the listeners and the synthesized + /// scroll gesture -- from the current form, so a wheel over a second window + /// would scroll the main form's content instead of the window's. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created, or 0 for + /// the application's main surface + /// + /// - `x`: the pointer x position in window pixels + /// + /// - `y`: the pointer y position in window pixels + /// + /// - `scrollX`: the horizontal scroll amount in display pixels + /// + /// - `scrollY`: the vertical scroll amount in display pixels + /// + /// - `precise`: true if the deltas come from a high resolution device + /// + /// - `modifiers`: bitmask of the held keyboard modifiers + public void windowPointerWheelMoved(final int windowId, final int x, final int y, + final int scrollX, final int scrollY, final boolean precise, final int modifiers) { if (scrollX == 0 && scrollY == 0) { return; } @@ -8920,24 +9315,60 @@ public void pointerWheelMoved(final int x, final int y, final int scrollX, final d.callSerially(new Runnable() { @Override public void run() { - if (d.fireMouseWheelEvent(x, y, scrollX, scrollY, precise, modifiers)) { + if (Desktop.getInstance().windowMouseWheelEvent( + windowId, x, y, scrollX, scrollY, precise, modifiers)) { return; } - playWheelScrollGesture(d, x, y, scrollX, scrollY); + playWheelScrollGesture(d, windowId, x, y, scrollX, scrollY); } }); } + /// Resolves the top level a wheel gesture should play into: the window with the + /// given id, or the current form for the main surface. + private Container wheelRoot(Display d, int windowId) { + // Modality is rechecked on every step, not only when the wheel arrived. The + // gesture is played as four queued steps and an unconsumed wheel listener can + // show a modal in between, after which the remaining synthetic press, drags + // and release would scroll or activate content behind it. + if (Desktop.getInstance().isWindowInputBlocked(windowId)) { + return null; + } + if (windowId > 0) { + com.codename1.ui.Window w = Desktop.getInstance().windowById(windowId); + // Visibility as well as modality, and for the same reason: an unconsumed + // wheel listener can hide or minimize its own window before the gesture + // starts, and a hidden window stays registered -- so the synthetic press, + // drags and release would scroll and activate components in a hierarchy + // nobody can see. + if (w == null || !w.isWindowShowing()) { + return null; + } + return w; + } + return d.getCurrent(); + } + /// Plays the default scroll gesture for a wheel movement. Quarter the gesture across four EDT /// cycles: a single press->drag(full)->release would read as a fling and overshoot, whereas /// stepped drags let the scroll container settle the way a finger drag does. While it runs /// `#isScrollWheeling` reports `true`. - private void playWheelScrollGesture(final Display d, final int x, final int y, final int scrollX, final int scrollY) { + private void playWheelScrollGesture(final Display d, final int windowId, final int x, + final int y, final int scrollX, final int scrollY) { + // The root is resolved once, by the step that dispatches the press, and the + // remaining steps reuse it. Re-checking modality on every step -- which is + // what the previous version did -- suppressed the later steps including the + // only release, so a gesture whose press had already been delivered never + // completed and left the top level's pressed and drag bookkeeping stranded. + // A gesture blocked *before* its press still never starts, which is the case + // modality is there to stop. + final Container[] started = new Container[1]; d.callSerially(new Runnable() { @Override public void run() { - Form f = d.getCurrent(); + Container f = wheelRoot(d, windowId); if (f != null) { + started[0] = f; scrollWheeling = true; dragWheelStep(f, x, y, scrollX / 4, scrollY / 4, true, false); } @@ -8946,7 +9377,7 @@ public void run() { d.callSerially(new Runnable() { @Override public void run() { - Form f = d.getCurrent(); + Container f = started[0]; if (f != null) { dragWheelStep(f, x, y, scrollX / 2, scrollY / 2, false, false); } @@ -8955,7 +9386,7 @@ public void run() { d.callSerially(new Runnable() { @Override public void run() { - Form f = d.getCurrent(); + Container f = started[0]; if (f != null) { dragWheelStep(f, x, y, scrollX * 3 / 4, scrollY * 3 / 4, false, false); } @@ -8964,7 +9395,9 @@ public void run() { d.callSerially(new Runnable() { @Override public void run() { - Form f = d.getCurrent(); + // The release, which must reach the same root the press did -- a + // modal shown mid-gesture must not strand the pressed component. + Container f = started[0]; if (f != null) { dragWheelStep(f, x, y, scrollX, scrollY, false, true); } @@ -8977,7 +9410,7 @@ public void run() { /// presses, drags to the accumulated `(dx, dy)` offset, and optionally /// releases. The component under the cursor is made non-focusable around the /// step so the synthetic press is not turned into a selection/click. - private void dragWheelStep(Form f, int x, int y, int dx, int dy, boolean press, boolean release) { + private void dragWheelStep(Container f, int x, int y, int dx, int dy, boolean press, boolean release) { Component cmp; try { cmp = f.getComponentAt(x, y); diff --git a/CodenameOne/src/com/codename1/impl/PaintSurface.java b/CodenameOne/src/com/codename1/impl/PaintSurface.java new file mode 100644 index 00000000000..228af8537f7 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/PaintSurface.java @@ -0,0 +1,299 @@ +/* + * 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; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Graphics; +import com.codename1.ui.animations.Animation; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.geom.Rectangle; + +/// One paintable surface: its own dirty queue, its own `Graphics` and the routine +/// that drains the one into the other. The application's main surface is one of +/// these and each native window adds another, so the clip and paintable-bounds +/// handling that issue #5273 turns on cannot drift between them. +/// +/// Before desktop windows existed this state was four fields on +/// `CodenameOneImplementation` and the routine was a method there. It lives here +/// instead so that opening a window adds an object rather than a set of public +/// methods to a class that is already the largest in the framework. +public final class PaintSurface { + + /// The implementation this surface paints through. A surface is meaningless + /// without it: the flush, the overlay and the paintable bounds are all its. + private final CodenameOneImplementation impl; + + /// The native window this surface draws into, or null for the main surface. + private final Object nativeWindow; + + private Animation[] paintQueue = new Animation[200]; + private Animation[] paintQueueTemp = new Animation[200]; + private int paintQueueFill; + private Graphics graphics; + + /// Scratch rectangle for the paintable bounds. Held per surface rather than + /// shared, so the arithmetic of one surface's paint pass cannot be read by + /// another's. + private final Rectangle paintableBounds = new Rectangle(); + + /// Creates a surface for a native window. + /// + /// #### Parameters + /// + /// - `impl`: the implementation that paints and flushes it + /// + /// - `nativeWindow`: the window peer the surface draws into, null for the + /// application's main surface + PaintSurface(CodenameOneImplementation impl, Object nativeWindow) { + this.impl = impl; + this.nativeWindow = nativeWindow; + } + + /// The native window this surface draws into. + /// + /// #### Returns + /// + /// the native window peer, or null for the main surface + Object getNativeWindow() { + return nativeWindow; + } + + /// The `Graphics` this surface paints through. + /// + /// #### Returns + /// + /// the graphics, or null before one has been installed + Graphics getGraphics() { + return graphics; + } + + /// Installs the `Graphics` this surface paints through. `Graphics` cannot be + /// constructed outside `com.codename1.ui`, so the framework creates it and hands + /// it over here, exactly as it does for the main surface. + /// + /// #### Parameters + /// + /// - `g`: the graphics to install + public void setGraphics(Graphics g) { + graphics = g; + } + + /// Whether anything is queued on this surface. + /// + /// #### Returns + /// + /// true when the dirty queue is not empty + boolean hasPendingPaints() { + return paintQueueFill != 0; + } + + /// Drops everything queued here, keeping the surface itself. A hidden window is + /// not painted, so work queued on it would never drain -- and an undrained queue + /// keeps `CodenameOneImplementation#hasPendingPaints()` true, which keeps the + /// event dispatch thread awake spinning on it. + public void clear() { + synchronized (impl.displayLock) { + paintQueueFill = 0; + java.util.Arrays.fill(paintQueue, null); + java.util.Arrays.fill(paintQueueTemp, null); + } + } + + /// Releases this surface, dropping anything still queued on it so a disposed + /// window cannot pin its component tree, and unregistering it so nothing paints + /// or sweeps it again. + public void dispose() { + synchronized (impl.displayLock) { + paintQueueFill = 0; + java.util.Arrays.fill(paintQueue, null); + java.util.Arrays.fill(paintQueueTemp, null); + graphics = null; + impl.forgetWindowSurface(this); + } + } + + /// Removes an entry from the queue if it is there. + /// + /// #### Parameters + /// + /// - `cmp`: the animation to drop + /// + /// #### Returns + /// + /// true if it was found and dropped + boolean cancelRepaint(Animation cmp) { + for (int iter = 0; iter < paintQueueFill; iter++) { + if (paintQueue[iter] == cmp) { //NOPMD CompareObjectsWithEquals + paintQueue[iter] = null; + return true; + } + } + return false; + } + + /// Queues an animation or component to be painted on this surface. + /// + /// #### Parameters + /// + /// - `cmp`: the animation or component to repaint + public void repaint(Animation cmp) { + synchronized (impl.displayLock) { + for (int iter = 0; iter < paintQueueFill; iter++) { + Animation ani = paintQueue[iter]; + if (ani == cmp) { //NOPMD CompareObjectsWithEquals + return; + } + //no need to paint a Component if one of its parent is already in the queue + if (ani instanceof Container && cmp instanceof Component) { + Component parent = ((Component) cmp).getParent(); + while (parent != null) { + if (parent == ani) { //NOPMD CompareObjectsWithEquals + return; + } + parent = parent.getParent(); + } + } + } + // overcrowding the queue don't try to grow the array! + if (paintQueueFill >= paintQueue.length) { + System.out.println("Warning paint queue size exceeded, please watch the amount of repaint calls"); + return; + } + + paintQueue[paintQueueFill] = cmp; + paintQueueFill++; + impl.displayLock.notifyAll(); + } + } + + /// Paints this surface's dirty regions. + /// + /// #### Parameters + /// + /// - `dwidth`: the surface width, used as the clip universe + /// + /// - `dheight`: the surface height, used as the clip universe + public void paintDirty(int dwidth, int dheight) { + if (graphics == null || dwidth <= 0 || dheight <= 0) { + return; + } + int size = 0; + synchronized (impl.displayLock) { + size = paintQueueFill; + Animation[] array = paintQueue; + paintQueue = paintQueueTemp; + paintQueueTemp = array; + paintQueueFill = 0; + } + if (size > 0) { + Graphics wrapper = graphics; + int topX = dwidth; + int topY = dheight; + int bottomX = 0; + int bottomY = 0; + for (int iter = 0; iter < size; iter++) { + Animation ani = paintQueueTemp[iter]; + + // might happen due to paint queue removal + if (ani == null) { + continue; + } + paintQueueTemp[iter] = null; + wrapper.translate(-wrapper.getTranslateX(), -wrapper.getTranslateY()); + wrapper.resetAffine(); + // Reset the flush-region hint to the full screen before the + // full-screen clip below so neither it nor a previous + // component's tighter region wrongly clamps this reset (#5273). + setDirtyRegionClip(0, 0, dwidth, dheight); + wrapper.setClip(0, 0, dwidth, dheight); + if (ani instanceof Component) { + Component cmp = (Component) ani; + Rectangle dirty = cmp.getDirtyRegion(); + if (dirty != null) { + Dimension d = dirty.getSize(); + wrapper.setClip(dirty.getX(), dirty.getY(), d.getWidth(), d.getHeight()); + cmp.setDirtyRegion(null); + } + // Confine any clip this component sets while painting to its + // flushed region on immediate-mode ports. Use the paintable + // bounds -- the region retained ports clamp to via the + // flushGraphics call below -- NOT the dirty region, which + // repaint() nulls (Component.repaint), in which case it would + // fall back to the full screen and the clip could still escape + // (#5273). Computed before paintComponent (paint does not move + // the component) so the clip set during paint can be clamped. + impl.getPaintableBounds(cmp, paintableBounds); + setDirtyRegionClip(paintableBounds.getX(), paintableBounds.getY(), + paintableBounds.getWidth(), paintableBounds.getHeight()); + cmp.paintComponent(wrapper); + // Recompute the paintable bounds AFTER paint for the flush + // region below: paintComponent can lay the component out (its + // bounds may change), and the retained ports clamp to / flush + // exactly this rect, so it must match the pre-#5273 value to + // the pixel (the before-paint value above is only the immediate + // -mode clip hint). + impl.getPaintableBounds(cmp, paintableBounds); + int cmpAbsX = paintableBounds.getX(); + topX = Math.min(cmpAbsX, topX); + bottomX = Math.max(cmpAbsX + paintableBounds.getWidth(), bottomX); + int cmpAbsY = paintableBounds.getY(); + topY = Math.min(cmpAbsY, topY); + bottomY = Math.max(cmpAbsY + paintableBounds.getHeight(), bottomY); + } else { + bottomX = dwidth; + bottomY = dheight; + topX = 0; + topY = 0; + ani.paint(wrapper); + } + } + + if (nativeWindow == null) { + impl.paintOverlay(wrapper); + //Log.p("Flushing graphics : "+topX+","+topY+","+bottomX+","+bottomY); + impl.flushGraphics(topX, topY, bottomX - topX, bottomY - topY); + } else { + WindowManager wm = impl.getWindowManager(); + if (wm != null) { + wm.flushGraphics(nativeWindow, topX, topY, bottomX - topX, bottomY - topY); + } + } + } + } + + /// Routes the flush-region hint to whichever surface this is. The window form + /// defaults to inert rather than delegating to the main surface version, so a + /// port that has not opted in cannot clamp a window's clip against the main + /// window's state. + private void setDirtyRegionClip(int x, int y, int w, int h) { + if (nativeWindow == null) { + impl.setPaintDirtyRegionClip(x, y, w, h); + return; + } + WindowManager wm = impl.getWindowManager(); + if (wm != null) { + wm.setPaintDirtyRegionClip(nativeWindow, x, y, w, h); + } + } +} diff --git a/CodenameOne/src/com/codename1/impl/PointerDragActivation.java b/CodenameOne/src/com/codename1/impl/PointerDragActivation.java new file mode 100644 index 00000000000..e21d923d838 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/PointerDragActivation.java @@ -0,0 +1,61 @@ +/* + * 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; + +/// The drag-activation filter's state for one surface: where the gesture started, +/// how many moves it has seen, and whether it has crossed the threshold into a real +/// drag. Without it a pixel of jitter after a press reads as a drag, which activates +/// drag and drop and moves a draggable component on what was meant as a click. +/// +/// The application's main surface keeps this state in fields on +/// `CodenameOneImplementation`, exactly as it always has. Each native window owns one +/// of these instead. That is deliberate: the alternative is a fixed table of slots +/// indexed by window id, which caps how many windows can drag at once, needs a +/// claim/release protocol on every press, and leaks a slot whenever a window is +/// disposed with a press still down -- after which the filter silently stops +/// filtering. State that belongs to a window and dies with it has none of those +/// failure modes. +public final class PointerDragActivation { + + /// Whether the gesture has been recognized as a drag. Package private rather + /// than behind accessors: the only reader is the implementation, which is in + /// this package. + boolean started; + + /// How many moves this gesture has produced. + int counter; + + /// Where the gesture started. + int x; + + /// Where the gesture started. + int y; + + /// Starts the filter over, so the next move begins a fresh gesture. Called when a + /// press starts one, when a release ends one, and when a window's input is + /// cancelled with a press still down. + public void reset() { + started = false; + counter = 0; + } +} diff --git a/CodenameOne/src/com/codename1/impl/WindowManager.java b/CodenameOne/src/com/codename1/impl/WindowManager.java new file mode 100644 index 00000000000..6251e4c50a6 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/WindowManager.java @@ -0,0 +1,577 @@ +/* + * 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; + +import com.codename1.ui.Command; +import com.codename1.ui.Image; + +/// The whole native windowing contract for a port, kept out of +/// `CodenameOneImplementation` so that adding desktop windows does not add several +/// dozen methods to an already very large class. +/// +/// A port that has a windowing system returns an instance from +/// `CodenameOneImplementation#getWindowManager()`; one that has none returns null. +/// That null **is** the capability query, so there is no separate supported flag +/// that could drift out of step with it. +/// +/// Windows are identified by two different handles. The `windowId` is an int chosen +/// by the framework and handed to `#createWindow` -- a port must store it and pass it +/// back on every event callback, because input arrives on the platform's own thread +/// where a map lookup would need locking. The peer is the opaque object the port +/// returns from `#createWindow`, and it is what every other method here takes. +/// +/// Unless a method says otherwise it is invoked on the Codename One event dispatch +/// thread, exactly like the single window methods it mirrors on +/// `CodenameOneImplementation`. A port that needs its own UI thread marshals +/// internally. +/// +/// Only the operations every windowing system provides are abstract. Everything a +/// platform might reasonably lack has an inert default, so a later addition here +/// never breaks an existing port. +/// +/// @author Shai Almog +public abstract class WindowManager { + + // ---- window lifecycle ----------------------------------------------------- + + /// Creates a native window without showing it. + /// + /// #### Parameters + /// + /// - `windowId`: framework assigned id, to be echoed back on every event + /// + /// - `title`: the initial window title + /// + /// - `x`: the initial x position in desktop coordinates + /// + /// - `y`: the initial y position in desktop coordinates + /// + /// - `width`: the initial width + /// + /// - `height`: the initial height + /// + /// - `decorated`: true for a normal titled and bordered window + /// + /// - `resizable`: true if the user may resize it + /// + /// - `parentPeer`: the owning window's peer, or null when the owner is the + /// application's main window or there is no owner at all -- see + /// `ownedByMainWindow` + /// + /// - `positionSet`: true when `x` and `y` are a position the application chose. + /// When false the platform places the window. A negative coordinate cannot + /// serve as the "unspecified" marker, because a monitor left of or above the + /// primary display has a negative origin and a window can legitimately be + /// restored onto it. + /// + /// - `ownedByMainWindow`: true when the owner is the application's main window, + /// which has no peer here. With `parentPeer` null this is what separates an + /// owned window from an unowned top level one. + /// + /// #### Returns + /// + /// the opaque peer identifying the new window + public abstract Object createWindow(int windowId, String title, int x, int y, + int width, int height, boolean decorated, boolean resizable, Object parentPeer, + boolean positionSet, boolean ownedByMainWindow); + + /// Maps the window onto the screen. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + public abstract void show(Object peer); + + /// Unmaps the window, leaving it able to be shown again. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + public abstract void hide(Object peer); + + /// Destroys the window and releases its native resources. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + public abstract void dispose(Object peer); + + // ---- window attributes ------------------------------------------------------ + + /// Sets the window title. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `title`: the title to display + public abstract void setTitle(Object peer, String title); + + /// Moves and resizes the window. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `x`: the x position in desktop coordinates + /// + /// - `y`: the y position in desktop coordinates + /// + /// - `width`: the new width + /// + /// - `height`: the new height + public abstract void setBounds(Object peer, int x, int y, int width, int height); + + /// Reads the window bounds, including any native chrome. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `out`: a four element array receiving x, y, width and height + /// + /// #### Returns + /// + /// the array that was passed in + public abstract int[] getBounds(Object peer, int[] out); + + /// Returns the width of the window's drawable area in device pixels. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// #### Returns + /// + /// the drawable width + public abstract int getWidth(Object peer); + + /// Returns the height of the window's drawable area in device pixels. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// #### Returns + /// + /// the drawable height + public abstract int getHeight(Object peer); + + /// Sets whether the user may resize the window. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `resizable`: true to allow resizing + public void setResizable(Object peer, boolean resizable) { + } + + /// Sets the smallest size the user may resize the window to, or clears the + /// constraint when either dimension is zero or less. + /// + /// A port that cannot express this leaves the default in place; the framework + /// additionally clamps a delivered resize, so the constraint holds either way. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `width`: the minimum width in Codename One pixels + /// + /// - `height`: the minimum height in Codename One pixels + public void setMinimumSize(Object peer, int width, int height) { + } + + /// Sets whether the platform draws the title bar and border. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `decorated`: true for native decorations + public void setDecorated(Object peer, boolean decorated) { + } + + /// Keeps the window above the other windows of the application. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `alwaysOnTop`: true to float the window + public void setAlwaysOnTop(Object peer, boolean alwaysOnTop) { + } + + /// Marks the window as a tool or palette window, which the platform keeps out of + /// the task bar and window switcher and typically gives lighter chrome. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `utility`: true for a utility window + public void setUtilityWindow(Object peer, boolean utility) { + } + + /// Applies the platform's own modality to the window. + /// + /// Codename One blocks input to the windows behind a modal window itself, so a + /// port that cannot do this stays correct. Implementing it still gives the user + /// the focus, dimming and taskbar behaviour the platform expects. + /// + /// The scope matters, because a port typically implements this by disabling + /// another window: an application modal blocks everything, while a window modal + /// blocks only the window that owns it, and disabling the main window for the + /// latter would make an unrelated part of the application unusable. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `modal`: true to make the window modal + /// + /// - `applicationWide`: true for `com.codename1.ui.Window#MODALITY_APPLICATION`, + /// false for `com.codename1.ui.Window#MODALITY_WINDOW` + /// + /// - `ownerPeer`: the peer of the window this one blocks, or null when it blocks + /// the application's main window or nothing at all + public void setModal(Object peer, boolean modal, boolean applicationWide, Object ownerPeer) { + } + + /// Rebuilds a window's native surface after the platform destroyed it without + /// asking, and reports whether that succeeded. + /// + /// Only needed where the platform's own close control cannot be disabled: Mac + /// Catalyst hands a scene disconnect over after the fact, so a window a modal is + /// blocking can be closed by the user even though the framework forbids it. Being + /// able to put it back is what keeps that from breaking the modality contract. + /// + /// A port that cannot do this returns false and the window is disposed instead, + /// which is honest -- the surface really is gone. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// #### Returns + /// + /// true if the native surface is being rebuilt + public boolean reopen(Object peer) { + return false; + } + + /// Enables or disables native input for one window. + /// + /// The framework calls this for every open window whenever the modal stack + /// changes, having already worked out which of them are blocked -- that answer + /// depends on the whole stack, on each window's modality scope and on who owns it, + /// so a port must not try to derive it from `#setModal`. + /// + /// Worth implementing even though the framework filters input itself, because a + /// blocked window's own title bar is outside that filter: its close button still + /// reaches the application. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `enabled`: false while the window is blocked by a modal window + public void setInputEnabled(Object peer, boolean enabled) { + } + + /// Enables or disables native input for the application's main window, which has + /// no peer. See `#setInputEnabled(Object, boolean)`. + /// + /// #### Parameters + /// + /// - `enabled`: false while the main window is blocked by a modal window + public void setMainWindowInputEnabled(boolean enabled) { + } + + /// Sets the window icon where the platform shows one. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `icon`: the icon to display + public void setIcon(Object peer, Image icon) { + } + + /// Raises the window and gives it keyboard focus. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + public void requestFocus(Object peer) { + } + + /// Minimizes the window. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + public void minimize(Object peer) { + } + + /// Restores a minimized window. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + public void restore(Object peer) { + } + + /// Toggles the window between maximized and its previous size. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + public void toggleMaximize(Object peer) { + } + + // ---- rendering ---------------------------------------------------------------- + + /// Returns the native graphics for this window's drawable. Called once per frame + /// on the event dispatch thread, mirroring + /// `CodenameOneImplementation#getNativeGraphics()`. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// #### Returns + /// + /// the native graphics object + public abstract Object getNativeGraphics(Object peer); + + /// Presents the given region of the window, mirroring + /// `CodenameOneImplementation#flushGraphics(int, int, int, int)`. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `x`: the region's x origin + /// + /// - `y`: the region's y origin + /// + /// - `width`: the region width + /// + /// - `height`: the region height + public abstract void flushGraphics(Object peer, int x, int y, int width, int height); + + /// Per window counterpart of + /// `CodenameOneImplementation#setPaintDirtyRegionClip(int, int, int, int)`, used + /// by the immediate mode ports to confine a component's clip to the region that + /// is about to be flushed. + /// + /// The default is inert rather than a delegation to the main surface version, so + /// that a port which has not opted in cannot clamp a window's clip against the + /// main window's state. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// - `x`: the region's x origin + /// + /// - `y`: the region's y origin + /// + /// - `width`: the region width + /// + /// - `height`: the region height + public void setPaintDirtyRegionClip(Object peer, int x, int y, int width, int height) { + } + + /// Captures the window's current contents. + /// + /// The ordinary screenshot path can only see the main surface, so the windowed + /// screenshot tests depend on this. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// #### Returns + /// + /// a native image of the window, or null when the port cannot capture one + public Object capture(Object peer) { + return null; + } + + /// Installs this window's commands into whatever command surface the platform + /// offers for a secondary window, typically a native menu bar on its own frame. + /// + /// A no-op by default. `com.codename1.ui.TopLevelContainer#addCommand` is shared + /// with `Form`, so a `Window` accepts commands everywhere; a port with nowhere to + /// put them simply does not show them, and an application can still activate them + /// through `com.codename1.ui.Window#dispatchCommand`. + /// + /// #### Parameters + /// + /// - `peer`: the window's native peer + /// + /// - `commands`: the window's commands in the order they were added, never null + public void setCommands(Object peer, Command[] commands) { + } + + // ---- monitors -------------------------------------------------------------------- + + /// Returns the number of monitors attached to the desktop. + /// + /// #### Returns + /// + /// the monitor count, at least one + public abstract int getMonitorCount(); + + /// Reads a monitor's full bounds in desktop coordinates. + /// + /// #### Parameters + /// + /// - `monitor`: the monitor offset + /// + /// - `out`: a four element array receiving x, y, width and height + /// + /// #### Returns + /// + /// the array that was passed in + public abstract int[] getMonitorBounds(int monitor, int[] out); + + /// Reads the part of a monitor that is usable by windows, which excludes the + /// task bar, dock and any reserved panels. + /// + /// #### Parameters + /// + /// - `monitor`: the monitor offset + /// + /// - `out`: a four element array receiving x, y, width and height + /// + /// #### Returns + /// + /// the array that was passed in + public abstract int[] getMonitorWorkArea(int monitor, int[] out); + + /// Returns the density bucket of a monitor, as one of the `Display` density + /// constants. + /// + /// #### Parameters + /// + /// - `monitor`: the monitor offset + /// + /// #### Returns + /// + /// the density constant + public abstract int getMonitorDensity(int monitor); + + /// Returns a monitor's backing scale, such as one for a standard display and two + /// for a high resolution one. + /// + /// #### Parameters + /// + /// - `monitor`: the monitor offset + /// + /// #### Returns + /// + /// the scale factor + public abstract double getMonitorScale(int monitor); + + /// Returns a monitor's dots per inch. + /// + /// #### Parameters + /// + /// - `monitor`: the monitor offset + /// + /// #### Returns + /// + /// the resolution in dots per inch + public abstract int getMonitorDotsPerInch(int monitor); + + /// Returns a human readable name for a monitor. + /// + /// #### Parameters + /// + /// - `monitor`: the monitor offset + /// + /// #### Returns + /// + /// the monitor name + public abstract String getMonitorName(int monitor); + + /// Returns the offset of the primary monitor. + /// + /// #### Returns + /// + /// the primary monitor offset + public abstract int getPrimaryMonitor(); + + /// Returns the offset of the monitor a window currently sits on. + /// + /// #### Parameters + /// + /// - `peer`: the window peer + /// + /// #### Returns + /// + /// the monitor offset + public abstract int getMonitorForWindow(Object peer); + + /// Returns the monitor the application's main window currently sits on. + /// + /// This is not answerable through `#getMonitorForWindow(Object)`, which takes a + /// secondary window's peer; the main window has none. Without it, asking for the + /// monitor of the main `Form` reported the primary monitor even when the + /// application had been dragged to a second display, so an application + /// positioning a window against the main form got the wrong work area, scale and + /// density. + /// + /// The default answers the primary monitor, which is correct for a port whose + /// main window cannot move between displays. + /// + /// #### Returns + /// + /// The application's main native window in desktop coordinates, or null when the + /// port cannot report it. + /// + /// A `Form` lives in that window, so centring a `Window` over a `Form` has to + /// centre over it. Without this the only thing available was the monitor work + /// area, which is a different place whenever the main window has been moved, + /// maximized or simply does not fill the screen. + /// + /// #### Parameters + /// + /// - `out`: a four element array to fill with x, y, width and height + /// + /// #### Returns + /// + /// `out` when the bounds were reported, or null when this port cannot + public int[] getMainWindowBounds(int[] out) { + return null; + } + + /// the monitor offset + public int getMonitorForMainWindow() { + return getPrimaryMonitor(); + } +} diff --git a/CodenameOne/src/com/codename1/io/services/ImageDownloadService.java b/CodenameOne/src/com/codename1/io/services/ImageDownloadService.java index d809441b868..7567e35b874 100644 --- a/CodenameOne/src/com/codename1/io/services/ImageDownloadService.java +++ b/CodenameOne/src/com/codename1/io/services/ImageDownloadService.java @@ -36,7 +36,6 @@ import com.codename1.ui.Component; import com.codename1.ui.Display; import com.codename1.ui.EncodedImage; -import com.codename1.ui.Form; import com.codename1.ui.Image; import com.codename1.ui.Label; import com.codename1.ui.List; @@ -671,9 +670,13 @@ public void run() { @Override public void run() { l.setIcon(i); - Form f = l.getComponentForm(); - if (f != null) { - f.revalidate(); + // The top level, not the form. This is the cache-hit return, which + // bypasses the download-completion path entirely, so it needs the + // same resolution: in a Window the icon changed and the layout + // stayed sized for the placeholder. + com.codename1.ui.TopLevelContainer top = l.getTopLevelContainer(); + if (top != null) { + top.asContainer().revalidate(); } } }); @@ -876,44 +879,35 @@ protected void postResponse() { final Image i = image; if (parentLabel != null) { final Dimension pref = parentLabel.getPreferredSize(); - if (parentLabel.getComponentForm() != null) { - Display.getInstance().callSerially(new Runnable() { - - @Override - public void run() { - if (isDownloadToStyles()) { - parentLabel.getUnselectedStyle().setBgImage(i); - parentLabel.getSelectedStyle().setBgImage(i); - parentLabel.getPressedStyle().setBgImage(i); - } else { - parentLabel.setIcon(i); - } - Dimension newPref = parentLabel.getPreferredSize(); - // if the preferred size changed we need to reflow the UI - // this might not be necessary if the label already had an identically - // sized image in place or has a hardcoded preferred size. - if (pref.getWidth() != newPref.getWidth() || pref.getHeight() != newPref.getHeight()) { - parentLabel.getComponentForm().revalidate(); - } - } - }); + // One branch rather than two. These used to differ only in whether the + // revalidate ran, chosen by whether the label was on a form -- so a label + // in a Window took the branch that applies the icon and never reflows, and + // the window stayed laid out for the placeholder's size. Resolving the top + // level covers both, and does nothing when the label is detached. + Display.getInstance().callSerially(new Runnable() { - } else { - Display.getInstance().callSerially(new Runnable() { - - @Override - public void run() { - if (isDownloadToStyles()) { - parentLabel.getUnselectedStyle().setBgImage(i); - parentLabel.getSelectedStyle().setBgImage(i); - parentLabel.getPressedStyle().setBgImage(i); - } else { - parentLabel.setIcon(i); + @Override + public void run() { + if (isDownloadToStyles()) { + parentLabel.getUnselectedStyle().setBgImage(i); + parentLabel.getSelectedStyle().setBgImage(i); + parentLabel.getPressedStyle().setBgImage(i); + } else { + parentLabel.setIcon(i); + } + Dimension newPref = parentLabel.getPreferredSize(); + // if the preferred size changed we need to reflow the UI + // this might not be necessary if the label already had an identically + // sized image in place or has a hardcoded preferred size. + if (pref.getWidth() != newPref.getWidth() || pref.getHeight() != newPref.getHeight()) { + com.codename1.ui.TopLevelContainer top = + parentLabel.getTopLevelContainer(); + if (top != null) { + top.asContainer().revalidate(); } - } - }); - } + } + }); parentLabel.repaint(); return; } else { diff --git a/CodenameOne/src/com/codename1/maps/MapComponent.java b/CodenameOne/src/com/codename1/maps/MapComponent.java index add7e954c42..da3d65720c1 100644 --- a/CodenameOne/src/com/codename1/maps/MapComponent.java +++ b/CodenameOne/src/com/codename1/maps/MapComponent.java @@ -422,7 +422,7 @@ public void run() { } }); - timer.schedule(doubleTapThreshold, false, this.getComponentForm()); + timer.schedule(doubleTapThreshold, false, this.getTopLevelContainer()); } else { tapCount = 0; } @@ -481,7 +481,12 @@ public void run() { super.repaint(); } else { // workaround for rounding error in scale/clipping - getComponentForm().repaint(); + // The top level rather than the form: getComponentForm() is null by + // design inside a Window, so panning the map threw. + com.codename1.ui.TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + top.asContainer().repaint(); + } } fireMapListenerEvent(); return; diff --git a/CodenameOne/src/com/codename1/maps/NativeMap.java b/CodenameOne/src/com/codename1/maps/NativeMap.java index 00ab0ac691a..fe342c273b2 100644 --- a/CodenameOne/src/com/codename1/maps/NativeMap.java +++ b/CodenameOne/src/com/codename1/maps/NativeMap.java @@ -32,10 +32,10 @@ import com.codename1.ui.Component; import com.codename1.ui.Container; import com.codename1.ui.EncodedImage; -import com.codename1.ui.Form; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.geom.Point; import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.TopLevelContainer; import java.util.ArrayList; import java.util.HashMap; @@ -190,9 +190,9 @@ LatLng getInitialCenter() { } private void revalidateForm() { - Form form = getComponentForm(); + TopLevelContainer form = getTopLevelContainer(); if (form != null) { - form.revalidate(); + form.asContainer().revalidate(); } } diff --git a/CodenameOne/src/com/codename1/ui/AnimationManager.java b/CodenameOne/src/com/codename1/ui/AnimationManager.java index 3ed16e0ea84..ff38495fb0b 100644 --- a/CodenameOne/src/com/codename1/ui/AnimationManager.java +++ b/CodenameOne/src/com/codename1/ui/AnimationManager.java @@ -36,12 +36,12 @@ /// /// @author Shai Almog public final class AnimationManager { - private final Form parentForm; + private final TopLevelContainer parentForm; private final ArrayList anims = new ArrayList(); private final ArrayList postAnimations = new ArrayList(); private final ArrayList uiMutations = new ArrayList(); - AnimationManager(Form parentForm) { + AnimationManager(TopLevelContainer parentForm) { this.parentForm = parentForm; } @@ -220,7 +220,7 @@ public void scrollChanged(int scrollX, int scrollY, int oldscrollX, int oldscrol } } if (changed) { - parentForm.revalidate(); + parentForm.asContainer().revalidate(); } } recursion = false; diff --git a/CodenameOne/src/com/codename1/ui/AutoCompleteTextField.java b/CodenameOne/src/com/codename1/ui/AutoCompleteTextField.java index 48764f081a3..102e72aff4d 100644 --- a/CodenameOne/src/com/codename1/ui/AutoCompleteTextField.java +++ b/CodenameOne/src/com/codename1/ui/AutoCompleteTextField.java @@ -167,9 +167,9 @@ public void actionPerformed(ActionEvent e) { if (popup.isVisible()) { popup.setVisible(false); popup.setEnabled(false); - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { - f.revalidateLater(); + f.asContainer().revalidateLater(); } } } @@ -187,8 +187,14 @@ public AutoCompleteTextField() { @Override protected void initComponent() { super.initComponent(); - getComponentForm().addPointerPressedListener(pressListener); - getComponentForm().addPointerReleasedListener(listener); + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so the very first show() of a window containing an + // autocomplete field threw here before anything else could run. + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + top.asContainer().addPointerPressedListener(pressListener); + top.asContainer().addPointerReleasedListener(listener); + } Display.getInstance().callSerially(new Runnable() { @Override @@ -202,8 +208,11 @@ public void run() { @Override protected void deinitialize() { super.deinitialize(); - getComponentForm().removePointerPressedListener(pressListener); - getComponentForm().removePointerReleasedListener(listener); + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + top.asContainer().removePointerPressedListener(pressListener); + top.asContainer().removePointerReleasedListener(listener); + } Display.getInstance().callSerially(new Runnable() { @Override @@ -246,7 +255,7 @@ private void setTextImpl(String text, boolean forceUpdate) { } } pickedText = null; - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null && filterImpl(text)) { updateFilterList(); } @@ -254,7 +263,7 @@ private void setTextImpl(String text, boolean forceUpdate) { /// In a case of an asynchronous filter this method can be invoked to refresh the completion list protected void updateFilterList() { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); boolean v = filter.getSize() > 0 && getText().length() >= minimumLength; if (v != popup.isVisible()) { if (popup.getComponentCount() > 0) { @@ -266,7 +275,7 @@ protected void updateFilterList() { } popup.setVisible(v); popup.setEnabled(v); - f.revalidate(); + f.asContainer().revalidate(); } if (v && popup.getComponentCount() > 0) { int popupHeight = calcPopupHeight((List) popup.getComponentAt(0)); @@ -276,11 +285,11 @@ protected void updateFilterList() { dontCalcSize = true; } if (f != null) { - f.revalidate(); + f.asContainer().revalidate(); } if (f != null) { dontCalcSize = false; - f.revalidate(); + f.asContainer().revalidate(); dontCalcSize = true; } @@ -316,7 +325,7 @@ private boolean filterImpl(String text) { popup.setVisible(v); popup.setEnabled(v); } - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (popup.getComponentCount() > 0) { int popupHeight = calcPopupHeight((List) popup.getComponentAt(0)); @@ -326,7 +335,7 @@ private boolean filterImpl(String text) { dontCalcSize = true; } if (f != null) { - f.revalidate(); + f.asContainer().revalidate(); } } return res; @@ -377,9 +386,11 @@ public void keyReleased(int k) { } private void removePopup() { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f == null && popup != null) { - f = popup.getComponentForm(); + // The popup outlives the field's own attachment during teardown, so it is + // asked next. Its top level, not its form, for the same reason. + f = popup.getTopLevelContainer(); } if (f != null) { Container lay = f.getLayeredPane(getClass(), true); @@ -387,7 +398,7 @@ private void removePopup() { if (parent != null) { lay.removeComponent(parent); popup.remove(); - f.revalidateLater(); + f.asContainer().revalidateLater(); } } @@ -416,7 +427,7 @@ public void removeListListener(ActionListener a) { } private void addPopup(boolean updateFilter) { - final Form f = getComponentForm(); + final TopLevelContainer f = getTopLevelContainer(); popup.removeAll(); popup.setVisible(false); popup.setEnabled(false); @@ -452,7 +463,7 @@ public void actionPerformed(ActionEvent evt) { } popup.setVisible(false); popup.setEnabled(false); - f.revalidate(); + f.asContainer().revalidate(); } } }); @@ -465,7 +476,7 @@ public void actionPerformed(ActionEvent evt) { } int leftMargin = isRTL() ? - Math.max(0, f.getWidth() - getAbsoluteX() - getWidth()) : + Math.max(0, f.asContainer().getWidth() - getAbsoluteX() - getWidth()) : Math.max(0, getAbsoluteX()); popup.getAllStyles().setMargin(LEFT, leftMargin); @@ -489,7 +500,7 @@ public void actionPerformed(ActionEvent evt) { wrapper.add(popup); lay.addComponent(wrapper); } - f.revalidate(); + f.asContainer().revalidate(); } } @@ -550,7 +561,7 @@ private int calcPopupHeight(List l) { int topMargin; int popupHeight; int items = l.getModel().getSize(); - final Form f = getComponentForm(); + final TopLevelContainer f = getTopLevelContainer(); if (f == null) { // for some reason this happens in the GUI builder return 10; @@ -560,12 +571,12 @@ private int calcPopupHeight(List l) { } int listHeight = items * l.getElementSize(false, true).getHeight(); if (popupPosition == POPUP_POSITION_UNDER || popupPosition == POPUP_POSITION_AUTO && y < f.getContentPane().getHeight() / 2) { - topMargin = y - f.getTitleArea().getHeight() + getHeight(); + topMargin = y - titleAreaHeight(f) + getHeight(); popupHeight = Math.min(listHeight, f.getContentPane().getHeight() / 2); } else { popupHeight = Math.min(listHeight, f.getContentPane().getHeight() / 2); - popupHeight = Math.min(popupHeight, y - f.getTitleArea().getHeight()); - topMargin = y - f.getTitleArea().getHeight() - popupHeight; + popupHeight = Math.min(popupHeight, y - titleAreaHeight(f)); + topMargin = y - titleAreaHeight(f) - popupHeight; } popup.getAllStyles().setMargin(TOP, Math.max(0, topMargin)); popup.setPreferredH(popupHeight); @@ -663,7 +674,7 @@ class FormPointerPressListener implements ActionListener { @Override public void actionPerformed(ActionEvent evt) { pressInBounds = false; - final Form f = getComponentForm(); + final TopLevelContainer f = getTopLevelContainer(); Container layered = f.getLayeredPane(AutoCompleteTextField.this.getClass(), true); for (int i = 0; i < layered.getComponentCount(); i++) { @@ -682,7 +693,7 @@ class FormPointerListener implements ActionListener { @Override public void actionPerformed(final ActionEvent evt) { - final Form f = getComponentForm(); + final TopLevelContainer f = getTopLevelContainer(); Container layered = f.getLayeredPane(AutoCompleteTextField.this.getClass(), true); boolean canOpenPopup = shouldShowPopup(); @@ -694,7 +705,7 @@ public void actionPerformed(final ActionEvent evt) { if (!pressInBounds && !pop.contains(evt.getX(), evt.getY())) { pop.setVisible(false); pop.setEnabled(false); - f.revalidateLater(); + f.asContainer().revalidateLater(); evt.consume(); } else { canOpenPopup = false; @@ -725,7 +736,7 @@ public void actionPerformed(final ActionEvent evt) { popup.setEnabled(true); popup.revalidate(); dontCalcSize = false; - f.revalidate(); + f.asContainer().revalidate(); dontCalcSize = true; Display.getInstance().callSerially(new Runnable() { @@ -737,4 +748,10 @@ public void run() { } } } + /// The height of the top level's title area, which only a Form has: a window's + /// title is drawn by the platform outside the content, so it takes no space here. + private static int titleAreaHeight(TopLevelContainer f) { + return f == null ? 0 : f.asContainer().titleAreaHeight(); + } + } diff --git a/CodenameOne/src/com/codename1/ui/Button.java b/CodenameOne/src/com/codename1/ui/Button.java index 2481962fb72..fbe8b27cef6 100644 --- a/CodenameOne/src/com/codename1/ui/Button.java +++ b/CodenameOne/src/com/codename1/ui/Button.java @@ -100,6 +100,9 @@ public class Button extends Label implements ReleasableComponent, ActionSource 0); non-null while fading. private Motion releaseFadeMotion; + + /// The top level the release fade was registered on. + private TopLevelContainer releaseFadeHost; /// Snapshot of the pressed background, faded out over the settled background. private Image releaseFadeImage; /// A listener used to bind the state with another button. When that button's state @@ -556,12 +559,7 @@ void checkAnimation() { if ((pressedIcon != null && pressedIcon.isAnimation()) || (rolloverIcon != null && rolloverIcon.isAnimation()) || (disabledIcon != null && disabledIcon.isAnimation())) { - Form parent = getComponentForm(); - if (parent != null) { - // animations are always running so the internal animation isn't - // good enough. We never want to stop this sort of animation - parent.registerAnimated(this); - } + registerForAnimation(); } } @@ -721,9 +719,14 @@ protected void fireActionEvent(int x, int y) { ActionEvent ev = new ActionEvent(cmd, this, x, y); dispatcher.fireActionEvent(ev); if (!ev.isConsumed()) { - Form f = getComponentForm(); - if (f != null) { - f.actionCommandImplNoRecurseComponent(cmd, ev); + // The top level rather than the form: getComponentForm() is null by + // design inside a Window, so a command-backed button there fired its + // own listeners and then told nobody -- the window's command listeners + // never saw the activation. Neither path re-invokes the command, which + // this method has already run. + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + top.asContainer().commandActivatedFromComponent(cmd, ev); } } } else { @@ -837,10 +840,14 @@ public void pointerPressed(int x, int y) { pointerPressedListeners.fireActionEvent(new ActionEvent(this, ActionEvent.Type.PointerPressed, x, y)); } pressed(); - Form f = getComponentForm(); + // The top level, not the Form: getComponentForm() is null inside a Window, so + // registering through it left the window's awaiting-release list empty and a + // press dragged out of the button was never cancelled -- releasing outside it + // still fired the action. + TopLevelContainer t = getTopLevelContainer(); // might happen when programmatically triggering press - if (f != null) { - f.addComponentAwaitingRelease(this); + if (t != null) { + t.addComponentAwaitingRelease(this); } } @@ -854,10 +861,10 @@ public void pointerReleased(int x, int y) { return; } } - Form f = getComponentForm(); + TopLevelContainer t = getTopLevelContainer(); // might happen when programmatically triggering press - if (f != null) { - f.removeComponentAwaitingRelease(this); + if (t != null) { + t.removeComponentAwaitingRelease(this); } // button shouldn't fire an event when a pointer is dragged into it @@ -1060,9 +1067,9 @@ public boolean animate() { if (releaseFadeMotion.isFinished()) { releaseFadeMotion = null; releaseFadeImage = null; - Form f = getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); + if (releaseFadeHost != null) { + releaseFadeHost.deregisterAnimated(this); + releaseFadeHost = null; } } a = true; @@ -1109,8 +1116,12 @@ private void startReleaseFade() { releaseFadeImage = img; releaseFadeMotion = Motion.createEaseOutMotion(255, 0, releaseFadeDuration); releaseFadeMotion.start(); - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { + // Remembered so the fade comes off the top level that took it. A button + // removed or reparented before the fade ends resolves to null or somewhere + // else, and the original keeps the animation for good. + releaseFadeHost = f; f.registerAnimated(this); } } diff --git a/CodenameOne/src/com/codename1/ui/CN.java b/CodenameOne/src/com/codename1/ui/CN.java index 799fbfa0460..9952ec61516 100644 --- a/CodenameOne/src/com/codename1/ui/CN.java +++ b/CodenameOne/src/com/codename1/ui/CN.java @@ -855,6 +855,21 @@ public static boolean isDesktop() { return Display.impl.isDesktop(); } + /// Indicates whether this platform can open desktop windows, so an application + /// can offer them where they exist and stay on one surface where they do not. + /// + /// Shorthand for `Desktop#isSupported()`, kept here because this is where an + /// application already asks what the platform can do. Constructing a + /// `com.codename1.ui.Window` on a platform that answers false throws rather than + /// quietly degrading to a `Form`, so this is the guard to branch on. + /// + /// #### Returns + /// + /// true if this platform supports `com.codename1.ui.Window` + public static boolean isMultiWindowSupported() { + return Desktop.isSupported(); + } + /// Indicates whether the application is running on a smartwatch form factor /// (Apple Watch / Wear OS). Notice that this is often a guess derived from /// the device metadata. diff --git a/CodenameOne/src/com/codename1/ui/Calendar.java b/CodenameOne/src/com/codename1/ui/Calendar.java index 6e1d14cf073..2997390ee59 100644 --- a/CodenameOne/src/com/codename1/ui/Calendar.java +++ b/CodenameOne/src/com/codename1/ui/Calendar.java @@ -1372,7 +1372,12 @@ public void actionPerformed(ActionEvent evt) { selected = components[iter]; } fireActionEvent(); - if (!getComponentForm().isSingleFocusMode()) { + // The top level rather than the form, and null tolerated: + // getComponentForm() is null by design inside a Window, so + // every ordinary day selection updated the date, fired its + // listeners and then threw. + TopLevelContainer top = getTopLevelContainer(); + if (top == null || !top.isSingleFocusMode()) { setHandlesInput(false); } revalidate(); diff --git a/CodenameOne/src/com/codename1/ui/CommonProgressAnimations.java b/CodenameOne/src/com/codename1/ui/CommonProgressAnimations.java index 6967e03876a..63f776bdbbe 100644 --- a/CodenameOne/src/com/codename1/ui/CommonProgressAnimations.java +++ b/CodenameOne/src/com/codename1/ui/CommonProgressAnimations.java @@ -141,12 +141,24 @@ public static ProgressAnimation getProgressAnimation(Component cmp) { @Override protected void initComponent() { super.initComponent(); - getComponentForm().registerAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.registerAnimated(this); + } } @Override protected void deinitialize() { - getComponentForm().deregisterAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.deregisterAnimated(this); + } super.deinitialize(); } } diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index 59f963821e6..1a868b5d773 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -2172,7 +2172,7 @@ public boolean containsOrOwns(int x, int y) { if (contains(x, y)) { return true; } - Form f = getComponentForm(); + Container f = TopLevelSupport.rootOf(this); if (f != null) { Component cmp = f.getComponentAt(x, y); if (cmp.isOwnedBy(this)) { @@ -3376,13 +3376,13 @@ void paintGlassImpl(Graphics g) { /// /// The height of the area under the virtual keyboard in pixels private int getInvisibleAreaUnderVKB() { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { - int invisibleAreaUnderVKB = Form.getInvisibleAreaUnderVKB(f); + int invisibleAreaUnderVKB = f.getInvisibleAreaUnderVKB(); if (invisibleAreaUnderVKB == 0) { return 0; } - int bottomGap = f.getHeight() - getAbsoluteY() - getScrollY() - getHeight(); + int bottomGap = f.asContainer().getHeight() - getAbsoluteY() - getScrollY() - getHeight(); if (bottomGap < invisibleAreaUnderVKB) { return invisibleAreaUnderVKB - bottomGap; } else { @@ -4331,6 +4331,30 @@ public Form getComponentForm() { return retVal; } + /// Returns the top level container this component currently belongs to, which is + /// either the `Form` filling the main surface or the `Window` of a native desktop + /// window, or null when this component is not attached to one. + /// + /// Prefer this over `#getComponentForm()` in code that must keep working inside a + /// desktop `Window`. `getComponentForm()` keeps its original meaning and returns + /// null for a component hosted in a `Window`, because a `Window` is not a `Form`. + /// + /// #### Returns + /// + /// the enclosing top level container, or null when detached + /// + /// #### See also + /// + /// - #getComponentForm() + public TopLevelContainer getTopLevelContainer() { + TopLevelContainer retVal = null; + Component parent = getParent(); + if (parent != null) { + retVal = parent.getTopLevelContainer(); + } + return retVal; + } + /// Repaint the given component to the screen /// /// #### Parameters @@ -4481,8 +4505,40 @@ private void setAnimationMotion(Motion motion) { /// #### Returns /// /// the animation manager instance + /// The top level this component is currently registered with for animation. + /// + /// Kept so that deregistering goes back to the same place registering went. That + /// used to be each caller's problem, and it was a recurring defect: the component + /// registered against the top level it was in, then something moved or detached it, + /// and deregistering resolved a *different* top level -- or none -- so the old one + /// went on animating a component that had left it. It was patched separately in a + /// dozen classes; holding the answer here fixes the shape rather than the instances. + private TopLevelContainer animationRegisteredWith; + + /// Registers this component to be animated by the top level it currently sits in. + /// Named apart from registerAnimatedInternal, which is the separate internal + /// animation registry Container keeps. + protected void registerForAnimation() { + TopLevelContainer f = getTopLevelContainer(); + if (f != null) { + animationRegisteredWith = f; + f.registerAnimated(this); + } + } + + /// Stops this component being animated, by the top level it registered with rather + /// than whichever one it can resolve now. + protected void deregisterFromAnimation() { + TopLevelContainer f = animationRegisteredWith != null + ? animationRegisteredWith : getTopLevelContainer(); + animationRegisteredWith = null; + if (f != null) { + f.deregisterAnimated(this); + } + } + public AnimationManager getAnimationManager() { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f == null) { return null; } @@ -5375,7 +5431,7 @@ public void drop(Component dragged, int x, int y) { /// /// a component drop target or null if no drop target is available at that coordinate private Component findDropTarget(Component source, int x, int y) { - Form f = getComponentForm(); + Container f = TopLevelSupport.rootOf(this); if (f != null) { Component c = f.findDropTargetAt(x, y); while (c != null) { @@ -5430,8 +5486,9 @@ public boolean respondsToPointerEvents() { } private boolean pointerReleaseMaterialPullToRefresh() { - if (refreshTask != null && InfiniteProgress.isDefaultMaterialDesignMode()) { - Container c = getComponentForm().getLayeredPane(InfiniteProgress.class, true); + TopLevelContainer top = getTopLevelContainer(); + if (refreshTask != null && top != null && InfiniteProgress.isDefaultMaterialDesignMode()) { + Container c = top.getLayeredPane(InfiniteProgress.class, true); if (c.getComponentCount() > 0) { Component cc = c.getComponentAt(0); if (cc instanceof InfiniteProgress) { @@ -5468,7 +5525,7 @@ public void run() { return false; } - private boolean updateMaterialPullToRefresh(final Form p, int y) { + private boolean updateMaterialPullToRefresh(final TopLevelContainer p, int y) { if (refreshTask != null && InfiniteProgress.isDefaultMaterialDesignMode() && pullY < getHeight() / 4 && scrollableYFlag() && getScrollY() == 0) { @@ -5500,11 +5557,12 @@ pullY < getHeight() / 4 && refreshLabel.putClientProperty("cn1$opacityMotion", opacityMotion); refreshLabel.putClientProperty("cn1$rotationMotion", rotationMotion); c.add(refreshLabel); - p.addPointerReleasedListener(new ActionListener() { + final Container pc = p.asContainer(); + pc.addPointerReleasedListener(new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { pointerReleaseMaterialPullToRefresh(); - p.removePointerReleasedListener(this); + pc.removePointerReleasedListener(this); evt.consume(); } }); @@ -5541,7 +5599,7 @@ public void actionPerformed(ActionEvent evt) { /// /// - `y`: the pointer y coordinate public void pointerDragged(final int x, final int y) { - Form f = getComponentForm(); + Container f = TopLevelSupport.rootOf(this); if (f != null) { pointerDragged(x, y, f.getCurrentPointerPress()); } else { @@ -5568,7 +5626,7 @@ private void pointerDragged(final int x, final int y, final Object currentPointe /// the pointer is pressed, a new Object is generated, and is passed to pointerDragged. /// This is to help prevent infinite loops of pointerDragged after a pointer press has been released. private void pointerDragged(final Component lead, final int x, final int y, final Object currentPointerPress) { - Form p = getComponentForm(); + Container p = TopLevelSupport.rootOf(this); if (p == null) { return; } @@ -5688,7 +5746,7 @@ public void run() { } if (!dragActivated) { - boolean draggedOnX = Math.abs(p.initialPressX - x) > Math.abs(p.initialPressY - y); + boolean draggedOnX = Math.abs(p.getInitialPressX() - x) > Math.abs(p.getInitialPressY() - y); shouldGrabScrollEvents = (isScrollableX() && draggedOnX) || isScrollableY() && !draggedOnX; } @@ -5809,9 +5867,12 @@ public void run() { lastScrollY = y; lastScrollX = x; } else { - //try to find a scrollable element until you reach the Form + //try to find a scrollable element until you reach the top level Component parent = getParent(); - if (!(parent instanceof Form)) { + // Any top level, not just a Form: a Window dispatches drags to the pressed + // component itself, so bubbling past one would come straight back here and + // recurse until the stack ran out. + if (parent != null && !(parent instanceof TopLevelContainer)) { parent.pointerDragged(x, y); } } @@ -5832,7 +5893,7 @@ private void initScrollMotion() { // the component might not be registered for animation if it started off // as smaller than the screen and grew (e.g. by adding components to the container // once it is visible). - Form f = getComponentForm(); + Container f = TopLevelSupport.rootOf(this); if (f != null) { f.registerAnimatedInternal(this); } @@ -6316,7 +6377,7 @@ void startTensile(int offset, int dest, boolean vertical) { draggedMotionX = draggedMotion; } // just to be sure, there are some cases where this doesn't work as expected - Form p = getComponentForm(); + Container p = TopLevelSupport.rootOf(this); if (p != null) { p.registerAnimatedInternal(this); } @@ -6326,8 +6387,11 @@ private boolean chooseScrollXOrY(int x, int y) { boolean ix = isScrollableX(); boolean iy = isScrollableY(); if (ix && iy) { - Form parent = getComponentForm(); - return Math.abs(parent.initialPressX - x) > Math.abs(parent.initialPressY - y); + Container parent = TopLevelSupport.rootOf(this); + if (parent == null) { + return ix; + } + return Math.abs(parent.getInitialPressX() - x) > Math.abs(parent.getInitialPressY() - y); } return ix; } @@ -6402,7 +6466,7 @@ void dragFinishedImpl(int x, int y) { private void dragFinishedImpl(Component lead, int x, int y) { if (dragAndDropInitialized && dragActivated) { - Form p = getComponentForm(); + Container p = TopLevelSupport.rootOf(this); if (p == null) { //The component was removed from the form during the drag dragActivated = false; @@ -6447,7 +6511,7 @@ private void dragFinishedImpl(Component lead, int x, int y) { dropTargetComponent = null; } if (getUIManager().getLookAndFeel().isFadeScrollBar() && isScrollable()) { - Form frm = getComponentForm(); + Container frm = TopLevelSupport.rootOf(this); if (frm != null) { frm.registerAnimatedInternal(this); } @@ -7209,7 +7273,7 @@ protected void installDefaultPainter(Style s) { /// Changes the current component to the focused component, will work only /// for a component that belongs to a parent form. public void requestFocus() { - Form rootForm = getComponentForm(); + Container rootForm = TopLevelSupport.rootOf(this); if (rootForm != null) { Component.setDisableSmoothScrolling(true); rootForm.requestFocus(this); @@ -7359,23 +7423,15 @@ void setDragActivated(boolean dragActivated) { void checkAnimation() { Image bgImage = getStyle().getBgImage(); if (bgImage != null && bgImage.isAnimation()) { - Form pf = getComponentForm(); - if (pf != null) { - // animations are always running so the internal animation isn't - // good enough. We never want to stop this sort of animation - pf.registerAnimated(this); - } + registerForAnimation(); } else { Painter p = getStyle().getBgPainter(); if (p != null && p.getClass() != BGPainter.class && p instanceof Animation) { - Form pf = getComponentForm(); - if (pf != null) { - pf.registerAnimated(this); - } + registerForAnimation(); } else { if (scrollOpacity == 0xff && isScrollable() && getUIManager().getLookAndFeel().isFadeScrollBar()) { // trigger initial fade process on a fresh view. - Form pf = getComponentForm(); + Container pf = TopLevelSupport.rootOf(this); if (pf != null) { pf.registerAnimatedInternal(this); } @@ -7388,7 +7444,7 @@ void deregisterAnimatedInternal() { if (!internalRegisteredAnimated) { return; } - Form f = getComponentForm(); + Container f = TopLevelSupport.rootOf(this); if (f != null) { f.deregisterAnimatedInternal(this); } @@ -7892,13 +7948,27 @@ void initComponentImpl() { } showNativeOverlay(); if (refreshTask != null && InfiniteProgress.isDefaultMaterialDesignMode()) { - final Form p = getComponentForm(); + // The top level rather than the Form: a component inside a Window has + // no Form, and this ran listener methods on the result immediately, so + // showing such a window threw. + final TopLevelContainer p = getTopLevelContainer(); if (refreshTaskDragListener == null) { refreshTaskDragListener = new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { if (evt.getEventType() == ActionEvent.Type.PointerDrag) { - if (updateMaterialPullToRefresh(p, evt.getY() - getAbsoluteY())) { + // Resolved when the drag happens rather than captured + // when the listener was built. The listener is created + // once and kept for the life of the component, while + // the component can be moved to another top level -- it + // is then re-registered on the new one while still + // holding the old, so the overlay went up on the top + // level the component had left and the release arriving + // on the new one found nothing to finish. The refresh + // task simply never ran. + TopLevelContainer host = getTopLevelContainer(); + if (host != null && updateMaterialPullToRefresh(host, + evt.getY() - getAbsoluteY())) { evt.consume(); } } else { @@ -7907,8 +7977,10 @@ public void actionPerformed(ActionEvent evt) { } }; } - p.addPointerDraggedListener(refreshTaskDragListener); - p.addPointerPressedListener(refreshTaskDragListener); + if (p != null) { + p.asContainer().addPointerDraggedListener(refreshTaskDragListener); + p.asContainer().addPointerPressedListener(refreshTaskDragListener); + } } } } @@ -7948,9 +8020,11 @@ void deinitializeImpl() { } deinitialize(); if (refreshTaskDragListener != null) { - Form f = getComponentForm(); - f.removePointerDraggedListener(refreshTaskDragListener); - f.removePointerPressedListener(refreshTaskDragListener); + Container f = TopLevelSupport.rootOf(this); + if (f != null) { + f.removePointerDraggedListener(refreshTaskDragListener); + f.removePointerPressedListener(refreshTaskDragListener); + } } } } @@ -8134,7 +8208,7 @@ public void styleChanged(String propertyName, Style source) { Style.PADDING.equals(propertyName))) { setShouldCalcPreferredSize(true); Container parent = getParent(); - if (parent != null && parent.getComponentForm() != null) { + if (parent != null && parent.getTopLevelContainer() != null) { if (isRevalidateOnStyleChange()) { parent.revalidateLater(); } @@ -8711,8 +8785,13 @@ public void growShrink(int duration) { hMotion.start(); setPreferredSize(new Dimension(getWidth(), getHeight())); // we are using bgpainter just to save the cost of creating another class - getComponentForm().registerAnimated(new BGPainter(wMotion, hMotion)); - getComponentForm().revalidate(); + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + BGPainter growth = new BGPainter(wMotion, hMotion); + growth.animationHost = top; + top.registerAnimated(growth); + top.asContainer().revalidate(); + } } /// Enable the tensile drag to work even when a component doesn't have a scroll showable (scrollable flag still needs to be set to true) @@ -9236,6 +9315,14 @@ public BGPainter(Motion wMotion, Motion hMotion) { impl = Display.impl; } + /// The top level this painter was registered on as an animation, so it comes + /// off that one rather than off whatever the component resolves to when the + /// motion ends. A component removed or reparented in between resolves to null + /// or somewhere else, and the original keeps the animation for good -- its + /// hasAnimations() stays true, so the event dispatch thread never sleeps and + /// this branch runs on every frame. + private TopLevelContainer animationHost; + public BGPainter() { impl = Display.impl; } @@ -9423,14 +9510,22 @@ public void paint(Graphics g, Rectangle rect) { @Override public boolean animate() { + TopLevelContainer top = getTopLevelContainer(); if (wMotion.isFinished() && hMotion.isFinished()) { - getComponentForm().deregisterAnimated(this); + if (animationHost != null) { + animationHost.deregisterAnimated(this); + animationHost = null; + } setPreferredSize(null); - getComponentForm().revalidate(); + if (top != null) { + top.asContainer().revalidate(); + } return false; } setPreferredSize(new Dimension(wMotion.getValue(), hMotion.getValue())); - getComponentForm().revalidate(); + if (top != null) { + top.asContainer().revalidate(); + } return false; } diff --git a/CodenameOne/src/com/codename1/ui/Container.java b/CodenameOne/src/com/codename1/ui/Container.java index 3f9430a3c23..565a17d32bf 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -24,6 +24,8 @@ package com.codename1.ui; import com.codename1.impl.CodenameOneImplementation; +import com.codename1.ui.animations.Animation; +import com.codename1.ui.events.ActionEvent; import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.animations.ComponentAnimation; import com.codename1.ui.animations.Motion; @@ -1146,7 +1148,7 @@ private ComponentAnimation replaceComponents(final Component current, final Comp if (!contains(current)) { throw new IllegalArgumentException("Component " + current + " is not contained in this Container"); } - if (t == null || !isVisible() || getComponentForm() == null) { + if (t == null || !isVisible() || getTopLevelContainer() == null) { next.setX(current.getX()); next.setY(current.getY()); next.setWidth(current.getWidth()); @@ -1219,7 +1221,10 @@ private boolean requestFocusChild(boolean avoidRepaint) { Component c = getComponentAt(iter); if (c.isFocusable()) { if (avoidRepaint) { - getComponentForm().setFocusedInternal(c); + Container top = TopLevelSupport.rootOf(this); + if (top != null) { + top.setFocusedInternal(c); + } } else { c.requestFocus(); } @@ -1250,8 +1255,9 @@ private void cancelRepaintsRecursively(Component c) { void replace(final Component current, final Component next, boolean avoidRepaint) { int index = components.indexOf(current); boolean currentFocused = false; - if (current.getComponentForm() != null) { - Component currentF = current.getComponentForm().getFocused(); + Container currentTop = TopLevelSupport.rootOf(current); + if (currentTop != null) { + Component currentF = currentTop.getFocused(); currentFocused = currentF == current; //NOPMD CompareObjectsWithEquals if (!currentFocused && current instanceof Container && currentF != null && ((Container) current).isParentOf(currentF)) { currentFocused = true; @@ -1273,7 +1279,10 @@ void replace(final Component current, final Component next, boolean avoidRepaint if (currentFocused) { if (next.isFocusable()) { if (avoidRepaint) { - getComponentForm().setFocusedInternal(next); + Container top = TopLevelSupport.rootOf(this); + if (top != null) { + top.setFocusedInternal(next); + } } else { next.requestFocus(); } @@ -1407,7 +1416,7 @@ public void flush() { /// /// - `cmp`: the removed component void removeComponentImplNoAnimationSafety(Component cmp) { - Form parentForm = getComponentForm(); + Container parentForm = TopLevelSupport.rootOf(this); layout.removeLayoutComponent(cmp); // the deinitizlize contract expects the component to be in a container but if this is a part of an animation @@ -1480,7 +1489,7 @@ public void flushReplace() { /// such an issue. Notice that this method doesn't recurse and only removes from /// the current container. public void removeAll() { - Form parentForm = getComponentForm(); + TopLevelContainer parentForm = getTopLevelContainer(); if (parentForm != null) { Component focus = parentForm.getFocused(); if (focus != null && contains(focus)) { @@ -1581,11 +1590,11 @@ public void revalidate() { /// - `fromRoot` void revalidateInternal(boolean fromRoot) { setShouldCalcPreferredSize(true); - Form root = getComponentForm(); + Container root = TopLevelSupport.rootOf(this); if (root != null && root != this) { //NOPMD CompareObjectsWithEquals root.removeFromRevalidateQueue(this); - if (fromRoot && root.revalidateFromRoot) { + if (fromRoot && root.isRevalidateFromRoot()) { root.layoutContainer(); root.repaint(); @@ -1614,13 +1623,354 @@ void revalidateInternal(boolean fromRoot) { /// of containers that require revalidation, so that the system doesn't end up /// revalidating the same container multiple times between paints. public void revalidateLater() { - Form root = getComponentForm(); + Container root = TopLevelSupport.rootOf(this); if (root != null) { root.revalidateLater(this); } } + // --------------------------------------------------------------------------- + // Top level hooks. + // + // These are the internals that Component, Container and Toolbar need to drive + // whichever top level they sit in -- a Form on the main surface, or a Window on + // the desktop. They live here rather than on an interface because they must stay + // package private: every method of a Java interface is implicitly public, so + // putting them on one would silently widen Form's public API. + // + // Container is the nearest common supertype of Form and Window, so declaring + // them here dispatches virtually with no instanceof. The defaults are inert; + // Form and Window override the ones that mean something to them. + // --------------------------------------------------------------------------- + + /// Registers an animation not exposed through the public animation registry. + /// Inert unless this container is a top level. + /// + /// #### Parameters + /// + /// - `cmp`: the animation to register + void registerAnimatedInternal(Animation cmp) { + } + + /// Removes an internally registered animation. Inert unless this container is a + /// top level. + /// + /// #### Parameters + /// + /// - `cmp`: the animation to remove + void deregisterAnimatedInternal(Animation cmp) { + } + + /// Moves focus without the side effects of the public setter. Inert unless this + /// container is a top level. + /// + /// #### Parameters + /// + /// - `focused`: the new focus owner + void setFocusedInternal(Component focused) { + } + + /// Returns the component owning focus within this top level. Overridden as a + /// public method by the top levels themselves. + /// + /// #### Returns + /// + /// the focus owner, or null unless this container is a top level + Component getFocused() { + return null; + } + + /// Indicates whether revalidating any container should lay out the whole top + /// level rather than only that container. + /// + /// #### Returns + /// + /// true to revalidate from the root + boolean isRevalidateFromRoot() { + return false; + } + + /// Returns the component below the focus owner in traversal order. + /// + /// #### Returns + /// + /// the next component down, or null unless this container is a top level + Component findNextFocusDown() { + return null; + } + + /// Returns the component above the focus owner in traversal order. + /// + /// #### Returns + /// + /// the next component up, or null unless this container is a top level + Component findNextFocusUp() { + return null; + } + + /// Returns the component right of the focus owner in traversal order. + /// + /// #### Returns + /// + /// the next component right, or null unless this container is a top level + Component findNextFocusRight() { + return null; + } + + /// Returns the component left of the focus owner in traversal order. + /// + /// #### Returns + /// + /// the next component left, or null unless this container is a top level + Component findNextFocusLeft() { + return null; + } + + /// Reacts to the surface this top level occupies changing size. Inert unless + /// this container is a top level. + /// + /// #### Parameters + /// + /// - `w`: the new width + /// + /// - `h`: the new height + void sizeChangedInternal(int w, int h) { + } + + /// Invoked when this top level stops being visible. Inert unless this container + /// is a top level. + void hideNotify() { + } + + /// Invoked when this top level becomes visible. Inert unless this container is a + /// top level. + void showNotify() { + } + + /// Indicates that this top level wants a pointer release even when the matching + /// press went somewhere else. + /// + /// #### Returns + /// + /// false unless a top level opts in + boolean shouldSendPointerReleaseToOtherForm() { + return false; + } + + /// Requests focus for a component. Inert unless this container is a top level. + /// + /// #### Parameters + /// + /// - `cmp`: the component requesting focus + void requestFocus(Component cmp) { + } + + /// Returns the container that hit testing and focus traversal treat as the root. + /// + /// #### Returns + /// + /// this container, unless a top level overrides it + Container getActualPane() { + return this; + } + + /// Adds a component to a top level's own layout, outside the content pane. Inert + /// unless this container is a top level, which is what keeps the structural add + /// off the public `TopLevelContainer` interface -- widening it would hand every + /// caller a way to place components beside the content pane. + /// + /// #### Parameters + /// + /// - `constraints`: the layout constraint + /// + /// - `cmp`: the component to add + void addComponentToTopLevel(Object constraints, Component cmp) { + } + + /// The counterpart to `#addComponentToTopLevel(java.lang.Object, Component)`. + /// Inert unless this container is a top level. + /// + /// #### Parameters + /// + /// - `cmp`: the component to remove + void removeComponentFromTopLevel(Component cmp) { + } + + /// Whether this container is a top level backed by its own native operating + /// system window. Inert unless this container is a `Window`. + /// + /// #### Returns + /// + /// true for a native window, false for everything else + boolean isNativeWindow() { + return false; + } + + /// Whether this top level is the one currently on screen. Inert unless this + /// container is a top level. + /// + /// #### Returns + /// + /// true when this top level is showing + boolean isTopLevelShowing() { + return false; + } + + /// The native peer this top level draws into. Inert unless this container is a + /// `Window`; the main surface has no peer of its own. + /// + /// #### Returns + /// + /// the native window peer, or null + Object topLevelNativePeer() { + return null; + } + + /// The height the top level's title takes out of its own content. A `Form` draws + /// its title inside the content; a window's title belongs to the platform chrome + /// outside it, so it costs nothing here. + /// + /// #### Returns + /// + /// the title area height, or zero when the title is not drawn in content + int titleAreaHeight() { + return 0; + } + + /// Whether this top level is holding a pointer press over the given component, + /// which is what decides if selection still paints. Each top level answers from + /// its own press coordinates: those are top level relative, so another one's + /// pointer position is not merely the wrong point but a point in a different + /// space. + /// + /// #### Parameters + /// + /// - `c`: the component to test + /// + /// #### Returns + /// + /// true if a live press of this top level falls inside the component + boolean showsSelectionFor(Component c) { + return false; + } + + /// Tells the top level that a command was activated from a list. The command has + /// already run; this is only the notification, so no implementation may invoke it + /// again. + /// + /// #### Parameters + /// + /// - `cmd`: the command that was activated + /// + /// - `ev`: the event that activated it + void commandActivatedFromList(Command cmd, ActionEvent ev) { + } + + /// Tells the top level that a command was activated from a component such as a + /// button. As with the list form, the command has already run. + /// + /// #### Parameters + /// + /// - `cmd`: the command that was activated + /// + /// - `ev`: the event that activated it + void commandActivatedFromComponent(Command cmd, ActionEvent ev) { + } + + /// Restores the command a text field displaced while it was being edited. Inert + /// unless this container is a top level with a soft button bar to restore it to. + /// + /// #### Parameters + /// + /// - `cmd`: the command to restore, may be null + void setClearCommandInternal(Command cmd) { + } + + /// Whether this top level should lay a popup out as though it were portrait. A + /// `Form` inherits the device orientation it was given; a window has none, so its + /// own shape is all there is to go on. + /// + /// #### Parameters + /// + /// - `deviceBias`: what the device orientation says + /// + /// #### Returns + /// + /// true to use the portrait placement + boolean prefersPortraitLayout(boolean deviceBias) { + return deviceBias; + } + + /// Schedules a container to be revalidated before the next paint. Inert unless + /// this container is a top level. + /// + /// #### Parameters + /// + /// - `cnt`: the container to revalidate later + void revalidateLater(Container cnt) { + } + + /// Drops a container from the pending revalidate queue. Inert unless this + /// container is a top level. + /// + /// #### Parameters + /// + /// - `cnt`: the container to drop + void removeFromRevalidateQueue(Container cnt) { + } + + /// Revalidates everything queued by `#revalidateLater(Container)`. Inert unless + /// this container is a top level. + void flushRevalidateQueue() { + } + + /// Returns the token identifying the current pointer press, used to stop a drag + /// from outliving the press that started it. + /// + /// #### Returns + /// + /// the press token, or null unless this container is a top level + Object getCurrentPointerPress() { + return null; + } + + /// Returns the x coordinate at which the current press began. + /// + /// #### Returns + /// + /// the initial press x, or zero unless this container is a top level + int getInitialPressX() { + return 0; + } + + /// Returns the y coordinate at which the current press began. + /// + /// #### Returns + /// + /// the initial press y, or zero unless this container is a top level + int getInitialPressY() { + return 0; + } + + /// Returns the component currently being dragged. + /// + /// #### Returns + /// + /// the dragged component, or null unless this container is a top level + Component getDraggedComponent() { + return null; + } + + /// Sets the component currently being dragged. Inert unless this container is a + /// top level. + /// + /// #### Parameters + /// + /// - `dragged`: the dragged component, or null to clear it + void setDraggedComponent(Component dragged) { + } + /// A more powerful form of revalidate that recursively lays out the full hierarchy public void forceRevalidate() { forceRevalidateImpl(); @@ -2403,11 +2753,31 @@ private boolean snapToSafeAreaInternal() { if (safeAreaRoot == null) { return false; } - Rectangle rect = Display.impl.getDisplaySafeArea(new Rectangle()); + Rectangle rect; + int surfaceWidth; + int surfaceHeight; + TopLevelContainer top = getTopLevelContainer(); + if (top != null && top.asContainer().isNativeWindow()) { + // The enclosing window's safe area and size, not the main surface's. A + // desktop window has no notch or rounded corner, so this yields zero + // margins and no snapping -- where reading the display's insets applied + // the main surface's notch to every secondary window on a device that has + // one. Copied rather than used directly, so the arithmetic below cannot + // write into whatever the top level handed back. + Rectangle windowSafe = top.getSafeArea(); + rect = new Rectangle(windowSafe.getX(), windowSafe.getY(), + windowSafe.getWidth(), windowSafe.getHeight()); + surfaceWidth = top.asContainer().getWidth(); + surfaceHeight = top.asContainer().getHeight(); + } else { + rect = Display.impl.getDisplaySafeArea(new Rectangle()); + surfaceWidth = CN.getDisplayWidth(); + surfaceHeight = CN.getDisplayHeight(); + } int safeLeftMargin = rect.getX(); - int safeRightMargin = CN.getDisplayWidth() - rect.getWidth() - rect.getX(); + int safeRightMargin = surfaceWidth - rect.getWidth() - rect.getX(); int safeTopMargin = rect.getY(); - int safeBottomMargin = CN.getDisplayHeight() - rect.getHeight() - rect.getY(); + int safeBottomMargin = surfaceHeight - rect.getHeight() - rect.getY(); if (safeLeftMargin == 0 && safeRightMargin == 0 && safeBottomMargin == 0 && safeTopMargin == 0) { return false; } @@ -2638,9 +3008,9 @@ public void scrollComponentToVisible(final Component c) { if (c.getParent() != null) { // special case for the first component to allow the user to scroll all the // way to the top - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null && f.getInvisibleAreaUnderVKB() == 0 && - f.findFirstFocusable() == c) { //NOPMD CompareObjectsWithEquals + f.asContainer().findFirstFocusable() == c) { //NOPMD CompareObjectsWithEquals // support this use case only if the component doesn't explicitly declare visible bounds if (r == c.getBounds() && !Display.getInstance().isTouchScreenDevice()) { //NOPMD CompareObjectsWithEquals scrollRectToVisible(new Rectangle(0, 0, @@ -2696,7 +3066,10 @@ public void scrollComponentToVisible(final Component c) { boolean moveScrollTowards(int direction, Component next) { if (isScrollable()) { Component current = null; - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); + if (f == null) { + return false; + } current = f.getFocused(); boolean cyclic = f.isCyclicFocus(); @@ -2716,7 +3089,7 @@ boolean moveScrollTowards(int direction, Component next) { return true; } y = getScrollY() - scrollIncrement; - edge = f.findNextFocusUp() == null; + edge = f.asContainer().findNextFocusUp() == null; currentLarge = (current != null && current.getVisibleBounds().getSize().getHeight() > getHeight()); scrollOutOfBounds = y < 0; if (scrollOutOfBounds) { @@ -2725,7 +3098,7 @@ boolean moveScrollTowards(int direction, Component next) { break; case Display.GAME_DOWN: y = getScrollY() + scrollIncrement; - edge = f.findNextFocusDown() == null; + edge = f.asContainer().findNextFocusDown() == null; currentLarge = (current != null && current.getVisibleBounds().getSize().getHeight() > getHeight()); scrollOutOfBounds = y > getScrollDimension().getHeight() - getHeight(); if (scrollOutOfBounds) { @@ -2734,7 +3107,7 @@ boolean moveScrollTowards(int direction, Component next) { break; case Display.GAME_RIGHT: x = getScrollX() + scrollIncrement; - edge = f.findNextFocusRight() == null; + edge = f.asContainer().findNextFocusRight() == null; currentLarge = (current != null && current.getVisibleBounds().getSize().getWidth() > getWidth()); scrollOutOfBounds = x > getScrollDimension().getWidth() - getWidth(); if (scrollOutOfBounds) { @@ -2743,7 +3116,7 @@ boolean moveScrollTowards(int direction, Component next) { break; case Display.GAME_LEFT: x = getScrollX() - scrollIncrement; - edge = f.findNextFocusLeft() == null; + edge = f.asContainer().findNextFocusLeft() == null; currentLarge = (current != null && current.getVisibleBounds().getSize().getWidth() > getWidth()); scrollOutOfBounds = x < 0; if (scrollOutOfBounds) { @@ -3253,7 +3626,7 @@ public void setScrollableX(boolean scrollableX) { /// {@inheritDoc} @Override public boolean isScrollableY() { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); int v = 0; if (f != null) { v = f.getInvisibleAreaUnderVKB(); @@ -3764,7 +4137,10 @@ public void drop(Component dragged, int x, int y) { } else { addComponent(dragged); } - getComponentForm().animateHierarchy(400); + Container dropRoot = TopLevelSupport.rootOf(this); + if (dropRoot != null) { + dropRoot.animateHierarchy(400); + } } } @@ -4094,7 +4470,7 @@ private ComponentAnimation animateUnlayout(final int duration, boolean wait, int /// - `duration`: the duration in milliseconds for the animation private ComponentAnimation animateLayout(final int duration, boolean wait, int opacity, boolean addAnimation) { // this happens for some reason - Form f = getComponentForm(); + Container f = TopLevelSupport.rootOf(this); if (f == null) { return null; } @@ -4374,7 +4750,7 @@ static class TransitionAnimation extends ComponentAnimation { private final Container thisContainer; private final Component current; private final Component next; - private final Form parent; + private final Container parent; int growSpeed; int layoutAnimationSpeed; private boolean started = false; @@ -4386,7 +4762,7 @@ static class TransitionAnimation extends ComponentAnimation { this.next = next; this.current = current; this.thisContainer = thisContainer; - this.parent = thisContainer.getComponentForm(); + this.parent = TopLevelSupport.rootOf(thisContainer); } @Override @@ -4539,7 +4915,7 @@ protected void updateState() { if (AnimationTime.now() - startTime >= duration) { setEnableLayoutOnPaint(true); thisContainer.dontRecurseContainer = false; - Form f = thisContainer.getComponentForm(); + Container f = TopLevelSupport.rootOf(thisContainer); finished = true; if (f == null) { return; diff --git a/CodenameOne/src/com/codename1/ui/Desktop.java b/CodenameOne/src/com/codename1/ui/Desktop.java new file mode 100644 index 00000000000..42b783ae08d --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/Desktop.java @@ -0,0 +1,1358 @@ +/* + * 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.ui; + +import com.codename1.impl.WindowManager; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.util.EventDispatcher; + +import java.util.ArrayList; +import java.util.Hashtable; + +/// The desktop a windowed application runs on: the monitors attached to it and the +/// `Window` instances open on them. +/// +/// This sits alongside `Display` rather than replacing any of it. `Display` answers +/// "how big is the application's main surface", which is the only question a phone +/// has; `Desktop` answers "what screens exist and what windows are open", which only +/// a windowing system can answer. +/// +/// Every method degrades safely on a platform with no windowing system: +/// `#getWindows()` returns an empty array, `#getMonitors()` returns a single monitor +/// describing the main display, and `#getFocusedWindow()` returns null. Only +/// constructing a `Window` throws. +/// +/// @author Shai Almog +public final class Desktop { + + private static final Desktop INSTANCE = new Desktop(); + + /// Dots per inch of the medium-density baseline, used when there is no + /// implementation to ask. + private static final int MEDIUM_DENSITY_DPI = 160; + private static final int[] BOUNDS_SCRATCH = new int[4]; + + private final ArrayList windows = new ArrayList(); + private final EventDispatcher monitorListeners = new EventDispatcher(); + private final EventDispatcher windowListeners = new EventDispatcher(); + private Window focusedWindow; + private int nextWindowId = 1; + + private Desktop() { + } + + /// Returns the singleton instance. + /// + /// #### Returns + /// + /// the desktop instance + public static Desktop getInstance() { + return INSTANCE; + } + + /// Indicates whether this platform has a windowing system, and therefore whether + /// `Window` can be used at all. + /// + /// #### Returns + /// + /// true if additional native windows can be opened + public static boolean isSupported() { + return Display.impl != null && Display.impl.getWindowManager() != null; + } + + private static WindowManager manager() { + if (Display.impl == null) { + return null; + } + return Display.impl.getWindowManager(); + } + + // ---- monitors -------------------------------------------------------------- + + /// Returns every monitor attached to the desktop. + /// + /// On a platform with no windowing system this reports a single monitor covering + /// the main display, so layout code that positions against a monitor works + /// everywhere. + /// + /// #### Returns + /// + /// the monitors, never empty and never null + public Monitor[] getMonitors() { + WindowManager wm = manager(); + if (wm == null) { + return new Monitor[]{fallbackMonitor()}; + } + int count = wm.getMonitorCount(); + if (count <= 0) { + return new Monitor[]{fallbackMonitor()}; + } + int primary = wm.getPrimaryMonitor(); + Monitor[] out = new Monitor[count]; + for (int iter = 0; iter < count; iter++) { + out[iter] = readMonitor(wm, iter, primary); + } + return out; + } + + /// Returns the monitor the platform treats as the origin of the desktop. + /// + /// #### Returns + /// + /// the primary monitor + public Monitor getPrimaryMonitor() { + WindowManager wm = manager(); + if (wm == null) { + return fallbackMonitor(); + } + return readMonitor(wm, wm.getPrimaryMonitor(), wm.getPrimaryMonitor()); + } + + /// Returns the monitor containing the given desktop coordinate. + /// + /// #### Parameters + /// + /// - `x`: the x coordinate in desktop space + /// + /// - `y`: the y coordinate in desktop space + /// + /// #### Returns + /// + /// the monitor containing that point, or the primary monitor when none does + public Monitor getMonitorAt(int x, int y) { + Monitor[] all = getMonitors(); + for (Monitor m : all) { + if (m.getBounds().contains(x, y)) { + return m; + } + } + return getPrimaryMonitor(); + } + + /// Returns the monitor a top level is currently displayed on. + /// + /// #### Parameters + /// + /// - `topLevel`: the form or window to locate + /// + /// #### Returns + /// + /// the monitor it sits on, or the primary monitor when that cannot be determined + public Monitor getMonitorFor(TopLevelContainer topLevel) { + WindowManager wm = manager(); + if (wm != null) { + Container c = topLevel == null ? null : topLevel.asContainer(); + Object peer = c == null ? null : c.topLevelNativePeer(); + if (peer != null) { + return readMonitor(wm, wm.getMonitorForWindow(peer), wm.getPrimaryMonitor()); + } + // A window that has not been shown yet has no peer and so no monitor; it + // falls through to the primary rather than borrowing the main window's. + if (c != null && !c.isNativeWindow()) { + // A Form lives in the application's main window, which has no peer to + // ask about. Reporting the primary monitor was wrong as soon as the + // application had been dragged to a second display: everything + // positioned against the main form got another monitor's work area, + // scale and density. + return readMonitor(wm, wm.getMonitorForMainWindow(), wm.getPrimaryMonitor()); + } + } + return getPrimaryMonitor(); + } + + /// Returns the union of every monitor's bounds. + /// + /// #### Returns + /// + /// the whole desktop area + public Rectangle getDesktopBounds() { + Monitor[] all = getMonitors(); + Rectangle out = all[0].getBounds(); + for (int iter = 1; iter < all.length; iter++) { + Rectangle b = all[iter].getBounds(); + int x = Math.min(out.getX(), b.getX()); + int y = Math.min(out.getY(), b.getY()); + int right = Math.max(out.getX() + out.getWidth(), b.getX() + b.getWidth()); + int bottom = Math.max(out.getY() + out.getHeight(), b.getY() + b.getHeight()); + out = new Rectangle(x, y, right - x, bottom - y); + } + return out; + } + + private Monitor readMonitor(WindowManager wm, int index, int primary) { + int[] b; + int[] w; + synchronized (BOUNDS_SCRATCH) { + b = copyOf(wm.getMonitorBounds(index, BOUNDS_SCRATCH)); + w = copyOf(wm.getMonitorWorkArea(index, BOUNDS_SCRATCH)); + } + return new Monitor(index, + new Rectangle(b[0], b[1], b[2], b[3]), + new Rectangle(w[0], w[1], w[2], w[3]), + wm.getMonitorDensity(index), + wm.getMonitorScale(index), + wm.getMonitorDotsPerInch(index), + wm.getMonitorName(index), + index == primary); + } + + private static int[] copyOf(int[] src) { + if (src == null) { + return new int[4]; + } + return new int[]{src[0], src[1], src[2], src[3]}; + } + + private Monitor fallbackMonitor() { + // Every value here has to survive Display having no implementation yet. This + // method is the documented answer during startup, so guarding the size and then + // asking the same missing implementation for the density and the dots per inch + // -- which is what getDeviceDensity() and convertToPixels() do -- threw exactly + // when the fallback was supposed to be returned. + boolean uninitialized = Display.impl == null; + int w = uninitialized ? 0 : Display.impl.getDisplayWidth(); + int h = uninitialized ? 0 : Display.impl.getDisplayHeight(); + Rectangle r = new Rectangle(0, 0, w, h); + int density = uninitialized + ? Display.DENSITY_MEDIUM : Display.getInstance().getDeviceDensity(); + int dotsPerInch = uninitialized + ? MEDIUM_DENSITY_DPI : Display.getInstance().convertToPixels(254, true) / 10; + return new Monitor(0, r, new Rectangle(r), density, 1.0, dotsPerInch, "main", true); + } + + // ---- windows ---------------------------------------------------------------- + + /// Returns every window currently open, not counting the application's main form. + /// + /// #### Returns + /// + /// the open windows, empty when there are none or the platform has no windows + public Window[] getWindows() { + synchronized (windows) { + return windows.toArray(new Window[windows.size()]); + } + } + + /// Returns the window that currently holds keyboard focus. + /// + /// #### Returns + /// + /// the focused window, or null when the main form has focus or none is open + public Window getFocusedWindow() { + return focusedWindow; + } + + /// Adds a listener notified when a monitor is attached, removed or reconfigured. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public void addMonitorListener(ActionListener l) { + monitorListeners.addListener(l); + // Touching the manager is what makes a port start watching for display + // changes; without it a listener registered before anything else looked at + // the windowing system would never hear about one. + // + // Registering a listener before Codename One is initialized is a reasonable + // thing to do -- it is the point at which an application knows it wants to hear + // about monitors -- and it used to throw. Guarding alone was not enough either: + // the ports start watching when their window manager is first created, so a + // listener registered early and never followed by anything that touches the + // desktop would simply never hear about a change. Display.init() calls + // startMonitorWatchingIfListening() once an implementation exists. + if (Display.impl != null) { + Display.impl.getWindowManager(); + } + } + + /// Starts the port watching for display changes if anything is listening for them. + /// + /// Called from `Display#init(java.lang.Object)`, because a listener registered + /// before there was an implementation could not start the watch itself. + static void startMonitorWatchingIfListening() { + if (Display.impl != null && INSTANCE.monitorListeners.hasListeners()) { + Display.impl.getWindowManager(); + } + } + + /// Removes a previously added monitor listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public void removeMonitorListener(ActionListener l) { + monitorListeners.removeListener(l); + } + + /// Adds a listener notified when any window is shown, hidden, moved or resized. + /// + /// This is the multi-window counterpart of + /// `Display#addWindowListener(com.codename1.ui.events.ActionListener)`, which + /// continues to report only the application's main window. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public void addWindowListener(ActionListener l) { + windowListeners.addListener(l); + } + + /// Removes a previously added window listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public void removeWindowListener(ActionListener l) { + windowListeners.removeListener(l); + } + + // ---- framework internals ------------------------------------------------------- + + /// Hands out the next window id. + /// + /// Synchronized on the same monitor as the registry. A window is constructed on + /// whatever thread calls `new Window(...)` -- a constructor cannot be marshalled, + /// since it has to return the object -- so two background threads creating + /// windows at once could take the same id from an unsynchronized post-increment. + /// Both native windows would then answer to one id, and `#windowById(int)` + /// returns the first match, so every input and lifecycle callback meant for the + /// second would have been delivered to the first. + /// + /// #### Returns + /// + /// an id no other window has been given + int nextWindowId() { + synchronized (windows) { + return nextWindowId++; + } + } + + void registerWindow(Window w) { + synchronized (windows) { + if (!windows.contains(w)) { + windows.add(w); + } + } + } + + /// Returns the open windows owned by the given top level. Used by + /// `Window#dispose()`, which cannot outlive its children -- the platform would + /// leave them open with nothing behind them. + Window[] windowsOwnedBy(TopLevelContainer owner) { + ArrayList owned = new ArrayList(); + synchronized (windows) { + int len = windows.size(); + for (int iter = 0; iter < len; iter++) { + Window w = windows.get(iter); + if (w.getOwnerWindow() == owner) { //NOPMD CompareObjectsWithEquals + owned.add(w); + } + } + } + return owned.toArray(new Window[owned.size()]); + } + + void deregisterWindow(Window w) { + synchronized (windows) { + windows.remove(w); + } + if (focusedWindow == w) { //NOPMD CompareObjectsWithEquals + focusedWindow = null; + } + } + + void setFocusedWindow(Window w) { + focusedWindow = w; + } + + /// Returns the window carrying the given framework assigned id, which is how an + /// event that arrived off the event dispatch thread is routed back to its tree. + /// + /// #### Parameters + /// + /// - `windowId`: the id from `Window#getWindowId()` + /// + /// #### Returns + /// + /// the matching window, or null when none is open with that id + public Window windowById(int windowId) { + synchronized (windows) { + int len = windows.size(); + for (int iter = 0; iter < len; iter++) { + Window w = windows.get(iter); + if (w.getWindowId() == windowId) { + return w; + } + } + } + return null; + } + + boolean hasOpenWindows() { + synchronized (windows) { + return !windows.isEmpty(); + } + } + + boolean hasVisibleWindows() { + synchronized (windows) { + int len = windows.size(); + for (int iter = 0; iter < len; iter++) { + if (windows.get(iter).isWindowShowing()) { + return true; + } + } + } + return false; + } + + /// Snapshot used by the paint pass. Callers iterate by index because a nested + /// event loop can dispose a window mid iteration. + ArrayList windowList() { + return windows; + } + + void fireMonitorChanged() { + monitorListeners.fireActionEvent(new ActionEvent(this)); + } + + void fireWindowEvent(ActionEvent evt) { + windowListeners.fireActionEvent(evt); + } + + /// Disposes every open window. Invoked as the application shuts down so a window + /// cannot outlive the event dispatch thread that paints it. + void disposeAll() { + Window[] all = getWindows(); + for (Window w : all) { + w.dispose(); + } + } + // ---- modality ---------------------------------------------------------------- + // + // Modality lives here rather than in Display because it is a question about + // windows, and the window registry is here. Display asks whether input for a + // window is blocked; it does not need to know what a modal is. + + private final ArrayList modalWindows = new ArrayList(); + + /// Drops a disposed window's modal registration and re-syncs what the ports + /// block, so a window that went away stops blocking. + void forgetModal(Window w) { + modalWindows.remove(w); + syncNativeModalBlocking(); + } + + void pushModalWindow(Window w) { + modalWindows.add(w); + syncNativeModalBlocking(); + } + + void popModalWindow(Window w) { + modalWindows.remove(w); + syncNativeModalBlocking(); + } + + /// Tells every native window whether input to it is currently blocked. + /// + /// The framework already decides this, in `#isBlockedByModal(int)`, and it is the + /// only place that can: the answer depends on the whole modal stack, on each + /// window's scope and on who owns it. A port that tried to derive it from a single + /// "this window became modal" call has to reinvent nesting and ownership, and gets + /// them wrong -- releasing an inner modal re-enabled everything the outer one was + /// still blocking, application modality left the other secondary windows enabled, + /// and an unowned window modal disabled a main window it never claimed. + /// + /// This matters beyond appearances, because a blocked window's own title bar is + /// outside the framework's input filter: its close button still reaches the + /// application. + void syncNativeModalBlocking() { + WindowManager wm = Display.impl.getWindowManager(); + if (wm == null) { + return; + } + wm.setMainWindowInputEnabled(!isBlockedByModal(0)); + for (Window each : getWindows()) { + Object peer = each.getNativePeer(); + if (peer != null) { + wm.setInputEnabled(peer, !isBlockedByModal(each.getWindowId())); + } + } + } + + /// Whether input aimed at the given window is currently blocked by a modal. + /// + /// Public because the implementation needs it: a wheel gesture is played as four + /// steps queued on the event dispatch thread, and a listener can show a modal + /// between the first check and the last step. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// #### Returns + /// + /// true when input to that window is currently blocked + public boolean isWindowInputBlocked(int windowId) { + return isBlockedByModal(windowId); + } + + private boolean isBlockedByModal(int windowId) { + // Every registered blocker is consulted rather than only the newest one. + // Modal windows nest: a window modal opened from inside an application modal + // blocks only its own owner, and stopping at the top of the stack would let + // input back into the main form and every unrelated window for as long as the + // narrower one was up. + Window self = windowId > 0 ? windowById(windowId) : null; + int len = modalWindows.size(); + for (int iter = len - 1; iter >= 0; iter--) { + Window modal = modalWindows.get(iter); + if (modal.getWindowId() == windowId) { + // Never blocked by itself -- but keep looking at the outer blockers. + // Returning here exempted a modal window from *every* other modal, + // so an unrelated modal shown while an application modal was up + // accepted input that application modality is meant to stop. + continue; + } + if (self != null && ownedBy(self, modal)) { + // A modal opened from inside another is not blocked by the one it was + // opened from. That is the exemption the self check was reaching for, + // and it applies to the owner chain rather than to any modal at all. + continue; + } + if (isModalOutOfReach(modal)) { + // Hidden along with an owner the application hid. The registration is + // kept on purpose -- hideNotify() cannot tell an owner cascade from a + // minimize, and a minimized modal is still open and still modal -- but a + // modal nobody can see or dismiss must not go on blocking. Left in, the + // owner's hide froze the main surface and every unrelated window with no + // window on screen to release them, until the owner was shown again. + continue; + } + if (blocks(modal, windowId)) { + return true; + } + } + return false; + } + + /// Whether `w` sits inside `candidateOwner`'s ownership chain. + private static boolean ownedBy(Window w, Window candidateOwner) { + TopLevelContainer owner = w.getOwnerWindow(); + while (owner instanceof Window) { + if (owner == candidateOwner) { //NOPMD CompareObjectsWithEquals + return true; + } + owner = ((Window) owner).getOwnerWindow(); + } + return false; + } + + /// Whether one modal window blocks input to the window with the given id. + /// Whether a registered modal is currently unreachable because an owner above it + /// is off screen. + /// + /// Only the owner chain is consulted, never the modal's own visibility: a modal the + /// user minimized is still open and still blocks, which is what keeps a minimized + /// modal from quietly releasing the application. An owner that is not showing is a + /// different situation -- its children went with it and cannot be restored + /// independently, so nothing on screen can dismiss them. + /// + /// #### Parameters + /// + /// - `modal`: the registered modal window + /// + /// #### Returns + /// + /// true when an owner of this modal is not showing + private static boolean isModalOutOfReach(Window modal) { + TopLevelContainer owner = modal.getOwnerWindow(); + while (owner instanceof Window) { + Window w = (Window) owner; + if (!w.isWindowShowing()) { + return true; + } + owner = w.getOwnerWindow(); + } + return false; + } + + private boolean blocks(Window modal, int windowId) { + if (modal.getModalityType() == Window.MODALITY_APPLICATION) { + return true; + } + // window modal: only the owner is blocked + TopLevelContainer owner = modal.getOwnerWindow(); + if (owner instanceof Window) { + return ((Window) owner).getWindowId() == windowId; + } + if (owner != null) { + // owned by the main form + return windowId == 0; + } + // No owner at all. Window modality blocks the owning window, and there is + // none, so it blocks nothing -- treating this as main-form ownership would + // block the main form on a window that never claimed it. + return false; + } + + // ---- window lifecycle -------------------------------------------------------- + // + // The platform reports a window shown, hidden, moved, resized, focused or closed, + // and these turn that into framework state. They live here rather than in Display + // because every one of them is about a window, and the registry that resolves an + // id to a window is here. + + private final Hashtable pendingWindowSizes = new Hashtable(); + + /// Guards `#monitorsChangedPending`, which is set from the port's native event + /// thread and cleared on the event dispatch thread. + private final Object monitorsChangedLock = new Object(); + + /// Whether a monitor-topology notification is already queued. See + /// `#monitorsChanged()`. + private boolean monitorsChangedPending; + + + /// Notifies Codename One that a native window became visible. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + public void windowShowNotify(int windowId) { + if (windowId > 0) { + // Deliberately not the packed input queue. That queue drops events when it + // is full and while invokeAndBlock is running in drop mode, and nothing + // reconciles a lost one afterwards: a dropped show leaves a visible window + // the framework believes is iconified and never paints again, and a + // dropped hide leaves a hidden window painting and keeping the event + // dispatch thread awake. Lifecycle notifications are not droppable. + Display.getInstance().callSerially(new WindowCallback(windowId, WindowCallback.SHOWN)); + } + } + + /// Notifies Codename One that a native window stopped being visible. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + public void windowHideNotify(int windowId) { + if (windowId > 0) { + // See windowShowNotify: not droppable. + Display.getInstance().callSerially(new WindowCallback(windowId, WindowCallback.HIDDEN)); + } + } + + /// Notifies Codename One that a native window gained or lost keyboard focus. + /// Marshalled onto the event dispatch thread, since it runs application code. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `gained`: true when the window gained focus + public void windowFocusChanged(int windowId, boolean gained) { + Display.getInstance().callSerially(new WindowCallback(windowId, + gained ? WindowCallback.FOCUS_GAINED : WindowCallback.FOCUS_LOST)); + } + + /// Notifies Codename One that the user activated a native window's close control. + /// Marshalled onto the event dispatch thread, since it runs application code and + /// may dispose the window. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + public void windowCloseRequested(int windowId) { + // Queued first, and the modality check made on the event dispatch thread inside + // the callback. The modal stack is mutated there by show, hide and dispose, so + // testing it from the port's callback thread raced: isBlockedByModal takes the + // stack's size and then indexes it, which a concurrent removal turns into an + // exception, and a stale read could let a blocked window's close through. + Display.getInstance().callSerially(new WindowCallback(windowId, WindowCallback.CLOSE_REQUESTED)); + } + + /// Notifies Codename One that the platform has already destroyed a window's + /// native surface, so the window is gone whatever the application would prefer. + /// + /// Distinct from `#windowCloseRequested(int)`, which asks. Some platforms do not + /// offer the close control as a question: a Mac Catalyst scene is disconnected + /// after the fact, with nothing left to veto. Reporting that as a request would + /// let `DO_NOTHING_ON_CLOSE` leave a registered window painting into a surface + /// that no longer exists, so it is reported as what it is and the window is + /// disposed. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + public void windowClosedNatively(int windowId) { + Display.getInstance().callSerially(new WindowCallback(windowId, WindowCallback.CLOSED_NATIVELY)); + } + + /// Notifies Codename One that the platform refused to create a window's native + /// surface, so the window will never appear. + /// + /// Separate from `#windowHideNotify(int)` because that one means "minimized", + /// which keeps a modal window's registration: a modal that never appeared would + /// otherwise block input to every other window while `showModal()` waited for it. + /// + /// #### Parameters + /// + /// - `windowId`: the window whose native surface could not be created + public void windowActivationFailed(int windowId) { + if (windowId > 0) { + WindowCallback failure = new WindowCallback(windowId, + WindowCallback.ACTIVATION_FAILED); + if (Display.getInstance().isEdt()) { + // Applied in the caller's own turn when it is already on the event + // dispatch thread. A port validates this failure against the request + // token it belongs to and then reports it, and queueing again splits + // those two steps across turns: a retrying show() can run in between, + // start a new request, and be marked hidden and stripped of its + // modality by a failure that no longer applies to it. Running here + // keeps the check and its consequence in one unit, which is what the + // check is for. + failure.run(); + return; + } + // Not droppable, for the same reason as the other lifecycle notifications. + Display.getInstance().callSerially(failure); + } + } + + /// Notifies Codename One that the user moved a native window. + /// + /// Separate from `#windowMonitorChanged(int)`, which is only for a move that + /// carried the window onto a different display: an ordinary move within one + /// monitor still has to reach the listeners, or nothing can persist a window's + /// position. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + public void windowMoved(int windowId) { + if (windowId > 0) { + Display.getInstance().callSerially(new WindowCallback(windowId, WindowCallback.MOVED)); + } + } + + /// Notifies Codename One that a native window moved to a monitor with different + /// characteristics, so that its scale and layout are recomputed. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + public void windowMonitorChanged(int windowId) { + Display.getInstance().callSerially(new WindowCallback(windowId, WindowCallback.MONITOR_CHANGED)); + } + + /// Notifies Codename One that a native window changed size. Invoked by the + /// implementation. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `w`: the new drawable width + /// + /// - `h`: the new drawable height + public void windowSizeChanged(int windowId, int w, int h) { + if (windowId <= 0) { + return; + } + // Coalesced onto the event dispatch thread rather than queued as a packet. + // The packed stack drops when it is full, which live resizing does easily, and + // the dropped packet can be the *final* size -- the native surface has already + // adopted it, so the hierarchy stays laid out for an earlier one with nothing + // guaranteed to correct it, leaving painting and hit testing misaligned. + // + // Coalescing is what makes a non-droppable path affordable here: only one + // notification per window is ever outstanding, and it carries whatever the + // latest dimensions are when it runs, so a drag that produces hundreds of + // resizes still costs one queued runnable at a time. + final Integer key = Integer.valueOf(windowId); + boolean queue; + synchronized (pendingWindowSizes) { + int[] latest = (int[]) pendingWindowSizes.get(key); + if (latest == null) { + latest = new int[2]; + pendingWindowSizes.put(key, latest); + queue = true; + } else { + queue = false; + } + latest[0] = w; + latest[1] = h; + } + if (!queue) { + return; + } + final int id = windowId; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + int width; + int height; + synchronized (pendingWindowSizes) { + int[] latest = (int[]) pendingWindowSizes.remove(key); + if (latest == null) { + return; + } + width = latest[0]; + height = latest[1]; + } + Window w = windowById(id); + if (w != null) { + w.sizeChangedInternal(width, height); + } + } + }); + } + + /// Notifies Codename One that the set of attached monitors changed. + public void monitorsChanged() { + synchronized (monitorsChangedLock) { + // Genuinely coalesced rather than merely documented as such. One physical + // display change is reported many times over: Windows broadcasts + // WM_DISPLAYCHANGE to every top level window, and GTK fires geometry, + // work-area and scale-factor notifications separately for each monitor. + // Each notification relays out every open window and fires every monitor + // listener, so without this a single resolution change did that work N + // times. A change arriving while one is queued is already covered by it. + if (monitorsChangedPending) { + return; + } + monitorsChangedPending = true; + } + Display.getInstance().callSerially(new WindowCallback(0, WindowCallback.MONITORS_CHANGED)); + } + + /// Lets the queued notification re-arm the coalescing guard. See + /// `#monitorsChanged()`. + void clearMonitorsChangedPending() { + synchronized (monitorsChangedLock) { + monitorsChangedPending = false; + } + } + + /// Marshals a window notification that arrived on the platform's own thread onto + /// the event dispatch thread. A named static class rather than an anonymous one so + /// it does not retain the `Display` it was created from. + private static final class WindowCallback implements Runnable { + private static final int FOCUS_GAINED = 0; + private static final int FOCUS_LOST = 1; + private static final int CLOSE_REQUESTED = 2; + private static final int MONITOR_CHANGED = 3; + private static final int MONITORS_CHANGED = 4; + private static final int MOVED = 5; + private static final int CLOSED_NATIVELY = 6; + private static final int SHOWN = 7; + private static final int HIDDEN = 8; + private static final int ACTIVATION_FAILED = 9; + + private final int windowId; + private final int kind; + + WindowCallback(int windowId, int kind) { + this.windowId = windowId; + this.kind = kind; + } + + @Override + public void run() { + Desktop desktop = Desktop.getInstance(); + Window w = desktop.windowById(windowId); + switch (kind) { + case FOCUS_GAINED: + desktop.setFocusedWindow(w); + break; + case FOCUS_LOST: + if (desktop.getFocusedWindow() == w) { //NOPMD CompareObjectsWithEquals + desktop.setFocusedWindow(null); + } + if (w != null) { + // The fifth way a window stops being reachable, after hide, + // minimize, dispose and modal blocking. The physical key-up + // goes to whatever has focus now, so without this a held key + // repeats into this window for as long as it stays open and + // a pressed component stays latched. + w.cancelPendingInput(); + } else if (windowId == 0) { + // Window zero is the application's main surface, which + // windowById() cannot answer for: it is not a registered + // Window. Its held keys are armed in Display's own fields, so + // the guard above skipped them -- and activating a secondary + // window reports only that window's focus gain, so nothing else + // told the main surface it had lost the keyboard. A key-up + // delivered to another application then left the main form + // repeating for as long as it stayed open. + Display.getInstance().mainSurfaceInputCancelled(); + } + break; + case CLOSE_REQUESTED: + // A close arrives outside the packed input queue, so it bypasses the + // modality filter that guards every other event. A port that cannot + // disable a blocked window natively -- Catalyst has no such control + // -- would otherwise let the user close a window an application + // modal is supposed to be blocking. Checked here rather than at the + // callback, so the modal stack is only ever read on this thread. + if (w != null && !Desktop.getInstance().isWindowInputBlocked(windowId)) { + w.closeRequested(); + } + break; + case MONITOR_CHANGED: + // One window moved to another display. Deliberately not + // desktop.fireMonitorChanged(): Desktop.addMonitorListener is + // documented for a monitor being attached, removed or + // reconfigured, and firing it for every drag across a mixed-DPI + // desktop turned an ordinary window move into a topology event -- + // repeatedly re-running whatever display reconfiguration work the + // application does there. The window itself re-reads its scale and + // lays out below, and an application that wants to follow one + // window across displays sees it through that window's Moved + // event plus getMonitor(). + if (w != null) { + w.monitorChanged(); + } + break; + case MONITORS_CHANGED: + // Cleared before the work, not after: a display change that + // happens while this runs describes a topology this pass has not + // read yet, so it has to queue another one rather than be + // swallowed as a duplicate. + desktop.clearMonitorsChangedPending(); + for (Window each : desktop.getWindows()) { + each.monitorChanged(); + } + desktop.fireMonitorChanged(); + break; + case MOVED: + if (w != null) { + w.moved(); + } + break; + case SHOWN: + if (w != null) { + w.showNotify(); + // A visibility change can change what is blocked: a modal whose + // owner went away stops blocking, and blocks again when the + // owner returns. Only push, pop and dispose re-synced the native + // flags, so the framework and the platform disagreed for as long + // as the window stayed hidden -- the platform kept the main + // surface disabled with no modal on screen to release it. + Desktop.getInstance().syncNativeModalBlocking(); + } + break; + case HIDDEN: + if (w != null) { + w.hideNotify(); + Desktop.getInstance().syncNativeModalBlocking(); + } + break; + case ACTIVATION_FAILED: + if (w != null) { + w.activationFailed(); + } + break; + case CLOSED_NATIVELY: + if (w != null) { + // A window a modal is blocking must not be closable. Where the + // platform's close control cannot be disabled the close has + // already happened, so the only way to honour the contract is + // to put the window back; a port that cannot returns false and + // the window is disposed, because the surface is genuinely gone. + if (!Desktop.getInstance().isWindowInputBlocked(windowId) + || !w.reopenNativeSurface()) { + w.dispose(); + } + } + break; + default: + break; + } + } + } + + // ---- window geometry queried by the ports ------------------------------------ + + /// Indicates whether input aimed at the given window is currently blocked by a + /// modal window above it. + /// The drag-region status at a point inside one of the additional native windows, + /// used by the implementation's drag activation filter. + /// + /// #### Parameters + /// + /// - `windowId`: the window to ask + /// + /// - `x`: x in the window's coordinates + /// + /// - `y`: y in the window's coordinates + /// + /// #### Returns + /// + /// the drag region status, or `Component#DRAG_REGION_NOT_DRAGGABLE` when there is + /// no such window + public int windowDragRegionStatus(int windowId, int x, int y) { + Window w = windowById(windowId); + return w == null ? Component.DRAG_REGION_NOT_DRAGGABLE : w.getDragRegionStatus(x, y); + } + + /// The width of one of the additional native windows, or 0 when there is no such + /// window. + /// + /// #### Parameters + /// + /// - `windowId`: the window to ask + /// + /// #### Returns + /// + /// the window's width in Codename One coordinates + public int windowWidth(int windowId) { + Window w = windowById(windowId); + return w == null ? 0 : w.getWidth(); + } + + /// The height of one of the additional native windows, or 0 when there is no such + /// window. + /// + /// #### Parameters + /// + /// - `windowId`: the window to ask + /// + /// #### Returns + /// + /// the window's height in Codename One coordinates + public int windowHeight(int windowId) { + Window w = windowById(windowId); + return w == null ? 0 : w.getHeight(); + } + + // ---- input reported by the ports --------------------------------------------- + // + // A port calls these when something happens in one of its windows. They hand the + // event to Display's input queue, which is the one thing about a window event that + // is genuinely Display's: there is a single queue and a single event dispatch + // thread, shared with the main surface. + + + /// Pushes a key press event aimed at one native window into Codename One. + /// Invoked by the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `keyCode`: keycode of the key event + public void windowKeyPressed(int windowId, int keyCode) { + if (windowId > 0) { + Display.getInstance().keyPressedImpl(windowId, keyCode); + } + } + + /// Pushes a key release aimed at one native window into Codename One. + /// Invoked by the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `keyCode`: keycode of the key event + public void windowKeyReleased(int windowId, int keyCode) { + if (windowId > 0) { + Display.getInstance().keyReleasedImpl(windowId, keyCode); + } + } + + /// Pushes a hover press aimed at one native window into Codename One. Invoked by + /// the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the x position of the pointer, in window coordinates + /// + /// - `y`: the y position of the pointer, in window coordinates + public void windowPointerHoverPressed(int windowId, int[] x, int[] y) { + if (windowId > 0) { + Display.getInstance().pointerHoverPressedImpl(windowId, x, y); + } + } + + /// Pushes a hover release aimed at one native window into Codename One. Invoked by + /// the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the x position of the pointer, in window coordinates + /// + /// - `y`: the y position of the pointer, in window coordinates + public void windowPointerHoverReleased(int windowId, int[] x, int[] y) { + if (windowId > 0) { + Display.getInstance().pointerHoverReleasedImpl(windowId, x, y); + } + } + + /// Dispatches a wheel event that arrived over a native window. + /// + /// A port with desktop windows has to route the wheel explicitly: the main + /// surface version resolves the component from the current form, so a wheel over + /// a second window would either do nothing or scroll the main form instead. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created, or 0 for + /// the application's main surface + /// + /// - `x`: the pointer x position in window pixels + /// + /// - `y`: the pointer y position in window pixels + /// + /// - `scrollX`: the horizontal scroll amount in display pixels + /// + /// - `scrollY`: the vertical scroll amount in display pixels + /// + /// - `precise`: true if the deltas come from a high resolution device such as a + /// trackpad + /// + /// - `modifiers`: bitmask of the held keyboard modifiers + /// + /// #### Returns + /// + /// true if a listener consumed the wheel event + public boolean windowMouseWheelEvent(int windowId, int x, int y, int scrollX, int scrollY, + boolean precise, int modifiers) { + return Display.getInstance().windowMouseWheelEventImpl(windowId, x, y, scrollX, scrollY, + precise, modifiers); + } + + /// Dispatches a magnify (pinch) gesture that arrived over a native window. Window + /// 0 is the application's main surface. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the gesture x position in pixels, relative to that window + /// + /// - `y`: the gesture y position in pixels, relative to that window + /// + /// - `scale`: the magnification scale, larger than 1 zooms in and smaller than 1 + /// zooms out + public void windowMagnifyGesture(int windowId, int x, int y, float scale) { + Display.getInstance().windowMagnifyGestureImpl(windowId, x, y, scale); + } + + /// Dispatches a rotation (twist) gesture that arrived over a native window. Window + /// 0 is the application's main surface. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the gesture x position in pixels, relative to that window + /// + /// - `y`: the gesture y position in pixels, relative to that window + /// + /// - `radians`: the incremental rotation in radians, positive is clockwise + public void windowRotationGesture(int windowId, int x, int y, float radians) { + Display.getInstance().windowRotationGestureImpl(windowId, x, y, radians); + } + + /// The drag-activation filter belonging to one window, which the implementation + /// applies to that window's pointer moves. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// #### Returns + /// + /// the window's filter, or null when no window holds that id + public com.codename1.impl.PointerDragActivation windowDragActivation(int windowId) { + Window w = windowById(windowId); + return w == null ? null : w.getDragActivation(); + } + + /// Returns the native window peer owning the given component, or null when it + /// belongs to the application's main surface. Ports use this to place native peers + /// and native text editors into the correct window. + /// + /// It lives here rather than on `Display` because this class owns the windows; + /// `Display` answers for the application's single main surface and knowing which + /// window a component is in is not a question about that surface. + /// + /// #### Parameters + /// + /// - `cmp`: the component to locate + /// + /// #### Returns + /// + /// the owning window's native peer, or null for the main surface + public Object getWindowPeerForComponent(Component cmp) { + if (cmp == null) { + return null; + } + TopLevelContainer top = cmp.getTopLevelContainer(); + return top == null ? null : top.asContainer().topLevelNativePeer(); + } + + /// Pushes a pointer drag aimed at one native window into Codename One. + /// Invoked by the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the x positions of the pointer + /// + /// - `y`: the y positions of the pointer + public void windowPointerDragged(int windowId, int[] x, int[] y) { + if (windowId > 0) { + Display.getInstance().pointerDraggedImpl(windowId, x, y); + } + } + + /// Pushes a pointer hover event that arrived over a specific native window. + /// + /// A port with desktop windows has to say which window the pointer was over, or + /// hovering a second window sends the event to whatever the main form has at the + /// same coordinates -- so the window gets no tooltips and the main form gets + /// spurious ones. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the x position of the pointer, in window coordinates + /// + /// - `y`: the y position of the pointer, in window coordinates + public void windowPointerHover(int windowId, final int[] x, final int[] y) { + if (windowId > 0) { + Display.getInstance().pointerHoverImpl(windowId, x, y); + } + } + + /// Pushes a pointer press aimed at one native window into Codename One. + /// Invoked by the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the x positions of the pointer + /// + /// - `y`: the y positions of the pointer + public void windowPointerPressed(int windowId, int[] x, int[] y) { + if (windowId > 0) { + Display.getInstance().pointerPressedImpl(windowId, x, y); + } + } + + /// Pushes a pointer release aimed at one native window into Codename One. + /// Invoked by the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the x positions of the pointer + /// + /// - `y`: the y positions of the pointer + public void windowPointerReleased(int windowId, int[] x, int[] y) { + if (windowId > 0) { + Display.getInstance().pointerReleasedImpl(windowId, x, y); + } + } + + // ---- painting the open windows ----------------------------------------------- + // + // Called once per pass from Display's event loop. The loop is Display's; walking + // the windows it has to paint is not. + + /// Creates the `Graphics` a window paints through and hands it to the + /// implementation. `Graphics` cannot be constructed outside this package, which + /// is why this lives here rather than on the window or the implementation -- + /// exactly as `#init(java.lang.Object)` does for the main surface. + Graphics createWindowGraphics(Window w) { + Graphics g = new Graphics(Display.impl.getWindowManager().getNativeGraphics(w.getNativePeer())); + g.paintPeersBehind = Display.impl.paintNativePeersBehind(); + w.getPaintSurface().setGraphics(g); + return g; + } + + void paintWindows() { + ArrayList open = windowList(); + for (int iter = 0; iter < open.size(); iter++) { // NOPMD ForLoopCanBeForeach + Window w = open.get(iter); + if (!w.isWindowShowing()) { + continue; + } + Graphics g = w.getWindowGraphics(); + Object peer = w.getNativePeer(); + // The manager as well as the graphics and the peer. A window stays + // registered until it is disposed, so one can outlive the platform's + // window manager -- and dereferencing it here throws on the event dispatch + // thread, which catches the exception, comes straight back round the loop + // and throws again. That spins forever rather than losing a frame, so the + // one thing this must not do is assume the manager is still there. + WindowManager wm = Display.impl.getWindowManager(); + if (g == null || peer == null || wm == null) { + continue; + } + g.setGraphics(wm.getNativeGraphics(peer)); + w.flushRevalidateQueue(); + w.getPaintSurface().paintDirty(w.getWidth(), w.getHeight()); + w.repaintAnimations(); + // The window's raster exists from the moment it is shown, so a capture + // before this point returns a blank frame of the right size. Recording + // that a cycle completed is what lets a caller wait for real content. + w.markPainted(); + } + } + + boolean anyWindowHasAnimations() { + ArrayList open = windowList(); + for (int iter = 0; iter < open.size(); iter++) { // NOPMD ForLoopCanBeForeach + Window w = open.get(iter); + if (w.isWindowShowing() && w.hasAnimations()) { + return true; + } + } + return false; + } + + /// Repaints every window that is on screen. The main surface is the caller's + /// business; this is the window half of it. + void repaintWindows() { + ArrayList open = windowList(); + for (int iter = 0; iter < open.size(); iter++) { // NOPMD ForLoopCanBeForeach + open.get(iter).repaint(); + } + } + +} diff --git a/CodenameOne/src/com/codename1/ui/Display.java b/CodenameOne/src/com/codename1/ui/Display.java index a309b73ef5b..159d269cd32 100644 --- a/CodenameOne/src/com/codename1/ui/Display.java +++ b/CodenameOne/src/com/codename1/ui/Display.java @@ -284,31 +284,69 @@ public final class Display extends CN1Constants { /// where we only synchronize on the very minimal point of switching between the stacks /// and adding to the active stack. private int[] inputEventStack = new int[1000]; + /// Pointer metadata slot per queued packet, indexed by the packet's type-word + /// offset in `inputEventStack`. Kept beside the stack rather than inside the packet + /// so the packet layout -- and every offset computation and `skipEvent` count that + /// depends on it -- is unchanged. A slot is only written when a pointer packet is + /// queued and only read for a pointer type at that same offset, so it is always the + /// slot belonging to the packet being dispatched. + private int[] pointerMetaStack = new int[1000]; + private int[] pointerMetaStackTmp = new int[1000]; private int inputEventStackPointer; private int[] inputEventStackTmp = new int[1000]; private int inputEventStackPointerTmp; - private boolean longPointerCharged; private boolean pointerPressedAndNotReleasedOrDragged; + + /// Per window: whether a contact is down and has not yet been released or + /// dragged, and where it went down. + /// + /// Window zero keeps using the singleton above and the global pointerX/pointerY, + /// so the single window path is untouched. Anything else needs its own copy: with + /// a contact down in two windows the singleton was set and cleared by whichever + /// window's packet ran last, so releasing in one window dropped the pressed + /// selection in the other while it was still held. The coordinates travel with it + /// because shouldRenderSelection(Component) tests them against the component's own + /// bounds, and window coordinates are window relative -- a component in one window + /// tested against another window's pointer is comparing two different origins. private boolean recursivePointerReleaseA; private boolean recursivePointerReleaseB; private int pointerX; private int pointerY; private PointerEvent currentPointerEvent; + /// The main surface's key-repeat and long-press timers, as they always were. + /// A window keeps its own on the window, so there is no table here to lease. private boolean keyRepeatCharged; private boolean longPressCharged; + private boolean longPointerCharged; + private int keyRepeatValue; + private long nextKeyRepeatEvent; private long longKeyPressTime; private int longPressInterval = 500; - private long nextKeyRepeatEvent; - private int keyRepeatValue; private boolean lastInteractionWasKeypad; - private boolean dragOccured; private boolean processingSerialCalls; private int PATHLENGTH; + + /// A gesture-path ring sized for this platform. Windows ask for their own rather + /// than being handed a row of a shared table. + PointerDragHistory newDragHistory() { + return new PointerDragHistory(PATHLENGTH, displayInitTime); + } + + /// Drag sample history, one ring per window. + /// + /// The main surface's gesture path, exactly as it always was. A window keeps its + /// own ring on the window rather than borrowing a row here, so two contacts + /// dragging in two surfaces cannot append to the same samples. private float[] dragPathX; private float[] dragPathY; private long[] dragPathTime; - private int dragPathOffset = 0; - private int dragPathLength = 0; + private int dragPathOffset; + private int dragPathLength; + + /// The window whose drag samples `#getDragSpeed(boolean)` should report. Set + /// while that window's pointer events are dispatched, since the public accessor + /// takes no window and is called by components during their own release. + private int dragHistoryCurrent; private Boolean darkMode; private PluginSupport pluginSupport; /// Internally track display initialization time as a fixed point to allow tagging of pointer @@ -328,7 +366,15 @@ public final class Display extends CN1Constants { private DebugRunnable currentEdtContext; private int previousKeyPressed; private int lastKeyPressed; + /// Offset of the dimensions in the size-change packet currently queued, or -1. + /// A newer size overwrites it rather than queueing behind it. + private int lastSizeChangeOffset = -1; + private int lastDragOffset; + /// The window the coalescable drag packet at lastDragOffset belongs to. Coalescing + /// across windows would overwrite one window's coordinates with another's while + /// the packet still carries the first window's id. + private int lastDragWindowId; private boolean lockOrientation; private boolean disableScreenshots; @@ -338,8 +384,45 @@ public final class Display extends CN1Constants { private boolean pendingHideOverlayWindows; // huge false positive from PMD... - @SuppressWarnings("PMD.SingularField") - private Form eventForm; + /// Ids of the windows with a pointer press in flight, paired with + /// `#pointerPressTargets` by index. + + /// The top level that received each in-flight pointer press. + /// + /// One entry per window rather than one for the pointer, for the same reason the + /// key targets are per key: two touchscreen contacts can be down in two windows + /// at once -- the Linux handlers deliberately track a sequence per window -- and + /// a single field made the second press erase the first, so both releases were + /// dropped and both components stayed latched down. + /// The main surface's pending press target; a window keeps its own. + private Container mainPointerPressTarget; + + /// How many entries the per-key and per-window input tables hold. + /// + /// CN1_MAX_DESKTOP_WINDOWS in the native ports, *plus one* for the application's + /// main surface, which holds a permanent entry of its own. Sizing this to 32 + /// alone left only 31 usable secondary slots, so the last window the ports allow + /// still lost its drag state. + /// + /// It was originally 8, a size chosen for simultaneous key presses that I reused + /// for the window-keyed tables without asking what bounds those. Exhaustion is + /// silent -- the lookup returns -1 and the setter no-ops -- so the window simply + /// behaves as though the drag never happened. + private static final int TRACKED_KEY_PRESSES = 33; + + /// Key codes currently held, paired with `#keyPressTargets` by index. + private final int[] keyPressCodes = new int[TRACKED_KEY_PRESSES]; + + /// The top level that received the press of each held key. + /// + /// One field per key rather than one for the keyboard: hold a key in window A, + /// focus window B and press another key there, and a single field names B, so + /// A's release matches nothing and is dropped while clearing the field -- which + /// then drops B's release too, latching a component in each window. + private final Container[] keyPressTargets = new Container[TRACKED_KEY_PRESSES]; + + /// Window ids remembered so a key repeat or long press started in a window is + /// delivered back to that window rather than to the main form. /// Private constructor to prevent instanciation private Display() { @@ -373,6 +456,13 @@ public static void init(Object m) { impl.setDisplayLock(lock); impl.initImpl(m); INSTANCE.codenameOneGraphics = new Graphics(impl.getNativeGraphics()); + // A monitor listener registered before this point could not start the + // port watching for display changes, because there was no implementation + // to ask. Doing it here rather than only guarding the registration is what + // makes such a listener actually hear about a change: the ports start + // watching when their window manager is first created, and nothing else + // necessarily creates it. + Desktop.startMonitorWatchingIfListening(); INSTANCE.codenameOneGraphics.paintPeersBehind = impl.paintNativePeersBehind(); impl.setCodenameOneGraphics(INSTANCE.codenameOneGraphics); @@ -1150,7 +1240,10 @@ void mainEDTLoop() { try { // when there is no current form the EDT is useful only // for features such as call serially - while (impl.getCurrentForm() == null) { // PMD Fix: AvoidBranchingStatementAsLastInLoop + // A window shown before the first Form.show() must not be starved: this + // phase never calls edtLoopImpl(), so it neither drains input nor paints. + while (impl.getCurrentForm() == null + && !Desktop.getInstance().hasVisibleWindows()) { // PMD Fix: AvoidBranchingStatementAsLastInLoop synchronized (lock) { while (shouldEDTSleep() && pendingIdleSerialCalls.isEmpty()) { try { @@ -1242,6 +1335,10 @@ void mainEDTLoop() { } } } + // Dispose any window still open, on the EDT, before the implementation goes + // away. Doing this from the static deinitialize() would run the teardown off + // the EDT, which is exactly the thread the window's tree expects. + Desktop.getInstance().disposeAll(); impl.deinitialize(); //INSTANCE.impl = null; //INSTANCE.codenameOneGraphics = null; @@ -1300,6 +1397,20 @@ void edtLoopImpl() { // paint transition or intro animations and don't do anything else if such // animations are in progress... paintTransitionAnimation(); + // Except the other windows. A transition belongs to the main surface, + // and this early return is what keeps the rest of the loop off it while + // one is running -- but a secondary window is an independent native + // window and has no part in it. Left out, an ordinary form transition + // froze every open window for its duration: no painting, no animation. + // + // Input stays queued rather than being dispatched here. That is how the + // main surface already behaves during a transition, and draining the + // shared queue from this branch would change the main path's semantics + // to fix a window's -- a worse trade than a few hundred milliseconds of + // deferred input. + if (Desktop.getInstance().hasOpenWindows()) { + Desktop.getInstance().paintWindows(); + } return; } } catch (RuntimeException ignor) { @@ -1312,16 +1423,25 @@ void edtLoopImpl() { inputEventStackPointerTmp = inputEventStackPointer; inputEventStackPointer = 0; lastDragOffset = -1; + lastSizeChangeOffset = -1; int[] qt = inputEventStackTmp; inputEventStackTmp = inputEventStack; + // The metadata slots are addressed by offset into the stack being + // dispatched, so they have to change hands with it; leaving them behind + // would have every packet read the slot of whatever packet last occupied + // that offset in the other buffer. + int[] qtMeta = pointerMetaStackTmp; + pointerMetaStackTmp = pointerMetaStack; // We have a special flag here for a case where the input event stack might still be processing this can // happen if an event callback calls something like invokeAndBlock while processing and might reach // this code again if (qt[qt.length - 1] == Integer.MAX_VALUE) { inputEventStack = new int[qt.length]; + pointerMetaStack = new int[qt.length]; } else { inputEventStack = qt; + pointerMetaStack = qtMeta; qt[qt.length - 1] = 0; } } @@ -1331,11 +1451,19 @@ void edtLoopImpl() { int actualTmpPointer = inputEventStackPointerTmp; inputEventStackPointerTmp = 0; int[] actualStack = inputEventStackTmp; + // Copied to the stack for the same reason as the event stack itself: a nested + // invokeAndBlock can swap the field while this loop is still dispatching. + int[] actualMeta = pointerMetaStackTmp; int offset = 0; actualStack[actualStack.length - 1] = Integer.MAX_VALUE; while (offset < actualTmpPointer) { - offset = handleEvent(offset, actualStack); + offset = handleEvent(offset, actualStack, actualMeta); } + // The restored metadata is only authoritative while this batch is being + // dispatched. Leaving it selected made every later read answer from the last + // packet dispatched, so a port that staged fresh metadata and then asked -- + // without going through the queue -- got the previous event's button back. + impl.clearPointerEventMetadataSelection(); actualStack[actualStack.length - 1] = 0; @@ -1355,8 +1483,19 @@ void edtLoopImpl() { if (current != null) { current.repaintAnimations(); - // check key repeat events - long t = System.currentTimeMillis(); + } + + // Additional native windows, painted after the main surface so its call + // ordering is untouched. One field read per frame when none are open. + if (Desktop.getInstance().hasOpenWindows()) { + Desktop.getInstance().paintWindows(); + } + + // The main surface's timers, exactly as they always were. + long t = System.currentTimeMillis(); + // The main surface is blockable too: a modal window blocks it, and a held key + // must stop repeating into the form behind that modal. + if (current != null && !Desktop.getInstance().isWindowInputBlocked(0)) { if (keyRepeatCharged && nextKeyRepeatEvent <= t) { current.keyRepeated(keyRepeatValue); int keyRepeatNextIntervalTime = 10; @@ -1371,6 +1510,12 @@ void edtLoopImpl() { current.longPointerPress(pointerX, pointerY); } } + // Every open window services its own, which is a walk over the windows that + // actually exist rather than over a fixed table of slots most of which are + // empty. A window that went away took its timers with it. + for (Window each : Desktop.getInstance().getWindows()) { + each.serviceInputTimers(t, longPressInterval); + } processSerialCalls(); time = System.currentTimeMillis() - currentTime; @@ -1831,9 +1976,8 @@ void setCurrentForm(Form newForm) { if (!initialWindowSizeApplied) { initialWindowSizeApplied = applyInitialWindowSize(newForm); } - keyRepeatCharged = false; - longPressCharged = false; - longPointerCharged = false; + cancelAllKeyRepeats(); + cancelAllLongPresses(); current = newForm; impl.setCurrentForm(current); current.setVisible(true); @@ -1919,7 +2063,11 @@ public void editString(Component cmp, int maxSize, int constraint, String text, if (cmp instanceof TextArea) { ((TextArea) cmp).setSuppressActionEvent(false); } - Form f = cmp.getComponentForm(); + // The top level rather than the form. getComponentForm() is null by design + // inside a Window, so this guard rejected every editor in a window before any + // of the port level editor routing could run -- native text editing in a window + // was unreachable from here however correct the ports were. + TopLevelContainer f = cmp.getTopLevelContainer(); // this can happen in the spinner in the simulator where the key press should in theory start native // edit @@ -1929,8 +2077,7 @@ public void editString(Component cmp, int maxSize, int constraint, String text, Component.setDisableSmoothScrolling(true); f.scrollComponentToVisible(cmp); Component.setDisableSmoothScrolling(false); - keyRepeatCharged = false; - longPressCharged = false; + cancelAllKeyRepeats(); lastKeyPressed = 0; previousKeyPressed = 0; impl.editStringImpl(cmp, maxSize, constraint, text, initiatingKeycode); @@ -2002,26 +2149,62 @@ public void restoreMinimizedApplication() { getImplementation().restoreMinimizedApplication(); } - private void addSingleArgumentEvent(int type, int code) { + /// #### Returns + /// + /// true if the event was queued, false if it was dropped -- the caller must not arm + /// anything off a press that was never accepted + private boolean addSingleArgumentEvent(int type, int code) { synchronized (lock) { if (this.dropEvents) { - return; + return false; } - if (!hasInputEventStackCapacity(2)) { - return; + if (isTerminationEvent(type) + ? !hasInputEventStackCapacity(2) + : !hasDroppableInputEventStackCapacity(2)) { + return false; } inputEventStack[inputEventStackPointer] = type; inputEventStackPointer++; inputEventStack[inputEventStackPointer] = code; inputEventStackPointer++; lock.notifyAll(); + return true; } } + /// Slots at the end of the input stack that only a termination may use. + /// + /// The native queues protect a release from being dropped on overflow, and that is + /// worth nothing if this second queue drops it instead: a lost release leaves the + /// component the press went to stuck down, or a drag never finished. Ordinary input + /// -- moves, drags, presses, hovers -- therefore stops short of the end of the + /// stack, leaving room for the releases and size changes that cannot be + /// reconstructed. + /// + /// A press is ordinary on purpose: a release arriving with no press behind it finds + /// no recorded target and is discarded harmlessly, so when something has to go it + /// must never be the release. + private static final int TERMINATION_RESERVE = 64; + private boolean hasInputEventStackCapacity(int additionalSlots) { return inputEventStackPointer + additionalSlots < inputEventStack.length; } + /// Capacity for an event that may be dropped, which stops short of the reserve. + private boolean hasDroppableInputEventStackCapacity(int additionalSlots) { + return inputEventStackPointer + additionalSlots + < inputEventStack.length - TERMINATION_RESERVE; + } + + /// Whether this packed event type is one whose loss the framework cannot recover + /// from, and which may therefore use the reserve. + private static boolean isTerminationEvent(int packedType) { + int type = packedType & 0xFF; + return type == POINTER_RELEASED || type == POINTER_RELEASED_MULTI + || type == POINTER_HOVER_RELEASED || type == KEY_RELEASED + || type == SIZE_CHANGED; + } + /// Checks if the control key is currently down. Only relevant for desktop ports. public boolean isControlKeyDown() { return impl.isControlKeyDown(); @@ -2141,21 +2324,7 @@ public boolean isStylusPointer() { /// /// true if a listener consumed the wheel event public boolean fireMouseWheelEvent(int x, int y, int scrollX, int scrollY, boolean precise, int modifiers) { - Form f = getCurrent(); - if (f == null) { - return false; - } - Component cmp; - try { - cmp = f.getComponentAt(x, y); - } catch (Throwable t) { - cmp = null; - } - if (cmp == null) { - return false; - } - com.codename1.ui.events.WheelEvent we = new com.codename1.ui.events.WheelEvent(cmp, x, y, scrollX, scrollY, precise, modifiers); - return cmp.fireMouseWheelEvent(we); + return windowMouseWheelEventImpl(0, x, y, scrollX, scrollY, precise, modifiers); } /// Dispatches a magnify (pinch) gesture to the component under the given coordinates, walking up @@ -2170,25 +2339,7 @@ public boolean fireMouseWheelEvent(int x, int y, int scrollX, int scrollY, boole /// /// - `scale`: the magnification scale, larger than 1 zooms in and smaller than 1 zooms out public void fireMagnifyGesture(int x, int y, float scale) { - Form f = getCurrent(); - if (f == null) { - return; - } - Component cmp; - try { - cmp = f.getComponentAt(x, y); - } catch (Throwable t) { - cmp = null; - } - if (cmp == null) { - cmp = f; - } - while (cmp != null) { - if (cmp.pinch(scale)) { - return; - } - cmp = cmp.getParent(); - } + windowMagnifyGestureImpl(0, x, y, scale); } /// Dispatches a rotation (twist) gesture to the component under the given coordinates, walking @@ -2203,21 +2354,33 @@ public void fireMagnifyGesture(int x, int y, float scale) { /// /// - `radians`: the incremental rotation in radians, positive is clockwise public void fireRotationGesture(int x, int y, float radians) { - Form f = getCurrent(); - if (f == null) { - return; + windowRotationGestureImpl(0, x, y, radians); + } + + /// The top level a gesture was aimed at, or null when it is gone or currently + /// blocked by a modal window. Gestures are filtered like every other input event: + /// pinching a window a modal is blocking has to do nothing, the same way clicking + /// it does. + private Container gestureRoot(int windowId) { + if (Desktop.getInstance().isWindowInputBlocked(windowId)) { + return null; } - Component cmp; + if (windowId > 0) { + // Hidden as well as blocked. A pinch or rotation callback queued before + // the window was hidden still finds it registered, and would drive the + // gesture handlers of an invisible tree -- the same stale-callback case + // the wheel path and the packed queue already reject. + Window w = Desktop.getInstance().windowById(windowId); + return w != null && w.isWindowShowing() ? w : null; + } + return getCurrent(); + } + + private static Component gestureComponentAt(Container root, int x, int y) { try { - cmp = f.getComponentAt(x, y); + return root.getComponentAt(x, y); } catch (Throwable t) { - cmp = null; - } - while (cmp != null) { - if (cmp.rotation(radians)) { - return; - } - cmp = cmp.getParent(); + return null; } } @@ -2230,18 +2393,27 @@ public void keyPressed(final int keyCode) { if (impl.getCurrentForm() == null) { return; } - addSingleArgumentEvent(KEY_PRESSED, keyCode); + keyPressedImpl(0, keyCode); + } + + void keyPressedImpl(int windowId, final int keyCode) { + if (!addSingleArgumentEvent(KEY_PRESSED | (windowId << 8), keyCode)) { + // The press was not accepted, so nothing may be armed off it. The repeat + // and long-press timers fire straight into the top level, so a component + // that never received keyPressed() would start getting keyRepeated() and + // longKeyPress() for a press it never saw -- and with the key still held, + // go on getting them. + return; + } lastInteractionWasKeypad = lastInteractionWasKeypad || (keyCode != MenuBar.leftSK && keyCode != MenuBar.clearSK && keyCode != MenuBar.backSK); // this solves a Sony Ericsson bug where on slider open/close someone "brilliant" chose // to send a keyPress with a -43/-44 keycode... Without ever sending a key release! - keyRepeatCharged = (keyCode >= 0 || getGameAction(keyCode) > 0) || keyCode == impl.getClearKeyCode(); - longPressCharged = keyRepeatCharged; - longKeyPressTime = System.currentTimeMillis(); - keyRepeatValue = keyCode; + boolean armed = (keyCode >= 0 || getGameAction(keyCode) > 0) || keyCode == impl.getClearKeyCode(); + long now = System.currentTimeMillis(); int keyRepeatInitialIntervalTime = 800; - nextKeyRepeatEvent = System.currentTimeMillis() + keyRepeatInitialIntervalTime; + chargeKeyRepeat(windowId, keyCode, armed, now, now + keyRepeatInitialIntervalTime); previousKeyPressed = lastKeyPressed; lastKeyPressed = keyCode; } @@ -2252,8 +2424,7 @@ public void keyPressed(final int keyCode) { /// /// - `keyCode`: keycode of the key event public void keyReleased(final int keyCode) { - keyRepeatCharged = false; - longPressCharged = false; + cancelKeyRepeatForCode(keyCode); if (impl.getCurrentForm() == null) { return; } @@ -2281,14 +2452,21 @@ public void keyReleased(final int keyCode) { void keyRepeatedInternal(final int keyCode) { } - private void addPointerEvent(int type, int x, int y) { + /// #### Returns + /// + /// true if the event was queued, false if it was dropped -- the caller must not arm + /// anything off a press that was never accepted + private boolean addPointerEvent(int type, int x, int y) { synchronized (lock) { if (this.dropEvents) { - return; + return false; } - if (!hasInputEventStackCapacity(3)) { - return; + if (isTerminationEvent(type) + ? !hasInputEventStackCapacity(3) + : !hasDroppableInputEventStackCapacity(3)) { + return false; } + pointerMetaStack[inputEventStackPointer] = impl.capturePointerEventMetadata(); inputEventStack[inputEventStackPointer] = type; inputEventStackPointer++; inputEventStack[inputEventStackPointer] = x; @@ -2296,17 +2474,24 @@ private void addPointerEvent(int type, int x, int y) { inputEventStack[inputEventStackPointer] = y; inputEventStackPointer++; lock.notifyAll(); + return true; } } - private void addPointerEvent(int type, int[] x, int[] y) { + /// #### Returns + /// + /// true if the event was queued, false if it was dropped + private boolean addPointerEvent(int type, int[] x, int[] y) { synchronized (lock) { if (this.dropEvents) { - return; + return false; } - if (!hasInputEventStackCapacity(3 + x.length + y.length)) { - return; + if (isTerminationEvent(type) + ? !hasInputEventStackCapacity(3 + x.length + y.length) + : !hasDroppableInputEventStackCapacity(3 + x.length + y.length)) { + return false; } + pointerMetaStack[inputEventStackPointer] = impl.capturePointerEventMetadata(); inputEventStack[inputEventStackPointer] = type; inputEventStackPointer++; inputEventStack[inputEventStackPointer] = x.length; @@ -2322,26 +2507,42 @@ private void addPointerEvent(int type, int[] x, int[] y) { inputEventStackPointer++; } lock.notifyAll(); + return true; } } - private void addPointerDragEventWithTimestamp(int x, int y) { + private void addPointerDragEventWithTimestamp(int windowId, int x, int y) { synchronized (lock) { if (this.dropEvents) { return; } try { - if (lastDragOffset > -1) { + if (lastDragOffset > -1 && lastDragWindowId == windowId) { + // A coalesced drag replaces the queued one, so it must also carry + // the newest metadata rather than the metadata of the drag it just + // overwrote. The type word sits one slot before the payload. + // + // The existing slot is overwritten rather than a new one taken: + // coalescing keeps one packet however many updates arrive, so + // taking a slot per update would run the ring forward without + // bound -- with the event dispatch thread blocked it would wrap + // and clobber slots belonging to presses and releases that are + // still queued, which is exactly the mix-up this prevents. + pointerMetaStack[lastDragOffset - 1] = + impl.recapturePointerEventMetadata(pointerMetaStack[lastDragOffset - 1]); inputEventStack[lastDragOffset] = x; inputEventStack[lastDragOffset + 1] = y; inputEventStack[lastDragOffset + 2] = (int) (System.currentTimeMillis() - displayInitTime); } else { - if (!hasInputEventStackCapacity(4)) { + // A drag is ordinary input, so it stops short of the reserve. + if (!hasDroppableInputEventStackCapacity(4)) { return; } - inputEventStack[inputEventStackPointer] = POINTER_DRAGGED; + pointerMetaStack[inputEventStackPointer] = impl.capturePointerEventMetadata(); + inputEventStack[inputEventStackPointer] = POINTER_DRAGGED | (windowId << 8); inputEventStackPointer++; lastDragOffset = inputEventStackPointer; + lastDragWindowId = windowId; inputEventStack[inputEventStackPointer] = x; inputEventStackPointer++; inputEventStack[inputEventStackPointer] = y; @@ -2363,9 +2564,12 @@ private void addPointerEventWithTimestamp(int type, int x, int y) { return; } try { - if (!hasInputEventStackCapacity(4)) { + if (isTerminationEvent(type) + ? !hasInputEventStackCapacity(4) + : !hasDroppableInputEventStackCapacity(4)) { return; } + pointerMetaStack[inputEventStackPointer] = impl.capturePointerEventMetadata(); inputEventStack[inputEventStackPointer] = type; inputEventStackPointer++; inputEventStack[inputEventStackPointer] = x; @@ -2393,15 +2597,19 @@ public void pointerDragged(final int[] x, final int[] y) { if (impl.getCurrentForm() == null) { return; } + pointerDraggedImpl(0, x, y); + } + + void pointerDraggedImpl(int windowId, final int[] x, final int[] y) { if (x.length == 0) { // Native ports have been observed to deliver zero-length pointer arrays return; } - longPointerCharged = false; + cancelLongPress(windowId); if (x.length == 1) { - addPointerDragEventWithTimestamp(x[0], y[0]); + addPointerDragEventWithTimestamp(windowId, x[0], y[0]); } else { - addPointerEvent(POINTER_DRAGGED_MULTI, x, y); + addPointerEvent(POINTER_DRAGGED_MULTI | (windowId << 8), x, y); } } @@ -2413,13 +2621,20 @@ public void pointerDragged(final int[] x, final int[] y) { /// /// - `y`: the y position of the pointer public void pointerHover(final int[] x, final int[] y) { - if (impl.getCurrentForm() == null) { + pointerHoverImpl(0, x, y); + } + + void pointerHoverImpl(int windowId, final int[] x, final int[] y) { + if (windowId == 0 && impl.getCurrentForm() == null) { + return; + } + if (windowId > 0 && Desktop.getInstance().windowById(windowId) == null) { return; } if (x.length == 1) { - addPointerEventWithTimestamp(POINTER_HOVER, x[0], y[0]); + addPointerEventWithTimestamp(POINTER_HOVER | (windowId << 8), x[0], y[0]); } else { - addPointerEvent(POINTER_HOVER, x, y); + addPointerEvent(POINTER_HOVER | (windowId << 8), x, y); } } @@ -2462,17 +2677,29 @@ public void pointerPressed(final int[] x, final int[] y) { if (impl.getCurrentForm() == null) { return; } + pointerPressedImpl(0, x, y); + } - lastInteractionWasKeypad = false; - longPointerCharged = true; - longKeyPressTime = System.currentTimeMillis(); - pointerX = x[0]; - pointerY = y[0]; + void pointerPressedImpl(int windowId, final int[] x, final int[] y) { + boolean accepted; if (x.length == 1) { - addPointerEvent(POINTER_PRESSED, x[0], y[0]); + accepted = addPointerEvent(POINTER_PRESSED | (windowId << 8), x[0], y[0]); } else { - addPointerEvent(POINTER_PRESSED_MULTI, x, y); + accepted = addPointerEvent(POINTER_PRESSED_MULTI | (windowId << 8), x, y); } + if (!accepted) { + // Nothing may be armed off a press the queue refused. longPointerPress() + // is delivered straight to the top level, so a component that never + // received pointerPressed() would get a long press for a press it never + // saw. Same rule as the key path. + return; + } + lastInteractionWasKeypad = false; + chargeLongPress(windowId, x[0], y[0]); + // Still tracked globally: this is "where the pointer last was", which + // getCurrentPointerEvent reports and which is not per window. + pointerX = x[0]; + pointerY = y[0]; } /// Pushes a pointer release event with the given coordinates into Codename One @@ -2483,24 +2710,49 @@ public void pointerPressed(final int[] x, final int[] y) { /// /// - `y`: the y position of the pointer public void pointerReleased(final int[] x, final int[] y) { - longPointerCharged = false; + cancelLongPress(0); if (impl.getCurrentForm() == null) { return; } + pointerReleasedImpl(0, x, y); + } + + void pointerReleasedImpl(int windowId, final int[] x, final int[] y) { + // Cancelled here rather than by each caller: a release ends the gesture, so + // the long-press timer it armed must not outlive it. + cancelLongPress(windowId); if (x.length == 1) { - addPointerEvent(POINTER_RELEASED, x[0], y[0]); + addPointerEvent(POINTER_RELEASED | (windowId << 8), x[0], y[0]); } else { - addPointerEvent(POINTER_RELEASED_MULTI, x, y); + addPointerEvent(POINTER_RELEASED_MULTI | (windowId << 8), x, y); } } private void addSizeChangeEvent(int type, int w, int h) { synchronized (lock) { + // Coalesced onto whichever size packet of this type is already queued. A + // live resize produces hundreds of these, and queueing each one fills the + // stack -- after which the *final* size is dropped, leaving the hierarchy + // laid out for a size the surface no longer has, and the releases behind it + // are dropped with it because a size change may use the reserve. Only the + // latest dimensions matter, so a queued packet is overwritten rather than + // followed. + if (lastSizeChangeOffset > -1 + && inputEventStack[lastSizeChangeOffset - 1] == type) { + inputEventStack[lastSizeChangeOffset] = w; + inputEventStack[lastSizeChangeOffset + 1] = h; + lock.notifyAll(); + return; + } + // A size change is state, and a lost one leaves the hierarchy laid out at + // a size the surface no longer has -- painting and hit testing stay + // misaligned until something else resizes the window. if (!hasInputEventStackCapacity(3)) { return; } inputEventStack[inputEventStackPointer] = type; inputEventStackPointer++; + lastSizeChangeOffset = inputEventStackPointer; inputEventStack[inputEventStackPointer] = w; inputEventStackPointer++; inputEventStack[inputEventStackPointer] = h; @@ -2522,15 +2774,396 @@ public void sizeChanged(int w, int h) { if (current == null) { return; } - if (w == current.getWidth() && h == current.getHeight()) { - // a workaround for a race condition on pixel 2 where size change events can happen really quickly - if (lastSizeChangeEventWH == -1 || lastSizeChangeEventWH == w + h) { - return; + if (w == current.getWidth() && h == current.getHeight()) { + // a workaround for a race condition on pixel 2 where size change events can happen really quickly + if (lastSizeChangeEventWH == -1 || lastSizeChangeEventWH == w + h) { + return; + } + } + + lastSizeChangeEventWH = w + h; + addSizeChangeEvent(SIZE_CHANGED, w, h); + } + + /// The most recent size reported for each open window that has not been delivered + /// yet. See `#windowSizeChanged(int, int, int)`. + + /// Whether this release finishes a press the framework already accepted. + /// + /// A press handler is allowed to open a modal window, and then the matching + /// release arrives with its own window blocked. Dropping it leaves the component + /// that took the press latched down for good and the recorded target never + /// cleared, so the next release matches the wrong thing. Modality is there to stop + /// *new* interaction, not to strand a gesture that was already under way. + /// + /// Only a release with a recorded press passes. A press that was itself filtered + /// leaves no record, so clicking a blocked window still does nothing. + private boolean completesAcceptedPress(int type, int windowId, int offset, int[] stack) { + switch (type) { + case KEY_RELEASED: + // The key code is the packet's first argument. + return hasKeyPressTarget(stack[offset + 1]); + case POINTER_RELEASED: + case POINTER_RELEASED_MULTI: + return hasPointerPressTarget(windowId); + default: + return false; + } + } + + /// Whether a press for this key is recorded, without consuming it. + private boolean hasKeyPressTarget(int keyCode) { + for (int iter = 0; iter < TRACKED_KEY_PRESSES; iter++) { + if (keyPressTargets[iter] != null && keyPressCodes[iter] == keyCode) { + return true; + } + } + return false; + } + + /// Whether this packet is user input, and so subject to modal blocking. + /// + /// Modality blocks what the user does to a window, not what the platform tells the + /// framework about it. A blocked window is still resized, hidden and shown by the + /// window system, and dropping those left the hierarchy at stale dimensions once + /// the modal closed -- painting and hit testing then disagreed with the native + /// canvas until something else forced a resize. + /// True for the event types that carry rich pointer metadata, i.e. the ones whose + /// dispatch builds a `com.codename1.ui.events.PointerEvent`. + private static boolean isPointerEvent(int type) { + switch (type) { + case POINTER_PRESSED: + case POINTER_RELEASED: + case POINTER_DRAGGED: + case POINTER_PRESSED_MULTI: + case POINTER_RELEASED_MULTI: + case POINTER_DRAGGED_MULTI: + case POINTER_HOVER: + case POINTER_HOVER_PRESSED: + case POINTER_HOVER_RELEASED: + return true; + default: + return false; + } + } + + private static boolean isUserInputEvent(int type) { + switch (type) { + case POINTER_PRESSED: + case POINTER_RELEASED: + case POINTER_DRAGGED: + case POINTER_PRESSED_MULTI: + case POINTER_RELEASED_MULTI: + case POINTER_DRAGGED_MULTI: + case POINTER_HOVER: + case POINTER_HOVER_PRESSED: + case POINTER_HOVER_RELEASED: + case KEY_PRESSED: + case KEY_RELEASED: + return true; + default: + // SIZE_CHANGED, HIDE_NOTIFY and SHOW_NOTIFY are the platform + // reporting what it did, not the user reaching the window. + return false; + } + } + + /// Whether a drag has happened on the *main* surface since its press. + /// + /// The main surface keeps the original single flag deliberately. Routing window 0 + /// through the per-window table changed behaviour for ordinary single-window + /// applications -- it regressed an unrelated component test -- and the defect + /// being fixed here is specifically that a *secondary* window's press clobbered + /// another window's state. Windows above 0 get their own entry. + private boolean dragOccured; + + /// Whether a drag has happened in each secondary window since its press, paired + /// by index with `#longPressWindows`. + /// + /// Global before: pressing in one window cleared the flag after another had + /// already dragged, so releasing the first made `List` and friends read + /// `hasDragOccured()` as false and treat a completed drag as a click. + + /// Arms key repeat and the long-key-press timer for one surface. Window zero is + /// the main surface and keeps the fields it always kept; any other window keeps + /// its own, on the window. + private void chargeKeyRepeat(int windowId, int keyCode, boolean armed, long now, + long firstRepeatAt) { + if (windowId == 0) { + keyRepeatCharged = armed; + longPressCharged = armed; + keyRepeatValue = keyCode; + longKeyPressTime = now; + nextKeyRepeatEvent = firstRepeatAt; + return; + } + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.chargeKeyRepeat(keyCode, armed, now, firstRepeatAt); + } + } + + /// Cancels whichever window armed a repeat for this key code. + /// + /// Keyed by the code rather than by the window the key-up packet names: the + /// physical key was armed by the *press*, and focus can move between the two, so + /// cancelling the releasing window's slot left the pressing window repeating + /// every 10ms with the key physically up. + private void cancelKeyRepeatForCode(int keyCode) { + if (keyRepeatValue == keyCode) { + keyRepeatCharged = false; + longPressCharged = false; + } + for (Window each : Desktop.getInstance().getWindows()) { + each.cancelKeyRepeatForCode(keyCode); + } + } + + /// Cancels key repeat for one window, leaving the others alone. + private void cancelKeyRepeat(int windowId) { + if (windowId == 0) { + keyRepeatCharged = false; + longPressCharged = false; + return; + } + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.cancelKeyRepeat(); + } + } + + /// Cancels key repeat everywhere, for the paths that reset all input state. + private void cancelAllKeyRepeats() { + keyRepeatCharged = false; + longPressCharged = false; + for (Window each : Desktop.getInstance().getWindows()) { + each.cancelKeyRepeat(); + } + } + + /// Whether any window has *both* key repeat and a long key press armed. The + /// single-flag predicate this replaces was `!keyRepeatCharged || + /// !longPressCharged`, i.e. false only when both were set. + private boolean anyKeyRepeatAndLongPressArmed() { + if (keyRepeatCharged && longPressCharged) { + return true; + } + for (Window each : Desktop.getInstance().getWindows()) { + if (each.hasKeyRepeatAndLongPressArmed()) { + return true; + } + } + return false; + } + + /// Whether any window still has key repeat or a long key press pending. + private boolean anyKeyRepeatArmed() { + if (keyRepeatCharged || longPressCharged) { + return true; + } + for (Window each : Desktop.getInstance().getWindows()) { + if (each.hasKeyRepeatArmed()) { + return true; + } + } + return false; + } + + /// Records a press that has not been released or dragged yet, for one window. + private void setSelectionPressed(int windowId, boolean value, int x, int y) { + if (windowId == 0) { + pointerPressedAndNotReleasedOrDragged = value; + return; + } + // Window zero is the main surface and keeps the field it has always kept. + // Every other window keeps its own, on the window -- so there is no table here + // to size, lease or reclaim. + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.setSelectionPressed(value, x, y); + } + } + + /// Clears the pressed selection for one window; the coordinates stop mattering + /// the moment the flag goes down. + private void clearSelectionPressed(int windowId) { + setSelectionPressed(windowId, false, 0, 0); + } + + /// Whether any window has a press down. What the component-less + /// shouldRenderSelection() answers, since it has nothing to resolve a window from. + private boolean anySelectionPressed() { + if (pointerPressedAndNotReleasedOrDragged) { + return true; + } + for (Window each : Desktop.getInstance().getWindows()) { + if (each.hasSelectionPressed()) { + return true; + } + } + return false; + } + + /// Clears every window's pressed selection. Used where the whole application + /// loses its input, which is not a per window event. + private void clearAllSelectionPressed() { + pointerPressedAndNotReleasedOrDragged = false; + for (Window each : Desktop.getInstance().getWindows()) { + each.setSelectionPressed(false, 0, 0); + } + } + + /// Records that a drag happened in one window. + private void setDragOccured(int windowId, boolean value) { + if (windowId == 0) { + dragOccured = value; + return; + } + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.setDragOccured(value); + } + } + + /// Starts timing a long press for one window. + /// + /// Per window rather than singleton for the same reason the press targets are: + /// with a contact down in two windows, pressing in the second replaced the + /// first's coordinates and timer, and releasing either cancelled the other's + /// pending long press. + private void chargeLongPress(int windowId, int x, int y) { + if (windowId == 0) { + longPointerCharged = true; + longKeyPressTime = System.currentTimeMillis(); + return; + } + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.chargeLongPointerPress(x, y); + } + } + + /// Cancels the long press pending for one window, leaving other windows alone. + private void cancelLongPress(int windowId) { + if (windowId == 0) { + longPointerCharged = false; + return; + } + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.cancelLongPointerPress(); + } + } + + /// Whether any window is still timing a long press; the event dispatch thread + /// must not park while one is pending. + private boolean anyLongPressArmed() { + if (longPointerCharged) { + return true; + } + for (Window each : Desktop.getInstance().getWindows()) { + if (each.hasLongPointerArmed()) { + return true; + } + } + return false; + } + + /// Cancels every pending long press, for the paths that reset all input state. + private void cancelAllLongPresses() { + longPointerCharged = false; + for (Window each : Desktop.getInstance().getWindows()) { + each.cancelLongPointerPress(); + } + } + + /// Records which top level saw a pointer press in the given window. + private void rememberPointerPress(int windowId, Container target) { + if (windowId == 0) { + mainPointerPressTarget = target; + return; + } + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.rememberPointerPress(target); + } + } + + /// Returns and forgets the top level that saw this window's pointer press. + private Container takePointerPressTarget(int windowId) { + if (windowId == 0) { + Container out = mainPointerPressTarget; + mainPointerPressTarget = null; + return out; + } + Window w = Desktop.getInstance().windowById(windowId); + return w == null ? null : w.takePointerPressTarget(); + } + + /// Whether a pointer press is recorded for the window, without consuming it. + private boolean hasPointerPressTarget(int windowId) { + if (windowId == 0) { + return mainPointerPressTarget != null; + } + Window w = Desktop.getInstance().windowById(windowId); + return w != null && w.hasPointerPressTarget(); + } + + /// Records which top level saw a key press, so its release can be matched to it. + /// Called on the event dispatch thread only. + /// Records which top level is holding this key, and answers the one the press + /// belongs to. + /// + /// #### Returns + /// + /// the top level that saw this key go down, which is `target` for a fresh press + /// and the remembered one for a repeat + private Container rememberKeyPress(int keyCode, Container target) { + int free = -1; + for (int iter = 0; iter < TRACKED_KEY_PRESSES; iter++) { + if (keyPressTargets[iter] != null && keyPressCodes[iter] == keyCode) { + // Already held. The native ports forward every autorepeat as another + // press, so replacing the target here handed the key to whichever + // window had focus when the repeat arrived -- and the eventual key-up + // then went there instead of to the window that saw the original + // press, leaving a fire-key-activated Button stuck down. + return keyPressTargets[iter]; + } + if (free < 0 && keyPressTargets[iter] == null) { + free = iter; + } + } + if (free >= 0) { + keyPressCodes[free] = keyCode; + keyPressTargets[free] = target; + } + return target; + } + + /// Forgets every key this window saw go down. Keyed by key code because several + /// keys can be held at once, which is why this one table is not per window and + /// still needs clearing when a window leaves. + private void forgetKeyPressesFor(Container w) { + for (int iter = 0; iter < TRACKED_KEY_PRESSES; iter++) { + if (keyPressTargets[iter] == w) { //NOPMD CompareObjectsWithEquals + keyPressTargets[iter] = null; + keyPressCodes[iter] = 0; } } + } - lastSizeChangeEventWH = w + h; - addSizeChangeEvent(SIZE_CHANGED, w, h); + /// Returns and forgets the top level that saw this key's press, or null when + /// there is no record of one. + private Container takeKeyPressTarget(int keyCode) { + for (int iter = 0; iter < TRACKED_KEY_PRESSES; iter++) { + if (keyPressTargets[iter] != null && keyPressCodes[iter] == keyCode) { + Container out = keyPressTargets[iter]; + keyPressTargets[iter] = null; + keyPressCodes[iter] = 0; + return out; + } + } + return null; } private void addNotifyEvent(int type) { @@ -2547,10 +3180,10 @@ private void addNotifyEvent(int type) { /// Broadcasts hide notify into Codename One, this method is invoked by the Codename One implementation /// to notify Codename One of hideNotify events public void hideNotify() { - keyRepeatCharged = false; - longPressCharged = false; - longPointerCharged = false; - pointerPressedAndNotReleasedOrDragged = false; + cancelAllKeyRepeats(); + cancelAllLongPresses(); + // Every window, not just the main one: the application is losing its input. + clearAllSelectionPressed(); addNotifyEvent(HIDE_NOTIFY); } @@ -2567,22 +3200,46 @@ boolean shouldEDTSleepNoFormAnimation() { synchronized (lock) { b = inputEventStackPointer == 0 && hasNoSerialCallsPending() && - (!keyRepeatCharged || !longPressCharged); + // Deliberately "not both", which is what the single-flag version + // meant: (!keyRepeatCharged || !longPressCharged). Collapsing it + // to "neither armed" is a different predicate and made this + // report not-idle far more often, which stalled the flush. + !anyKeyRepeatAndLongPressArmed(); } return b; } - private void updateDragSpeedStatus(int x, int y, int timestamp) { - //save dragging input to calculate the dragging speed later - dragPathX[dragPathOffset] = x; - dragPathY[dragPathOffset] = y; - dragPathTime[dragPathOffset] = displayInitTime + (long) timestamp; - if (dragPathLength < PATHLENGTH) { - dragPathLength++; + private void updateDragSpeedStatus(int windowId, int x, int y, int timestamp) { + if (windowId == 0) { + //save dragging input to calculate the dragging speed later + dragPathX[dragPathOffset] = x; + dragPathY[dragPathOffset] = y; + dragPathTime[dragPathOffset] = displayInitTime + (long) timestamp; + if (dragPathLength < PATHLENGTH) { + dragPathLength++; + } + dragPathOffset++; + if (dragPathOffset >= PATHLENGTH) { + dragPathOffset = 0; + } + return; + } + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.recordDrag(x, y, timestamp); } - dragPathOffset++; - if (dragPathOffset >= PATHLENGTH) { + } + + /// Clears one window's drag history, on press and on disposal. + private void resetDragHistory(int windowId) { + if (windowId == 0) { + dragPathLength = 0; dragPathOffset = 0; + return; + } + Window w = Desktop.getInstance().windowById(windowId); + if (w != null) { + w.resetDragHistory(); } } @@ -2600,195 +3257,353 @@ private int[] readArrayStackArgument(int[] stack, int offset) { } /// Invoked on the EDT to propagate the event - private int handleEvent(int offset, int[] inputEventStackTmp) { - Form f = getCurrentUpcomingForm(true); - - // might happen when returning from a deinitialized version of Codename One - if (f == null) { - return offset; - } - - // no need to synchronize since we are reading only and modifying the stack frame offset - int type = inputEventStackTmp[offset]; - offset++; + private int handleEvent(int offset, int[] inputEventStackTmp, int[] pointerMetaTmp) { + // The window id is packed into the high bits of the type word. Window 0 is + // the main surface, and for it the packed word is numerically identical to + // what it always was, so the main path is unchanged. + int packed = inputEventStackTmp[offset]; + int type = packed & 0xFF; + int windowId = packed >>> 8; + + // Restore the metadata that arrived with this packet. The port reports it into + // a single mutable record, and a port that drains a burst of pointer messages + // overwrites that record several times before any of them is dispatched -- so + // without this every event in the burst would build its PointerEvent from the + // last packet's button and device type. + if (isPointerEvent(type) && offset < pointerMetaTmp.length) { + impl.selectPointerEventMetadata(pointerMetaTmp[offset]); + } + + Container f; + if (windowId == 0) { + f = getCurrentUpcomingForm(true); + } else { + f = Desktop.getInstance().windowById(windowId); + } + + // might happen when returning from a deinitialized version of Codename One, + // or when a window was disposed while its events were still in flight + // A packet already queued when the window was hidden would otherwise be + // dispatched into an invisible tree -- and a press among them would re-arm + // the very timers the hide just cancelled. Cancelling at the transition + // cannot close that race on its own, because these are already in flight. + boolean hidden = windowId > 0 && f != null && !f.isTopLevelShowing(); + if (f == null || (isUserInputEvent(type) && hidden) + || (isUserInputEvent(type) && Desktop.getInstance().isWindowInputBlocked(windowId) + && !completesAcceptedPress(type, windowId, offset, inputEventStackTmp))) { + // A press that is being filtered must not leave its long-press timer + // armed. The timer is charged off the event dispatch thread when the + // press is queued, before modality has had a say, and the event + // dispatch thread later fires longPointerPress directly without + // consulting the filter again -- so a context menu could open behind an + // application modal for a press the component never received. + if (type == POINTER_PRESSED || type == POINTER_PRESSED_MULTI) { + cancelLongPress(windowId); + } + // The same for the keyboard. keyPressedImpl arms this window's key repeat + // and long-key timers before modality has had a say, and the paint loop + // fires keyRepeated and longKeyPress directly without consulting the + // filter again -- so holding a key could drive a component behind a modal + // that never received the press. I fixed the pointer half of this and did + // not check the keyboard half at the time. + if (type == KEY_PRESSED) { + cancelKeyRepeat(windowId); + } + // NOTE: drain the packet rather than returning offset unchanged. The + // caller loops while (offset < end), so returning it unchanged spins the + // EDT forever, and returning a sentinel would drop the rest of the batch + // -- which may contain main form events. + return skipEvent(type, offset + 1, inputEventStackTmp); + } + + // Which window's samples getDragSpeed should report while this packet's + // handlers run. Saved and restored around the dispatch rather than simply + // assigned: a listener may call invokeAndBlock, whose nested event loop + // dispatches another window's packets, and without restoring it the rest of + // *this* release would read the nested window's drag state. + final int previousDragHistory = dragHistoryCurrent; + dragHistoryCurrent = windowId; + try { - switch (type) { - case KEY_PRESSED: - f.keyPressed(inputEventStackTmp[offset]); - offset++; - eventForm = f; - break; - case KEY_RELEASED: - // pointer release can cycle into invoke and block which will cause this method - // to recurse if a pointer will be released while we are in an invoke and block state - // this is the case in http://code.google.com/p/codenameone/issues/detail?id=265 - Form xf = eventForm; - eventForm = null; - - //make sure the released event is sent to the same Form who got a - //pressed event - if (xf == f || multiKeyMode) { //NOPMD CompareObjectsWithEquals - f.keyReleased(inputEventStackTmp[offset]); + // no need to synchronize since we are reading only and modifying the stack frame offset + offset++; + + switch (type) { + case KEY_PRESSED: + // Dispatched to the top level that saw the key go down, not to the + // one this packet names -- the same rule the release already + // follows. A repeat names whichever window has focus now, so once + // focus moves mid-hold the repeats landed in the new window while + // the key-up still went to the old one: the new window entered its + // pressed state and no release was ever coming for it. + Container pressTarget = rememberKeyPress(inputEventStackTmp[offset], f); + pressTarget.keyPressed(inputEventStackTmp[offset]); offset++; - } - break; - case POINTER_PRESSED: - if (recursivePointerReleaseA) { - recursivePointerReleaseB = true; - } - dragOccured = false; - dragPathLength = 0; - pointerPressedAndNotReleasedOrDragged = true; - xArray1[0] = inputEventStackTmp[offset]; - offset++; - yArray1[0] = inputEventStackTmp[offset]; - offset++; - currentPointerEvent = impl.buildPointerEvent(xArray1[0], yArray1[0], false); - f.pointerPressed(xArray1, yArray1); - eventForm = f; - break; - case POINTER_PRESSED_MULTI: { - if (recursivePointerReleaseA) { - recursivePointerReleaseB = true; - } - dragOccured = false; - dragPathLength = 0; - pointerPressedAndNotReleasedOrDragged = true; - int[] array1 = readArrayStackArgument(inputEventStackTmp, offset); - offset += array1.length + 1; - int[] array2 = readArrayStackArgument(inputEventStackTmp, offset); - offset += array2.length + 1; - currentPointerEvent = impl.buildPointerEvent(array1[0], array2[0], false); - f.pointerPressed(array1, array2); - eventForm = f; - break; - } - case POINTER_RELEASED: - recursivePointerReleaseA = true; - pointerPressedAndNotReleasedOrDragged = false; - - // pointer release can cycle into invoke and block which will cause this method - // to recurse if a pointer will be released while we are in an invoke and block state - // this is the case in http://code.google.com/p/codenameone/issues/detail?id=265 - Form x = eventForm; - eventForm = null; - - // make sure the released event is sent to the same Form that got a - // pressed event - if (x == f || f.shouldSendPointerReleaseToOtherForm()) { //NOPMD CompareObjectsWithEquals + break; + case KEY_RELEASED: + // pointer release can cycle into invoke and block which will cause this method + // to recurse if a pointer will be released while we are in an invoke and block state + // this is the case in http://code.google.com/p/codenameone/issues/detail?id=265 + //make sure the released event is sent to the same Form who got a + //pressed event + int releasedKey = inputEventStackTmp[offset]; + Container xf = takeKeyPressTarget(releasedKey); + offset++; + if (xf != null) { + // Delivered to the top level that saw the press, not to the one + // the key-up packet names. A desktop window system sends key-up + // to whatever is focused now, so releasing a key after focus has + // moved reports the new window -- and matching on that dropped + // the release, latching the pressed component in the old one. + // For the single window case the two are the same top level, so + // this is the behaviour that was always there. + xf.keyReleased(releasedKey); + } else if (multiKeyMode) { + // No record of the press: either it arrived before this window + // existed or the tracking table was full. Multi key mode has + // always delivered these anyway. + f.keyReleased(releasedKey); + } + break; + case POINTER_PRESSED: + if (recursivePointerReleaseA) { + recursivePointerReleaseB = true; + } + setDragOccured(windowId, false); + resetDragHistory(windowId); xArray1[0] = inputEventStackTmp[offset]; offset++; yArray1[0] = inputEventStackTmp[offset]; offset++; + // After the coordinates are decoded, because the press is recorded + // with them: the selection test compares them against the pressed + // component's own bounds. + setSelectionPressed(windowId, true, xArray1[0], yArray1[0]); currentPointerEvent = impl.buildPointerEvent(xArray1[0], yArray1[0], false); - f.pointerReleased(xArray1, yArray1); + // Recorded before the dispatch, not after. A pressed callback can + // enter a nested loop -- showModal() does -- and the matching + // release can be processed inside it; with the record made + // afterwards that release saw no accepted press and was + // discarded, and the record then landed stale, latching the + // component and misrouting the next release. + rememberPointerPress(windowId, f); + f.pointerPressed(xArray1, yArray1); + break; + case POINTER_PRESSED_MULTI: { + if (recursivePointerReleaseA) { + recursivePointerReleaseB = true; + } + setDragOccured(windowId, false); + resetDragHistory(windowId); + int[] array1 = readArrayStackArgument(inputEventStackTmp, offset); + offset += array1.length + 1; + int[] array2 = readArrayStackArgument(inputEventStackTmp, offset); + offset += array2.length + 1; + // Same ordering reason as the single-pointer branch. + setSelectionPressed(windowId, true, array1[0], array2[0]); + currentPointerEvent = impl.buildPointerEvent(array1[0], array2[0], false); + // Same ordering as the single-pointer branch above. + rememberPointerPress(windowId, f); + f.pointerPressed(array1, array2); + break; } - recursivePointerReleaseA = false; - recursivePointerReleaseB = false; - break; - case POINTER_RELEASED_MULTI: - recursivePointerReleaseA = true; - pointerPressedAndNotReleasedOrDragged = false; - - // pointer release can cycle into invoke and block which will cause this method - // to recurse if a pointer will be released while we are in an invoke and block state - // this is the case in http://code.google.com/p/codenameone/issues/detail?id=265 - Form xy = eventForm; - eventForm = null; - - // make sure the released event is sent to the same Form that got a - // pressed event - if (xy == f || f.shouldSendPointerReleaseToOtherForm()) { //NOPMD CompareObjectsWithEquals + case POINTER_RELEASED: + recursivePointerReleaseA = true; + clearSelectionPressed(windowId); + + // pointer release can cycle into invoke and block which will cause this method + // to recurse if a pointer will be released while we are in an invoke and block state + // this is the case in http://code.google.com/p/codenameone/issues/detail?id=265 + Container x = takePointerPressTarget(windowId); + + // make sure the released event is sent to the same Form that got a + // pressed event + int releasedX = inputEventStackTmp[offset]; + offset++; + int releasedY = inputEventStackTmp[offset]; + offset++; + if (x == f || f.shouldSendPointerReleaseToOtherForm()) { //NOPMD CompareObjectsWithEquals + xArray1[0] = releasedX; + yArray1[0] = releasedY; + currentPointerEvent = impl.buildPointerEvent(xArray1[0], yArray1[0], false); + f.pointerReleased(xArray1, yArray1); + } + recursivePointerReleaseA = false; + recursivePointerReleaseB = false; + // The gesture is over, so hand the ring back. Reclaimed here rather + // than only on disposal: entries were held for the life of the + // window, so a handful of long-lived windows could exhaust the table + // and leave a later window unable to record drag state at all. + // After the dispatch, since the release handlers read it. + // + // Unless a newer gesture has already started in this window: a + // release handler can enter invokeAndBlock, whose nested loop + // dispatches a fresh press, and that press records a target. Freeing + // the ring then would strip the replacement gesture of its velocity. + break; + case POINTER_RELEASED_MULTI: + recursivePointerReleaseA = true; + clearSelectionPressed(windowId); + + // pointer release can cycle into invoke and block which will cause this method + // to recurse if a pointer will be released while we are in an invoke and block state + // this is the case in http://code.google.com/p/codenameone/issues/detail?id=265 + Container xy = takePointerPressTarget(windowId); + + // make sure the released event is sent to the same Form that got a + // pressed event + int[] releasedMultiX = readArrayStackArgument(inputEventStackTmp, offset); + offset += releasedMultiX.length + 1; + int[] releasedMultiY = readArrayStackArgument(inputEventStackTmp, offset); + offset += releasedMultiY.length + 1; + if (xy == f || f.shouldSendPointerReleaseToOtherForm()) { //NOPMD CompareObjectsWithEquals + currentPointerEvent = impl.buildPointerEvent(releasedMultiX[0], releasedMultiY[0], false); + f.pointerReleased(releasedMultiX, releasedMultiY); + } + recursivePointerReleaseA = false; + recursivePointerReleaseB = false; + // The gesture is over, so hand the ring back. Reclaimed here rather + // than only on disposal: entries were held for the life of the + // window, so a handful of long-lived windows could exhaust the table + // and leave a later window unable to record drag state at all. + // After the dispatch, since the release handlers read it. + // + // Unless a newer gesture has already started in this window: a + // release handler can enter invokeAndBlock, whose nested loop + // dispatches a fresh press, and that press records a target. Freeing + // the ring then would strip the replacement gesture of its velocity. + break; + case POINTER_DRAGGED: { + setDragOccured(windowId, true); + int arg1 = inputEventStackTmp[offset]; + offset++; + int arg2 = inputEventStackTmp[offset]; + offset++; + int timestamp = inputEventStackTmp[offset]; + offset++; + updateDragSpeedStatus(windowId, arg1, arg2, timestamp); + clearSelectionPressed(windowId); + xArray1[0] = arg1; + yArray1[0] = arg2; + currentPointerEvent = impl.buildPointerEvent(arg1, arg2, false); + f.pointerDragged(xArray1, yArray1); + break; + } + case POINTER_DRAGGED_MULTI: { + setDragOccured(windowId, true); + clearSelectionPressed(windowId); int[] array1 = readArrayStackArgument(inputEventStackTmp, offset); offset += array1.length + 1; int[] array2 = readArrayStackArgument(inputEventStackTmp, offset); offset += array2.length + 1; currentPointerEvent = impl.buildPointerEvent(array1[0], array2[0], false); - f.pointerReleased(array1, array1); + f.pointerDragged(array1, array2); + break; + } + case POINTER_HOVER: { + int arg1 = inputEventStackTmp[offset]; + offset++; + int arg2 = inputEventStackTmp[offset]; + offset++; + int timestamp = inputEventStackTmp[offset]; + offset++; + updateDragSpeedStatus(windowId, arg1, arg2, timestamp); + xArray1[0] = arg1; + yArray1[0] = arg2; + currentPointerEvent = impl.buildPointerEvent(arg1, arg2, true); + f.pointerHover(xArray1, yArray1); + break; + } + case POINTER_HOVER_RELEASED: { + int arg1 = inputEventStackTmp[offset]; + offset++; + int arg2 = inputEventStackTmp[offset]; + offset++; + xArray1[0] = arg1; + yArray1[0] = arg2; + currentPointerEvent = impl.buildPointerEvent(arg1, arg2, true); + f.pointerHoverReleased(xArray1, yArray1); + break; + } + case POINTER_HOVER_PRESSED: { + int arg1 = inputEventStackTmp[offset]; + offset++; + int arg2 = inputEventStackTmp[offset]; + offset++; + xArray1[0] = arg1; + yArray1[0] = arg2; + currentPointerEvent = impl.buildPointerEvent(arg1, arg2, true); + f.pointerHoverPressed(xArray1, yArray1); + break; } - recursivePointerReleaseA = false; - recursivePointerReleaseB = false; - break; - case POINTER_DRAGGED: { - dragOccured = true; - int arg1 = inputEventStackTmp[offset]; - offset++; - int arg2 = inputEventStackTmp[offset]; - offset++; - int timestamp = inputEventStackTmp[offset]; - offset++; - updateDragSpeedStatus(arg1, arg2, timestamp); - pointerPressedAndNotReleasedOrDragged = false; - xArray1[0] = arg1; - yArray1[0] = arg2; - currentPointerEvent = impl.buildPointerEvent(arg1, arg2, false); - f.pointerDragged(xArray1, yArray1); - break; + case SIZE_CHANGED: + int w = inputEventStackTmp[offset]; + offset++; + int h = inputEventStackTmp[offset]; + offset++; + f.sizeChangedInternal(w, h); + break; + case HIDE_NOTIFY: + f.hideNotify(); + break; + case SHOW_NOTIFY: + f.showNotify(); + break; + default: + break; } + return offset; + + } finally { + dragHistoryCurrent = previousDragHistory; + } + } + + /// Consumes one event's payload without dispatching it, so that a packet aimed at + /// a window that has gone away does not desynchronise the rest of the batch. + /// + /// The lengths here mirror the switch in `#handleEvent(int, int[], int[])` exactly; the + /// multi touch forms are self describing, each array being a length followed by + /// that many values. + /// + /// #### Parameters + /// + /// - `type`: the event type, with the window id already stripped + /// + /// - `offset`: the offset just past the type word + /// + /// - `stack`: the event stack + /// + /// #### Returns + /// + /// the offset of the next event + private int skipEvent(int type, int offset, int[] stack) { + switch (type) { + case KEY_PRESSED: + case KEY_RELEASED: + return offset + 1; + case POINTER_PRESSED: + case POINTER_RELEASED: + case POINTER_HOVER_RELEASED: + case POINTER_HOVER_PRESSED: + case SIZE_CHANGED: + return offset + 2; + case POINTER_DRAGGED: + case POINTER_HOVER: + return offset + 3; + case POINTER_PRESSED_MULTI: + case POINTER_RELEASED_MULTI: case POINTER_DRAGGED_MULTI: { - dragOccured = true; - pointerPressedAndNotReleasedOrDragged = false; - int[] array1 = readArrayStackArgument(inputEventStackTmp, offset); - offset += array1.length + 1; - int[] array2 = readArrayStackArgument(inputEventStackTmp, offset); - offset += array2.length + 1; - currentPointerEvent = impl.buildPointerEvent(array1[0], array2[0], false); - f.pointerDragged(array1, array2); - break; - } - case POINTER_HOVER: { - int arg1 = inputEventStackTmp[offset]; - offset++; - int arg2 = inputEventStackTmp[offset]; - offset++; - int timestamp = inputEventStackTmp[offset]; - offset++; - updateDragSpeedStatus(arg1, arg2, timestamp); - xArray1[0] = arg1; - yArray1[0] = arg2; - currentPointerEvent = impl.buildPointerEvent(arg1, arg2, true); - f.pointerHover(xArray1, yArray1); - break; + int len1 = stack[offset]; + offset += len1 + 1; + int len2 = stack[offset]; + return offset + len2 + 1; } - case POINTER_HOVER_RELEASED: { - int arg1 = inputEventStackTmp[offset]; - offset++; - int arg2 = inputEventStackTmp[offset]; - offset++; - xArray1[0] = arg1; - yArray1[0] = arg2; - currentPointerEvent = impl.buildPointerEvent(arg1, arg2, true); - f.pointerHoverReleased(xArray1, yArray1); - break; - } - case POINTER_HOVER_PRESSED: { - int arg1 = inputEventStackTmp[offset]; - offset++; - int arg2 = inputEventStackTmp[offset]; - offset++; - xArray1[0] = arg1; - yArray1[0] = arg2; - currentPointerEvent = impl.buildPointerEvent(arg1, arg2, true); - f.pointerHoverPressed(xArray1, yArray1); - break; - } - case SIZE_CHANGED: - int w = inputEventStackTmp[offset]; - offset++; - int h = inputEventStackTmp[offset]; - offset++; - f.sizeChangedInternal(w, h); - break; case HIDE_NOTIFY: - f.hideNotify(); - break; case SHOW_NOTIFY: - f.showNotify(); - break; default: - break; + return offset; } - return offset; } /// This method should be invoked by components that broadcast events on the pointerReleased callback. @@ -2799,18 +3614,29 @@ private int handleEvent(int offset, int[] inputEventStackTmp) { /// /// true if a drag has occured since the last pointer pressed public boolean hasDragOccured() { - return dragOccured; + // The surface whose events are being dispatched, for the same reason + // getDragSpeed uses it: components ask during their own release handling. + if (dragHistoryCurrent == 0) { + return dragOccured; + } + Window w = Desktop.getInstance().windowById(dragHistoryCurrent); + return w != null && w.hasDragOccured(); } /// Returns true for a case where the EDT has nothing at all to do boolean shouldEDTSleep() { Form current = impl.getCurrentForm(); return ((current == null || (!current.hasAnimations())) && + !Desktop.getInstance().anyWindowHasAnimations() && (animationQueue == null || animationQueue.isEmpty()) && inputEventStackPointer == 0 && (!impl.hasPendingPaints()) && - hasNoSerialCallsPending() && !keyRepeatCharged - && !longPointerCharged) || (isMinimized() && hasNoSerialCallsPending()); + hasNoSerialCallsPending() && !anyKeyRepeatArmed() + && !anyLongPressArmed()) + // a minimized main window must not park the EDT while a tool window + // is still on screen and animating + || (isMinimized() && !Desktop.getInstance().hasVisibleWindows() + && hasNoSerialCallsPending()); } Form getCurrentInternal() { @@ -3001,6 +3827,42 @@ void repaint(final Animation cmp) { impl.repaint(cmp); } + // ---- desktop windows --------------------------------------------------------- + + /// Windows blocking input, innermost last. A modal window drops input aimed at + /// anything it blocks; enforcing this here rather than in the ports means + /// modality behaves identically on every platform, whether or not the platform + /// implements its own. + + /// Wakes the event dispatch thread, used when a window becomes visible before + /// the first form has been shown and the loop would otherwise still be parked. + void wakeEdt() { + synchronized (lock) { + lock.notifyAll(); + } + } + + /// Paints every open window after the main surface. Iterates by index and + /// re-reads the size because a nested event loop -- a modal dialog, or + /// invokeAndBlock -- can dispose a window part way through. + /// Repaints the main form and every open window. + /// + /// For work that finishes without knowing which top level is showing its result -- + /// an image that has just decoded, say. Repainting only the current form left that + /// result invisible in every window until something else happened to dirty one. + /// + /// Lives here rather than at the call site so `Desktop` and `Window` are not + /// referenced from code every application uses: on ParparVM that reference would + /// keep the whole window implementation alive in binaries that never open one. + /// `Display` already reaches `Desktop`, so this adds nothing. + void repaintTopLevels() { + Form current = getCurrent(); + if (current != null) { + current.repaint(); + } + Desktop.getInstance().repaintWindows(); + } + /// Converts the dips count to pixels, dips are roughly 1mm in length. This is a very rough estimate and not /// to be relied upon /// @@ -3418,13 +4280,17 @@ public boolean isClickTouchScreen() { /// /// the dragging speed public float getDragSpeed(boolean yAxis) { - float speed; - if (yAxis) { - speed = impl.getDragSpeed(dragPathY, dragPathTime, dragPathOffset, dragPathLength); - } else { - speed = impl.getDragSpeed(dragPathX, dragPathTime, dragPathOffset, dragPathLength); + // The surface whose events are being dispatched. Components call this from + // their own pointerReleased, so "the surface currently being serviced" is the + // one that owns the samples they mean. + if (dragHistoryCurrent == 0) { + if (yAxis) { + return impl.getDragSpeed(dragPathY, dragPathTime, dragPathOffset, dragPathLength); + } + return impl.getDragSpeed(dragPathX, dragPathTime, dragPathOffset, dragPathLength); } - return speed; + Window w = Desktop.getInstance().windowById(dragHistoryCurrent); + return w == null ? 0 : w.windowDragSpeed(yAxis); } /// Indicates whether Codename One should consider the bidi RTL algorithm @@ -3601,7 +4467,7 @@ public void setAllowMinimizing(boolean allowMinimizing) { /// /// the shouldRenderSelection public boolean shouldRenderSelection() { - return !pureTouch || pointerPressedAndNotReleasedOrDragged || lastInteractionWasKeypad; + return !pureTouch || anySelectionPressed() || lastInteractionWasKeypad; } /// This is an internal state flag relevant only for pureTouch mode (otherwise it @@ -3615,11 +4481,38 @@ public boolean shouldRenderSelection() { /// #### Returns /// /// the shouldRenderSelection + /// Whether the main surface is holding a press that falls inside the given + /// component. Package private: it exists so `Form` can answer + /// `Container#showsSelectionFor(Component)` from the pointer state that lives + /// here, rather than having this class ask what kind of top level it is looking + /// at. + /// + /// #### Parameters + /// + /// - `c`: the component to test + /// + /// #### Returns + /// + /// true if a live main surface press falls inside the component + boolean mainSurfacePressIsOver(Component c) { + return pointerPressedAndNotReleasedOrDragged && c.contains(pointerX, pointerY); + } + public boolean shouldRenderSelection(Component c) { if (c.isCellRenderer()) { return shouldRenderSelection(); } - return !pureTouch || lastInteractionWasKeypad || (pointerPressedAndNotReleasedOrDragged && c.contains(pointerX, pointerY)) || c.shouldRenderComponentSelection(); + // Asked of the component's own top level rather than resolved through a window + // id here: a window knows whether it is holding a press and where, and those + // coordinates only mean anything against its own components. + TopLevelContainer top = c.getTopLevelContainer(); + // A component with no top level still answers from the main surface's press, + // which is what this did before each top level owned the test. + boolean pressed = top == null + ? mainSurfacePressIsOver(c) + : top.asContainer().showsSelectionFor(c); + return !pureTouch || lastInteractionWasKeypad || pressed + || c.shouldRenderComponentSelection(); } /// A pure touch device has no focus showing when the user is using the touch @@ -7541,4 +8434,221 @@ public void run() { } + + /// Cancels every input timer and recorded press for a window that is no longer + /// reachable, without deregistering it. Called when a window is hidden: it stays + /// registered, so a repeat armed before it went away would keep firing into a + /// component tree the user cannot see. + /// Drops the input the application's main surface is holding, the way + /// `#windowInputCancelled(Window)` does for a window. + /// + /// The main surface is window zero and is not a registered `Window`, so the + /// window-keyed paths cannot reach it: its repeat and long-press timers are fields + /// here. Called when the platform reports that focus has left it -- activating a + /// secondary window, or another application -- because the key-up is then + /// delivered somewhere else and never disarms them. + void mainSurfaceInputCancelled() { + cancelKeyRepeat(0); + cancelLongPress(0); + // Keyed by key code rather than by surface, so entries pressed on the main + // form outlive the focus change unless they are cleared here. + Form current = getCurrent(); + if (current != null) { + forgetKeyPressesFor(current); + } + } + + void windowInputCancelled(Window w) { + int id = w.getWindowId(); + cancelKeyRepeat(id); + cancelLongPress(id); + // The implementation holds its own per-window input state -- the drag + // activation slot -- which these records cannot reach. + Display.impl.releaseWindowInputState(id); + // The key table is keyed by key code rather than by window -- several keys can + // be held at once -- so a departing window's entries still have to be cleared + // here. Its pointer press target went with the window itself. + forgetKeyPressesFor(w); + } + + void windowDisposed(Window w) { + Desktop.getInstance().forgetModal(w); + forgetKeyPressesFor(w); + cancelKeyRepeat(w.getWindowId()); + cancelLongPress(w.getWindowId()); + // As above: a window disposed mid-press never delivers a release, and its + // drag slot would be held by an id that can never come back. + Display.impl.releaseWindowInputState(w.getWindowId()); + } + + /// Same as `#fireMouseWheelEvent(int, int, int, int, boolean, int)`, for a wheel + /// event that arrived over a specific native window. + /// + /// A port with desktop windows has to route the wheel explicitly: the main form + /// version resolves the component from `#getCurrent()`, so a wheel over a second + /// window would either do nothing or scroll the main form's content instead. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created, or 0 for + /// the application's main surface + /// + /// - `x`: the pointer x position in window pixels + /// + /// - `y`: the pointer y position in window pixels + /// + /// - `scrollX`: the horizontal scroll amount in display pixels + /// + /// - `scrollY`: the vertical scroll amount in display pixels + /// + /// - `precise`: true if the deltas come from a high resolution device such as a trackpad + /// + /// - `modifiers`: bitmask of the held keyboard modifiers + /// + /// #### Returns + /// + /// true if a listener consumed the wheel event + boolean windowMouseWheelEventImpl(int windowId, int x, int y, int scrollX, int scrollY, + boolean precise, int modifiers) { + if (Desktop.getInstance().isWindowInputBlocked(windowId)) { + return true; + } + Container root; + if (windowId > 0) { + Window w = Desktop.getInstance().windowById(windowId); + // The same hidden check the packed input path applies. A wheel callback + // queued before its window was hidden still finds the window registered, + // and would dispatch into an invisible tree -- an unconsumed listener that + // hides its own window is the immediate case, since the synthetic press, + // drag and release queued after it start against a window that is gone. + if (w == null || !w.isWindowShowing()) { + return false; + } + root = w; + } else { + root = getCurrent(); + } + if (root == null) { + return false; + } + Component cmp; + try { + cmp = root.getComponentAt(x, y); + } catch (Throwable t) { + cmp = null; + } + if (cmp == null) { + return false; + } + com.codename1.ui.events.WheelEvent we = new com.codename1.ui.events.WheelEvent(cmp, x, y, scrollX, scrollY, precise, modifiers); + return cmp.fireMouseWheelEvent(we); + } + + /// Dispatches a magnify (pinch) gesture aimed at one native window. Invoked by the + /// implementation for a gesture that arrived over a secondary window; window 0 is + /// the application's main surface, which is what `#fireMagnifyGesture(int, int, float)` + /// reports. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the gesture x position in pixels, relative to that window + /// + /// - `y`: the gesture y position in pixels, relative to that window + /// + /// - `scale`: the magnification scale, larger than 1 zooms in and smaller than 1 zooms out + void windowMagnifyGestureImpl(int windowId, int x, int y, float scale) { + Container f = gestureRoot(windowId); + if (f == null) { + return; + } + Component cmp = gestureComponentAt(f, x, y); + if (cmp == null) { + cmp = f; + } + while (cmp != null) { + if (cmp.pinch(scale)) { + return; + } + cmp = cmp.getParent(); + } + } + + /// Dispatches a rotation (twist) gesture aimed at one native window. Invoked by the + /// implementation for a gesture that arrived over a secondary window; window 0 is + /// the application's main surface, which is what `#fireRotationGesture(int, int, float)` + /// reports. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the gesture x position in pixels, relative to that window + /// + /// - `y`: the gesture y position in pixels, relative to that window + /// + /// - `radians`: the incremental rotation in radians, positive is clockwise + void windowRotationGestureImpl(int windowId, int x, int y, float radians) { + Container f = gestureRoot(windowId); + if (f == null) { + return; + } + Component cmp = gestureComponentAt(f, x, y); + while (cmp != null) { + if (cmp.rotation(radians)) { + return; + } + cmp = cmp.getParent(); + } + } + + + /// Pushes a key release aimed at one native window into Codename One. + /// Invoked by the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `keyCode`: keycode of the key event + void keyReleasedImpl(int windowId, int keyCode) { + if (windowId > 0) { + cancelKeyRepeatForCode(keyCode); + addSingleArgumentEvent(KEY_RELEASED | (windowId << 8), keyCode); + } + } + + /// Pushes a hover press aimed at one native window into Codename One. Invoked by + /// the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the x position of the pointer, in window coordinates + /// + /// - `y`: the y position of the pointer, in window coordinates + void pointerHoverPressedImpl(int windowId, final int[] x, final int[] y) { + if (windowId > 0 && Desktop.getInstance().windowById(windowId) != null) { + addPointerEvent(POINTER_HOVER_PRESSED | (windowId << 8), x[0], y[0]); + } + } + + /// Pushes a hover release aimed at one native window into Codename One. Invoked by + /// the implementation, off the event dispatch thread. + /// + /// #### Parameters + /// + /// - `windowId`: the id the port was given when the window was created + /// + /// - `x`: the x position of the pointer, in window coordinates + /// + /// - `y`: the y position of the pointer, in window coordinates + void pointerHoverReleasedImpl(int windowId, final int[] x, final int[] y) { + if (windowId > 0 && Desktop.getInstance().windowById(windowId) != null) { + addPointerEvent(POINTER_HOVER_RELEASED | (windowId << 8), x[0], y[0]); + } + } + } diff --git a/CodenameOne/src/com/codename1/ui/EncodedImage.java b/CodenameOne/src/com/codename1/ui/EncodedImage.java index 4da70e94f00..7e50a291188 100644 --- a/CodenameOne/src/com/codename1/ui/EncodedImage.java +++ b/CodenameOne/src/com/codename1/ui/EncodedImage.java @@ -511,7 +511,13 @@ public void run() { hardCache = i; } cache = Display.getInstance().createSoftWeakRef(i); - Display.getInstance().getCurrent().repaint(); + // Every top level, not just the current form: an image + // decoded asynchronously has no idea where it is being + // displayed, and this is the only notification that its + // pixels arrived. Repainting the current form alone left + // it blank in every window -- and threw when no form was + // showing at all. + Display.getInstance().repaintTopLevels(); width = i.getWidth(); height = i.getHeight(); } diff --git a/CodenameOne/src/com/codename1/ui/Form.java b/CodenameOne/src/com/codename1/ui/Form.java index 9c65fc054ef..2d7ead9401c 100644 --- a/CodenameOne/src/com/codename1/ui/Form.java +++ b/CodenameOne/src/com/codename1/ui/Form.java @@ -65,8 +65,7 @@ /// will work whereas `form.animateLayout(200)` will fail. /// /// @author Chen Fishbein -public class Form extends Container { - private static final String Z_INDEX_PROP = "cn1$_zIndex"; +public class Form extends Container implements TopLevelContainer { static int activePeerCount; static int rippleX; static int rippleY; @@ -103,6 +102,12 @@ public class Form extends Container { /// /// Used in `Container#revalidate()`. boolean revalidateFromRoot = "true".equals(CN.getProperty("Form.revalidateFromRoot", "true")); + + /// {@inheritDoc} + @Override + boolean isRevalidateFromRoot() { + return revalidateFromRoot; + } private Command sourceCommand; private boolean globalAnimationLock; private Painter glassPane; @@ -331,6 +336,7 @@ public void removePasteListener(ActionListener l) { /// #### Parameters /// /// - `cnt`: The container to schedule for revalidation + @Override void revalidateLater(Container cnt) { if (!pendingRevalidateQueue.contains(cnt)) { // It doesn't need to be in queue more than once. @@ -361,10 +367,12 @@ void revalidateLater(Container cnt) { /// #### Parameters /// /// - `cnt`: The container to remove from the queue. + @Override void removeFromRevalidateQueue(Container cnt) { pendingRevalidateQueue.remove(cnt); } + @Override void flushRevalidateQueue() { if (!pendingRevalidateQueue.isEmpty()) { @@ -403,6 +411,7 @@ public void dispatchPaste(ActionEvent l) { /// /// The text selection support for this form. /// + @Override public TextSelection getTextSelection() { if (textSelection == null) { textSelection = new TextSelection(getContentPane()); @@ -422,6 +431,7 @@ public TextSelection getTextSelection() { /// - #setEnableCursors(boolean) /// /// - Component#setCursor(int) + @Override public boolean isEnableCursors() { return enableCursors; } @@ -435,6 +445,7 @@ public boolean isEnableCursors() { /// #### See also /// /// - Component#setCursor(int) + @Override public void setEnableCursors(boolean e) { this.enableCursors = e; } @@ -470,6 +481,7 @@ public void setSourceCommand(Command sourceCommand) { /// #### See also /// /// - #setCurrentInputDevice(com.codename1.ui.VirtualInputDevice) + @Override public VirtualInputDevice getCurrentInputDevice() { return currentInputDevice; } @@ -486,6 +498,7 @@ public VirtualInputDevice getCurrentInputDevice() { /// #### Throws /// /// - `Exception` + @Override public void setCurrentInputDevice(VirtualInputDevice device) throws Exception { if (currentInputDevice != null) { currentInputDevice.close(); @@ -526,6 +539,7 @@ public void setOverrideInvisibleAreaUnderVKB(int invisibleAreaUnderVKB) { /// #### See also /// /// - #setOverrideInvisibleAreaUnderVKB(int) + @Override public int getInvisibleAreaUnderVKB() { if (bottomPaddingMode) { return 0; @@ -588,6 +602,7 @@ public void setFormBottomPaddingEditingMode(boolean b) { /// - Container#setSafeArea(boolean) /// /// - Container#isSafeArea() + @Override public Rectangle getSafeArea() { if (safeAreaDirty) { Display.impl.getDisplaySafeArea(safeArea); @@ -767,6 +782,7 @@ public void setAlwaysTensile(boolean alwaysTensile) { /// #### Deprecated /// /// this is effectively invalidated by the newer animation framework + @Override public boolean grabAnimationLock() { if (globalAnimationLock) { return false; @@ -780,6 +796,7 @@ public boolean grabAnimationLock() { /// #### Deprecated /// /// this is effectively invalidated by the newer animation framework + @Override public void releaseAnimationLock() { globalAnimationLock = false; } @@ -794,6 +811,7 @@ public void releaseAnimationLock() { /// #### See also /// /// - Component#isEditing() + @Override public Component findCurrentlyEditingComponent() { return ComponentSelector.select("*", this).filter(new CurrentlyEditingFilter()).asComponent(); } @@ -870,6 +888,7 @@ public void setUIManager(UIManager uiManager) { /// #### Parameters /// /// - `l`: listener + @Override public void addShowListener(ActionListener l) { if (showListener == null) { showListener = new EventDispatcher(); @@ -882,6 +901,7 @@ public void addShowListener(ActionListener l) { /// #### Parameters /// /// - `l`: the listener + @Override public void removeShowListener(ActionListener l) { if (showListener == null) { return; @@ -926,6 +946,7 @@ public void removeOrientationListener(ActionListener l) { /// #### Parameters /// /// - `l`: listener + @Override public void addSizeChangedListener(ActionListener l) { if (sizeChangedListener == null) { sizeChangedListener = new EventDispatcher(); @@ -938,6 +959,7 @@ public void addSizeChangedListener(ActionListener l) { /// #### Parameters /// /// - `l`: the listener + @Override public void removeSizeChangedListener(ActionListener l) { if (sizeChangedListener == null) { return; @@ -948,6 +970,7 @@ public void removeSizeChangedListener(ActionListener l) { /// This method is only invoked when the underlying canvas for the form is hidden /// this method isn't called for form based events and is generally usable for /// suspend/resume based behavior + @Override protected void hideNotify() { setVisible(false); } @@ -955,6 +978,7 @@ protected void hideNotify() { /// This method is only invoked when the underlying canvas for the form is shown /// this method isn't called for form based events and is generally usable for /// suspend/resume based behavior + @Override protected void showNotify() { setVisible(true); } @@ -991,6 +1015,7 @@ public void setSafeAreaChanged() { /// - `w`: the new width of the Form /// /// - `h`: the new height of the Form + @Override void sizeChangedInternal(int w, int h) { int oldWidth = getWidth(); int oldHeight = getHeight(); @@ -1175,6 +1200,7 @@ void paintGlassImpl(Graphics g) { /// #### See also /// /// - com.codename1.ui.painter.PainterChain#installGlassPane(Form, com.codename1.ui.Painter) + @Override public Painter getGlassPane() { return glassPane; } @@ -1212,6 +1238,7 @@ public Painter getGlassPane() { /// /// - `glassPane`: @param glassPane a new glass pane to install. It is generally recommended to /// use a painter chain if more than one painter is required. + @Override public void setGlassPane(Painter glassPane) { this.glassPane = glassPane; repaint(); @@ -1259,6 +1286,7 @@ public void setTitleComponent(Label title, Transition t) { /// - `keyCode`: code on which to send the event /// /// - `listener`: listener to invoke when the key code released. + @Override public void addKeyListener(int keyCode, ActionListener listener) { if (keyListeners == null) { keyListeners = new HashMap>(); @@ -1273,6 +1301,7 @@ public void addKeyListener(int keyCode, ActionListener listener) { /// - `keyCode`: code on which the event is sent /// /// - `listener`: listener instance to remove + @Override public void removeKeyListener(int keyCode, ActionListener listener) { if (keyListeners == null) { return; @@ -1475,33 +1504,32 @@ protected void initLaf(UIManager uim) { } /// Gets the current dragged Component + @Override Component getDraggedComponent() { return dragged; } /// Sets the current dragged Component + @Override void setDraggedComponent(Component dragged) { this.dragged = LeadUtil.leadParentImpl(dragged); } - /// Returns true if the given dest component is in the column of the source component - private boolean isInSameColumn(Component source, Component dest) { - // workaround for NPE - if (source == null || dest == null) { - return false; - } - return Rectangle.intersects(source.getAbsoluteX(), 0, - source.getWidth(), Integer.MAX_VALUE, dest.getAbsoluteX(), dest.getAbsoluteY(), - dest.getWidth(), dest.getHeight()); + /// {@inheritDoc} + @Override + int getInitialPressX() { + return initialPressX; } - /// Returns true if the given dest component is in the row of the source component - private boolean isInSameRow(Component source, Component dest) { - return Rectangle.intersects(0, source.getAbsoluteY(), - Integer.MAX_VALUE, source.getHeight(), dest.getAbsoluteX(), dest.getAbsoluteY(), - dest.getWidth(), dest.getHeight()); + /// {@inheritDoc} + @Override + int getInitialPressY() { + return initialPressY; } + + + /// Default command is invoked when a user presses fire, this functionality works /// well in some situations but might collide with elements such as navigation /// and combo boxes. Use with caution. @@ -1645,10 +1673,17 @@ public boolean checkPopGuard(com.codename1.router.PopReason reason) { /// #### Returns /// /// a content pane instance + @Override public Container getContentPane() { return contentPane; } + /// {@inheritDoc} + @Override + public Container asContainer() { + return this; + } + /// This method returns the layered pane of the Form, the layered pane is laid /// on top of the content pane and is created lazily upon calling this method the layer /// will be created. This is equivalent to getLayeredPane(null, false). @@ -1656,6 +1691,7 @@ public Container getContentPane() { /// #### Returns /// /// the LayeredPane + @Override public Container getLayeredPane() { return getLayeredPane(null, false); } @@ -1672,56 +1708,9 @@ public Container getLayeredPane() { /// #### Returns /// /// the layered pane instance + @Override public Container getLayeredPane(Class c, boolean top) { - Container layeredPaneImpl = getLayeredPaneImpl(); - if (c == null) { - // NOTE: We need to use getChildrenAsList(true) rather than simply iterating - // over layeredPaneImpl because the latter won't find components while an animation - // is in progress.... We could end up adding a whole bunch of layered panes - // by accident - for (Component cmp : layeredPaneImpl.getChildrenAsList(true)) { - if (cmp != null && cmp.getClientProperty("cn1$_cls") == null) { - return (Container) cmp; - } - } - } - String n = c != null ? c.getName() : null; - // NOTE: We need to use getChildrenAsList(true) rather than simply iterating - // over layeredPaneImpl because the latter won't find components while an animation - // is in progress.... We could end up adding a whole bunch of layered panes - // by accident - java.util.List children = layeredPaneImpl.getChildrenAsList(true); - if (n != null) { - for (Component cmp : children) { - if (cmp != null && n.equals(cmp.getClientProperty("cn1$_cls"))) { - return (Container) cmp; - } - } - } - - Container cnt = new Container(); - int zIndex = 0; - int componentCount = children.size(); - if (top) { - if (componentCount > 0) { - Integer z = (Integer) children.get(componentCount - 1).getClientProperty(Z_INDEX_PROP); - if (z != null) { - zIndex = z.intValue(); - } - } - layeredPaneImpl.add(cnt); - } else { - if (componentCount > 0) { - Integer z = (Integer) children.get(0).getClientProperty(Z_INDEX_PROP); - if (z != null) { - zIndex = z.intValue(); - } - } - layeredPaneImpl.addComponent(0, cnt); - } - cnt.putClientProperty("cn1$_cls", n); - cnt.putClientProperty(Z_INDEX_PROP, zIndex); - return cnt; + return TopLevelSupport.layeredPane(getLayeredPaneImpl(), c, top); } /// Returns the layered pane for the class and if one doesn't exist a new one is created dynamically and returned @@ -1736,56 +1725,9 @@ public Container getLayeredPane(Class c, boolean top) { /// #### Returns /// /// the layered pane instance + @Override public Container getLayeredPane(Class c, int zIndex) { - Container layeredPaneImpl = getLayeredPaneImpl(); - - if (c == null) { - // NOTE: We need to use getChildrenAsList(true) rather than simply iterating - // over layeredPaneImpl because the latter won't find components while an animation - // is in progress.... We could end up adding a whole bunch of layered panes - // by accident - for (Component cmp : layeredPaneImpl.getChildrenAsList(true)) { - if (cmp != null && cmp.getClientProperty("cn1$_cls") == null) { - return (Container) cmp; - } - } - } - String n = c != null ? c.getName() : null; - // NOTE: We need to use getChildrenAsList(true) rather than simply iterating - // over layeredPaneImpl because the latter won't find components while an animation - // is in progress.... We could end up adding a whole bunch of layered panes - // by accident - java.util.List children = layeredPaneImpl.getChildrenAsList(true); - if (n != null) { - for (Component cmp : children) { - if (cmp != null && n.equals(cmp.getClientProperty("cn1$_cls"))) { - return (Container) cmp; - } - } - } - - Container cnt = new Container(); - cnt.putClientProperty(Z_INDEX_PROP, zIndex); - int len = children.size(); - int insertIndex = -1; - - for (int i = 0; i < len; i++) { - Component cmp = children.get(i); - Integer cmpZIndex = (Integer) cmp.getClientProperty(Z_INDEX_PROP); - int cmpZ = cmpZIndex == null ? 0 : cmpZIndex.intValue(); - if (cmpZ >= zIndex) { - insertIndex = i; - break; - } - } - - if (insertIndex == -1) { - layeredPaneImpl.add(cnt); - } else { - layeredPaneImpl.addComponent(insertIndex, cnt); - } - cnt.putClientProperty("cn1$_cls", n); - return cnt; + return TopLevelSupport.layeredPane(getLayeredPaneImpl(), c, zIndex); } /// Returns the layered pane for the class and if one doesn't exist a new one is created @@ -1802,6 +1744,7 @@ public Container getLayeredPane(Class c, int zIndex) { /// #### Returns /// /// the layered pane instance + @Override public Container getFormLayeredPane(Class c, boolean top) { if (formLayeredPane == null) { formLayeredPane = new Container(new LayeredLayout()) { @@ -1909,6 +1852,7 @@ private Container getLayeredPaneImpl() { return layeredPane; } + @Override Container getActualPane() { if (layeredPane != null) { return layeredPane.getParent(); @@ -1999,6 +1943,7 @@ public boolean isEditing() { /// #### Returns /// /// returns the form title + @Override public String getTitle() { if (toolbar != null) { Component cmp = toolbar.getTitleComponent(); @@ -2015,6 +1960,7 @@ public String getTitle() { /// #### Parameters /// /// - `title`: the form title + @Override public void setTitle(String title) { if (toolbar != null) { toolbar.setTitle(title); @@ -2178,6 +2124,46 @@ void removeComponentFromForm(Component cmp) { super.removeComponent(cmp); } + @Override + void addComponentToTopLevel(Object constraints, Component cmp) { + addComponentToForm(constraints, cmp); + } + + @Override + void removeComponentFromTopLevel(Component cmp) { + removeComponentFromForm(cmp); + } + + @Override + boolean isTopLevelShowing() { + return Display.getInstance().getCurrent() == this; //NOPMD CompareObjectsWithEquals + } + + @Override + int titleAreaHeight() { + return getTitleArea().getHeight(); + } + + @Override + boolean showsSelectionFor(Component c) { + return Display.getInstance().mainSurfacePressIsOver(c); + } + + @Override + void commandActivatedFromList(Command cmd, ActionEvent ev) { + actionCommandImpl(cmd); + } + + @Override + void commandActivatedFromComponent(Command cmd, ActionEvent ev) { + actionCommandImplNoRecurseComponent(cmd, ev); + } + + @Override + void setClearCommandInternal(Command cmd) { + setClearCommand(cmd); + } + /// Registering media component to this Form, that like to receive /// animation events /// @@ -2218,6 +2204,7 @@ void deregisterMediaComponent(Component mediaCmp) { /// #### Parameters /// /// - `cmp`: component that would be animated + @Override public final void registerAnimated(Animation cmp) { if (animatableComponents == null) { animatableComponents = new ArrayList(); @@ -2241,6 +2228,7 @@ protected void onRegisterAnimated(Animation cmp) { /// Identical to the none-internal version, the difference between the internal/none-internal /// is that it references a different vector that is unaffected by the user actions. /// That is why we can dynamically register/deregister without interfering with user interaction. + @Override void registerAnimatedInternal(Animation cmp) { if (cmp instanceof Component) { Component c = (Component) cmp; @@ -2261,6 +2249,7 @@ void registerAnimatedInternal(Animation cmp) { /// Identical to the none-internal version, the difference between the internal/none-internal /// is that it references a different vector that is unaffected by the user actions. /// That is why we can dynamically register/deregister without interfering with user interaction. + @Override void deregisterAnimatedInternal(Animation cmp) { if (internalAnimatableComponents != null) { if (cmp instanceof Component) { @@ -2279,6 +2268,7 @@ void deregisterAnimatedInternal(Animation cmp) { /// #### Parameters /// /// - `cmp`: component that would no longer receive animation events + @Override public void deregisterAnimated(Animation cmp) { if (animatableComponents != null) { animatableComponents.remove(cmp); @@ -2468,6 +2458,7 @@ public void setTransitionOutAnimator(Transition transitionOutAnimator) { /// #### Parameters /// /// - `l`: the command action listener + @Override public void addCommandListener(ActionListener l) { if (commandListener == null) { commandListener = new EventDispatcher(); @@ -2481,6 +2472,7 @@ public void addCommandListener(ActionListener l) { /// #### Parameters /// /// - `l`: the command action listener + @Override public void removeCommandListener(ActionListener l) { commandListener.removeListener(l); } @@ -2502,6 +2494,7 @@ protected void actionCommand(Command cmd) { /// - `cmd`: The command to dispatch /// /// - `ev`: the event to dispatch + @Override public void dispatchCommand(Command cmd, ActionEvent ev) { cmd.actionPerformed(ev); if (!ev.isConsumed()) { @@ -2604,6 +2597,7 @@ void initFocused() { } /// Displays the current form on the screen + @Override public void show() { Display.impl.onShow(this); show(false); @@ -2967,6 +2961,18 @@ public final Form getComponentForm() { return this; } + /// {@inheritDoc} + /// + /// A `Form` terminates the walk unless it is itself embedded in another + /// hierarchy, exactly as `#getComponentForm()` does. + @Override + public TopLevelContainer getTopLevelContainer() { + if (getParent() != null) { + return super.getTopLevelContainer(); + } + return this; + } + /// Invoked by display to hide the menu during transition /// /// #### See also @@ -2985,6 +2991,7 @@ void restoreMenu() { menuBar.installMenuBar(); } + @Override void setFocusedInternal(Component focused) { this.focused = focused; } @@ -3038,13 +3045,15 @@ private boolean changeFocusState(Component cmp, boolean gained) { fireFocusLost(cmp); } - //if the styles are different there is a chance the preffered size is - //still the same therefore make sure there is a real need to preform - //a revalidate + // The styles can differ without the preferred size actually moving, so only + // revalidate when it really did. The test used to be inverted -- it cleared + // the trigger when the size *changed*, which dropped the revalidate in + // exactly the case that needs one and left neighbouring components at their + // old positions until some unrelated layout came along. if (trigger) { cmp.setShouldCalcPreferredSize(true); Dimension d = cmp.getPreferredSize(); - if (prefW != d.getWidth() || prefH != d.getHeight()) { + if (prefW == d.getWidth() && prefH == d.getHeight()) { cmp.setShouldCalcPreferredSize(false); trigger = false; } @@ -3058,6 +3067,7 @@ private boolean changeFocusState(Component cmp, boolean gained) { /// #### Returns /// /// the current focus component for this form + @Override public Component getFocused() { return focused; } @@ -3067,6 +3077,7 @@ public Component getFocused() { /// #### Parameters /// /// - `focused`: the newly focused component or null for no focus + @Override public void setFocused(Component focused) { if (this.focused == focused && focused != null) { //NOPMD CompareObjectsWithEquals this.focused.repaint(); @@ -3137,6 +3148,7 @@ public void longPointerPress(int x, int y) { /// #### Returns /// /// false by default + @Override protected boolean shouldSendPointerReleaseToOtherForm() { return false; } @@ -3193,10 +3205,27 @@ public Component getPreviousComponent(Component current) { /// - Component#getPreferredTabIndex() /// /// - Component#setPreferredTabIndex(int) + @Override public TabIterator getTabIterator(Component start) { - updateTabIndices(0); + return buildTabIterator(this, start); + } + + /// Builds the traversal order for a top level. Shared with `Window`, which needs + /// the identical ordering rules but is not a `Form`. + /// + /// #### Parameters + /// + /// - `root`: the top level to walk + /// + /// - `start`: the component to start from + /// + /// #### Returns + /// + /// the traversal iterator + static TabIterator buildTabIterator(Container root, Component start) { + root.updateTabIndices(0); java.util.List out = new ArrayList(); - out.addAll(ComponentSelector.select("*", this).filter(new TabIteratorFilter())); + out.addAll(ComponentSelector.select("*", root).filter(new TabIteratorFilter())); Collections.sort(out, new TabIteratorComparator()); return new TabIterator(out, start); } @@ -3448,6 +3477,7 @@ private void setPressedCmp(Component cmp) { /// Gets the handle for the current pointer press event. A new object /// is generated for each pointer press. /// + @Override Object getCurrentPointerPress() { return currentPointerPress; } @@ -3617,6 +3647,7 @@ private boolean isCurrentlyScrolling(Component cmp) { } + @Override public void addComponentAwaitingRelease(C c) { if (componentsAwaitingRelease == null) { componentsAwaitingRelease = new ArrayList(); @@ -3624,12 +3655,14 @@ public void addComponentAwaitingRelease(C c) { componentsAwaitingRelease.add(c); } + @Override public void removeComponentAwaitingRelease(C c) { if (componentsAwaitingRelease != null) { componentsAwaitingRelease.remove(c); } } + @Override public void clearComponentsAwaitingRelease() { if (componentsAwaitingRelease != null) { componentsAwaitingRelease.clear(); //componentsAwatingRelease = null; //can be set to null or cleared, would be the same. clear may save some unnecessary GC operations when some releasable components are pressed multiple times @@ -3937,6 +3970,7 @@ private void updateInteractiveScrollHover(Component cmp, int x, int y) { /// #### Returns /// /// true if there is one focusable component in this form, false for 0 or more + @Override public boolean isSingleFocusMode() { if (formLayeredPane != null) { return countFocusables(formLayeredPane) + countFocusables(getActualPane()) < 2; @@ -4235,6 +4269,7 @@ public void addCommand(Command cmd, int offset) { /// #### Deprecated /// /// Please use `Toolbar#getComponentCount()` or similar methods + @Override public int getCommandCount() { return menuBar.getCommandCount(); } @@ -4248,6 +4283,7 @@ public int getCommandCount() { /// #### Returns /// /// the command at the given index + @Override public Command getCommand(int index) { return menuBar.getCommand(index); } @@ -4267,6 +4303,7 @@ public Command getCommand(int index) { /// #### Deprecated /// /// Please use `Toolbar#addCommandToLeftBar(com.codename1.ui.Command)` or similar methods + @Override public final void addCommand(Command cmd) { //menuBar.addCommand(cmd); addCommand(cmd, 0); @@ -4277,100 +4314,12 @@ public final void addCommand(Command cmd) { /// #### Parameters /// /// - `cmd`: the Form command to be removed + @Override public void removeCommand(Command cmd) { menuBar.removeCommand(cmd); } - private Component findNextFocusHorizontal(Component focused, Component bestCandidate, Container root, boolean right) { - int count = root.getComponentCount(); - for (int iter = 0; iter < count; iter++) { - Component current = root.getComponentAt(iter); - if (current.isFocusable()) { - if (isInSameRow(focused, current)) { - int currentX = current.getAbsoluteX(); - int focusedX = focused.getAbsoluteX(); - if (right) { - if (focusedX < currentX) { - if (bestCandidate != null) { - if (bestCandidate.getAbsoluteX() < currentX) { - continue; - } - } - bestCandidate = current; - } - } else { - if (focusedX > currentX) { - if (bestCandidate != null) { - if (bestCandidate.getAbsoluteX() > currentX) { - continue; - } - } - bestCandidate = current; - } - } - } - } - if (current instanceof Container && !(((Container) current).isBlockFocus())) { - bestCandidate = findNextFocusHorizontal(focused, bestCandidate, (Container) current, right); - } - } - return bestCandidate; - } - private Component findNextFocusVertical(Component focused, Component bestCandidate, Container root, boolean down) { - int count = root.getComponentCount(); - for (int iter = 0; iter < count; iter++) { - Component current = root.getComponentAt(iter); - if (current.isFocusable()) { - int currentY = current.getAbsoluteY(); - int focusedY = 0; - if (focused != null) { - focusedY = focused.getAbsoluteY(); - } - if (down) { - if (focusedY < currentY) { - if (bestCandidate != null) { - boolean exitingInSame = isInSameColumn(focused, bestCandidate); - if (bestCandidate.getAbsoluteY() < currentY) { - if (exitingInSame) { - continue; - } - if (isInSameRow(current, bestCandidate) && !isInSameColumn(focused, current)) { - continue; - } - } - if (exitingInSame && isInSameRow(current, bestCandidate)) { - continue; - } - } - bestCandidate = current; - } - } else { - if (focusedY > currentY) { - if (bestCandidate != null) { - boolean exitingInSame = isInSameColumn(focused, bestCandidate); - if (bestCandidate.getAbsoluteY() > currentY) { - if (exitingInSame) { - continue; - } - if (isInSameRow(current, bestCandidate) && !isInSameColumn(focused, current)) { - continue; - } - } - if (exitingInSame && isInSameRow(current, bestCandidate)) { - continue; - } - } - bestCandidate = current; - } - } - } - if (current instanceof Container && !(((Container) current).isBlockFocus())) { - bestCandidate = findNextFocusVertical(focused, bestCandidate, (Container) current, down); - } - } - return bestCandidate; - } /// This method returns the next focusable Component vertically /// @@ -4388,23 +4337,23 @@ private Component findNextFocusVertical(Component focused, Component bestCandida public Component findNextFocusVertical(boolean down) { Component c = null; if (formLayeredPane != null) { - c = findNextFocusVertical(focused, null, formLayeredPane, down); + c = TopLevelSupport.findNextFocusVertical(focused, null, formLayeredPane, down); if (c != null) { return c; } } Container actual = getActualPane(); - c = findNextFocusVertical(focused, null, actual, down); + c = TopLevelSupport.findNextFocusVertical(focused, null, actual, down); if (c != null) { return c; } if (cyclicFocus) { - c = findNextFocusVertical(focused, null, actual, !down); + c = TopLevelSupport.findNextFocusVertical(focused, null, actual, !down); if (c != null) { - Component current = findNextFocusVertical(c, null, actual, !down); + Component current = TopLevelSupport.findNextFocusVertical(c, null, actual, !down); while (current != null) { c = current; - current = findNextFocusVertical(c, null, actual, !down); + current = TopLevelSupport.findNextFocusVertical(c, null, actual, !down); } return c; } @@ -4428,23 +4377,23 @@ public Component findNextFocusVertical(boolean down) { public Component findNextFocusHorizontal(boolean right) { Component c = null; if (formLayeredPane != null) { - c = findNextFocusHorizontal(focused, null, formLayeredPane, right); + c = TopLevelSupport.findNextFocusHorizontal(focused, null, formLayeredPane, right); if (c != null) { return c; } } Container actual = getActualPane(); - c = findNextFocusHorizontal(focused, null, actual, right); + c = TopLevelSupport.findNextFocusHorizontal(focused, null, actual, right); if (c != null) { return c; } if (cyclicFocus) { - c = findNextFocusHorizontal(focused, null, actual, !right); + c = TopLevelSupport.findNextFocusHorizontal(focused, null, actual, !right); if (c != null) { - Component current = findNextFocusHorizontal(c, null, actual, !right); + Component current = TopLevelSupport.findNextFocusHorizontal(c, null, actual, !right); while (current != null) { c = current; - current = findNextFocusHorizontal(c, null, actual, !right); + current = TopLevelSupport.findNextFocusHorizontal(c, null, actual, !right); } return c; } @@ -4454,6 +4403,7 @@ public Component findNextFocusHorizontal(boolean right) { /// Finds next focusable component. This will first check `Component#getNextFocusDown()` /// on the currently focused component. Failing that it will scan the form based on Y-coord. + @Override Component findNextFocusDown() { if (focused != null) { if (focused.getNextFocusDown() != null) { @@ -4466,6 +4416,7 @@ Component findNextFocusDown() { /// Finds next focusable component in upward direction. This will first check `Component#getNextFocusUp()` /// on the currently focused component. Failing that it will scan the form based on Y-coord. + @Override Component findNextFocusUp() { if (focused != null) { if (focused.getNextFocusUp() != null) { @@ -4478,6 +4429,7 @@ Component findNextFocusUp() { /// Finds next focusable component in rightward direction. This will first check `Component#getNextFocusRight()` /// on the currently focused component. Failing that it will scan the form based on X-coord. + @Override Component findNextFocusRight() { if (focused != null) { if (focused.getNextFocusRight() != null) { @@ -4490,6 +4442,7 @@ Component findNextFocusRight() { /// Finds next focusable component in leftward direction. This will first check `Component#getNextFocusLeft()` /// on the currently focused component. Failing that it will scan the form based on X-coord. + @Override Component findNextFocusLeft() { if (focused != null) { if (focused.getNextFocusLeft() != null) { @@ -4505,6 +4458,7 @@ Component findNextFocusLeft() { /// #### Returns /// /// true if focus should cycle + @Override public boolean isCyclicFocus() { return cyclicFocus; } @@ -4514,6 +4468,7 @@ public boolean isCyclicFocus() { /// #### Parameters /// /// - `cyclicFocus`: marks whether focus should cycle + @Override public void setCyclicFocus(boolean cyclicFocus) { this.cyclicFocus = cyclicFocus; } @@ -4650,6 +4605,7 @@ public void setMenuCellRenderer(ListCellRenderer menuCellRenderer) { } /// Clear menu commands from the menu bar + @Override public void removeAllCommands() { menuBar.removeAllCommands(); } @@ -4659,6 +4615,7 @@ public void removeAllCommands() { /// #### Parameters /// /// - `cmp`: the form child component + @Override void requestFocus(Component cmp) { if (cmp.isFocusable() && contains(cmp)) { scrollComponentToVisible(cmp); diff --git a/CodenameOne/src/com/codename1/ui/Label.java b/CodenameOne/src/com/codename1/ui/Label.java index 0e08ace3df5..1742bc49507 100644 --- a/CodenameOne/src/com/codename1/ui/Label.java +++ b/CodenameOne/src/com/codename1/ui/Label.java @@ -593,7 +593,7 @@ void initComponentImpl() { // solves the case of a user starting a ticker before adding the component // into the container if (isTickerEnabled() && isTickerRunning() && !isCellRenderer()) { - getComponentForm().registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(this, this); } checkAnimation(); if (maskName != null && mask == null) { @@ -612,10 +612,7 @@ void initComponentImpl() { @Override void deinitializeImpl() { super.deinitializeImpl(); - Form f = getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); - } + deregisterFromAnimation(); if (getIcon() != null) { getIcon().removeActionListener(iconChangeListener); @@ -656,12 +653,7 @@ public void setText(String text) { void checkAnimation() { super.checkAnimation(); if (icon != null && icon.isAnimation()) { - Form parent = getComponentForm(); - if (parent != null) { - // animations are always running so the internal animation isn't - // good enough. We never want to stop this sort of animation - parent.registerAnimated(this); - } + registerForAnimation(); } } @@ -1073,9 +1065,9 @@ public void startTicker(long delay, boolean rightToLeft) { return; } if (!isCellRenderer()) { - Form parent = getComponentForm(); + TopLevelContainer parent = getTopLevelContainer(); if (parent != null) { - parent.registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(parent, this); } } tickerStartTime = AnimationTime.now(); diff --git a/CodenameOne/src/com/codename1/ui/LeadUtil.java b/CodenameOne/src/com/codename1/ui/LeadUtil.java index aef6d100732..31587837778 100644 --- a/CodenameOne/src/com/codename1/ui/LeadUtil.java +++ b/CodenameOne/src/com/codename1/ui/LeadUtil.java @@ -98,10 +98,12 @@ public static void pointerPressed(Component cmp, int x, int y) { } Component lead = leadComponentImpl(cmp); lead.pointerPressed(x, y); - Form f = cmp.getComponentForm(); + // The top level, not the Form: getComponentForm() is null inside a Window, so + // resolving through it silently skipped focus for every lead component there. + TopLevelContainer t = cmp.getTopLevelContainer(); Component leadParent = leadParentImpl(cmp); - if (f != null && !Display.impl.isScrollWheeling() && leadParent.isFocusable() && leadParent.isEnabled()) { - f.setFocused(leadParent); + if (t != null && !Display.impl.isScrollWheeling() && leadParent.isFocusable() && leadParent.isEnabled()) { + t.setFocused(leadParent); } if (cmp != lead) { //NOPMD CompareObjectsWithEquals leadParent.repaint(); @@ -238,9 +240,13 @@ public static void dragInitiated(Component cmp) { if (cmp == null) { return; } - Form f = cmp.getComponentForm(); - if (f != null) { - Component fc = f.getFocused(); + // Resolved through the top level rather than the Form. This is what cancels a + // button press once the pointer leaves it, and getComponentForm() is null + // inside a Window -- so the whole method did nothing there and a press dragged + // out of a button still fired on release. + TopLevelContainer t = cmp.getTopLevelContainer(); + if (t != null) { + Component fc = t.getFocused(); if (fc != null) { fc.dragInitiated(); } diff --git a/CodenameOne/src/com/codename1/ui/List.java b/CodenameOne/src/com/codename1/ui/List.java index ac24ac2851d..cda9f7147b1 100644 --- a/CodenameOne/src/com/codename1/ui/List.java +++ b/CodenameOne/src/com/codename1/ui/List.java @@ -618,11 +618,11 @@ public void setSelectedIndex(int index, boolean scrollToSelection) { accessibilityChanged(AccessibilityManager.CHANGE_STATE | AccessibilityManager.CHANGE_VALUE); } if (!isInitialized()) { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f == null) { return; } - f.revalidate(); + f.asContainer().revalidate(); } if (scrollToSelection/* && isInitialized() */) { selectElement(index); @@ -966,7 +966,7 @@ public void scrollRectToVisible(Rectangle rect) { /// {@inheritDoc} @Override public void setHandlesInput(boolean b) { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { // prevent the list from losing focus if its the only element // or when the user presses fire and there is no other component @@ -1154,9 +1154,9 @@ private void updateAnimationPosition(int direction) { } private void initListMotion() { - Form p = getComponentForm(); + TopLevelContainer p = getTopLevelContainer(); if (p != null) { - p.registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(p, this); } listMotion = Motion.createSplineMotion(0, destination, getScrollAnimationSpeed()); listMotion.start(); @@ -1666,7 +1666,13 @@ protected void fireActionEvent() { protected void fireActionEvent(ActionEvent a) { if (isEnabled() && !Display.getInstance().hasDragOccured()) { if (disposeDialogOnSelection) { - getComponentForm().dispose(); + // Form only on purpose: this disposes the enclosing Dialog, and Dialog + // is documented as unsupported inside a Window. Form.dispose() means + // "pop back to the previous form", which a Window has no notion of. + Form disposing = getComponentForm(); + if (disposing != null) { + disposing.dispose(); + } } super.fireActionEvent(); dispatcher.fireActionEvent(a); @@ -1675,9 +1681,14 @@ protected void fireActionEvent(ActionEvent a) { if (i != null && i instanceof Command && ((Command) i).isEnabled()) { ((Command) i).actionPerformed(a); if (!a.isConsumed()) { - Form f = getComponentForm(); - if (f != null) { - f.actionCommandImpl((Command) i); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so a command list there invoked the + // command and then told nobody -- the window's command listeners + // never saw the activation. Neither branch re-invokes the + // command, which has just run above. + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + top.asContainer().commandActivatedFromList((Command) i, a); } } } @@ -2086,9 +2097,9 @@ private void pointerReleasedImpl(int x, int y, boolean isHover, boolean longPres fixedDraggedMotion = Motion.createFrictionMotion(-fixedDraggedAnimationPosition, Integer.MAX_VALUE, speed, 0.0007f); fixedDraggedPosition = fixedDraggedAnimationPosition; - Form p = getComponentForm(); + TopLevelContainer p = getTopLevelContainer(); if (p != null) { - p.registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(p, this); } fixedDraggedMotion.start(); } diff --git a/CodenameOne/src/com/codename1/ui/Monitor.java b/CodenameOne/src/com/codename1/ui/Monitor.java new file mode 100644 index 00000000000..b37687b49ab --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/Monitor.java @@ -0,0 +1,184 @@ +/* + * 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.ui; + +import com.codename1.ui.geom.Rectangle; + +/// One physical display attached to the desktop. +/// +/// A `Window` sits on exactly one monitor at a time and takes its scale and density +/// from that monitor, so two windows of the same application can legitimately render +/// at different scales. Obtain instances through `Desktop#getMonitors()`. +/// +/// Instances are snapshots. A monitor that is unplugged, moved or has its resolution +/// changed produces a fresh set, announced through +/// `Desktop#addMonitorListener(com.codename1.ui.events.ActionListener)`. +/// +/// @author Shai Almog +public final class Monitor { + + private final int index; + private final Rectangle bounds; + private final Rectangle workArea; + private final int density; + private final double scale; + private final int dotsPerInch; + private final String name; + private final boolean primary; + + Monitor(int index, Rectangle bounds, Rectangle workArea, int density, double scale, + int dotsPerInch, String name, boolean primary) { + this.index = index; + this.bounds = bounds; + this.workArea = workArea; + this.density = density; + this.scale = scale; + this.dotsPerInch = dotsPerInch; + this.name = name; + this.primary = primary; + } + + int getIndex() { + return index; + } + + /// Returns the monitor's full area in desktop coordinates. + /// + /// Desktop coordinates span every monitor, so a secondary display placed left of + /// or above the primary one legitimately has a negative origin. + /// + /// #### Returns + /// + /// a copy of the monitor bounds + public Rectangle getBounds() { + return new Rectangle(bounds); + } + + /// Returns the part of the monitor that is actually usable by a window, with the + /// task bar, dock and any reserved panels excluded. + /// + /// Prefer this over `#getBounds()` when placing or maximising a window. + /// + /// #### Returns + /// + /// a copy of the usable area + public Rectangle getWorkArea() { + return new Rectangle(workArea); + } + + /// Returns the density bucket of this monitor, as one of the `Display` density + /// constants. + /// + /// #### Returns + /// + /// the density constant + public int getDensity() { + return density; + } + + /// Returns the backing scale of this monitor: one for a conventional display, two + /// for a high resolution one, and fractional values on platforms that allow them. + /// + /// #### Returns + /// + /// the scale factor + public double getScale() { + return scale; + } + + /// Returns the resolution of this monitor in dots per inch. + /// + /// #### Returns + /// + /// the dots per inch + public int getDotsPerInch() { + return dotsPerInch; + } + + /// Returns a name for this monitor suitable for showing to a person. + /// + /// #### Returns + /// + /// the monitor name + public String getName() { + return name; + } + + /// Indicates whether this is the primary monitor, the one the platform treats as + /// the origin of the desktop. + /// + /// #### Returns + /// + /// true if this is the primary monitor + public boolean isPrimary() { + return primary; + } + + /// {@inheritDoc} + /// + /// Every property counts, not just the index and the bounds. A monitor can be + /// reconfigured without either changing -- display scaling adjusted, a taskbar + /// switched to auto-hide, a monitor promoted to primary -- and `Desktop` reports + /// that and hands out fresh snapshots. Comparing only index and bounds made the + /// new snapshot equal to the old one, so anything that caches `getMonitors()` and + /// uses equality to spot what changed kept the stale scale or work area. + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Monitor)) { + return false; + } + Monitor o = (Monitor) other; + return index == o.index + && density == o.density + && dotsPerInch == o.dotsPerInch + && primary == o.primary + && Double.compare(scale, o.scale) == 0 + && bounds.equals(o.bounds) + && workArea.equals(o.workArea) + && (name == null ? o.name == null : name.equals(o.name)); + } + + /// {@inheritDoc} + @Override + public int hashCode() { + int result = index; + result = result * 31 + bounds.hashCode(); + result = result * 31 + workArea.hashCode(); + result = result * 31 + density; + result = result * 31 + dotsPerInch; + result = result * 31 + (name == null ? 0 : name.hashCode()); + result = result * 31 + (primary ? 1 : 0); + long scaleBits = Double.doubleToLongBits(scale); + result = result * 31 + (int) (scaleBits ^ (scaleBits >>> 32)); + return result; + } + + /// {@inheritDoc} + @Override + public String toString() { + return "Monitor[" + index + " " + name + " " + bounds + " scale=" + scale + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/ui/PeerComponent.java b/CodenameOne/src/com/codename1/ui/PeerComponent.java index 3758e4a6cbd..6f07cd6ea2a 100644 --- a/CodenameOne/src/com/codename1/ui/PeerComponent.java +++ b/CodenameOne/src/com/codename1/ui/PeerComponent.java @@ -274,9 +274,9 @@ public void pointerReleased(int x, int y) { /// Updates the size of the component from the native widget public void invalidate() { setShouldCalcPreferredSize(true); - Form parentForm = getComponentForm(); + TopLevelContainer parentForm = getTopLevelContainer(); if (parentForm != null) { - parentForm.revalidate(); + parentForm.asContainer().revalidate(); } } diff --git a/CodenameOne/src/com/codename1/ui/PointerDragHistory.java b/CodenameOne/src/com/codename1/ui/PointerDragHistory.java new file mode 100644 index 00000000000..1b36aee3c06 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/PointerDragHistory.java @@ -0,0 +1,91 @@ +/* + * 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.ui; + +import com.codename1.impl.CodenameOneImplementation; + +/// The recent path of one pointer gesture, which is what a fling's speed is computed +/// from. +/// +/// This is the only piece of gesture bookkeeping involved enough to be worth sharing: +/// a ring of positions and timestamps, plus the wrap arithmetic around it. Everything +/// else a top level tracks during a gesture -- the pressed component, whether a drag +/// happened, the long press timer -- is a field, and a field on the right object needs +/// no sharing. +/// +/// One of these belongs to each top level that dispatches pointer events: `Display` +/// owns the main surface's, and every `Window` owns its own. That is deliberately not +/// a table keyed by window: a per-window slot table has to be leased, reclaimed and +/// bounded, and getting any of that wrong loses a gesture or leaks a slot. An object +/// the window holds is created when the window is and collected with it. +/// +/// @author Shai Almog +final class PointerDragHistory { + + private final float[] pathX; + private final float[] pathY; + private final long[] pathTime; + private int offset; + private int length; + + /// The moment the display started, which recorded timestamps are relative to. + private final long baseTime; + + PointerDragHistory(int pathLength, long baseTime) { + int len = pathLength > 0 ? pathLength : 1; + pathX = new float[len]; + pathY = new float[len]; + pathTime = new long[len]; + this.baseTime = baseTime; + } + + /// Records one position in the gesture. + void record(int x, int y, int timestamp) { + pathX[offset] = x; + pathY[offset] = y; + pathTime[offset] = baseTime + (long) timestamp; + if (length < pathX.length) { + length++; + } + offset++; + if (offset >= pathX.length) { + offset = 0; + } + } + + /// Forgets the gesture, so the next one starts from nothing rather than flinging + /// with the previous gesture's speed. + void reset() { + offset = 0; + length = 0; + } + + /// The speed of the recorded gesture along one axis, as the implementation + /// computes it. + float speed(CodenameOneImplementation impl, boolean yAxis) { + if (yAxis) { + return impl.getDragSpeed(pathY, pathTime, offset, length); + } + return impl.getDragSpeed(pathX, pathTime, offset, length); + } +} diff --git a/CodenameOne/src/com/codename1/ui/RadioButton.java b/CodenameOne/src/com/codename1/ui/RadioButton.java index 6cf49607fd6..36358b6419b 100644 --- a/CodenameOne/src/com/codename1/ui/RadioButton.java +++ b/CodenameOne/src/com/codename1/ui/RadioButton.java @@ -343,13 +343,20 @@ private void initNamedGroup() { if (isInitialized()) { String s = getGroup(); if (s != null) { - Form f = getComponentForm(); - ButtonGroup b = (ButtonGroup) f.getClientProperty("$radio" + s); - if (b == null) { - b = new ButtonGroup(); - f.putClientProperty("$radio" + s, b); + // The named group is stored on whichever surface owns this button. + // getComponentForm() is null by design inside a Window, so showing a + // window containing a grouped radio button threw before the native + // window was even mapped. + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + Container host = top.asContainer(); + ButtonGroup b = (ButtonGroup) host.getClientProperty("$radio" + s); + if (b == null) { + b = new ButtonGroup(); + host.putClientProperty("$radio" + s, b); + } + b.add(this); } - b.add(this); } } } diff --git a/CodenameOne/src/com/codename1/ui/SearchBar.java b/CodenameOne/src/com/codename1/ui/SearchBar.java index 41421170d3e..7aa2771359e 100644 --- a/CodenameOne/src/com/codename1/ui/SearchBar.java +++ b/CodenameOne/src/com/codename1/ui/SearchBar.java @@ -67,6 +67,9 @@ public void dataChanged(int type, int index) { } }); setUIIDFinal("ToolbarSearch"); + // A search bar lives in a Toolbar and a Toolbar belongs to a Form, so the + // form is the only top level it can be in and getComponentForm() is the + // right question to ask. if (parent.getComponentForm() == Display.INSTANCE.getCurrent()) { //NOPMD CompareObjectsWithEquals search.startEditingAsync(); } else { @@ -87,7 +90,14 @@ public void actionPerformed(ActionEvent evt) { @Override public void run() { onSearch(""); - final Form f = (Form) SearchBar.this.getParent(); + // getComponentForm() rather than a cast of getParent(): a + // search bar is not always a direct child of its form, and + // ParparVM does not check CHECKCAST, so that cast would not + // fail as a ClassCastException anything could catch. + final Form f = SearchBar.this.getComponentForm(); + if (f == null) { + return; + } f.getAnimationManager().flushAnimation(new Runnable() { @Override public void run() { diff --git a/CodenameOne/src/com/codename1/ui/Slider.java b/CodenameOne/src/com/codename1/ui/Slider.java index 1b5c30db46e..b4645e219f0 100644 --- a/CodenameOne/src/com/codename1/ui/Slider.java +++ b/CodenameOne/src/com/codename1/ui/Slider.java @@ -174,7 +174,7 @@ protected boolean isStickyDrag() { @Override public void initComponent() { if (infinite) { - getComponentForm().registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(this, this); if (thumbImage == null) { thumbImage = UIManager.getInstance().getThemeImageConstant("sliderThumbImage"); } @@ -185,10 +185,10 @@ public void initComponent() { @Override public void deinitialize() { if (infinite) { - Form f = getComponentForm(); - if (f != null) { - f.deregisterAnimatedInternal(this); - } + // Matches the registration, which goes through the top level. Resolving + // the form here leaked the animation for every infinite slider inside a + // Window, where that form is null. + TopLevelSupport.deregisterAnimatedInternal(this, this); } } @@ -240,9 +240,9 @@ public void setInfinite(boolean i) { infinite = i; if (isInitialized()) { if (i) { - getComponentForm().registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(this, this); } else { - getComponentForm().deregisterAnimatedInternal(this); + TopLevelSupport.deregisterAnimatedInternal(this, this); } } } diff --git a/CodenameOne/src/com/codename1/ui/SwipeableContainer.java b/CodenameOne/src/com/codename1/ui/SwipeableContainer.java index 009ed503c3c..41c43248dcc 100644 --- a/CodenameOne/src/com/codename1/ui/SwipeableContainer.java +++ b/CodenameOne/src/com/codename1/ui/SwipeableContainer.java @@ -154,11 +154,15 @@ public SwipeableContainer(Component bottomLeft, Component bottomRight, Component @Override protected void deinitialize() { waitForRelease = false; - Form form = this.getComponentForm(); - if (form != null) { - form.removePointerPressedListener(press); - form.removePointerReleasedListener(release); - form.removePointerDraggedListener(drag); + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so a swipeable there never released its listeners -- and + // never installed them in the first place, in initComponent below. + TopLevelContainer top = this.getTopLevelContainer(); + if (top != null) { + Container c = top.asContainer(); + c.removePointerPressedListener(press); + c.removePointerReleasedListener(release); + c.removePointerDraggedListener(drag); } super.deinitialize(); } @@ -167,11 +171,12 @@ protected void deinitialize() { @Override protected void initComponent() { super.initComponent(); - Form form = this.getComponentForm(); - if (form != null && swipeActivated) { - form.addPointerPressedListener(press); - form.addPointerReleasedListener(release); - form.addPointerDraggedListener(drag); + TopLevelContainer top = this.getTopLevelContainer(); + if (top != null && swipeActivated) { + Container c = top.asContainer(); + c.addPointerPressedListener(press); + c.addPointerReleasedListener(release); + c.addPointerDraggedListener(drag); } } @@ -193,7 +198,13 @@ public void openToRight() { int topX = topWrapper.getX(); openCloseMotion = Motion.createSplineMotion(topX, bottom.getWidth(), 300); - getComponentForm().registerAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.registerAnimated(this); + } openCloseMotion.start(); openedToRight = true; open = true; @@ -217,7 +228,13 @@ public void openToLeft() { int topX = topWrapper.getX(); openCloseMotion = Motion.createSplineMotion(-topX, bottom.getWidth(), 300); - getComponentForm().registerAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.registerAnimated(this); + } openCloseMotion.start(); openedToLeft = true; open = true; @@ -228,7 +245,7 @@ public void close() { if (!open) { return; } - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { if (openedToRight) { int topX = topWrapper.getX(); @@ -388,11 +405,11 @@ public void actionPerformed(ActionEvent evt) { } final int x = evt.getX(); final int y = evt.getY(); - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f == null) { return; } - Component cmp = f.getComponentAt(x, y); + Component cmp = f.asContainer().getComponentAt(x, y); if (!waitForRelease && !contains(cmp)) { return; } diff --git a/CodenameOne/src/com/codename1/ui/Tabs.java b/CodenameOne/src/com/codename1/ui/Tabs.java index 84e35aa0576..48f8edee5ba 100644 --- a/CodenameOne/src/com/codename1/ui/Tabs.java +++ b/CodenameOne/src/com/codename1/ui/Tabs.java @@ -343,11 +343,11 @@ protected void initLaf(UIManager manager) { @Override void initComponentImpl() { super.initComponentImpl(); - Form frm = getComponentForm(); + TopLevelContainer frm = getTopLevelContainer(); if (frm != null) { - frm.registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(frm, this); if (changeTabContainerStyleOnFocus && Display.getInstance().shouldRenderSelection()) { - Component f = getComponentForm().getFocused(); + Component f = frm.getFocused(); if (f != null && f.getParent() == tabsContainer) { //NOPMD CompareObjectsWithEquals initTabsContainerStyle(); tabsContainer.setUnselectedStyle(originalTabsContainerSelected); @@ -368,11 +368,11 @@ public void refreshTheme(boolean merge) { /// {@inheritDoc} @Override protected void deinitialize() { - Form form = this.getComponentForm(); + TopLevelContainer form = getTopLevelContainer(); if (form != null) { - form.removePointerPressedListener(press); - form.removePointerReleasedListener(release); - form.removePointerDraggedListener(drag); + form.asContainer().removePointerPressedListener(press); + form.asContainer().removePointerReleasedListener(release); + form.asContainer().removePointerDraggedListener(drag); } super.deinitialize(); } @@ -381,11 +381,11 @@ protected void deinitialize() { @Override protected void initComponent() { super.initComponent(); - Form form = this.getComponentForm(); + TopLevelContainer form = getTopLevelContainer(); if (form != null && swipeActivated) { - form.addPointerPressedListener(press); - form.addPointerReleasedListener(release); - form.addPointerDraggedListener(drag); + form.asContainer().addPointerPressedListener(press); + form.asContainer().addPointerReleasedListener(release); + form.asContainer().addPointerDraggedListener(drag); } } @@ -470,9 +470,9 @@ void deregisterAnimatedInternal() { // animation while the 550ms morph was still in flight, freezing the drop mid-travel. if ((slideToDestMotion == null || slideToDestMotion.isFinished()) && (indicatorAnimMotion == null || indicatorAnimMotion.isFinished())) { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { - f.deregisterAnimatedInternal(this); + TopLevelSupport.deregisterAnimatedInternal(f, this); } } } @@ -1365,7 +1365,7 @@ public void setSelectedIndex(int index, boolean slideToSelected) { // selection's bounds. startIndicatorAnimation(activeComponent, index); - Form form = getComponentForm(); + TopLevelContainer form = getTopLevelContainer(); if (slideToSelected && form != null) { int end; int start; @@ -1378,7 +1378,7 @@ public void setSelectedIndex(int index, boolean slideToSelected) { } slideToDestMotion = createTabSlideMotion(start, end); slideToDestMotion.start(); - form.registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(form, this); active = index; } else { if (selectionListener != null) { @@ -1499,9 +1499,9 @@ private void startIndicatorAnimation(int fromIndex, int toIndex) { // Material underline path reads the same value as a plain position fraction.) indicatorAnimMotion = Motion.createLinearMotion(0, 100, animatedIndicatorDurationMs); indicatorAnimMotion.start(); - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { - f.registerAnimatedInternal(this); + TopLevelSupport.registerAnimatedInternal(f, this); } } @@ -1888,16 +1888,16 @@ public void setSwipeActivated(boolean swipeActivated) { if (this.swipeActivated != swipeActivated) { this.swipeActivated = swipeActivated; if (isInitialized()) { - Form form = this.getComponentForm(); + TopLevelContainer form = getTopLevelContainer(); if (form != null) { if (swipeActivated) { - form.addPointerPressedListener(press); - form.addPointerReleasedListener(release); - form.addPointerDraggedListener(drag); + form.asContainer().addPointerPressedListener(press); + form.asContainer().addPointerReleasedListener(release); + form.asContainer().addPointerDraggedListener(drag); } else { - form.removePointerPressedListener(press); - form.removePointerReleasedListener(release); - form.removePointerDraggedListener(drag); + form.asContainer().removePointerPressedListener(press); + form.asContainer().removePointerReleasedListener(release); + form.asContainer().removePointerDraggedListener(drag); } } } @@ -2468,7 +2468,7 @@ public void actionPerformed(ActionEvent evt) { return; } } - Form parent = getComponentForm(); + TopLevelContainer parent = getTopLevelContainer(); // A tab can be removed in response to the same pointer gesture // (e.g. an inspector rebuild). Its global swipe listener may still // receive the queued drag after deinitialization. @@ -2540,9 +2540,9 @@ public void actionPerformed(ActionEvent evt) { int end = tabsGap; slideToDestMotion = createTabSlideMotion(start, end); slideToDestMotion.start(); - Form form = getComponentForm(); + TopLevelContainer form = getTopLevelContainer(); if (form != null) { - form.registerAnimatedInternal(Tabs.this); + TopLevelSupport.registerAnimatedInternal(form, Tabs.this); } evt.consume(); } @@ -2567,9 +2567,9 @@ public void actionPerformed(ActionEvent evt) { int end = tabsGap; slideToDestMotion = createTabSlideMotion(start, end); slideToDestMotion.start(); - Form form = getComponentForm(); + TopLevelContainer form = getTopLevelContainer(); if (form != null) { - form.registerAnimatedInternal(Tabs.this); + TopLevelSupport.registerAnimatedInternal(form, Tabs.this); } evt.consume(); } @@ -2589,11 +2589,15 @@ public void actionPerformed(ActionEvent evt) { private boolean isEventBlockedByHigherComponent(ActionEvent evt) { final int x = evt.getX(); final int y = evt.getY(); - final Form currentForm = Display.INSTANCE.getCurrent(); - if (currentForm == null) { + // These coordinates are local to the surface the tabs live on, so the hit + // test has to run against that surface. Resolving the current form meant a + // window's swipe was tested against an unrelated main-form component at the + // same coordinates, which set blockSwipe and made the swipe do nothing. + final TopLevelContainer top = getTopLevelContainer(); + if (top == null) { return false; } - final Component targetComponent = currentForm.getComponentAt(x, y); + final Component targetComponent = top.asContainer().getComponentAt(x, y); return !contentPane.equals(targetComponent) && !contentPane.contains(targetComponent); } } diff --git a/CodenameOne/src/com/codename1/ui/TextArea.java b/CodenameOne/src/com/codename1/ui/TextArea.java index 261d3773f75..355bdb28cfc 100644 --- a/CodenameOne/src/com/codename1/ui/TextArea.java +++ b/CodenameOne/src/com/codename1/ui/TextArea.java @@ -208,9 +208,14 @@ public class TextArea extends Component implements ActionSource, TextHolder { private final ActionListener formPressListener = new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { - Form f = getComponentForm(); - if (f != null) { - if (isEditing() && f.getComponentAt(evt.getX(), evt.getY()) != TextArea.this) { //NOPMD CompareObjectsWithEquals + // The top level, not the form: this listener is registered on the window + // a text area lives in, and resolving the form here is null there -- so the + // documented pre-click action event never fired and the other component's + // handler could observe an uncommitted value. + TopLevelContainer top = getTopLevelContainer(); + if (top != null) { + Component hit = top.asContainer().getComponentAt(evt.getX(), evt.getY()); + if (isEditing() && hit != TextArea.this) { //NOPMD CompareObjectsWithEquals fireActionEvent(); setSuppressActionEvent(true); } @@ -503,21 +508,21 @@ public static void setUseStringWidth(boolean aUseStringWidth) { @Override protected void initComponent() { super.initComponent(); - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { // To be able to send action events early. // https://github.com/codenameone/CodenameOne/issues/2472 - f.addPointerPressedListener(formPressListener); + f.asContainer().addPointerPressedListener(formPressListener); } } @Override protected void deinitialize() { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { // For sending action events early // https://github.com/codenameone/CodenameOne/issues/2472 - f.removePointerPressedListener(formPressListener); + f.asContainer().removePointerPressedListener(formPressListener); } super.deinitialize(); } @@ -2046,7 +2051,7 @@ public void setEndsWith3Points(boolean endsWith3Points) { /// @deprecated Don't call this method directly, unless you really know what you're doing. It is used /// primarily by implementation APIs. public void registerAsInputDevice() { - Form f = this.getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null && Display.impl.getEditingText() != this) { //NOPMD CompareObjectsWithEquals try { diff --git a/CodenameOne/src/com/codename1/ui/TextField.java b/CodenameOne/src/com/codename1/ui/TextField.java index 22fc3078c10..7c566af90fc 100644 --- a/CodenameOne/src/com/codename1/ui/TextField.java +++ b/CodenameOne/src/com/codename1/ui/TextField.java @@ -1362,17 +1362,18 @@ protected boolean isSymbolDialogKey(int keyCode) { /// {@inheritDoc} @Override protected void deinitialize() { - Form f = getComponentForm(); - if (f != null) { - f.deregisterAnimated(this); - } + deregisterFromAnimation(); // if the text field is removed without restoring the commands we need to restore them if (handlesInput()) { + TopLevelContainer f = getTopLevelContainer(); if (useSoftkeys) { removeCommands(DELETE_COMMAND, T9_COMMAND, originalClearCommand); } else { + // Form only: the clear command lives on the soft button bar, which a + // Window has no equivalent of -- commands there reach the desktop menu + // instead. Nothing to restore when there is no such bar. if (f != null) { - f.setClearCommand(originalClearCommand); + f.asContainer().setClearCommandInternal(originalClearCommand); } originalClearCommand = null; } @@ -1392,7 +1393,12 @@ public void setEditable(boolean b) { removeCommands(DELETE_COMMAND, T9_COMMAND, originalClearCommand); } else { Form f = getComponentForm(); - f.setClearCommand(originalClearCommand); + // Null inside a Window by design. The clear command lives on a Form's + // MenuBar, which a Window has none of, so there is nothing to restore + // there -- but dereferencing it threw out of an ordinary click. + if (f != null) { + f.setClearCommand(originalClearCommand); + } originalClearCommand = null; } pressedAndNotReleased = false; @@ -1538,8 +1544,11 @@ protected void fireClicked() { originalClearCommand = installCommands(DELETE_COMMAND, T9_COMMAND); } else { Form f = getComponentForm(); - originalClearCommand = f.getClearCommand(); - f.setClearCommand(DELETE_COMMAND); + // See above: no MenuBar in a Window, so no clear command to take over. + if (f != null) { + originalClearCommand = f.getClearCommand(); + f.setClearCommand(DELETE_COMMAND); + } } return; } @@ -1549,7 +1558,12 @@ protected void fireClicked() { removeCommands(DELETE_COMMAND, T9_COMMAND, originalClearCommand); } else { Form f = getComponentForm(); - f.setClearCommand(originalClearCommand); + // Null inside a Window by design. The clear command lives on a Form's + // MenuBar, which a Window has none of, so there is nothing to restore + // there -- but dereferencing it threw out of an ordinary click. + if (f != null) { + f.setClearCommand(originalClearCommand); + } originalClearCommand = null; } fireActionEvent(); @@ -1728,12 +1742,7 @@ void initComponentImpl() { keyFwd = rtl ? Display.GAME_LEFT : Display.GAME_RIGHT; keyBack = rtl ? Display.GAME_RIGHT : Display.GAME_LEFT; - // text field relies too much on animation to use internal animations -// getComponentForm().registerAnimated(this); - Form f = getComponentForm(); - if (f != null) { - f.registerAnimated(this); - } + registerForAnimation(); } /// The amount of time in milliseconds in which the cursor is visible diff --git a/CodenameOne/src/com/codename1/ui/TextSelection.java b/CodenameOne/src/com/codename1/ui/TextSelection.java index 017b2532b39..4eac7e2b6bf 100644 --- a/CodenameOne/src/com/codename1/ui/TextSelection.java +++ b/CodenameOne/src/com/codename1/ui/TextSelection.java @@ -60,6 +60,25 @@ public class TextSelection { /// Comparator used for ordering components in left-to-right mode. + + /// Revalidates the surface a component lives in, whether that is a form or a + /// window. Resolving the form directly is null inside a Window, and text selection + /// is supported there. + private static void revalidateTopLevel(Component c) { + TopLevelContainer top = c == null ? null : c.getTopLevelContainer(); + if (top != null) { + top.asContainer().revalidate(); + } + } + + /// The deferred counterpart to `#revalidateTopLevel(Component)`. + private static void revalidateLaterTopLevel(Component c) { + TopLevelContainer top = c == null ? null : c.getTopLevelContainer(); + if (top != null) { + top.asContainer().revalidateLater(); + } + } + private static final Comparator LTRComparator = new Comparator() { /// We can't just use component's AbsoluteY coordinates for ordering because of scrolling, @@ -200,7 +219,7 @@ public void actionPerformed(final ActionEvent evt) { selectionMask.remove(); getLayeredPane().remove(); selectionMask = null; - root.getComponentForm().revalidate(); + revalidateTopLevel(root); } startX = evt.getX(); startY = evt.getY(); @@ -242,7 +261,7 @@ public void actionPerformed(final ActionEvent evt) { getLayeredPane().add(selectionMask); } - root.getComponentForm().revalidate(); + revalidateTopLevel(root); if (selectionRoot.isScrollableX() && evt.getX() > selectionRoot.getAbsoluteX() + selectionRoot.getScrollX() + selectionRoot.getWidth() - ONE_MM * 5) { Component.setDisableSmoothScrolling(true); int scrollX = selectionRoot.getScrollX(); @@ -252,9 +271,13 @@ public void actionPerformed(final ActionEvent evt) { CN.callSerially(new Runnable() { @Override public void run() { - Form f = selectionRoot.getComponentForm(); + // The top level, not the form: this synthetic drag + // is what continues the auto-scroll, and resolving + // a null form in a Window stopped selection dead at + // the edge of the visible area. + TopLevelContainer f = selectionRoot.getTopLevelContainer(); if (f != null) { - f.pointerDragged(evt.getX(), evt.getY()); + f.asContainer().pointerDragged(evt.getX(), evt.getY()); } } }); @@ -269,9 +292,13 @@ public void run() { CN.callSerially(new Runnable() { @Override public void run() { - Form f = selectionRoot.getComponentForm(); + // The top level, not the form: this synthetic drag + // is what continues the auto-scroll, and resolving + // a null form in a Window stopped selection dead at + // the edge of the visible area. + TopLevelContainer f = selectionRoot.getTopLevelContainer(); if (f != null) { - f.pointerDragged(evt.getX(), evt.getY()); + f.asContainer().pointerDragged(evt.getX(), evt.getY()); } } }); @@ -286,9 +313,13 @@ public void run() { CN.callSerially(new Runnable() { @Override public void run() { - Form f = selectionRoot.getComponentForm(); + // The top level, not the form: this synthetic drag + // is what continues the auto-scroll, and resolving + // a null form in a Window stopped selection dead at + // the edge of the visible area. + TopLevelContainer f = selectionRoot.getTopLevelContainer(); if (f != null) { - f.pointerDragged(evt.getX(), evt.getY()); + f.asContainer().pointerDragged(evt.getX(), evt.getY()); } } }); @@ -303,9 +334,13 @@ public void run() { CN.callSerially(new Runnable() { @Override public void run() { - Form f = selectionRoot.getComponentForm(); + // The top level, not the form: this synthetic drag + // is what continues the auto-scroll, and resolving + // a null form in a Window stopped selection dead at + // the edge of the visible area. + TopLevelContainer f = selectionRoot.getTopLevelContainer(); if (f != null) { - f.pointerDragged(evt.getX(), evt.getY()); + f.asContainer().pointerDragged(evt.getX(), evt.getY()); } } }); @@ -345,12 +380,12 @@ public void run() { selectedBounds.setHeight(startSelectedBounds.getHeight() + offY); } update(); - root.getComponentForm().revalidate(); + revalidateTopLevel(root); } else if (inSelectionDrag && evt.getEventType() == ActionEvent.Type.PointerReleased || evt.getEventType() == ActionEvent.Type.DragFinished) { evt.consume(); inSelectionDrag = false; update(); - root.getComponentForm().revalidate(); + revalidateTopLevel(root); textSelectionListeners.fireActionEvent(new ActionEvent(TextSelection.this, Type.Change)); } } else { @@ -386,7 +421,7 @@ public void run() { layeredPane.add(selectionMask); } - root.getComponentForm().revalidate(); + revalidateTopLevel(root); } @@ -413,7 +448,7 @@ public void run() { selectionMask.remove(); getLayeredPane().remove(); selectionMask = null; - root.getComponentForm().revalidate(); + revalidateTopLevel(root); } } } @@ -485,11 +520,20 @@ public boolean isEnabled() { /// - `enabled` public void setEnabled(boolean enabled) { if (enabled != this.enabled) { + // The top level rather than the form. getComponentForm() is null by design + // inside a Window, and this dereferenced it immediately -- so enabling text + // selection, which TopLevelContainer exposes on every top level, threw in + // every secondary window. + TopLevelContainer top = root.getTopLevelContainer(); + if (top == null) { + // Not in a hierarchy: nothing to wire to, and the flag stays as it was + // rather than claiming a setup that did not happen. + return; + } this.enabled = enabled; - Component f = root.getComponentForm(); + Component f = top.asContainer(); if (enabled) { - Form form = f.getComponentForm(); - form.setEnableCursors(true); + top.setEnableCursors(true); f.addPointerPressedListener(pressListener); f.addPointerDraggedListener(pressListener); f.addPointerReleasedListener(pressListener); @@ -677,7 +721,8 @@ public void removeTextSelectionListener(ActionListener l) { private Container getLayeredPane() { //return root.getComponentForm().getLayeredPane(TextSelection.class, true); - return root.getComponentForm().getFormLayeredPane(TextSelection.class, true); + TopLevelContainer top = root.getTopLevelContainer(); + return top == null ? null : top.getFormLayeredPane(TextSelection.class, true); } /// Copies the current selection to the system clipboard. @@ -697,7 +742,7 @@ public void selectAll() { selectionRoot = root; selectedBounds.setBounds(0, 0, selectionRoot.getWidth(), selectionRoot.getHeight()); update(); - selectionRoot.getComponentForm().revalidateLater(); + revalidateLaterTopLevel(selectionRoot); textSelectionListeners.fireActionEvent(new ActionEvent(this, Type.Change)); } diff --git a/CodenameOne/src/com/codename1/ui/TopLevelContainer.java b/CodenameOne/src/com/codename1/ui/TopLevelContainer.java new file mode 100644 index 00000000000..b3d0b249f31 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/TopLevelContainer.java @@ -0,0 +1,473 @@ +/* + * 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.ui; + +import com.codename1.ui.animations.Animation; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.plaf.UIManager; + +/// The contract shared by the two things that can sit at the root of a Codename One +/// component hierarchy: `Form`, which fills the application's main surface, and +/// `Window`, which is a separate native operating system window on the desktop. +/// +/// Code that needs to work against "whatever top level I happen to be in" should +/// resolve it with `Component#getTopLevelContainer()` and talk to it through this +/// interface, rather than through `Component#getComponentForm()`. The latter keeps +/// its original meaning and returns `null` for a component hosted inside a `Window`. +/// +/// Members that belong to a `Component` or a `Container` are deliberately absent -- +/// reach them through `#asContainer()` instead. So are the parts of `Form` that model +/// mobile navigation, such as form transitions, the back command and the menu bar; +/// those have no meaning for a desktop window. +/// +/// @author Shai Almog +public interface TopLevelContainer { + + /// Returns this top level as a `Container`. + /// + /// A Java interface cannot extend a class, so without this a `TopLevelContainer` + /// reference could not be handed to anything expecting a `Component`. + /// + /// #### Returns + /// + /// this instance, as a `Container` + /// Records a component that is waiting for a pointer release, so the top level can + /// release it if the gesture ends somewhere else. + /// + /// #### Parameters + /// + /// - `c`: the component awaiting a release + void addComponentAwaitingRelease(C c); + + /// Stops tracking a component that was waiting for a pointer release. + /// + /// #### Parameters + /// + /// - `c`: the component to stop tracking + void removeComponentAwaitingRelease(C c); + + /// Drops every component waiting for a pointer release, used when a gesture is + /// taken over by something else -- a pull to refresh, for instance. + void clearComponentsAwaitingRelease(); + + Container asContainer(); + + // ---- content and structure ------------------------------------------------ + + /// Returns the container holding the application content of this top level. + /// + /// #### Returns + /// + /// the content pane + Container getContentPane(); + + /// Returns the layered pane covering the content area, creating it if needed. + /// + /// #### Returns + /// + /// the layered pane + Container getLayeredPane(); + + /// Returns the layer belonging to the given class within the content-area + /// layered pane, creating it if needed. + /// + /// #### Parameters + /// + /// - `c`: the class owning the layer + /// + /// - `top`: true to place the layer above the existing layers + /// + /// #### Returns + /// + /// the layer for the given class + Container getLayeredPane(Class c, boolean top); + + /// Returns the layer belonging to the given class within the content-area + /// layered pane at an explicit depth, creating it if needed. + /// + /// #### Parameters + /// + /// - `c`: the class owning the layer + /// + /// - `zIndex`: the depth at which the layer should sit + /// + /// #### Returns + /// + /// the layer for the given class + Container getLayeredPane(Class c, int zIndex); + + /// Returns the layer belonging to the given class within the layered pane that + /// spans the whole top level, including the title area, creating it if needed. + /// + /// #### Parameters + /// + /// - `c`: the class owning the layer + /// + /// - `top`: true to place the layer above the existing layers + /// + /// #### Returns + /// + /// the layer for the given class + Container getFormLayeredPane(Class c, boolean top); + + /// Returns the painter drawn above everything else in this top level. + /// + /// #### Returns + /// + /// the glass pane painter, or null when none is installed + Painter getGlassPane(); + + /// Sets the painter drawn above everything else in this top level. + /// + /// #### Parameters + /// + /// - `glassPane`: the painter to install, or null to remove the current one + void setGlassPane(Painter glassPane); + + // ---- title ---------------------------------------------------------------- + + /// Returns the title text. + /// + /// #### Returns + /// + /// the title + String getTitle(); + + /// Sets the title text. + /// + /// #### Parameters + /// + /// - `title`: the title to display + void setTitle(String title); + + // ---- toolbar and commands -------------------------------------------------- + + /// Adds a command to this top level. + /// + /// #### Parameters + /// + /// - `cmd`: the command to add + void addCommand(Command cmd); + + /// Removes a command from this top level. + /// + /// #### Parameters + /// + /// - `cmd`: the command to remove + void removeCommand(Command cmd); + + /// Removes every command from this top level. + void removeAllCommands(); + + /// Returns the number of commands. + /// + /// #### Returns + /// + /// the command count + int getCommandCount(); + + /// Returns the command at the given offset. + /// + /// #### Parameters + /// + /// - `index`: the offset of the command + /// + /// #### Returns + /// + /// the command at that offset + Command getCommand(int index); + + /// Adds a listener notified when a command is activated. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + void addCommandListener(ActionListener l); + + /// Removes a previously added command listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + void removeCommandListener(ActionListener l); + + // ---- animation ------------------------------------------------------------- + + /// Returns the animation manager coordinating mutations of this top level. + /// + /// #### Returns + /// + /// the animation manager + AnimationManager getAnimationManager(); + + /// Registers an animation that is invoked on every frame of this top level. + /// + /// #### Parameters + /// + /// - `cmp`: the animation to register + void registerAnimated(Animation cmp); + + /// Removes a previously registered animation. + /// + /// #### Parameters + /// + /// - `cmp`: the animation to remove + void deregisterAnimated(Animation cmp); + + /// Takes the animation lock, blocking until no animation is in progress. + /// + /// #### Returns + /// + /// true if the lock was taken + boolean grabAnimationLock(); + + /// Releases a previously taken animation lock. + void releaseAnimationLock(); + + // ---- focus ------------------------------------------------------------------ + + /// Returns the component currently owning focus. + /// + /// #### Returns + /// + /// the focus owner, or null when nothing is focused + Component getFocused(); + + /// Moves focus to the given component. + /// + /// #### Parameters + /// + /// - `focused`: the component that should take focus + void setFocused(Component focused); + + /// Returns true when focus traversal wraps around at the edges. + /// + /// #### Returns + /// + /// true if focus is cyclic + boolean isCyclicFocus(); + + /// Sets whether focus traversal wraps around at the edges. + /// + /// #### Parameters + /// + /// - `cyclicFocus`: true to make focus cyclic + void setCyclicFocus(boolean cyclicFocus); + + /// Returns true when only one component in this top level can take focus. + /// + /// #### Returns + /// + /// true if this is a single focus top level + boolean isSingleFocusMode(); + + /// Returns an iterator walking the components in traversal order. + /// + /// #### Parameters + /// + /// - `start`: the component to start from + /// + /// #### Returns + /// + /// the traversal iterator + Form.TabIterator getTabIterator(Component start); + + /// Scrolls so that the given component becomes visible. + /// + /// #### Parameters + /// + /// - `c`: the component to reveal + void scrollComponentToVisible(Component c); + + /// Adds a key binding scoped to this top level. + /// + /// #### Parameters + /// + /// - `keyCode`: the key code to bind + /// + /// - `listener`: the listener invoked for that key + void addKeyListener(int keyCode, ActionListener listener); + + /// Removes a previously added key binding. + /// + /// #### Parameters + /// + /// - `keyCode`: the bound key code + /// + /// - `listener`: the listener to remove + void removeKeyListener(int keyCode, ActionListener listener); + + // ---- editing ----------------------------------------------------------------- + + /// Returns true when a component in this top level is being edited. + /// + /// #### Returns + /// + /// true if editing is in progress + boolean isEditing(); + + /// Stops the in-progress edit and invokes the callback once it has finished. + /// + /// #### Parameters + /// + /// - `onFinish`: invoked once editing has stopped + void stopEditing(Runnable onFinish); + + /// Returns the component currently being edited. + /// + /// #### Returns + /// + /// the edited component, or null when nothing is being edited + Component findCurrentlyEditingComponent(); + + /// Returns the virtual input device currently open for this top level. + /// + /// #### Returns + /// + /// the open input device, or null when none is open + VirtualInputDevice getCurrentInputDevice(); + + /// Opens a virtual input device, closing whichever one was open before it. + /// + /// #### Parameters + /// + /// - `device`: the device to open, or null to close the current one + /// + /// #### Throws + /// + /// - `Exception`: if the previously open device failed to close + void setCurrentInputDevice(VirtualInputDevice device) throws Exception; + + // ---- theme and metrics ---------------------------------------------------------- + + /// Returns the theme manager used to style this top level. + /// + /// #### Returns + /// + /// the UI manager + UIManager getUIManager(); + + /// Sets the theme manager used to style this top level. + /// + /// #### Parameters + /// + /// - `uiManager`: the UI manager to use + void setUIManager(UIManager uiManager); + + /// Returns the region of this top level that is guaranteed not to be obscured + /// by system chrome such as a notch or a rounded corner. + /// + /// #### Returns + /// + /// the safe area rectangle + Rectangle getSafeArea(); + + /// Returns the height hidden behind the virtual keyboard, which is zero on a + /// platform without one. + /// + /// #### Returns + /// + /// the obscured height in pixels + int getInvisibleAreaUnderVKB(); + + /// Indicates whether the given coordinate begins a drag of the whole top level + /// rather than of a component inside it. + /// + /// #### Parameters + /// + /// - `x`: the x coordinate + /// + /// - `y`: the y coordinate + /// + /// #### Returns + /// + /// the drag region status for that coordinate + int getDragRegionStatus(int x, int y); + + /// Returns true when components may change the mouse cursor. + /// + /// #### Returns + /// + /// true if cursors are enabled + boolean isEnableCursors(); + + /// Sets whether components may change the mouse cursor. + /// + /// #### Parameters + /// + /// - `e`: true to enable cursors + void setEnableCursors(boolean e); + + /// Returns the text selection support for this top level. + /// + /// #### Returns + /// + /// the text selection + TextSelection getTextSelection(); + + // ---- lifecycle ------------------------------------------------------------------- + + /// Makes this top level visible. + void show(); + + /// Adds a listener notified whenever this top level is shown. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + void addShowListener(ActionListener l); + + /// Removes a previously added show listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + void removeShowListener(ActionListener l); + + /// Adds a listener notified whenever this top level changes size. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + void addSizeChangedListener(ActionListener l); + + /// Removes a previously added size changed listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + void removeSizeChangedListener(ActionListener l); + + /// Dispatches a command to this top level's command handling, which is how a + /// component that holds a `Command` triggers it without knowing whether it + /// lives in a `Form` or a `Window`. + /// + /// #### Parameters + /// + /// - `cmd`: the command to dispatch + /// + /// - `ev`: the event to dispatch + void dispatchCommand(Command cmd, ActionEvent ev); +} diff --git a/CodenameOne/src/com/codename1/ui/TopLevelSupport.java b/CodenameOne/src/com/codename1/ui/TopLevelSupport.java new file mode 100644 index 00000000000..9534c8e5e1d --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/TopLevelSupport.java @@ -0,0 +1,412 @@ +/* + * 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.ui; + +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.animations.Animation; + +/// Package private helpers shared by `Form` and `Window`. +/// +/// Java 5 has no default methods, so behaviour common to the two top levels lives +/// here as statics rather than on `TopLevelContainer`. +/// +/// @author Shai Almog +final class TopLevelSupport { + + /// Client property recording a layer's depth within a layered pane. + static final String Z_INDEX_PROP = "cn1$_zIndex"; + + /// Client property recording the class a layer belongs to. + static final String CLASS_PROP = "cn1$_cls"; + + private TopLevelSupport() { + } + + /// Returns the layer belonging to the given class, creating it at the top or the + /// bottom of the stack if it does not exist yet. + /// + /// #### Parameters + /// + /// - `layeredPaneImpl`: the container holding the layers + /// + /// - `c`: the class owning the layer, or null for the global layer + /// + /// - `top`: true to create the layer above the existing ones + /// + /// #### Returns + /// + /// the layer for the given class + static Container layeredPane(Container layeredPaneImpl, Class c, boolean top) { + Container existing = findLayer(layeredPaneImpl, c); + if (existing != null) { + return existing; + } + // getChildrenAsList(true) rather than iterating the container directly: the + // latter will not find components while an animation is in progress, and we + // would then add a duplicate layer. + java.util.List children = layeredPaneImpl.getChildrenAsList(true); + Container cnt = new Container(); + int zIndex = 0; + int componentCount = children.size(); + if (top) { + if (componentCount > 0) { + Integer z = (Integer) children.get(componentCount - 1).getClientProperty(Z_INDEX_PROP); + if (z != null) { + zIndex = z.intValue(); + } + } + layeredPaneImpl.add(cnt); + } else { + if (componentCount > 0) { + Integer z = (Integer) children.get(0).getClientProperty(Z_INDEX_PROP); + if (z != null) { + zIndex = z.intValue(); + } + } + layeredPaneImpl.addComponent(0, cnt); + } + cnt.putClientProperty(CLASS_PROP, c != null ? c.getName() : null); + cnt.putClientProperty(Z_INDEX_PROP, zIndex); + return cnt; + } + + /// Returns the layer belonging to the given class, creating it at an explicit + /// depth if it does not exist yet. + /// + /// #### Parameters + /// + /// - `layeredPaneImpl`: the container holding the layers + /// + /// - `c`: the class owning the layer, or null for the global layer + /// + /// - `zIndex`: the depth at which to create the layer, higher sits in front + /// + /// #### Returns + /// + /// the layer for the given class + static Container layeredPane(Container layeredPaneImpl, Class c, int zIndex) { + Container existing = findLayer(layeredPaneImpl, c); + if (existing != null) { + return existing; + } + java.util.List children = layeredPaneImpl.getChildrenAsList(true); + Container cnt = new Container(); + cnt.putClientProperty(Z_INDEX_PROP, zIndex); + int len = children.size(); + int insertIndex = -1; + for (int i = 0; i < len; i++) { + Component cmp = children.get(i); + Integer cmpZIndex = (Integer) cmp.getClientProperty(Z_INDEX_PROP); + int cmpZ = cmpZIndex == null ? 0 : cmpZIndex.intValue(); + if (cmpZ >= zIndex) { + insertIndex = i; + break; + } + } + if (insertIndex == -1) { + layeredPaneImpl.add(cnt); + } else { + layeredPaneImpl.addComponent(insertIndex, cnt); + } + cnt.putClientProperty(CLASS_PROP, c != null ? c.getName() : null); + return cnt; + } + + private static Container findLayer(Container layeredPaneImpl, Class c) { + java.util.List children = layeredPaneImpl.getChildrenAsList(true); + if (c == null) { + for (Component cmp : children) { + if (cmp != null && cmp.getClientProperty(CLASS_PROP) == null) { + return (Container) cmp; + } + } + return null; + } + String n = c.getName(); + for (Component cmp : children) { + if (cmp != null && n.equals(cmp.getClientProperty(CLASS_PROP))) { + return (Container) cmp; + } + } + return null; + } + + /// Resolves the top level containing the given component. + /// + /// #### Parameters + /// + /// - `cmp`: the component to resolve from, may be null + /// + /// #### Returns + /// + /// the enclosing top level, or null when the component is detached + static TopLevelContainer of(Component cmp) { + if (cmp == null) { + return null; + } + return cmp.getTopLevelContainer(); + } + + /// Resolves the top level containing the given component and returns it as a + /// `Container`, which is the form the package private top level hooks are + /// declared in. + /// + /// #### Parameters + /// + /// - `cmp`: the component to resolve from, may be null + /// + /// #### Returns + /// + /// the enclosing top level as a container, or null when the component is detached + static Container rootOf(Component cmp) { + TopLevelContainer top = of(cmp); + if (top == null) { + return null; + } + return top.asContainer(); + } + + /// Registers a component for animation with the top level it lives in, using the + /// internal registration that skips the public bookkeeping. + /// + /// `registerAnimatedInternal` is package private on both `Form` and `Window` and so + /// cannot sit on the public `TopLevelContainer` interface -- an interface member + /// would force it public. It is declared on `Container`, the nearest common + /// supertype, so the call below dispatches virtually. Callers inside this package + /// go through here instead of `getComponentForm()`, which is null by design inside + /// a `Window`. + /// + /// #### Parameters + /// + /// - `c`: the component whose top level is registered against + /// + /// - `a`: the animation to register + static void registerAnimatedInternal(Component c, Animation a) { + registerAnimatedInternal(c == null ? null : c.getTopLevelContainer(), a); + } + + /// The counterpart to `#registerAnimatedInternal(Component, Animation)`. + /// + /// #### Parameters + /// + /// - `c`: the component whose top level is deregistered from + /// + /// - `a`: the animation to deregister + static void deregisterAnimatedInternal(Component c, Animation a) { + deregisterAnimatedInternal(c == null ? null : c.getTopLevelContainer(), a); + } + + /// Registers an animation with an already resolved top level. + /// + /// The `Component` overload covers the common case; this one is for callers that + /// hold the top level in a local, or that register something other than themselves. + /// + /// #### Parameters + /// + /// - `top`: the top level to register with, may be null + /// + /// - `a`: the animation to register + static void registerAnimatedInternal(TopLevelContainer top, Animation a) { + if (top != null) { + top.asContainer().registerAnimatedInternal(a); + } + } + + /// The counterpart to `#registerAnimatedInternal(TopLevelContainer, Animation)`. + /// + /// #### Parameters + /// + /// - `top`: the top level to deregister from, may be null + /// + /// - `a`: the animation to deregister + static void deregisterAnimatedInternal(TopLevelContainer top, Animation a) { + if (top != null) { + top.asContainer().deregisterAnimatedInternal(a); + } + } + + /// Throws when the running platform has no windowing system, so that misuse + /// fails at the point of construction rather than at the first paint. + /// + /// #### Throws + /// + /// - `UnsupportedOperationException`: if this platform cannot open native windows + static void requireMultiWindow() { + if (Display.impl == null || Display.impl.getWindowManager() == null) { + throw new UnsupportedOperationException( + "Multiple native windows are not supported on this platform. " + + "Guard with Desktop.isSupported() or CN.isMultiWindowSupported()."); + } + } + + /// Adds a component to the top level's own layout, outside the content pane. Both + /// `Form` and `Window` keep that structural add package private rather than on + /// `TopLevelContainer`, since widening it would hand every caller a way to place + /// components beside the content pane; `Container` declares the hook they override. + /// + /// #### Parameters + /// + /// - `top`: the top level to add to + /// + /// - `constraints`: the layout constraint + /// + /// - `cmp`: the component to add + static void addComponentToTopLevel(TopLevelContainer top, Object constraints, Component cmp) { + if (top != null) { + top.asContainer().addComponentToTopLevel(constraints, cmp); + } + } + + /// The counterpart to + /// `#addComponentToTopLevel(TopLevelContainer, Object, Component)`. + /// + /// #### Parameters + /// + /// - `top`: the top level to remove from + /// + /// - `cmp`: the component to remove + static void removeComponentFromTopLevel(TopLevelContainer top, Component cmp) { + if (top != null) { + top.asContainer().removeComponentFromTopLevel(cmp); + } + } + + // --------------------------------------------------------------------------- + // Directional focus traversal, shared by Form and Window. + // + // The scan is generic: it walks a root container by absolute coordinates and + // knows nothing about which kind of top level it came from. It lived on Form, + // where Window could not reach it -- so every arrow key in a window resolved + // through Container's inert stubs and moved focus nowhere at all. Moved here + // rather than copied, so the two cannot drift. + // --------------------------------------------------------------------------- + + /// Returns true if the given dest component is in the column of the source component + static boolean isInSameColumn(Component source, Component dest) { + // workaround for NPE + if (source == null || dest == null) { + return false; + } + return Rectangle.intersects(source.getAbsoluteX(), 0, + source.getWidth(), Integer.MAX_VALUE, dest.getAbsoluteX(), dest.getAbsoluteY(), + dest.getWidth(), dest.getHeight()); + } + + /// Returns true if the given dest component is in the row of the source component + static boolean isInSameRow(Component source, Component dest) { + return Rectangle.intersects(0, source.getAbsoluteY(), + Integer.MAX_VALUE, source.getHeight(), dest.getAbsoluteX(), dest.getAbsoluteY(), + dest.getWidth(), dest.getHeight()); + } + + static Component findNextFocusHorizontal(Component focused, Component bestCandidate, Container root, boolean right) { + int count = root.getComponentCount(); + for (int iter = 0; iter < count; iter++) { + Component current = root.getComponentAt(iter); + if (current.isFocusable()) { + if (isInSameRow(focused, current)) { + int currentX = current.getAbsoluteX(); + int focusedX = focused.getAbsoluteX(); + if (right) { + if (focusedX < currentX) { + if (bestCandidate != null) { + if (bestCandidate.getAbsoluteX() < currentX) { + continue; + } + } + bestCandidate = current; + } + } else { + if (focusedX > currentX) { + if (bestCandidate != null) { + if (bestCandidate.getAbsoluteX() > currentX) { + continue; + } + } + bestCandidate = current; + } + } + } + } + if (current instanceof Container && !(((Container) current).isBlockFocus())) { + bestCandidate = findNextFocusHorizontal(focused, bestCandidate, (Container) current, right); + } + } + return bestCandidate; + } + + static Component findNextFocusVertical(Component focused, Component bestCandidate, Container root, boolean down) { + int count = root.getComponentCount(); + for (int iter = 0; iter < count; iter++) { + Component current = root.getComponentAt(iter); + if (current.isFocusable()) { + int currentY = current.getAbsoluteY(); + int focusedY = 0; + if (focused != null) { + focusedY = focused.getAbsoluteY(); + } + if (down) { + if (focusedY < currentY) { + if (bestCandidate != null) { + boolean exitingInSame = isInSameColumn(focused, bestCandidate); + if (bestCandidate.getAbsoluteY() < currentY) { + if (exitingInSame) { + continue; + } + if (isInSameRow(current, bestCandidate) && !isInSameColumn(focused, current)) { + continue; + } + } + if (exitingInSame && isInSameRow(current, bestCandidate)) { + continue; + } + } + bestCandidate = current; + } + } else { + if (focusedY > currentY) { + if (bestCandidate != null) { + boolean exitingInSame = isInSameColumn(focused, bestCandidate); + if (bestCandidate.getAbsoluteY() > currentY) { + if (exitingInSame) { + continue; + } + if (isInSameRow(current, bestCandidate) && !isInSameColumn(focused, current)) { + continue; + } + } + if (exitingInSame && isInSameRow(current, bestCandidate)) { + continue; + } + } + bestCandidate = current; + } + } + } + if (current instanceof Container && !(((Container) current).isBlockFocus())) { + bestCandidate = findNextFocusVertical(focused, bestCandidate, (Container) current, down); + } + } + return bestCandidate; + } +} diff --git a/CodenameOne/src/com/codename1/ui/Window.java b/CodenameOne/src/com/codename1/ui/Window.java new file mode 100644 index 00000000000..d788b63a83d --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/Window.java @@ -0,0 +1,3866 @@ +/* + * 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.ui; + +import com.codename1.impl.WindowManager; +import com.codename1.io.Log; +import com.codename1.ui.animations.Animation; +import com.codename1.ui.animations.Transition; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.PointerEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.events.WindowEvent; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.FlowLayout; +import com.codename1.ui.layouts.LayeredLayout; +import com.codename1.ui.layouts.Layout; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.EventDispatcher; + +import java.util.ArrayList; +import java.util.HashMap; + +/// A separate native operating system window, with its own Codename One component +/// hierarchy inside it. +/// +/// A `Window` is the desktop counterpart of `Form`. The application's main surface +/// stays a `Form` and keeps behaving exactly as it always has; a `Window` is an +/// *additional* top level, rendered into its own native window, with its own focus +/// owner, its own animations and its own dirty region. +/// +/// ```java +/// if (Desktop.isSupported()) { +/// Window w = new Window("Inspector", new BorderLayout()); +/// w.add(BorderLayout.CENTER, new Label("Hello from a second window")); +/// w.setWindowSize(400, 300); +/// w.show(); +/// } +/// ``` +/// +/// A `Window` is not a `Form`, so `Component#getComponentForm()` returns null for the +/// components inside one. Code that has to work in both places should ask for +/// `Component#getTopLevelContainer()` instead. +/// +/// Windows exist only where the platform has a windowing system. Every constructor +/// throws `UnsupportedOperationException` when it does not, so guard with +/// `Desktop#isSupported()`. There is deliberately no silent fallback to showing a +/// `Form`: a window that quietly is not a window produces layout and lifecycle bugs +/// that are far harder to find than an exception on the first line. +/// +/// @author Shai Almog +public class Window extends Container implements TopLevelContainer { + + /// Closing the window disposes it and releases the native window. The default. + public static final int DISPOSE_ON_CLOSE = 0; + + /// Closing the window hides it, leaving it able to be shown again. + public static final int HIDE_ON_CLOSE = 1; + + /// Closing the window does nothing, leaving the application to call + /// `#dispose()` itself from a close listener. + public static final int DO_NOTHING_ON_CLOSE = 2; + + /// The window does not block input to any other window. + public static final int MODALITY_NONE = 0; + + /// The window blocks input to the window that owns it. + public static final int MODALITY_WINDOW = 1; + + /// The window blocks input to every other window and to the main form. + public static final int MODALITY_APPLICATION = 2; + + private final int windowId; + private Object nativePeer; + private com.codename1.impl.PaintSurface paintSurface; + + /// This window's drag-activation filter. Owned here rather than in a table keyed + /// by window id on the implementation, so it dies with the window and nothing + /// caps how many windows can be dragged at once. + private final com.codename1.impl.PointerDragActivation dragActivation = + new com.codename1.impl.PointerDragActivation(); + private Graphics windowGraphics; + /// Set as soon as dispose() begins, so re-entering it is a no-op. + private boolean disposing; + /// True while internalPaintImpl is running, so paint() does not repeat the + /// background it has already drawn. Mirrors the guard Form carries. + private boolean inInternalPaint; + /// Published under Display.lock once teardown is complete; showModal waits on it, + /// and isWindowDisposed() reads it under the same monitor rather than relying on + /// volatile, so the write is visible to a parked caller on any thread. + private boolean disposed; + private boolean nativeVisible; + /// Set while this window holds a modal blocker, so it is pushed and popped once. + private boolean modalRegistered; + /// Set while the platform has the window minimized, which is not the same as + /// hidden: it is still open, and still modal if it was. + private boolean iconified; + + /// Mirrors Form's flag of the same name, read from the same property. + private final boolean revalidateFromRoot = + "true".equals(CN.getProperty("Form.revalidateFromRoot", "true")); + + /// Held while a caller owns the right to start an animation; see + /// `#grabAnimationLock()`. + private boolean animationLock; + + /// Vibration length for a tactile touch, in milliseconds; -1 until read from the + /// look and feel, the same value `Form` uses so a window feels like the rest of + /// the application. + private int tactileTouchDuration = -1; + + private final Container contentPane; + private Container layeredPane; + private Container windowLayeredPane; + private Painter glassPane; + + private ArrayList componentsAwaitingRelease; + private Component focused; + private Component dragged; + private Component pressedCmp; + private Object currentPointerPress; + private int initialPressX; + private int initialPressY; + private boolean cyclicFocus = true; + + /// This window's own gesture state. + /// + /// Fields, not entries in a table keyed by window id. The table version had to + /// lease a slot on the first event and hand it back on the last, and every way of + /// getting that wrong is a real defect: a window disposed mid-press never returned + /// its slot, and once the fixed number of slots was gone the drag filter silently + /// switched off for every window. Held here, the state is created with the window + /// and collected with it, and none of those failures can be expressed. + private PointerDragHistory dragHistory; + private boolean dragOccured; + /// A contact is down in this window and has not been released or dragged yet, and + /// where it went down. In this window's coordinates, which is why it cannot be a + /// global: the coordinates of two windows are not comparable. + private boolean selectionPressed; + private int selectionPressedX; + private int selectionPressedY; + + private final AnimationManager animMananger = new AnimationManager(this); + private final ArrayList animatableComponents = new ArrayList(); + private final ArrayList internalAnimatableComponents = new ArrayList(); + private final ArrayList revalidateQueue = new ArrayList(); + private final ArrayList pendingRevalidateQueue = new ArrayList(); + + private UIManager uiManager; + private VirtualInputDevice currentInputDevice; + private final TextSelection textSelection = new TextSelection(this); + private boolean enableCursors; + + private HashMap> keyListeners; + private final ArrayList commands = new ArrayList(); + private final EventDispatcher commandListeners = new EventDispatcher(); + private final EventDispatcher showListeners = new EventDispatcher(); + private final EventDispatcher sizeChangedListeners = new EventDispatcher(); + private final EventDispatcher closeListeners = new EventDispatcher(); + private final EventDispatcher windowListeners = new EventDispatcher(); + + private String pendingTitle = ""; + private int pendingX; + private int pendingY; + /// Whether the application chose a position. A negative coordinate is a perfectly + /// ordinary one -- a monitor to the left of or above the primary display has a + /// negative origin -- so it cannot double as "no position was asked for", or a + /// window restored onto such a monitor would be centred on the primary one instead. + private boolean pendingPositionSet; + private int pendingWidth = 400; + private int pendingHeight = 300; + private boolean decorated = true; + private boolean resizable = true; + private boolean alwaysOnTop; + private boolean utilityWindow; + private Image windowIcon; + private Dimension minimumWindowSize; + private int closeOperation = DISPOSE_ON_CLOSE; + private int modalityType = MODALITY_NONE; + private TopLevelContainer ownerWindow; + private Monitor currentMonitor; + + /// Creates a window whose content is laid out with a `FlowLayout`. + public Window() { + this(null, new FlowLayout()); + } + + /// Creates a window with the given content layout. + /// + /// #### Parameters + /// + /// - `contentPaneLayout`: the layout for the content pane + public Window(Layout contentPaneLayout) { + this(null, contentPaneLayout); + } + + /// Creates a window with the given title, laid out with a `FlowLayout`. + /// + /// #### Parameters + /// + /// - `title`: the window title + public Window(String title) { + this(title, new FlowLayout()); + } + + /// Creates a window with the given title and content layout. + /// + /// #### Parameters + /// + /// - `title`: the window title + /// + /// - `contentPaneLayout`: the layout for the content pane + public Window(String title, Layout contentPaneLayout) { + super(new BorderLayout()); + // Fail here rather than at show(): a developer who guessed wrong about the + // platform finds out on the line that constructed the window. + TopLevelSupport.requireMultiWindow(); + windowId = Desktop.getInstance().nextWindowId(); + setSafeAreaRoot(true); + // A window is a top level surface, so it takes the styles a theme already + // defines for one. Naming these "Window" and "WindowContentPane" instead + // would leave every theme written before desktop windows existed with no + // entry for them, and an unstyled top level paints nothing at all -- the + // window would come up as an unpainted rectangle. A theme or an application + // that wants windows to look different from forms sets its own UIID. + setUIID("Form"); + setVisible(false); + contentPane = new Container(contentPaneLayout); + contentPane.setUIID("ContentPane"); + // The same default a Form's content pane gets. Without it, content taller than + // the window is simply clipped and unreachable, and identical content moved + // from a Form to a Window silently stopped scrolling unless the application + // knew to opt in. A BorderLayout content pane ignores this, as it does on a + // Form -- setScrollableY forces false for one. + contentPane.setScrollableY(true); + // No title area and no toolbar. A window's title is its native chrome, drawn + // by the platform, so a second one inside the content would be a mobile idiom + // in a desktop window -- and it would eat content space to duplicate what the + // title bar already says. + super.addComponent(BorderLayout.CENTER, contentPane); + if (title != null) { + setTitle(title); + } + setWidth(pendingWidth); + setHeight(pendingHeight); + // Hardcoded for the same reason Form hardcodes it: a top level surface has + // nothing behind it, so a translucent one shows whatever the raster happened + // to contain. + getStyle().setBgTransparency(0xFF); + } + + // ---- identity ------------------------------------------------------------- + + /// Returns the framework assigned id of this window. + /// + /// This is the id a port stores at creation and echoes back on every event, so it + /// is also how a window is looked up from + /// `Desktop#windowById(int)`. + /// + /// #### Returns + /// + /// the window id + public int getWindowId() { + return windowId; + } + + Object getNativePeer() { + return nativePeer; + } + + com.codename1.impl.PointerDragActivation getDragActivation() { + return dragActivation; + } + + com.codename1.impl.PaintSurface getPaintSurface() { + return paintSurface; + } + + /// Drops everything queued on this window's surface, if it has one yet. A window + /// that was never shown has no surface, and callers below can be reached in that + /// state -- hide() most obviously. The handle-based API this replaced tolerated a + /// null surface, and dropping that tolerance would turn those into a failure. + private void clearPaintSurface() { + if (paintSurface != null) { + paintSurface.clear(); + } + } + + Graphics getWindowGraphics() { + return windowGraphics; + } + + private static WindowManager manager() { + return Display.impl.getWindowManager(); + } + + private void requireLive() { + if (disposing) { + throw new IllegalStateException("This Window has been disposed"); + } + } + + // ---- TopLevelContainer ------------------------------------------------------ + + /// {@inheritDoc} + @Override + public Container asContainer() { + return this; + } + + /// {@inheritDoc} + @Override + public TopLevelContainer getTopLevelContainer() { + if (getParent() != null) { + return super.getTopLevelContainer(); + } + return this; + } + + /// {@inheritDoc} + @Override + public Container getContentPane() { + return contentPane; + } + + /// {@inheritDoc} + @Override + public Container getLayeredPane() { + return getLayeredPane(null, false); + } + + /// {@inheritDoc} + @Override + public Container getLayeredPane(Class c, boolean top) { + return TopLevelSupport.layeredPane(getLayeredPaneImpl(), c, top); + } + + /// {@inheritDoc} + @Override + public Container getLayeredPane(Class c, int zIndex) { + return TopLevelSupport.layeredPane(getLayeredPaneImpl(), c, zIndex); + } + + private Container getLayeredPaneImpl() { + if (layeredPane == null) { + layeredPane = new Container(new LayeredLayout()); + Container parent = contentPane.wrapInLayeredPane(); + layeredPane.add(new Container()); + parent.addComponent(layeredPane); + revalidateWithAnimationSafety(); + } + return layeredPane; + } + + /// {@inheritDoc} + /// + /// The name mirrors `Form#getFormLayeredPane(java.lang.Class, boolean)` on + /// purpose: `Sheet`, `InteractionDialog` and `ToastBar` attach through this + /// method, and renaming it for windows would fork them. + @Override + public Container getFormLayeredPane(Class c, boolean top) { + if (windowLayeredPane == null) { + windowLayeredPane = new Container(new LayeredLayout()) { + @Override + protected void paintBackground(Graphics g) { + if (getComponentCount() > 0 && super.isVisible()) { + super.setVisible(false); + // Clear inInternalPaint across this call so the window paints its + // background too. This runs while the window is mid-paint, and + // paint() skips the background in that state -- correctly, for the + // window's own pass, which has already drawn it. This pass is a + // different thing: it is the backdrop for whatever sits in this + // pane, so it has to be a whole window, background included. + // Without it the children were redrawn straight over the pixels + // already on screen. Anything opaque repainted its own background + // and hid that, but the title area is transparent, so its text was + // composited over the identical text underneath and came out + // heavier -- the one visible symptom of a window being drawn twice. + boolean wasInInternalPaint = inInternalPaint; + inInternalPaint = false; + try { + Window.this.paint(g); + } finally { + inInternalPaint = wasInInternalPaint; + super.setVisible(true); + } + } + } + }; + windowLayeredPane.setShouldLayout(false); + super.addComponent(BorderLayout.OVERLAY, windowLayeredPane); + windowLayeredPane.setWidth(getWidth()); + windowLayeredPane.setHeight(getHeight()); + } + // The whole window overlay has its layout disabled, exactly as Form's does, so + // nothing sizes the layers inside it. Form assigns each one the top level's + // size at creation; without the same here every layer stays at zero and the + // overlays that attach through this method -- Sheet, InteractionDialog, + // ToastBar -- have no area to render into. Applied on every call rather than + // only at creation, so a layer created before a resize is corrected too. + Container layer = TopLevelSupport.layeredPane(windowLayeredPane, c, top); + layer.setShouldLayout(false); + layer.setWidth(getWidth()); + layer.setHeight(getHeight()); + return layer; + } + + /// {@inheritDoc} + @Override + public Painter getGlassPane() { + return glassPane; + } + + /// {@inheritDoc} + @Override + public void setGlassPane(Painter glassPane) { + this.glassPane = glassPane; + repaint(); + } + + /// {@inheritDoc} + @Override + public String getTitle() { + return pendingTitle; + } + + @Override + public void setTitle(final String title) { + // Straight to the platform. The window's title is the one the OS draws in its + // title bar; there is no in-content label to keep in step with it. + pendingTitle = title; + onPeer(new Runnable() { + @Override + public void run() { + manager().setTitle(nativePeer, title); + } + }); + } + + /// {@inheritDoc} + @Override + public void addCommand(Command cmd) { + commands.add(cmd); + publishCommands(); + } + + /// {@inheritDoc} + @Override + public void removeCommand(Command cmd) { + commands.remove(cmd); + publishCommands(); + } + + /// {@inheritDoc} + @Override + public void removeAllCommands() { + commands.clear(); + publishCommands(); + } + + /// Hands the current command list to the port so it can put them wherever this + /// platform shows a window's commands -- a native menu bar on the window's own + /// frame, where one exists. + /// + /// Without this the list was private bookkeeping: nothing consumed it, so a command + /// added to a window was never displayed and never activated, while the same call + /// on a `Form` works. A port with no command surface leaves them undisplayed, and + /// `#dispatchCommand(Command, ActionEvent)` remains the programmatic path. + private void publishCommands() { + if (nativePeer == null) { + return; + } + manager().setCommands(nativePeer, + commands.toArray(new Command[commands.size()])); + } + + /// {@inheritDoc} + @Override + public int getCommandCount() { + return commands.size(); + } + + /// {@inheritDoc} + @Override + public Command getCommand(int index) { + return commands.get(index); + } + + /// {@inheritDoc} + @Override + public void addCommandListener(ActionListener l) { + commandListeners.addListener(l); + } + + /// {@inheritDoc} + @Override + public void removeCommandListener(ActionListener l) { + commandListeners.removeListener(l); + } + + /// Dispatches a command to the listeners registered on this window. + /// + /// #### Parameters + /// + /// - `cmd`: the command that was activated + /// + /// - `ev`: the event describing the activation + /// Notifies this window's command listeners of an activation whose command action + /// has already run. + /// + /// The counterpart of `Form`'s no-recurse dispatch, and needed for the same reason: + /// a `Button` backed by a `Command` invokes the command itself and then tells its + /// top level, which must not invoke it again. + /// + /// #### Parameters + /// + /// - `cmd`: the command that was activated + /// + /// - `ev`: the event describing the activation + void dispatchCommandNoRecurse(Command cmd, ActionEvent ev) { + if (cmd == null || ev.isConsumed()) { + return; + } + commandListeners.fireActionEvent(ev); + } + + /// Adds a component to the window's own border layout, outside the content pane, + /// which is where structural furniture such as a permanent side menu belongs. The + /// counterpart of `Form`'s form-level add. + /// + /// #### Parameters + /// + /// - `constraints`: the layout constraint + /// + /// - `cmp`: the component to add + @Override + final void addComponentToTopLevel(Object constraints, Component cmp) { + super.addComponent(constraints, cmp); + } + + /// Removes a component previously added with + /// `#addComponentToTopLevel(Object, Component)`. + /// + /// #### Parameters + /// + /// - `cmp`: the component to remove + @Override + void removeComponentFromTopLevel(Component cmp) { + super.removeComponent(cmp); + } + + @Override + boolean isNativeWindow() { + return true; + } + + @Override + boolean isTopLevelShowing() { + return isWindowShowing(); + } + + @Override + Object topLevelNativePeer() { + return nativePeer; + } + + @Override + void commandActivatedFromList(Command cmd, ActionEvent ev) { + dispatchCommandNoRecurse(cmd, ev); + } + + @Override + void commandActivatedFromComponent(Command cmd, ActionEvent ev) { + dispatchCommandNoRecurse(cmd, ev); + } + + @Override + boolean prefersPortraitLayout(boolean deviceBias) { + return getHeight() >= getWidth(); + } + + @Override + public void dispatchCommand(Command cmd, ActionEvent ev) { + cmd.actionPerformed(ev); + if (!ev.isConsumed()) { + commandListeners.fireActionEvent(ev); + } + } + + // ---- animation --------------------------------------------------------------- + + /// {@inheritDoc} + @Override + public AnimationManager getAnimationManager() { + return animMananger; + } + + /// {@inheritDoc} + @Override + public void registerAnimated(Animation cmp) { + if (!animatableComponents.contains(cmp)) { + animatableComponents.add(cmp); + repaint(); + } + } + + /// {@inheritDoc} + @Override + public void deregisterAnimated(Animation cmp) { + animatableComponents.remove(cmp); + } + + /// {@inheritDoc} + @Override + void registerAnimatedInternal(Animation cmp) { + // The component's own flag has to move with the list, exactly as Form does it: + // deregisterAnimatedInternal returns early when the flag is clear, so leaving + // it unset makes the removal a no-op and the component stays registered for + // good. A fading scrollbar is enough to do it, and hasAnimations() then never + // goes false again -- the event dispatch thread stops being able to sleep. + if (cmp instanceof Component) { + Component c = (Component) cmp; + if (c.internalRegisteredAnimated) { + return; + } + c.internalRegisteredAnimated = true; + } + if (!internalAnimatableComponents.contains(cmp)) { + internalAnimatableComponents.add(cmp); + repaint(); + } + } + + /// {@inheritDoc} + @Override + void deregisterAnimatedInternal(Animation cmp) { + if (cmp instanceof Component) { + Component c = (Component) cmp; + if (!c.internalRegisteredAnimated) { + return; + } + c.internalRegisteredAnimated = false; + } + internalAnimatableComponents.remove(cmp); + } + + /// {@inheritDoc} + @Override + public boolean grabAnimationLock() { + // A real lock, as Form keeps: whether an animation happens to be running is + // not the same question as whether this caller now owns the right to start + // one. Returning isAnimating() inverted the contract -- callers acquired the + // "lock" precisely when something else was already animating, and failed to + // acquire it when the window was idle. + if (animationLock) { + return false; + } + animationLock = true; + return true; + } + + /// {@inheritDoc} + @Override + public void releaseAnimationLock() { + // Simply drops the lock. The previous version handed null to + // flushAnimation, which either invoked it immediately (an NPE on the spot + // when nothing was animating) or queued it for updateAnimations to invoke + // later (an NPE on the event dispatch thread when the queue drained). + animationLock = false; + } + + boolean hasAnimations() { + return !animatableComponents.isEmpty() + || !internalAnimatableComponents.isEmpty() + || animMananger.isAnimating(); + } + + void repaintAnimations() { + if (Display.getInstance().isEdt()) { + loopAnimations(animatableComponents, null); + // Excluding what the public list already animated, exactly as Form does. + // A component can sit in both -- an explicitly animated scrollable whose + // fading scrollbar is also running -- and animating it twice per frame + // advances its motion at double speed and repeats any side effect. + loopAnimations(internalAnimatableComponents, animatableComponents); + animMananger.updateAnimations(); + } + } + + private void loopAnimations(ArrayList v, ArrayList notIn) { + // iterate by index and re-read the size: animate() may deregister itself + for (int iter = 0; iter < v.size(); iter++) { // NOPMD ForLoopCanBeForeach + Animation an = v.get(iter); + if (an != null && (notIn == null || !notIn.contains(an)) && an.animate()) { + if (an instanceof Component) { + Rectangle rect = ((Component) an).getDirtyRegion(); + if (rect != null) { + Dimension d = rect.getSize(); + ((Component) an).repaint(rect.getX(), rect.getY(), d.getWidth(), d.getHeight()); + } else { + ((Component) an).repaint(); + } + } else { + repaintAnimation(an); + } + } + } + } + + private void repaintAnimation(Animation a) { + if (paintSurface != null) { + paintSurface.repaint(a); + } + } + + // ---- revalidate queue ------------------------------------------------------------ + + /// {@inheritDoc} + @Override + void revalidateLater(Container cnt) { + synchronized (pendingRevalidateQueue) { + for (Container c : pendingRevalidateQueue) { + if (c == cnt || c.contains(cnt)) { //NOPMD CompareObjectsWithEquals + return; + } + } + pendingRevalidateQueue.add(cnt); + } + repaint(); + } + + /// {@inheritDoc} + @Override + void removeFromRevalidateQueue(Container cnt) { + synchronized (pendingRevalidateQueue) { + pendingRevalidateQueue.remove(cnt); + } + } + + /// {@inheritDoc} + @Override + void flushRevalidateQueue() { + synchronized (pendingRevalidateQueue) { + if (pendingRevalidateQueue.isEmpty()) { + return; + } + revalidateQueue.addAll(pendingRevalidateQueue); + pendingRevalidateQueue.clear(); + } + int len = revalidateQueue.size(); + for (int i = 0; i < len; i++) { + revalidateQueue.get(i).revalidateWithAnimationSafetyInternal(false); + } + revalidateQueue.clear(); + } + + /// {@inheritDoc} + @Override + boolean isRevalidateFromRoot() { + // The same property Form honours. Hardcoding true ignored an application that + // had turned it off, so a window revalidated from the root while its forms + // did not. + return revalidateFromRoot; + } + + // ---- focus ----------------------------------------------------------------------- + + /// {@inheritDoc} + @Override + public Component getFocused() { + return focused; + } + + /// {@inheritDoc} + @Override + public void setFocused(Component focused) { + if (this.focused == focused) { //NOPMD CompareObjectsWithEquals + return; + } + Component oldFocus = this.focused; + this.focused = focused; + boolean triggerRevalidate = false; + if (oldFocus != null) { + triggerRevalidate = changeFocusState(oldFocus, false); + // No repaint when a revalidate is coming: the window repaints it. + if (!triggerRevalidate && oldFocus.getParent() != null) { + oldFocus.repaint(); + } + } + // A listener may change focus again from inside the notification, which must + // not be undone here. + if (focused != null && this.focused == focused) { //NOPMD CompareObjectsWithEquals + triggerRevalidate = changeFocusState(focused, true) || triggerRevalidate; + if (!triggerRevalidate) { + focused.repaint(); + } + } + if (triggerRevalidate) { + revalidateLater(); + } + } + + /// Runs the full focus lifecycle for a component, the same way `Form` does. + /// + /// Toggling the focus flag and repainting is not enough: the notifications are + /// what components build their behaviour on. `TextArea.focusGainedInternal()` + /// enables its input handling there, so without this an arrow key traversed away + /// from a text field in a window instead of moving the caret inside it. + /// + /// Returns true when the selected and unselected styles differ enough to change + /// the preferred size, so the caller revalidates instead of repainting. + private boolean changeFocusState(Component cmp, boolean gained) { + boolean trigger = false; + Style selected = cmp.getSelectedStyle(); + Style unselected = cmp.getUnselectedStyle(); + // Different selected styling is a good hint the preferred size moves with it. + if (!selected.getFont().equals(unselected.getFont()) + || selected.getPaddingTop() != unselected.getPaddingTop() + || selected.getPaddingBottom() != unselected.getPaddingBottom() + || selected.getPaddingRight(isRTL()) != unselected.getPaddingRight(isRTL()) + || selected.getPaddingLeft(isRTL()) != unselected.getPaddingLeft(isRTL()) + || selected.getMarginTop() != unselected.getMarginTop() + || selected.getMarginBottom() != unselected.getMarginBottom() + || selected.getMarginRight(isRTL()) != unselected.getMarginRight(isRTL()) + || selected.getMarginLeft(isRTL()) != unselected.getMarginLeft(isRTL())) { + trigger = true; + } + int prefW = 0; + int prefH = 0; + if (trigger) { + Dimension d = cmp.getPreferredSize(); + prefW = d.getWidth(); + prefH = d.getHeight(); + } + + if (gained) { + cmp.setFocus(true); + cmp.fireFocusGained(); + } else { + cmp.setFocus(false); + cmp.fireFocusLost(); + } + + // The styles can differ without the preferred size actually moving, so only + // revalidate when it really did. Form had this test inverted and is fixed to + // match; getting it wrong drops the revalidate in exactly the case that needs + // one. + if (trigger) { + cmp.setShouldCalcPreferredSize(true); + Dimension d = cmp.getPreferredSize(); + if (prefW == d.getWidth() && prefH == d.getHeight()) { + cmp.setShouldCalcPreferredSize(false); + trigger = false; + } + } + + return trigger; + } + + /// {@inheritDoc} + @Override + void setFocusedInternal(Component focused) { + if (this.focused != null) { + this.focused.setFocus(false); + } + this.focused = focused; + if (focused != null) { + focused.setFocus(true); + } + } + + /// {@inheritDoc} + @Override + void requestFocus(Component cmp) { + if (cmp.isFocusable() && contains(cmp)) { + scrollComponentToVisible(cmp); + setFocused(cmp); + } + } + + /// {@inheritDoc} + @Override + public boolean isCyclicFocus() { + return cyclicFocus; + } + + /// {@inheritDoc} + @Override + public void setCyclicFocus(boolean cyclicFocus) { + this.cyclicFocus = cyclicFocus; + } + + /// {@inheritDoc} + @Override + public boolean isSingleFocusMode() { + // Computed as Form computes it, rather than hardcoded. Single focus mode + // changes key handling -- with one focusable there is nothing to traverse to, + // so the arrow keys belong to the component -- and returning a constant made + // a one-control window behave differently from the identical Form. + return countFocusables(getActualPane()) + countFocusables(windowLayeredPane) < 2; + } + + /// Focusable components in a subtree, used by `#isSingleFocusMode()`. + private static int countFocusables(Container root) { + if (root == null) { + return 0; + } + int count = 0; + int len = root.getComponentCount(); + for (int iter = 0; iter < len; iter++) { + Component c = root.getComponentAt(iter); + if (c instanceof Container) { + count += countFocusables((Container) c); + } + if (c.isFocusable()) { + count++; + } + if (count > 1) { + // Only the "fewer than two" answer matters; stop early. + return count; + } + } + return count; + } + + /// {@inheritDoc} + @Override + public Form.TabIterator getTabIterator(Component start) { + return Form.buildTabIterator(this, start); + } + + /// {@inheritDoc} + @Override + public void scrollComponentToVisible(Component c) { + Container parent = c.getParent(); + while (parent != null) { + if (parent.isScrollable()) { + parent.scrollComponentToVisible(c); + return; + } + parent = parent.getParent(); + } + } + + /// {@inheritDoc} + @Override + public void addKeyListener(int keyCode, ActionListener listener) { + if (keyListeners == null) { + keyListeners = new HashMap>(); + } + Integer code = Integer.valueOf(keyCode); + ArrayList l = keyListeners.get(code); + if (l == null) { + l = new ArrayList(); + keyListeners.put(code, l); + } + if (!l.contains(listener)) { + l.add(listener); + } + } + + /// {@inheritDoc} + @Override + public void removeKeyListener(int keyCode, ActionListener listener) { + if (keyListeners == null) { + return; + } + ArrayList l = keyListeners.get(Integer.valueOf(keyCode)); + if (l != null) { + l.remove(listener); + } + } + + // ---- editing -------------------------------------------------------------------- + + /// {@inheritDoc} + @Override + public boolean isEditing() { + Component c = findCurrentlyEditingComponent(); + return c != null && c.isEditing(); + } + + /// {@inheritDoc} + @Override + public void stopEditing(Runnable onFinish) { + Component c = findCurrentlyEditingComponent(); + if (c != null) { + c.stopEditing(onFinish); + } else if (onFinish != null) { + onFinish.run(); + } + } + + /// {@inheritDoc} + @Override + public Component findCurrentlyEditingComponent() { + return findCurrentlyEditingComponent(this); + } + + private static Component findCurrentlyEditingComponent(Container root) { + int len = root.getComponentCount(); + for (int iter = 0; iter < len; iter++) { + Component c = root.getComponentAt(iter); + if (c.isEditing()) { + return c; + } + if (c instanceof Container) { + Component inner = findCurrentlyEditingComponent((Container) c); + if (inner != null) { + return inner; + } + } + } + return null; + } + + /// {@inheritDoc} + @Override + public VirtualInputDevice getCurrentInputDevice() { + return currentInputDevice; + } + + /// {@inheritDoc} + @Override + public void setCurrentInputDevice(VirtualInputDevice device) throws Exception { + if (currentInputDevice != null) { + currentInputDevice.close(); + } + currentInputDevice = device; + } + + // ---- theme and metrics ----------------------------------------------------------- + + /// {@inheritDoc} + @Override + public UIManager getUIManager() { + return uiManager != null ? uiManager : UIManager.getInstance(); + } + + /// {@inheritDoc} + @Override + public void setUIManager(UIManager uiManager) { + this.uiManager = uiManager; + refreshTheme(false); + } + + /// {@inheritDoc} + /// + /// A desktop window has no notch or rounded corner to avoid, so the safe area is + /// the whole window. + @Override + public Rectangle getSafeArea() { + return new Rectangle(0, 0, getWidth(), getHeight()); + } + + /// {@inheritDoc} + /// + /// Always zero: a desktop window has no virtual keyboard overlaying it. + @Override + public int getInvisibleAreaUnderVKB() { + return 0; + } + + /// {@inheritDoc} + @Override + public int getDragRegionStatus(int x, int y) { + // A decorated window is dragged by its native title bar, and an undecorated one + // draws whatever chrome it wants inside its own content -- so nothing here is + // a drag handle by default. This used to answer "draggable" whenever a toolbar + // was installed, which was the mobile title bar standing in for a title bar the + // platform already provides. + return Component.DRAG_REGION_NOT_DRAGGABLE; + } + + /// {@inheritDoc} + @Override + public boolean isEnableCursors() { + return enableCursors; + } + + /// {@inheritDoc} + @Override + public void setEnableCursors(boolean e) { + enableCursors = e; + } + + /// {@inheritDoc} + @Override + public TextSelection getTextSelection() { + return textSelection; + } + + // ---- native window attributes ------------------------------------------------------- + + /// Sets whether the user may resize this window. + /// + /// #### Parameters + /// + /// - `resizable`: true to allow resizing + public void setResizable(final boolean resizable) { + this.resizable = resizable; + onPeer(new Runnable() { + @Override + public void run() { + manager().setResizable(nativePeer, resizable); + } + }); + } + + /// Runs an operation against this window's native peer on the event dispatch + /// thread, checking the peer is still there when it gets there. + /// + /// Every attribute setter needs this. The ports resolve a peer to a slot in a + /// native table on whatever thread calls them, and a slot is reused once its + /// window is disposed -- so a setter called from a background thread can land on + /// whichever window took the slot, or race the teardown freeing it. The field each + /// setter keeps is assigned on the calling thread, so its getter stays consistent + /// with what the caller asked for; only the platform call is deferred. + /// + /// #### Parameters + /// + /// - `op`: the platform call, which may assume a non-null peer + private void onPeer(final Runnable op) { + if (nativePeer == null) { + return; + } + if (Display.getInstance().isEdt()) { + op.run(); + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (nativePeer != null) { + op.run(); + } + } + }); + } + + /// Indicates whether the user may resize this window. + /// + /// #### Returns + /// + /// true if the window is resizable + public boolean isResizable() { + return resizable; + } + + /// Sets whether the platform draws a title bar and border for this window. + /// + /// An undecorated window paired with a `Toolbar` is how an application draws its + /// own chrome. + /// + /// #### Parameters + /// + /// - `decorated`: true for native decorations + public void setDecorated(final boolean decorated) { + this.decorated = decorated; + onPeer(new Runnable() { + @Override + public void run() { + manager().setDecorated(nativePeer, decorated); + } + }); + } + + /// Indicates whether the platform draws this window's chrome. + /// + /// #### Returns + /// + /// true if the window is natively decorated + public boolean isDecorated() { + return decorated; + } + + /// Keeps this window above the application's other windows. + /// + /// #### Parameters + /// + /// - `alwaysOnTop`: true to float the window + public void setAlwaysOnTop(final boolean alwaysOnTop) { + this.alwaysOnTop = alwaysOnTop; + if (nativePeer != null) { + // The field is set above on the calling thread so a getter stays + // consistent, but the SPI call is marshalled: the ports resolve the peer to + // a slot on whatever thread calls them, which races an EDT disposal. + if (Display.getInstance().isEdt()) { + manager().setAlwaysOnTop(nativePeer, alwaysOnTop); + } else { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (nativePeer != null) { + manager().setAlwaysOnTop(nativePeer, alwaysOnTop); + } + } + }); + } + } + } + + /// Indicates whether this window floats above the others. + /// + /// #### Returns + /// + /// true if the window is always on top + public boolean isAlwaysOnTop() { + return alwaysOnTop; + } + + /// Marks this window as a palette or tool window, which the platform typically + /// keeps out of the task bar. + /// + /// #### Parameters + /// + /// - `utility`: true for a utility window + public void setUtilityWindow(final boolean utility) { + this.utilityWindow = utility; + onPeer(new Runnable() { + @Override + public void run() { + manager().setUtilityWindow(nativePeer, utility); + } + }); + } + + /// Indicates whether this is a utility window. + /// + /// #### Returns + /// + /// true for a utility window + public boolean isUtilityWindow() { + return utilityWindow; + } + + /// Sets the icon the platform shows for this window. + /// + /// #### Parameters + /// + /// - `icon`: the icon to display + public void setWindowIcon(final Image icon) { + this.windowIcon = icon; + onPeer(new Runnable() { + @Override + public void run() { + manager().setIcon(nativePeer, icon); + } + }); + } + + /// Returns the icon the platform shows for this window. + /// + /// #### Returns + /// + /// the window icon, or null when none was set + public Image getWindowIcon() { + return windowIcon; + } + + // ---- geometry ------------------------------------------------------------------- + + /// Returns this window's bounds in desktop coordinates, including any native + /// chrome. + /// + /// This is a different coordinate space from `Component#getWidth()` and + /// `Component#getHeight()`, which report the Codename One content size. + /// + /// #### Returns + /// + /// the native window bounds + public Rectangle getWindowBounds() { + if (nativePeer == null) { + return new Rectangle(pendingX, pendingY, pendingWidth, pendingHeight); + } + int[] out = manager().getBounds(nativePeer, new int[4]); + return new Rectangle(out[0], out[1], out[2], out[3]); + } + + /// Moves and resizes this window. + /// + /// #### Parameters + /// + /// - `r`: the new bounds in desktop coordinates + public void setWindowBounds(Rectangle r) { + setWindowBounds(r.getX(), r.getY(), r.getWidth(), r.getHeight()); + } + + private void setWindowBounds(final int x, final int y, final int w, final int h) { + // Marshalled exactly as show(), hide() and dispose() are, and as the developer + // guide promises for moving a window. Without it a background caller mutated + // the pending geometry and the cached monitor while the event dispatch thread + // was reading them, and drove the window manager concurrently with the + // platform callbacks that report the result. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + setWindowBounds(x, y, w, h); + } + }); + return; + } + pendingX = x; + pendingY = y; + pendingPositionSet = true; + pendingWidth = w; + pendingHeight = h; + // A move can land the window on a different display, so the cached monitor no + // longer answers for it. Without this the cache stood until the port's + // monitor-change callback arrived, and that callback is queued back to the + // event dispatch thread: a centerOnDesktop(), getScale() or getDensity() in + // the same turn still read the old display, and centring right after a move + // to another monitor put the window back on the one it came from. + currentMonitor = null; + if (nativePeer != null) { + manager().setBounds(nativePeer, x, y, w, h); + } + } + + /// Resizes this window, leaving its position alone. + /// + /// #### Parameters + /// + /// - `width`: the new width + /// + /// - `height`: the new height + public void setWindowSize(final int width, final int height) { + // As setWindowBounds: the no-peer branch below writes the pending size, which + // the event dispatch thread reads when the window is shown. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + setWindowSize(width, height); + } + }); + return; + } + if (nativePeer == null) { + // Only the size. Routing through setWindowBounds before the window exists + // would hand the port the placeholder (0,0) as though the application had + // chosen it, and every port then skips the window manager's own + // placement -- which is the opposite of what this method promises. + pendingWidth = width; + pendingHeight = height; + return; + } + Rectangle current = getWindowBounds(); + setWindowBounds(current.getX(), current.getY(), width, height); + } + + /// Moves this window, leaving its size alone. + /// + /// #### Parameters + /// + /// - `x`: the new x position in desktop coordinates + /// + /// - `y`: the new y position in desktop coordinates + public void setWindowLocation(final int x, final int y) { + // The read has to happen on the event dispatch thread with the write, not + // before it. setWindowBounds marshals itself, but reading the bounds out here + // first meant a background caller that resized and then moved queued the move + // carrying the *old* size -- so the event dispatch thread applied the resize + // and then silently undid it. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + setWindowLocation(x, y); + } + }); + return; + } + Rectangle current = getWindowBounds(); + setWindowBounds(x, y, current.getWidth(), current.getHeight()); + } + + /// Sets the smallest size the user may resize this window to. + /// + /// #### Parameters + /// + /// - `d`: the minimum size + public void setMinimumWindowSize(final Dimension d) { + minimumWindowSize = d; + onPeer(new Runnable() { + @Override + public void run() { + manager().setMinimumSize(nativePeer, + d == null ? 0 : d.getWidth(), d == null ? 0 : d.getHeight()); + } + }); + } + + /// Returns the smallest size the user may resize this window to. + /// + /// #### Returns + /// + /// the minimum size, or null when none was set + public Dimension getMinimumWindowSize() { + return minimumWindowSize; + } + + /// Centres this window on the work area of the monitor it sits on, so it does not + /// land under the task bar or dock. + public void centerOnDesktop() { + // The whole calculation runs on the event dispatch thread, not just the move at + // the end. setWindowLocation marshals itself, but the reads above it did not, so + // a background caller that resized and then centred computed the centre from the + // size the window had *before* the queued resize -- and the event dispatch thread + // then applied the resize followed by a location centred for the old size. The + // same trap setWindowLocation itself documents. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + centerOnDesktop(); + } + }); + return; + } + Rectangle work = getMonitor().getWorkArea(); + Rectangle b = getWindowBounds(); + setWindowLocation(work.getX() + (work.getWidth() - b.getWidth()) / 2, + work.getY() + (work.getHeight() - b.getHeight()) / 2); + } + + /// Centres this window over another top level. + /// + /// #### Parameters + /// + /// - `other`: the top level to centre over + public void centerOn(final TopLevelContainer other) { + // Marshalled as a whole for the same reason as centerOnDesktop: this reads both + // windows' bounds and only then moves, so computing off the event dispatch + // thread centres against geometry a queued resize is about to invalidate. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + centerOn(other); + } + }); + return; + } + Rectangle o = null; + if (other instanceof Window) { + o = ((Window) other).getWindowBounds(); + } else if (other != null) { + // A Form lives in the application's main native window, so centre over + // that. Falling through to centerOnDesktop() centred on the monitor's work + // area instead, which is a different place whenever the main window has + // been moved, maximized or simply does not fill the screen -- and this + // method's contract is to centre over the top level it was given. + o = mainWindowBounds(); + } + if (o == null) { + centerOnDesktop(); + return; + } + Rectangle b = getWindowBounds(); + setWindowLocation(o.getX() + (o.getWidth() - b.getWidth()) / 2, + o.getY() + (o.getHeight() - b.getHeight()) / 2); + } + + /// The application's main native window in desktop coordinates, or null when the + /// port cannot report it. + private Rectangle mainWindowBounds() { + WindowManager wm = Display.impl == null ? null : Display.impl.getWindowManager(); + if (wm == null) { + return null; + } + int[] b = wm.getMainWindowBounds(new int[4]); + if (b == null || b[2] <= 0 || b[3] <= 0) { + return null; + } + return new Rectangle(b[0], b[1], b[2], b[3]); + } + + /// Minimizes this window. + public void minimize() { + // Marshalled like show(), hide() and dispose(). The window manager SPI is + // defined on the event dispatch thread, and the ports take it literally: the + // Windows one resolves the peer to a slot index on the calling thread and hands + // that index to the native layer, so a call from a background thread can read a + // slot an EDT disposal is tearing down. The developer guide also promises this + // is marshalled for the caller. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + minimize(); + } + }); + return; + } + if (nativePeer != null) { + manager().minimize(nativePeer); + } + } + + /// Restores this window from a minimized state. + /// + /// A window the application hid is not minimized and is not brought back by this. + /// `#hide()` leaves the peer alive with the hierarchy invisible, so handing that + /// peer to the platform's restore puts the native window back on screen while the + /// framework still counts it as hidden -- and nothing ever repaints it, because the + /// paint loop skips a window that is not showing. The result is a blank or stale + /// window that `#isWindowShowing()` denies is there. Bringing a hidden window back + /// is `#show()`'s job, which restores the whole lifecycle rather than just the + /// native state. + public void restore() { + // Marshalled as a whole, not just the native call. showOwnerChain() below may + // queue the owner's show(), and a background caller would then hand the child to + // the platform's restore first -- putting it back on screen ahead of its owner, + // or letting the window system suppress it while the framework counts it back. + // It also kept a WindowManager call off the event dispatch thread, which is the + // only context the SPI is defined in. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + restore(); + } + }); + return; + } + // Deliberately not "iconified only": between minimize() and the platform + // reporting it, a window is still nativeVisible, and an application that + // minimizes and immediately restores has to get its window back. + if (!nativeVisible && !iconified) { + return; + } + // Same reason show() does it: bringing a window back while its owner is away + // puts it on screen without the owner, or lets the window system suppress it + // while the framework counts it back. The owner chain comes first. + showOwnerChain(); + if (nativePeer != null) { + manager().restore(nativePeer); + } + } + + /// The platform refused to create this window's native surface, so it will never + /// appear. + /// + /// Deliberately not routed through `#hideNotify()`, which is the minimize path: + /// that keeps the modal registration on purpose, because a minimized window is + /// still open. A modal window that never appeared would then go on blocking input + /// to every other window while `#showModal()` waited for a window nobody can see. + /// This releases modality the way an explicit `#hide()` does. + /// + /// The window stays registered rather than being disposed, so the application's + /// object survives and a later `#show()` can ask the platform again. + void activationFailed() { + if (!nativeVisible && !iconified) { + return; + } + nativeVisible = false; + iconified = false; + cancelPendingInput(); + releaseModal(); + setVisible(false); + clearPaintSurface(); + fireWindowEvent(WindowEvent.Type.Hidden); + } + + /// Brings any owner above this window back before this one goes on screen. + /// + /// An owned window cannot be on screen without its owner, and an owner the + /// application hid has to come back through its own lifecycle: a port can map the + /// native window, but only `#show()` makes the component hierarchy visible again + /// and reacquires the modality that `#hide()` released, so restoring it natively + /// alone would leave an unpainted, non-interactive window that no longer blocks + /// input. + /// + /// A minimized owner is included: only one port restored one of those itself, so + /// everywhere else the child was mapped against an owner still minimized -- + /// appearing without it, or suppressed by the window system while the framework + /// counted it visible and took its modal blocker, which strands an application + /// modal with all input blocked. + /// + /// `show()` recurses into this, so a whole hidden chain comes back furthest owner + /// first. The owner chain cannot cycle -- `#setOwnerWindow(TopLevelContainer)` + /// rejects that when the relation is established. + private void showOwnerChain() { + if (ownerWindow instanceof Window) { + Window owner = (Window) ownerWindow; + if (!owner.isWindowShowing()) { + owner.show(); + } + } + } + + /// Toggles this window between maximized and its previous size. + public void toggleMaximize() { + // Marshalled like show(), hide() and dispose(). The window manager SPI is + // defined on the event dispatch thread, and the ports take it literally: the + // Windows one resolves the peer to a slot index on the calling thread and hands + // that index to the native layer, so a call from a background thread can read a + // slot an EDT disposal is tearing down. The developer guide also promises this + // is marshalled for the caller. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + toggleMaximize(); + } + }); + return; + } + if (nativePeer != null) { + manager().toggleMaximize(nativePeer); + } + } + + /// Raises this window and gives it keyboard focus. + public void requestWindowFocus() { + // Marshalled like show(), hide() and dispose(). The window manager SPI is + // defined on the event dispatch thread, and the ports take it literally: the + // Windows one resolves the peer to a slot index on the calling thread and hands + // that index to the native layer, so a call from a background thread can read a + // slot an EDT disposal is tearing down. The developer guide also promises this + // is marshalled for the caller. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + requestWindowFocus(); + } + }); + return; + } + if (nativePeer != null) { + manager().requestFocus(nativePeer); + } + } + + /// Indicates whether this window currently holds keyboard focus. + /// + /// #### Returns + /// + /// true if this window is focused + public boolean isWindowFocused() { + return Desktop.getInstance().getFocusedWindow() == this; //NOPMD CompareObjectsWithEquals + } + + // ---- monitor and density -------------------------------------------------------- + + /// Returns the monitor this window currently sits on. + /// + /// #### Returns + /// + /// the monitor showing this window + public Monitor getMonitor() { + if (nativePeer == null) { + // No peer to ask yet. Answer from the location the application requested + // rather than caching the primary monitor: centerOnDesktop() would + // otherwise recentre a pre-positioned window back onto the primary + // display, and the cached fallback would leave scale and density stale + // once the window did appear. + if (pendingPositionSet) { + return Desktop.getInstance().getMonitorAt(pendingX, pendingY); + } + return Desktop.getInstance().getPrimaryMonitor(); + } + if (currentMonitor == null) { + currentMonitor = Desktop.getInstance().getMonitorFor(this); + } + return currentMonitor; + } + + /// Returns the density of the monitor this window sits on, which on a mixed + /// resolution desktop is not necessarily the density `Display` reports. + /// + /// #### Returns + /// + /// the density constant for this window's monitor + public int getDensity() { + return getMonitor().getDensity(); + } + + /// Returns the backing scale of the monitor this window sits on. + /// + /// #### Returns + /// + /// the scale factor for this window's monitor + public double getScale() { + return getMonitor().getScale(); + } + + /// Asks the port to rebuild this window's native surface after the platform + /// destroyed it unasked. See `com.codename1.impl.WindowManager#reopen(Object)`. + boolean reopenNativeSurface() { + if (nativePeer == null || disposing) { + return false; + } + if (!manager().reopen(nativePeer)) { + return false; + } + // The surface is being rebuilt, so nothing painted so far survives. + clearPaintSurface(); + paintedOnce = false; + repaint(); + return true; + } + + /// Invoked by the framework when the platform reports that the user moved this + /// window. Nothing needs re-laying out -- only the position changed -- so this + /// just reports it. + void moved() { + rememberNativeBounds(); + // Dropped before the event, not after it. getMonitor() answers from a lazy + // cache, and a move is exactly what invalidates it -- so a Moved listener + // asking getMonitor(), getScale() or getDensity() was told which monitor the + // window had been on before it moved. The cache is otherwise refreshed only by + // the monitor-changed notification, which the ports queue *after* this one, and + // nothing tells the application to ask again in between. + // + // Dropped rather than recomputed, so a move nobody asks about costs nothing. + currentMonitor = null; + fireWindowEvent(WindowEvent.Type.Moved); + } + + /// Copies the peer's current geometry into the fields `#getWindowBounds()` falls + /// back on once the peer is gone. + /// + /// Without this the fallback answered with whatever the application last + /// *requested*, so a window the user had dragged or resized reported its original + /// position and size in the terminal `Hidden` and `Disposed` events -- and a + /// listener persisting geometry across runs restored the wrong rectangle. + private void rememberNativeBounds() { + if (nativePeer == null) { + return; + } + int[] out = manager().getBounds(nativePeer, new int[4]); + if (out[2] > 0 && out[3] > 0) { + pendingX = out[0]; + pendingY = out[1]; + pendingPositionSet = true; + pendingWidth = out[2]; + pendingHeight = out[3]; + } + } + + /// Invoked by the framework when the platform reports that this window has moved + /// to a monitor with different characteristics. Re-reads the scale and lays the + /// hierarchy out again, since preferred sizes computed at the old scale are stale. + void monitorChanged() { + currentMonitor = Desktop.getInstance().getMonitorFor(this); + // Re-read the drawable size rather than laying out at the one we already had. + // A move between monitors of different backing scale changes how many device + // pixels the same window is worth without any logical resize accompanying it, + // so the port reports a new size while this window still believes the old one: + // the hierarchy went on laying out and painting at the previous scale into a + // buffer sized for the new one, which clips the content or leaves part of it + // blank. + if (nativePeer != null) { + WindowManager wm = manager(); + int nativeWidth = wm.getWidth(nativePeer); + int nativeHeight = wm.getHeight(nativePeer); + if (nativeWidth > 0 && nativeHeight > 0 + && (nativeWidth != getWidth() || nativeHeight != getHeight())) { + sizeChangedInternal(nativeWidth, nativeHeight); + repaint(); + return; + } + } + setShouldCalcPreferredSize(true); + revalidateWithAnimationSafety(); + repaint(); + } + + // ---- lifecycle --------------------------------------------------------------------- + + /// Shows this window, creating the native window the first time it is called. + @Override + public void show() { + requireLive(); + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + show(); + } + }); + return; + } + if (nativePeer != null && nativeVisible) { + // Already on screen. Everything below is a transition -- taking the modal + // blocker, mapping the peer, firing Shown -- and repeating it for a window + // that never left fires a second Shown at listeners doing initialization or + // persistence, which is one showing as far as the user is concerned. + // + // showModal() calls this before its wait, so a window shown this way and + // then made modal still parks its caller: the wait is the caller's, not + // something this method does. + return; + } + WindowManager wm = manager(); + showOwnerChain(); + if (nativePeer == null) { + if (ownerWindow instanceof Window && ((Window) ownerWindow).nativePeer == null) { + // Showing a window whose owner has not been shown yet would create the + // child against the wrong native owner, permanently: every port fixes + // the relation at creation. Create the owner's native window first. + ((Window) ownerWindow).show(); + } + Object parentPeer = ownerPeer(); + // A null peer means two different things -- no owner at all, or an owner + // that is the application's main form -- and a port has to tell them + // apart: one is a top level window, the other is a child of the main one. + boolean ownedByMainWindow = parentPeer == null && ownerWindow != null; + Object peer = wm.createWindow(windowId, pendingTitle, pendingX, pendingY, + pendingWidth, pendingHeight, decorated, resizable, parentPeer, + pendingPositionSet, ownedByMainWindow); + if (peer == null) { + // Every port has a bounded native window table. Continuing here would + // register a window that paints through null graphics forever, which + // surfaces far away from the call that asked for one window too many. + throw new IllegalStateException( + "the platform could not create a native window for " + getTitle()); + } + nativePeer = peer; + paintSurface = Display.impl.createPaintSurface(nativePeer); + windowGraphics = Desktop.getInstance().createWindowGraphics(this); + if (windowIcon != null) { + wm.setIcon(nativePeer, windowIcon); + } + if (alwaysOnTop) { + wm.setAlwaysOnTop(nativePeer, true); + } + if (utilityWindow) { + wm.setUtilityWindow(nativePeer, true); + } + if (minimumWindowSize != null) { + wm.setMinimumSize(nativePeer, minimumWindowSize.getWidth(), + minimumWindowSize.getHeight()); + } + } + Desktop.getInstance().registerWindow(this); + // Commands added before the peer existed have not reached the port yet. + publishCommands(); + setVisible(true); + // A port that creates its native window asynchronously reports zero until it + // exists. Keep the requested size until a real one is delivered, rather than + // collapsing the window to nothing and laying out against that. + int nativeWidth = wm.getWidth(nativePeer); + int nativeHeight = wm.getHeight(nativePeer); + if (nativeWidth > 0 && nativeHeight > 0) { + sizeChangedInternal(nativeWidth, nativeHeight); + } + // Same hierarchy initialization Display.setCurrent() performs for a Form. + // Without it every component added before show() stays uninitialized, so + // initComponent() never runs, the look and feel is never bound and native + // peers are never attached. It has to happen before layout, since a peer + // reports a preferred size only once it exists. + if (!isInitialized()) { + initComponentImpl(); + } + revalidateWithAnimationSafety(); + initFocused(); + // Whether this is bringing a hidden window back, which decides whether what it + // painted before still stands for what it shows now. + boolean wasHidden = !nativeVisible; + nativeVisible = true; + if (wasHidden) { + // Its surface was dropped when it went away, and its components were free + // to change while nothing was painting it. The repaint below fills it in + // again, but until that runs the raster is the one from before the hide -- + // so anything waiting on hasPaintedOnce() has to wait for the new content + // rather than capture the old. The resize and surface-reopen paths reset + // this for the same reason. + paintedOnce = false; + } + // A window being shown is by definition no longer minimized. Only hide() and + // showNotify() cleared this before, so restoring an iconified window through + // show() left it marked iconified while it was on screen. + boolean wasIconified = iconified; + iconified = false; + acquireModal(); + // Only meaningful when this window is *not* modal, which is why acquireModal() + // above cannot cover it: a window shown while someone else's application modal + // is up registers no blocker of its own, so no port ever hears about the new + // peer. Ports enable a native window by default, leaving its title bar live -- + // focusable, movable, closable -- underneath a modal that is supposed to be + // blocking it. Recomputed here, before the peer is mapped, so the window is + // never briefly interactive. + Desktop.getInstance().syncNativeModalBlocking(); + wm.show(nativePeer); + if (wasIconified) { + // Mapping a window does not clear its iconic state: AWT's setVisible(true) + // and Win32's SW_SHOW both leave it minimized, and only the dedicated + // restore path (Frame.NORMAL, SW_RESTORE) brings it back. Without this the + // framework counted the window restored -- and, when it was an owner, + // mapped the child and took its modal blocker -- while the platform still + // had the window in the dock or taskbar. + wm.restore(nativePeer); + } + showListeners.fireActionEvent(new ActionEvent(this)); + fireWindowEvent(WindowEvent.Type.Shown); + repaint(); + Display.getInstance().wakeEdt(); + } + + /// Shows this window and blocks the calling code until it is disposed. + /// + /// This uses the same mechanism as a modal `Dialog`: the caller is parked while + /// the event dispatch thread keeps running, so every other window carries on + /// painting and animating. Input to the windows this one blocks is dropped by the + /// framework, so modality behaves the same way on every platform whether or not + /// the platform implements its own. + public void showModal() { + if (modalityType == MODALITY_NONE) { + modalityType = MODALITY_APPLICATION; + } + // show() registers the blocker, since a window shown any other way with a + // modality type set has to block too. + // + // From a background thread show() only queues its work and returns, so the + // wait below would find the window not visible yet, decide the modal was + // already over and return before it ever appeared. Wait for the show to + // actually happen first. + if (Display.getInstance().isEdt()) { + showAndBlock(); + } else { + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + showAndBlock(); + } + }); + } + try { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + // Hidden counts as over, not only disposed: HIDE_ON_CLOSE means the + // user closed the window without destroying it, and parking the + // caller for a window nobody can see again is a hang. + while (!isModalFinished()) { + synchronized (Display.lock) { + if (isModalFinished()) { + break; + } + try { + Display.lock.wait(40); + } catch (InterruptedException err) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + }); + } finally { + // Only when the wait ended because the window did. The loop above also + // breaks on an interrupt, and the window is still on screen then -- so + // releasing here left a modal window visible with input flowing to the + // windows behind it, which is the one thing a modal must not do. + // + // Nothing leaks by keeping it: the blocker belongs to the window, and + // hide(), dispose(), activationFailed() and setModalityType() all release + // it when the window is really finished with. + if (isModalFinished()) { + releaseModal(); + } + } + } + + /// Shows this window and makes sure its modal blocker is in place, on the event + /// dispatch thread. + /// + /// `show()` is a no-op for a window that is already on screen, and taking the + /// blocker is one of the things it would otherwise have done -- so a `showModal()` + /// on a window shown earlier parked its caller against a window that was in + /// nobody's modal stack and that no port had been told to block behind. The + /// application waited while the user carried on using the windows underneath. + /// + /// Idempotent when `show()` did run: `acquireModal()` returns immediately once the + /// blocker is registered. + /// + /// Both steps happen in the same hop so no input can be dispatched between the + /// window appearing and the block taking effect. + private void showAndBlock() { + show(); + acquireModal(); + // The windows this one blocks have to be told as well, which acquireModal() + // only does for this window's own peer. + Desktop.getInstance().syncNativeModalBlocking(); + } + + /// True once a modal window has stopped being modal, either because it was + /// disposed or because it was hidden. + /// + /// Minimizing is deliberately not either of those. It also clears `nativeVisible`, + /// but the window is still open and still modal, and treating it as finished would + /// end the wait and drop the blocker -- so restoring the window would leave a modal + /// window on screen with input flowing to the windows behind it. + private boolean isModalFinished() { + return isWindowDisposed() || (!nativeVisible && !iconified); + } + + /// Takes this window's modal blocker, both the framework one and the native flag. + /// + /// A window shown with a modality type set blocks exactly as one shown through + /// `#showModal()` does; the only difference between them is that showModal() also + /// parks the caller. Acquiring here rather than only there is what makes the + /// framework's input blocking agree with the platform's own modal state. + /// + /// The two always move together and exactly once, because a port may implement + /// the native flag by disabling another window -- Win32 does -- and an unbalanced + /// pair leaves that window disabled for good. + private void acquireModal() { + if (modalRegistered || modalityType == MODALITY_NONE || nativePeer == null) { + return; + } + modalRegistered = true; + Desktop.getInstance().pushModalWindow(this); + manager().setModal(nativePeer, true, + modalityType == MODALITY_APPLICATION, ownerPeer()); + } + + /// The native peer of the window this one blocks, or null when it blocks the + /// application's main window. A port implements modality by disabling that + /// window, so it has to be told which one. + private Object ownerPeer() { + return ownerWindow == null ? null : ownerWindow.asContainer().topLevelNativePeer(); + } + + /// Drops this window's modal blocker, both the framework one and the native flag. + /// Called from `#showModal()` and from `#dispose()`, so a modal window released + /// either way stops blocking -- a native modal on Windows disables the owner's + /// HWND, and leaving that in place makes the application unusable. + private void releaseModal() { + if (!modalRegistered) { + return; + } + modalRegistered = false; + Desktop.getInstance().popModalWindow(this); + if (nativePeer != null) { + manager().setModal(nativePeer, false, + modalityType == MODALITY_APPLICATION, ownerPeer()); + } + } + + /// Sets how this window blocks input to the others. + /// + /// #### Parameters + /// + /// - `type`: one of `#MODALITY_NONE`, `#MODALITY_WINDOW` or `#MODALITY_APPLICATION` + public void setModalityType(final int type) { + // On the event dispatch thread, whole. The steps below release and take + // blockers, which mutate Desktop's modal stack and call the port -- and unlike + // the plain attribute setters the field itself is part of that protocol, read + // by the release to decide which scope to undo. Assigning it on the calling + // thread and deferring only the platform calls would hand the release the new + // scope, which is the bug the comment below describes. So a background caller + // sees getModalityType() change after the hop rather than immediately. + if (!Display.getInstance().isEdt()) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + setModalityType(type); + } + }); + return; + } + // Released under the *old* scope before the type changes, because that is the + // scope the port was told about. Releasing afterwards would hand the port the + // new one and undo the wrong block -- on Windows, switching an application + // modal to window modal would re-enable an owner rather than decrement the + // main window's disable count, leaving the main window disabled for good. + releaseModal(); + modalityType = type; + // iconified counts as live here, exactly as it does in isModalFinished() and + // in hideNotify(): a minimized window is still open and still modal. Testing + // nativeVisible alone released the old blocker and never took the new one, and + // showNotify() does not reacquire on restore -- so a modality change made + // while minimized left the window visibly non-modal while getModalityType() + // still reported the mode that was asked for. + if (type != MODALITY_NONE && (nativeVisible || iconified)) { + acquireModal(); + } + } + + /// Returns how this window blocks input to the others. + /// + /// #### Returns + /// + /// the modality type + public int getModalityType() { + return modalityType; + } + + /// Hides this window without destroying it, so it can be shown again. + public void hide() { + if (!Display.getInstance().isEdt()) { + // Marshalled exactly as show() and dispose() are: changing visibility, + // mutating the modal and paint registries and firing listeners from a + // background thread would race the event dispatch thread while it is + // painting this window or dispatching input to it. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + hide(); + } + }); + return; + } + // iconified counts as still shown here: the platform took the window off + // screen, but the application asking to hide it still has to release the + // native window, drop the modal blocker and unpark showModal(). + if (nativePeer != null && (nativeVisible || iconified)) { + nativeVisible = false; + iconified = false; + // A key handler can hide its own window, and the window stays registered + // while hidden -- so a repeat or long press armed by the press that got + // us here would go on firing into a component tree the user cannot see, + // and keep the event dispatch thread awake. The key-up may never arrive + // either, once the native window has lost focus. + cancelPendingInput(); + // A window the user can no longer reach must not go on blocking the ones + // behind it. Without this a modal hidden through HIDE_ON_CLOSE stays at the + // top of the modal stack -- and where the platform implements modality + // natively, keeps the owner's native input disabled too. + releaseModal(); + // A hidden window is not painted, so anything its components queue would + // sit on its surface forever -- and hasPendingPaints() seeing that queue + // keeps the event dispatch thread awake spinning on work it will never + // drain. Marking the hierarchy invisible stops components enqueuing, and + // clearing the surface drops whatever was queued before this call. + setVisible(false); + clearPaintSurface(); + manager().hide(nativePeer); + fireWindowEvent(WindowEvent.Type.Hidden); + } + } + + /// Indicates whether this window is currently mapped on screen. + /// + /// #### Returns + /// + /// true if the window is showing + public boolean isWindowShowing() { + return nativeVisible && !disposing; + } + + /// Destroys this window and releases the native window behind it. Calling this + /// more than once is harmless. + public void dispose() { + if (disposing) { + return; + } + if (!Display.getInstance().isEdt()) { + // Marshalled exactly as show() is. Tearing the hierarchy down, firing the + // window events and mutating the desktop and paint registries from a + // background thread would race the event dispatch thread while it is + // painting this very window or dispatching input to it. + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + dispose(); + } + }); + return; + } + disposing = true; + // Whether this dispose is the thing actually taking the window off screen. + // hide() and activationFailed() each report Hidden themselves and leave the + // window invisible, and a window may never have been shown at all -- so the + // terminal Hidden below would either repeat a transition that already happened + // or announce one that never did. Listeners persist geometry and run teardown + // off that event, so a spurious one is not free. + boolean wasOnScreen = nativeVisible || iconified; + nativeVisible = false; + // An owned window cannot outlive its owner: the platform would leave it open + // with no owner behind it, and it would keep painting. Snapshot first -- each + // dispose deregisters, which mutates the registry being walked. + for (Window each : Desktop.getInstance().windowsOwnedBy(this)) { + each.dispose(); + } + releaseModal(); + Desktop.getInstance().deregisterWindow(this); + Display.getInstance().windowDisposed(this); + deinitializeImpl(); + if (currentInputDevice != null) { + try { + currentInputDevice.close(); + } catch (Exception err) { + Log.e(err); + } + currentInputDevice = null; + } + // Same cleanup the hide and minimize paths owe: a window disposed mid-gesture + // leaves a hidden drag component and a latched pressed component behind, and + // windowDisposed below only forgets the framework's records. + cancelPendingInput(); + // Before the native window is destroyed, not merely before the Java reference + // is cleared: the terminal Hidden and Disposed events below report bounds, and + // every port tears the slot down inside dispose() -- Win32 destroys it + // synchronously through SendMessage, Linux waits for its destroy, Catalyst + // memsets the slot -- so a read afterwards answers with zeros and leaves the + // stale requested rectangle in place. + rememberNativeBounds(); + if (nativePeer != null) { + WindowManager wm = manager(); + wm.hide(nativePeer); + wm.dispose(nativePeer); + } + // dropping the surface also drops anything queued on it, so a disposed + // window cannot pin its component tree + if (paintSurface != null) { + paintSurface.dispose(); + paintSurface = null; + } + nativePeer = null; + windowGraphics = null; + // showModal parks on Display.lock and wakes on this flag, so publish it under + // the very monitor the waiter is blocked on + synchronized (Display.lock) { + disposed = true; + Display.lock.notifyAll(); + } + // Deliberately NOT closeListeners: those are the vetoable close *request*, and + // a native close with DISPOSE_ON_CLOSE has already fired them once. Firing + // them again would run a listener's save or cleanup work twice for one user + // close, and a listener consuming the second event could not veto anything + // because the window is already gone. + if (wasOnScreen) { + fireWindowEvent(WindowEvent.Type.Hidden); + } + fireWindowEvent(WindowEvent.Type.Disposed); + } + + /// Indicates whether this window has been disposed. + /// + /// #### Returns + /// + /// true once `#dispose()` has run + public boolean isWindowDisposed() { + synchronized (Display.lock) { + return disposed; + } + } + + /// Indicates whether this window has completed at least one paint cycle, and so + /// whether its content -- rather than an empty surface -- is what a capture would + /// return. + /// + /// A window's raster exists from the moment it is shown, so capturing before the + /// first paint yields a blank frame of the right size rather than a failure. Test + /// and tooling code that wants the content should wait on this. + /// + /// #### Returns + /// + /// true once the window has painted + public boolean hasPaintedOnce() { + return paintedOnce; + } + + /// Invoked by the framework once a paint cycle for this window has completed. + void markPainted() { + paintedOnce = true; + } + + /// Written by the paint loop and read by whatever is waiting for content, both on + /// the event dispatch thread, so no cross thread publication is involved. + private boolean paintedOnce; + + /// Captures this window's current contents. + /// + /// The ordinary `Display#screenshot(com.codename1.util.SuccessCallback)` can only + /// see the application's main surface, so a window has to be captured through the + /// window manager instead. This is what the windowed screenshot tests use. + /// + /// #### Returns + /// + /// an image of the window, or null when the port cannot capture one + public Image capture() { + if (disposing || nativePeer == null) { + return null; + } + Object nativeImage = manager().capture(nativePeer); + if (nativeImage != null) { + return Image.createImage(nativeImage); + } + // A port that cannot read its own window back still owes a capture, so render + // the hierarchy again at the window's current size. This is the same content + // the window is showing rather than a readback of the pixels on screen, so a + // port that can read back should -- that is the version that would also catch + // the window and its raster disagreeing. + int w = getWidth(); + int h = getHeight(); + if (w <= 0 || h <= 0) { + return null; + } + Image img = Image.createImage(w, h); + paintComponent(img.getGraphics(), true); + return img; + } + + /// Sets what happens when the user closes this window through the platform's own + /// close control. + /// + /// #### Parameters + /// + /// - `op`: one of `#DISPOSE_ON_CLOSE`, `#HIDE_ON_CLOSE` or `#DO_NOTHING_ON_CLOSE` + public void setCloseOperation(int op) { + closeOperation = op; + } + + /// Returns what happens when the user closes this window. + /// + /// #### Returns + /// + /// the close operation + public int getCloseOperation() { + return closeOperation; + } + + /// Invoked by the framework when the user activates the platform's close control. + void closeRequested() { + ActionEvent evt = new ActionEvent(this); + closeListeners.fireActionEvent(evt); + if (evt.isConsumed()) { + return; + } + switch (closeOperation) { + case HIDE_ON_CLOSE: + hide(); + break; + case DO_NOTHING_ON_CLOSE: + break; + default: + dispose(); + break; + } + } + + /// Sets the top level that owns this window. An owned window stays above its + /// owner and is disposed with it. + /// + /// The name avoids `setOwner`, which `Component` already uses for an unrelated + /// hit testing mechanism. + /// + /// #### Parameters + /// + /// - `owner`: the owning top level + public void setOwnerWindow(TopLevelContainer owner) { + if (nativePeer != null) { + // The native ownership relation is established when the window is created + // -- the owner HWND on Windows, the transient parent on GTK, the owner + // passed to the JDialog on Java SE -- and none of those can be re-pointed + // afterwards. Silently keeping the old one while this field claimed + // otherwise would also strand a modal blocker on the previous owner, since + // that is the window the port was told to disable. + throw new IllegalStateException( + "the owner has to be set before the window is shown"); + } + // A cycle here is not caught anywhere downstream: show() creates an unshown + // owner's native window first, so a window that owns itself -- directly or + // round a longer chain -- recurses through show() until the stack runs out, + // before either peer exists. A StackOverflowError names none of the windows + // involved, so reject the relation at the point it is described. + TopLevelContainer probe = owner; + while (probe instanceof Window) { + if (probe == this) { //NOPMD CompareObjectsWithEquals + throw new IllegalArgumentException( + "a window cannot own itself, directly or through its owner chain"); + } + probe = ((Window) probe).ownerWindow; + } + this.ownerWindow = owner; + } + + /// Returns the top level that owns this window. + /// + /// #### Returns + /// + /// the owner, or null when the window is unowned + public TopLevelContainer getOwnerWindow() { + return ownerWindow; + } + + // ---- listeners ----------------------------------------------------------------- + + /// {@inheritDoc} + @Override + public void addShowListener(ActionListener l) { + showListeners.addListener(l); + } + + /// {@inheritDoc} + @Override + public void removeShowListener(ActionListener l) { + showListeners.removeListener(l); + } + + /// {@inheritDoc} + @Override + public void addSizeChangedListener(ActionListener l) { + sizeChangedListeners.addListener(l); + } + + /// {@inheritDoc} + @Override + public void removeSizeChangedListener(ActionListener l) { + sizeChangedListeners.removeListener(l); + } + + /// Adds a listener notified when the user tries to close this window. Consuming + /// the event vetoes the close. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public void addCloseListener(ActionListener l) { + closeListeners.addListener(l); + } + + /// Removes a previously added close listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public void removeCloseListener(ActionListener l) { + closeListeners.removeListener(l); + } + + /// Adds a listener notified when this window is shown, hidden, moved or resized. + /// + /// #### Parameters + /// + /// - `l`: the listener to add + public void addWindowListener(ActionListener l) { + windowListeners.addListener(l); + } + + /// Removes a previously added window listener. + /// + /// #### Parameters + /// + /// - `l`: the listener to remove + public void removeWindowListener(ActionListener l) { + windowListeners.removeListener(l); + } + + private void fireWindowEvent(WindowEvent.Type type) { + WindowEvent evt = new WindowEvent(this, type, getWindowBounds()); + windowListeners.fireActionEvent(evt); + Desktop.getInstance().fireWindowEvent(evt); + } + + // ---- painting and layout ------------------------------------------------------------ + + /// {@inheritDoc} + /// + /// Routes the repaint into this window's own dirty queue rather than the main + /// surface's, which is what keeps two windows from repainting each other. + @Override + void repaint(Component cmp) { + if (getParent() != null) { + super.repaint(cmp); + return; + } + // An elevated component's shadow is drawn by its surface's elevated pane, not + // by the component, and the shadow is larger than the component. Queueing the + // component alone left the old shadow pixels on screen until something else + // happened to repaint the surface. Form.repaint(Component) redirects the same + // way; this is the window's copy of it. + if (cmp.hasElevation()) { + Container surface = cmp.findSurface(); + if (surface != null) { + surface.repaint(cmp.getAbsoluteX() + cmp.calculateShadowOffsetX(24), + cmp.getAbsoluteY() + cmp.calculateShadowOffsetY(24), + cmp.calculateShadowWidth(24), cmp.calculateShadowHeight(24)); + return; + } + } + // nativeVisible as well as isVisible(): a minimized window keeps its + // hierarchy visible on purpose, so a model update or explicit repaint would + // otherwise queue paint work that can never drain -- paintOpenWindows skips + // a window that is not showing while hasPendingPaints() still counts its + // queue, which spins the event dispatch thread until the window is restored. + // Nothing is lost: showNotify repaints in full on restore. + if (isVisible() && nativeVisible && paintSurface != null) { + paintSurface.repaint(cmp); + } + } + + /// {@inheritDoc} + @Override + public void paint(Graphics g) { + // internalPaintImpl has already painted the background by the time it invokes + // this, so painting it again ran a custom background painter twice per frame + // and composited a translucent one on top of itself. Form carries the same + // guard for the same reason. + if (!inInternalPaint) { + paintComponentBackground(g); + } + super.paint(g); + } + + @Override + void internalPaintImpl(Graphics g, boolean paintIntersects) { + inInternalPaint = true; + super.internalPaintImpl(g, paintIntersects); + inInternalPaint = false; + } + + /// {@inheritDoc} + @Override + void paintGlassImpl(Graphics g) { + if (getParent() != null) { + super.paintGlassImpl(g); + return; + } + if (glassPane != null) { + int tx = g.getTranslateX(); + int ty = g.getTranslateY(); + g.translate(-tx, -ty); + glassPane.paint(g, getBounds()); + g.translate(tx, ty); + } + } + + /// {@inheritDoc} + @Override + public int getSideGap() { + if (getParent() == null) { + return 0; + } + return super.getSideGap(); + } + + /// {@inheritDoc} + @Override + void sizeChangedInternal(int w, int h) { + // Deliberately no clamp against the minimum size here. That minimum is native + // geometry, including the platform's chrome, while these are the content + // dimensions -- so comparing them mixes two coordinate spaces, and on a + // decorated window it laid the hierarchy out larger than the canvas it is + // drawn into, clipping controls and putting hit testing out of step with what + // is on screen. The constraint belongs to the platform, which applies it to + // the frame it owns; every desktop port implements it. + int oldWidth = getWidth(); + int oldHeight = getHeight(); + setSize(new Dimension(w, h)); + setShouldCalcPreferredSize(true); + if (windowLayeredPane != null) { + windowLayeredPane.setWidth(w); + windowLayeredPane.setHeight(h); + // Its layout is disabled, so its layers do not follow it by themselves. + java.util.List layers = windowLayeredPane.getChildrenAsList(true); + int layerCount = layers.size(); + for (int iter = 0; iter < layerCount; iter++) { + Component layer = layers.get(iter); + layer.setWidth(w); + layer.setHeight(h); + } + } + doLayout(); + if (oldWidth != w || oldHeight != h) { + // Anything already queued was computed against the old geometry, and a + // port that reallocates its buffer on resize would paint those stale + // rectangles into a fresh one -- leaving the rest of the new, larger + // surface unpainted. Drop them and repaint the whole window instead. + clearPaintSurface(); + // The frames painted so far were painted at the old size, so anything + // waiting on hasPaintedOnce() has to wait again rather than capture a + // surface that is half old content and half unpainted. + paintedOnce = false; + // As in moved(): keep the fallback geometry current so a window resized by + // the user still reports its real size once the peer is gone. + rememberNativeBounds(); + sizeChangedListeners.fireActionEvent(new ActionEvent(this, w, h)); + fireWindowEvent(WindowEvent.Type.Resized); + } + repaint(); + } + + /// {@inheritDoc} + /// + /// Matches `Form`: once the content pane has been wrapped in a layered pane the + /// wrapper is the pane, since the content is no longer a direct child. The whole + /// window overlay is deliberately not returned here -- see + /// `#getActualPane(int, int)`. + @Override + Container getActualPane() { + if (layeredPane != null) { + return layeredPane.getParent(); + } + return contentPane; + } + + /// The pane a pointer at the given point should be dispatched into. + /// + /// The whole window overlay covers the window, so making it the hit testing root + /// whenever it exists would swallow every click -- including over empty parts of + /// it -- and leave the content and the title unresponsive for as long as anything + /// had ever installed a layer. `Form` solves this by consulting the overlay only + /// where it has something interactive, and this does the same. + private Container getActualPane(int x, int y) { + if (windowLayeredPane != null && windowLayeredPane.getResponderAt(x, y) != null) { + return windowLayeredPane; + } + return getActualPane(); + } + + // ---- pointer dispatch --------------------------------------------------------- + + /// {@inheritDoc} + /// + /// A `Container` has no hit testing of its own -- `Form` does that work itself -- + /// so a `Window` has to as well, or a press would never reach the component under + /// it. This is the same walk `Form` performs, without the menu bar special case a + /// window has no equivalent of. + @Override + public void pointerPressed(int x, int y) { + // A secondary (right / stylus barrel) press is a context menu request first, + // exactly as on a Form. Without this a right click in a window never reached + // the component's context menu listener, and an unconsumed right press could + // then activate the component as an ordinary click. + if (Display.getInstance().getPointerButton() == PointerEvent.BUTTON_SECONDARY) { + Component ctxCmp = resolveComponentAt(x, y); + if (ctxCmp != null && ctxCmp.fireContextMenu(x, y)) { + return; + } + } + // Surfaced at the top level so addStylusListener fires regardless of which + // component is under the pen, exactly as Form does it. + if (Display.getInstance().isStylusPointer()) { + Component stylusCmp = resolveComponentAt(x, y); + if (stylusCmp != null) { + stylusCmp.fireStylusEvent(ActionEvent.Type.PointerPressed, x, y); + } + } + // Listeners registered on the window itself can consume the event. They run + // *after* the context menu and stylus dispatches above, which is Form's + // order: a consuming pressed listener must not be able to suppress a right + // click's context menu or a pen's stylus event. + // Without this block at all, addPointerPressedListener on a Window never + // fired -- and material pull to refresh broke with it, since Component + // installs its refresh listeners on the top level. + // Recorded before the listeners run, which is Form's order and matters for the + // same reason the framework records its own press before dispatching: a pressed + // listener can enter a nested event loop -- showModal() does -- and the matching + // physical release is then processed inside it. With the handle created + // afterwards that nested release found no gesture to clear, and this method then + // installed a fresh press whose release had already happened, leaving the + // component latched until some later gesture freed it. + initialPressX = x; + initialPressY = y; + currentPointerPress = new Object(); + dragged = null; + if (pointerPressedListeners != null && pointerPressedListeners.hasListeners()) { + ActionEvent e = new ActionEvent(this, ActionEvent.Type.PointerPressed, x, y); + pointerPressedListeners.fireActionEvent(e); + if (e.isConsumed()) { + return; + } + } + // A press dismisses any tooltip so it cannot linger over a drag image or be + // stranded when the gesture rebuilds the UI, as on a Form. + if (TooltipManager.getInstance() != null) { + TooltipManager.getInstance().clearTooltip(); + } + Component cmp = resolveComponentAt(x, y); + // Gated exactly as Form.pointerPressed gates it. Many components -- Button + // among them -- override pointerPressed without checking isEnabled + // themselves, relying on the top level never to call them, so dispatching + // unconditionally left a disabled button entering its pressed state and + // firing its action on release. Leaving pressedCmp null for a disabled + // component also keeps the drag and release paths off it, since both start + // from pressedCmp. + if (cmp != null && isCurrentlyScrolling(cmp)) { + // A press landing on a container that is still gliding stops the scroll and + // hands the gesture to the user, which is what Form.resumeDragAfterScrolling + // does (issue #2352). Stopping the motion and returning was only half of it: + // pressedCmp stayed null, so every drag packet in the same physical gesture + // had no target and the user could not take the scroll over without lifting + // and pressing again. + cancelScrolling(cmp); + cmp.initDragAndDrop(x, y); + // The component the scroll is handed to, so the rest of this physical + // gesture has a target. Form gets there differently -- its drag path + // re-resolves the component under the pointer when it has no pressed one -- + // but this window's drag path dispatches through pressedCmp, and giving it + // the same fallback changed routing for every gesture, not just this one. + pressedCmp = cmp; + pointerPressedAgainDuringDrag = true; + // Re-entered through this window rather than Display.pointerDragged(), which + // Form uses: that one is the main surface's path and would deliver the drag + // to the current Form instead of here. + Desktop.getInstance().windowPointerDragged(getWindowId(), + new int[] { x }, new int[] { y }); + return; + } + if (cmp != null && cmp.isEnabled()) { + pressedCmp = cmp; + // Drag and drop has to be primed on the press, as Form does in every one + // of its dispatch branches: Component.pointerDragged checks + // dragAndDropInitialized and silently does nothing without it, so a + // draggable component simply could not be dragged inside a window. + cmp.initDragAndDrop(x, y); + if (!cmp.isDragAndDropInitialized()) { + Container draggableCnt = cmp.getParent(); + while (draggableCnt != null && !draggableCnt.isDraggable()) { + draggableCnt = draggableCnt.getParent(); + } + if (draggableCnt != null && draggableCnt.isDraggable() + && !(draggableCnt instanceof TopLevelContainer)) { + draggableCnt.initDragAndDrop(x, y); + } + } + LeadUtil.pointerPressed(cmp, x, y); + // Not while a wheel gesture is being synthesized: dragWheelStep disables + // only the deepest hit component, so a focusable lead parent resolved + // here would still take focus and merely scrolling over a lead-based + // control would steal the keyboard. Form and LeadUtil both guard on this. + if (cmp.isFocusable() && !Display.impl.isScrollWheeling()) { + setFocused(cmp); + } + tactileTouchVibe(x, y, cmp); + } else { + pressedCmp = null; + } + } + + /// {@inheritDoc} + @Override + public void pointerDragged(int x, int y) { + if (Display.getInstance().isStylusPointer()) { + Component stylusCmp = resolveComponentAt(x, y); + if (stylusCmp != null) { + stylusCmp.fireStylusEvent(ActionEvent.Type.PointerDrag, x, y); + } + } + // Read and cleared here, exactly as Form does: the flag describes the drag that + // took a momentum scroll over, and leaving it set would tell every later drag in + // the session that it too continued out of a glide. + boolean pressedDuringDrag = pointerPressedAgainDuringDrag; + pointerPressedAgainDuringDrag = false; + if (pointerDraggedListeners != null && pointerDraggedListeners.hasListeners()) { + ActionEvent e = new ActionEvent(this, ActionEvent.Type.PointerDrag, x, y); + e.setPointerPressedDuringDrag(pressedDuringDrag); + pointerDraggedListeners.fireActionEvent(e); + if (e.isConsumed()) { + return; + } + } + autoRelease(x, y); + Component target = dragged != null ? dragged : pressedCmp; + if (target != null) { + LeadUtil.pointerDragged(target, x, y); + } + } + + /// {@inheritDoc} + /// + /// The multi pointer form, which is how a pinch reaches the component under the + /// fingers. Without it `Component`'s version runs instead: it tests the pinch on + /// the window itself and then collapses the event to a single coordinate, so the + /// pressed child gets an ordinary one-finger drag and never its `pinch` callbacks. + @Override + public void pointerDragged(int[] x, int[] y) { + // The same listener block the scalar overload runs. Adding it there only + // meant a gesture stopped notifying window listeners the moment it became + // multi touch, which is where pull to refresh loses its updates. + if (pointerDraggedListeners != null && pointerDraggedListeners.hasListeners()) { + ActionEvent e = new ActionEvent(this, ActionEvent.Type.PointerDrag, x[0], y[0]); + // Reported but not cleared here, which is what Form's multi-pointer path + // does -- the scalar path above owns the reset. + e.setPointerPressedDuringDrag(pointerPressedAgainDuringDrag); + pointerDraggedListeners.fireActionEvent(e); + if (e.isConsumed()) { + return; + } + } + autoRelease(x[0], y[0]); + Component target = dragged != null ? dragged : pressedCmp; + if (target != null) { + LeadUtil.pointerDragged(target, x, y); + } + } + + + /// {@inheritDoc} + @Override + public void pointerReleased(int x, int y) { + if (Display.getInstance().isStylusPointer()) { + Component stylusCmp = resolveComponentAt(x, y); + if (stylusCmp != null) { + stylusCmp.fireStylusEvent(ActionEvent.Type.PointerReleased, x, y); + } + } + // The token identifying *this* gesture. A release handler may enter + // invokeAndBlock, whose nested event loop can dispatch a fresh press in this + // same window before the handler returns; tearing down unconditionally then + // erases the replacement gesture's target rather than this one's. + final Object releasing = currentPointerPress; + // Captured before the listeners run, not after. A listener can enter + // invokeAndBlock, whose nested loop dispatches a fresh press in this window + // and replaces these fields; resolving the target afterwards released the + // *replacement* gesture's component, activating it with no native release of + // its own. + final Component releasingDragged = dragged; + final Component releasingPressed = pressedCmp; + if (pointerReleasedListeners != null && pointerReleasedListeners.hasListeners()) { + ActionEvent e = new ActionEvent(this, ActionEvent.Type.PointerReleased, x, y); + pointerReleasedListeners.fireActionEvent(e); + if (e.isConsumed()) { + // A drag that was actually activated still has to be finished, or the + // component stays hidden and the drop never runs -- Form does the + // same on its consumed path. + if (releasingDragged != null && releasingDragged.isDragAndDropInitialized()) { + LeadUtil.dragFinished(releasingDragged, x, y); + } + // Still cleared: the gesture is over regardless of who handled it, + // and leaving these set would strand the next press. + endGesture(releasing); + return; + } + } + Component target = releasingDragged != null ? releasingDragged : releasingPressed; + if (target != null) { + if (releasingDragged != null && releasingDragged.isDragAndDropInitialized()) { + // An activated drag ends with dragFinished, not pointerReleased. + // Component hides the component when the drag activates and only + // dragFinishedImpl restores it, clears the top level's dragged + // component and runs the drop callbacks -- so releasing through the + // ordinary path left the component invisible and the drop unfinished. + LeadUtil.dragFinished(releasingDragged, x, y); + } else { + LeadUtil.pointerReleased(target, x, y); + } + } + endGesture(releasing); + } + + /// Clears the pressed state for the gesture identified by `token`, and only that + /// gesture. A newer press installed during nested dispatch carries a different + /// token and is left alone. + private void endGesture(Object token) { + if (currentPointerPress != token) { //NOPMD CompareObjectsWithEquals + return; + } + pressedCmp = null; + dragged = null; + currentPointerPress = null; + } + + /// Cancels a press once the pointer leaves the pressed component's release + /// radius, the same way `Form` does it. + /// + /// Without this a button pressed in a window, dragged outside it and released + /// still fired its action: the window kept forwarding to the pressed component + /// and nothing ever cancelled the press. The list this consumes was here from + /// the start but nothing filled it, because `Button` registered through + /// `Component#getComponentForm()`, which is null inside a window. + private void autoRelease(int x, int y) { + if (componentsAwaitingRelease != null && componentsAwaitingRelease.size() == 1) { + // special case allowing drag within a button + Component atXY = LeadUtil.leadParentImpl(getComponentAt(x, y)); + Component pendingC = componentsAwaitingRelease.get(0); + if (pendingC != null) { + pendingC = LeadUtil.leadParentImpl(pendingC); + } + Component pendingCLead = LeadUtil.leadComponentImpl(pendingC); + if (atXY != pendingC) { //NOPMD CompareObjectsWithEquals + if (pendingCLead instanceof ReleasableComponent) { + ReleasableComponent rc = (ReleasableComponent) pendingCLead; + int relRadius = rc.getReleaseRadius(); + if (relRadius > 0) { + Rectangle r = new Rectangle( + pendingC.getAbsoluteX() - relRadius, + pendingC.getAbsoluteY() - relRadius, + pendingC.getWidth() + relRadius * 2, + pendingC.getHeight() + relRadius * 2 + ); + if (!r.contains(x, y)) { + componentsAwaitingRelease = null; + LeadUtil.dragInitiated(pendingC); + } + return; + } + componentsAwaitingRelease = null; + LeadUtil.dragInitiated(pendingC); + } + } else if (pendingCLead instanceof ReleasableComponent + && ((ReleasableComponent) pendingCLead).isAutoRelease()) { + componentsAwaitingRelease = null; + LeadUtil.dragInitiated(pendingC); + } + } + } + + /// {@inheritDoc} + /// + /// The keyboard counterpart of `#longPointerPress(int, int)`, and broken the same + /// way: `Display` dispatches a long key press to the top level, `Component`'s + /// implementation is empty, so holding a key inside a window reached nothing. + /// Found by checking what else shares that dispatch site rather than waiting for + /// it to be reported. + @Override + protected void longKeyPress(int keyCode) { + if (focused != null && focused.getTopLevelContainer() == this) { //NOPMD CompareObjectsWithEquals + focused.longKeyPress(keyCode); + } + } + + /// {@inheritDoc} + /// + /// `Component`'s implementation only fires listeners attached to this window, so + /// without this a long press on a button inside a window reached nothing -- + /// neither the component nor its context menu. + @Override + public void longPointerPress(int x, int y) { + // Listeners registered on the window itself run first and can consume the + // gesture, the same order Form uses. Forwarding to the child without this + // silently dropped every addLongPressListener attached to the window. + if (longPressListeners != null && longPressListeners.hasListeners()) { + ActionEvent ev = new ActionEvent(this, ActionEvent.Type.LongPointerPress, x, y); + longPressListeners.fireActionEvent(ev); + if (ev.isConsumed()) { + return; + } + } + // A long press is the touch equivalent of a right click, so it is a context + // menu request next, exactly as on a Form. + Component ctxCmp = resolveComponentAt(x, y); + if (ctxCmp != null && ctxCmp.fireContextMenu(x, y)) { + return; + } + Component target = pressedCmp != null ? pressedCmp : focused; + if (target != null && target.contains(x, y) + && target.getTopLevelContainer() == this) { //NOPMD CompareObjectsWithEquals + LeadUtil.longPointerPress(target, x, y); + } + } + + /// This window's key-repeat and long-press timers. Fields, for the same reason the + /// gesture state is: a window that goes away takes them with it, and nothing has to + /// remember to hand a slot back. + private boolean keyRepeatArmed; + private boolean keyLongPressArmed; + private int keyRepeatValue; + private long keyRepeatNext; + private long keyLongPressStart; + private boolean longPointerArmed; + private int longPointerX; + private int longPointerY; + private long longPointerStart; + + /// The container in this window that accepted the current press, so its release + /// reaches the same place. A field rather than an entry in a table keyed by window, + /// for the same reason as everything else here. + private Container pointerPressTarget; + + void rememberPointerPress(Container target) { + pointerPressTarget = target; + } + + /// Returns the pending press target and clears it, so a release consumes it. + Container takePointerPressTarget() { + Container out = pointerPressTarget; + pointerPressTarget = null; + return out; + } + + boolean hasPointerPressTarget() { + return pointerPressTarget != null; + } + + /// Arms key repeat and long key press for a press this window accepted. + void chargeKeyRepeat(int keyCode, boolean armed, long now, long firstRepeatAt) { + keyRepeatArmed = armed; + keyLongPressArmed = armed; + keyRepeatValue = keyCode; + keyLongPressStart = now; + keyRepeatNext = firstRepeatAt; + } + + /// Cancels repeat for one key code, used when that key's release arrived + /// somewhere else. + void cancelKeyRepeatForCode(int keyCode) { + if (keyRepeatValue == keyCode) { + cancelKeyRepeat(); + } + } + + void cancelKeyRepeat() { + keyRepeatArmed = false; + keyLongPressArmed = false; + } + + boolean hasKeyRepeatArmed() { + return keyRepeatArmed || keyLongPressArmed; + } + + /// True while both are still pending, which is what tells the event loop it may + /// not go to sleep yet. + boolean hasKeyRepeatAndLongPressArmed() { + return keyRepeatArmed && keyLongPressArmed; + } + + /// Arms the long pointer press for a press this window accepted. + void chargeLongPointerPress(int x, int y) { + longPointerArmed = true; + longPointerX = x; + longPointerY = y; + longPointerStart = System.currentTimeMillis(); + } + + void cancelLongPointerPress() { + longPointerArmed = false; + } + + boolean hasLongPointerArmed() { + return longPointerArmed; + } + + /// Fires whichever of this window's timers are due. Called once per paint pass + /// from the event loop, with the loop's clock so every surface agrees on the time. + void serviceInputTimers(long now, int longPressInterval) { + if (!nativeVisible || disposing) { + return; + } + // A window the user cannot reach must not go on receiving the repeats and long + // presses a still-held key armed before it was blocked. The routing helper this + // replaced made the same check, and dropping it here would have delivered input + // to a window sitting behind a modal. + if (Desktop.getInstance().isWindowInputBlocked(getWindowId())) { + return; + } + if (keyRepeatArmed && keyRepeatNext <= now) { + keyRepeated(keyRepeatValue); + int keyRepeatNextIntervalTime = 10; + keyRepeatNext = now + keyRepeatNextIntervalTime; + } + if (keyLongPressArmed && longPressInterval <= now - keyLongPressStart) { + keyLongPressArmed = false; + longKeyPress(keyRepeatValue); + } + if (longPointerArmed && longPressInterval <= now - longPointerStart) { + longPointerArmed = false; + longPointerPress(longPointerX, longPointerY); + } + } + + /// The recent pointer path in this window, created on first use because a window + /// that never sees a drag has no reason to hold the ring. + PointerDragHistory dragHistory() { + if (dragHistory == null) { + dragHistory = Display.getInstance().newDragHistory(); + } + return dragHistory; + } + + /// Records a position in the current gesture. + void recordDrag(int x, int y, int timestamp) { + dragHistory().record(x, y, timestamp); + } + + /// Forgets the previous gesture so a new press does not fling with its speed. + void resetDragHistory() { + if (dragHistory != null) { + dragHistory.reset(); + } + } + + /// Whether a drag happened during the gesture currently in this window. + boolean hasDragOccured() { + return dragOccured; + } + + void setDragOccured(boolean value) { + dragOccured = value; + } + + /// The fling speed of the gesture in this window. Named apart from + /// Component.getDragSpeed(boolean), which this class inherits and which means the + /// speed of the component's own drag. + float windowDragSpeed(boolean yAxis) { + return dragHistory().speed(Display.impl, yAxis); + } + + /// Records a press that has not been released or dragged, with the point it went + /// down at, for the pureTouch selection test. + void setSelectionPressed(boolean value, int x, int y) { + selectionPressed = value; + if (value) { + selectionPressedX = x; + selectionPressedY = y; + } + } + + /// Whether this window holds a press that should still show selection on the + /// given component. The component is tested against this window's own press + /// coordinates -- window coordinates are window relative, so another window's + /// pointer position is not merely the wrong point but a point in a different + /// space. + @Override + boolean showsSelectionFor(Component c) { + return selectionPressed && c.contains(selectionPressedX, selectionPressedY); + } + + /// Whether this window holds a press at all, which is what the component-less + /// selection query asks. + boolean hasSelectionPressed() { + return selectionPressed; + } + + /// Stops any glide still running in the pressed component's ancestors, so the + /// press can take the scroll over. The counterpart of `isCurrentlyScrolling`, + /// ported from `Form.cancelScrolling`. + private void cancelScrolling(Component cmp) { + Container parent = cmp.getParent(); + while (parent != null) { + if (parent.draggedMotionX != null || parent.draggedMotionY != null) { + parent.draggedMotionX = null; + parent.draggedMotionY = null; + } + parent = parent.getParent(); + } + } + + /// Set when a press landed on a still-gliding container and took the scroll over. + /// Reported to drag listeners the way `Form` reports it, so a listener can tell a + /// fresh drag from one that continued out of a momentum scroll. + private boolean pointerPressedAgainDuringDrag; + + /// Whether any ancestor of the pressed component is still gliding from a + /// previous drag. Ported from `Form`, which swallows the press in that case so a + /// tap stops the scroll instead of starting an interaction. + private boolean isCurrentlyScrolling(Component cmp) { + Container parent = cmp.getParent(); + while (parent != null) { + if (parent.draggedMotionX != null || parent.draggedMotionY != null) { + return true; + } + parent = parent.getParent(); + } + return false; + } + + /// Haptic feedback for a press on a component that asks for it, as `Form` does. + private void tactileTouchVibe(int x, int y, Component cmp) { + if (tactileTouchDuration < 0) { + // Resolved on first use rather than in a constructor: the look and feel a + // window should follow is the one in effect when it is interacted with. + tactileTouchDuration = getUIManager().getLookAndFeel().getTactileTouchDuration(); + } + if (tactileTouchDuration > 0 && cmp.isTactileTouch(x, y)) { + Display.getInstance().vibrate(tactileTouchDuration); + } + } + + /// Ends every gesture in flight because the window has left the user's reach. + /// + /// Called from every path that does that -- `#hide()`, a native minimize through + /// `#hideNotify()`, `#dispose()`, and losing focus to another application -- + /// rather than from whichever one was last reported. Losing focus counts: the + /// key-up goes to whoever has focus now, so a held key would otherwise repeat + /// here forever. A window that goes away mid-gesture leaves three kinds of state + /// behind, and all three have to be undone together: + /// + /// an activated drag and drop, whose component `Component` has already hidden and + /// which only `dragFinishedImpl` restores; a pressed component, latched in its + /// pressed state with no release coming; and the framework's own recorded targets + /// and timers, which otherwise keep firing into a tree nobody can see. + void cancelPendingInput() { + if (dragged != null && dragged.isDragAndDropInitialized()) { + // Finished outside the window so no drop target is found: the user never + // completed the drag, the window simply went away. This still restores + // the component's visibility and clears the drag flags. + LeadUtil.dragFinished(dragged, -1, -1); + } + // dragInitiated is the existing "ended without completing" primitive -- it + // resets the pressed state without firing the action. + if (pressedCmp != null) { + LeadUtil.dragInitiated(pressedCmp); + } + if (focused != null && focused != pressedCmp) { //NOPMD CompareObjectsWithEquals + LeadUtil.dragInitiated(focused); + } + pressedCmp = null; + dragged = null; + currentPointerPress = null; + Display.getInstance().windowInputCancelled(this); + } + + private Component resolveComponentAt(int x, int y) { + Component cmp = getActualPane(x, y).getComponentAt(x, y); + while (cmp != null && cmp.isIgnorePointerEvents()) { + cmp = cmp.getParent(); + } + if (cmp == null) { + return null; + } + return LeadUtil.leadParentImpl(cmp); + } + + /// {@inheritDoc} + @Override + Object getCurrentPointerPress() { + return currentPointerPress; + } + + /// {@inheritDoc} + @Override + int getInitialPressX() { + return initialPressX; + } + + /// {@inheritDoc} + @Override + int getInitialPressY() { + return initialPressY; + } + + /// {@inheritDoc} + @Override + Component getDraggedComponent() { + return dragged; + } + + /// {@inheritDoc} + @Override + void setDraggedComponent(Component dragged) { + this.dragged = LeadUtil.leadParentImpl(dragged); + } + + private void initFocused() { + if (focused == null) { + Component first = getActualPane().findFirstFocusable(); + if (first != null) { + setFocused(first); + } + } + } + + /// {@inheritDoc} + /// + /// `Component`'s implementation is empty, so without this a window would receive + /// hover events and drop them: no tooltips, and no hover state on the components + /// under the pointer. + @Override + public void pointerHover(int[] x, int[] y) { + if (dragged != null) { + LeadUtil.pointerHover(dragged, x, y); + return; + } + Component cmp = resolveComponentAt(x[0], y[0]); + if (cmp != null) { + LeadUtil.pointerHover(cmp, x, y); + // Deliberately no TooltipManager call. It schedules only when + // getComponentForm() is non-null and displays through InteractionDialog on + // the current form, so from a window it would either do nothing or put the + // tooltip on the main window. It is listed with the other form-coupled + // overlays in the developer guide rather than half-wired here. + } + } + + /// {@inheritDoc} + @Override + public void pointerHoverReleased(int[] x, int[] y) { + Component cmp = resolveComponentAt(x[0], y[0]); + if (cmp != null) { + LeadUtil.pointerHoverReleased(cmp, x, y); + } + } + + /// {@inheritDoc} + @Override + public void pointerHoverPressed(int[] x, int[] y) { + Component cmp = resolveComponentAt(x[0], y[0]); + if (cmp != null) { + LeadUtil.pointerHoverPressed(cmp, x, y); + } + } + + // ---- native visibility ---------------------------------------------------------- + + /// {@inheritDoc} + /// + /// The platform telling us the window is no longer on screen -- minimized, or + /// hidden by the window manager. `Container`'s implementation is inert, which + /// would leave the window counted as visible: it would keep being painted, its + /// animations would keep the event dispatch thread awake, and a minimized window + /// that animates would spin the thread forever. + @Override + void hideNotify() { + super.hideNotify(); + if (nativeVisible) { + nativeVisible = false; + // Native minimization arrives here rather than through hide(), and the + // window stays registered either way, so the same cleanup is owed: a held + // key would otherwise keep repeating into the hidden tree, and the + // platform may never deliver the release once focus is gone. + cancelPendingInput(); + // Recorded separately from an explicit hide(): a minimized window is still + // open, and still modal if it was, so this must not read as "the modal is + // over" -- see isModalFinished(). + iconified = true; + // Nothing paints a window that is not on screen, so anything queued on its + // surface would sit there keeping hasPendingPaints() true. + clearPaintSurface(); + fireWindowEvent(WindowEvent.Type.Minimized); + } + } + + /// {@inheritDoc} + /// + /// The platform telling us the window is on screen again. Painting resumes from + /// here, so the window is repainted in full rather than waiting for something to + /// dirty it. + @Override + void showNotify() { + super.showNotify(); + if (!nativeVisible && !disposing && nativePeer != null) { + nativeVisible = true; + iconified = false; + fireWindowEvent(WindowEvent.Type.Restored); + repaint(); + Display.getInstance().wakeEdt(); + } + } + + /// {@inheritDoc} + @Override + public void addComponentAwaitingRelease(C c) { + if (componentsAwaitingRelease == null) { + componentsAwaitingRelease = new ArrayList(); + } + componentsAwaitingRelease.add(c); + } + + /// {@inheritDoc} + @Override + public void removeComponentAwaitingRelease(C c) { + if (componentsAwaitingRelease != null) { + componentsAwaitingRelease.remove(c); + } + } + + /// {@inheritDoc} + @Override + public void clearComponentsAwaitingRelease() { + if (componentsAwaitingRelease != null) { + componentsAwaitingRelease.clear(); + } + } + + // ---- key dispatch -------------------------------------------------------------- + + /// {@inheritDoc} + /// + /// A window dispatches keys itself, exactly as `Form` does. Inheriting + /// `Container`'s handler instead only forwards to a lead component, so the focused + /// component would never see a key, arrow traversal would not work and nothing + /// registered through `#addKeyListener(int, ActionListener)` would ever fire. + /// + /// This is the same shape as `Form#keyPressed(int)` minus the menu bar, which a + /// window does not have: commands reach the desktop menu instead. + @Override + public void keyPressed(int keyCode) { + int game = Display.getInstance().getGameAction(keyCode); + if (focused != null) { + if (focused.isEnabled()) { + focused.keyPressed(keyCode); + } + if (focused.handlesInput()) { + return; + } + if (focused.getTopLevelContainer() == this) { //NOPMD CompareObjectsWithEquals + updateWindowFocus(game); + } else { + focused = null; + initFocused(); + } + } else { + initFocused(); + if (focused == null) { + getContentPane().moveScrollTowards(game, null); + } + } + } + + /// {@inheritDoc} + @Override + public void keyReleased(int keyCode) { + if (focused != null && focused.getTopLevelContainer() == this //NOPMD CompareObjectsWithEquals + && focused.isEnabled()) { + focused.keyReleased(keyCode); + } + fireKeyEvent(keyCode); + } + + /// {@inheritDoc} + @Override + public void keyRepeated(int keyCode) { + if (focused == null) { + keyPressed(keyCode); + keyReleased(keyCode); + return; + } + if (focused.isEnabled()) { + focused.keyRepeated(keyCode); + } + int game = Display.getInstance().getGameAction(keyCode); + if (!focused.handlesInput() + && (game == Display.GAME_DOWN || game == Display.GAME_UP + || game == Display.GAME_LEFT || game == Display.GAME_RIGHT)) { + keyPressed(keyCode); + keyReleased(keyCode); + } + } + + private void fireKeyEvent(int keyCode) { + if (keyListeners == null) { + return; + } + ArrayList listeners = keyListeners.get(Integer.valueOf(keyCode)); + if (listeners == null) { + return; + } + ActionEvent evt = new ActionEvent(this, keyCode); + int len = listeners.size(); + for (int iter = 0; iter < len; iter++) { + listeners.get(iter).actionPerformed(evt); + if (evt.isConsumed()) { + return; + } + } + } + + /// The component below the focus owner, honouring an explicit + /// `Component#getNextFocusDown()` before scanning by position, exactly as a `Form` + /// does. + /// + /// `Container`'s versions of these four answer null, which is right for an + /// ordinary container and wrong for a top level: every arrow key in a window + /// resolved through them and moved focus nowhere, so a window could not be + /// navigated from the keyboard at all. + @Override + Component findNextFocusDown() { + if (focused != null) { + if (focused.getNextFocusDown() != null) { + return focused.getNextFocusDown(); + } + return findNextFocusVertical(true); + } + return null; + } + + /// The counterpart to `#findNextFocusDown()`. + @Override + Component findNextFocusUp() { + if (focused != null) { + if (focused.getNextFocusUp() != null) { + return focused.getNextFocusUp(); + } + return findNextFocusVertical(false); + } + return null; + } + + /// The component right of the focus owner, honouring an explicit + /// `Component#getNextFocusRight()` before scanning by position. + @Override + Component findNextFocusRight() { + if (focused != null) { + if (focused.getNextFocusRight() != null) { + return focused.getNextFocusRight(); + } + return findNextFocusHorizontal(true); + } + return null; + } + + /// The counterpart to `#findNextFocusRight()`. + @Override + Component findNextFocusLeft() { + if (focused != null) { + if (focused.getNextFocusLeft() != null) { + return focused.getNextFocusLeft(); + } + return findNextFocusHorizontal(false); + } + return null; + } + + /// Scans this window for the next focusable component above or below the focus + /// owner, through the shared traversal `Form` uses. + /// + /// The layered pane is searched first when there is one, so a component in an + /// overlay takes focus before one underneath it, and `isCyclicFocus()` wraps to + /// the far end when nothing lies in the direction asked for. + /// + /// #### Parameters + /// + /// - `down`: true for the next component below, false for above + /// + /// #### Returns + /// + /// the next focusable component, or null + private Component findNextFocusVertical(boolean down) { + Component c; + if (layeredPane != null) { + c = TopLevelSupport.findNextFocusVertical(focused, null, layeredPane, down); + if (c != null) { + return c; + } + } + Container actual = getActualPane(); + c = TopLevelSupport.findNextFocusVertical(focused, null, actual, down); + if (c != null) { + return c; + } + if (isCyclicFocus()) { + c = TopLevelSupport.findNextFocusVertical(focused, null, actual, !down); + if (c != null) { + Component current = TopLevelSupport.findNextFocusVertical(c, null, actual, !down); + while (current != null) { + c = current; + current = TopLevelSupport.findNextFocusVertical(c, null, actual, !down); + } + return c; + } + } + return null; + } + + /// The horizontal counterpart to `#findNextFocusVertical(boolean)`. + /// + /// #### Parameters + /// + /// - `right`: true for the next component to the right, false for the left + /// + /// #### Returns + /// + /// the next focusable component, or null + private Component findNextFocusHorizontal(boolean right) { + Component c; + if (layeredPane != null) { + c = TopLevelSupport.findNextFocusHorizontal(focused, null, layeredPane, right); + if (c != null) { + return c; + } + } + Container actual = getActualPane(); + c = TopLevelSupport.findNextFocusHorizontal(focused, null, actual, right); + if (c != null) { + return c; + } + if (isCyclicFocus()) { + c = TopLevelSupport.findNextFocusHorizontal(focused, null, actual, !right); + if (c != null) { + Component current = TopLevelSupport.findNextFocusHorizontal(c, null, actual, !right); + while (current != null) { + c = current; + current = TopLevelSupport.findNextFocusHorizontal(c, null, actual, !right); + } + return c; + } + } + return null; + } + + /// Moves focus in the direction of an arrow key, mirroring `Form`'s traversal. + private void updateWindowFocus(int gameAction) { + Component next = null; + switch (gameAction) { + case Display.GAME_DOWN: + next = findNextFocusDown(); + break; + case Display.GAME_UP: + next = findNextFocusUp(); + break; + case Display.GAME_RIGHT: + next = findNextFocusRight(); + break; + case Display.GAME_LEFT: + next = findNextFocusLeft(); + break; + default: + return; + } + if (next != null) { + setFocused(next); + scrollComponentToVisible(next); + } + } + + // ---- content delegation ------------------------------------------------------------ + + /// {@inheritDoc} + /// + /// Adds to the content pane, mirroring `Form`, so `window.add(cmp)` means + /// `window.getContentPane().add(cmp)`. Container's add() is final and routes + /// through here, so overriding addComponent covers both. + @Override + public void addComponent(Component cmp) { + contentPane.addComponent(cmp); + } + + /// {@inheritDoc} + @Override + public void addComponent(Object constraints, Component cmp) { + contentPane.addComponent(constraints, cmp); + } + + /// {@inheritDoc} + /// + /// The indexed overloads need delegating too. They are separate methods rather + /// than paths through the two above, so without these an indexed add put the + /// component in the window root beside the title area and the content pane -- + /// where the root's own BorderLayout would place it, and where + /// `#getContentPane()` cannot see it. + @Override + public void addComponent(int index, Component cmp) { + contentPane.addComponent(index, cmp); + } + + /// {@inheritDoc} + @Override + public void addComponent(int index, Object constraints, Component cmp) { + contentPane.addComponent(index, constraints, cmp); + } + + /// {@inheritDoc} + @Override + public void removeComponent(Component cmp) { + contentPane.removeComponent(cmp); + } + + /// {@inheritDoc} + @Override + public void removeAll() { + contentPane.removeAll(); + } + + // The animation and replace family, delegated for the same reason add() is: the + // application's components live in the content pane, so animating or searching the + // window root would animate the title area along with them and look for children + // that are not there. Form delegates every one of these. + + /// {@inheritDoc} + @Override + public int getComponentIndex(Component cmp) { + return contentPane.getComponentIndex(cmp); + } + + /// {@inheritDoc} + @Override + public void replace(Component current, Component next, Transition t) { + contentPane.replace(current, next, t); + } + + /// {@inheritDoc} + @Override + public void replaceAndWait(Component current, Component next, Transition t) { + contentPane.replaceAndWait(current, next, t); + } + + /// {@inheritDoc} + @Override + public void animateLayout(int duration) { + contentPane.animateLayout(duration); + } + + /// {@inheritDoc} + @Override + public void animateLayoutAndWait(int duration) { + contentPane.animateLayoutAndWait(duration); + } + + /// {@inheritDoc} + @Override + public void animateLayoutFade(int duration, int startingOpacity) { + contentPane.animateLayoutFade(duration, startingOpacity); + } + + /// {@inheritDoc} + @Override + public void animateLayoutFadeAndWait(int duration, int startingOpacity) { + contentPane.animateLayoutFadeAndWait(duration, startingOpacity); + } + + /// {@inheritDoc} + @Override + public void animateHierarchy(int duration) { + contentPane.animateHierarchy(duration); + } + + /// {@inheritDoc} + @Override + public void animateHierarchyAndWait(int duration) { + contentPane.animateHierarchyAndWait(duration); + } + + /// {@inheritDoc} + @Override + public void animateHierarchyFade(int duration, int startingOpacity) { + contentPane.animateHierarchyFade(duration, startingOpacity); + } + + /// {@inheritDoc} + @Override + public void animateHierarchyFadeAndWait(int duration, int startingOpacity) { + contentPane.animateHierarchyFadeAndWait(duration, startingOpacity); + } + + /// {@inheritDoc} + @Override + public void animateUnlayout(int duration, int opacity, Runnable callback) { + contentPane.animateUnlayout(duration, opacity, callback); + } + + /// {@inheritDoc} + @Override + public void animateUnlayoutAndWait(int duration, int opacity) { + contentPane.animateUnlayoutAndWait(duration, opacity); + } + + /// {@inheritDoc} + @Override + public Layout getLayout() { + return contentPane.getLayout(); + } + + /// {@inheritDoc} + @Override + public void setLayout(Layout layout) { + contentPane.setLayout(layout); + } + + /// {@inheritDoc} + @Override + public boolean isScrollable() { + return contentPane.isScrollable(); + } + + /// {@inheritDoc} + @Override + public void setScrollable(boolean scrollable) { + contentPane.setScrollable(scrollable); + } + + // The rest of the scrolling surface, delegated for the same reason isScrollable is: + // the content pane scrolls, not the window root, which is a fixed BorderLayout + // holding the title area and the content. Without these, window.setScrollableY(true) + // set the flag on the root -- where nothing reads it -- while the identical call on + // a Form reached the content pane, so code moved from a Form to a Window silently + // stopped scrolling. + + /// {@inheritDoc} + /// + /// Forwarded to the content pane as well as the window root: the application's + /// layout runs in the content pane, so setting it on the root alone left + /// directional layouts and alignment reversed while `isRTL()` reported true. + @Override + public void setRTL(boolean r) { + super.setRTL(r); + contentPane.setRTL(r); + } + + /// {@inheritDoc} + @Override + public boolean isScrollableX() { + return contentPane.isScrollableX(); + } + + /// {@inheritDoc} + @Override + public void setScrollableX(boolean scrollableX) { + contentPane.setScrollableX(scrollableX); + } + + /// {@inheritDoc} + @Override + public boolean isScrollableY() { + return contentPane.isScrollableY(); + } + + /// {@inheritDoc} + @Override + public void setScrollableY(boolean scrollableY) { + contentPane.setScrollableY(scrollableY); + } + + /// {@inheritDoc} + @Override + public boolean isScrollVisible() { + return contentPane.isScrollVisible(); + } + + /// {@inheritDoc} + @Override + public void setScrollVisible(boolean scrollVisible) { + contentPane.setScrollVisible(scrollVisible); + } + + /// {@inheritDoc} + @Override + public boolean isSmoothScrolling() { + return contentPane.isSmoothScrolling(); + } + + /// {@inheritDoc} + @Override + public void setSmoothScrolling(boolean smoothScrolling) { + // Null-checked as Form does: Component's constructor reaches this before the + // content pane exists. + if (contentPane != null) { + contentPane.setSmoothScrolling(smoothScrolling); + } + } + + /// {@inheritDoc} + @Override + public int getScrollAnimationSpeed() { + return contentPane.getScrollAnimationSpeed(); + } + + /// {@inheritDoc} + @Override + public void setScrollAnimationSpeed(int animationSpeed) { + contentPane.setScrollAnimationSpeed(animationSpeed); + } + + /// {@inheritDoc} + @Override + public boolean isAlwaysTensile() { + return contentPane.isAlwaysTensile(); + } + + /// {@inheritDoc} + @Override + public void setAlwaysTensile(boolean alwaysTensile) { + contentPane.setAlwaysTensile(alwaysTensile); + } +} diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index 7cdebafbe1e..842653afcbc 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -36,6 +36,7 @@ import com.codename1.ui.events.ActionListener; import com.codename1.ui.events.WheelEvent; import com.codename1.ui.geom.Dimension; +import com.codename1.ui.TopLevelContainer; /// The pure Codename One text editing surface. It renders a plain text `EditorDocument` with its own /// `Graphics` code, owns the caret and selection, handles pointer and keyboard interaction, and captures @@ -67,6 +68,9 @@ public class EditorView extends Component implements TextInputClient { private long lastBlink; private boolean animRegistered; + /// The top level the caret animation was registered on. + private TopLevelContainer caretAnimationHost; + private Object inputHandle; private boolean inputActive; @@ -192,9 +196,14 @@ public boolean isEditableState() { /// Relinquishes focus and stops the active platform text-input session. public void blur() { - com.codename1.ui.Form form = getComponentForm(); - if (form != null && equals(form.getFocused())) { - form.setFocused(null); + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so blurring there took the else branch and merely stopped + // the input session. focusLost() then never ran, leaving the caret animation + // registered and the global multi-key mode switched on while the editor still + // reported itself focused. + TopLevelContainer top = getTopLevelContainer(); + if (top != null && equals(top.getFocused())) { + top.setFocused(null); } else { stopInput(); } @@ -1394,8 +1403,13 @@ protected void focusGained() { multiKeyModeInstalled = true; } startInput(); - if (!animRegistered && getComponentForm() != null) { - getComponentForm().registerAnimated(this); + // Gated on the top level, not on an enclosing Form: getComponentForm() is + // null by design inside a Window, so the old guard skipped the registration + // there entirely and the caret never blinked. + TopLevelContainer focusTop = getTopLevelContainer(); + if (!animRegistered && focusTop != null) { + caretAnimationHost = focusTop; + focusTop.registerAnimated(this); animRegistered = true; } resetBlink(); @@ -1419,8 +1433,17 @@ protected void focusLost() { super.focusLost(); restoreMultiKeyMode(); stopInput(); - if (animRegistered && getComponentForm() != null) { - getComponentForm().deregisterAnimated(this); + // Deregistration is driven by animRegistered rather than by an enclosing + // Form, so an editor that registered inside a Window is also released. + if (animRegistered) { + // The top level that took the registration, not whatever this editor + // resolves to now: focus can be lost *because* the editor was removed, in + // which case resolving again answers null and the caret animation stays on + // the original for good. + if (caretAnimationHost != null) { + caretAnimationHost.deregisterAnimated(this); + caretAnimationHost = null; + } animRegistered = false; } repaint(); diff --git a/CodenameOne/src/com/codename1/ui/events/WindowEvent.java b/CodenameOne/src/com/codename1/ui/events/WindowEvent.java index d98751dea42..75cadeb2c5a 100644 --- a/CodenameOne/src/com/codename1/ui/events/WindowEvent.java +++ b/CodenameOne/src/com/codename1/ui/events/WindowEvent.java @@ -47,6 +47,27 @@ public WindowEvent(Display source, Type type, Rectangle bounds) { this.bounds = bounds; } + /// Creates a new window event for a source other than the display, such as an + /// individual `com.codename1.ui.Window`. + /// + /// The `Display` form is kept separate so that code registered through + /// `Display#addWindowListener(com.codename1.ui.events.ActionListener)` -- which + /// only ever hears about the application's main window -- can keep casting + /// `getSource()` to `Display` safely. + /// + /// #### Parameters + /// + /// - `source`: the object that generated the event + /// + /// - `type`: the type of the window event + /// + /// - `bounds`: the bounds of the window, if known + public WindowEvent(Object source, Type type, Rectangle bounds) { + super(source, ActionEvent.Type.Other); + this.type = type; + this.bounds = bounds; + } + /// The type of window event. /// /// #### Returns @@ -78,6 +99,9 @@ public enum Type { /// The window was resized. Resized, /// The window was moved. - Moved + Moved, + /// The window was destroyed and its native window released. Unlike a close + /// request, this cannot be vetoed -- it reports what already happened. + Disposed } } diff --git a/CodenameOne/src/com/codename1/ui/list/ContainerList.java b/CodenameOne/src/com/codename1/ui/list/ContainerList.java index 3ebe7dd6596..d0d8dbe53d8 100644 --- a/CodenameOne/src/com/codename1/ui/list/ContainerList.java +++ b/CodenameOne/src/com/codename1/ui/list/ContainerList.java @@ -26,7 +26,6 @@ import com.codename1.ui.Component; import com.codename1.ui.Container; import com.codename1.ui.Display; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; @@ -36,6 +35,7 @@ import com.codename1.ui.geom.Rectangle; import com.codename1.ui.layouts.Layout; import com.codename1.ui.util.EventDispatcher; +import com.codename1.ui.TopLevelContainer; import java.util.Collection; import java.util.Vector; @@ -129,9 +129,9 @@ private void updateComponentCount() { removeComponent(getComponentAt(getComponentCount() - 1)); } } - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { - f.revalidate(); + f.asContainer().revalidate(); } } } diff --git a/CodenameOne/src/com/codename1/ui/list/GenericListCellRenderer.java b/CodenameOne/src/com/codename1/ui/list/GenericListCellRenderer.java index 88083a3b92f..3ed9fd9c1df 100644 --- a/CodenameOne/src/com/codename1/ui/list/GenericListCellRenderer.java +++ b/CodenameOne/src/com/codename1/ui/list/GenericListCellRenderer.java @@ -30,7 +30,6 @@ import com.codename1.ui.Container; import com.codename1.ui.Display; import com.codename1.ui.EncodedImage; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.Label; @@ -38,6 +37,7 @@ import com.codename1.ui.RadioButton; import com.codename1.ui.Slider; import com.codename1.ui.TextArea; +import com.codename1.ui.TopLevelContainer; import com.codename1.ui.URLImage; import com.codename1.ui.animations.Animation; import com.codename1.ui.events.ActionEvent; @@ -180,6 +180,10 @@ public class GenericListCellRenderer implements ListCellRenderer, CellRend private final Component[] selectedEntries; private final Component[] unselectedEntries; private final Monitor mon = new Monitor(); + + /// The top level the monitor was registered on, so it is released from that one + /// rather than from wherever the list has since moved. + private TopLevelContainer monitorHost; private final boolean firstCharacterRTL; private final HashMap placeholders = new HashMap(); private Button lastClickedComponent; @@ -508,8 +512,12 @@ private void setComponentValueWithTickering(Component cmp, Object value, Compone if (!label.isTickerRunning()) { parentList = l; if (parentList != null) { - Form f = parentList.getComponentForm(); + // Resolve the top level rather than the Form: this renderer + // works inside a Window, where getComponentForm() is null and + // the ticker would silently never animate. + TopLevelContainer f = parentList.getTopLevelContainer(); if (f != null) { + monitorHost = f; f.registerAnimated(mon); label.startTicker(cmp.getUIManager().getLookAndFeel().getTickerSpeed(), true); } @@ -551,8 +559,9 @@ private void setComponentValue(Component cmp, Object value, Component parent, Co parentList = parent; } if (parentList != null) { - Form f = parentList.getComponentForm(); + TopLevelContainer f = parentList.getTopLevelContainer(); if (f != null) { + monitorHost = f; f.registerAnimated(mon); waitingForRegisterAnimation = false; } else { @@ -562,8 +571,9 @@ private void setComponentValue(Component cmp, Object value, Component parent, Co } else { if (waitingForRegisterAnimation) { if (parentList != null) { - Form f = parentList.getComponentForm(); + TopLevelContainer f = parentList.getTopLevelContainer(); if (f != null) { + monitorHost = f; f.registerAnimated(mon); waitingForRegisterAnimation = false; } @@ -744,7 +754,7 @@ public boolean animate() { } } } - Form f = parentList.getComponentForm(); + TopLevelContainer f = parentList.getTopLevelContainer(); if (f != null) { if (parentList.hasFocus() && Display.getInstance().shouldRenderSelection(parentList)) { int slen = selectedEntries.length; @@ -773,7 +783,15 @@ public boolean animate() { parentList.repaint(); } else { if (!hasAnimations) { - f.deregisterAnimated(this); + // The top level that took the registration, not whatever the + // list resolves to now: a list removed or reparented while a + // ticker or animated image is running resolves to null or + // somewhere else, and the original keeps this monitor for + // good -- invoking it every frame and never sleeping. + if (monitorHost != null) { + monitorHost.deregisterAnimated(this); + monitorHost = null; + } } } return false; @@ -805,7 +823,13 @@ public void actionPerformed(ActionEvent evt) { Map h = (Map) selection; Command cmd = (Command) h.get("$navigation"); if (cmd != null) { - parentList.getComponentForm().dispatchCommand(cmd, new ActionEvent(cmd, ActionEvent.Type.Command)); + // Resolve the top level rather than the Form: this renderer + // works in a Window too, where getComponentForm() is null and + // this dereference would NPE on the EDT. + TopLevelContainer top = parentList.getTopLevelContainer(); + if (top != null) { + top.dispatchCommand(cmd, new ActionEvent(cmd, ActionEvent.Type.Command)); + } return; } int slen = selectedEntries.length; diff --git a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java index d76f5821430..1811a85defd 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java +++ b/CodenameOne/src/com/codename1/ui/plaf/DefaultLookAndFeel.java @@ -36,7 +36,6 @@ import com.codename1.ui.Display; import com.codename1.ui.Font; import com.codename1.ui.FontImage; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.Label; @@ -47,6 +46,7 @@ import com.codename1.ui.TextSelection.Char; import com.codename1.ui.TextSelection.Span; import com.codename1.ui.TextSelection.Spans; +import com.codename1.ui.TopLevelContainer; import com.codename1.ui.animations.Animation; import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.events.FocusListener; @@ -2238,7 +2238,14 @@ public void drawPullToRefresh(Graphics g, final Component cmp, boolean taskExecu return; } - final Form parentForm = cmp.getComponentForm(); + // Resolve the top level rather than the Form: getComponentForm() is null + // for anything inside a Window, and this registration is unguarded, so a + // pull-to-refresh gesture in a window would NPE on the EDT. + final TopLevelContainer parentTopLevel = cmp.getTopLevelContainer(); + // Through a local: the container is created lazily, and the taskExecuted path + // below never queries the height, which is the other place that would have + // created it -- so the field could still be null right here. + final Container pullContainer = initPullToRefreshComponents(); final int scrollY = cmp.getScrollY(); Component cmpToDraw; if (taskExecuted) { @@ -2251,9 +2258,9 @@ public void drawPullToRefresh(Graphics g, final Component cmp, boolean taskExecu } } - if (pull.getComponentAt(0) != updating && cmpToDraw != pull.getComponentAt(0)) { //NOPMD CompareObjectsWithEquals + if (parentTopLevel != null && pullContainer.getComponentAt(0) != updating && cmpToDraw != pullContainer.getComponentAt(0)) { //NOPMD CompareObjectsWithEquals - parentForm.registerAnimated(new Animation() { + parentTopLevel.registerAnimated(new Animation() { int counter = 0; Image i; @@ -2269,7 +2276,7 @@ public void drawPullToRefresh(Graphics g, final Component cmp, boolean taskExecu public boolean animate() { counter++; - if (pull.getComponentAt(0) == releaseToRefresh) { //NOPMD CompareObjectsWithEquals + if (pullContainer.getComponentAt(0) == releaseToRefresh) { //NOPMD CompareObjectsWithEquals ((Label) releaseToRefresh).setIcon(i.rotate(180 - (180 / 6) * counter)); } else { ((Label) pullDown).setIcon(i.rotate(180 * counter / 6)); @@ -2277,7 +2284,7 @@ public boolean animate() { if (counter == 6) { ((Label) releaseToRefresh).setIcon(i); ((Label) pullDown).setIcon(i.rotate(180)); - parentForm.deregisterAnimated(this); + parentTopLevel.deregisterAnimated(this); } // Placing the repaint inside a callSerially() because repaint directly @@ -2300,25 +2307,25 @@ public void paint(Graphics g) { }); } - if (pull.getComponentAt(0) != cmpToDraw //NOPMD CompareObjectsWithEquals + if (pullContainer.getComponentAt(0) != cmpToDraw //NOPMD CompareObjectsWithEquals && cmpToDraw instanceof Label - && (pull.getComponentAt(0) instanceof Label)) { - ((Label) cmpToDraw).setIcon(((Label) pull.getComponentAt(0)).getIcon()); + && (pullContainer.getComponentAt(0) instanceof Label)) { + ((Label) cmpToDraw).setIcon(((Label) pullContainer.getComponentAt(0)).getIcon()); } - Component current = pull.getComponentAt(0); + Component current = pullContainer.getComponentAt(0); if (current != cmpToDraw) { //NOPMD CompareObjectsWithEquals - pull.replace(current, cmpToDraw, null); + pullContainer.replace(current, cmpToDraw, null); } - pull.setWidth(cmp.getWidth()); - pull.setX(cmp.getAbsoluteX()); - pull.setY(cmp.getY() - scrollY - getPullToRefreshHeight()); - pull.layoutContainer(); + pullContainer.setWidth(cmp.getWidth()); + pullContainer.setX(cmp.getAbsoluteX()); + pullContainer.setY(cmp.getY() - scrollY - getPullToRefreshHeight()); + pullContainer.layoutContainer(); // We need to make the InfiniteProgress to animate, otherwise the progress // just stays static. - ComponentSelector.select("*", pull).each(new PullToRefreshComponentClosure()); - pull.paintComponent(g); + ComponentSelector.select("*", pullContainer).each(new PullToRefreshComponentClosure()); + pullContainer.paintComponent(g); } /// Material 3 / iOS modern pull-to-refresh: a circular arc spinner @@ -2343,6 +2350,13 @@ public void paint(Graphics g) { /// pre-release) the sweep is fixed at the full ring. private long modernSpinStartTime = 0L; + /// The spinner's repaint animation, kept so the same instance is registered each + /// frame and can be released when spinning ends. + private Animation modernSpinnerAnimation; + + /// The top level that animation was registered on. + private TopLevelContainer modernSpinnerHost; + public void drawModernPullToRefresh(Graphics g, Component cmp, boolean taskExecuted) { final int height = getPullToRefreshHeight(); final int scrollY = cmp.getScrollY(); @@ -2372,12 +2386,31 @@ public void drawModernPullToRefresh(Graphics g, Component cmp, boolean taskExecu sweep = 280; // Schedule the next frame -- without this the spinner freezes // after one paint pass. - Form f = cmp.getComponentForm(); + TopLevelContainer f = cmp.getTopLevelContainer(); if (f != null) { - f.registerAnimated(modernSpinnerRepaintAnimation(cmp)); + // One animation for the spinner, not one per paint. registerAnimated + // de-duplicates by identity, and a fresh instance every frame is never + // the one already registered -- so the list grew by one per frame and + // none of them ever came off, keeping the event dispatch thread awake + // for good once a refresh had run. + if (modernSpinnerAnimation == null) { + modernSpinnerAnimation = modernSpinnerRepaintAnimation(cmp); + } + modernSpinnerHost = f; + f.registerAnimated(modernSpinnerAnimation); } } else { modernSpinStartTime = 0L; + // Spinning has stopped, so the repaint animation has no more work. Released + // from the top level that took it rather than from wherever the component + // resolves to now. + if (modernSpinnerAnimation != null) { + if (modernSpinnerHost != null) { + modernSpinnerHost.deregisterAnimated(modernSpinnerAnimation); + modernSpinnerHost = null; + } + modernSpinnerAnimation = null; + } // Pull fraction 0..1 over the threshold height. float pull = pullDistance / (float) Math.max(1, height); float clamped = Math.min(1f, Math.max(0f, pull)); @@ -2496,11 +2529,33 @@ public int getPullToRefreshHeight() { int margin = Display.getInstance().convertToPixels(2f); return diameter + margin * 2; } + Container pullContainer = initPullToRefreshComponents(); + String s = UIManager.getInstance().getThemeConstant("pullToRefreshHeight", null); + if (s != null) { + float f = Util.toFloatValue(s); + if (f > 0) { + return Display.getInstance().convertToPixels(f); + } + } + return pullContainer.getHeight(); + } + + /// Creates the legacy pull-to-refresh components on first use and returns the + /// container that hosts them, which is never null. Both the height query and the + /// drawing path need them, and the drawing path is reached without a height query + /// when a refresh task is already running, so the initialization cannot live in + /// `#getPullToRefreshHeight()` alone. + /// + /// #### Returns + /// + /// the pull-to-refresh container, never null + private Container initPullToRefreshComponents() { if (pull == null) { BorderLayout bl = new BorderLayout(); bl.setCenterBehavior(BorderLayout.CENTER_BEHAVIOR_CENTER_ABSOLUTE); pull = new Container(bl); } + Container pullContainer = pull; if (pullDown == null) { pullDown = new Label(getUIManager().localize("pull.down", "Pull down to refresh...")); pullDown.setUIID("PullToRefresh"); @@ -2528,20 +2583,13 @@ public int getPullToRefreshHeight() { l.setUIID("PullToRefresh"); ((Container) updating).addComponent(l); - pull.getUnselectedStyle().setPadding(0, 0, 0, 0); - pull.getUnselectedStyle().setMargin(0, 0, 0, 0); - pull.addComponent(BorderLayout.CENTER, updating); - pull.layoutContainer(); - pull.setHeight(Math.max(pullDown.getPreferredH(), pull.getPreferredH())); - } - String s = UIManager.getInstance().getThemeConstant("pullToRefreshHeight", null); - if (s != null) { - float f = Util.toFloatValue(s); - if (f > 0) { - return Display.getInstance().convertToPixels(f); - } + pullContainer.getUnselectedStyle().setPadding(0, 0, 0, 0); + pullContainer.getUnselectedStyle().setMargin(0, 0, 0, 0); + pullContainer.addComponent(BorderLayout.CENTER, updating); + pullContainer.layoutContainer(); + pullContainer.setHeight(Math.max(pullDown.getPreferredH(), pullContainer.getPreferredH())); } - return pull.getHeight(); + return pullContainer; } diff --git a/CodenameOne/src/com/codename1/ui/spinner/Picker.java b/CodenameOne/src/com/codename1/ui/spinner/Picker.java index 81c2bcba725..2e1935a1f61 100644 --- a/CodenameOne/src/com/codename1/ui/spinner/Picker.java +++ b/CodenameOne/src/com/codename1/ui/spinner/Picker.java @@ -53,6 +53,7 @@ import com.codename1.ui.plaf.RoundRectBorder; import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.TopLevelContainer; import java.util.Calendar; import java.util.Date; @@ -298,7 +299,17 @@ public void actionPerformed(ActionEvent evt) { // still lets the next open re-resolve the default (useful when the // getter returns a moving target like "due date - 1 hour"). applyDefaultDateIfNeeded(); - if ((useLightweightPopup || !Display.getInstance().isNativePickerTypeSupported(type)) && isLightweightModeSupportedForType(type)) { + // A picker inside a desktop window uses the lightweight popup even + // where a native one exists. Every native picker is attached to the + // application's main surface -- the Catalyst one sizes itself against + // the main scene's view -- so from a window it would open over the + // wrong window entirely. The lightweight popup is an InteractionDialog, + // which resolves its host from the component and lands in the right + // one. + boolean inWindow = getTopLevelContainer() instanceof com.codename1.ui.Window; + if ((useLightweightPopup || inWindow + || !Display.getInstance().isNativePickerTypeSupported(type)) + && isLightweightModeSupportedForType(type)) { showInteractionDialog(); evt.consume(); return; @@ -639,12 +650,17 @@ private void endEditing(int command, InteractionDialog dlg, InternalPickerWidget fireActionEvent(-99, -99); Component next = null; - Form f = getComponentForm(); + // Through the tab iterator rather than Form's getNextComponent / + // getPreviousComponent: those are Form-only, but they are defined + // as exactly this call, and getTabIterator is on TopLevelContainer. + // Resolving a Form here left the Next and Previous buttons visible + // in a window and doing nothing but closing the popup. + TopLevelContainer f = getTopLevelContainer(); if (f != null && Picker.this.isTraversable()) { if (command == COMMAND_NEXT) { - next = f.getNextComponent(Picker.this); + next = f.getTabIterator(Picker.this).getNext(); } else if (command == COMMAND_PREV) { - next = f.getPreviousComponent(Picker.this); + next = f.getTabIterator(Picker.this).getPrevious(); } } final Component nextToEdit = next; @@ -705,6 +721,9 @@ private void showInteractionDialog() { final InteractionDialog dlg = new InteractionDialog() { ActionListener keyListener; + /// The top level the Tab listener was added to, kept so it is + /// removed from that one rather than from whatever resolves later. + TopLevelContainer keyListenerHost; @Override protected void initComponent() { @@ -725,17 +744,24 @@ public void actionPerformed(ActionEvent evt) { }; } - getComponentForm().addKeyListener(9, keyListener); + { + keyListenerHost = getTopLevelContainer(); + if (keyListenerHost != null) { + keyListenerHost.addKeyListener(9, keyListener); + } + } } @Override protected void deinitialize() { - Form f = getComponentForm(); - if (f == null) { - f = Display.getInstance().getCurrent(); - } - if (f != null && keyListener != null) { - f.removeKeyListener(9, keyListener); + // Removed from the very top level it was added to. Resolving it + // again here found a Form -- null in a window, then falling back + // to the current form, which never had the listener -- so after + // the picker was dismissed every Tab release still ran + // endEditing() against the stale dialog and spinner. + if (keyListenerHost != null && keyListener != null) { + keyListenerHost.removeKeyListener(9, keyListener); + keyListenerHost = null; } super.deinitialize(); } @@ -825,7 +851,9 @@ public void actionPerformed(ActionEvent evt) { //final Component nextComponent = getNextFocusRight() != null ? getNextFocusRight() : // getNextFocusDown() != null ? getNextFocusDown() : // null; - ListIterator traversalIt = getComponentForm().getTabIterator(Picker.this); + TopLevelContainer tabTop = getTopLevelContainer(); + ListIterator traversalIt = tabTop == null + ? null : tabTop.getTabIterator(Picker.this); if (Picker.this.isTraversable() && traversalIt.hasNext()) { nextButton = new Button("", isTablet ? "PickerButtonTablet" : "PickerButton"); // Javascript port needs to know that this button is going to try to @@ -883,10 +911,17 @@ public void actionPerformed(ActionEvent evt) { buttonBar.setUIID(isTablet ? "PickerButtonBarTablet" : "PickerButtonBar"); dlg.getContentPane().add(BorderLayout.NORTH, buttonBar); - Form form = getComponentForm(); + // Through the top level. This used to insist on a Form, because the + // popup is an InteractionDialog and that was unsupported inside a + // Window -- so a picker in a window threw rather than opening over the + // wrong surface. InteractionDialog now takes an explicit host, so the + // reason for refusing is gone and a picker works in a window like any + // other component. + TopLevelContainer form = getTopLevelContainer(); if (form == null) { throw new RuntimeException("Attempt to show interaction dialog while button is not on form. Illegal state"); } + dlg.setTopLevelHost(form); // The popup is anchored to the very bottom of the screen, so on devices with a // bottom inset (e.g. the iPhone home indicator) its bottom-most row would be drawn @@ -895,7 +930,16 @@ public void actionPerformed(ActionEvent evt) { // their bar (so the bar's background extends through the inset and the buttons remain // tappable above it); otherwise it goes on the content pane. See issue #5152. Rectangle safeArea = form.getSafeArea(); - int bottomInset = Display.getInstance().getDisplayHeight() - (safeArea.getY() + safeArea.getHeight()); + // Measured against the surface the popup sits on, not the display: in + // a window those differ, and the inset would be computed from the + // wrong height. + int hostHeight = form instanceof com.codename1.ui.Window + ? form.asContainer().getHeight() + : Display.getInstance().getDisplayHeight(); + int hostWidth = form instanceof com.codename1.ui.Window + ? form.asContainer().getWidth() + : Display.getInstance().getDisplayWidth(); + int bottomInset = hostHeight - (safeArea.getY() + safeArea.getHeight()); if (bottomInset > 0) { Container insetTarget = bottomCustomButtons != null ? bottomCustomButtons : dlg.getContentPane(); Style insetStyle = insetTarget.getAllStyles(); @@ -911,14 +955,18 @@ public void actionPerformed(ActionEvent evt) { final int left = 0; final int right = 0; final int bottom = 0; - dlg.setWidth(Display.getInstance().getDisplayWidth()); + // The host's geometry, not the display's. Reposition animation is off, + // so these are the popup's starting bounds: taken from the display, a + // window of a different size got a bottom sheet that was the wrong + // width and started its slide from the wrong place. + dlg.setWidth(hostWidth); dlg.setHeight(dlg.getPreferredH()); - dlg.setY(Display.getInstance().getDisplayHeight()); + dlg.setY(hostHeight); dlg.setX(0); dlg.setRepositionAnimation(false); registerAsInputDevice(dlg, spinner); if (Display.getInstance().isTablet()) { - getComponentForm().getAnimationManager().flushAnimation(new Runnable() { + getAnimationManager().flushAnimation(new Runnable() { @Override public void run() { @@ -928,7 +976,7 @@ public void run() { }); } else { - getComponentForm().getAnimationManager().flushAnimation(new Runnable() { + getAnimationManager().flushAnimation(new Runnable() { @Override public void run() { @@ -1284,7 +1332,10 @@ public void startEditingAsync() { @Override public void stopEditing(Runnable onFinish) { stopEditingCallback = onFinish; - Form f = this.getComponentForm(); + // Through the top level, as registerAsInputDevice registers it: resolving a + // Form here found nothing in a window, so stopEditing() did not close the + // picker and never ran its callback. + TopLevelContainer f = this.getTopLevelContainer(); if (f != null) { if (f.getCurrentInputDevice() == currentInput) { //NOPMD CompareObjectsWithEquals try { @@ -1298,7 +1349,7 @@ public void stopEditing(Runnable onFinish) { @Override public boolean isEditing() { - Form f = this.getComponentForm(); + TopLevelContainer f = this.getTopLevelContainer(); return currentInput != null && f != null && f.getCurrentInputDevice() == currentInput; //NOPMD CompareObjectsWithEquals } @@ -1629,7 +1680,10 @@ public void run() { private void registerAsInputDevice(final InteractionDialog dlg, final InternalPickerWidget spinner) { - final Form f = this.getComponentForm(); + // Through the top level: this skipped every registration in a window, so an + // open picker reported isEditing() false, input-device replacement could not + // dismiss it, and stopEditing(onFinish) neither closed it nor ran its callback. + final TopLevelContainer f = this.getTopLevelContainer(); if (f != null) { final ActionListener sizeChanged; if (!Display.getInstance().isTablet()) { @@ -1644,9 +1698,12 @@ public void actionPerformed(ActionEvent evt) { final int left = 0; final int right = 0; final int bottom = 0; - dlg.setWidth(Display.getInstance().getDisplayWidth()); + // As at the point of opening: the host's geometry rather than + // the display's, so a resize re-lays the sheet out against the + // window it lives in. + dlg.setWidth(f.asContainer().getWidth()); dlg.setHeight(dlg.getPreferredH()); - dlg.setY(Display.getInstance().getDisplayHeight()); + dlg.setY(f.asContainer().getHeight()); dlg.setX(0); f.getAnimationManager().flushAnimation(new Runnable() { diff --git a/CodenameOne/src/com/codename1/ui/spinner/Spinner.java b/CodenameOne/src/com/codename1/ui/spinner/Spinner.java index 806149e4a7a..33e13d4db9a 100644 --- a/CodenameOne/src/com/codename1/ui/spinner/Spinner.java +++ b/CodenameOne/src/com/codename1/ui/spinner/Spinner.java @@ -37,6 +37,7 @@ import com.codename1.ui.list.ListModel; import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.TopLevelContainer; import java.util.Calendar; import java.util.Date; @@ -301,7 +302,13 @@ void updateToDefaultRTL() { /// {@inheritDoc} @Override protected void initComponent() { - getComponentForm().registerAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.registerAnimated(this); + } boolean n = UIManager.getInstance().isThemeConstant("spinnerFocusBool", false); setIgnoreFocusComponentWhenUnfocused(!n); } @@ -309,7 +316,13 @@ protected void initComponent() { /// {@inheritDoc} @Override protected void deinitialize() { - getComponentForm().deregisterAnimated(this); + // The top level rather than the form: getComponentForm() is null + // by design inside a Window, so this both threw and left the + // animation unregistered there. + TopLevelContainer topLevel = getTopLevelContainer(); + if (topLevel != null) { + topLevel.deregisterAnimated(this); + } } /// {@inheritDoc} diff --git a/CodenameOne/src/com/codename1/ui/table/Table.java b/CodenameOne/src/com/codename1/ui/table/Table.java index 2eab01c8b6c..11a885b81fe 100644 --- a/CodenameOne/src/com/codename1/ui/table/Table.java +++ b/CodenameOne/src/com/codename1/ui/table/Table.java @@ -31,7 +31,6 @@ import com.codename1.ui.Container; import com.codename1.ui.Display; import com.codename1.ui.FontImage; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Label; import com.codename1.ui.TextArea; @@ -43,6 +42,7 @@ import com.codename1.ui.spinner.Picker; import com.codename1.ui.validation.Constraint; import com.codename1.ui.validation.Validator; +import com.codename1.ui.TopLevelContainer; import com.codename1.util.CaseInsensitiveOrder; import java.util.Comparator; @@ -189,7 +189,7 @@ public Table(TableModel model, boolean includeHeader) { /// /// the offset of the selected row in the table if a selection exists public int getSelectedRow() { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { Component c = f.getFocused(); if (c != null) { @@ -215,7 +215,7 @@ protected boolean includeNullValues() { /// /// the offset of the selected column in the table if a selection exists public int getSelectedColumn() { - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { Component c = f.getFocused(); if (c != null) { @@ -228,7 +228,7 @@ public int getSelectedColumn() { private void updateModel() { int selectionRow = -1; int selectionColumn = -1; - Form f = getComponentForm(); + TopLevelContainer f = getTopLevelContainer(); if (f != null) { Component c = f.getFocused(); if (c != null) { diff --git a/CodenameOne/src/com/codename1/ui/tree/Tree.java b/CodenameOne/src/com/codename1/ui/tree/Tree.java index 45f93ab22e5..cec7574ed03 100644 --- a/CodenameOne/src/com/codename1/ui/tree/Tree.java +++ b/CodenameOne/src/com/codename1/ui/tree/Tree.java @@ -42,6 +42,7 @@ import com.codename1.ui.plaf.Style; import com.codename1.ui.plaf.UIManager; import com.codename1.ui.util.EventDispatcher; +import com.codename1.ui.TopLevelContainer; import java.util.HashSet; import java.util.Set; @@ -529,7 +530,8 @@ private void collapseNode(Component c, Transition t) { /// /// the object selected within the tree public Object getSelectedItem() { - Component c = getComponentForm().getFocused(); + TopLevelContainer treeTop = getTopLevelContainer(); + Component c = treeTop == null ? null : treeTop.getFocused(); if (c != null) { return c.getClientProperty(KEY_OBJECT); } diff --git a/CodenameOne/src/com/codename1/ui/util/UITimer.java b/CodenameOne/src/com/codename1/ui/util/UITimer.java index ee76351ed17..e235d7e8905 100644 --- a/CodenameOne/src/com/codename1/ui/util/UITimer.java +++ b/CodenameOne/src/com/codename1/ui/util/UITimer.java @@ -24,6 +24,7 @@ import com.codename1.ui.Display; import com.codename1.ui.Form; +import com.codename1.ui.TopLevelContainer; import com.codename1.ui.Graphics; import com.codename1.ui.animations.Animation; @@ -35,7 +36,7 @@ public class UITimer { private final Internal i = new Internal(); private Runnable internalRunnable; - private Form bound; + private TopLevelContainer bound; private long lastEllapse; private int ms; private boolean repeat; @@ -74,6 +75,30 @@ public static UITimer timer(int timeMillis, boolean repeat, Form parent, Runnabl return uit; } + /// Schedules a timer bound to any top level, so a component inside a `Window` can + /// have one. `Component#getComponentForm()` is null there, and a port that bound + /// its timer to the form silently ran no timer at all inside a window. + /// + /// #### Parameters + /// + /// - `timeMillis`: the timer interval in milliseconds + /// + /// - `repeat`: whether the timer repeats + /// + /// - `parent`: the top level the timer is bound to + /// + /// - `r`: the task to run + /// + /// #### Returns + /// + /// the scheduled timer + public static UITimer timer(int timeMillis, boolean repeat, TopLevelContainer parent, + Runnable r) { + UITimer uit = new UITimer(r); + uit.schedule(timeMillis, repeat, parent); + return uit; + } + /// Convenience method to schedule a UITimer more easily on the current form /// /// #### Parameters @@ -103,6 +128,20 @@ public static UITimer timer(int timeMillis, boolean repeat, Runnable r) { /// /// - `bound`: the form to which the timer is bound public void schedule(int timeMillis, boolean repeat, Form bound) { + schedule(timeMillis, repeat, (TopLevelContainer) bound); + } + + /// Schedules this timer against any top level; see + /// `#timer(int, boolean, TopLevelContainer, Runnable)`. + /// + /// #### Parameters + /// + /// - `timeMillis`: the timer interval in milliseconds + /// + /// - `repeat`: whether the timer repeats + /// + /// - `bound`: the top level the timer is bound to + public void schedule(int timeMillis, boolean repeat, TopLevelContainer bound) { lastEllapse = System.currentTimeMillis(); ms = timeMillis; this.repeat = repeat; @@ -122,7 +161,16 @@ void testEllapse() { long t = System.currentTimeMillis(); if (t - lastEllapse >= ms) { if (!repeat) { - Display.getInstance().getCurrent().deregisterAnimated(i); + // Deregistered from whatever this timer was bound to, not from the + // current form. Those are the same thing for the Form overloads, + // which is why it went unnoticed, but a timer bound to a Window was + // never deregistered -- so a one-shot kept firing every interval + // forever. Falling back to the current form keeps the behaviour of + // the no-parent convenience overload, which binds to it. + TopLevelContainer target = bound != null ? bound : Display.getInstance().getCurrent(); + if (target != null) { + target.deregisterAnimated(i); + } } lastEllapse = t; i.run(); diff --git a/CodenameOne/src/com/codename1/ui/validation/Validator.java b/CodenameOne/src/com/codename1/ui/validation/Validator.java index b4c41a46c7c..fbaaebd63ab 100644 --- a/CodenameOne/src/com/codename1/ui/validation/Validator.java +++ b/CodenameOne/src/com/codename1/ui/validation/Validator.java @@ -29,7 +29,6 @@ import com.codename1.ui.Container; import com.codename1.ui.Display; import com.codename1.ui.FontImage; -import com.codename1.ui.Form; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.InputComponent; @@ -551,9 +550,16 @@ public void focusLost(Component focused) { cmp.addFocusListener(new FocusListener() { @Override public void focusGained(Component cmp) { - // special case. Before the form is showing don't show error dialogs - Form p = cmp.getComponentForm(); - if (p != Display.getInstance().getCurrent()) { //NOPMD CompareObjectsWithEquals + // special case. Before the top level is showing don't show + // error dialogs. Resolved through the top level rather than the + // form: getComponentForm() is null inside a Window, so this + // returned every time and the configured popup never appeared + // there at all. + com.codename1.ui.TopLevelContainer p = cmp.getTopLevelContainer(); + boolean showing = p instanceof com.codename1.ui.Window + ? ((com.codename1.ui.Window) p).isWindowShowing() + : p == Display.getInstance().getCurrent(); //NOPMD CompareObjectsWithEquals + if (!showing) { return; } if (message != null) { @@ -563,6 +569,9 @@ public void focusGained(Component cmp) { String err = getErrorMessage(cmp); if (err != null && err.length() > 0) { message = new InteractionDialog(err); + // The emblem path below shows by rectangle, which has + // no anchor component to resolve a host from. + message.setTopLevelHost(p); message.getTitleComponent().setUIID(errorMessageUIID); message.setAnimateShow(false); if (validationFailureHighlightMode == HighlightMode.EMBLEM || validationFailureHighlightMode == HighlightMode.UIID_AND_EMBLEM) { @@ -724,11 +733,13 @@ void setValid(Component cmp, boolean v) { } } - if (cmp.getComponentForm() != null) { - if (validationFailureHighlightMode == HighlightMode.EMBLEM || validationFailureHighlightMode == HighlightMode.UIID_AND_EMBLEM) { - if (!(cmp.getComponentForm().getGlassPane() instanceof ComponentListener)) { - cmp.getComponentForm().setGlassPane(new ComponentListener(null)); - } + if (validationFailureHighlightMode == HighlightMode.EMBLEM || validationFailureHighlightMode == HighlightMode.UIID_AND_EMBLEM) { + // The outer guard used to resolve the form, which is null by design inside a + // Window -- so the emblem glass pane was never installed there and EMBLEM + // validation showed nothing at all. + com.codename1.ui.TopLevelContainer top = cmp.getTopLevelContainer(); + if (top != null && !(top.getGlassPane() instanceof ComponentListener)) { + top.setGlassPane(new ComponentListener(null)); } } if (v) { @@ -809,20 +820,29 @@ public void paint(Graphics g, Rectangle rect) { xpos += Math.round(width * validationEmblemPositionX); ypos += Math.round(height * validationEmblemPositionY); - Form componentForm = c.getComponentForm(); - if (isPointCoveredByFormLayer(xpos, ypos, componentForm)) { + // The top level, not the form: in a window getComponentForm() is + // null, both helpers below then took their null guard, and the + // emblem was painted straight over any overlay covering it. + com.codename1.ui.TopLevelContainer emblemTop = c.getTopLevelContainer(); + if (isPointCoveredByFormLayer(xpos, ypos, emblemTop)) { continue; } int emblemWidth = validationFailedEmblem.getWidth(); int emblemHeight = validationFailedEmblem.getHeight(); int drawX; - if (xpos + emblemWidth > Display.getInstance().getDisplayWidth()) { + // The owning surface's width, not the main display's. Component + // coordinates are local to the window they live in, so a narrower + // window clipped the emblem and a wider one flipped it needlessly. + int surfaceWidth = emblemTop == null + ? Display.getInstance().getDisplayWidth() + : emblemTop.asContainer().getWidth(); + if (xpos + emblemWidth > surfaceWidth) { drawX = xpos - emblemWidth; } else { drawX = xpos - emblemWidth / 2; } int drawY = ypos - emblemHeight / 2; - if (isEmblemRectCoveredByInteractionDialog(new Rectangle(drawX, drawY, emblemWidth, emblemHeight), componentForm)) { + if (isEmblemRectCoveredByInteractionDialog(new Rectangle(drawX, drawY, emblemWidth, emblemHeight), emblemTop)) { continue; } @@ -842,7 +862,7 @@ public void paint(Graphics g, Rectangle rect) { } } - boolean isPointCoveredByFormLayer(int x, int y, Form form) { + boolean isPointCoveredByFormLayer(int x, int y, com.codename1.ui.TopLevelContainer form) { if (form == null) { return false; } @@ -869,7 +889,8 @@ private boolean isPointCoveredByContainer(int x, int y, Container container) { return false; } - boolean isEmblemRectCoveredByInteractionDialog(Rectangle emblemRect, Form form) { + boolean isEmblemRectCoveredByInteractionDialog(Rectangle emblemRect, + com.codename1.ui.TopLevelContainer form) { if (form == null || emblemRect == null) { return false; } diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 834342dd90e..ac596f264b3 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -332,7 +332,9 @@ public static void setFullScreen(boolean aFullScreen) { fullScreen = aFullScreen; } - private JFrame findTopFrame() { + /* Package private rather than private: the window manager needs it to own a + * window to the application's main frame. */ + JFrame findTopFrame() { java.awt.Component c = canvas; return (JFrame)canvas.getTopLevelAncestor(); /* @@ -2299,7 +2301,7 @@ public void setNativeCommands(Vector commands) { EventQueue.invokeLater(new Runnable() { @Override public void run() { - frame.setJMenuBar(buildNativeMenuBar(snapshot, frame)); + frame.setJMenuBar(buildNativeMenuBar(snapshot, frame, null)); frame.revalidate(); } }); @@ -2309,7 +2311,30 @@ public void run() { /// menus by each command's desktop-menu placement hint (Command.getDesktopMenu()); commands /// with no hint fall under a default "Commands" menu. Each menu item dispatches back onto the /// Codename One EDT before invoking the command's action. - private JMenuBar buildNativeMenuBar(java.util.List commands, JFrame frame) { + /// Builds a menu bar for a secondary desktop window's commands. + /// + /// Same builder as the main window's, with the owning window passed through so + /// activation goes to `com.codename1.ui.Window#dispatchCommand` -- the command + /// listeners registered on the window have to see it. Passing an owner also keeps + /// the MCP menu off: that belongs to the application's main frame, not to every + /// window that happens to carry a command. + /// + /// #### Parameters + /// + /// - `commands`: the window's named commands + /// + /// - `owner`: the window these commands belong to + /// + /// #### Returns + /// + /// the menu bar to install on the window + JMenuBar buildWindowMenuBar(java.util.List commands, + com.codename1.ui.Window owner) { + return buildNativeMenuBar(commands, null, owner); + } + + private JMenuBar buildNativeMenuBar(java.util.List commands, JFrame frame, + final com.codename1.ui.Window owner) { JMenuBar bar = new JMenuBar(); // preserve first-seen order of the menu groups java.util.LinkedHashMap menus = new java.util.LinkedHashMap(); @@ -2336,16 +2361,30 @@ public void actionPerformed(java.awt.event.ActionEvent e) { Display.getInstance().callSerially(new Runnable() { @Override public void run() { - cmd.actionPerformed(new com.codename1.ui.events.ActionEvent(cmd)); + com.codename1.ui.events.ActionEvent ev = + new com.codename1.ui.events.ActionEvent(cmd); + if (owner != null) { + // Through the window, not straight to the command: + // TopLevelContainer says listeners registered with + // addCommandListener observe activated commands, and + // invoking the command directly bypasses them. + owner.dispatchCommand(cmd, ev); + } else { + cmd.actionPerformed(ev); + } } }); } }); menu.add(item); } - // Every desktop Codename One tool gets the native MCP menu so it can be exposed - // to and driven by an LLM agent. - bar.add(MCPDesktopMenu.build(frame != null ? frame.getTitle() : null, frame)); + if (owner == null) { + // Every desktop Codename One tool gets the native MCP menu so it can be + // exposed to and driven by an LLM agent -- on the application's main frame + // only. A secondary application window would otherwise gain development + // controls like "Expose This Tool To Agents" simply for carrying a command. + bar.add(MCPDesktopMenu.build(frame != null ? frame.getTitle() : null, frame)); + } return bar; } @@ -2494,7 +2533,7 @@ private String resolveDesktopTitleBarMode() { /// @return true when running on the desktop with a title-bar mode that hides the CN1 /// Toolbar in favor of native chrome (native or custom). - private boolean isDesktopNativeChromeMode() { + boolean isDesktopNativeChromeMode() { if (!isDesktop()) { return false; } @@ -2926,6 +2965,80 @@ protected class C extends JPanel implements KeyListener, MouseListener, MouseMot private AWTEventListener magnificationWheelFallbackListener; private boolean gestureDebug = Boolean.getBoolean("cn1.javase.gestureDebug"); public int x, y; + /** + * The desktop window this canvas renders, or 0 for the application's main + * surface. Input from this canvas is tagged with it so the framework routes + * the event to the right component hierarchy. + */ + int windowId; + + /** + * This canvas's own drawable width, which is what its pointer bounds checks + * and coordinate clamping have to use. The display dimensions describe the + * primary canvas, so a secondary window larger than it -- or shown + * before a main form has sized it -- would silently discard presses outside + * those dimensions and clamp drags into them. The primary canvas keeps going + * through the display dimensions unchanged, because those also account for a + * loaded skin's screen coordinates. + */ + /// The backing scale of the display *this* canvas is on. + /// + /// The global `retinaScale` is the main display's, fixed at startup. A + /// secondary window can sit on a monitor with a different transform, and the + /// window manager already lays it out using that monitor's scale -- so + /// sizing the surface, the backing buffer and the pointer mapping from the + /// global one clipped or stretched the content and put hit testing out of + /// step as soon as the window was moved to another display. + /// The top level this canvas actually renders, rather than whatever form is + /// current. + /// + /// Hit testing and focus lookups that resolved `Display.getCurrent()` were + /// answering about the main form even when the event arrived on a secondary + /// window's canvas: a peer in a window stopped receiving mouse input because an + /// unrelated main-form component at those window-local coordinates was not a + /// peer, and an editor focused in a window was not seen as focused at all. + /// + /// #### Returns + /// + /// this canvas's top level, or null when there is none + com.codename1.ui.TopLevelContainer canvasTopLevel() { + if (windowId == 0) { + return com.codename1.ui.CN.getCurrentForm(); + } + return com.codename1.ui.Desktop.getInstance().windowById(windowId); + } + + double canvasScale() { + if (windowId == 0) { + return retinaScale; + } + try { + GraphicsConfiguration cfg = getGraphicsConfiguration(); + if (cfg != null) { + double sx = cfg.getDefaultTransform().getScaleX(); + if (sx > 0) { + return sx; + } + } + } catch (Throwable err) { + // fall through to the global scale + } + return retinaScale; + } + + int surfaceWidth() { + if (windowId == 0) { + return getDisplayWidthImpl(); + } + return Math.max(1, (int) (getWidth() * canvasScale())); + } + + int surfaceHeight() { + if (windowId == 0) { + return getDisplayHeightImpl(); + } + return Math.max(1, (int) (getHeight() * canvasScale())); + } C() { super(null); @@ -3075,7 +3188,9 @@ private void debugGesture(String message) { } } - private void disposeGestureListeners() { + /// Package private so JavaSEWindowManager can release the global Toolkit + /// listener when the window is disposed; otherwise it outlives the canvas. + void disposeGestureListeners() { if (magnificationWheelFallbackListener != null) { try { Toolkit.getDefaultToolkit().removeAWTEventListener(magnificationWheelFallbackListener); @@ -3085,6 +3200,26 @@ private void disposeGestureListeners() { } } + /** + * Drops this canvas's screen buffers and its entry in the screen graphics + * registry. The registry keys a Graphics2D to its owning canvas strongly, so + * without this a disposed window stayed reachable through it for the life of + * the application, holding its BufferedImages -- tens of megabytes at a large + * window size, and one set per window ever opened. A disposed window never + * paints again, so nothing here can be needed afterwards. + */ + void releaseScreenGraphics() { + unregisterScreenGraphics(g2dInstance); + if (g2dInstance != null) { + g2dInstance.dispose(); + g2dInstance = null; + } + synchronized (bufferLock) { + edtBuffer = null; + } + buffer = null; + } + private void handleMagnification(final int x, final int y, double magnification) { magnificationAccumulator += magnification; while (magnificationAccumulator >= 0.08d) { @@ -3100,7 +3235,10 @@ private void handleMagnification(final int x, final int y, double magnification) private void fireMagnify(final int x, final int y, final float scale) { Display.getInstance().callSerially(new Runnable() { public void run() { - Display.getInstance().fireMagnifyGesture(x, y, scale); + // Routed by window id like every other input this canvas + // produces: the canvas a trackpad magnify arrived on is the + // window whose component tree has to see it. + com.codename1.ui.Desktop.getInstance().windowMagnifyGesture(windowId, x, y, scale); } }); } @@ -3121,7 +3259,8 @@ private BufferedImage updateBufferSize(BufferedImage buffer) { BufferedImage previous = buffer; if (getScreenCoordinates() == null) { java.awt.Dimension d = getSize(); - if (buffer == null || buffer.getWidth() != (int)(d.width * retinaScale) || buffer.getHeight() != (int)(d.height*retinaScale)) { + double cs = canvasScale(); + if (buffer == null || buffer.getWidth() != (int)(d.width * cs) || buffer.getHeight() != (int)(d.height * cs)) { buffer = createBufferedImage(); } } else { @@ -3203,6 +3342,25 @@ private void updateBuffer(BufferedImage inputBuf) { */ int blitCounter; + /** + * Snapshot of what this canvas last painted, used by the windowed screenshot + * tests: the ordinary screenshot path can only see the primary surface. + */ + BufferedImage captureBuffer() { + synchronized (bufferLock) { + BufferedImage src = buffer != null ? buffer : edtBuffer; + if (src == null) { + return null; + } + BufferedImage out = new BufferedImage(src.getWidth(), src.getHeight(), + BufferedImage.TYPE_INT_RGB); + java.awt.Graphics g = out.getGraphics(); + g.drawImage(src, 0, 0, null); + g.dispose(); + return out; + } + } + public void blit() { if(menuDisplayed){ return; @@ -3340,7 +3498,14 @@ private boolean drawScreenBuffer(java.awt.Graphics g) { //g.setColor(Color.white); //g.fillRect(0, 0, canvas.getWidth(), canvas.getHeight()); AffineTransform t = ((Graphics2D)g).getTransform(); - AffineTransform t2 = AffineTransform.getScaleInstance(1/retinaScale, 1/retinaScale); + // This canvas's scale, not the main display's. The raster is sized with + // canvasScale(), so undoing it with the global retinaScale left the + // raster-to-canvas transform as the *ratio* between two monitors' scales + // -- a window moved to a display of a different scale was then stretched + // or clipped. Identical for the main window, whose canvasScale() is + // retinaScale by definition. + double blitScale = canvasScale(); + AffineTransform t2 = AffineTransform.getScaleInstance(1/blitScale, 1/blitScale); t2.concatenate(t); @@ -3449,12 +3614,16 @@ public void paintComponent(java.awt.Graphics g) { AffineTransform t = g2.getTransform(); double tx = t.getTranslateX(); double ty = t.getTranslateY(); - AffineTransform t2 = AffineTransform.getScaleInstance(retinaScale, retinaScale); + // Paired with the inverse applied in drawScreenBuffer, and for the + // same reason it uses this canvas's scale rather than the main + // display's. + double paintScale = canvasScale(); + AffineTransform t2 = AffineTransform.getScaleInstance(paintScale, paintScale); t2.translate(tx, ty); if (getJavaVersion() < 9) { // Java 8 didn't have full retina support t2 = AffineTransform.getScaleInstance(1, 1); - t2.translate(tx * retinaScale, ty * retinaScale); + t2.translate(tx * paintScale, ty * paintScale); } @@ -3522,6 +3691,7 @@ public Graphics2D getGraphics2D() { while(g2dInstance == null) { g2dInstance = edtBuffer.createGraphics(); + registerScreenGraphics(g2dInstance, this); updateGraphicsScale(g2dInstance); try { Thread.sleep(10); @@ -3533,11 +3703,12 @@ public Graphics2D getGraphics2D() { } private BufferedImage createBufferedImage() { + unregisterScreenGraphics(g2dInstance); g2dInstance = null; if (getScreenCoordinates() != null) { return new BufferedImage(Math.max(20, (int) (getScreenCoordinates().width * zoomLevel)), Math.max(20, (int) (getScreenCoordinates().height * zoomLevel)), BufferedImage.TYPE_INT_RGB); } - return new BufferedImage(Math.max(20, (int)(getWidth() * retinaScale)), Math.max(20, (int)(getHeight() * retinaScale)), BufferedImage.TYPE_INT_RGB); + return new BufferedImage(Math.max(20, (int)(getWidth() * canvasScale())), Math.max(20, (int)(getHeight() * canvasScale())), BufferedImage.TYPE_INT_RGB); } public void validate() { @@ -3598,7 +3769,7 @@ public void keyTyped(KeyEvent e) { // control key was down while a key was pressed. private HashSet ignorePressedKeys = new HashSet(); private boolean isPureEditorFocused() { - com.codename1.ui.Form f = com.codename1.ui.CN.getCurrentForm(); + com.codename1.ui.TopLevelContainer f = canvasTopLevel(); return f != null && (f.getFocused() instanceof com.codename1.ui.editor.EditorView); } @@ -3634,7 +3805,11 @@ public void keyPressed(KeyEvent e) { } boolean editorFocused = isPureEditorFocused(); if (!editorFocused && e.isMetaDown() && e.getKeyChar() == 'c') { - Form f = CN.getCurrentForm(); + // This canvas's top level, not the current form: the shortcut arrived + // on a particular window's canvas, and resolving the current form + // copied or selected from the unrelated main form -- or did nothing at + // all in a window-only application. + com.codename1.ui.TopLevelContainer f = canvasTopLevel(); if (f != null) { final TextSelection ts = f.getTextSelection(); if (ts.isEnabled()) { @@ -3660,7 +3835,11 @@ public void run() { } if (!editorFocused && e.isMetaDown() && e.getKeyChar() == 'a') { - Form f = CN.getCurrentForm(); + // This canvas's top level, not the current form: the shortcut arrived + // on a particular window's canvas, and resolving the current form + // copied or selected from the unrelated main form -- or did nothing at + // all in a window-only application. + com.codename1.ui.TopLevelContainer f = canvasTopLevel(); if (f != null) { final TextSelection ts = f.getTextSelection(); if (ts.isEnabled()) { @@ -3701,7 +3880,7 @@ public void run() { if (testRecorder != null) { testRecorder.eventKeyPressed(code); } - JavaSEPort.this.keyPressed(code); + JavaSEPort.this.windowKeyPressed(windowId, code); } public void keyReleased(KeyEvent e) { @@ -3737,7 +3916,7 @@ public void keyReleased(KeyEvent e) { if (testRecorder != null) { testRecorder.eventKeyReleased(code); } - JavaSEPort.this.keyReleased(code); + JavaSEPort.this.windowKeyReleased(windowId, code); } public void mouseClicked(MouseEvent e) { @@ -3752,11 +3931,16 @@ private boolean showContextMenu(final MouseEvent me) { return false; } - Form f = Display.getInstance().getCurrent(); + // This canvas is shared with secondary windows, so the hit test has to run + // against the top level this canvas renders. Resolving the current form + // inspected a component of the unrelated main form, and in a window-only + // application it showed and consumed the inspection menu while having + // nothing to inspect -- which also swallowed the window's own context menu. + com.codename1.ui.TopLevelContainer f = canvasTopLevel(); if (f != null) { int x = scaleCoordinateX(me.getX()); int y = scaleCoordinateY(me.getY()); - Component cmp = f.getComponentAt(x, y); + Component cmp = f.asContainer().getComponentAt(x, y); if (cmp == null || cmp instanceof PeerComponent) { return false; } @@ -3771,11 +3955,11 @@ private boolean showContextMenu(final MouseEvent me) { public void actionPerformed(ActionEvent e) { ComponentTreeInspector inspector = getOrCreateComponentTreeInspector(); if (inspector != null && inspector.isSimulatorRightClickEnabled()) { - Form f = Display.getInstance().getCurrent(); + com.codename1.ui.TopLevelContainer f = canvasTopLevel(); if (f != null) { int x = scaleCoordinateX(me.getX()); int y = scaleCoordinateY(me.getY()); - Component cmp = f.getComponentAt(x, y); + Component cmp = f.asContainer().getComponentAt(x, y); inspector.inspectComponent(cmp); } } @@ -3790,14 +3974,16 @@ private int scaleCoordinateX(int coordinate) { if (getScreenCoordinates() != null) { return (int) (retinaScale * coordinate / zoomLevel - (getScreenCoordinates().x + x)); } - return (int)(coordinate * retinaScale); + // canvasScale(), not retinaScale: a press has to land where the content + // was drawn, and the two differ on a mixed-scale desktop. + return (int)(coordinate * canvasScale()); } private int scaleCoordinateY(int coordinate) { if (getScreenCoordinates() != null) { return (int) (retinaScale * coordinate / zoomLevel - (getScreenCoordinates().y + y)); } - return (int)(coordinate * retinaScale); + return (int)(coordinate * canvasScale()); } Integer triggeredKeyCode; private boolean mouseDown; @@ -3808,11 +3994,11 @@ public void mousePressed(MouseEvent e) { } } this.mouseDown = true; - Form f = Display.getInstance().getCurrent(); + com.codename1.ui.TopLevelContainer f = canvasTopLevel(); if (f != null) { int x = scaleCoordinateX(e.getX()); int y = scaleCoordinateY(e.getY()); - Component cmp = f.getComponentAt(x, y); + Component cmp = f.asContainer().getComponentAt(x, y); if (!(cmp instanceof PeerComponent)) { cn1GrabbedDrag = true; } @@ -3826,13 +4012,13 @@ public void mousePressed(MouseEvent e) { releaseLock = false; int x = scaleCoordinateX(e.getX()); int y = scaleCoordinateY(e.getY()); - if (x >= 0 && x < getDisplayWidthImpl() && y >= 0 && y < getDisplayHeightImpl()) { + if (x >= 0 && x < surfaceWidth() && y >= 0 && y < surfaceHeight()) { if (touchDevice) { if (testRecorder != null) { testRecorder.eventPointerPressed(x, y); } updatePointerMetadata(e, true); - JavaSEPort.this.pointerPressed(x, y); + JavaSEPort.this.windowPointerPressed(windowId, x, y); } } else { if (getSkin() != null) { @@ -3863,7 +4049,7 @@ public void mousePressed(MouseEvent e) { if (testRecorder != null) { testRecorder.eventKeyPressed(code); } - JavaSEPort.this.keyPressed(code); + JavaSEPort.this.windowKeyPressed(windowId, code); } } } @@ -3889,15 +4075,15 @@ public void mouseReleased(MouseEvent e) { if ((e.getModifiers() & MouseEvent.BUTTON1_MASK) != 0 || (e.getModifiers() & MouseEvent.BUTTON3_MASK) != 0) { int x = scaleCoordinateX(e.getX()); int y = scaleCoordinateY(e.getY()); - if (mouseDown || (x >= 0 && x < getDisplayWidthImpl() && y >= 0 && y < getDisplayHeightImpl())) { + if (mouseDown || (x >= 0 && x < surfaceWidth() && y >= 0 && y < surfaceHeight())) { if (touchDevice) { - x = Math.min(getDisplayWidthImpl(), Math.max(0, x)); - y = Math.min(getDisplayHeightImpl(), Math.max(0, y)); + x = Math.min(surfaceWidth(), Math.max(0, x)); + y = Math.min(surfaceHeight(), Math.max(0, y)); if (testRecorder != null) { testRecorder.eventPointerReleased(x, y); } updatePointerMetadata(e, true); - JavaSEPort.this.pointerReleased(x, y); + JavaSEPort.this.windowPointerReleased(windowId, x, y); } } if (triggeredKeyCode != null) { @@ -3905,7 +4091,7 @@ public void mouseReleased(MouseEvent e) { if (testRecorder != null) { testRecorder.eventKeyReleased(code); } - JavaSEPort.this.keyReleased(code); + JavaSEPort.this.windowKeyReleased(windowId, code); triggeredKeyCode = null; } } @@ -3927,15 +4113,15 @@ public void mouseDragged(MouseEvent e) { if (!releaseLock && (e.getModifiers() & MouseEvent.BUTTON1_MASK) != 0) { int x = scaleCoordinateX(e.getX()); int y = scaleCoordinateY(e.getY()); - if (mouseDown || (x >= 0 && x < getDisplayWidthImpl() && y >= 0 && y < getDisplayHeightImpl())) { + if (mouseDown || (x >= 0 && x < surfaceWidth() && y >= 0 && y < surfaceHeight())) { if (touchDevice) { - x = Math.min(getDisplayWidthImpl(), Math.max(0, x)); - y = Math.min(getDisplayHeightImpl(), Math.max(0, y)); + x = Math.min(surfaceWidth(), Math.max(0, x)); + y = Math.min(surfaceHeight(), Math.max(0, y)); if (testRecorder != null && hasDragStarted(x, y)) { testRecorder.eventPointerDragged(x, y); } updatePointerMetadata(e, false); - JavaSEPort.this.pointerDragged(x, y); + JavaSEPort.this.windowPointerDragged(windowId, x, y); } } return; @@ -3945,9 +4131,14 @@ public void mouseDragged(MouseEvent e) { if (!releaseLock && isPinchZoom(e)) { int x = scaleCoordinateX(e.getX()); int y = scaleCoordinateY(e.getY()); - if (mouseDown || (x >= 0 && x < getDisplayWidthImpl() && y >= 0 && y < getDisplayHeightImpl())) { + if (mouseDown || (x >= 0 && x < surfaceWidth() && y >= 0 && y < surfaceHeight())) { if (touchDevice) { - JavaSEPort.this.pointerDragged(new int[]{Math.min(getDisplayWidthImpl(), Math.max(0,x)), 0}, new int[]{Math.min(getDisplayHeightImpl(), Math.max(0, y)), 0}); + // Tagged with the window id like the press that started the + // gesture; otherwise the second pointer of a simulated pinch + // lands on the main form and drags unrelated content. + JavaSEPort.this.windowPointerDragged(windowId, + new int[]{Math.min(surfaceWidth(), Math.max(0, x)), 0}, + new int[]{Math.min(surfaceHeight(), Math.max(0, y)), 0}); } } return; @@ -4030,6 +4221,20 @@ private int modifierMask(InputEvent e) { private boolean pendingDelayedWindowRepaint; private void queueSizeChangeEvent(int w, int h, boolean revalidate, boolean forceRevalidate, boolean resetGraphics, boolean delayedWindowRepaint) { + if (windowId != 0) { + // This canvas is a secondary window's, and this path resizes the + // *main* surface: laying out a secondary frame would have resized the + // main form's hierarchy to the secondary canvas's dimensions. The + // guard is here rather than at the call sites because all three of + // them -- setBounds and both branches of ancestorResized -- are + // primary-canvas logic that a secondary canvas also runs, being the + // same class and the same listener. + // + // A secondary window reports its own size through + // JavaSEWindowManager's componentResized, which tags the event with + // the window id. + return; + } synchronized (pendingSizeChangeLock) { pendingSizeChangeWidth = w; pendingSizeChangeHeight = h; @@ -4067,6 +4272,7 @@ public void run() { JavaSEPort.this.sizeChanged(queuedW, queuedH); if (doResetGraphics) { + unregisterScreenGraphics(g2dInstance); g2dInstance = null; } @@ -4110,18 +4316,22 @@ public void mouseMoved(MouseEvent e) { if(invokePointerHover || JavaSEPort.this.isDesktop()) { int x = scaleCoordinateX(e.getX()); int y = scaleCoordinateY(e.getY()); - if (x >= 0 && x < getDisplayWidthImpl() && y >= 0 && y < getDisplayHeightImpl()) { - JavaSEPort.this.pointerHover(x, y); + if (x >= 0 && x < surfaceWidth() && y >= 0 && y < surfaceHeight()) { + JavaSEPort.this.windowPointerHover(windowId, x, y); } } - Form f = Display.getInstance().getCurrent(); - if (f != null && f.isEnableCursors()) { + // Cursors resolve against this canvas's own top level, not the current + // form: a window's components are the ones under the pointer here. + com.codename1.ui.TopLevelContainer top = windowId > 0 + ? com.codename1.ui.Desktop.getInstance().windowById(windowId) + : Display.getInstance().getCurrent(); + if (top != null && top.isEnableCursors()) { int x = scaleCoordinateX(e.getX()); int y = scaleCoordinateY(e.getY()); - if (x >= 0 && x < getDisplayWidthImpl() && y >= 0 && y < getDisplayHeightImpl()) { - Component cmp = f.getComponentAt(x, y); + if (x >= 0 && x < surfaceWidth() && y >= 0 && y < surfaceHeight()) { + Component cmp = top.asContainer().getComponentAt(x, y); if (cmp != null) { int cursor = cmp.getCursor(); if (cursor != currentCursor) { @@ -4170,7 +4380,20 @@ public void setBounds(int x, int y, int w, int h) { public void ancestorResized(HierarchyEvent e) { - + if (windowId != 0) { + // Everything below is primary-surface logic that a secondary canvas + // also runs, being the same class and the same listener: it reads the + // skin, and mutates the *port's* canvas field rather than this one -- + // canvas.setForcedSize() would stamp the main canvas with a secondary + // window's dimensions and let a later Swing layout resize or clip the + // main surface. A secondary window's own resize arrives through its + // componentResized, window-tagged. + // + // Rejected here rather than deeper down: queueSizeChangeEvent already + // guards itself, but by then the main canvas has been mutated. + return; + } + /* if (e.getChanged() != getParent()) { EventQueue.invokeLater(new Runnable() { @@ -4281,13 +4504,18 @@ public void mouseWheelMoved(final MouseWheelEvent e) { return; } if (e.getScrollType() == MouseWheelEvent.WHEEL_UNIT_SCROLL) { - Form f = getCurrentForm(); + // Resolve against the canvas's own top level rather than the current + // form: this handler is installed on every canvas, and a wheel over a + // desktop window has to scroll that window's content. + com.codename1.ui.Container f = windowId > 0 + ? (com.codename1.ui.Container) com.codename1.ui.Desktop.getInstance().windowById(windowId) + : (com.codename1.ui.Container) getCurrentForm(); if(f != null){ - Component cmp; + com.codename1.ui.Component cmp; try { cmp = f.getComponentAt(x, y); } catch (Throwable t) { - // Since this is called off the edt, we sometimes hit + // Since this is called off the edt, we sometimes hit // NPEs and Array Index out of bounds errors here cmp = null; } @@ -4322,9 +4550,9 @@ public void mouseWheelMoved(final MouseWheelEvent e) { boolean precise = e.getPreciseWheelRotation() != e.getWheelRotation(); int modifiers = modifierMask(e); if (e.isShiftDown()) { - pointerWheelMoved(x, y, units, 0, precise, modifiers); + windowPointerWheelMoved(windowId, x, y, units, 0, precise, modifiers); } else { - pointerWheelMoved(x, y, 0, units, precise, modifiers); + windowPointerWheelMoved(windowId, x, y, 0, units, precise, modifiers); } } } @@ -4371,6 +4599,16 @@ public void paintDirty() { * @inheritDoc */ public void deinitialize() { + // The window manager's monitor poller is a daemon timer that outlives the port + // otherwise. A simulator session that restarts through deinitialize()/init() + // builds a new manager each time, and every previous poller kept waking every + // two seconds and reporting the same topology change independently. + synchronized (windowManagerLock) { + if (windowManager != null) { + windowManager.stopWatchingMonitorTopology(); + windowManager = null; + } + } if (canvas != null) { canvas.disposeGestureListeners(); } @@ -10599,7 +10837,15 @@ public void editStringLegacy(final Component cmp, int maxSize, int constraint, S } else { setText(tf, text); } - canvas.add(tf); + // The owning window's canvas, like the Swing editor path: an editor added to + // the primary canvas would appear on the main window. + final C editorCanvas = editorCanvasFor(cmp); + editorCanvas.add(tf); + // Recorded so a monitor-scale change can reposition this editor as well. The + // compat editor divides by the same canvas scale as the normal one and goes + // stale the same way when its window moves to a display with another. + legacyEditor = tf; + legacyEditingField = cmp; if (getSkin() != null) { tf.setBounds((int) ((cmp.getAbsoluteX() + getScreenCoordinates().x + canvas.x) * zoomLevel), (int) ((cmp.getAbsoluteY() + getScreenCoordinates().y + canvas.y) * zoomLevel), @@ -10607,7 +10853,10 @@ public void editStringLegacy(final Component cmp, int maxSize, int constraint, S java.awt.Font f = font(cmp.getStyle().getFont().getNativeFont()); tf.setFont(f.deriveFont(f.getSize2D() * zoomLevel)); } else { - tf.setBounds(cmp.getAbsoluteX(), cmp.getAbsoluteY(), cmp.getWidth(), cmp.getHeight()); + // As in the path above: device pixels converted to Swing's logical ones. + double legacyScale = editorCanvas.canvasScale(); + tf.setBounds((int) (cmp.getAbsoluteX() / legacyScale), (int) (cmp.getAbsoluteY() / legacyScale), + (int) (cmp.getWidth() / legacyScale), (int) (cmp.getHeight() / legacyScale)); tf.setFont(font(cmp.getStyle().getFont().getNativeFont())); } setCaretPosition(tf, getText(tf).length()); @@ -10634,11 +10883,13 @@ public void actionPerformed(ActionEvent e) { } ((TextComponent) tf).removeTextListener(this); tf.removeFocusListener(this); - canvas.remove(tf); + legacyEditor = null; + legacyEditingField = null; + editorCanvas.remove(tf); synchronized (this) { notify(); } - canvas.repaint(); + editorCanvas.repaint(); } public void focusGained(FocusEvent e) { @@ -10731,10 +10982,117 @@ public void run() { @Override public void stopTextEditing() { if (textCmp != null && textCmp.getParent() != null) { - canvas.remove(textCmp); + // remove from whichever canvas it was attached to, which is not + // necessarily the primary one + textCmp.getParent().remove(textCmp); } } + /** + * The canvas a native editor for this component belongs on. Editing inside a + * desktop Window has to attach the Swing editor to that window's canvas, or the + * caret appears on the main window instead. + */ + /** + * Places the Swing editor over the field it is editing. + * + * Divided by the owning canvas's backing scale, exactly as a native peer divides + * by peerScale(). Codename One coordinates are device pixels -- + * getDisplayWidthImpl multiplies the canvas size by this same scale -- while Swing + * bounds are logical, so assigning one to the other left the editor oversized and + * offset from its field by the scale factor on any canvas whose monitor is not 1x. + * + * Computed here rather than inline so the same placement can be reapplied when the + * window moves to a display with a different scale; see reapplyEditorBounds. + */ + private void applyEditorBounds(com.codename1.ui.Component cmp) { + if (textCmp == null || cmp == null) { + return; + } + int marginTop = cmp.getSelectedStyle().getPadding(Component.TOP); + int marginLeft = cmp.getSelectedStyle().getPadding(Component.LEFT); + int marginRight = cmp.getSelectedStyle().getPadding(Component.RIGHT); + int marginBottom = cmp.getSelectedStyle().getPadding(Component.BOTTOM); + double editorScale = editorCanvasFor(cmp).canvasScale(); + textCmp.setBounds((int) ((cmp.getAbsoluteX() + cmp.getScrollX() + marginLeft) / editorScale), + (int) ((cmp.getAbsoluteY() + cmp.getScrollY() + marginTop) / editorScale), + (int) ((cmp.getWidth() - marginRight - marginLeft) / editorScale), + (int) ((cmp.getHeight() - marginTop - marginBottom) / editorScale)); + } + + /** + * Reapplies the editor's placement after its window may have changed backing + * scale. The placement divides by the canvas's scale, and that divisor changes + * when a window is dragged to a display with a different one -- the Codename One + * hierarchy is re-laid out for the new scale, but nothing moved the Swing editor, + * leaving it offset and mis-sized over its field until editing restarted. + * + * Called on the AWT thread, where the move is reported and where Swing bounds may + * be set. + */ + void reapplyEditorBounds(int windowId) { + if (getSkin() != null) { + return; + } + if (currentlyEditingField != null && textCmp != null + && editorWindowId(currentlyEditingField) == windowId) { + applyEditorBounds(currentlyEditingField); + } + // TextCompatMode uses its own editor, placed by the same division and stale in + // the same way. The mode is opt-in; the bug is not, once it is on. + if (legacyEditor != null && legacyEditingField != null + && editorWindowId(legacyEditingField) == windowId) { + double legacyScale = editorCanvasFor(legacyEditingField).canvasScale(); + legacyEditor.setBounds((int) (legacyEditingField.getAbsoluteX() / legacyScale), + (int) (legacyEditingField.getAbsoluteY() / legacyScale), + (int) (legacyEditingField.getWidth() / legacyScale), + (int) (legacyEditingField.getHeight() / legacyScale)); + } + } + + private int editorWindowId(com.codename1.ui.Component cmp) { + Object peer = com.codename1.ui.Desktop.getInstance().getWindowPeerForComponent(cmp); + return peer instanceof JavaSEWindowManager.Peer + ? ((JavaSEWindowManager.Peer) peer).windowId : 0; + } + + /// Recomputes the Swing bounds of every native peer hosted by the given window. + /// + /// A monitor move changes the backing scale the peer bounds divide by, but the + /// Codename One bounds behind them often do not change at all, so nothing would + /// ask the peer to re-place itself. The scale is part of the cache key as well; + /// this is what makes something consult that key after a move. + void reapplyPeerBounds(int windowId) { + com.codename1.ui.Window w = com.codename1.ui.Desktop.getInstance().windowById(windowId); + if (w == null) { + return; + } + refreshPeers(w.asContainer()); + } + + private void refreshPeers(com.codename1.ui.Container c) { + int count = c.getComponentCount(); + for (int iter = 0; iter < count; iter++) { + com.codename1.ui.Component cmp = c.getComponentAt(iter); + if (cmp instanceof Peer) { + ((Peer) cmp).onPositionSizeChange(); + } else if (cmp instanceof com.codename1.ui.Container) { + refreshPeers((com.codename1.ui.Container) cmp); + } + } + } + + private C editorCanvasFor(com.codename1.ui.Component cmp) { + Object peer = com.codename1.ui.Desktop.getInstance().getWindowPeerForComponent(cmp); + if (peer instanceof JavaSEWindowManager.Peer) { + C owner = ((JavaSEWindowManager.Peer) peer).canvas; + if (owner != null) { + return owner; + } + } + return canvas; + } + @Override public boolean usesInvokeAndBlockForEditString() { return false; @@ -10755,6 +11113,10 @@ private interface EditingInProgress { private EditingInProgress editingInProgress; private Component currentlyEditingField; + /// The TextCompatMode editor and the field it is editing, tracked only so a + /// monitor-scale change can reposition it; cleared when that editor is removed. + private java.awt.Component legacyEditor; + private Component legacyEditingField; private Process tabTipProcess; @@ -11019,7 +11381,7 @@ public void keyReleased(KeyEvent e) { textCmp.setBorder(null); textCmp.setOpaque(false); - canvas.add(textCmp); + editorCanvasFor(cmp).add(textCmp); int marginTop = cmp.getSelectedStyle().getPadding(Component.TOP); int marginLeft = cmp.getSelectedStyle().getPadding(Component.LEFT); int marginRight = cmp.getSelectedStyle().getPadding(Component.RIGHT); @@ -11033,8 +11395,7 @@ public void keyReleased(KeyEvent e) { java.awt.Font f = font(cmp.getStyle().getFont().getNativeFont()); tf.setFont(f.deriveFont(f.getSize2D() * zoomLevel)); } else { - textCmp.setBounds(cmp.getAbsoluteX() + cmp.getScrollX() + marginLeft, cmp.getAbsoluteY() + cmp.getScrollY() + marginTop, cmp.getWidth() - marginRight - marginLeft, cmp.getHeight() - marginTop - marginBottom); - //System.out.println("Set bounds to "+textCmp.getBounds()); + applyEditorBounds(cmp); tf.setFont(font(cmp.getStyle().getFont().getNativeFont())); } if (tf instanceof JPasswordField && tf.getFont() != null && tf.getFont().getFontName().contains("Roboto")) { @@ -11167,13 +11528,25 @@ public void actionPerformed(ActionEvent e) { ((JTextComponent) tf).getDocument().removeDocumentListener(this); tf.removeFocusListener(this); - canvas.remove(swingComponentToRemove); + // Removed from whatever actually holds it, not from the primary canvas. + // An editor opened in a desktop window is attached to that window's + // canvas, so removing from the primary one is a no-op that leaves the + // native editor on screen swallowing input, and every further edit + // stacks another one on top. + java.awt.Container editorParent = swingComponentToRemove.getParent(); + if (editorParent != null) { + editorParent.remove(swingComponentToRemove); + } editingInProgress = null; currentlyEditingField = null; synchronized (this) { notify(); } - canvas.repaint(); + if (editorParent != null) { + editorParent.repaint(); + } else { + canvas.repaint(); + } if (invokeAfter != null) { for (Runnable r : invokeAfter) { r.run(); @@ -12513,11 +12886,21 @@ public void drawRGB(Object graphics, int[] rgbData, int offset, int x, int y, in public Object getNativeGraphics() { //if (ng == null) { ng = new NativeScreenGraphics(); + ng.owner = canvas; //} return ng; //return new NativeScreenGraphics(); } + /** + * Screen graphics for a secondary window's canvas rather than the primary one. + */ + Object getNativeGraphics(C owner) { + NativeScreenGraphics g = new NativeScreenGraphics(); + g.owner = owner; + return g; + } + /** * @inheritDoc */ @@ -14250,13 +14633,51 @@ public Graphics2D getGraphics(Object nativeG) { if (ng.sourceImage != null) { return ng.sourceImage.createGraphics(); } - Graphics2D g2d = canvas.getGraphics2D(); + C owner = ng.owner != null ? ng.owner : canvas; + Graphics2D g2d = owner.getGraphics2D(); g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); return g2d; } + /** + * True when this Graphics2D is some canvas's screen buffer rather than a mutable + * image's. With desktop windows there is more than one canvas, so this is a + * membership test over the registered screen buffers rather than an identity + * comparison against the primary canvas. Consumers use it to decide whether to + * undo the zoom scale when drawing native peers, so answering wrongly for a + * secondary window would mis-scale its peers. + */ public boolean isScreenGraphics(Graphics2D g) { - return g == canvas.getGraphics2D(); + if (g == null) { + return false; + } + synchronized (screenGraphicsRegistry) { + return screenGraphicsRegistry.containsKey(g); + } + } + + /** + * Registered screen buffers, keyed by identity. Maintained in the only two places + * C.g2dInstance is written: getGraphics2D() creates one, createBufferedImage() + * and the size-change reset discard it. + */ + private final java.util.Map screenGraphicsRegistry = + new java.util.IdentityHashMap(); + + private void registerScreenGraphics(Graphics2D g, C owner) { + if (g != null) { + synchronized (screenGraphicsRegistry) { + screenGraphicsRegistry.put(g, owner); + } + } + } + + private void unregisterScreenGraphics(Graphics2D g) { + if (g != null) { + synchronized (screenGraphicsRegistry) { + screenGraphicsRegistry.remove(g); + } + } } /** @@ -14313,6 +14734,59 @@ public AsyncResource createBackgroundMediaAsync(String uri) { public static class CN1JPanel extends JPanel { + + /** + * The canvas belonging to the window this peer is in. A peer placed inside a + * desktop {@code Window} lives in that window's frame, so hit testing, + * coordinate conversion and event forwarding against the primary canvas all + * resolve outside it -- and the peer silently stops responding to the mouse. + * + * Resolved from this panel's own Swing ancestry rather than from the Codename + * One hierarchy, because these run on the AWT thread during event dispatch. + * Cached because they run for every mouse move, and dropped whenever the panel + * is re-parented. + */ + private C ownerCanvas() { + if (cachedOwnerCanvas == null) { + java.awt.Container top = getTopLevelAncestor(); + cachedOwnerCanvas = top == null ? null : findCanvas(top); + if (cachedOwnerCanvas == null) { + cachedOwnerCanvas = instance == null ? null : instance.canvas; + } + } + return cachedOwnerCanvas; + } + + private static C findCanvas(java.awt.Container parent) { + java.awt.Component[] children = parent.getComponents(); + for (int iter = 0; iter < children.length; iter++) { + if (children[iter] instanceof C) { + return (C) children[iter]; + } + if (children[iter] instanceof java.awt.Container) { + C found = findCanvas((java.awt.Container) children[iter]); + if (found != null) { + return found; + } + } + } + return null; + } + + private C cachedOwnerCanvas; + + @Override + public void addNotify() { + super.addNotify(); + cachedOwnerCanvas = null; + } + + @Override + public void removeNotify() { + super.removeNotify(); + cachedOwnerCanvas = null; + } + double zoom_; @@ -14348,8 +14822,8 @@ protected void processMouseEvent(MouseEvent e) { @Override public boolean contains(int x, int y) { - Point p = SwingUtilities.convertPoint(this, new Point(x, y), instance.canvas); - return instance.canvas.getVisibleRect().contains(p); + Point p = SwingUtilities.convertPoint(this, new Point(x, y), ownerCanvas()); + return ownerCanvas().getVisibleRect().contains(p); } @Override @@ -14373,8 +14847,8 @@ protected void processMouseWheelEvent(MouseWheelEvent e) { private boolean isOnCanvas(MouseEvent e) { - Point p = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), instance.canvas); - return instance.canvas.getVisibleRect().contains(p); + Point p = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), ownerCanvas()); + return ownerCanvas().getVisibleRect().contains(p); } @@ -14386,35 +14860,40 @@ private boolean sendToCn1(MouseEvent e) { int cn1Y = getCN1Y(e); if ((!peerGrabbedDrag || true) && Display.isInitialized()) { if (!isOnCanvas(e)) return false; - Form f = Display.getInstance().getCurrent(); + // The owning canvas's top level, not the current form: a peer in a + // secondary window was hit tested against the main form, and an + // unrelated non-peer at those coordinates set cn1GrabbedDrag and + // swallowed the event, so browser links and other native controls in + // the window stopped receiving mouse input. + com.codename1.ui.TopLevelContainer f = ownerCanvas().canvasTopLevel(); if (f != null) { - Component cmp = f.getComponentAt(cn1X, cn1Y); + Component cmp = f.asContainer().getComponentAt(cn1X, cn1Y); //if (!(cmp instanceof PeerComponent) || cn1GrabbedDrag) { // It's not a peer component, so we should pass the event to the canvas - e = SwingUtilities.convertMouseEvent(this, e, instance.canvas); + e = SwingUtilities.convertMouseEvent(this, e, ownerCanvas()); switch (e.getID()) { case MouseEvent.MOUSE_CLICKED: - instance.canvas.mouseClicked(e); + ownerCanvas().mouseClicked(e); break; case MouseEvent.MOUSE_DRAGGED: - instance.canvas.mouseDragged(e); + ownerCanvas().mouseDragged(e); break; case MouseEvent.MOUSE_MOVED: - instance.canvas.mouseMoved(e); + ownerCanvas().mouseMoved(e); break; case MouseEvent.MOUSE_PRESSED: // Mouse pressed in native component - passed to lightweight cmp if (!(cmp instanceof PeerComponent)) { instance.cn1GrabbedDrag = true; } - instance.canvas.mousePressed(e); + ownerCanvas().mousePressed(e); break; case MouseEvent.MOUSE_RELEASED: instance.cn1GrabbedDrag = false; - instance.canvas.mouseReleased(e); + ownerCanvas().mouseReleased(e); break; case MouseEvent.MOUSE_WHEEL: - instance.canvas.mouseWheelMoved((MouseWheelEvent)e); + ownerCanvas().mouseWheelMoved((MouseWheelEvent)e); break; } @@ -14438,8 +14917,34 @@ private boolean sendToCn1(MouseEvent e) { return false; } + /** + * Converts one axis of a screen coordinate into the owning canvas's Codename + * One coordinate space. + * + *

The scale passed in is the owning canvas's, not the global + * retinaScale. A peer is laid out with peerScale(), which already answers from + * the canvas's monitor; converting the hit test with the main monitor's scale + * instead makes the preliminary lookup in sendToCn1() test a different point + * than the peer occupies on a mixed-DPI desktop. That lookup can then find an + * unrelated component, set cn1GrabbedDrag and swallow mouse input meant for a + * browser or other native control. + * + * @param screenCoordinate the event's coordinate on screen + * @param canvasOriginOnScreen the owning canvas's origin on screen + * @param canvasOffset the owning canvas's offset within its frame + * @param screenCoordsOffset the skin's screen-coordinate offset + * @param zoom the active zoom level + * @param canvasScale the owning canvas's backing scale + * @return the coordinate in the canvas's Codename One space + */ + static int toCn1Coordinate(int screenCoordinate, int canvasOriginOnScreen, + int canvasOffset, int screenCoordsOffset, double zoom, double canvasScale) { + return (int) ((screenCoordinate - canvasOriginOnScreen + - (canvasOffset + screenCoordsOffset) * zoom / canvasScale) / zoom * canvasScale); + } + private int getCN1X(MouseEvent e) { - if (instance.canvas == null) { + if (ownerCanvas() == null) { int out = e.getXOnScreen(); if (out == 0) { // For some reason the web browser would return 0 for screen coordinates @@ -14476,11 +14981,12 @@ private int getCN1X(MouseEvent e) { } double zoom = zoom_ > 0 ? zoom_ : instance.zoomLevel; - return (int)((x - instance.canvas.getLocationOnScreen().x - (instance.canvas.x + screenCoords.x) * zoom / retinaScale) / zoom * retinaScale); + return toCn1Coordinate(x, ownerCanvas().getLocationOnScreen().x, + ownerCanvas().x, screenCoords.x, zoom, ownerCanvas().canvasScale()); } private int getCN1Y(MouseEvent e) { - if (instance.canvas == null) { + if (ownerCanvas() == null) { int out = e.getYOnScreen(); if (out == 0) { // For some reason the web browser would return 0 for screen coordinates @@ -14516,7 +15022,8 @@ private int getCN1Y(MouseEvent e) { } } double zoom = zoom_ > 0 ? zoom_ : instance.zoomLevel; - return (int)((y - instance.canvas.getLocationOnScreen().y - (instance.canvas.y + screenCoords.y) * zoom / retinaScale) / zoom * retinaScale); + return toCn1Coordinate(y, ownerCanvas().getLocationOnScreen().y, + ownerCanvas().y, screenCoords.y, zoom, ownerCanvas().canvasScale()); } @@ -14674,6 +15181,13 @@ private class NativeScreenGraphics { Graphics2D cachedGraphics; Transform transform; LinkedList clipStack = new LinkedList(); + /** + * The canvas this screen graphics draws into. Null means the primary canvas, + * which is the only possibility before desktop windows; a secondary window + * carries its own canvas here so getGraphics() resolves to that window's + * buffer instead of the primary one's. + */ + C owner; } private Object lastNativeGraphics; @@ -19362,9 +19876,9 @@ public static class Peer extends PeerComponent implements HierarchyListener { * @see #drawNativePeer(java.lang.Object, com.codename1.ui.PeerComponent, javax.swing.JComponent) */ private BufferedImage getBuffer() { - if (buf == null || buf.getWidth() != cnt.getWidth() * retinaScale / instance.zoomLevel || buf.getHeight() != cnt.getHeight() * retinaScale / instance.zoomLevel) { + if (buf == null || buf.getWidth() != cnt.getWidth() * peerScale() / instance.zoomLevel || buf.getHeight() != cnt.getHeight() * peerScale() / instance.zoomLevel) { - buf = new BufferedImage((int)(cnt.getWidth() * retinaScale / instance.zoomLevel), (int)(cnt.getHeight() * retinaScale / instance.zoomLevel), BufferedImage.TYPE_INT_ARGB); + buf = new BufferedImage((int)(cnt.getWidth() * peerScale() / instance.zoomLevel), (int)(cnt.getHeight() * peerScale() / instance.zoomLevel), BufferedImage.TYPE_INT_ARGB); } return buf; } @@ -19534,7 +20048,7 @@ public void run() { private void paintOnBufferImpl() { final BufferedImage buf = getBuffer(); Graphics2D g2d = buf.createGraphics(); - g2d.scale(retinaScale / instance.zoomLevel, retinaScale / instance.zoomLevel); + g2d.scale(peerScale() / instance.zoomLevel, peerScale() / instance.zoomLevel); cmp.paintAll(g2d); g2d.dispose(); @@ -19694,12 +20208,101 @@ public void run() { init = true; cnt.setVisible(true); - frm.add(cnt, 0); - frm.repaint(); + java.awt.Window target = resolveOwningFrame(); + addPeerTo(target); + target.repaint(); } } + /** + * The frame this peer belongs in. A peer inside a desktop Window has to be + * parented to that window's frame rather than to the main one, or it appears + * on the wrong window. Resolved at attach time rather than at construction + * because the peer is created before it is added to a hierarchy, so its + * window is not known yet. + */ + /** + * Attaches the peer panel to the given window. + * + * A desktop window's frame lays its content pane out with a BorderLayout and + * holds the Codename One canvas in CENTER, and an unconstrained add() takes + * that slot -- so the peer replaced the canvas as the managed centre. The + * canvas then stopped being resized with the window while the peer was laid + * out over the whole frame. The layered pane has no layout manager, which is + * what the peer's absolute bounds need anyway, and leaves the canvas alone. + * + * Only for a secondary window. The main frame's peers have gone through + * add(cnt, 0) since long before windows existed, and its content pane is not + * arranged the same way, so that path is left exactly as it was. + */ + private void addPeerTo(java.awt.Window target) { + if (owningFrame != null && target instanceof javax.swing.RootPaneContainer) { + ((javax.swing.RootPaneContainer) target).getLayeredPane() + .add(cnt, javax.swing.JLayeredPane.PALETTE_LAYER); + return; + } + target.add(cnt, 0); + } + + private void removePeerFrom(java.awt.Window target) { + if (owningFrame != null && target instanceof javax.swing.RootPaneContainer) { + ((javax.swing.RootPaneContainer) target).getLayeredPane().remove(cnt); + return; + } + target.remove(cnt); + } + + private java.awt.Window resolveOwningFrame() { + Object peer = com.codename1.ui.Desktop.getInstance().getWindowPeerForComponent(this); + if (peer instanceof JavaSEWindowManager.Peer) { + JavaSEWindowManager.Peer owner = (JavaSEWindowManager.Peer) peer; + if (owner.frame != null) { + // Remembered rather than re-resolved: removeNativeCnt() runs after + // the component has left the hierarchy, so the owning window is no + // longer discoverable from it, and removing from the wrong frame + // leaves the Swing panel attached to the window that is going away. + owningFrame = owner.frame; + owningCanvas = owner.canvas; + return owner.frame; + } + } + // Cleared rather than left alone: a peer moved back out of a window and + // into the main form would otherwise keep pointing at the window it used + // to be in. addPeerTo would still treat it as a secondary-window peer, and + // its bounds, its scale and its eventual removal would all target a canvas + // it no longer lives in. + owningFrame = null; + owningCanvas = null; + return frm; + } + + /** + * The backing scale of the display this peer's window is on. + * + * Peer geometry used the global retinaScale -- the main display's -- + * while the hierarchy and pointer coordinates around it use the owning + * canvas's. On a mixed-scale desktop the peer was then offset and sized by the + * ratio between the two monitors, so a browser or a native editor drifted away + * from the component it belongs to. + */ + private double peerScale() { + C c = owningCanvas(); + return c == null ? retinaScale : c.canvasScale(); + } + + /** + * The canvas this peer's coordinates are relative to. A peer inside a desktop + * Window is positioned against that window's canvas; using the main one offsets + * it by the two frames' on-screen distance. + */ + private C owningCanvas() { + return owningCanvas != null ? owningCanvas : instance.canvas; + } + + private java.awt.Window owningFrame; + private C owningCanvas; + /** * Removes the native container from the Swing component hierarchy. * This can be called on or off the swing event thread. If called off the swing event @@ -19718,9 +20321,10 @@ public void run() { peerImage = generatePeerImage(); } init = false; - frm.remove(cnt); - frm.repaint(); - + java.awt.Window target = owningFrame != null ? owningFrame : frm; + removePeerFrom(target); + target.repaint(); + } @Override @@ -19790,8 +20394,8 @@ public void run() { @Override protected com.codename1.ui.geom.Dimension calcPreferredSize() { - return new com.codename1.ui.geom.Dimension((int)(cmp.getPreferredSize().getWidth()* retinaScale / instance.zoomLevel), - (int)(cmp.getPreferredSize().getHeight() * retinaScale / instance.zoomLevel)); + return new com.codename1.ui.geom.Dimension((int)(cmp.getPreferredSize().getWidth()* peerScale() / instance.zoomLevel), + (int)(cmp.getPreferredSize().getHeight() * peerScale() / instance.zoomLevel)); } @@ -19834,6 +20438,12 @@ public void run() { int lastX, lastY, lastW, lastH; double lastZoom; + /// The backing scale the cached bounds were computed with. Part of the key + /// because the Swing bounds divide by it: without it a window moved to a + /// display of another scale skipped the update whenever its Codename One + /// bounds happened to be unchanged, and the native control kept the old + /// divisor -- wrong size and wrong place. + double lastPeerScale = -1; @Override protected void onPositionSizeChange() { @@ -19855,9 +20465,12 @@ protected void onPositionSizeChange() { final int w = getWidth(); final int h = getHeight(); double zoom_ = instance.zoomLevel; - if (lastZoom == zoom_ && x == lastX && y == lastY && w == lastW && h == lastH) { + double peerScale_ = peerScale(); + if (lastZoom == zoom_ && lastPeerScale == peerScale_ + && x == lastX && y == lastY && w == lastW && h == lastH) { return; } + lastPeerScale = peerScale_; final int screenX; final int screenY; if (instance.getScreenCoordinates() != null) { @@ -19879,21 +20492,21 @@ protected void onPositionSizeChange() { @Override public void run() { if (cnt.getParent() == null) return; - Point absCanvasLocation = SwingUtilities.convertPoint(instance.canvas, new Point(0, 0), cnt.getParent()); + Point absCanvasLocation = SwingUtilities.convertPoint(owningCanvas(), new Point(0, 0), cnt.getParent()); if (peerBuffer == null) { - double scale = zoom/retinaScale; + double scale = zoom / peerScale(); setCntBounds( - (int) ((x + screenX + instance.canvas.x) * scale) + absCanvasLocation.x, - (int) ((y + screenY + instance.canvas.y) * scale) + absCanvasLocation.y, + (int) ((x + screenX + owningCanvas().x) * scale) + absCanvasLocation.x, + (int) ((y + screenY + owningCanvas().y) * scale) + absCanvasLocation.y, (int) (w * scale), (int) (h * scale) ); } else { - double scale = zoom/retinaScale; + double scale = zoom / peerScale(); setCntBounds( - (int) ((x + screenX + instance.canvas.x) * scale) + absCanvasLocation.x, - (int) ((y + screenY + instance.canvas.y) * scale) + absCanvasLocation.y, + (int) ((x + screenX + owningCanvas().x) * scale) + absCanvasLocation.x, + (int) ((y + screenY + owningCanvas().y) * scale) + absCanvasLocation.y, (int) (w * scale), (int) (h * scale) ); @@ -19921,6 +20534,13 @@ public void hierarchyChanged(HierarchyEvent e) { if (_inHierarchyChanged) return; _inHierarchyChanged = true; try { + // A peer inside a desktop window is already parented to that window's + // frame; re-homing it to the main canvas's ancestor would move it to + // the wrong window on every hierarchy change. + if (owningFrame != null) { + onPositionSizeChange(); + return; + } java.awt.Container win = instance.canvas.getTopLevelAncestor(); if (win != frm) { removeNativeCnt(); @@ -20950,6 +21570,53 @@ public BrowserWindowFactory setBrowserWindowFactory(BrowserWindowFactory newFact return old; } + private JavaSEWindowManager windowManager; + + /// Guards the lazy creation of windowManager against the matching teardown in + /// deinitialize(). Both paths are reachable off the EDT -- Desktop.isSupported() + /// and the Window constructor are callable from any thread -- and an unsynchronized + /// lazy init loses more than a wasted allocation here: the constructor starts a + /// monitor-topology timer, so the instance that loses the race is unreachable + /// through the field yet its poller keeps waking every two seconds for the life of + /// the process, reporting every topology change a second time and surviving the + /// deinitialize() that was supposed to stop it. + private final Object windowManagerLock = new Object(); + + /** + * Creates the canvas backing one desktop window, tagged with the id its input + * events are routed by. + */ + C createWindowCanvas(int windowId) { + C c = new C(); + c.windowId = windowId; + return c; + } + + /** + * @inheritDoc + * + * Returns null while a phone skin is loaded: a skin simulates a single device + * screen with its own coordinates and zoom, and a real operating system window + * inside that simulation would be incoherent. Mirrors the predicate + * isFullScreenSupported already uses. + */ + @Override + public com.codename1.impl.WindowManager getWindowManager() { + if (java.awt.GraphicsEnvironment.isHeadless()) { + return null; + } + if (isSimulator() && !Preferences.userNodeForPackage(JavaSEPort.class) + .getBoolean("desktopSkin", false)) { + return null; + } + synchronized (windowManagerLock) { + if (windowManager == null) { + windowManager = new JavaSEWindowManager(this); + } + return windowManager; + } + } + @Override public Object createNativeBrowserWindow(String startURL) { diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWindowManager.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWindowManager.java new file mode 100644 index 00000000000..1c84bda0e4e --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWindowManager.java @@ -0,0 +1,1219 @@ +/* + * 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.javase; + +import com.codename1.ui.Desktop; +import com.codename1.impl.WindowManager; +import com.codename1.ui.Display; + +import javax.swing.JFrame; +import javax.swing.SwingUtilities; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.GraphicsConfiguration; +import java.awt.GraphicsDevice; +import java.awt.GraphicsEnvironment; +import java.awt.Insets; +import java.awt.Rectangle; +import java.awt.Toolkit; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.awt.geom.AffineTransform; +import java.util.ArrayList; +import java.util.List; + +/** + * The JavaSE implementation of the native windowing contract. Each Codename One + * {@code Window} becomes a {@link JFrame} holding its own instance of the port's + * {@code C} canvas, so it gets the whole buffered blit machine -- including the + * aliasing fast path -- without any of it being duplicated. + * + *

Multi-window is deliberately unsupported while a phone skin is loaded. A skin + * simulates one device screen, complete with fixed screen coordinates and a zoom + * factor, and opening a real operating system window inside that simulation is + * incoherent. The predicate mirrors the one {@code isFullScreenSupported} already + * uses.

+ * + * @author Shai Almog + */ +public class JavaSEWindowManager extends WindowManager { + + private final JavaSEPort port; + private final List peers = new ArrayList(); + + JavaSEWindowManager(JavaSEPort port) { + this.port = port; + watchMonitorTopology(); + } + + /** + * Reports monitors being attached, removed or reconfigured. + * + *

AWT has no notification for this -- {@code GraphicsEnvironment} is a + * snapshot -- so the device set is sampled on a daemon timer and the framework is + * told only when it actually changes. Without this the documented + * {@code Desktop.addMonitorListener()} never fires and windows keep stale + * per-monitor scale after a display is unplugged.

+ */ + /** Returned by {@link #topologySignature()} when the sample itself failed. */ + private static final String TOPOLOGY_UNAVAILABLE = "unavailable"; + + /// The topology poller, kept so a port restart can stop it. A simulator session + /// that cycles through Display.deinitialize()/init() builds a new window manager + /// each time, and every previous poller went on waking every two seconds for the + /// life of the process -- each of them reporting the same topology change. + private java.util.Timer monitorWatch; + + /// Stops the topology poller. Called when the port is torn down. + void stopWatchingMonitorTopology() { + java.util.Timer timer = monitorWatch; + monitorWatch = null; + if (timer != null) { + timer.cancel(); + } + } + + private void watchMonitorTopology() { + final java.util.Timer timer = new java.util.Timer("cn1-monitor-watch", true); + monitorWatch = timer; + timer.schedule(new java.util.TimerTask() { + private String last = topologySignature(); + + @Override + public void run() { + String now = topologySignature(); + if (TOPOLOGY_UNAVAILABLE.equals(now)) { + // A failed sample is not a topology change, and the comment on + // that branch already said so. Recording it fired a notification + // for the failure and a second one when the next sample + // succeeded, and the first pass could rebuild monitor data from + // fallbacks and relayout every window against them. + return; + } + if (!now.equals(last)) { + last = now; + Desktop.getInstance().monitorsChanged(); + } + } + }, MONITOR_POLL_MS, MONITOR_POLL_MS); + } + + /** + * Cheap fingerprint of the attached displays: count, bounds, scale and screen + * insets. + * + * The insets matter as much as the bounds. A taskbar or dock that moves edge, + * changes size or toggles auto-hide reconfigures the work area while leaving the + * monitor's bounds and scale identical, so a fingerprint without them never fired + * monitorsChanged(): windows kept a stale work area and centerOnDesktop() could + * place one underneath the taskbar that had just appeared. + */ + private static String topologySignature() { + StringBuilder sb = new StringBuilder(); + try { + Toolkit toolkit = Toolkit.getDefaultToolkit(); + for (GraphicsDevice device : devices()) { + GraphicsConfiguration cfg = device.getDefaultConfiguration(); + Rectangle b = cfg.getBounds(); + AffineTransform tx = cfg.getDefaultTransform(); + Insets in = toolkit.getScreenInsets(cfg); + sb.append(device.getIDstring()).append(':') + .append(b.x).append(',').append(b.y).append(',') + .append(b.width).append('x').append(b.height).append('@') + .append(tx.getScaleX()).append('/') + .append(in.top).append(',').append(in.left).append(',') + .append(in.bottom).append(',').append(in.right).append(';'); + } + } catch (Throwable err) { + // A display being reconfigured mid-query throws in AWT; the next tick sees + // the settled state, so a failed sample is not worth reporting. + return TOPOLOGY_UNAVAILABLE; + } + return sb.toString(); + } + + /** Two seconds is imperceptible for a display change and costs nothing. */ + private static final int MONITOR_POLL_MS = 2000; + + /** + * One native window: its frame, the canvas Codename One paints into, and the id + * the framework tags this window's input events with. + */ + static final class Peer { + /** + * Typed as the AWT base class rather than JFrame because an owned window has + * to be a JDialog: Swing expresses ownership through the owner passed at + * construction, and JFrame has no owned form. Everything here works against + * java.awt.Window; the few Frame-only operations go through {@link #asFrame()}. + */ + java.awt.Window frame; + JavaSEPort.C canvas; + int windowId; + int monitorIndex; + /** + * True while the AWT peer is being torn down and rebuilt to apply a chrome + * change. That cycle calls setVisible(false) and setVisible(true) on a window + * the framework still considers shown, so the component listener would report + * it as a minimize and a restore -- firing Minimized and Restored events and + * cancelling pending input for the window and its owned children. Only ever + * touched on the AWT thread, which is where both the cycle and the callbacks + * run. + */ + boolean reconfiguring; + + /// Visibility events AWT is about to deliver for a show() or hide() this + /// manager asked for, rather than for something the user or window manager did. + /// + /// Only ever touched on the AWT thread, which is also where the events arrive, + /// so the increment always precedes the delivery it accounts for. + int selfInflictedVisibilityEvents; + /// The application's own always-on-top setting, kept apart from the temporary + /// elevation a modal window gets so releasing modality cannot clear it. + boolean explicitAlwaysOnTop; + boolean modalElevated; + /// The requested minimum, in the device pixels Codename One lays out in. AWT + /// wants logical units, and the two differ by the backing scale of whatever + /// monitor the window is on -- which changes when the user drags it to another + /// display -- so the request is kept in its original units and re-converted + /// rather than converted once at the point of the call. + int minWidth; + int minHeight; + + /** + * The frame operations that only exist on a top level window. An owned window + * is a dialog and answers null, which is also the right behaviour: a dialog is + * iconified and restored with its owner rather than on its own. + */ + java.awt.Frame asFrame() { + return frame instanceof java.awt.Frame ? (java.awt.Frame) frame : null; + } + } + + private static Peer peer(Object p) { + if (p instanceof Peer) { + return (Peer) p; + } + return null; + } + + // ---- window lifecycle --------------------------------------------------- + + @Override + public Object createWindow(final int windowId, final String title, final int x, final int y, + final int width, final int height, final boolean decorated, final boolean resizable, + final Object parentPeer, final boolean positionSet, + final boolean ownedByMainWindow) { + final Peer p = new Peer(); + p.windowId = windowId; + boolean created = runOnAwtAndWait(new Runnable() { + @Override + public void run() { + // An owned window stays above its owner and is iconified with it, + // which is what setOwnerWindow() promises. Swing expresses that through + // the owner passed at construction, and JFrame has no owned form, so an + // owned window is a JDialog. Both are java.awt.Window subclasses and + // everything below only uses that surface -- except the JFrame typed + // field, which keeps its meaning for the unowned case. + // An owned window stays above its owner and is iconified with it, + // which is what setOwnerWindow() promises. Swing establishes that only + // through the owner passed at construction, and JFrame has no owned + // form, so an owned window is a JDialog. + // An owner with no peer is the application's main window, which is + // the port's own frame rather than one of ours. + Peer owner = peer(parentPeer); + java.awt.Window ownerWindow = owner != null ? owner.frame + : (ownedByMainWindow ? port.findTopFrame() : null); + java.awt.Window frame; + if (ownerWindow != null) { + javax.swing.JDialog dlg = + new javax.swing.JDialog(ownerWindow, title == null ? "" : title); + dlg.setDefaultCloseOperation(javax.swing.JDialog.DO_NOTHING_ON_CLOSE); + dlg.setUndecorated(!decorated); + dlg.setResizable(resizable); + frame = dlg; + } else { + JFrame f = new JFrame(title == null ? "" : title); + f.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + f.setUndecorated(!decorated); + f.setResizable(resizable); + frame = f; + } + frame.setLayout(new BorderLayout()); + + JavaSEPort.C canvas = port.createWindowCanvas(windowId); + frame.add(BorderLayout.CENTER, canvas); + frame.setSize(new Dimension(width, height)); + if (positionSet) { + // Applied whatever the sign: a monitor left of or above the + // primary display has a negative origin, and a window restored + // onto it must not be re-centred on the primary one. + frame.setLocation(x, y); + } else { + frame.setLocationRelativeTo(null); + } + + frame.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + Desktop.getInstance().windowCloseRequested(windowId); + } + + @Override + public void windowActivated(WindowEvent e) { + Desktop.getInstance().windowFocusChanged(windowId, true); + } + + @Override + public void windowDeactivated(WindowEvent e) { + Desktop.getInstance().windowFocusChanged(windowId, false); + } + + @Override + public void windowIconified(WindowEvent e) { + Desktop.getInstance().windowHideNotify(windowId); + } + + @Override + public void windowDeiconified(WindowEvent e) { + Desktop.getInstance().windowShowNotify(windowId); + } + }); + frame.addComponentListener(new ComponentAdapter() { + /** + * AWT hides a window's owned dialogs along with it and shows them + * again with it, without any window event of its own. Nothing told + * the framework, so an owned window kept nativeVisible true with no + * native surface behind it: isWindowShowing() went on reporting it, + * and it went on painting and animating, which also keeps the event + * dispatch thread awake. + * + *

Safe for the explicit path too. Window.hide() and show() + * clear or set nativeVisible before calling this manager, so the + * notification they trigger here finds the state already correct + * and does nothing.

+ */ + @Override + public void componentHidden(ComponentEvent e) { + if (p.reconfiguring || consumeSelfInflicted(p)) { + return; + } + Desktop.getInstance().windowHideNotify(windowId); + } + + @Override + public void componentShown(ComponentEvent e) { + if (p.reconfiguring || consumeSelfInflicted(p)) { + return; + } + Desktop.getInstance().windowShowNotify(windowId); + } + + @Override + public void componentResized(ComponentEvent e) { + Desktop.getInstance().windowSizeChanged(windowId, + scaled(p, p.canvas.getWidth()), scaled(p, p.canvas.getHeight())); + } + + @Override + public void componentMoved(ComponentEvent e) { + Desktop.getInstance().windowMoved(windowId); + // A move can also carry the window onto a different display, + // and a different display can mean a different backing scale, + // which invalidates every preferred size computed at the old + // one. + int now = monitorIndexOf(p); + if (now != p.monitorIndex) { + p.monitorIndex = now; + // The minimum is held in Codename One pixels, so the AWT + // constraint means something different on a display with + // another backing scale and has to be re-converted. + applyMinimumSize(p); + Desktop.getInstance().windowMonitorChanged(windowId); + // The Swing editor is placed by dividing by the canvas's + // backing scale, so a move to a display with another one + // leaves it offset and mis-sized over its field. The + // hierarchy is re-laid out for the new scale; nothing + // moved the editor. + port.reapplyEditorBounds(windowId); + // Native peers divide by the same scale and cache the + // result, and a move often leaves the Codename One bounds + // untouched, so nothing else would ask them to re-place. + port.reapplyPeerBounds(windowId); + } + } + }); + + p.frame = frame; + p.canvas = canvas; + p.monitorIndex = monitorIndexOf(p); + } + }); + // These are the last thing the AWT task does, so either it finished or it did + // not. runOnAwtAndWait logs whatever the task threw and returns normally -- + // allocating a native peer can fail, and a headless or exhausted window server + // is the ordinary way -- and returning this peer anyway would report success. + // Window.show() checks only for null, so it would register the window, publish + // it through Desktop and fire Shown for a window with no frame and no surface + // behind it; every later call here would then quietly do nothing against the + // null frame, which is a window that exists to the application and to nobody + // else. Nor is the peer registered: a peer that never became a window has + // nothing for the sweeps over `peers` to do. + if (!created || p.frame == null || p.canvas == null) { + return null; + } + synchronized (peers) { + peers.add(p); + } + return p; + } + + /// Whether this AWT visibility event was caused by show() or hide() here, rather + /// than by the user or the window manager. + /// + /// The framework already knows about its own show and hide -- Window sets + /// nativeVisible before calling this manager -- so reporting them again is at best + /// redundant. It is not merely redundant, though, because the report is queued onto + /// the Codename One event dispatch thread rather than delivered inline: a show and + /// a hide in the same turn both queue, and both then run against the state the + /// second one left. The pair is read as a minimize and a restore, and in the + /// show-then-hide order the window ends up hidden but marked iconified -- which is + /// the state showModal() waits on, so its caller waits for good. + private static boolean consumeSelfInflicted(Peer p) { + if (p.selfInflictedVisibilityEvents > 0) { + p.selfInflictedVisibilityEvents--; + return true; + } + return false; + } + + @Override + public void show(Object peerObj) { + final Peer p = peer(peerObj); + if (p == null) { + return; + } + runOnAwtAndWait(new Runnable() { + @Override + public void run() { + // Counted only when the frame is actually changing state, because AWT + // delivers nothing when it is not and the count would then be spent on + // some later event that the user caused. + if (!p.frame.isVisible()) { + p.selfInflictedVisibilityEvents++; + } + p.frame.setVisible(true); + p.canvas.requestFocus(); + } + }); + } + + @Override + public void hide(Object peerObj) { + final Peer p = peer(peerObj); + if (p == null) { + return; + } + // Waits, exactly as show() does. Queued, a hide followed by a show in the same + // EDT turn ran after the show had already put nativeVisible back: the frame's + // componentHidden then arrived with the window visible and was reported as a + // minimize, and the show's componentShown as a restore -- a spurious + // Minimized/Restored pair after the real Hidden/Shown, with minimize listeners + // firing for a window that is on screen. + runOnAwtAndWait(new Runnable() { + @Override + public void run() { + if (p.frame.isVisible()) { + p.selfInflictedVisibilityEvents++; + } + p.frame.setVisible(false); + } + }); + } + + @Override + public void dispose(Object peerObj) { + final Peer p = peer(peerObj); + if (p == null) { + return; + } + synchronized (peers) { + peers.remove(p); + } + runOnAwt(new Runnable() { + @Override + public void run() { + // Before the frame goes: the canvas registers an AWTEventListener on + // the global Toolkit for the magnification wheel fallback, and the + // Toolkit holds it for the life of the VM. Disposing only the frame + // left that listener retaining the canvas and its whole hierarchy, + // and inspecting every wheel event in the application, once per + // window ever opened. + if (p.canvas != null) { + p.canvas.disposeGestureListeners(); + // The screen graphics registry keys a Graphics2D to its canvas + // strongly, so disposing only the frame left the canvas and its + // buffers reachable for the life of the application. + p.canvas.releaseScreenGraphics(); + } + p.frame.dispose(); + } + }); + } + + // ---- attributes ------------------------------------------------------------ + + @Override + public void setTitle(Object peerObj, final String title) { + final Peer p = peer(peerObj); + if (p != null) { + runOnAwt(new Runnable() { + @Override + public void run() { + String text = title == null ? "" : title; + if (p.frame instanceof java.awt.Frame) { + ((java.awt.Frame) p.frame).setTitle(text); + } else if (p.frame instanceof java.awt.Dialog) { + ((java.awt.Dialog) p.frame).setTitle(text); + } + } + }); + } + } + + /** + * Applied on the AWT thread before returning, not queued. + * + *

Geometry is read back synchronously: {@code setWindowSize()} followed by + * {@code centerOnDesktop()} or {@code centerOn()} in one Codename One event + * dispatch turn has the centring read {@link #getBounds}, work out an origin from + * it and write the whole rectangle back. Queued, the read saw the frame's old + * dimensions, so the second write carried the old size and the later AWT task + * undid the resize -- the resize silently did nothing. + * + *

Waiting here follows what {@code createWindow} and {@code show} already do + * in this class for the same reason. + */ + @Override + public void setBounds(Object peerObj, final int x, final int y, final int width, final int height) { + final Peer p = peer(peerObj); + if (p != null) { + runOnAwtAndWait(new Runnable() { + @Override + public void run() { + p.frame.setBounds(x, y, width, height); + } + }); + } + } + + @Override + public int[] getBounds(Object peerObj, int[] out) { + Peer p = peer(peerObj); + if (p == null || p.frame == null) { + return out; + } + Rectangle r = p.frame.getBounds(); + out[0] = r.x; + out[1] = r.y; + out[2] = r.width; + out[3] = r.height; + return out; + } + + @Override + public int getWidth(Object peerObj) { + Peer p = peer(peerObj); + if (p == null || p.canvas == null) { + return 0; + } + return Math.max(1, scaled(p, p.canvas.getWidth())); + } + + @Override + public int getHeight(Object peerObj) { + Peer p = peer(peerObj); + if (p == null || p.canvas == null) { + return 0; + } + return Math.max(1, scaled(p, p.canvas.getHeight())); + } + + @Override + public void setResizable(Object peerObj, final boolean resizable) { + final Peer p = peer(peerObj); + if (p != null) { + runOnAwt(new Runnable() { + @Override + public void run() { + if (p.frame instanceof java.awt.Frame) { + ((java.awt.Frame) p.frame).setResizable(resizable); + } else if (p.frame instanceof java.awt.Dialog) { + ((java.awt.Dialog) p.frame).setResizable(resizable); + } + } + }); + } + } + + @Override + public void setMinimumSize(Object peerObj, int width, int height) { + Peer p = peer(peerObj); + if (p != null) { + p.minWidth = width; + p.minHeight = height; + applyMinimumSize(p); + } + } + + /** + * Pushes the stored minimum to AWT in AWT's own units. + * + * The SPI supplies the minimum in the device pixels Codename One lays out in, + * while {@code java.awt.Window.setMinimumSize} takes logical units -- the same + * distinction {@link #scaled} applies in the other direction when reporting a + * window's size. Handing the device value straight to AWT made a requested 320 + * pixel minimum a 640 device pixel floor on a 2x display, and the meaning changed + * again whenever the window was dragged to a monitor with a different scale, so + * this is re-applied on a monitor change rather than converted once. + */ + private void applyMinimumSize(final Peer p) { + final int w = p.minWidth; + final int h = p.minHeight; + if (p.frame == null) { + return; + } + final double scale = getMonitorScale(monitorIndexOf(p)); + runOnAwt(new Runnable() { + @Override + public void run() { + if (w > 0 && h > 0) { + double s = scale > 0 ? scale : 1.0; + p.frame.setMinimumSize(new Dimension( + Math.max(1, (int) Math.round(w / s)), + Math.max(1, (int) Math.round(h / s)))); + } else { + p.frame.setMinimumSize(null); + } + } + }); + } + + @Override + public void setDecorated(Object peerObj, final boolean decorated) { + final Peer p = peer(peerObj); + if (p == null) { + return; + } + applyWhileUndisplayable(p, new Runnable() { + @Override + public void run() { + if (p.frame instanceof java.awt.Frame) { + ((java.awt.Frame) p.frame).setUndecorated(!decorated); + } else if (p.frame instanceof java.awt.Dialog) { + ((java.awt.Dialog) p.frame).setUndecorated(!decorated); + } + } + }); + } + + /** + * Runs a change that Swing only permits while a window is undisplayable, taking + * the frame down and putting it back around it. + * + * Disposing an AWT window disposes everything it owns, so a window with open child + * windows would take them down with it and only put itself back. The children + * stayed registered and visible as far as the framework knew, painting into a + * hierarchy that was no longer displayable. They are remembered here and re-shown + * after the owner, so a child is never briefly parented to a window that is not on + * screen. + * + * Shared by the decoration and utility-window setters. The utility setter used to + * skip the change outright once the window was showing, which left the platform on + * the old taskbar behaviour while {@code Window.isUtilityWindow()} reported the + * requested value -- a setter that silently did nothing. + */ + /// Every window owned by the given one, at any depth. + /// + /// AWT's hide and show of owned windows is recursive, so anything less than the + /// full tree leaves descendants observing transitions that are an implementation + /// detail of a chrome change. + private static void collectOwnedWindows(java.awt.Window root, + java.util.List out) { + for (java.awt.Window each : root.getOwnedWindows()) { + out.add(each); + collectOwnedWindows(each, out); + } + } + + /// The peer that owns the given AWT window, or null when it is not one of ours. + private Peer peerFor(java.awt.Window frame) { + synchronized (peers) { + for (Peer each : peers) { + if (each.frame == frame) { //NOPMD CompareObjectsWithEquals + return each; + } + } + } + return null; + } + + private void applyWhileUndisplayable(final Peer p, final Runnable change) { + runOnAwt(new Runnable() { + @Override + public void run() { + // The hide and show below are an implementation detail of applying the + // change, not a visibility transition the framework should hear about. + p.reconfiguring = true; + java.util.List childrenSuppressed = new java.util.ArrayList(); + try { + boolean wasVisible = p.frame.isVisible(); + // The whole owned tree, not just the direct children. AWT hides and + // shows every descendant recursively, so a visible grandchild takes + // the same implicit hide and explicit reshow -- and collecting only + // getOwnedWindows() left it unsuppressed and reporting the spurious + // pair, which is the same mistake one level down. + java.util.List owned = + new java.util.ArrayList(); + collectOwnedWindows(p.frame, owned); + java.util.List wereVisible = + new java.util.ArrayList(); + for (java.awt.Window each : owned) { + if (each.isVisible()) { + wereVisible.add(each); + // The owner's hide takes its visible children down with it and + // they are put back explicitly below, so their listeners see + // the same spurious pair the owner's did. Marking only the + // owner left every owned window reporting a minimize and a + // restore, and cancelling its pending input. + Peer child = peerFor(each); + if (child != null) { + child.reconfiguring = true; + childrenSuppressed.add(child); + } + } + } + if (wasVisible) { + p.frame.setVisible(false); + } + p.frame.dispose(); + change.run(); + if (wasVisible) { + // setVisible(true) recreates the native peer the dispose destroyed. + p.frame.setVisible(true); + } + for (java.awt.Window each : wereVisible) { + each.setVisible(true); + } + } finally { + p.reconfiguring = false; + for (Peer child : childrenSuppressed) { + child.reconfiguring = false; + } + } + } + }); + } + + @Override + public void setAlwaysOnTop(Object peerObj, final boolean alwaysOnTop) { + final Peer p = peer(peerObj); + if (p != null) { + p.explicitAlwaysOnTop = alwaysOnTop; + applyAlwaysOnTop(p); + } + } + + @Override + public void setModal(Object peerObj, final boolean modal, boolean applicationWide, + Object ownerPeer) { + // Elevation only. Which windows are actually blocked is decided by the + // framework and delivered through setInputEnabled/setMainWindowInputEnabled, + // because that answer depends on the whole modal stack rather than on this + // one call. + final Peer p = peer(peerObj); + if (p == null) { + return; + } + p.modalElevated = modal; + applyAlwaysOnTop(p); + } + + @Override + public void setInputEnabled(Object peerObj, final boolean enabled) { + final Peer p = peer(peerObj); + if (p != null) { + runOnAwt(new Runnable() { + @Override + public void run() { + p.frame.setEnabled(enabled); + } + }); + } + } + + @Override + public void setMainWindowInputEnabled(final boolean enabled) { + runOnAwt(new Runnable() { + @Override + public void run() { + java.awt.Window main = port.findTopFrame(); + if (main != null) { + main.setEnabled(enabled); + } + } + }); + } + + /// Floats the frame when the application asked for it or while it is modal. + private void applyAlwaysOnTop(final Peer p) { + runOnAwt(new Runnable() { + @Override + public void run() { + p.frame.setAlwaysOnTop(p.explicitAlwaysOnTop || p.modalElevated); + } + }); + } + + @Override + public void setUtilityWindow(Object peerObj, final boolean utility) { + final Peer p = peer(peerObj); + if (p != null) { + // UTILITY is the Swing window type that keeps a palette out of the task + // bar and gives it lighter chrome. Swing only allows the type to change + // while the frame is undisplayable, so a window that is already up is taken + // down and put back rather than being left on the old behaviour -- which is + // what it used to do, silently, while isUtilityWindow() reported otherwise. + applyWhileUndisplayable(p, new Runnable() { + @Override + public void run() { + p.frame.setType(utility + ? java.awt.Window.Type.UTILITY + : java.awt.Window.Type.NORMAL); + } + }); + } + } + + @Override + public void setIcon(Object peerObj, final com.codename1.ui.Image icon) { + final Peer p = peer(peerObj); + if (p == null) { + return; + } + // A null icon is a request to clear one, not a missing argument. Returning + // here left the previous image on the frame while getWindowIcon() reported + // none, so the title bar and the taskbar went on showing an icon the + // application had removed and there was no way to take it back off. + final Object nativeImage = icon == null ? null : icon.getImage(); + if (icon != null && !(nativeImage instanceof java.awt.Image)) { + return; + } + runOnAwt(new Runnable() { + @Override + public void run() { + // Only a top level window carries an icon; an owned dialog shows its + // owner's. + if (p.asFrame() != null) { + p.asFrame().setIconImage((java.awt.Image) nativeImage); + } + } + }); + } + + @Override + public void requestFocus(Object peerObj) { + final Peer p = peer(peerObj); + if (p != null) { + runOnAwt(new Runnable() { + @Override + public void run() { + p.frame.toFront(); + p.frame.requestFocus(); + p.canvas.requestFocus(); + } + }); + } + } + + @Override + public void minimize(Object peerObj) { + final Peer p = peer(peerObj); + if (p != null) { + runOnAwt(new Runnable() { + @Override + public void run() { + // An owned dialog has no independent iconified state; it minimizes + // with its owner, which is the platform's own behaviour. + if (p.asFrame() != null) { + p.asFrame().setState(java.awt.Frame.ICONIFIED); + } + } + }); + } + } + + @Override + public void restore(Object peerObj) { + final Peer p = peer(peerObj); + if (p != null) { + runOnAwt(new Runnable() { + @Override + public void run() { + if (p.asFrame() != null) { + p.asFrame().setState(java.awt.Frame.NORMAL); + } + } + }); + } + } + + @Override + public void toggleMaximize(Object peerObj) { + final Peer p = peer(peerObj); + if (p != null) { + runOnAwt(new Runnable() { + @Override + public void run() { + if (p.asFrame() == null) { + return; + } + if ((p.asFrame().getExtendedState() & java.awt.Frame.MAXIMIZED_BOTH) + == java.awt.Frame.MAXIMIZED_BOTH) { + p.asFrame().setExtendedState(java.awt.Frame.NORMAL); + } else { + p.asFrame().setExtendedState(java.awt.Frame.MAXIMIZED_BOTH); + } + } + }); + } + } + + // ---- rendering ---------------------------------------------------------------- + + @Override + public Object getNativeGraphics(Object peerObj) { + Peer p = peer(peerObj); + if (p == null) { + return null; + } + return port.getNativeGraphics(p.canvas); + } + + @Override + public void flushGraphics(Object peerObj, int x, int y, int width, int height) { + Peer p = peer(peerObj); + if (p != null && p.canvas != null) { + p.canvas.blit(); + } + } + + @Override + public Object capture(Object peerObj) { + Peer p = peer(peerObj); + if (p == null || p.canvas == null) { + return null; + } + return p.canvas.captureBuffer(); + } + + /** + * Installs the window's commands as a native menu bar on its own frame. + * + * The main window builds its menu the same way through + * {@code JavaSEPort.setNativeCommands}; this is the per-window counterpart, so a + * command added to a Window is displayed and activated rather than only recorded. + * Gated on desktop native chrome mode for the same reason the main window is: in + * skin mode there is no native frame to hang a menu on. + */ + @Override + public void setCommands(Object peerObj, final com.codename1.ui.Command[] commands) { + final Peer p = peer(peerObj); + if (p == null || !port.isDesktopNativeChromeMode()) { + return; + } + // Activation is routed back through this window so its command listeners see it. + final com.codename1.ui.Window owner = + com.codename1.ui.Desktop.getInstance().windowById(p.windowId); + final java.util.ArrayList named = + new java.util.ArrayList(); + if (commands != null) { + for (com.codename1.ui.Command c : commands) { + String name = c == null ? null : c.getCommandName(); + if (name != null && name.length() > 0) { + // Icon-only commands have nothing to label a menu item with. + named.add(c); + } + } + } + runOnAwt(new Runnable() { + @Override + public void run() { + javax.swing.JMenuBar bar = named.isEmpty() + ? null : port.buildWindowMenuBar(named, owner); + if (p.frame instanceof javax.swing.JFrame) { + ((javax.swing.JFrame) p.frame).setJMenuBar(bar); + } else if (p.frame instanceof javax.swing.JDialog) { + ((javax.swing.JDialog) p.frame).setJMenuBar(bar); + } else { + return; + } + p.frame.revalidate(); + } + }); + } + + /** + * The application's main frame in desktop coordinates. + * + * findTopFrame() is deliberately the primary window only -- every other caller of + * it is a main-window operation -- and that is exactly what is wanted here: a Form + * lives in that frame, so centring a Window over a Form centres over it. + */ + @Override + public int[] getMainWindowBounds(int[] out) { + java.awt.Window main = port.findTopFrame(); + if (main == null || out == null || out.length < 4) { + return null; + } + Rectangle b = main.getBounds(); + out[0] = b.x; + out[1] = b.y; + out[2] = b.width; + out[3] = b.height; + return out; + } + + // ---- monitors --------------------------------------------------------------------- + + private static GraphicsDevice[] devices() { + return GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices(); + } + + @Override + public int getMonitorCount() { + try { + return devices().length; + } catch (Throwable err) { + return 1; + } + } + + @Override + public int[] getMonitorBounds(int monitor, int[] out) { + GraphicsDevice d = device(monitor); + Rectangle r = d == null ? new Rectangle(0, 0, 0, 0) + : d.getDefaultConfiguration().getBounds(); + out[0] = r.x; + out[1] = r.y; + out[2] = r.width; + out[3] = r.height; + return out; + } + + @Override + public int[] getMonitorWorkArea(int monitor, int[] out) { + GraphicsDevice d = device(monitor); + if (d == null) { + return getMonitorBounds(monitor, out); + } + GraphicsConfiguration cfg = d.getDefaultConfiguration(); + Rectangle r = cfg.getBounds(); + Insets in = Toolkit.getDefaultToolkit().getScreenInsets(cfg); + out[0] = r.x + in.left; + out[1] = r.y + in.top; + out[2] = r.width - in.left - in.right; + out[3] = r.height - in.top - in.bottom; + return out; + } + + @Override + public int getMonitorDensity(int monitor) { + int dpi = getMonitorDotsPerInch(monitor); + if (dpi >= 280) { + return Display.DENSITY_VERY_HIGH; + } + if (dpi >= 200) { + return Display.DENSITY_HIGH; + } + if (dpi >= 140) { + return Display.DENSITY_MEDIUM; + } + return Display.DENSITY_LOW; + } + + @Override + public double getMonitorScale(int monitor) { + GraphicsDevice d = device(monitor); + if (d == null) { + return 1.0; + } + AffineTransform t = d.getDefaultConfiguration().getDefaultTransform(); + return t.getScaleX(); + } + + @Override + public int getMonitorDotsPerInch(int monitor) { + try { + return (int) Math.round(Toolkit.getDefaultToolkit().getScreenResolution() + * getMonitorScale(monitor)); + } catch (Throwable err) { + return 96; + } + } + + @Override + public String getMonitorName(int monitor) { + GraphicsDevice d = device(monitor); + return d == null ? "unknown" : d.getIDstring(); + } + + @Override + public int getPrimaryMonitor() { + try { + GraphicsDevice primary = + GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice(); + GraphicsDevice[] all = devices(); + for (int iter = 0; iter < all.length; iter++) { + if (all[iter] == primary) { + return iter; + } + } + } catch (Throwable err) { + // fall through to zero + } + return 0; + } + + @Override + public int getMonitorForWindow(Object peerObj) { + Peer p = peer(peerObj); + if (p == null) { + return getPrimaryMonitor(); + } + return monitorIndexOf(p); + } + + @Override + public int getMonitorForMainWindow() { + // The simulator's own frame, which is the window a Form is displayed in. + // It moves between displays like any other, so reporting the primary + // monitor for it was wrong the moment the user dragged it. + java.awt.Window main = port.findTopFrame(); + if (main == null) { + return getPrimaryMonitor(); + } + return monitorIndexOfWindow(main); + } + + private static GraphicsDevice device(int monitor) { + try { + GraphicsDevice[] all = devices(); + if (monitor >= 0 && monitor < all.length) { + return all[monitor]; + } + if (all.length > 0) { + return all[0]; + } + } catch (Throwable err) { + // headless + } + return null; + } + + private int monitorIndexOf(Peer p) { + if (p.frame == null) { + return getPrimaryMonitor(); + } + return monitorIndexOfWindow(p.frame); + } + + /// Which display the given AWT window is on, shared by the secondary windows + /// and the application's main frame. + private int monitorIndexOfWindow(java.awt.Window frame) { + try { + GraphicsConfiguration cfg = frame.getGraphicsConfiguration(); + if (cfg != null) { + GraphicsDevice[] all = devices(); + for (int iter = 0; iter < all.length; iter++) { + if (all[iter] == cfg.getDevice()) { + return iter; + } + } + } + } catch (Throwable err) { + // fall through + } + return getPrimaryMonitor(); + } + + /** + * Converts an AWT coordinate to the device pixels Codename One lays out in, using + * the backing scale of the display this window is actually on rather than a + * single global scale. + */ + private int scaled(Peer p, int value) { + return (int) Math.round(value * getMonitorScale(monitorIndexOf(p))); + } + + // ---- threading ----------------------------------------------------------------- + + private static void runOnAwt(Runnable r) { + if (SwingUtilities.isEventDispatchThread()) { + r.run(); + } else { + SwingUtilities.invokeLater(r); + } + } + + /** + * Runs a task on the AWT event thread and waits for it. + * + *

Returns whether the task actually completed. Allocating a native window peer + * can fail -- an exhausted or headless window server is the ordinary way -- and + * this swallows and logs that, so a caller that goes on to report success needs a + * way to know. A caller that ignores the result behaves exactly as before.

+ * + *

Package private so it can be tested directly: whether a failure is reported + * rather than logged and forgotten is the whole point of the return value.

+ * + * @param r the task to run + * @return true when the task ran to completion + */ + static boolean runOnAwtAndWait(Runnable r) { + if (SwingUtilities.isEventDispatchThread()) { + // Already there, so nothing is swallowed: a failure propagates to the + // caller as it always has. + r.run(); + return true; + } + try { + SwingUtilities.invokeAndWait(r); + return true; + } catch (Exception err) { + com.codename1.io.Log.e(err); + return false; + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/SourceChangeWatcher.java b/Ports/JavaSE/src/com/codename1/impl/javase/SourceChangeWatcher.java index 406dace722e..7fbcc7e6efa 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/SourceChangeWatcher.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/SourceChangeWatcher.java @@ -1,7 +1,24 @@ /* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. + * 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.javase; @@ -491,7 +508,7 @@ public void run() { } else if (hotReloadSetting == 2) { // Not using hotswap agent, but the option is selected to refresh current form. stopped = true; - Window win = SwingUtilities.getWindowAncestor(JavaSEPort.instance.canvas); + java.awt.Window win = SwingUtilities.getWindowAncestor(JavaSEPort.instance.canvas); JavaSEPort.instance.deinitializeSync(); win.dispose(); registerCurrentFormForReload(); @@ -500,7 +517,7 @@ public void run() { } else if (hotReloadSetting == 1) { stopped = true; - Window win = SwingUtilities.getWindowAncestor(JavaSEPort.instance.canvas); + java.awt.Window win = SwingUtilities.getWindowAncestor(JavaSEPort.instance.canvas); JavaSEPort.instance.deinitializeSync(); win.dispose(); System.setProperty("reload.simulator", "true"); @@ -618,7 +635,7 @@ public void run() { return true; } else if (hotReloadSetting == 2) { stopped = true; - Window win = SwingUtilities.getWindowAncestor(JavaSEPort.instance.canvas); + java.awt.Window win = SwingUtilities.getWindowAncestor(JavaSEPort.instance.canvas); JavaSEPort.instance.deinitializeSync(); win.dispose(); registerCurrentFormForReload(); @@ -627,7 +644,7 @@ public void run() { } else if (hotReloadSetting == 1) { stopped = true; - Window win = SwingUtilities.getWindowAncestor(JavaSEPort.instance.canvas); + java.awt.Window win = SwingUtilities.getWindowAncestor(JavaSEPort.instance.canvas); JavaSEPort.instance.deinitializeSync(); win.dispose(); System.setProperty("reload.simulator", "true"); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h index 56dde128c7c..29061b86cf8 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux.h @@ -39,6 +39,9 @@ #ifdef __cplusplus extern "C" { +/* Starts reporting display attach/remove; idempotent. */ +void cn1LinuxWatchMonitors(void); + #endif /* ------------------------------------------------------------------ events */ @@ -70,7 +73,16 @@ typedef enum { * fireRotationGesture, the same hooks the macOS trackpad drives. */ CN1_EVENT_PINCH = 10, CN1_EVENT_ROTATE = 11, - CN1_EVENT_ACCESSIBILITY_ACTION = 12 + CN1_EVENT_ACCESSIBILITY_ACTION = 12, + /* Additional desktop windows. These carry a non-zero windowId; everything + * above carries zero, meaning the application's main window. */ + CN1_EVENT_WINDOW_CLOSE = 13, + CN1_EVENT_WINDOW_FOCUS = 14, /* keyCode 1 == gained, 0 == lost */ + CN1_EVENT_WINDOW_MONITOR = 15, /* window moved to a different monitor */ + CN1_EVENT_WINDOW_SHOWN = 16, + CN1_EVENT_WINDOW_HIDDEN = 17, + CN1_EVENT_WINDOW_MOVED = 18, + CN1_EVENT_MONITORS_CHANGED = 19 } CN1EventType; /* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */ @@ -91,7 +103,23 @@ typedef enum { #define CN1_PE_TOUCH_FLAG 256 /* Pushes one event onto the ring buffer (called from the GTK thread). */ +/* Turns fractional smooth-scroll notches into whole ones, carrying the remainder + * in *residue. Shared by the main window and by each secondary window. */ +int cn1LinuxTakeWholeNotches(double delta, double* residue); + void cn1LinuxPushEvent(int type, int x, int y, int keyCode); +/* Same, but tagged with the desktop window the event came from. */ +void cn1LinuxPushWindowEvent(int windowId, int type, int x, int y, int keyCode); + +/* Additional desktop windows (cn1_linux_desktopwindow.c). The main window's + * statics are left alone: a secondary window carries its own GtkWindow, overlay, + * drawing area and cairo back buffer, so the single-window path is unchanged. + * Every entry point here must run on the GTK main thread; callers marshal with + * cn1LinuxRunOnMainAndWait. */ +#define CN1_MAX_DESKTOP_WINDOWS 32 +/* The GTK-typed accessors live in cn1_linux_gfx.h, which is the header that + * includes gtk. This one is included by units that have no GTK on their include + * path, so declaring a GtkWidget* here would break them. */ /* * Pops one event into out[0..3] = {type, x, y, keyCode}; returns 1 if one was diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_browser.c b/Ports/LinuxPort/nativeSources/cn1_linux_browser.c index 63d941aa492..d3b2c38244e 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_browser.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_browser.c @@ -113,6 +113,9 @@ typedef struct { GtkWidget* view; char* queue[CN1_BROWSER_QUEUE]; int head, tail; + /* The window whose overlay hosts this view, so move and teardown target the + * same place it was added to. */ + int slot; pthread_mutex_t lock; } CN1Browser; @@ -150,11 +153,15 @@ static void cn1BrowserScriptMessage(WebKitUserContentManager* mgr, WebKitJavascr (void) mgr; } -typedef struct { int w, h; CN1Browser* result; } CN1BrowserCreateReq; +typedef struct { int w, h; int slot; CN1Browser* result; } CN1BrowserCreateReq; static void cn1BrowserCreateOnMain(void* p) { CN1BrowserCreateReq* req = (CN1BrowserCreateReq*) p; CN1Browser* b = (CN1Browser*) calloc(1, sizeof(CN1Browser)); + /* Set before anything reads it: calloc leaves it 0, which is a *valid* desktop + * window slot, so adding the view to "slot 0" targeted a window that does not + * exist and the browser was never placed in any overlay at all. */ + b->slot = req->slot; WebKitUserContentManager* mgr = p_webkit_user_content_manager_new(); pthread_mutex_init(&b->lock, 0); p_webkit_user_content_manager_register_script_message_handler(mgr, "cn1"); @@ -181,8 +188,13 @@ static void cn1BrowserCreateOnMain(void* p) { } } b->view = p_webkit_web_view_new_with_user_content_manager(mgr); + /* A reference of our own, held for the life of the CN1Browser. Without it the + * overlay holds the only one, so the widget goes away with whichever window + * happens to be hosting it -- while Java still has the peer and can re-host it, + * navigate it or destroy it. */ + g_object_ref_sink(b->view); g_signal_connect(b->view, "load-changed", G_CALLBACK(cn1BrowserLoadChanged), b); - cn1LinuxOverlayAdd(b->view, 0, 0, req->w > 0 ? req->w : 1, req->h > 0 ? req->h : 1); + cn1LinuxOverlayAdd(b->slot, b->view, 0, 0, req->w > 0 ? req->w : 1, req->h > 0 ? req->h : 1); req->result = b; } @@ -190,18 +202,58 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_browserSupported___R_boolean(C return (cn1LinuxWindowWidget() != 0 && cn1LoadWebkit()) ? JAVA_TRUE : JAVA_FALSE; } -JAVA_LONG com_codename1_impl_linux_LinuxNative_browserCreate___int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_INT w, JAVA_INT h) { +JAVA_LONG com_codename1_impl_linux_LinuxNative_browserCreate___int_int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_INT w, JAVA_INT h, JAVA_INT slot) { CN1BrowserCreateReq req; if (cn1LinuxWindowWidget() == 0 || !cn1LoadWebkit()) { return 0; } req.w = w; req.h = h; + req.slot = slot; req.result = 0; cn1LinuxRunOnMainAndWait(cn1BrowserCreateOnMain, &req); return (JAVA_LONG) (intptr_t) req.result; } +typedef struct { CN1Browser* b; int slot; } CN1BrowserHostReq; + +static void cn1BrowserSetHostOnMain(void* p) { + CN1BrowserHostReq* req = (CN1BrowserHostReq*) p; + CN1Browser* b = req->b; + /* The slot alone is not enough to conclude the view is already where it belongs. + * Disposing a window detaches whatever its overlay was hosting, and window + * creation reuses the lowest free slot -- so the next window commonly gets the + * same number, and skipping on the number alone left the browser unparented and + * invisible while the framework believed it was hosted. Attachment is the thing + * being decided, so attachment is what gets tested. */ + if (b->slot == req->slot && gtk_widget_get_parent(b->view) != 0) { + return; + } + /* Moved between overlays rather than recreated: the view keeps its page, its load + * state and its script message handler, so a browser added to a window after it + * was constructed does not lose whatever it had already loaded. + * + * Safe to unparent because the CN1Browser holds a reference of its own from + * creation; the overlay's is not the only one. */ + cn1LinuxOverlayRemove(b->slot, b->view); + b->slot = req->slot; + cn1LinuxOverlayAdd(b->slot, b->view, 0, 0, 1, 1); +} + +/* Re-hosts the view in the given window's overlay. The real bounds arrive right + * after from browserSetBounds, so the 1x1 above is never displayed. */ +JAVA_VOID com_codename1_impl_linux_LinuxNative_browserSetHost___long_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT slot) { + CN1Browser* b = (CN1Browser*) (intptr_t) peer; + CN1BrowserHostReq req; + if (!b) { + return; + } + req.b = b; + req.slot = slot; + cn1LinuxRunOnMainAndWait(cn1BrowserSetHostOnMain, &req); +} + typedef struct { CN1Browser* b; char* a; char* c; int x, y, w, h; } CN1BrowserOp; static void cn1BrowserSetHtmlOnMain(void* p) { @@ -221,7 +273,7 @@ static void cn1BrowserExecuteOnMain(void* p) { static void cn1BrowserBoundsOnMain(void* p) { CN1BrowserOp* op = (CN1BrowserOp*) p; - cn1LinuxOverlayMove(op->b->view, op->x, op->y, op->w, op->h); + cn1LinuxOverlayMove(op->b->slot, op->b->view, op->x, op->y, op->w, op->h); } static void cn1BrowserSetVisibleOnMain(void* p) { @@ -311,8 +363,11 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_browserCapturePng___long_R_byte static void cn1BrowserDestroyOnMain(void* p) { CN1Browser* b = (CN1Browser*) p; if (b->view) { - cn1LinuxOverlayRemove(b->view); + cn1LinuxOverlayRemove(b->slot, b->view); gtk_widget_destroy(b->view); + /* The reference taken at creation. Dropping it last is what finally finalizes + * the view, and only when the application asked for the browser to go. */ + g_object_unref(b->view); b->view = 0; } } diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c new file mode 100644 index 00000000000..ba51aef8699 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c @@ -0,0 +1,1516 @@ +/* + * 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. + */ + +/* + * Additional desktop windows for the Linux port. + * + * The application's main window keeps its own file statics in cn1_linux_window.c + * and is untouched by this file. A Codename One Window takes a slot in the table + * below, with its own GtkWindow, GtkOverlay, GtkDrawingArea, GtkFixed peer layer + * and cairo back buffer, so the existing single-window path cannot change. + * + * Routing is free here: every GTK signal handler already receives a gpointer, so + * passing the slot as the closure data makes each handler window-scoped with no + * lookup at all. The message loop needs no change either -- gtk_main_iteration + * already services every window in the process. + * + * GTK is not thread safe, so every entry point runs on the GTK main thread. The + * EDT-facing natives marshal through cn1LinuxRunOnMainAndWait, which the port + * already uses for exactly this. + */ + +#include "cn1_linux.h" +#include "cn1_linux_gfx.h" +#include +#include +#include +#include + +/* Defined in cn1_linux_window.c beside the main window's capture, which reads + * the same kind of cairo surface. */ +int cn1LinuxSurfaceToPng(cairo_surface_t* surface, unsigned char** outData, int* outLen); + +typedef struct { + GtkWidget* window; + GtkWidget* overlay; + GtkWidget* drawingArea; + GtkWidget* fixed; /* positioned native peers live here */ + CN1Graphics g; /* the window's cairo back buffer */ + int width; + int height; + int x; + int y; + int windowId; + int monitorIndex; + int inUse; + /* Pinch baseline, per window rather than per process: GDK reports scale + * cumulatively from the gesture's BEGIN, so the previous value is what turns + * it into the incremental multiplier Codename One dispatches. */ + double pinchLastScale; + /* Smooth-scroll residue, per window for the same reason as the pinch baseline. + * GDK reports touchpad scrolling as a stream of fractional notches; whole + * notches are dispatched and the remainder is carried here. */ + double scrollResidueX; + double scrollResidueY; + /* The outer size setWindowBounds asked for, held until the window-manager + * frame is known. gdk_window_get_frame_extents() reports the client rectangle + * until the window is realized AND the WM has attached a frame, which is + * usually after the app has already called setWindowBounds -- so the + * conversion cannot be done where the request arrives. */ + int requestedOuterW; + int requestedOuterH; + int outerSizePending; + /* Back-buffer replacement is deferred to the drawing thread, mirroring the + * Windows port's pendingResize: GTK reports a resize on its own thread while + * the event dispatch thread may be painting through g.cr, and freeing the + * context or surface underneath it crashes or corrupts the frame. */ + /* The touch sequence this window is tracking, so two windows can each follow + * their own contact. */ + void* touchSeq; + volatile int pendingResize; + int pendingW; + int pendingH; + /* Held while GTK reads the surface to blit it, and while the drawing thread + * swaps it. Those are the only two places one thread can destroy what the + * other is using. */ + pthread_mutex_t bufferLock; +} CN1LinuxWindow; + +static CN1LinuxWindow cn1DesktopWindows[CN1_MAX_DESKTOP_WINDOWS]; + +static CN1LinuxWindow* slotAt(int slot) { + if (slot < 0 || slot >= CN1_MAX_DESKTOP_WINDOWS) { + return 0; + } + if (!cn1DesktopWindows[slot].inUse) { + return 0; + } + return &cn1DesktopWindows[slot]; +} + +static void cn1DesktopResizeBuffer(CN1LinuxWindow* w, int width, int height); +static int cn1DesktopIsTouchSource(GdkEvent* e); + +/* Called by the port at the start of a frame, on the drawing thread, which is + * what makes it the safe point to swap the back buffer. */ +CN1Graphics* cn1LinuxDesktopGraphics(int slot) { + CN1LinuxWindow* w = slotAt(slot); + if (w == 0) { + return 0; + } + if (w->pendingResize) { + pthread_mutex_lock(&w->bufferLock); + /* Re-checked under the lock: GTK can report another resize between the + * test above and here. */ + if (w->pendingResize) { + /* Claimed before the buffer work, not cleared after it, so a configure + * that arrives meanwhile re-arms rather than being erased. */ + int width = w->pendingW; + int height = w->pendingH; + w->pendingResize = 0; + cn1DesktopResizeBuffer(w, width, height); + } + pthread_mutex_unlock(&w->bufferLock); + } + return &w->g; +} + +GtkWidget* cn1LinuxDesktopWidget(int slot) { + CN1LinuxWindow* w = slotAt(slot); + return w == 0 ? 0 : w->window; +} + +GtkWidget* cn1LinuxDesktopFixed(int slot) { + CN1LinuxWindow* w = slotAt(slot); + return w == 0 ? 0 : w->fixed; +} + +/* ------------------------------------------------------------ back buffer */ + +static void cn1DesktopResizeBuffer(CN1LinuxWindow* w, int width, int height) { + if (width <= 0) width = 1; + if (height <= 0) height = 1; + if (w->g.cr) { + cairo_destroy(w->g.cr); + } + if (w->g.surface) { + cairo_surface_destroy(w->g.surface); + } + w->g.surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height); + w->g.cr = cairo_create(w->g.surface); + w->g.width = width; + w->g.height = height; + w->g.color = 0; + w->g.alpha = 255; + w->g.clipX = 0; + w->g.clipY = 0; + w->g.clipW = width; + w->g.clipH = height; + w->g.clipIsRect = 1; + /* Enables the #5273 flush-region clip clamp, as for the main window: a clip + * set while a component paints is confined to the region about to be flushed + * so a fill cannot escape into the retained surface. */ + w->g.isWindowTarget = 1; + cairo_matrix_init_identity(&w->g.transform); +} + +/* ------------------------------------------------------- monitors */ + +static int cn1DesktopMonitorIndexFor(GtkWidget* window) { + GdkDisplay* display = gdk_display_get_default(); + GdkWindow* gdkWindow; + GdkMonitor* mon; + int count; + int iter; + if (display == 0 || window == 0) { + return 0; + } + gdkWindow = gtk_widget_get_window(window); + if (gdkWindow == 0) { + return 0; + } + mon = gdk_display_get_monitor_at_window(display, gdkWindow); + count = gdk_display_get_n_monitors(display); + for (iter = 0; iter < count; iter++) { + if (gdk_display_get_monitor(display, iter) == mon) { + return iter; + } + } + return 0; +} + +/* --------------------------------------------------------- GTK callbacks */ + +static gboolean cn1DesktopOnDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w != 0) { + /* Locked so the drawing thread cannot swap the surface out from under + * this blit. */ + pthread_mutex_lock(&w->bufferLock); + if (w->g.surface) { + cairo_set_source_surface(cr, w->g.surface, 0, 0); + cairo_paint(cr); + } + pthread_mutex_unlock(&w->bufferLock); + } + return FALSE; +} + +static gboolean cn1DesktopOnConfigure(GtkWidget* widget, GdkEventConfigure* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w == 0) { + return FALSE; + } + if (e->width != w->width || e->height != w->height) { + w->width = e->width; + w->height = e->height; + /* Recorded, not applied: this runs on the GTK thread and the event + * dispatch thread may be part way through a frame on the current buffer. + * cn1LinuxDesktopGraphics applies it between frames. + * + * Under bufferLock, which is the lock the consumer already re-checks the + * flag under. Written unlocked, a configure arriving while the consumer was + * inside cn1DesktopResizeBuffer had its request cleared on the way out: the + * framework still got the SIZE_CHANGED below and laid the window out at the + * new size, while the cairo surface stayed at the old one -- clipped or + * blank until the next resize. */ + pthread_mutex_lock(&w->bufferLock); + w->pendingW = w->width; + w->pendingH = w->height; + w->pendingResize = 1; + pthread_mutex_unlock(&w->bufferLock); + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_SIZE_CHANGED, w->width, w->height, 0); + } + /* Position and monitor are handled by cn1DesktopOnWindowConfigure, on the top + * level. They were handled here, on the drawing area, where e->x and e->y are + * the child's offset inside its parent -- which does not change when the user + * drags the window, so a move was never reported at all and the cached monitor + * never refreshed. */ + return FALSE; +} + +/* Moves and monitor changes, observed on the top level rather than on the drawing + * area: dragging a window changes the toplevel's position while leaving the child's + * allocation untouched, so the drawing area sees no configure event. */ +/* Defined with the other geometry helpers below; used here because configure-event + * is where a deferred outer size finally becomes applicable. */ +static void cn1DesktopApplyPendingOuterSize(CN1LinuxWindow* w); + +static gboolean cn1DesktopOnWindowConfigure(GtkWidget* widget, GdkEventConfigure* e, + gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w == 0) { + return FALSE; + } + if (e->x != w->x || e->y != w->y) { + w->x = e->x; + w->y = e->y; + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_MOVED, 0, 0, 0); + } + cn1DesktopApplyPendingOuterSize(w); + { + int now = cn1DesktopMonitorIndexFor(w->window); + if (now != w->monitorIndex) { + w->monitorIndex = now; + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_MONITOR, 0, 0, now); + } + } + return FALSE; +} + +static int cn1DesktopButtonMask(guint button) { + switch (button) { + case 1: return CN1_PE_MASK_PRIMARY; + case 2: return CN1_PE_MASK_MIDDLE; + case 3: return CN1_PE_MASK_SECONDARY; + case 8: return CN1_PE_MASK_BACK; + case 9: return CN1_PE_MASK_FORWARD; + default: return CN1_PE_MASK_PRIMARY; + } +} + +static gboolean cn1DesktopOnButton(GtkWidget* widget, GdkEventButton* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (cn1DesktopIsTouchSource((GdkEvent*) e)) { + /* Handled by cn1DesktopOnTouch, flagged as touch. */ + return TRUE; + } + if (w == 0) { + return FALSE; + } + if (e->type == GDK_BUTTON_PRESS) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_PRESSED, + (int) e->x, (int) e->y, cn1DesktopButtonMask(e->button)); + } else if (e->type == GDK_BUTTON_RELEASE) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_RELEASED, + (int) e->x, (int) e->y, cn1DesktopButtonMask(e->button)); + } + return FALSE; +} + +static gboolean cn1DesktopOnMotion(GtkWidget* widget, GdkEventMotion* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + int mask = 0; + (void) widget; + if (cn1DesktopIsTouchSource((GdkEvent*) e)) { + /* Synthesized from a contact cn1DesktopOnTouch already reported. */ + return TRUE; + } + if (w == 0) { + return FALSE; + } + if (e->state & GDK_BUTTON1_MASK) { mask |= CN1_PE_MASK_PRIMARY; } + if (e->state & GDK_BUTTON2_MASK) { mask |= CN1_PE_MASK_MIDDLE; } + if (e->state & GDK_BUTTON3_MASK) { mask |= CN1_PE_MASK_SECONDARY; } + if (mask != 0) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_DRAGGED, + (int) e->x, (int) e->y, mask); + } + return FALSE; +} + +/* Same shape as the main window's cn1OnScroll, with the window id attached so the + * event dispatch thread scrolls this window's content rather than the main form's. + * One notch == 120 units, the WHEEL_DELTA the implementation converts to pixels. */ +static gboolean cn1DesktopOnScroll(GtkWidget* widget, GdkEventScroll* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w == 0) { + return FALSE; + } + if (e->direction == GDK_SCROLL_UP) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_MOUSE_WHEEL, (int) e->x, (int) e->y, 120); + } else if (e->direction == GDK_SCROLL_DOWN) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_MOUSE_WHEEL, (int) e->x, (int) e->y, -120); + } else if (e->direction == GDK_SCROLL_LEFT) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_MOUSE_HWHEEL, (int) e->x, (int) e->y, -120); + } else if (e->direction == GDK_SCROLL_RIGHT) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_MOUSE_HWHEEL, (int) e->x, (int) e->y, 120); + } else if (e->direction == GDK_SCROLL_SMOOTH) { + /* Two-finger touchpad scrolling arrives here and nowhere else. A touchpad + * reports no discrete steps, so GDK emits only a smooth event for it, and + * gdk_window.c drops that event before delivery unless the widget selected + * GDK_SMOOTH_SCROLL_MASK -- which is why the mask above is not optional. + * Selecting it also makes GDK drop the pointer-emulated discrete events a + * real wheel produces, so the branches above and this one cannot both fire + * for one physical movement. + * + * The deltas are fractions of a notch and arrive continuously, so they + * cannot be forwarded one for one: wheelUnits() on the Java side floors any + * sub-notch delta to a whole notch, which would turn a gentle drag into a + * page-a-frame stampede. Only whole notches are dispatched; the remainder + * stays in the window until it adds up. */ + int vertical; + int horizontal; + if (e->is_stop) { + /* Kinetic end-of-gesture marker. Dropping the residue keeps the next + * gesture from inheriting a partial notch from this one. */ + w->scrollResidueX = 0; + w->scrollResidueY = 0; + return TRUE; + } + vertical = cn1LinuxTakeWholeNotches(e->delta_y, &w->scrollResidueY); + horizontal = cn1LinuxTakeWholeNotches(e->delta_x, &w->scrollResidueX); + if (vertical != 0) { + /* delta_y grows downwards, the direction GDK_SCROLL_DOWN reports as + * negative units above. */ + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_MOUSE_WHEEL, + (int) e->x, (int) e->y, -vertical * 120); + } + if (horizontal != 0) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_MOUSE_HWHEEL, + (int) e->x, (int) e->y, horizontal * 120); + } + } + return TRUE; +} + +static gboolean cn1DesktopOnKey(GtkWidget* widget, GdkEventKey* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w == 0) { + return FALSE; + } + /* When a native peer inside this window holds the focus -- the text editor's + * GtkEntry/GtkTextView, a WebKit view, an application @NativeInterface widget -- + * the keystroke belongs to that widget, not to the Codename One hierarchy. + * Returning FALSE without queueing lets GtkWindow's default handler forward it + * to the focused widget and nowhere else; queueing first meant typing into a + * native editor *also* reached the window's focused component and its key + * listeners, firing shortcuts while the user was typing. The main window has + * had this guard from the start. + * + * Once the peer is torn down GTK clears the toplevel focus, so Codename One + * keys resume by themselves. */ + if (w->window != 0) { + GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(w->window)); + if (focus != 0 && focus != w->drawingArea) { + return FALSE; + } + } + /* Same mapping the main window uses: GDK encodes many Unicode keyvals + * differently from their code point, so a raw keyval gives the wrong key code + * for anything outside ASCII. Navigation keys have no Unicode mapping and fall + * back to the keyval, which is what the event loop recognises for them. */ + { + int code = (int) gdk_keyval_to_unicode(e->keyval); + if (code == 0) { + code = (int) e->keyval; + } + cn1LinuxPushWindowEvent(w->windowId, + e->type == GDK_KEY_PRESS ? CN1_EVENT_KEY_PRESSED : CN1_EVENT_KEY_RELEASED, + 0, 0, code); + } + return FALSE; +} + +/* Minimize and restore. Without this the framework goes on treating a minimized + * window as displayed: it keeps painting it, and an animation in it keeps the event + * dispatch thread awake indefinitely. */ +/* Displays being attached, removed or reconfigured. Connected once, the first time + * a window is created, because GdkDisplay outlives every window. */ +static void cn1DesktopMonitorPropertyChanged(GObject* obj, GParamSpec* spec, gpointer data) { + (void) obj; + (void) spec; + (void) data; + cn1LinuxPushWindowEvent(0, CN1_EVENT_MONITORS_CHANGED, 0, 0, 0); +} + +/* + * Watches an individual monitor's geometry, work area and scale. Changing the + * resolution, scale or rotation of a display that is already connected emits none + * of the display-level add/remove signals, so without these a reconfiguration + * never reached a listener and open windows kept stale geometry and density. + */ +static void cn1DesktopWatchMonitor(GdkMonitor* mon) { + if (mon == 0) { + return; + } + /* g_signal_connect is idempotent only by handler identity, so guard with a + * one-shot flag stored on the monitor itself. */ + if (g_object_get_data(G_OBJECT(mon), "cn1-watched") != 0) { + return; + } + g_object_set_data(G_OBJECT(mon), "cn1-watched", (gpointer) 1); + g_signal_connect(mon, "notify::geometry", + G_CALLBACK(cn1DesktopMonitorPropertyChanged), 0); + g_signal_connect(mon, "notify::workarea", + G_CALLBACK(cn1DesktopMonitorPropertyChanged), 0); + g_signal_connect(mon, "notify::scale-factor", + G_CALLBACK(cn1DesktopMonitorPropertyChanged), 0); +} + +/* Attaches the per-monitor watch to every monitor currently connected. */ +static void cn1DesktopWatchAllMonitors(GdkDisplay* display) { + int count; + int iter; + if (display == 0) { + return; + } + count = gdk_display_get_n_monitors(display); + for (iter = 0; iter < count; iter++) { + cn1DesktopWatchMonitor(gdk_display_get_monitor(display, iter)); + } +} + +static void cn1DesktopMonitorsChanged(GdkDisplay* display, GdkMonitor* monitor, gpointer data) { + (void) monitor; + (void) data; + /* A newly attached monitor needs its own property watch. */ + cn1DesktopWatchAllMonitors(display); + cn1LinuxPushWindowEvent(0, CN1_EVENT_MONITORS_CHANGED, 0, 0, 0); +} + +static int cn1DesktopMonitorWatchInstalled; + +void cn1LinuxWatchMonitors(void) { + GdkDisplay* display; + if (cn1DesktopMonitorWatchInstalled) { + return; + } + display = gdk_display_get_default(); + if (display == 0) { + return; + } + cn1DesktopMonitorWatchInstalled = 1; + g_signal_connect(display, "monitor-added", G_CALLBACK(cn1DesktopMonitorsChanged), 0); + g_signal_connect(display, "monitor-removed", G_CALLBACK(cn1DesktopMonitorsChanged), 0); + cn1DesktopWatchAllMonitors(display); +} + +static gboolean cn1DesktopOnWindowState(GtkWidget* widget, GdkEventWindowState* e, + gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w == 0 || (e->changed_mask & GDK_WINDOW_STATE_ICONIFIED) == 0) { + return FALSE; + } + if (e->new_window_state & GDK_WINDOW_STATE_ICONIFIED) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_HIDDEN, 0, 0, 0); + } else if (gtk_widget_get_visible(w->window)) { + /* Only a window that is actually mapped. An owned window is de-iconified with + * its owner, and reporting that for one the application had hidden itself would + * tell the framework a window is back when nothing is on screen -- the same + * mistake the Windows port made by keeping one flag for "hidden with its owner" + * and "minimized". There is no flag to go stale here, so this is the whole of + * it. */ + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_SHOWN, 0, 0, 0); + } + return FALSE; +} + +static gboolean cn1DesktopOnDelete(GtkWidget* widget, GdkEvent* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + (void) e; + if (w != 0) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_CLOSE, 0, 0, 0); + } + /* TRUE stops GTK destroying the window: Codename One decides, because an + * application may veto the close from a listener. */ + return TRUE; +} + +/* True when an event came from a touchscreen. GTK synthesizes button and motion + * events from touch for widgets that ignore touch, so those are dropped here and + * the touch handler drives the pointer instead -- otherwise every contact + * dispatches twice, once unflagged. Same rule the main window applies. */ +static int cn1DesktopIsTouchSource(GdkEvent* e) { + GdkDevice* dev = gdk_event_get_source_device(e); + return dev != NULL && gdk_device_get_source(dev) == GDK_SOURCE_TOUCHSCREEN; +} + +/* Real touch sequences, mirroring the main window's cn1OnTouch with the window id + * carried through. Without this a touchscreen contact over a secondary window only + * ever arrived as a synthesized mouse event, so listeners saw it as a mouse and no + * touch sequence reached the window at all. */ +static gboolean cn1DesktopOnTouch(GtkWidget* widget, GdkEventTouch* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w == 0) { + return FALSE; + } + switch (e->type) { + case GDK_TOUCH_BEGIN: + if (w->touchSeq == 0) { + w->touchSeq = e->sequence; + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_PRESSED, + (int) e->x, (int) e->y, CN1_PE_MASK_PRIMARY | CN1_PE_TOUCH_FLAG); + } + return TRUE; + case GDK_TOUCH_UPDATE: + if (e->sequence == w->touchSeq) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_DRAGGED, + (int) e->x, (int) e->y, CN1_PE_MASK_PRIMARY | CN1_PE_TOUCH_FLAG); + } + return TRUE; + case GDK_TOUCH_END: + case GDK_TOUCH_CANCEL: + if (e->sequence == w->touchSeq) { + w->touchSeq = 0; + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_POINTER_RELEASED, + (int) e->x, (int) e->y, CN1_PE_MASK_PRIMARY | CN1_PE_TOUCH_FLAG); + } + return TRUE; + default: + return FALSE; + } +} + +static gboolean cn1DesktopOnFocus(GtkWidget* widget, GdkEventFocus* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w != 0) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_FOCUS, 0, 0, e->in ? 1 : 0); + } + return FALSE; +} + +/* Touchpad pinch / rotate, the same GDK_TOUCHPAD_PINCH handling the main window + * does, routed to this window instead. Without it a trackpad pinch over a + * secondary window produced no Codename One gesture at all, so a component that + * zooms on the main form did nothing once it was hosted in a window. */ +static gboolean cn1DesktopOnGenericEvent(GtkWidget* widget, GdkEvent* e, gpointer data) { + CN1LinuxWindow* w = (CN1LinuxWindow*) data; + (void) widget; + if (w == 0 || e->type != GDK_TOUCHPAD_PINCH) { + /* FALSE for everything else, so the handlers connected to the specific + * signals above still see their events. */ + return FALSE; + } + GdkEventTouchpadPinch* pe = (GdkEventTouchpadPinch*) e; + if (pe->phase == GDK_TOUCHPAD_GESTURE_PHASE_BEGIN) { + w->pinchLastScale = pe->scale > 0 ? pe->scale : 1.0; + } else if (pe->phase == GDK_TOUCHPAD_GESTURE_PHASE_UPDATE) { + int x = (int) pe->x; + int y = (int) pe->y; + if (pe->scale > 0 && w->pinchLastScale > 0) { + double inc = pe->scale / w->pinchLastScale; + w->pinchLastScale = pe->scale; + if (inc != 1.0) { + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_PINCH, x, y, + (int) (inc * CN1_GESTURE_FIXED + 0.5)); + } + } + if (pe->angle_delta != 0.0) { + /* Radians already. GdkEventTouchpadPinch.angle_delta is documented as + * "the angle change in radians", and the Java side reads the packed value + * as radians too -- converting it as though it were degrees divided every + * rotation by 57.3, so a gesture the user could plainly feel barely moved + * anything on screen. */ + double rad = pe->angle_delta; + cn1LinuxPushWindowEvent(w->windowId, CN1_EVENT_ROTATE, x, y, + (int) (rad * CN1_GESTURE_FIXED + (rad >= 0 ? 0.5 : -0.5))); + } + } + return TRUE; +} + +/* ------------------------------------------------------- create / destroy */ + +typedef struct { + int slot; + int windowId; + const char* title; + int x; + int y; + int width; + int height; + int decorated; + int resizable; + int ownerSlot; + int positionSet; + int result; +} CN1DesktopCreateOp; + +static void cn1DesktopCreateOnMain(void* arg) { + CN1DesktopCreateOp* op = (CN1DesktopCreateOp*) arg; + CN1LinuxWindow* w = &cn1DesktopWindows[op->slot]; + + memset(w, 0, sizeof(*w)); + w->windowId = op->windowId; + /* memset above zeroed it, and a zero baseline would divide the first pinch + * update by nothing. */ + w->pinchLastScale = 1.0; + pthread_mutex_init(&w->bufferLock, 0); + w->width = op->width > 0 ? op->width : 1; + w->height = op->height > 0 ? op->height : 1; + w->inUse = 1; + + w->window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + { + /* An owned window stays above its owner and is minimized with it, which is + * what setOwnerWindow() promises; the transient parent is how GTK expresses + * that, and it is also what scopes gtk_window_set_modal to the right window. + * Falling back to the main window keeps a window opened from the main form + * above it, which is what a user expects of a tool window. */ + /* ownerSlot: >= 0 another Codename One window, -2 the application's main + * window, anything else unowned -- an unowned window must not be silently + * made transient for the main one. */ + CN1LinuxWindow* owner = op->ownerSlot >= 0 ? slotAt(op->ownerSlot) : 0; + GtkWidget* ownerWidget = owner != 0 ? owner->window + : (op->ownerSlot == -2 ? cn1LinuxWindowWidget() : 0); + if (ownerWidget != 0) { + gtk_window_set_transient_for(GTK_WINDOW(w->window), GTK_WINDOW(ownerWidget)); + } + } + gtk_window_set_title(GTK_WINDOW(w->window), op->title != 0 ? op->title : ""); + /* The creation size is outer geometry too, and nothing is realized yet, so the + * default size is the requested figure and the chrome comes off it once the + * window manager has framed the window. */ + gtk_window_set_default_size(GTK_WINDOW(w->window), w->width, w->height); + w->requestedOuterW = w->width; + w->requestedOuterH = w->height; + w->outerSizePending = 1; + gtk_window_set_decorated(GTK_WINDOW(w->window), op->decorated ? TRUE : FALSE); + gtk_window_set_resizable(GTK_WINDOW(w->window), op->resizable ? TRUE : FALSE); + if (op->positionSet) { + /* Moved whatever the sign: a monitor left of or above the primary display + * has a negative origin and a restored window belongs there. */ + gtk_window_move(GTK_WINDOW(w->window), op->x, op->y); + } + + /* Same structure as the main window: a drawing area for the Codename One + * back buffer, with a GtkFixed above it hosting native peers so a browser or + * video widget can be positioned over the lightweight components. */ + w->overlay = gtk_overlay_new(); + w->drawingArea = gtk_drawing_area_new(); + w->fixed = gtk_fixed_new(); + gtk_widget_set_has_window(w->fixed, FALSE); + + gtk_container_add(GTK_CONTAINER(w->overlay), w->drawingArea); + gtk_overlay_add_overlay(GTK_OVERLAY(w->overlay), w->fixed); + gtk_container_add(GTK_CONTAINER(w->window), w->overlay); + + gtk_widget_add_events(w->drawingArea, + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK + | GDK_POINTER_MOTION_MASK | GDK_SCROLL_MASK + | GDK_SMOOTH_SCROLL_MASK + | GDK_TOUCHPAD_GESTURE_MASK | GDK_TOUCH_MASK); + + /* Every handler takes the window as its closure data, which is what makes + * routing free -- no lookup, no shared state. */ + g_signal_connect(w->drawingArea, "draw", G_CALLBACK(cn1DesktopOnDraw), w); + g_signal_connect(w->drawingArea, "configure-event", G_CALLBACK(cn1DesktopOnConfigure), w); + g_signal_connect(w->window, "configure-event", G_CALLBACK(cn1DesktopOnWindowConfigure), w); + g_signal_connect(w->drawingArea, "button-press-event", G_CALLBACK(cn1DesktopOnButton), w); + g_signal_connect(w->drawingArea, "button-release-event", G_CALLBACK(cn1DesktopOnButton), w); + g_signal_connect(w->drawingArea, "motion-notify-event", G_CALLBACK(cn1DesktopOnMotion), w); + g_signal_connect(w->drawingArea, "scroll-event", G_CALLBACK(cn1DesktopOnScroll), w); + g_signal_connect(w->drawingArea, "touch-event", G_CALLBACK(cn1DesktopOnTouch), w); + /* Touchpad gestures arrive through the generic "event" signal rather than one + * of their own, which is why this is connected separately. */ + g_signal_connect(w->drawingArea, "event", G_CALLBACK(cn1DesktopOnGenericEvent), w); + g_signal_connect(w->window, "key-press-event", G_CALLBACK(cn1DesktopOnKey), w); + g_signal_connect(w->window, "key-release-event", G_CALLBACK(cn1DesktopOnKey), w); + cn1LinuxWatchMonitors(); + g_signal_connect(w->window, "window-state-event", G_CALLBACK(cn1DesktopOnWindowState), w); + g_signal_connect(w->window, "delete-event", G_CALLBACK(cn1DesktopOnDelete), w); + g_signal_connect(w->window, "focus-in-event", G_CALLBACK(cn1DesktopOnFocus), w); + g_signal_connect(w->window, "focus-out-event", G_CALLBACK(cn1DesktopOnFocus), w); + + cn1DesktopResizeBuffer(w, w->width, w->height); + w->monitorIndex = cn1DesktopMonitorIndexFor(w->window); + op->result = 1; +} + +static void cn1DesktopDestroyOnMain(void* arg) { + int slot = *((int*) arg); + CN1LinuxWindow* w = slotAt(slot); + if (w == 0) { + return; + } + /* Under the lock for the same reason the swap is: GTK's draw handler blits + * from this surface, and this runs on the GTK thread only for the widget + * teardown below. */ + pthread_mutex_lock(&w->bufferLock); + if (w->g.cr) { + cairo_destroy(w->g.cr); + w->g.cr = 0; + } + if (w->g.surface) { + cairo_surface_destroy(w->g.surface); + w->g.surface = 0; + } + w->pendingResize = 0; + pthread_mutex_unlock(&w->bufferLock); + if (w->window != 0) { + /* Peers hosted in this window -- a browser, a native editor -- are children of + * the overlay, and destroying the window destroys its children with it. The + * Java objects behind them outlive the window: BrowserComponent keeps its + * peer, and deinitialize() only hides the widget, so a later destroy() or a + * re-host would touch a widget that had already gone. Detached first, so what + * happens to them stays their owner's decision. + * + * Each owner holds its own reference, so removing them here cannot finalize + * them; without that this would swap a destroyed child for a freed one. */ + if (w->fixed != 0) { + GList* hosted = gtk_container_get_children(GTK_CONTAINER(w->fixed)); + GList* each; + for (each = hosted; each != 0; each = each->next) { + gtk_container_remove(GTK_CONTAINER(w->fixed), GTK_WIDGET(each->data)); + } + g_list_free(hosted); + } + gtk_widget_destroy(w->window); + w->window = 0; + } + w->inUse = 0; + w->windowId = 0; + pthread_mutex_destroy(&w->bufferLock); +} + +typedef struct { + int slot; + int a; + int b; + int c; + int d; + const char* text; + int out[4]; +} CN1DesktopOp; + +static void cn1DesktopShowOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + CN1LinuxWindow* w = slotAt(op->slot); + if (w != 0) { + if (op->a) { + gtk_widget_show_all(w->window); + } else { + gtk_widget_hide(w->window); + } + } +} + +static void cn1DesktopTitleOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + CN1LinuxWindow* w = slotAt(op->slot); + if (w != 0) { + gtk_window_set_title(GTK_WINDOW(w->window), op->text != 0 ? op->text : ""); + } +} + +/* Thickness of the window-manager chrome: the frame extents minus the client + * area. Answers 0 when that difference is not knowable yet, so callers can defer + * rather than silently apply a zero correction. + * + * Window.setWindowBounds() defines its dimensions as native geometry including + * chrome, but gtk_window_resize() and gtk_window_get_size() both speak the client + * area, so every request and every readback has to be converted. */ +/* The decision the insets turn on, separated from the GTK sampling so it can be + * exercised directly -- there is no window manager under Xvfb, so CI cannot reach + * the interesting cases through GTK. Returns non-zero when the chrome is knowable, + * and only then writes the outputs. */ +int cn1DesktopChromeFromExtents(int frameW, int frameH, int clientW, int clientH, + int decorated, int* chromeW, int* chromeH) { + *chromeW = 0; + *chromeH = 0; + if (frameW <= 0 || frameH <= 0 || clientW <= 0 || clientH <= 0) { + return 0; + } + if (frameW < clientW || frameH < clientH) { + /* A frame smaller than its own contents means the extents are stale. */ + return 0; + } + if (frameW == clientW && frameH == clientH && decorated) { + /* Realized, but the window manager has not attached a frame yet: the extents + * are still the client rectangle. Indistinguishable from a genuinely + * borderless window except by asking whether decorations were requested, + * which is why this tests that rather than the numbers. Answering zero here + * would bake "no chrome" in permanently for a window that is about to get + * some; deferring costs nothing, because a window that never gets a frame -- + * running with no window manager, as CI does -- has a client area that + * genuinely is its outer size. */ + return 0; + } + *chromeW = frameW - clientW; + *chromeH = frameH - clientH; + return 1; +} + +static int cn1DesktopChromeInsets(GtkWidget* window, int* chromeW, int* chromeH) { + GdkWindow* gdkWindow; + GdkRectangle frame; + int clientW = 0; + int clientH = 0; + *chromeW = 0; + *chromeH = 0; + if (window == 0 || !gtk_widget_get_realized(window)) { + return 0; + } + gdkWindow = gtk_widget_get_window(window); + if (gdkWindow == 0) { + return 0; + } + gdk_window_get_frame_extents(gdkWindow, &frame); + gtk_window_get_size(GTK_WINDOW(window), &clientW, &clientH); + return cn1DesktopChromeFromExtents(frame.width, frame.height, clientW, clientH, + gtk_window_get_decorated(GTK_WINDOW(window)) ? 1 : 0, chromeW, chromeH); +} + +/* Applies a pending outer size once the chrome is measurable. Called both where + * the request arrives and from the window's configure-event, since which of the + * two can actually do the work depends on how far the window has been realized. */ +static void cn1DesktopApplyPendingOuterSize(CN1LinuxWindow* w) { + int chromeW; + int chromeH; + int clientW; + int clientH; + if (w == 0 || w->window == 0 || !w->outerSizePending) { + return; + } + if (!cn1DesktopChromeInsets(w->window, &chromeW, &chromeH)) { + return; + } + clientW = w->requestedOuterW - chromeW; + clientH = w->requestedOuterH - chromeH; + if (clientW < 1) { + clientW = 1; + } + if (clientH < 1) { + clientH = 1; + } + /* Cleared before the resize, not after: the resize re-enters this through + * configure-event, and a still-pending flag there would resize forever. */ + w->outerSizePending = 0; + gtk_window_resize(GTK_WINDOW(w->window), clientW, clientH); +} + +static void cn1DesktopBoundsOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + CN1LinuxWindow* w = slotAt(op->slot); + if (w != 0) { + gtk_window_move(GTK_WINDOW(w->window), op->a, op->b); + w->requestedOuterW = op->c > 0 ? op->c : 1; + w->requestedOuterH = op->d > 0 ? op->d : 1; + w->outerSizePending = 1; + cn1DesktopApplyPendingOuterSize(w); + if (w->outerSizePending) { + /* Chrome not measurable yet. Resize to the requested figure so the + * window is not left at its old size, and let configure-event apply the + * correction as soon as the frame exists. */ + gtk_window_resize(GTK_WINDOW(w->window), w->requestedOuterW, w->requestedOuterH); + } + } +} + +static void cn1DesktopGetBoundsOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + CN1LinuxWindow* w = slotAt(op->slot); + if (w != 0) { + int chromeW; + int chromeH; + gtk_window_get_position(GTK_WINDOW(w->window), &op->out[0], &op->out[1]); + gtk_window_get_size(GTK_WINDOW(w->window), &op->out[2], &op->out[3]); + if (cn1DesktopChromeInsets(w->window, &chromeW, &chromeH)) { + /* Report what setWindowBounds accepts: the outer rectangle. Without + * this the round trip disagreed with the request by exactly the chrome. */ + op->out[2] += chromeW; + op->out[3] += chromeH; + } else if (w->outerSizePending) { + /* Asked before the frame existed. The requested figure is a better + * answer than the uncorrected client size, and it is what the window + * is about to become. */ + op->out[2] = w->requestedOuterW; + op->out[3] = w->requestedOuterH; + } + } +} + +/* The application's own GTK window. cn1LinuxWindowWidget() is the main window, the + * one a Form renders into. */ +static void cn1MainWindowGetBoundsOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + GtkWidget* main = cn1LinuxWindowWidget(); + op->a = 0; + if (main != 0) { + gtk_window_get_position(GTK_WINDOW(main), &op->out[0], &op->out[1]); + gtk_window_get_size(GTK_WINDOW(main), &op->out[2], &op->out[3]); + op->a = 1; + } +} + +static void cn1DesktopFlagOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + CN1LinuxWindow* w = slotAt(op->slot); + if (w == 0) { + return; + } + /* a selects the flag, b its value */ + switch (op->a) { + case 0: + gtk_window_set_resizable(GTK_WINDOW(w->window), op->b ? TRUE : FALSE); + break; + case 1: + gtk_window_set_keep_above(GTK_WINDOW(w->window), op->b ? TRUE : FALSE); + break; + case 2: + gtk_window_set_modal(GTK_WINDOW(w->window), op->b ? TRUE : FALSE); + break; + case 3: + gtk_window_set_decorated(GTK_WINDOW(w->window), op->b ? TRUE : FALSE); + break; + case 4: + /* GTK_WINDOW_TYPE_HINT_UTILITY is what keeps a palette off the task bar + * and gives it the lighter frame a tool window is expected to have. */ + gtk_window_set_type_hint(GTK_WINDOW(w->window), + op->b ? GDK_WINDOW_TYPE_HINT_UTILITY : GDK_WINDOW_TYPE_HINT_NORMAL); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(w->window), op->b ? TRUE : FALSE); + break; + case 5: + /* Insensitive is GTK's "blocked": the window is still on screen but the + * whole frame, title bar included, stops accepting input. */ + gtk_widget_set_sensitive(w->window, op->b ? TRUE : FALSE); + break; + default: + break; + } +} + +static void cn1DesktopStateOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + CN1LinuxWindow* w = slotAt(op->slot); + if (w == 0) { + return; + } + if (op->a == 1) { + gtk_window_iconify(GTK_WINDOW(w->window)); + } else if (op->a == 2) { + GdkWindow* gw = gtk_widget_get_window(w->window); + if (gw != 0 && (gdk_window_get_state(gw) & GDK_WINDOW_STATE_MAXIMIZED)) { + gtk_window_unmaximize(GTK_WINDOW(w->window)); + } else { + gtk_window_maximize(GTK_WINDOW(w->window)); + } + } else if (op->a == 3) { + gtk_window_present(GTK_WINDOW(w->window)); + } else { + gtk_window_deiconify(GTK_WINDOW(w->window)); + } +} + +static void cn1DesktopFlushOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + CN1LinuxWindow* w = slotAt(op->slot); + if (w != 0 && w->drawingArea != 0) { + gtk_widget_queue_draw_area(w->drawingArea, op->a, op->b, op->c, op->d); + } +} + +/* ------------------------------------------------------ LinuxNative bridge */ + +JAVA_INT com_codename1_impl_linux_LinuxNative_desktopWindowCreate___int_java_lang_String_int_int_int_int_boolean_boolean_int_boolean_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT windowId, JAVA_OBJECT title, + JAVA_INT x, JAVA_INT y, JAVA_INT width, JAVA_INT height, + JAVA_BOOLEAN decorated, JAVA_BOOLEAN resizable, JAVA_INT ownerSlot, + JAVA_BOOLEAN positionSet) { + CN1DesktopCreateOp op; + char* utf8 = 0; + int slot = -1; + int iter; + for (iter = 0; iter < CN1_MAX_DESKTOP_WINDOWS; iter++) { + if (!cn1DesktopWindows[iter].inUse) { + slot = iter; + break; + } + } + if (slot < 0) { + return -1; + } + if (title != JAVA_NULL) { + utf8 = cn1LinuxJStrDup(threadStateData, title); + } + memset(&op, 0, sizeof(op)); + op.slot = slot; + op.ownerSlot = ownerSlot; + op.positionSet = positionSet == JAVA_TRUE ? 1 : 0; + op.windowId = windowId; + op.title = utf8 != 0 ? utf8 : ""; + op.x = x; + op.y = y; + op.width = width; + op.height = height; + op.decorated = decorated == JAVA_TRUE ? 1 : 0; + op.resizable = resizable == JAVA_TRUE ? 1 : 0; + cn1LinuxRunOnMainAndWait(cn1DesktopCreateOnMain, &op); + if (utf8 != 0) { + free(utf8); + } + return op.result ? slot : -1; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowDestroy___int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + int s = slot; + cn1LinuxRunOnMainAndWait(cn1DesktopDestroyOnMain, &s); +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowShow___int_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_BOOLEAN visible) { + CN1DesktopOp op; + memset(&op, 0, sizeof(op)); + op.slot = slot; + op.a = visible == JAVA_TRUE ? 1 : 0; + cn1LinuxRunOnMainAndWait(cn1DesktopShowOnMain, &op); +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowSetTitle___int_java_lang_String( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_OBJECT title) { + CN1DesktopOp op; + char* utf8 = title == JAVA_NULL ? 0 : cn1LinuxJStrDup(threadStateData, title); + memset(&op, 0, sizeof(op)); + op.slot = slot; + op.text = utf8; + cn1LinuxRunOnMainAndWait(cn1DesktopTitleOnMain, &op); + if (utf8 != 0) { + free(utf8); + } +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowSetBounds___int_int_int_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT x, JAVA_INT y, + JAVA_INT width, JAVA_INT height) { + CN1DesktopOp op; + memset(&op, 0, sizeof(op)); + op.slot = slot; + op.a = x; + op.b = y; + op.c = width; + op.d = height; + cn1LinuxRunOnMainAndWait(cn1DesktopBoundsOnMain, &op); +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowGetBounds___int_int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_OBJECT out) { + CN1DesktopOp op; + JAVA_INT* arr; + if (out == JAVA_NULL) { + return; + } + memset(&op, 0, sizeof(op)); + op.slot = slot; + cn1LinuxRunOnMainAndWait(cn1DesktopGetBoundsOnMain, &op); + arr = (JAVA_INT*) (*(JAVA_ARRAY) out).data; + if ((int) (*(JAVA_ARRAY) out).length >= 4) { + arr[0] = op.out[0]; + arr[1] = op.out[1]; + arr[2] = op.out[2]; + arr[3] = op.out[3]; + } +} + +/* + * The application's own top-level window in desktop coordinates. + * + * centerOn(Form) needs this: a Form lives in the main window, so centring a window + * over a Form means centring over that window. Without it the framework falls back + * to the monitor work area, which is a different place whenever the main window has + * been moved, resized or simply does not fill the screen. + */ +JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_mainWindowGetBounds___int_1ARRAY_R_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { + CN1DesktopOp op; + JAVA_INT* arr; + if (out == JAVA_NULL) { + return JAVA_FALSE; + } + if ((int) (*(JAVA_ARRAY) out).length < 4) { + return JAVA_FALSE; + } + memset(&op, 0, sizeof(op)); + cn1LinuxRunOnMainAndWait(cn1MainWindowGetBoundsOnMain, &op); + if (!op.a) { + return JAVA_FALSE; + } + arr = (JAVA_INT*) (*(JAVA_ARRAY) out).data; + arr[0] = op.out[0]; + arr[1] = op.out[1]; + arr[2] = op.out[2]; + arr[3] = op.out[3]; + return JAVA_TRUE; +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_desktopWindowGetWidth___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1LinuxWindow* w = slotAt(slot); + return w == 0 ? 0 : w->width; +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_desktopWindowGetHeight___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1LinuxWindow* w = slotAt(slot); + return w == 0 ? 0 : w->height; +} + +JAVA_LONG com_codename1_impl_linux_LinuxNative_desktopWindowGraphics___int_R_long( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + return (JAVA_LONG) (intptr_t) cn1LinuxDesktopGraphics(slot); +} + +/* Reads a desktop window's own back buffer back as PNG bytes. + * + * Window.capture() falls back to re-rendering the component tree when a port + * cannot read its window back, which produces the content the window *should* + * be showing rather than the pixels it actually has -- so the windowed + * screenshot goldens could not tell a correct window from one whose raster and + * hierarchy disagree. This is the real readback. + * + * Taken under bufferLock for the same reason the GTK draw handler holds it: the + * drawing thread swaps the surface on a resize, and reading one that is being + * destroyed underneath is a use-after-free. Pending resizes are applied first + * through cn1LinuxDesktopGraphics so the capture is the size the window + * currently is, not the size it was before the last configure. */ +JAVA_OBJECT com_codename1_impl_linux_LinuxNative_captureDesktopWindowToPngBytes___int_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1LinuxWindow* w; + unsigned char* data = 0; + int len = 0; + int ok = 0; + JAVA_OBJECT arr; + cn1LinuxDesktopGraphics(slot); + w = slotAt(slot); + if (w == 0) { + return JAVA_NULL; + } + pthread_mutex_lock(&w->bufferLock); + if (w->g.surface != 0) { + ok = cn1LinuxSurfaceToPng(w->g.surface, &data, &len); + } + pthread_mutex_unlock(&w->bufferLock); + if (!ok) { + return JAVA_NULL; + } + arr = cn1LinuxNewByteArray(threadStateData, data, len); + free(data); + return arr; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowFlush___int_int_int_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT x, JAVA_INT y, + JAVA_INT width, JAVA_INT height) { + CN1DesktopOp op; + memset(&op, 0, sizeof(op)); + op.slot = slot; + op.a = x; + op.b = y; + op.c = width > 0 ? width : 1; + op.d = height > 0 ? height : 1; + cn1LinuxRunOnMainAndWait(cn1DesktopFlushOnMain, &op); +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowSetFlag___int_int_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT flag, JAVA_BOOLEAN value) { + CN1DesktopOp op; + memset(&op, 0, sizeof(op)); + op.slot = slot; + op.a = flag; + op.b = value == JAVA_TRUE ? 1 : 0; + cn1LinuxRunOnMainAndWait(cn1DesktopFlagOnMain, &op); +} + +static void cn1DesktopMinSizeOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + CN1LinuxWindow* w = slotAt(op->slot); + GdkGeometry geom; + if (w == 0) { + return; + } + memset(&geom, 0, sizeof(geom)); + geom.min_width = op->a; + geom.min_height = op->b; + /* Applied to the whole frame, which is what a native minimum means. Zero + * clears the constraint. */ + gtk_window_set_geometry_hints(GTK_WINDOW(w->window), 0, &geom, + (op->a > 0 && op->b > 0) ? GDK_HINT_MIN_SIZE : 0); +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowSetMinimumSize___int_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT width, JAVA_INT height) { + CN1DesktopOp op; + memset(&op, 0, sizeof(op)); + op.slot = slot; + op.a = width; + op.b = height; + cn1LinuxRunOnMainAndWait(cn1DesktopMinSizeOnMain, &op); +} + +static void cn1MainWindowSensitiveOnMain(void* arg) { + CN1DesktopOp* op = (CN1DesktopOp*) arg; + GtkWidget* main = cn1LinuxWindowWidget(); + if (main != 0) { + gtk_widget_set_sensitive(main, op->a ? TRUE : FALSE); + } +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_mainWindowSetSensitive___boolean( + CODENAME_ONE_THREAD_STATE, JAVA_BOOLEAN sensitive) { + CN1DesktopOp op; + memset(&op, 0, sizeof(op)); + op.a = sensitive == JAVA_TRUE ? 1 : 0; + cn1LinuxRunOnMainAndWait(cn1MainWindowSensitiveOnMain, &op); +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_desktopWindowSetState___int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT state) { + CN1DesktopOp op; + memset(&op, 0, sizeof(op)); + op.slot = slot; + op.a = state; + cn1LinuxRunOnMainAndWait(cn1DesktopStateOnMain, &op); +} + +/* ---- monitors ---- + * + * GDK is not thread safe and these are called from the Codename One event + * dispatch thread, so every one of them marshals onto the GTK main thread the + * same way the window operations above do. Reading the display directly from the + * EDT raced GTK's own use of it. + */ + +typedef struct { + int monitor; + int workArea; + int result; + int x; + int y; + int width; + int height; +} CN1MonitorOp; + +static void cn1MonitorCountOnMain(void* arg) { + CN1MonitorOp* op = (CN1MonitorOp*) arg; + GdkDisplay* display = gdk_display_get_default(); + int n; + /* Idempotent, and this is the first monitor call any application makes. */ + cn1LinuxWatchMonitors(); + op->result = 1; + if (display == 0) { + return; + } + n = gdk_display_get_n_monitors(display); + op->result = n > 0 ? n : 1; +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_monitorCount___R_int(CODENAME_ONE_THREAD_STATE) { + CN1MonitorOp op; + memset(&op, 0, sizeof(op)); + cn1LinuxRunOnMainAndWait(cn1MonitorCountOnMain, &op); + return op.result; +} + +static void cn1PrimaryMonitorOnMain(void* arg) { + CN1MonitorOp* op = (CN1MonitorOp*) arg; + GdkDisplay* display = gdk_display_get_default(); + GdkMonitor* primary; + int count; + int iter; + op->result = 0; + if (display == 0) { + return; + } + primary = gdk_display_get_primary_monitor(display); + count = gdk_display_get_n_monitors(display); + for (iter = 0; iter < count; iter++) { + if (gdk_display_get_monitor(display, iter) == primary) { + op->result = iter; + return; + } + } +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_primaryMonitor___R_int(CODENAME_ONE_THREAD_STATE) { + CN1MonitorOp op; + memset(&op, 0, sizeof(op)); + cn1LinuxRunOnMainAndWait(cn1PrimaryMonitorOnMain, &op); + return op.result; +} + +static void cn1MonitorBoundsOnMain(void* arg) { + CN1MonitorOp* op = (CN1MonitorOp*) arg; + GdkDisplay* display = gdk_display_get_default(); + GdkMonitor* mon; + GdkRectangle r; + op->result = 0; + if (display == 0) { + return; + } + mon = gdk_display_get_monitor(display, op->monitor); + if (mon == 0) { + mon = gdk_display_get_primary_monitor(display); + } + if (mon == 0) { + return; + } + if (op->workArea) { + gdk_monitor_get_workarea(mon, &r); + } else { + gdk_monitor_get_geometry(mon, &r); + } + op->x = r.x; + op->y = r.y; + op->width = r.width; + op->height = r.height; + op->result = 1; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_monitorBounds___int_boolean_int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_INT monitor, JAVA_BOOLEAN workArea, JAVA_OBJECT out) { + CN1MonitorOp op; + JAVA_INT* arr; + if (out == JAVA_NULL) { + return; + } + memset(&op, 0, sizeof(op)); + op.monitor = monitor; + op.workArea = workArea == JAVA_TRUE ? 1 : 0; + cn1LinuxRunOnMainAndWait(cn1MonitorBoundsOnMain, &op); + if (!op.result) { + return; + } + arr = (JAVA_INT*) (*(JAVA_ARRAY) out).data; + if ((int) (*(JAVA_ARRAY) out).length >= 4) { + arr[0] = op.x; + arr[1] = op.y; + arr[2] = op.width; + arr[3] = op.height; + } +} + +static void cn1MonitorScaleOnMain(void* arg) { + CN1MonitorOp* op = (CN1MonitorOp*) arg; + GdkDisplay* display = gdk_display_get_default(); + GdkMonitor* mon; + op->result = 1; + if (display == 0) { + return; + } + mon = gdk_display_get_monitor(display, op->monitor); + if (mon == 0) { + return; + } + op->result = gdk_monitor_get_scale_factor(mon); +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_monitorScale___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT monitor) { + CN1MonitorOp op; + memset(&op, 0, sizeof(op)); + op.monitor = monitor; + cn1LinuxRunOnMainAndWait(cn1MonitorScaleOnMain, &op); + return op.result; +} + +static void cn1MonitorDpiOnMain(void* arg) { + CN1MonitorOp* op = (CN1MonitorOp*) arg; + GdkDisplay* display = gdk_display_get_default(); + GdkMonitor* mon; + GdkRectangle r; + int widthMm; + int scale; + op->result = 96; + if (display == 0) { + return; + } + mon = gdk_display_get_monitor(display, op->monitor); + if (mon == 0) { + return; + } + gdk_monitor_get_geometry(mon, &r); + widthMm = gdk_monitor_get_width_mm(mon); + /* gdk_monitor_get_geometry reports application (logical) pixels while the + * physical width is millimetres of glass, so dividing one by the other on a + * scaled monitor understates the density by exactly the scale factor -- a 2x + * HiDPI panel came out at roughly half its real DPI, which is enough to have + * Monitor.getDotsPerInch() lie and getMonitorDensity() classify it as a low + * density display. */ + scale = gdk_monitor_get_scale_factor(mon); + if (scale < 1) { + scale = 1; + } + if (widthMm <= 0 || r.width <= 0) { + return; + } + op->result = (int) ((r.width * (double) scale * 25.4) / widthMm + 0.5); +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_monitorDpi___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT monitor) { + CN1MonitorOp op; + memset(&op, 0, sizeof(op)); + op.monitor = monitor; + cn1LinuxRunOnMainAndWait(cn1MonitorDpiOnMain, &op); + return op.result; +} + + +/* Recomputed on the GTK thread rather than answered from w->monitorIndex. That + * cached ordinal is only refreshed by configure-event, and a monitor added or + * removed elsewhere on the desktop renumbers GDK's list without moving this window + * -- so the cache kept pointing at whatever now occupies that index, and the window + * reported another display's bounds, scale and DPI indefinitely. */ +static void cn1MonitorForWindowOnMain(void* arg) { + CN1MonitorOp* op = (CN1MonitorOp*) arg; + CN1LinuxWindow* w = slotAt(op->monitor); + if (w == 0 || w->window == 0) { + op->result = 0; + return; + } + op->result = cn1DesktopMonitorIndexFor(w->window); + /* Kept in step so the change notification the configure path sends still + * compares against something current. */ + w->monitorIndex = op->result; +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_monitorForWindow___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1MonitorOp op; + memset(&op, 0, sizeof(op)); + op.monitor = slot; + cn1LinuxRunOnMainAndWait(cn1MonitorForWindowOnMain, &op); + return op.result; +} + +/* The application's main window has no desktop-window slot, so its monitor cannot + * be asked for through monitorForWindow. Without this, everything positioned + * against the main form reported monitor 0 even after the application had been + * dragged to a second display. */ +static void cn1MonitorForMainWindowOnMain(void* arg) { + CN1MonitorOp* op = (CN1MonitorOp*) arg; + GtkWidget* main = cn1LinuxWindowWidget(); + op->result = main == 0 ? 0 : cn1DesktopMonitorIndexFor(main); +} + +JAVA_INT com_codename1_impl_linux_LinuxNative_monitorForMainWindow___R_int( + CODENAME_ONE_THREAD_STATE) { + /* Marshalled like every other monitor native. Calling GDK straight from the + * event dispatch thread, which is what this did when I added it, is exactly the + * thread-safety violation the rest of this file goes out of its way to avoid. */ + CN1MonitorOp op; + memset(&op, 0, sizeof(op)); + cn1LinuxRunOnMainAndWait(cn1MonitorForMainWindowOnMain, &op); + return op.result; +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_edit.c b/Ports/LinuxPort/nativeSources/cn1_linux_edit.c index 7f0602f3e5f..25b73c87035 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_edit.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_edit.c @@ -45,6 +45,9 @@ typedef struct { int singleLine; volatile int done; char* text; /* latest committed/changed text (heap) */ + /* The window whose overlay hosts this editor, so teardown removes it from the + * same place it was added. */ + int slot; pthread_mutex_t lock; } CN1Edit; @@ -56,6 +59,8 @@ typedef struct { int maxSize; CN1Font* font; int fg, bg, align; + /* Which window's overlay the editor belongs in. */ + int slot; CN1Edit* result; } CN1EditReq; @@ -159,12 +164,18 @@ static void cn1EditCreateOnMain(void* p) { g_signal_connect(tv, "focus-out-event", G_CALLBACK(cn1EditFocusOut), e); } - cn1LinuxOverlayAdd(e->container, req->x, req->y, req->w, req->h); + e->slot = req->slot; + /* A reference of our own, held for the life of the CN1Edit, for the same reason + * the browser takes one: the overlay's reference disappears with the window that + * hosts the editor, and Java can still close or re-host it afterwards. */ + g_object_ref_sink(e->container); + + cn1LinuxOverlayAdd(req->slot, e->container, req->x, req->y, req->w, req->h); gtk_widget_grab_focus(e->container); req->result = e; } -JAVA_LONG com_codename1_impl_linux_LinuxNative_editStringAt___int_int_int_int_java_lang_String_boolean_int_long_int_int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h, JAVA_OBJECT text, JAVA_BOOLEAN singleLine, JAVA_INT maxSize, JAVA_LONG fontPeer, JAVA_INT fgColor, JAVA_INT bgColor, JAVA_INT align) { +JAVA_LONG com_codename1_impl_linux_LinuxNative_editStringAt___int_int_int_int_java_lang_String_boolean_int_long_int_int_int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h, JAVA_OBJECT text, JAVA_BOOLEAN singleLine, JAVA_INT maxSize, JAVA_LONG fontPeer, JAVA_INT fgColor, JAVA_INT bgColor, JAVA_INT align, JAVA_INT slot) { CN1EditReq req; if (cn1LinuxWindowWidget() == 0) { return 0; /* headless: no native edit */ @@ -180,6 +191,7 @@ JAVA_LONG com_codename1_impl_linux_LinuxNative_editStringAt___int_int_int_int_ja req.fg = fgColor; req.bg = bgColor; req.align = align; + req.slot = slot; req.result = 0; cn1LinuxRunOnMainAndWait(cn1EditCreateOnMain, &req); return (JAVA_LONG) (intptr_t) req.result; @@ -205,8 +217,10 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_editGetText___long_R_java_lang_ static void cn1EditCloseOnMain(void* p) { CN1Edit* e = (CN1Edit*) p; if (e->container) { - cn1LinuxOverlayRemove(e->container); + cn1LinuxOverlayRemove(e->slot, e->container); gtk_widget_destroy(e->container); + /* The reference taken at creation, dropped last. */ + g_object_unref(e->container); e->container = 0; } } diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h b/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h index f26bd2d0d43..18db2c4e617 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h @@ -119,13 +119,24 @@ int cn1LinuxSurfaceToPng(cairo_surface_t* surface, unsigned char** outData, int* * sits in a pass-through GtkOverlay over the Cairo drawing area; these place / * move / remove a native widget tracking a lightweight PeerComponent. Must run * on the GTK main thread. */ -void cn1LinuxOverlayAdd(GtkWidget* w, int x, int y, int width, int height); -void cn1LinuxOverlayMove(GtkWidget* w, int x, int y, int width, int height); -void cn1LinuxOverlayRemove(GtkWidget* w); +/* Slot of the application's main window, for the overlay calls below. A secondary + * desktop window passes its own slot so its peers land in its overlay. */ +#define CN1_MAIN_WINDOW_SLOT (-1) + +void cn1LinuxOverlayAdd(int slot, GtkWidget* w, int x, int y, int width, int height); +void cn1LinuxOverlayMove(int slot, GtkWidget* w, int x, int y, int width, int height); +void cn1LinuxOverlayRemove(int slot, GtkWidget* w); /* The top-level GtkWindow (NULL in headless mode). */ GtkWidget* cn1LinuxWindowWidget(void); +/* Additional desktop windows (cn1_linux_desktopwindow.c). A secondary window + * carries its own GtkWindow, drawing area, peer overlay and cairo back buffer; + * the main window's statics are untouched. */ +CN1Graphics* cn1LinuxDesktopGraphics(int slot); +GtkWidget* cn1LinuxDesktopWidget(int slot); +GtkWidget* cn1LinuxDesktopFixed(int slot); + /* Runs fn(arg) on the GTK main loop and blocks the caller until done (inline in * headless mode). For GTK calls the EDT must not make directly. */ void cn1LinuxRunOnMainAndWait(void (*fn)(void*), void* arg); diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_peer.c b/Ports/LinuxPort/nativeSources/cn1_linux_peer.c index 467234fd9b8..7aba269c0f0 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_peer.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_peer.c @@ -36,30 +36,35 @@ extern JAVA_OBJECT allocArray(CODENAME_ONE_THREAD_STATE, int length, struct clazz* type, int primitiveSize, int dim); extern struct clazz class_array1__JAVA_INT; -typedef struct { GtkWidget* w; int x, y, width, height; int out[2]; JAVA_INT* argb; } CN1PeerOp; +/* slot identifies the window the peer belongs to; CN1_MAIN_WINDOW_SLOT is the + * application's own window. Without it every peer landed in the main window's + * overlay regardless of which Window hosted it. */ +typedef struct { int slot; GtkWidget* w; int x, y, width, height; int out[2]; JAVA_INT* argb; } CN1PeerOp; static void cn1PeerInitOnMain(void* p) { CN1PeerOp* op = (CN1PeerOp*) p; - cn1LinuxOverlayAdd(op->w, op->x, op->y, op->width, op->height); + cn1LinuxOverlayAdd(op->slot, op->w, op->x, op->y, op->width, op->height); } -JAVA_VOID com_codename1_impl_linux_LinuxNative_peerInitialized___long_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h) { +JAVA_VOID com_codename1_impl_linux_LinuxNative_peerInitialized___long_int_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h, JAVA_INT slot) { CN1PeerOp op; op.w = (GtkWidget*) (intptr_t) peer; if (!op.w) { return; } + op.slot = slot; op.x = x; op.y = y; op.width = w; op.height = h; cn1LinuxRunOnMainAndWait(cn1PeerInitOnMain, &op); } static void cn1PeerBoundsOnMain(void* p) { CN1PeerOp* op = (CN1PeerOp*) p; - cn1LinuxOverlayMove(op->w, op->x, op->y, op->width, op->height); + cn1LinuxOverlayMove(op->slot, op->w, op->x, op->y, op->width, op->height); } -JAVA_VOID com_codename1_impl_linux_LinuxNative_peerSetBounds___long_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h) { +JAVA_VOID com_codename1_impl_linux_LinuxNative_peerSetBounds___long_int_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h, JAVA_INT slot) { CN1PeerOp op; op.w = (GtkWidget*) (intptr_t) peer; if (!op.w) { return; } + op.slot = slot; op.x = x; op.y = y; op.width = w; op.height = h; cn1LinuxRunOnMainAndWait(cn1PeerBoundsOnMain, &op); } @@ -83,13 +88,14 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_peerSetVisible___long_boolean(COD static void cn1PeerDeinitOnMain(void* p) { CN1PeerOp* op = (CN1PeerOp*) p; - cn1LinuxOverlayRemove(op->w); /* the app still owns the widget's lifetime */ + cn1LinuxOverlayRemove(op->slot, op->w); /* the app still owns the widget's lifetime */ } -JAVA_VOID com_codename1_impl_linux_LinuxNative_peerDeinitialized___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer) { +JAVA_VOID com_codename1_impl_linux_LinuxNative_peerDeinitialized___long_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT slot) { CN1PeerOp op; op.w = (GtkWidget*) (intptr_t) peer; if (!op.w) { return; } + op.slot = slot; cn1LinuxRunOnMainAndWait(cn1PeerDeinitOnMain, &op); } diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c index 198c129118e..f27ab162bb1 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c @@ -55,6 +55,9 @@ #define CN1_EVENT_RING 1024 typedef struct { int type, x, y, key; + /* Which window the event came from. Zero is the application's main window, + * which is every event this port produced before desktop windows existed. */ + int windowId; } CN1Event; static CN1Event cn1EventRing[CN1_EVENT_RING]; static int cn1EventHead = 0; @@ -62,13 +65,183 @@ static int cn1EventTail = 0; static pthread_mutex_t cn1EventLock = PTHREAD_MUTEX_INITIALIZER; void cn1LinuxPushEvent(int type, int x, int y, int keyCode) { + cn1LinuxPushWindowEvent(0, type, x, y, keyCode); +} + +/* Events the framework cannot reconstruct if they are lost, of which there are two + * kinds. + * + * Lifecycle: a lost hide leaves a window the framework believes is on screen, painting + * and animating until something else happens to it, and a lost close leaves it + * registered with no native window behind it. + * + * Terminations: a release ends something a press started. Lose it and the component + * the press went to stays in that state for good -- the key goes on repeating, the + * button stays down, the drag never finishes -- and the focus change that would + * otherwise cancel a held gesture is no use as a backstop if it is droppable too. + * + * Note the asymmetry with presses, which stay droppable: a release that arrives with + * no press behind it finds no recorded target and is discarded harmlessly, so when + * something has to go it must never be the release. */ +static int cn1LinuxIsProtectedEvent(int type) { + return type == CN1_EVENT_WINDOW_SHOWN || type == CN1_EVENT_WINDOW_HIDDEN + || type == CN1_EVENT_WINDOW_CLOSE + || type == CN1_EVENT_KEY_RELEASED + || type == CN1_EVENT_POINTER_RELEASED + || type == CN1_EVENT_WINDOW_FOCUS + || type == CN1_EVENT_SIZE_CHANGED; +} + +/* Visibility only. A close request is protected from eviction like any other + * lifecycle event, but it is not a state that a later one supersedes: the delete + * signal does not destroy the window, so a close that a subsequent minimize overwrote + * would take the close listener and the close operation with it. */ +static int cn1LinuxStateClass(int type) { + if (type == CN1_EVENT_WINDOW_SHOWN || type == CN1_EVENT_WINDOW_HIDDEN) { + return 1; + } + if (type == CN1_EVENT_SIZE_CHANGED) { + return 2; + } + return 0; +} + +/* Replaces a queued visibility event for the same window with this newer one. The + * latest state is the one that matters -- a hide followed by a show leaves the window + * shown -- so superseding costs nothing and needs no room. */ +static int cn1LinuxCoalesceLifecycleLocked(int windowId, int type, int x, int y, + int keyCode) { + int idx = cn1EventHead; + int newest = -1; + int cls = cn1LinuxStateClass(type); + if (cls == 0) { + return 0; + } + /* The *newest* match, not the first one found. A window can already have more than + * one transition queued -- a hide then a show -- and replacing the older of the two + * leaves the newer one as the last word, so the framework would end up believing a + * window that is natively hidden is on screen, and go on painting it. */ + while (idx != cn1EventTail) { + if (cn1EventRing[idx].windowId == windowId + && cn1LinuxStateClass(cn1EventRing[idx].type) == cls) { + newest = idx; + } + idx = (idx + 1) % CN1_EVENT_RING; + } + if (newest < 0) { + return 0; + } + cn1EventRing[newest].type = type; + cn1EventRing[newest].x = x; + cn1EventRing[newest].y = y; + cn1EventRing[newest].key = keyCode; + return 1; +} + +/* Removes the oldest droppable event, closing the gap. Used to make room for a + * protected one: advancing the head instead would evict whatever is oldest, and that + * can be a protected event itself -- which is the very thing being kept. */ +static void cn1LinuxRemoveAtLocked(int idx) { + int cur = idx; + int follow = (cur + 1) % CN1_EVENT_RING; + while (follow != cn1EventTail) { + cn1EventRing[cur] = cn1EventRing[follow]; + cur = follow; + follow = (follow + 1) % CN1_EVENT_RING; + } + cn1EventTail = cur; +} + +static int cn1LinuxEvictInputLocked(void) { + int idx = cn1EventHead; + while (idx != cn1EventTail) { + if (!cn1LinuxIsProtectedEvent(cn1EventRing[idx].type)) { + cn1LinuxRemoveAtLocked(idx); + return 1; + } + idx = (idx + 1) % CN1_EVENT_RING; + } + return 0; +} + +/* Last resort when the ring holds nothing but lifecycle events and so has no input to + * give up. A window that toggled visibility several times before the framework drained + * anything has more than one transition queued, and every one but its last is already + * superseded, so dropping the oldest of them frees a slot without changing what any + * window ends up as. Without this a close arriving for a *different* window has nowhere + * to go and is dropped, which is the one outcome this whole path exists to prevent. */ +static int cn1LinuxEvictSupersededVisibilityLocked(void) { + int idx = cn1EventHead; + while (idx != cn1EventTail) { + int cls = cn1LinuxStateClass(cn1EventRing[idx].type); + if (cls != 0) { + int scan = (idx + 1) % CN1_EVENT_RING; + while (scan != cn1EventTail) { + if (cn1EventRing[scan].windowId == cn1EventRing[idx].windowId + && cn1LinuxStateClass(cn1EventRing[scan].type) == cls) { + cn1LinuxRemoveAtLocked(idx); + return 1; + } + scan = (scan + 1) % CN1_EVENT_RING; + } + } + idx = (idx + 1) % CN1_EVENT_RING; + } + return 0; +} + +/* Last resort before giving up an entry outright: drop the oldest *termination*. + * + * When the queue cannot grow, the question is only which loss costs least, and the + * order is droppable input, then a state a later event already supersedes, then a + * termination, then a lifecycle event. A lost release latches one component; a lost + * close or hide loses a whole window -- the close operation never runs, or the + * framework goes on painting a window that is not on screen. So a queued close or + * visibility transition outranks any number of releases behind it. */ +static int cn1LinuxEvictOldestTerminationLocked(void) { + int idx = cn1EventHead; + while (idx != cn1EventTail) { + int t = cn1EventRing[idx].type; + if (t == CN1_EVENT_KEY_RELEASED || t == CN1_EVENT_POINTER_RELEASED + || t == CN1_EVENT_WINDOW_FOCUS) { + cn1LinuxRemoveAtLocked(idx); + return 1; + } + idx = (idx + 1) % CN1_EVENT_RING; + } + return 0; +} + +void cn1LinuxPushWindowEvent(int windowId, int type, int x, int y, int keyCode) { pthread_mutex_lock(&cn1EventLock); int next = (cn1EventTail + 1) % CN1_EVENT_RING; + if (next == cn1EventHead && cn1LinuxIsProtectedEvent(type)) { + /* Full, and this one must not be the casualty. Supersede this window's own + * queued transition if it has one, otherwise take the room from an input event, + * and failing that from a transition that a later one already supersedes. Never + * from a transition that is still some window's last word. */ + if (cn1LinuxCoalesceLifecycleLocked(windowId, type, x, y, keyCode)) { + pthread_mutex_unlock(&cn1EventLock); + return; + } + if (cn1LinuxEvictInputLocked() || cn1LinuxEvictSupersededVisibilityLocked() + || cn1LinuxEvictOldestTerminationLocked()) { + next = (cn1EventTail + 1) % CN1_EVENT_RING; + } else { + /* Nothing left but lifecycle events -- closes and visibility transitions + * for more distinct windows than the ring can hold, which needs more windows + * open than any application has. Giving up the oldest is all that remains, + * and the newer event at least describes the more recent state. */ + cn1EventHead = (cn1EventHead + 1) % CN1_EVENT_RING; + next = (cn1EventTail + 1) % CN1_EVENT_RING; + } + } if (next != cn1EventHead) { cn1EventRing[cn1EventTail].type = type; cn1EventRing[cn1EventTail].x = x; cn1EventRing[cn1EventTail].y = y; cn1EventRing[cn1EventTail].key = keyCode; + cn1EventRing[cn1EventTail].windowId = windowId; cn1EventTail = next; } pthread_mutex_unlock(&cn1EventLock); @@ -82,6 +255,7 @@ int cn1LinuxPopEvent(int* out) { out[1] = cn1EventRing[cn1EventHead].x; out[2] = cn1EventRing[cn1EventHead].y; out[3] = cn1EventRing[cn1EventHead].key; + out[4] = cn1EventRing[cn1EventHead].windowId; cn1EventHead = (cn1EventHead + 1) % CN1_EVENT_RING; has = 1; } @@ -97,6 +271,22 @@ static GtkWidget* cn1Overlay = 0; /* GtkOverlay: drawing area + native wid static GtkWidget* cn1Fixed = 0; /* GtkFixed overlay hosting positioned native peers */ static GtkWidget* cn1AccessibilityFixed = 0; /* transparent GTK/ATK semantic hierarchy */ static CN1Graphics cn1WindowG; /* the on-screen / headless back buffer */ +/* Back-buffer replacement is deferred to the drawing thread. GTK reports a resize + * on its own thread, while the event dispatch thread paints through cn1WindowG.cr + * for the whole frame -- destroying the context or surface underneath it is a use + * after free, not merely a torn frame. cn1OnConfigure records the new size and + * flushGraphics applies it between frames, the same shape the secondary desktop + * windows and the Windows port use. */ +static volatile int cn1PendingResize; +static int cn1PendingW; +static int cn1PendingH; +/* Held while GTK blits the surface and while the drawing thread swaps it: those + * are the two places one thread can destroy what the other is reading. */ +static pthread_mutex_t cn1BufferLock = PTHREAD_MUTEX_INITIALIZER; + +/* Applies a resize recorded by cn1OnConfigure. Must run on the drawing thread, + * between frames. */ +static void cn1ApplyPendingResize(void); static int cn1DisplayWidth = 800; static int cn1DisplayHeight = 600; static int cn1WindowOpen = 0; @@ -108,28 +298,44 @@ CN1Graphics* cn1LinuxWindowGraphics(void) { return &cn1WindowG; } +/* The GtkFixed a peer belongs in: a secondary desktop window's own overlay, or the + * main window's. CN1_MAIN_WINDOW_SLOT means the application's main window. + * + * Peers used to go into cn1Fixed unconditionally, so a BrowserComponent or native + * editor inside a Window appeared over the *main* window while the window it + * belonged to stayed empty. */ +static GtkWidget* cn1LinuxOverlayHost(int slot) { + if (slot == CN1_MAIN_WINDOW_SLOT) { + return cn1Fixed; + } + return cn1LinuxDesktopFixed(slot); +} + /* Native-peer overlay management (edit / browser / video / generic peers). All * must run on the GTK main thread (callers marshal via gdk_threads_add_idle). */ -void cn1LinuxOverlayAdd(GtkWidget* w, int x, int y, int width, int height) { - if (cn1Fixed == 0 || w == 0) { +void cn1LinuxOverlayAdd(int slot, GtkWidget* w, int x, int y, int width, int height) { + GtkWidget* host = cn1LinuxOverlayHost(slot); + if (host == 0 || w == 0) { return; } gtk_widget_set_size_request(w, width, height); - gtk_fixed_put(GTK_FIXED(cn1Fixed), w, x, y); + gtk_fixed_put(GTK_FIXED(host), w, x, y); gtk_widget_show_all(w); } -void cn1LinuxOverlayMove(GtkWidget* w, int x, int y, int width, int height) { - if (cn1Fixed == 0 || w == 0) { +void cn1LinuxOverlayMove(int slot, GtkWidget* w, int x, int y, int width, int height) { + GtkWidget* host = cn1LinuxOverlayHost(slot); + if (host == 0 || w == 0) { return; } gtk_widget_set_size_request(w, width, height); - gtk_fixed_move(GTK_FIXED(cn1Fixed), w, x, y); + gtk_fixed_move(GTK_FIXED(host), w, x, y); } -void cn1LinuxOverlayRemove(GtkWidget* w) { - if (cn1Fixed != 0 && w != 0 && gtk_widget_get_parent(w) == cn1Fixed) { - gtk_container_remove(GTK_CONTAINER(cn1Fixed), w); +void cn1LinuxOverlayRemove(int slot, GtkWidget* w) { + GtkWidget* host = cn1LinuxOverlayHost(slot); + if (host != 0 && w != 0 && gtk_widget_get_parent(w) == host) { + gtk_container_remove(GTK_CONTAINER(host), w); } } @@ -206,15 +412,33 @@ static void cn1ResizeBackBuffer(int w, int h) { cairo_matrix_init_identity(&cn1WindowG.transform); } +static void cn1ApplyPendingResize(void) { + if (!cn1PendingResize) { + return; + } + pthread_mutex_lock(&cn1BufferLock); + /* Re-checked under the lock: GTK can record another resize between the test + * above and here. */ + if (cn1PendingResize) { + cn1ResizeBackBuffer(cn1PendingW, cn1PendingH); + cn1PendingResize = 0; + } + pthread_mutex_unlock(&cn1BufferLock); +} + /* ------------------------------------------------------ GTK callbacks */ static gboolean cn1OnDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { (void) widget; (void) data; + /* Locked so the drawing thread cannot swap the surface out from under this + * blit. */ + pthread_mutex_lock(&cn1BufferLock); if (cn1WindowG.surface) { cairo_set_source_surface(cr, cn1WindowG.surface, 0, 0); cairo_paint(cr); } + pthread_mutex_unlock(&cn1BufferLock); return FALSE; } @@ -224,7 +448,13 @@ static gboolean cn1OnConfigure(GtkWidget* widget, GdkEventConfigure* e, gpointer if (e->width != cn1DisplayWidth || e->height != cn1DisplayHeight) { cn1DisplayWidth = e->width; cn1DisplayHeight = e->height; - cn1ResizeBackBuffer(cn1DisplayWidth, cn1DisplayHeight); + /* Recorded, not applied: this is the GTK thread and the event dispatch + * thread may be part way through a frame on the current buffer. */ + pthread_mutex_lock(&cn1BufferLock); + cn1PendingW = cn1DisplayWidth; + cn1PendingH = cn1DisplayHeight; + cn1PendingResize = 1; + pthread_mutex_unlock(&cn1BufferLock); cn1LinuxPushEvent(CN1_EVENT_SIZE_CHANGED, cn1DisplayWidth, cn1DisplayHeight, 0); } return FALSE; @@ -350,8 +580,8 @@ static gboolean cn1OnKey(GtkWidget* widget, GdkEventKey* e, gpointer data) { /* Touchpad pinch / rotate (GDK_TOUCHPAD_PINCH, libinput). scale is cumulative * relative to the gesture's BEGIN, so we forward the incremental multiplier; - * angle_delta is already a per-event delta in degrees, forwarded as incremental - * radians. These map to Display.fireMagnifyGesture / fireRotationGesture, the + * angle_delta is a per-event delta already in radians, which is what the Java side + * reads it as. These map to Display.fireMagnifyGesture / fireRotationGesture, the * same hooks the macOS trackpad drives. Delivered through the generic "event" * signal, so we return FALSE for anything else to leave other handlers intact. */ static double cn1PinchLastScale = 1.0; @@ -376,7 +606,12 @@ static gboolean cn1OnGenericEvent(GtkWidget* widget, GdkEvent* e, gpointer data) } } if (pe->angle_delta != 0.0) { - double rad = pe->angle_delta * G_PI / 180.0; + /* Radians already. GdkEventTouchpadPinch.angle_delta is documented as + * "the angle change in radians", and the Java side reads the packed value + * as radians too -- converting it as though it were degrees divided every + * rotation by 57.3, so a gesture the user could plainly feel barely moved + * anything on screen. */ + double rad = pe->angle_delta; cn1LinuxPushEvent(CN1_EVENT_ROTATE, x, y, (int) (rad * CN1_GESTURE_FIXED + (rad >= 0 ? 0.5 : -0.5))); } @@ -384,6 +619,25 @@ static gboolean cn1OnGenericEvent(GtkWidget* widget, GdkEvent* e, gpointer data) return TRUE; } +/* Converts a stream of fractional scroll notches into whole ones. + * + * Smooth scroll deltas are fractions of a notch, so they cannot be forwarded one + * for one: wheelUnits() on the Java side floors any sub-notch delta to a whole + * notch, and a touchpad emits deltas continuously. Whole notches are returned and + * the remainder is carried in *residue until it adds up. Truncation is toward + * zero, so the residue always keeps the sign of the travel. */ +int cn1LinuxTakeWholeNotches(double delta, double* residue) { + double total = *residue + delta; + int notches = (int) total; + *residue = total - notches; + return notches; +} + +/* The main window's smooth-scroll residue. Secondary windows keep their own in + * CN1LinuxWindow, so two windows cannot consume each other's partial notches. */ +static double cn1ScrollResidueX = 0; +static double cn1ScrollResidueY = 0; + static gboolean cn1OnScroll(GtkWidget* widget, GdkEventScroll* e, gpointer data) { (void) widget; (void) data; @@ -396,6 +650,31 @@ static gboolean cn1OnScroll(GtkWidget* widget, GdkEventScroll* e, gpointer data) cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, (int) e->x, (int) e->y, -120); } else if (e->direction == GDK_SCROLL_RIGHT) { cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, (int) e->x, (int) e->y, 120); + } else if (e->direction == GDK_SCROLL_SMOOTH) { + /* Two-finger touchpad scrolling arrives here and nowhere else: a touchpad + * reports no discrete steps, so GDK emits only a smooth event for it, and + * drops that event before delivery unless the widget selected + * GDK_SMOOTH_SCROLL_MASK. Without the mask and this branch the main window + * ignored touchpad scrolling entirely. Selecting the mask also makes GDK + * drop the pointer-emulated discrete events a real wheel produces, so the + * branches above and this one cannot both fire for one movement. */ + int vertical; + int horizontal; + if (e->is_stop) { + cn1ScrollResidueX = 0; + cn1ScrollResidueY = 0; + return TRUE; + } + vertical = cn1LinuxTakeWholeNotches(e->delta_y, &cn1ScrollResidueY); + horizontal = cn1LinuxTakeWholeNotches(e->delta_x, &cn1ScrollResidueX); + if (vertical != 0) { + /* delta_y grows downwards, which GDK_SCROLL_DOWN reports as negative + * units above. */ + cn1LinuxPushEvent(CN1_EVENT_MOUSE_WHEEL, (int) e->x, (int) e->y, -vertical * 120); + } + if (horizontal != 0) { + cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, (int) e->x, (int) e->y, horizontal * 120); + } } return TRUE; } @@ -590,7 +869,8 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in cn1DrawingArea = gtk_drawing_area_new(); gtk_widget_set_events(cn1DrawingArea, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_POINTER_MOTION_MASK | - GDK_KEY_PRESS_MASK | GDK_KEY_RELEASE_MASK | GDK_SCROLL_MASK | GDK_TOUCH_MASK | + GDK_KEY_PRESS_MASK | GDK_KEY_RELEASE_MASK | GDK_SCROLL_MASK | + GDK_SMOOTH_SCROLL_MASK | GDK_TOUCH_MASK | GDK_TOUCHPAD_GESTURE_MASK | GDK_STRUCTURE_MASK); gtk_widget_set_can_focus(cn1DrawingArea, TRUE); @@ -717,10 +997,15 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_flushGraphics___long_int_int_int_ r->x = x; r->y = y; r->w = width; r->h = height; gdk_threads_add_idle(cn1QueueDrawIdle, r); } + /* The frame is finished and the next has not started, which is the only point + * on this thread where replacing the buffer cannot pull it out from under a + * paint in progress. Cairo is immediate mode, so there is no frame-open hook + * to hang this on the way the Direct2D port does. */ + cn1ApplyPendingResize(); } JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_pollEvent___int_1ARRAY_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { - int scratch[4]; + int scratch[5]; if (out == JAVA_NULL) { return JAVA_FALSE; } @@ -732,6 +1017,9 @@ JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_pollEvent___int_1ARRAY_R_boole arr[1] = scratch[1]; arr[2] = scratch[2]; arr[3] = scratch[3]; + if (len >= 5) { + arr[4] = scratch[4]; + } return JAVA_TRUE; } } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java index cd175bb3f1d..c3414dd5aee 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxBrowserComponent.java @@ -55,7 +55,11 @@ class LinuxBrowserComponent extends PeerComponent { LinuxBrowserComponent(BrowserComponent browser) { super(null); this.browser = browser; - this.peer = LinuxNative.browserCreate(800, 600); + // A starting slot only. A BrowserComponent is routinely constructed while + // detached, and then this resolves to the main window; initComponent() re-hosts + // it once the component is in its real hierarchy. + this.peer = LinuxNative.browserCreate(800, 600, + LinuxWindowManager.slotForComponent(browser)); } long peer() { @@ -89,10 +93,20 @@ protected Dimension calcPreferredSize() { @Override protected void initComponent() { super.initComponent(); + // Resolved here rather than in the constructor: the constructor runs while the + // component is usually still detached, so the slot it picked was the main + // window's and the WebKit view stayed there permanently -- visible, and taking + // input, over the main window instead of the one the browser is in. + LinuxNative.browserSetHost(peer, LinuxWindowManager.slotForComponent(browser)); LinuxNative.browserSetBounds(peer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); LinuxNative.browserSetVisible(peer, true); - if (poller == null && getComponentForm() != null) { - poller = UITimer.timer(60, true, getComponentForm(), new Runnable() { + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so the poller never started there and poll() never drained + // the native LOAD, NAV and MSG events -- onLoad, navigation callbacks and + // JavaScript return callbacks simply never fired in a window. + com.codename1.ui.TopLevelContainer browserTop = getTopLevelContainer(); + if (poller == null && browserTop != null) { + poller = UITimer.timer(60, true, browserTop, new Runnable() { public void run() { poll(); } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxCameraImpl.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxCameraImpl.java index 7540713bd79..97ee3689f5d 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxCameraImpl.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxCameraImpl.java @@ -1,3 +1,25 @@ +/* + * 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.linux; import com.codename1.camera.CameraFacing; @@ -298,10 +320,13 @@ protected Dimension calcPreferredSize() { @Override protected void initComponent() { super.initComponent(); - if (poller == null && getComponentForm() != null) { + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so this timer never started for a peer hosted in one. + com.codename1.ui.TopLevelContainer peerTop = getTopLevelContainer(); + if (poller == null && peerTop != null) { int fps = Math.max(1, frameMaxFps); int periodMs = Math.max(33, 1000 / fps); - poller = UITimer.timer(periodMs, true, getComponentForm(), new Runnable() { + poller = UITimer.timer(periodMs, true, peerTop, new Runnable() { @Override public void run() { refresh(); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGLSurface.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGLSurface.java index 2f2d084609f..1b715e323f3 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGLSurface.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGLSurface.java @@ -6,6 +6,19 @@ * 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.linux; @@ -50,8 +63,11 @@ long getContextPeer() { void setContinuous(boolean continuous) { this.continuous = continuous; if (continuous) { - if (animationTimer == null && getComponentForm() != null) { - animationTimer = UITimer.timer(16, true, getComponentForm(), new Runnable() { + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so this timer never started for a peer hosted in one. + com.codename1.ui.TopLevelContainer peerTop = getTopLevelContainer(); + if (animationTimer == null && peerTop != null) { + animationTimer = UITimer.timer(16, true, peerTop, new Runnable() { public void run() { repaint(); } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGenericPeer.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGenericPeer.java index a93004e60f7..819b308e5af 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGenericPeer.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxGenericPeer.java @@ -1,3 +1,25 @@ +/* + * 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.linux; import com.codename1.ui.Display; @@ -32,7 +54,8 @@ long peer() { @Override protected void initComponent() { super.initComponent(); - LinuxNative.peerInitialized(peer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); + LinuxNative.peerInitialized(peer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight(), + LinuxWindowManager.slotForComponent(this)); } @Override @@ -41,14 +64,15 @@ protected void deinitialize() { if (img != null) { setPeerImage(img); } - LinuxNative.peerDeinitialized(peer); + LinuxNative.peerDeinitialized(peer, LinuxWindowManager.slotForComponent(this)); super.deinitialize(); } @Override protected void onPositionSizeChange() { super.onPositionSizeChange(); - LinuxNative.peerSetBounds(peer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); + LinuxNative.peerSetBounds(peer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight(), + LinuxWindowManager.slotForComponent(this)); } @Override diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 71f18a3d0d7..49f7a6e32ec 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -22,6 +22,7 @@ */ package com.codename1.impl.linux; +import com.codename1.ui.Desktop; import com.codename1.impl.CodenameOneImplementation; import com.codename1.impl.WebSocketImpl; import com.codename1.io.Util; @@ -76,6 +77,11 @@ */ public class LinuxImplementation extends CodenameOneImplementation { + /// Slot value meaning "the application's main window" for the native peer, + /// editor and browser hosts. Matches CN1_MAIN_WINDOW_SLOT in cn1_linux_gfx.h. + static final int MAIN_WINDOW_SLOT = -1; + + @Override public boolean isHighContrastEnabled() { return LinuxNative.isHighContrastEnabled(); @@ -105,6 +111,14 @@ public boolean isScreenReaderEnabled() { private static final int EVENT_PINCH = 10; private static final int EVENT_ROTATE = 11; private static final int EVENT_ACCESSIBILITY_ACTION = 12; + // Additional desktop windows. These always carry a non-zero window id. + private static final int EVENT_WINDOW_CLOSE = 13; + private static final int EVENT_WINDOW_FOCUS = 14; + private static final int EVENT_WINDOW_MONITOR = 15; + private static final int EVENT_WINDOW_SHOWN = 16; + private static final int EVENT_WINDOW_HIDDEN = 17; + private static final int EVENT_WINDOW_MOVED = 18; + private static final int EVENT_MONITORS_CHANGED = 19; // The native gesture events encode their float (incremental scale / radians) as // an int in 1/10000 units; see CN1_GESTURE_FIXED in cn1_linux.h. @@ -121,7 +135,7 @@ public boolean isScreenReaderEnabled() { private Long defaultFont; private L10NManager l10n; private com.codename1.ui.util.ImageIO imageIO; - private final int[] eventScratch = new int[4]; + private final int[] eventScratch = new int[5]; private final Map accessibilityActionTokens = new HashMap(); private final Map accessibilityActionTargets = new HashMap(); @@ -588,6 +602,19 @@ public com.codename1.contacts.Contact getContactById(String id) { return c; } + private LinuxWindowManager windowManager; + + /** + * @inheritDoc + */ + @Override + public com.codename1.impl.WindowManager getWindowManager() { + if (windowManager == null) { + windowManager = new LinuxWindowManager(); + } + return windowManager; + } + @Override public int getDisplayWidth() { return LinuxNative.getDisplayWidth(); @@ -762,47 +789,80 @@ private void drainInput() { int x = eventScratch[1]; int y = eventScratch[2]; int key = eventScratch[3]; + // Zero is the main window, which is every event this port produced + // before desktop windows existed. + int windowId = eventScratch[4]; switch (type) { + case EVENT_WINDOW_CLOSE: + Desktop.getInstance().windowCloseRequested(windowId); + break; + case EVENT_WINDOW_FOCUS: + Desktop.getInstance().windowFocusChanged(windowId, key != 0); + break; + case EVENT_WINDOW_MONITOR: + Desktop.getInstance().windowMonitorChanged(windowId); + break; + case EVENT_WINDOW_SHOWN: + Desktop.getInstance().windowShowNotify(windowId); + // The window manager keeps its own record of what is on screen to + // decide what an owner may take down and bring back. This change + // did not go through it, so it has to be told. + LinuxWindowManager.windowVisibilityChanged(windowId, true); + break; + case EVENT_WINDOW_HIDDEN: + Desktop.getInstance().windowHideNotify(windowId); + LinuxWindowManager.windowVisibilityChanged(windowId, false); + break; + case EVENT_WINDOW_MOVED: + Desktop.getInstance().windowMoved(windowId); + break; + case EVENT_MONITORS_CHANGED: + Desktop.getInstance().monitorsChanged(); + break; case EVENT_POINTER_PRESSED: markPointer(key); - pointerPressed(x, y); + windowPointerPressed(windowId, x, y); break; case EVENT_POINTER_RELEASED: markPointer(key); - pointerReleased(x, y); + windowPointerReleased(windowId, x, y); break; case EVENT_POINTER_DRAGGED: markPointer(key); - pointerDragged(x, y); + windowPointerDragged(windowId, x, y); break; case EVENT_KEY_PRESSED: - keyPressed(key); + windowKeyPressed(windowId, key); break; case EVENT_KEY_RELEASED: - keyReleased(key); + windowKeyReleased(windowId, key); break; case EVENT_SIZE_CHANGED: - sizeChanged(x, y); + if (windowId == 0) { + sizeChanged(x, y); + } else { + Desktop.getInstance().windowSizeChanged(windowId, x, y); + } break; case EVENT_MOUSE_WHEEL: // key carries the signed wheel delta (multiple of WHEEL_DELTA). // A forward (positive) notch reveals content above, i.e. drags // the finger down -> positive scrollY. Map through the shared // CodenameOneImplementation.pointerWheelMoved scroll gesture. - pointerWheelMoved(x, y, 0, wheelUnits(key)); + windowPointerWheelMoved(windowId, x, y, 0, wheelUnits(key), false, 0); break; case EVENT_MOUSE_HWHEEL: // A positive horizontal notch tilts right (scrolls content // left), i.e. drags the finger left -> negative scrollX. - pointerWheelMoved(x, y, -wheelUnits(key), 0); + windowPointerWheelMoved(windowId, x, y, -wheelUnits(key), 0, false, 0); break; case EVENT_PINCH: // key is the incremental scale multiplier in 1/10000 units. - Display.getInstance().fireMagnifyGesture(x, y, key / GESTURE_FIXED); + com.codename1.ui.Desktop.getInstance().windowMagnifyGesture(windowId, x, y, key / GESTURE_FIXED); break; case EVENT_ROTATE: // key is the incremental rotation in 1/10000 radians. - Display.getInstance().fireRotationGesture(x, y, key / GESTURE_FIXED); + com.codename1.ui.Desktop.getInstance().windowRotationGesture(windowId, x, y, key / GESTURE_FIXED); break; case EVENT_CLOSE: Display.getInstance().exitApplication(); @@ -2010,8 +2070,12 @@ public void editString(final Component cmp, int maxSize, int constraint, String fontPeer = ((Long) f.getNativeFont()).longValue(); } + // The editor goes into the overlay of the window the field lives in; without + // the slot it appeared over the main window while the window being typed into + // showed nothing. long peer = LinuxNative.editStringAt(x, y, w, h, text == null ? "" : text, - singleLine, maxSize, fontPeer, s.getFgColor(), s.getBgColor(), 0); + singleLine, maxSize, fontPeer, s.getFgColor(), s.getBgColor(), 0, + LinuxWindowManager.slotForComponent(cmp)); if (peer == 0) { // No native window (headless) -> nothing to edit; complete with the // existing text so a caller awaiting the callback still proceeds. @@ -2020,9 +2084,13 @@ public void editString(final Component cmp, int maxSize, int constraint, String } editPeer = peer; editCmp = cmp; - com.codename1.ui.Form form = cmp.getComponentForm(); - if (form != null) { - editPoller = com.codename1.ui.util.UITimer.timer(30, true, form, new Runnable() { + // The top level, not the Form: getComponentForm() is null inside a Window, so + // binding the poller to it meant no timer ran at all there -- the native + // control's text was never streamed back into the field and the edit never + // auto-committed. + com.codename1.ui.TopLevelContainer top = cmp.getTopLevelContainer(); + if (top != null) { + editPoller = com.codename1.ui.util.UITimer.timer(30, true, top, new Runnable() { public void run() { if (editPeer == 0) { return; diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 02c72849771..a0c5c75935a 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -104,7 +104,7 @@ public static native long videoWriterOpen(String outPath, boolean hevc, int widt public static native boolean browserSupported(); /** Creates a WebView2-backed browser peer; returns an opaque native handle. */ - public static native long browserCreate(int width, int height); + public static native long browserCreate(int width, int height, int slot); public static native void browserSetHtml(long peer, String html); @@ -112,6 +112,13 @@ public static native long videoWriterOpen(String outPath, boolean hevc, int widt public static native void browserExecute(long peer, String js); + /** + * Re-hosts the browser in the given window's overlay. A BrowserComponent built + * while detached has no top level yet, so the slot chosen at construction is the + * main window's and would otherwise be permanent. + */ + public static native void browserSetHost(long peer, int slot); + public static native void browserSetBounds(long peer, int x, int y, int w, int h); /** Shows/hides the native WebKit widget. Hidden when the BrowserComponent's form @@ -159,12 +166,83 @@ public static native void setTransform(long graphics, float m00, float m10, floa float m11, float m02, float m12); /** - * Drains one queued input event into {@code out} ([type, x, y, keyCode]); - * returns true if an event was dequeued. See the {@code CN1_EVENT_*} - * constants in cn1_linux.h for the type codes. + * Drains one queued input event into {@code out} + * ([type, x, y, keyCode, windowId]); returns true if an event was dequeued. + * See the {@code CN1_EVENT_*} constants in cn1_linux.h for the type codes. + * + *

{@code windowId} is zero for the application's main window, which is every + * event this port produced before desktop windows existed. A shorter array is + * still accepted and simply drops the trailing field.

*/ public static native boolean pollEvent(int[] out); + // ---- additional desktop windows (cn1_linux_desktopwindow.c) -------------- + // + // A window is addressed by the slot index returned from desktopWindowCreate. + // The windowId passed in is the framework's own id, which the native layer + // stores and echoes back on every event so input routes without a lookup. + + /** Creates a hidden GtkWindow; returns its slot, or -1 on failure. */ + public static native int desktopWindowCreate(int windowId, String title, int x, int y, + int width, int height, boolean decorated, boolean resizable, int ownerSlot, + boolean positionSet); + + public static native void desktopWindowDestroy(int slot); + + public static native void desktopWindowShow(int slot, boolean visible); + + public static native void desktopWindowSetTitle(int slot, String title); + + public static native void desktopWindowSetBounds(int slot, int x, int y, int width, int height); + + /** Fills {@code out} with x, y, width and height in desktop coordinates. */ + public static native void desktopWindowGetBounds(int slot, int[] out); + + public static native boolean mainWindowGetBounds(int[] out); + + public static native int desktopWindowGetWidth(int slot); + + public static native int desktopWindowGetHeight(int slot); + + /** The window's CN1Graphics pointer. */ + public static native long desktopWindowGraphics(int slot); + + /** Queues a redraw of the given region of the window's drawing area. */ + public static native void desktopWindowFlush(int slot, int x, int y, int width, int height); + + /** 0 resizable, 1 always on top, 2 modal, 3 decorated. */ + public static native void desktopWindowSetFlag(int slot, int flag, boolean value); + + /** The smallest frame the user may drag the window to; 0 clears the constraint. */ + public static native void desktopWindowSetMinimumSize(int slot, int width, int height); + + /** Enables or disables input for the application's main window. */ + public static native void mainWindowSetSensitive(boolean sensitive); + + /** 0 restore, 1 minimize, 2 toggle maximize, 3 present (raise and focus). */ + public static native void desktopWindowSetState(int slot, int state); + + // ---- monitors ---- + + public static native int monitorCount(); + + public static native int primaryMonitor(); + + /** Fills {@code out} with a monitor's geometry, or its work area when asked. */ + public static native void monitorBounds(int monitor, boolean workArea, int[] out); + + /** GTK's integer scale factor for a monitor. */ + public static native int monitorScale(int monitor); + + /** Physical resolution derived from the monitor's reported millimetre size. */ + public static native int monitorDpi(int monitor); + + public static native int monitorForWindow(int slot); + + /// The monitor the application's main window sits on. The main window has no + /// desktop-window slot, so `#monitorForWindow(int)` cannot answer for it. + public static native int monitorForMainWindow(); + /** Rebuilds the GTK/ATK virtual accessibility hierarchy. */ public static native void accessibilityBegin(); public static native void accessibilityNode(long id, long parentId, String role, String label, @@ -242,6 +320,14 @@ public static native void accessibilityNode(long id, long parentId, String role, */ public static native byte[] captureWindowToPngBytes(); + /** + * Reads a desktop window's own back buffer back as PNG bytes, or null when the + * slot has no surface. This is a genuine readback rather than a re-render, which + * is what lets the windowed screenshot goldens catch a window whose raster and + * component hierarchy disagree. + */ + public static native byte[] captureDesktopWindowToPngBytes(int slot); + /* ----------------------------------------------------- graphics state */ public static native int getColor(long graphics); @@ -384,7 +470,8 @@ public static native void accessibilityNode(long id, long parentId, String role, * {@link #editIsDone(long)}. */ public static native long editStringAt(int x, int y, int w, int h, String text, - boolean singleLine, int maxSize, long fontPeer, int fgColor, int bgColor, int align); + boolean singleLine, int maxSize, long fontPeer, int fgColor, int bgColor, int align, + int slot); /** True once the user has committed the native edit (Enter / focus loss). */ public static native boolean editIsDone(long peer); @@ -836,16 +923,16 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, * {@code @NativeInterface}; these reparent it onto the host window and * move/size/show it to track the lightweight {@link com.codename1.ui.PeerComponent}. */ - public static native void peerInitialized(long peer, int x, int y, int w, int h); + public static native void peerInitialized(long peer, int x, int y, int w, int h, int slot); /** Repositions / resizes the peer HWND to the component's absolute bounds. */ - public static native void peerSetBounds(long peer, int x, int y, int w, int h); + public static native void peerSetBounds(long peer, int x, int y, int w, int h, int slot); /** Shows / hides the peer HWND (transition lightweight mode). */ public static native void peerSetVisible(long peer, boolean visible); /** Hides and detaches the peer HWND (the app still owns its lifetime). */ - public static native void peerDeinitialized(long peer); + public static native void peerDeinitialized(long peer, int slot); /** Fills {@code out[0]=w, [1]=h} with the peer HWND's current size (0 if none). */ public static native void peerCalcPreferredSize(long peer, int dispW, int dispH, int[] out); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWindowManager.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWindowManager.java new file mode 100644 index 00000000000..1f6f7a2c471 --- /dev/null +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxWindowManager.java @@ -0,0 +1,557 @@ +/* + * 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.linux; + +import com.codename1.impl.WindowManager; +import com.codename1.ui.Display; +import com.codename1.ui.Image; + +/** + * The native Linux implementation of the desktop windowing contract. + * + *

Each Codename One window is a slot in the native table in + * {@code cn1_linux_desktopwindow.c}, with its own GtkWindow, drawing area, peer + * overlay and cairo back buffer. The application's main window keeps its own file + * statics and is not part of that table, so the single-window path is unchanged.

+ * + * @author Shai Almog + */ +public class LinuxWindowManager extends WindowManager { + + /** Flag selectors matching {@code cn1DesktopFlagOnMain}. */ + private static final int FLAG_RESIZABLE = 0; + private static final int FLAG_ALWAYS_ON_TOP = 1; + private static final int FLAG_MODAL = 2; + private static final int FLAG_DECORATED = 3; + private static final int FLAG_UTILITY = 4; + private static final int FLAG_SENSITIVE = 5; + + /** State selectors matching {@code cn1DesktopStateOnMain}. */ + private static final int STATE_RESTORE = 0; + private static final int STATE_MINIMIZE = 1; + private static final int STATE_TOGGLE_MAXIMIZE = 2; + private static final int STATE_PRESENT = 3; + + /** One native window, identified by its slot in the native table. */ + static final class Peer { + final int slot; + final int windowId; + /// The peer of the window that owns this one, or null. + /// + /// GTK expresses ownership as a transient-for hint, which keeps an owned + /// window above its owner but does not take it down with it -- unlike Win32, + /// where the window manager hides owned windows and reports it. So the + /// cascade is kept here, as the Catalyst port does for the same reason. + Object owner; + /// True while this window is hidden only because its owner is. + boolean hiddenByOwner; + boolean visible; + + Peer(int slot, int windowId) { + this.slot = slot; + this.windowId = windowId; + } + } + + /// Every live window, so an owner can find the windows it owns. + private static final java.util.List peers = new java.util.ArrayList(); + + /// Stands in for the application's main window, which has no `Peer`. + private static final Object MAIN_WINDOW = new Object(); + + private static java.util.List ownedBy(Object owner) { + java.util.List out = new java.util.ArrayList(); + synchronized (peers) { + for (Peer each : peers) { + if (each.owner == owner) { //NOPMD CompareObjectsWithEquals + out.add(each); + } + } + } + return out; + } + + /// Applies an owner's visibility to every window it owns, to any depth, and tells + /// the framework about each window that actually changed. + /// + /// Ownership is only assigned when a window is created, so the graph is a tree and + /// this cannot cycle. + private static void cascadeFrom(Object owner, boolean shown) { + for (Peer child : ownedBy(owner)) { + boolean changed = false; + if (shown) { + if (child.hiddenByOwner) { + child.hiddenByOwner = false; + child.visible = true; + LinuxNative.desktopWindowShow(child.slot, true); + changed = true; + } + } else if (child.visible) { + child.hiddenByOwner = true; + child.visible = false; + LinuxNative.desktopWindowShow(child.slot, false); + changed = true; + } + if (changed) { + // Unmapping the native window alone leaves the framework believing it + // is up: it keeps painting and animating it and fires no lifecycle + // event. + if (shown) { + com.codename1.ui.Desktop.getInstance().windowShowNotify(child.windowId); + } else { + com.codename1.ui.Desktop.getInstance().windowHideNotify(child.windowId); + } + } + // Going down, a descendant follows even when its own parent was already + // hidden by the application. Coming back up, only a child that actually + // reappeared may restore the windows it owns. + if (!shown || child.visible) { + cascadeFrom(child, shown); + } + } + } + + /// Records a visibility change the platform made on its own, so that the owner + /// cascade does not later act on stale state. + /// + /// Without this, a window the user minimized himself still looked visible here, so + /// a later owner hide marked it hidden-by-owner and the owner's restore brought + /// back a window the user had put away. + /// + /// Unlike the Catalyst port this deliberately does not cascade. GTK iconifies and + /// de-iconifies a transient child along with its owner, and each of those windows + /// reports its own state through here, so cascading would be a second opinion on + /// something already handled. What GTK does not propagate is an explicit map or + /// unmap, which is why `#show(Object)` and `#hide(Object)` cascade and this does + /// not. + /// + /// #### Parameters + /// + /// - `windowId`: the window whose visibility the platform changed + /// + /// - `shown`: true when it became visible, false when it went away + static void windowVisibilityChanged(int windowId, boolean shown) { + synchronized (peers) { + for (Peer each : peers) { + if (each.windowId == windowId) { + each.visible = shown; + // The change came from the platform rather than from an owner, so + // no owner may undo it. + each.hiddenByOwner = false; + return; + } + } + } + } + + private static int slot(Object p) { + return p instanceof Peer ? ((Peer) p).slot : -1; + } + + /// The desktop-window slot hosting the given component, or + /// `LinuxImplementation#MAIN_WINDOW_SLOT` when it lives in the application's main + /// window. Native peers, the text editor and the browser all need this to reach + /// the right window's overlay; without it they were placed over the main window + /// whatever window they belonged to. + static int slotForComponent(com.codename1.ui.Component cmp) { + Object peer = com.codename1.ui.Desktop.getInstance().getWindowPeerForComponent(cmp); + if (peer == null) { + return LinuxImplementation.MAIN_WINDOW_SLOT; + } + int s = slot(peer); + return s < 0 ? LinuxImplementation.MAIN_WINDOW_SLOT : s; + } + + // ---- lifecycle ----------------------------------------------------------- + + @Override + public Object createWindow(int windowId, String title, int x, int y, int width, int height, + boolean decorated, boolean resizable, Object parentPeer, boolean positionSet, + boolean ownedByMainWindow) { + // The transient parent is what makes an owned window stay above its owner and + // is also what scopes GTK's modality. -2 asks for the application's main + // window; -1 leaves the window unowned rather than silently parenting it. + int ownerSlot = parentPeer != null ? slot(parentPeer) : (ownedByMainWindow ? -2 : -1); + int s = LinuxNative.desktopWindowCreate(windowId, title == null ? "" : title, + x, y, width, height, decorated, resizable, ownerSlot, positionSet); + if (s < 0) { + return null; + } + Peer created = new Peer(s, windowId); + created.owner = ownedByMainWindow ? MAIN_WINDOW : parentPeer; + synchronized (peers) { + peers.add(created); + } + return created; + } + + @Override + public void show(Object peer) { + int s = slot(peer); + if (s < 0) { + return; + } + Peer w = (Peer) peer; + w.visible = true; + w.hiddenByOwner = false; + LinuxNative.desktopWindowShow(s, true); + // Only the ones this owner took down. A child hidden by the application stays + // hidden, exactly as AWT and the Catalyst port behave. + cascadeFrom(w, true); + } + + @Override + public void hide(Object peer) { + int s = slot(peer); + if (s < 0) { + return; + } + Peer w = (Peer) peer; + w.visible = false; + // An explicit hide takes the window's visibility over from any owner, so the + // owner's restore must not bring it back. + w.hiddenByOwner = false; + LinuxNative.desktopWindowShow(s, false); + // GTK leaves owned windows alone when their transient parent is unmapped, so + // without this the children either stayed on screen without their owner or + // were unmapped with no notification -- either way the framework went on + // painting and animating them. + cascadeFrom(w, false); + } + + @Override + public void dispose(Object peer) { + int s = slot(peer); + if (peer instanceof Peer) { + synchronized (peers) { + peers.remove(peer); + // An owned window outliving its owner would keep a dangling reference + // and could be matched against a later peer at the same address. + for (Peer each : peers) { + if (each.owner == peer) { //NOPMD CompareObjectsWithEquals + each.owner = null; + } + } + } + } + if (s >= 0) { + LinuxNative.desktopWindowDestroy(s); + } + } + + // ---- attributes ------------------------------------------------------------ + + @Override + public void setTitle(Object peer, String title) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetTitle(s, title == null ? "" : title); + } + } + + @Override + public void setBounds(Object peer, int x, int y, int width, int height) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetBounds(s, x, y, width, height); + } + } + + /// {@inheritDoc} + /// + /// A Form lives in the application's own window, so centring a window over a + /// Form means centring over that window. Left unimplemented the framework fell + /// back to the monitor work area, which is a different place whenever the main + /// window has been moved, resized or simply does not fill the screen. + @Override + public int[] getMainWindowBounds(int[] out) { + if (out == null || out.length < 4) { + return null; + } + return LinuxNative.mainWindowGetBounds(out) ? out : null; + } + + @Override + public int[] getBounds(Object peer, int[] out) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowGetBounds(s, out); + } + return out; + } + + @Override + public int getWidth(Object peer) { + int s = slot(peer); + return s < 0 ? 0 : LinuxNative.desktopWindowGetWidth(s); + } + + @Override + public int getHeight(Object peer) { + int s = slot(peer); + return s < 0 ? 0 : LinuxNative.desktopWindowGetHeight(s); + } + + @Override + public void setResizable(Object peer, boolean resizable) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetFlag(s, FLAG_RESIZABLE, resizable); + } + } + + @Override + public void setDecorated(Object peer, boolean decorated) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetFlag(s, FLAG_DECORATED, decorated); + } + } + + @Override + public void setAlwaysOnTop(Object peer, boolean alwaysOnTop) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetFlag(s, FLAG_ALWAYS_ON_TOP, alwaysOnTop); + } + } + + @Override + public void setMinimumSize(Object peer, int width, int height) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetMinimumSize(s, width, height); + } + } + + @Override + public void setUtilityWindow(Object peer, boolean utility) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetFlag(s, FLAG_UTILITY, utility); + } + } + + @Override + public void setModal(Object peer, boolean modal, boolean applicationWide, Object ownerPeer) { + // Codename One blocks input itself; this only gives the window manager the hint + // it needs for correct stacking and focus. + // + // Only for an application wide modal, though. gtk_window_set_modal() makes the + // window modal for the whole application rather than for its transient parent, + // so raising it for MODALITY_WINDOW made every other window and the main form + // unusable -- while Display.blocks() deliberately blocks only the owner. A + // window scoped modal expresses its scope through the per-window sensitivity + // wiring in setInputEnabled instead. + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetFlag(s, FLAG_MODAL, modal && applicationWide); + } + } + + @Override + public void setInputEnabled(Object peer, boolean enabled) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetFlag(s, FLAG_SENSITIVE, enabled); + } + } + + @Override + public void setMainWindowInputEnabled(boolean enabled) { + LinuxNative.mainWindowSetSensitive(enabled); + } + + @Override + public void setIcon(Object peer, Image icon) { + // Not supported yet: the port has no GdkPixbuf conversion for a CN1 image. + } + + @Override + public void requestFocus(Object peer) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetState(s, STATE_PRESENT); + } + } + + @Override + public void minimize(Object peer) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetState(s, STATE_MINIMIZE); + } + } + + @Override + public void restore(Object peer) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetState(s, STATE_RESTORE); + } + } + + @Override + public void toggleMaximize(Object peer) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowSetState(s, STATE_TOGGLE_MAXIMIZE); + } + } + + // ---- rendering ------------------------------------------------------------------ + + @Override + public Object getNativeGraphics(Object peer) { + int s = slot(peer); + if (s < 0) { + return null; + } + return Long.valueOf(LinuxNative.desktopWindowGraphics(s)); + } + + @Override + public void flushGraphics(Object peer, int x, int y, int width, int height) { + int s = slot(peer); + if (s >= 0) { + LinuxNative.desktopWindowFlush(s, x, y, width, height); + } + } + + /// Reads this window's own back buffer back, rather than letting + /// `com.codename1.ui.Window#capture()` fall back to re-rendering the component + /// tree. The fallback produces the content the window *should* be showing, so it + /// cannot tell a correct window from one whose raster and hierarchy disagree -- + /// which is exactly what the windowed screenshot goldens are here to catch. + /// + /// #### Parameters + /// + /// - `peer`: the window's native peer + /// + /// #### Returns + /// + /// the native image, or null when the window has no surface to read + @Override + public Object capture(Object peer) { + int s = slot(peer); + if (s < 0) { + return null; + } + byte[] png = LinuxNative.captureDesktopWindowToPngBytes(s); + if (png == null || png.length == 0) { + return null; + } + long img = LinuxNative.createImageFromBytes(png, 0, png.length); + if (img == 0) { + return null; + } + return Long.valueOf(img); + } + + @Override + public void setPaintDirtyRegionClip(Object peer, int x, int y, int width, int height) { + int s = slot(peer); + if (s < 0) { + return; + } + long g = LinuxNative.desktopWindowGraphics(s); + if (g != 0) { + // Cairo draws into a persistent surface, so a clip set while a component + // paints has to be confined to the region about to be flushed or an + // oversized fill leaves stale pixels behind (issue #5273). + LinuxNative.setFlushRect(g, x, y, width, height); + } + } + + // ---- monitors ---------------------------------------------------------------------- + + @Override + public int getMonitorCount() { + return Math.max(1, LinuxNative.monitorCount()); + } + + @Override + public int[] getMonitorBounds(int monitor, int[] out) { + LinuxNative.monitorBounds(monitor, false, out); + return out; + } + + @Override + public int[] getMonitorWorkArea(int monitor, int[] out) { + LinuxNative.monitorBounds(monitor, true, out); + return out; + } + + @Override + public int getMonitorDensity(int monitor) { + int dpi = getMonitorDotsPerInch(monitor); + if (dpi >= 280) { + return Display.DENSITY_VERY_HIGH; + } + if (dpi >= 200) { + return Display.DENSITY_HIGH; + } + if (dpi >= 140) { + return Display.DENSITY_MEDIUM; + } + return Display.DENSITY_LOW; + } + + @Override + public double getMonitorScale(int monitor) { + // GTK exposes only an integer scale factor, which is what actually governs + // how the toolkit renders, so that is what a window's scale reports. + int scale = LinuxNative.monitorScale(monitor); + return scale > 0 ? scale : 1.0; + } + + @Override + public int getMonitorDotsPerInch(int monitor) { + int dpi = LinuxNative.monitorDpi(monitor); + return dpi > 0 ? dpi : 96; + } + + @Override + public String getMonitorName(int monitor) { + return "display-" + monitor; + } + + @Override + public int getPrimaryMonitor() { + return Math.max(0, LinuxNative.primaryMonitor()); + } + + @Override + public int getMonitorForWindow(Object peer) { + int s = slot(peer); + if (s < 0) { + return getPrimaryMonitor(); + } + return Math.max(0, LinuxNative.monitorForWindow(s)); + } + + @Override + public int getMonitorForMainWindow() { + return Math.max(0, LinuxNative.monitorForMainWindow()); + } +} diff --git a/Ports/WindowsPort/nativeSources/cn1_windows.h b/Ports/WindowsPort/nativeSources/cn1_windows.h index 26bd845c5ae..205ca7f9962 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows.h +++ b/Ports/WindowsPort/nativeSources/cn1_windows.h @@ -102,7 +102,16 @@ typedef enum { * fireRotationGesture, the same hooks the macOS trackpad drives. */ CN1_EVENT_PINCH = 10, CN1_EVENT_ROTATE = 11, - CN1_EVENT_ACCESSIBILITY_ACTION = 12 + CN1_EVENT_ACCESSIBILITY_ACTION = 12, + /* Additional desktop windows. These carry a non-zero windowId; every event + * above carries windowId 0, meaning the application's main window. */ + CN1_EVENT_WINDOW_CLOSE = 13, + CN1_EVENT_WINDOW_FOCUS = 14, /* keyCode 1 == gained, 0 == lost */ + CN1_EVENT_WINDOW_MONITOR = 15, /* window moved to a different monitor */ + CN1_EVENT_WINDOW_SHOWN = 16, + CN1_EVENT_WINDOW_HIDDEN = 17, + CN1_EVENT_WINDOW_MOVED = 18, + CN1_EVENT_MONITORS_CHANGED = 19 } CN1EventType; /* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */ @@ -113,6 +122,12 @@ typedef struct { JAVA_INT x; JAVA_INT y; JAVA_INT keyCode; + /* Which window the event came from. Zero is the application's main window, + * which is every event the port produced before desktop windows existed, so + * the Java side's main path is unchanged. A port must echo back the id it was + * handed at creation rather than looking the window up, because these are + * pushed from the pump thread. */ + JAVA_INT windowId; } CN1Event; /* For pointer (pressed/released/dragged) events the otherwise-unused keyCode @@ -254,10 +269,37 @@ void cn1WindowsLog(const char* message); void cn1WinApplyPendingResize(void); int cn1WinCreateWindow(const char* utf8Title, int width, int height); void cn1WinPushEvent(CN1EventType type, int x, int y, int keyCode); +/* Same, but tagged with the desktop window the event came from. */ +void cn1WinPushWindowEvent(int windowId, CN1EventType type, int x, int y, int keyCode); + +/* CN1_PE_TOUCH_FLAG / CN1_PE_PEN_FLAG for the message being handled, or 0 for a + * real mouse. Windows promotes touch and pen contacts to mouse messages and only + * distinguishes them through GetMessageExtraInfo. */ +int cn1WinTouchFlag(void); + +#ifdef WM_GESTURE +/* Handles a WM_GESTURE for the given window, pushing pinch / rotate events tagged + * with windowId (0 is the main window). Returns non-zero when the gesture was + * consumed, in which case the handle has already been closed. Shared so a + * secondary desktop window reports trackpad gestures the same way the main one + * does. */ +int cn1WinHandleGesture(HWND hwnd, int windowId, LPARAM lParam); +#endif int cn1WinPollEvent(CN1Event* out); LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); LRESULT cn1WinAccessibilityObject(HWND hwnd, WPARAM wParam, LPARAM lParam); +/* Additional desktop windows (cn1_windows_desktopwindow.cpp). The main window in + * cn1Win is deliberately left alone: secondary windows live in their own table + * with their own HWND, render target and graphics, so nothing about the existing + * single-window path changes. */ +#define CN1_MAX_DESKTOP_WINDOWS 32 +/* Marshals window creation onto the pump thread, which must own the HWND. */ +#define WM_CN1_DESKTOPWINDOW (WM_APP + 25) +void cn1WinDesktopHandleMessage(WPARAM wParam, LPARAM lParam); +HWND cn1WinDesktopHwnd(int slot); +int cn1WinDesktopSlotForHwnd(HWND hwnd); + /* BrowserComponent / WebView2 peer (cn1_windows_browser.cpp). The EDT-facing * native methods marshal each WebView2 operation to the main (pump) thread by * posting WM_CN1_BROWSER to cn1Win.hwnd; cn1WinWndProc forwards it here. */ diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_browser.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_browser.cpp index 2e4caa57851..a3ea1e46100 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_browser.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_browser.cpp @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + /* * BrowserComponent peer for the native Windows port, backed by WebView2. * @@ -46,7 +69,7 @@ extern "C" { using namespace Microsoft::WRL; -enum { OP_NAV_HTML = 2, OP_NAV_URL = 3, OP_EXECUTE = 4, OP_BOUNDS = 5, OP_DESTROY = 6 }; +enum { OP_NAV_HTML = 2, OP_NAV_URL = 3, OP_EXECUTE = 4, OP_BOUNDS = 5, OP_DESTROY = 6, OP_SETHOST = 7 }; struct CritLock { CRITICAL_SECTION* cs; @@ -63,6 +86,7 @@ struct CN1Browser { std::deque> cmds; // queued ops (run on main thread) std::deque events; // UTF-8: "LOAD" | "NAV|" std::string png; // last CapturePreview PNG bytes + int slot = -1; // owning desktop window, -1 = main CN1Browser() { InitializeCriticalSection(&lock); } ~CN1Browser() { DeleteCriticalSection(&lock); } }; @@ -114,6 +138,14 @@ static void cn1BrowserCapture(CN1Browser* b) { }).Get()); } +/* The window this browser belongs to, falling back to the main one. A slot whose + * window has already gone answers NULL, and a WebView2 controller parented to NULL + * is an error, so the fallback is not merely for the -1 case. */ +static HWND cn1BrowserHostHwnd(CN1Browser* b) { + HWND host = b->slot >= 0 ? cn1WinDesktopHwnd(b->slot) : NULL; + return host ? host : cn1Win.hwnd; +} + static void cn1BrowserApplyBounds(CN1Browser* b) { if (!b->controller) return; RECT rc; rc.left = b->x; rc.top = b->y; rc.right = b->x + b->w; rc.bottom = b->y + b->h; @@ -127,6 +159,14 @@ static void cn1BrowserRunCmd(CN1Browser* b, int op, const std::wstring& data) { case OP_NAV_URL: if (b->webview) b->webview->Navigate(data.c_str()); break; case OP_EXECUTE: if (b->webview) b->webview->ExecuteScript(data.c_str(), nullptr); break; case OP_BOUNDS: cn1BrowserApplyBounds(b); break; + case OP_SETHOST: + // Only meaningful once the controller exists; when it does not, creation + // reads b->slot itself, so both orderings end up parented correctly. + if (b->controller) { + b->controller->put_ParentWindow(cn1BrowserHostHwnd(b)); + cn1BrowserApplyBounds(b); + } + break; case OP_DESTROY: if (b->controller) { b->controller->Close(); } delete b; @@ -153,7 +193,7 @@ static void cn1BrowserCreate(CN1Browser* b) { Callback( [b](HRESULT r, ICoreWebView2Environment* env) -> HRESULT { if (FAILED(r) || !env) { cn1BrowserEnqueueEvent(b, "LOAD"); return S_OK; } - env->CreateCoreWebView2Controller(cn1Win.hwnd, + env->CreateCoreWebView2Controller(cn1BrowserHostHwnd(b), Callback( [b](HRESULT r2, ICoreWebView2Controller* ctl) -> HRESULT { if (FAILED(r2) || !ctl) { cn1BrowserEnqueueEvent(b, "LOAD"); return S_OK; } @@ -226,6 +266,19 @@ static void cn1BrowserPost(CN1Browser* b, int op, const std::wstring& data) { PostMessageW(cn1Win.hwnd, WM_CN1_BROWSER, 0, (LPARAM) b); } +/* Re-hosts the browser in the given window. A BrowserComponent is routinely built + * while detached, so the window it belongs to is only known once it is initialized. */ +JAVA_VOID com_codename1_impl_windows_WindowsNative_browserSetHost___long_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT slot) { + CN1Browser* b = (CN1Browser*) (intptr_t) peer; + if (!b) return; + { + CritLock g(&b->lock); + b->slot = slot; + } + cn1BrowserPost(b, OP_SETHOST, std::wstring()); +} + JAVA_VOID com_codename1_impl_windows_WindowsNative_browserSetHtml___long_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_OBJECT html) { CN1Browser* b = (CN1Browser*) (intptr_t) peer; if (!b) return; cn1BrowserPost(b, OP_NAV_HTML, utf8ToWide(stringToUTF8(threadStateData, html))); @@ -284,6 +337,7 @@ void cn1WinBrowserHandleMessage(WPARAM wParam, LPARAM lParam) {} JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_browserSupported___R_boolean(CODENAME_ONE_THREAD_STATE) { return JAVA_FALSE; } JAVA_LONG com_codename1_impl_windows_WindowsNative_browserCreate___int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_INT w, JAVA_INT h) { return 0; } +JAVA_VOID com_codename1_impl_windows_WindowsNative_browserSetHost___long_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG p, JAVA_INT slot) {} JAVA_VOID com_codename1_impl_windows_WindowsNative_browserSetHtml___long_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG p, JAVA_OBJECT s) {} JAVA_VOID com_codename1_impl_windows_WindowsNative_browserSetUrl___long_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG p, JAVA_OBJECT s) {} JAVA_VOID com_codename1_impl_windows_WindowsNative_browserExecute___long_java_lang_String(CODENAME_ONE_THREAD_STATE, JAVA_LONG p, JAVA_OBJECT s) {} diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_comc.h b/Ports/WindowsPort/nativeSources/cn1_windows_comc.h index a6ff91c81eb..017b5ffc21e 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_comc.h +++ b/Ports/WindowsPort/nativeSources/cn1_windows_comc.h @@ -89,6 +89,9 @@ #ifndef ID2D1HwndRenderTarget_Resize #define ID2D1HwndRenderTarget_Resize(This, ...) ((This)->Resize(__VA_ARGS__)) #endif +#ifndef ID2D1HwndRenderTarget_Release +#define ID2D1HwndRenderTarget_Release(This, ...) ((This)->Release(__VA_ARGS__)) +#endif #ifndef ID2D1PathGeometry_Open #define ID2D1PathGeometry_Open(This, ...) ((This)->Open(__VA_ARGS__)) #endif @@ -170,6 +173,9 @@ #ifndef ID2D1SolidColorBrush_SetColor #define ID2D1SolidColorBrush_SetColor(This, ...) ((This)->SetColor(__VA_ARGS__)) #endif +#ifndef ID2D1SolidColorBrush_Release +#define ID2D1SolidColorBrush_Release(This, ...) ((This)->Release(__VA_ARGS__)) +#endif #ifndef IWICBitmapDecoder_GetFrame #define IWICBitmapDecoder_GetFrame(This, ...) ((This)->GetFrame(__VA_ARGS__)) #endif diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_desktopwindow.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_desktopwindow.cpp new file mode 100644 index 00000000000..4ee0d081025 --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_desktopwindow.cpp @@ -0,0 +1,1081 @@ +/* + * 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. + */ + +/* + * Additional desktop windows for the Windows port. + * + * The application's main window stays exactly where it was, in cn1Win: its HWND, + * its ID2D1HwndRenderTarget and its CN1Graphics are untouched by this file. A + * Codename One Window instead takes a slot in the table below, with its own HWND, + * its own render target and its own graphics, so the single-window path -- which + * every existing app and every screenshot baseline exercises -- cannot change + * behaviour. + * + * Window identity in the window procedure comes from GWLP_USERDATA, set in + * WM_NCCREATE. That is O(1) and needs no lock, which matters because the + * procedure runs on the pump thread while the EDT is drawing. + * + * Windows must be created on the thread that owns the message pump, so creation + * and destruction marshal through WM_CN1_DESKTOPWINDOW using the same blocking + * SendMessageW pattern the native edit control and the file dialog already use. + * Everything else (move, size, show, title) is legal cross-thread and runs + * directly. + */ + +#include "cn1_windows.h" +#include +#include +#include + +/* --------------------------------------------------------------- table */ + +typedef struct { + HWND hwnd; + ID2D1HwndRenderTarget* target; + CN1Graphics* graphics; + JAVA_INT width; + JAVA_INT height; + volatile LONG pendingResize; + volatile LONG pendingW; + volatile LONG pendingH; + int windowId; /* framework assigned; 0 marks a free slot */ + int monitorIndex; + int minimized; + /* Set when Windows took this window down with its owner, kept separate from + * `minimized` so an explicit hide can clear it: reusing one flag meant an owner + * restore resurrected a window the application had hidden itself. */ + int ownerHidden; + int minWidth; + int minHeight; + /* The resizable state the application asked for. Remembered because restoring + * decorations re-adds WS_OVERLAPPEDWINDOW, which carries WS_THICKFRAME and + * WS_MAXIMIZEBOX with it -- silently undoing an earlier setResizable(false) + * while the framework still reported the window as fixed. */ + int resizable; + int inUse; +} CN1DesktopWindow; + +static CN1DesktopWindow g_windows[CN1_MAX_DESKTOP_WINDOWS]; +static int g_classRegistered = 0; + +/* Op codes marshaled through WM_CN1_DESKTOPWINDOW. */ +#define CN1_DW_OP_CREATE 1 +#define CN1_DW_OP_DESTROY 2 + +typedef struct { + int op; + int slot; + int windowId; + const char* utf8Title; + int x; + int y; + int width; + int height; + int decorated; + int resizable; + int ownerSlot; + int positionSet; + int result; +} CN1DesktopWindowOp; + +static CN1DesktopWindow* slotAt(int slot) { + if (slot < 0 || slot >= CN1_MAX_DESKTOP_WINDOWS) { + return NULL; + } + if (!g_windows[slot].inUse) { + return NULL; + } + return &g_windows[slot]; +} + +HWND cn1WinDesktopHwnd(int slot) { + CN1DesktopWindow* w = slotAt(slot); + return w == NULL ? NULL : w->hwnd; +} + +int cn1WinDesktopSlotForHwnd(HWND hwnd) { + int iter; + for (iter = 0; iter < CN1_MAX_DESKTOP_WINDOWS; iter++) { + if (g_windows[iter].inUse && g_windows[iter].hwnd == hwnd) { + return iter; + } + } + return -1; +} + +/* ------------------------------------------------------------- monitors */ + +typedef struct { + RECT bounds[CN1_MAX_DESKTOP_WINDOWS]; + RECT work[CN1_MAX_DESKTOP_WINDOWS]; + HMONITOR handles[CN1_MAX_DESKTOP_WINDOWS]; + int primary; + int count; +} CN1MonitorTable; + +static CN1MonitorTable g_monitors; +/* The table is refreshed from two threads: the event dispatch thread through + * Desktop.getMonitors(), and the window pump thread from WM_MOVE, WM_DPICHANGED and + * WM_DISPLAYCHANGE. Enumerating straight into the shared table let one refresh + * observe the other's partially built state -- duplicate or half-initialised + * monitors, and with enough displays a count past the array. Each refresh now builds + * a local table and publishes it under this lock, and every reader takes it shared. + * A plain SRWLOCK needs no initialisation, which matters because there is no single + * point where this file is set up. */ +static SRWLOCK g_monitorLock = SRWLOCK_INIT; + +static BOOL CALLBACK cn1WinMonitorEnum(HMONITOR mon, HDC hdc, LPRECT rect, LPARAM data) { + MONITORINFO info; + CN1MonitorTable* out = (CN1MonitorTable*) data; + (void) hdc; + (void) rect; + if (out == NULL || out->count >= CN1_MAX_DESKTOP_WINDOWS) { + return FALSE; + } + ZeroMemory(&info, sizeof(info)); + info.cbSize = sizeof(info); + if (GetMonitorInfoW(mon, &info)) { + int i = out->count; + out->handles[i] = mon; + out->bounds[i] = info.rcMonitor; + out->work[i] = info.rcWork; + if (info.dwFlags & MONITORINFOF_PRIMARY) { + out->primary = i; + } + out->count++; + } + return TRUE; +} + +/* Re-reads the attached monitors. Cheap and called on demand rather than cached + * across time, because a display can be unplugged or reconfigured at any moment + * and a stale table would place windows off-screen. */ +static void cn1WinRefreshMonitors(void) { + CN1MonitorTable built; + ZeroMemory(&built, sizeof(built)); + EnumDisplayMonitors(NULL, NULL, cn1WinMonitorEnum, (LPARAM) &built); + if (built.count == 0) { + /* Degenerate but survivable: report the virtual screen as one monitor. */ + RECT r; + r.left = 0; + r.top = 0; + r.right = GetSystemMetrics(SM_CXSCREEN); + r.bottom = GetSystemMetrics(SM_CYSCREEN); + built.bounds[0] = r; + built.work[0] = r; + built.handles[0] = NULL; + built.count = 1; + } + /* Published in one step, so a reader never sees a half-enumerated table. */ + AcquireSRWLockExclusive(&g_monitorLock); + g_monitors = built; + ReleaseSRWLockExclusive(&g_monitorLock); +} + +/* A consistent copy of the table for readers, so a refresh mid-read cannot change + * the count out from under an index that was already validated against it. */ +static void cn1WinMonitorSnapshot(CN1MonitorTable* out) { + AcquireSRWLockShared(&g_monitorLock); + *out = g_monitors; + ReleaseSRWLockShared(&g_monitorLock); +} + +static int cn1WinMonitorIndexForHwnd(HWND hwnd) { + HMONITOR mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + CN1MonitorTable t; + int iter; + cn1WinRefreshMonitors(); + cn1WinMonitorSnapshot(&t); + for (iter = 0; iter < t.count; iter++) { + if (t.handles[iter] == mon) { + return iter; + } + } + return t.primary; +} + +/* + * Dots per inch of one monitor. GetDpiForMonitor lives in shcore.dll, which is + * only present from Windows 8.1, so it is resolved dynamically and falls back to + * the system DPI. Resolving it per call is fine -- these are not hot paths, and + * the loader caches the module. + */ +typedef HRESULT (WINAPI *CN1GetDpiForMonitor)(HMONITOR, int, UINT*, UINT*); + +static int cn1WinMonitorDpi(int monitor) { + HMODULE shcore; + CN1MonitorTable t; + cn1WinMonitorSnapshot(&t); + if (monitor < 0 || monitor >= t.count) { + return 96; + } + shcore = LoadLibraryW(L"shcore.dll"); + if (shcore != NULL) { + CN1GetDpiForMonitor fn = + (CN1GetDpiForMonitor) GetProcAddress(shcore, "GetDpiForMonitor"); + if (fn != NULL && t.handles[monitor] != NULL) { + UINT dpiX = 96; + UINT dpiY = 96; + /* 0 == MDT_EFFECTIVE_DPI */ + if (SUCCEEDED(fn(t.handles[monitor], 0, &dpiX, &dpiY))) { + FreeLibrary(shcore); + return (int) dpiX; + } + } + FreeLibrary(shcore); + } + { + HDC screen = GetDC(NULL); + int dpi = screen != NULL ? GetDeviceCaps(screen, LOGPIXELSX) : 96; + if (screen != NULL) { + ReleaseDC(NULL, screen); + } + return dpi; + } +} + +/* ------------------------------------------------------- window procedure */ + +static void cn1WinDesktopPushPointer(CN1DesktopWindow* w, CN1EventType type, + LPARAM lParam, int mask) { + cn1WinPushWindowEvent(w->windowId, type, GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), mask); +} + +/* + * Drops the mouse capture once no button is still held. wParam on a button-up + * message carries the buttons that remain down, minus the one being released, so + * releasing capture on the first up would strand a drag started with two buttons. + */ +static void cn1WinDesktopReleaseCaptureIfIdle(WPARAM wParam, WPARAM released) { + /* The extra buttons count as held too, or releasing one of the three main + * buttons would drop the capture while a back/forward drag was still going. */ + WPARAM stillDown = wParam & (MK_LBUTTON | MK_RBUTTON | MK_MBUTTON + | MK_XBUTTON1 | MK_XBUTTON2) & ~released; + if (stillDown == 0) { + ReleaseCapture(); + } +} + +static LRESULT CALLBACK cn1WinDesktopWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + CN1DesktopWindow* w; + if (msg == WM_NCCREATE) { + CREATESTRUCTW* cs = (CREATESTRUCTW*) lParam; + SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR) cs->lpCreateParams); + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + w = (CN1DesktopWindow*) (LONG_PTR) GetWindowLongPtrW(hwnd, GWLP_USERDATA); + if (w == NULL || !w->inUse) { + return DefWindowProcW(hwnd, msg, wParam, lParam); + } + switch (msg) { + /* Every button captures, and capture is released only once the last one is + * up. Without it a drag that leaves the window is routed to whatever is under + * the cursor: this window would miss the rest of the drag and the release, + * leaving its pressed component stuck down. */ + case WM_LBUTTONDOWN: + SetCapture(hwnd); + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_PRESSED, lParam, + CN1_PE_MASK_PRIMARY | cn1WinTouchFlag()); + return 0; + case WM_LBUTTONUP: + cn1WinDesktopReleaseCaptureIfIdle(wParam, MK_LBUTTON); + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_RELEASED, lParam, + CN1_PE_MASK_PRIMARY | cn1WinTouchFlag()); + return 0; + case WM_RBUTTONDOWN: + SetCapture(hwnd); + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_PRESSED, lParam, + CN1_PE_MASK_SECONDARY | cn1WinTouchFlag()); + return 0; + case WM_RBUTTONUP: + cn1WinDesktopReleaseCaptureIfIdle(wParam, MK_RBUTTON); + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_RELEASED, lParam, + CN1_PE_MASK_SECONDARY | cn1WinTouchFlag()); + return 0; + case WM_MBUTTONDOWN: + SetCapture(hwnd); + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_PRESSED, lParam, + CN1_PE_MASK_MIDDLE | cn1WinTouchFlag()); + return 0; + case WM_MBUTTONUP: + cn1WinDesktopReleaseCaptureIfIdle(wParam, MK_MBUTTON); + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_RELEASED, lParam, + CN1_PE_MASK_MIDDLE | cn1WinTouchFlag()); + return 0; + /* The extra mouse buttons, mirroring the main window procedure. Without + * these a back or forward click over a secondary window was silently + * dropped, so the same hardware worked on the main form and nowhere else. + * WM_XBUTTON* returns TRUE rather than 0 by contract. */ + case WM_XBUTTONDOWN: { + int xmask = (GET_XBUTTON_WPARAM(wParam) == XBUTTON1) + ? CN1_PE_MASK_BACK : CN1_PE_MASK_FORWARD; + SetCapture(hwnd); + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_PRESSED, lParam, + xmask | cn1WinTouchFlag()); + return TRUE; + } + case WM_XBUTTONUP: { + int xmask = (GET_XBUTTON_WPARAM(wParam) == XBUTTON1) + ? CN1_PE_MASK_BACK : CN1_PE_MASK_FORWARD; + cn1WinDesktopReleaseCaptureIfIdle(wParam, + (GET_XBUTTON_WPARAM(wParam) == XBUTTON1) ? MK_XBUTTON1 : MK_XBUTTON2); + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_RELEASED, lParam, + xmask | cn1WinTouchFlag()); + return TRUE; + } + case WM_MOUSEMOVE: + if ((wParam & (MK_LBUTTON | MK_RBUTTON | MK_MBUTTON + | MK_XBUTTON1 | MK_XBUTTON2)) != 0) { + int mask = 0; + if (wParam & MK_LBUTTON) { mask |= CN1_PE_MASK_PRIMARY; } + if (wParam & MK_RBUTTON) { mask |= CN1_PE_MASK_SECONDARY; } + if (wParam & MK_MBUTTON) { mask |= CN1_PE_MASK_MIDDLE; } + /* Dragging with a held back/forward button counts as a drag too. */ + if (wParam & MK_XBUTTON1) { mask |= CN1_PE_MASK_BACK; } + if (wParam & MK_XBUTTON2) { mask |= CN1_PE_MASK_FORWARD; } + cn1WinDesktopPushPointer(w, CN1_EVENT_POINTER_DRAGGED, lParam, + mask | cn1WinTouchFlag()); + } + return 0; + case WM_MOUSEWHEEL: + case WM_MOUSEHWHEEL: { + /* Same shape as the main window's handler: the wheel message reports the + * cursor in SCREEN coordinates while the input ring works in client + * coordinates, and the delta is a signed multiple of WHEEL_DELTA (120). + * The windowId is what makes the EDT scroll this window's content rather + * than the main form's. */ + POINT pt; + pt.x = GET_X_LPARAM(lParam); + pt.y = GET_Y_LPARAM(lParam); + ScreenToClient(hwnd, &pt); + cn1WinPushWindowEvent(w->windowId, + msg == WM_MOUSEHWHEEL ? CN1_EVENT_MOUSE_HWHEEL : CN1_EVENT_MOUSE_WHEEL, + pt.x, pt.y, GET_WHEEL_DELTA_WPARAM(wParam)); + return 0; + } +#ifdef WM_GESTURE + case WM_GESTURE: + /* Trackpad / touchscreen pinch and rotate, handled by the same routine + * the main window proc uses so the two cannot drift; without this case a + * gesture over a secondary window produced nothing at all. */ + if (cn1WinHandleGesture(hwnd, w->windowId, lParam)) { + return 0; + } + return DefWindowProcW(hwnd, msg, wParam, lParam); +#endif + case WM_KEYDOWN: + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_KEY_PRESSED, 0, 0, (int) wParam); + return 0; + case WM_KEYUP: + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_KEY_RELEASED, 0, 0, (int) wParam); + return 0; + /* The system-key variants are forwarded and then handed on, never swallowed. + * Alt+F4, Alt+Space and F10 arrive as WM_SYSKEYDOWN, and it is DefWindowProcW + * that turns them into WM_CLOSE and the window menu; returning 0 here left the + * window unclosable by the keyboard and killed the native menu shortcuts. + * The main window proc in cn1_windows_window.cpp does not claim these messages + * at all, so a secondary window ends up with strictly more: the application + * sees the key, and the operating system still behaves normally. */ + case WM_SYSKEYDOWN: + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_KEY_PRESSED, 0, 0, (int) wParam); + return DefWindowProcW(hwnd, msg, wParam, lParam); + case WM_SYSKEYUP: + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_KEY_RELEASED, 0, 0, (int) wParam); + return DefWindowProcW(hwnd, msg, wParam, lParam); + case WM_SHOWWINDOW: + /* Windows hides and shows a window's owned windows along with it and + * reports it here with SW_PARENTCLOSING / SW_PARENTOPENING. There is no + * WM_SIZE for that, so without this an owned window kept nativeVisible + * true with no window on screen: the framework went on painting and + * animating it, which also keeps the event dispatch thread awake. + * + * Only the owner-driven case is forwarded. lParam is zero when the call + * came from ShowWindow, which is the framework's own show()/hide() -- it + * already knows about those, and reporting them would be redundant. */ + if (lParam == SW_PARENTCLOSING) { + if (!w->ownerHidden && !w->minimized) { + w->ownerHidden = 1; + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_HIDDEN, 0, 0, 0); + } + } else if (lParam == SW_PARENTOPENING) { + /* Only what this owner took down. A window the application hid, or one + * the user minimized on its own, cleared or never set this flag and so + * is not brought back. */ + if (w->ownerHidden) { + w->ownerHidden = 0; + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_SHOWN, 0, 0, 0); + } + } else if (lParam == 0) { + /* The framework's own show()/hide() through ShowWindow. Deliberately + * not reported -- the framework already knows -- but it takes the + * window's visibility over from any owner, so the owner's restore must + * not resurrect it. Without this the flag stayed set through an + * explicit hide and SW_PARENTOPENING reported the window shown again, + * with its component hierarchy still invisible. */ + w->ownerHidden = 0; + } + return DefWindowProcW(hwnd, msg, wParam, lParam); + case WM_SIZE: + /* A minimize arrives as a resize to zero. Reporting only that leaves the + * framework thinking the window is still on screen: it keeps painting it + * and an animation in it keeps the event dispatch thread awake. */ + if (wParam == SIZE_MINIMIZED) { + if (!w->minimized) { + w->minimized = 1; + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_HIDDEN, 0, 0, 0); + } + return 0; + } + /* Any transition out of minimized, not just SIZE_RESTORED. Restoring a + * window that was maximized before it was minimized reports + * SIZE_MAXIMIZED, so keying on SIZE_RESTORED alone left `minimized` set + * and never sent WINDOW_SHOWN: the framework went on treating a visible + * window as iconified and excluded it from painting and animation for + * good. */ + if (w->minimized) { + w->minimized = 0; + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_SHOWN, 0, 0, 0); + } + w->width = LOWORD(lParam); + w->height = HIWORD(lParam); + /* The Direct2D Resize has to happen on the drawing thread between + * frames -- resizing a render target while the EDT is mid-BeginDraw + * is invalid and presents black -- so record it and let the EDT apply + * it, exactly as the main window does. */ + w->pendingW = w->width; + w->pendingH = w->height; + w->pendingResize = 1; + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_SIZE_CHANGED, w->width, w->height, 0); + return 0; + /* Deliberately no WM_DISPLAYCHANGE case. Windows broadcasts it to every top + * level window, and the main window -- which always exists -- already reports + * it. Reporting from here as well produced N+1 notifications for one physical + * display change, each one relaying out every open window. */ + case WM_GETMINMAXINFO: + /* The minimum is native geometry, so it applies to the whole frame -- + * which is the window this message is about. */ + if (w->minWidth > 0 && w->minHeight > 0 && lParam != 0) { + MINMAXINFO* mmi = (MINMAXINFO*) lParam; + mmi->ptMinTrackSize.x = w->minWidth; + mmi->ptMinTrackSize.y = w->minHeight; + return 0; + } + return DefWindowProcW(hwnd, msg, wParam, lParam); + case WM_MOVE: { + int now; + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_MOVED, 0, 0, 0); + now = cn1WinMonitorIndexForHwnd(hwnd); + if (now != w->monitorIndex) { + w->monitorIndex = now; + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_MONITOR, 0, 0, now); + } + return 0; + } + case WM_DPICHANGED: + /* Windows hands us the rectangle the window should occupy at the new + * scale; honouring it is what keeps a drag between mixed-DPI displays + * from leaving the window the wrong physical size. */ + if (lParam != 0) { + RECT* suggested = (RECT*) lParam; + SetWindowPos(hwnd, NULL, suggested->left, suggested->top, + suggested->right - suggested->left, + suggested->bottom - suggested->top, + SWP_NOZORDER | SWP_NOACTIVATE); + } + w->monitorIndex = cn1WinMonitorIndexForHwnd(hwnd); + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_MONITOR, 0, 0, w->monitorIndex); + return 0; + case WM_ACTIVATE: + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_FOCUS, 0, 0, + LOWORD(wParam) == WA_INACTIVE ? 0 : 1); + return 0; + case WM_CLOSE: + /* Never destroy here. Codename One decides, because an application + * may veto the close from a listener. */ + cn1WinPushWindowEvent(w->windowId, CN1_EVENT_WINDOW_CLOSE, 0, 0, 0); + return 0; + case WM_DESTROY: + /* Deliberately no PostQuitMessage: only the main window ends the + * message loop. A secondary window closing must not exit the app. */ + return 0; + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } +} + +/* ------------------------------------------------------- create / destroy */ + +static void cn1WinDesktopEnsureClass(void) { + WNDCLASSEXW wc; + if (g_classRegistered) { + return; + } + ZeroMemory(&wc, sizeof(wc)); + wc.cbSize = sizeof(wc); + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = cn1WinDesktopWndProc; + wc.hInstance = GetModuleHandleW(NULL); + wc.hCursor = LoadCursorW(NULL, (LPCWSTR) IDC_ARROW); + wc.lpszClassName = L"CodenameOneDesktopWindow"; + RegisterClassExW(&wc); + g_classRegistered = 1; +} + +static void cn1WinDesktopCreateOnPump(CN1DesktopWindowOp* op) { + CN1DesktopWindow* w = &g_windows[op->slot]; + DWORD style; + int titleLen; + WCHAR* wTitle; + D2D1_RENDER_TARGET_PROPERTIES rtProps; + D2D1_HWND_RENDER_TARGET_PROPERTIES hwndProps; + RECT rc; + + cn1WinDesktopEnsureClass(); + + ZeroMemory(w, sizeof(*w)); + w->windowId = op->windowId; + w->inUse = 1; + /* Seeded here so a later decoration change knows what to restore. */ + w->resizable = op->resizable ? 1 : 0; + + style = op->decorated ? WS_OVERLAPPEDWINDOW : WS_POPUP; + if (op->decorated && !op->resizable) { + style &= ~(WS_THICKFRAME | WS_MAXIMIZEBOX); + } + + titleLen = MultiByteToWideChar(CP_UTF8, 0, op->utf8Title, -1, NULL, 0); + if (titleLen <= 0) { + titleLen = 1; + } + wTitle = (WCHAR*) malloc((size_t) titleLen * sizeof(WCHAR)); + MultiByteToWideChar(CP_UTF8, 0, op->utf8Title, -1, wTitle, titleLen); + + /* WS_CLIPCHILDREN for the same reason the main window uses it: the Direct2D + * present must not paint over native child controls overlaid on the form + * (the WebView2 peer and the EDIT control used for native text editing). */ + /* An owned window stays above its owner and is minimized with it, which is + * exactly what setOwnerWindow() promises; passing the owner HWND is the only way + * Windows establishes that. Falling back to the main window keeps a window opened + * from the main form on top of it, which is what a user expects of a tool window. */ + { + /* ownerSlot: >= 0 another Codename One window, -2 the application's main + * window, anything else unowned. An unowned window must not be silently + * parented to the main one -- that would minimize it with the main window. */ + CN1DesktopWindow* owner = op->ownerSlot >= 0 ? slotAt(op->ownerSlot) : NULL; + HWND ownerHwnd = owner != NULL ? owner->hwnd + : (op->ownerSlot == -2 ? cn1Win.hwnd : NULL); + w->hwnd = CreateWindowExW(0, L"CodenameOneDesktopWindow", wTitle, + style | WS_CLIPCHILDREN, + op->positionSet ? op->x : CW_USEDEFAULT, + op->positionSet ? op->y : CW_USEDEFAULT, + op->width, op->height, + ownerHwnd, NULL, GetModuleHandleW(NULL), w); + } + free(wTitle); + + if (w->hwnd == NULL) { + w->inUse = 0; + op->result = 0; + return; + } + + GetClientRect(w->hwnd, &rc); + w->width = rc.right - rc.left; + w->height = rc.bottom - rc.top; + + ZeroMemory(&rtProps, sizeof(rtProps)); + rtProps.type = D2D1_RENDER_TARGET_TYPE_DEFAULT; + rtProps.pixelFormat.format = DXGI_FORMAT_B8G8R8A8_UNORM; + rtProps.pixelFormat.alphaMode = D2D1_ALPHA_MODE_PREMULTIPLIED; + + ZeroMemory(&hwndProps, sizeof(hwndProps)); + hwndProps.hwnd = w->hwnd; + hwndProps.pixelSize.width = (UINT32) (w->width > 0 ? w->width : 1); + hwndProps.pixelSize.height = (UINT32) (w->height > 0 ? w->height : 1); + /* RETAIN_CONTENTS for the same reason as the main window: Codename One + * repaints only the dirty region and relies on the rest being preserved. */ + hwndProps.presentOptions = D2D1_PRESENT_OPTIONS_RETAIN_CONTENTS; + + if (FAILED(ID2D1Factory_CreateHwndRenderTarget(cn1Win.d2dFactory, &rtProps, + &hwndProps, &w->target))) { + cn1WindowsLog("desktopWindow: failed to create HWND render target"); + DestroyWindow(w->hwnd); + w->hwnd = NULL; + w->inUse = 0; + op->result = 0; + return; + } + + w->graphics = cn1WinCreateGraphics((ID2D1RenderTarget*) w->target); + if (w->graphics != NULL) { + /* Enables the #5273 flush-region clip clamp, exactly as for the main + * window: a clip set while a component paints is confined to the region + * about to be flushed so a fill cannot escape into the retained surface. */ + w->graphics->isWindowTarget = JAVA_TRUE; + } + w->monitorIndex = cn1WinMonitorIndexForHwnd(w->hwnd); + op->result = 1; +} + +static void cn1WinDesktopDestroyOnPump(CN1DesktopWindowOp* op) { + CN1DesktopWindow* w = slotAt(op->slot); + if (w == NULL) { + return; + } + if (w->graphics != NULL) { + /* cn1WinCreateGraphics mallocs the struct and does not own the target; + * releasing the target below is what frees the Direct2D resources. */ + if (w->graphics->brush != NULL) { + ID2D1SolidColorBrush_Release(w->graphics->brush); + } + free(w->graphics); + w->graphics = NULL; + } + if (w->target != NULL) { + ID2D1HwndRenderTarget_Release(w->target); + w->target = NULL; + } + if (w->hwnd != NULL) { + /* Peers hosted in this window are the application's own HWNDs, reparented here + * by peerInitialized. DestroyWindow destroys a window's children along with + * it, so disposing the window would destroy a browser or a peer component the + * application still holds, leaving its Java side with a dangling handle. + * Detached first, and hidden because SetParent(NULL) makes a window top level + * and an unhidden one would appear on screen by itself. + * + * Re-reading the first child each time rather than walking the sibling chain: + * detaching a child removes it from that chain. Bounded so a SetParent that + * fails cannot spin here. */ + int guard = 0; + HWND child = GetWindow(w->hwnd, GW_CHILD); + while (child != NULL && guard++ < CN1_MAX_DESKTOP_WINDOWS * 64) { + ShowWindow(child, SW_HIDE); + SetParent(child, NULL); + child = GetWindow(w->hwnd, GW_CHILD); + } + DestroyWindow(w->hwnd); + w->hwnd = NULL; + } + w->inUse = 0; + w->windowId = 0; +} + +void cn1WinDesktopHandleMessage(WPARAM wParam, LPARAM lParam) { + CN1DesktopWindowOp* op = (CN1DesktopWindowOp*) lParam; + (void) wParam; + if (op == NULL) { + return; + } + if (op->op == CN1_DW_OP_CREATE) { + cn1WinDesktopCreateOnPump(op); + } else if (op->op == CN1_DW_OP_DESTROY) { + cn1WinDesktopDestroyOnPump(op); + } +} + +/* Applies a pending resize on the drawing thread, mirroring the main window's + * cn1WinApplyPendingResize. Called from the graphics layer before a frame. */ +extern "C" void cn1WinDesktopApplyPendingResize(int slot) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL && w->pendingResize && w->target != NULL) { + D2D1_SIZE_U size; + /* Claimed before the dimensions are read and before the Direct2D call, not + * cleared afterwards. WM_SIZE runs on the window thread and can arm a newer + * request while ID2D1HwndRenderTarget_Resize is still running; clearing at + * the end discarded it. The framework still received the matching + * SIZE_CHANGED, so layout advanced to the new size while the render target + * stayed at the old one -- clipped or black until something resized again. */ + w->pendingResize = 0; + size.width = (UINT32) (w->pendingW > 0 ? w->pendingW : 1); + size.height = (UINT32) (w->pendingH > 0 ? w->pendingH : 1); + ID2D1HwndRenderTarget_Resize(w->target, &size); + } +} + +/* ------------------------------------------------------ WindowsNative bridge */ + +extern "C" { + +JAVA_INT com_codename1_impl_windows_WindowsNative_desktopWindowCreate___int_java_lang_String_int_int_int_int_boolean_boolean_int_boolean_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT windowId, JAVA_OBJECT title, + JAVA_INT x, JAVA_INT y, JAVA_INT width, JAVA_INT height, + JAVA_BOOLEAN decorated, JAVA_BOOLEAN resizable, JAVA_INT ownerSlot, + JAVA_BOOLEAN positionSet) { + CN1DesktopWindowOp op; + int slot = -1; + int iter; + for (iter = 0; iter < CN1_MAX_DESKTOP_WINDOWS; iter++) { + if (!g_windows[iter].inUse) { + slot = iter; + break; + } + } + if (slot < 0 || cn1Win.hwnd == NULL) { + return -1; + } + ZeroMemory(&op, sizeof(op)); + op.op = CN1_DW_OP_CREATE; + op.slot = slot; + op.windowId = windowId; + op.utf8Title = title == JAVA_NULL ? "" : stringToUTF8(threadStateData, title); + op.x = x; + op.y = y; + op.width = width; + op.height = height; + op.decorated = decorated == JAVA_TRUE ? 1 : 0; + op.resizable = resizable == JAVA_TRUE ? 1 : 0; + op.ownerSlot = ownerSlot; + op.positionSet = positionSet == JAVA_TRUE ? 1 : 0; + /* Blocking send: the window must be created on the thread that owns the pump, + * and the caller needs the slot back before it can use it. */ + SendMessageW(cn1Win.hwnd, WM_CN1_DESKTOPWINDOW, 0, (LPARAM) &op); + return op.result ? slot : -1; +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowDestroy___int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1DesktopWindowOp op; + if (cn1Win.hwnd == NULL) { + return; + } + ZeroMemory(&op, sizeof(op)); + op.op = CN1_DW_OP_DESTROY; + op.slot = slot; + SendMessageW(cn1Win.hwnd, WM_CN1_DESKTOPWINDOW, 0, (LPARAM) &op); +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowShow___int_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_BOOLEAN visible) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + ShowWindow(w->hwnd, visible == JAVA_TRUE ? SW_SHOW : SW_HIDE); + if (visible == JAVA_TRUE) { + UpdateWindow(w->hwnd); + } + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetTitle___int_java_lang_String( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_OBJECT title) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL && title != JAVA_NULL) { + const char* utf8 = stringToUTF8(threadStateData, title); + int len = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, NULL, 0); + if (len > 0) { + WCHAR* wide = (WCHAR*) malloc((size_t) len * sizeof(WCHAR)); + MultiByteToWideChar(CP_UTF8, 0, utf8, -1, wide, len); + SetWindowTextW(w->hwnd, wide); + free(wide); + } + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetBounds___int_int_int_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT x, JAVA_INT y, + JAVA_INT width, JAVA_INT height) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + SetWindowPos(w->hwnd, NULL, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE); + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowGetBounds___int_int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_OBJECT out) { + CN1DesktopWindow* w = slotAt(slot); + RECT r; + JAVA_ARRAY_INT* data; + if (w == NULL || out == JAVA_NULL) { + return; + } + if (!GetWindowRect(w->hwnd, &r)) { + return; + } + data = (JAVA_ARRAY_INT*) (*(JAVA_ARRAY) out).data; + if ((*(JAVA_ARRAY) out).length >= 4) { + data[0] = r.left; + data[1] = r.top; + data[2] = r.right - r.left; + data[3] = r.bottom - r.top; + } +} + +/* + * The application's own top-level window in desktop coordinates. + * + * centerOn(Form) needs this: a Form lives in the main window, so centring a window + * over a Form means centring over that window. Without it the framework falls back + * to the monitor work area, which is a different place whenever the main window has + * been moved, resized or simply does not fill the screen. + */ +JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_mainWindowGetBounds___int_1ARRAY_R_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT out) { + RECT r; + JAVA_ARRAY_INT* data; + if (out == JAVA_NULL || cn1Win.hwnd == NULL) { + return JAVA_FALSE; + } + if ((*(JAVA_ARRAY) out).length < 4) { + return JAVA_FALSE; + } + if (!GetWindowRect(cn1Win.hwnd, &r)) { + return JAVA_FALSE; + } + data = (JAVA_ARRAY_INT*) (*(JAVA_ARRAY) out).data; + data[0] = r.left; + data[1] = r.top; + data[2] = r.right - r.left; + data[3] = r.bottom - r.top; + return JAVA_TRUE; +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_desktopWindowGetWidth___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1DesktopWindow* w = slotAt(slot); + return w == NULL ? 0 : w->width; +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_desktopWindowGetHeight___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1DesktopWindow* w = slotAt(slot); + return w == NULL ? 0 : w->height; +} + +JAVA_LONG com_codename1_impl_windows_WindowsNative_desktopWindowGraphics___int_R_long( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1DesktopWindow* w = slotAt(slot); + if (w == NULL) { + return 0; + } + /* Apply any resize the pump recorded, on this (drawing) thread and between + * frames -- the same contract the main window's begin-frame path follows. */ + cn1WinDesktopApplyPendingResize(slot); + return (JAVA_LONG) (intptr_t) w->graphics; +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetResizable___int_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_BOOLEAN resizable) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + LONG_PTR style; + w->resizable = resizable == JAVA_TRUE ? 1 : 0; + style = GetWindowLongPtrW(w->hwnd, GWL_STYLE); + if (resizable == JAVA_TRUE) { + style |= (WS_THICKFRAME | WS_MAXIMIZEBOX); + } else { + style &= ~(WS_THICKFRAME | WS_MAXIMIZEBOX); + } + SetWindowLongPtrW(w->hwnd, GWL_STYLE, style); + SetWindowPos(w->hwnd, NULL, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); + } +} + +/* Adds or removes the title bar and border. Without this setDecorated fell through + * to the SPI's empty default on this port alone, so the Java state said undecorated + * while the window kept its chrome. */ +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetDecorated___int_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_BOOLEAN decorated) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + LONG_PTR style = GetWindowLongPtrW(w->hwnd, GWL_STYLE); + if (decorated == JAVA_TRUE) { + style |= WS_OVERLAPPEDWINDOW; + style &= ~WS_POPUP; + if (!w->resizable) { + /* WS_OVERLAPPEDWINDOW bundles the resize affordances, so restoring + * the chrome would quietly make a fixed window resizable again. */ + style &= ~(WS_THICKFRAME | WS_MAXIMIZEBOX); + } + } else { + /* WS_POPUP rather than merely clearing the caption bits: a window with + * no caption but still WS_OVERLAPPED keeps a thin non-client frame. */ + style &= ~WS_OVERLAPPEDWINDOW; + style |= WS_POPUP; + } + SetWindowLongPtrW(w->hwnd, GWL_STYLE, style); + /* SWP_FRAMECHANGED is what makes the non-client area recompute; without it + * the old chrome stays on screen until something else forces a reframe. */ + SetWindowPos(w->hwnd, NULL, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetAlwaysOnTop___int_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_BOOLEAN onTop) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + SetWindowPos(w->hwnd, onTop == JAVA_TRUE ? HWND_TOPMOST : HWND_NOTOPMOST, + 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetMinimumSize___int_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT width, JAVA_INT height) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + w->minWidth = width; + w->minHeight = height; + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetUtility___int_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_BOOLEAN utility) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + /* WS_EX_TOOLWINDOW is what keeps a palette out of the task bar and the + * Alt-Tab switcher, and gives it the narrower title bar users expect. The + * frame has to be recalculated for the change to show. */ + LONG_PTR ex = GetWindowLongPtrW(w->hwnd, GWL_EXSTYLE); + if (utility == JAVA_TRUE) { + ex |= WS_EX_TOOLWINDOW; + ex &= ~WS_EX_APPWINDOW; + } else { + ex &= ~WS_EX_TOOLWINDOW; + } + SetWindowLongPtrW(w->hwnd, GWL_EXSTYLE, ex); + SetWindowPos(w->hwnd, NULL, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetEnabled___int_boolean( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_BOOLEAN enabled) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + EnableWindow(w->hwnd, enabled == JAVA_TRUE ? TRUE : FALSE); + } +} + +/* Disables or re-enables the main window, which is how an application-modal + * Codename One window gets the platform's own modal behaviour on top of the + * framework's input blocking. */ +JAVA_VOID com_codename1_impl_windows_WindowsNative_mainWindowSetEnabled___boolean( + CODENAME_ONE_THREAD_STATE, JAVA_BOOLEAN enabled) { + if (cn1Win.hwnd != NULL) { + EnableWindow(cn1Win.hwnd, enabled == JAVA_TRUE ? TRUE : FALSE); + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowFocus___int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + SetForegroundWindow(w->hwnd); + SetFocus(w->hwnd); + } +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_desktopWindowSetState___int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot, JAVA_INT state) { + CN1DesktopWindow* w = slotAt(slot); + if (w != NULL) { + /* 0 restore, 1 minimize, 2 toggle maximize */ + if (state == 1) { + ShowWindow(w->hwnd, SW_MINIMIZE); + } else if (state == 2) { + WINDOWPLACEMENT pl; + ZeroMemory(&pl, sizeof(pl)); + pl.length = sizeof(pl); + GetWindowPlacement(w->hwnd, &pl); + ShowWindow(w->hwnd, pl.showCmd == SW_SHOWMAXIMIZED ? SW_RESTORE : SW_MAXIMIZE); + } else { + ShowWindow(w->hwnd, SW_RESTORE); + } + } +} + +/* ---- monitors ---- */ + +JAVA_INT com_codename1_impl_windows_WindowsNative_monitorCount___R_int( + CODENAME_ONE_THREAD_STATE) { + CN1MonitorTable t; + cn1WinRefreshMonitors(); + cn1WinMonitorSnapshot(&t); + return t.count; +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_primaryMonitor___R_int( + CODENAME_ONE_THREAD_STATE) { + CN1MonitorTable t; + cn1WinRefreshMonitors(); + cn1WinMonitorSnapshot(&t); + return t.primary; +} + +JAVA_VOID com_codename1_impl_windows_WindowsNative_monitorBounds___int_boolean_int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_INT monitor, JAVA_BOOLEAN workArea, JAVA_OBJECT out) { + JAVA_ARRAY_INT* data; + RECT r; + CN1MonitorTable t; + if (out == JAVA_NULL) { + return; + } + cn1WinRefreshMonitors(); + cn1WinMonitorSnapshot(&t); + if (monitor < 0 || monitor >= t.count) { + monitor = t.primary; + } + r = workArea == JAVA_TRUE ? t.work[monitor] : t.bounds[monitor]; + data = (JAVA_ARRAY_INT*) (*(JAVA_ARRAY) out).data; + if ((*(JAVA_ARRAY) out).length >= 4) { + data[0] = r.left; + data[1] = r.top; + data[2] = r.right - r.left; + data[3] = r.bottom - r.top; + } +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_monitorDpi___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT monitor) { + cn1WinRefreshMonitors(); + return cn1WinMonitorDpi(monitor); +} + +JAVA_INT com_codename1_impl_windows_WindowsNative_monitorForWindow___int_R_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT slot) { + CN1DesktopWindow* w = slotAt(slot); + if (w == NULL) { + CN1MonitorTable t; + cn1WinRefreshMonitors(); + cn1WinMonitorSnapshot(&t); + return t.primary; + } + return cn1WinMonitorIndexForHwnd(w->hwnd); +} + +/* The application's main window has no desktop-window slot, so its monitor cannot + * be asked for through monitorForWindow. Without this, everything positioned + * against the main form reported the primary monitor even after the application + * had been dragged to a second display. */ +JAVA_INT com_codename1_impl_windows_WindowsNative_monitorForMainWindow___R_int( + CODENAME_ONE_THREAD_STATE) { + if (cn1Win.hwnd == NULL) { + CN1MonitorTable t; + cn1WinRefreshMonitors(); + cn1WinMonitorSnapshot(&t); + return t.primary; + } + return cn1WinMonitorIndexForHwnd(cn1Win.hwnd); +} + +} /* extern "C" */ diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_edit.c b/Ports/WindowsPort/nativeSources/cn1_windows_edit.c index 2c216d08c1d..120bdab3802 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_edit.c +++ b/Ports/WindowsPort/nativeSources/cn1_windows_edit.c @@ -45,6 +45,7 @@ typedef struct CN1Edit { HWND hwnd; /* the EDIT control, created on the pump thread */ + int slot; /* owning desktop window, or -1 for the main window */ int x, y, w, h; JAVA_BOOLEAN singleLine; int maxSize; @@ -139,8 +140,15 @@ void cn1WinEditHandleMessage(WPARAM op, LPARAM lp) { /* No WS_EX_CLIENTEDGE: the Codename One field already draws its border and * background around the control's (padding-inset) text area, so the EDIT is * borderless and colour-matched to blend in. */ + /* Parented to the window the field actually lives in. With the main HWND + * hard-coded here the editor appeared over the main window while the user + * was typing into a secondary one. */ + HWND host = e->slot >= 0 ? cn1WinDesktopHwnd(e->slot) : cn1Win.hwnd; + if (host == NULL) { + host = cn1Win.hwnd; + } e->hwnd = CreateWindowExW(0, L"EDIT", L"", style, - e->x, e->y, e->w, e->h, cn1Win.hwnd, NULL, GetModuleHandleW(NULL), NULL); + e->x, e->y, e->w, e->h, host, NULL, GetModuleHandleW(NULL), NULL); if (e->hwnd != NULL) { g_currentEdit = e; SetWindowLongPtrW(e->hwnd, GWLP_USERDATA, (LONG_PTR) e); @@ -196,10 +204,10 @@ void cn1WinEditHandleMessage(WPARAM op, LPARAM lp) { } } -JAVA_LONG com_codename1_impl_windows_WindowsNative_editStringAt___int_int_int_int_java_lang_String_boolean_int_long_int_int_int_R_long( +JAVA_LONG com_codename1_impl_windows_WindowsNative_editStringAt___int_int_int_int_java_lang_String_boolean_int_long_int_int_int_int_R_long( CODENAME_ONE_THREAD_STATE, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h, JAVA_OBJECT text, JAVA_BOOLEAN singleLine, JAVA_INT maxSize, JAVA_LONG fontPeer, - JAVA_INT fgColor, JAVA_INT bgColor, JAVA_INT align) { + JAVA_INT fgColor, JAVA_INT bgColor, JAVA_INT align, JAVA_INT slot) { /* No host window (headless screenshot mode) -> no native editing surface. */ if (cn1Win.hwnd == NULL) { return 0; @@ -215,6 +223,7 @@ JAVA_LONG com_codename1_impl_windows_WindowsNative_editStringAt___int_int_int_in e->singleLine = singleLine; e->maxSize = maxSize; e->align = align; + e->slot = slot; e->font = (void*) (intptr_t) fontPeer; if (fgColor >= 0 && bgColor >= 0) { e->fg = cn1EditColorRef(fgColor); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_peer.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_peer.cpp index 5d84adfb520..a8d16dbf884 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_peer.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_peer.cpp @@ -60,12 +60,21 @@ static HWND cn1PeerHwnd(JAVA_LONG peer) { return (HWND) (intptr_t) peer; } -/* Reparent the app's HWND onto the host window and place + show it. */ -JAVA_VOID com_codename1_impl_windows_WindowsNative_peerInitialized___long_int_int_int_int( - CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h) { +/* Reparent the app's HWND onto its owning window and place + show it. The slot is + * the window the component is in, -1 for the main one; a slot whose window has gone + * answers NULL, and SetParent(NULL) would make the peer a top level window of its + * own, so both fall back to the main window. */ +JAVA_VOID com_codename1_impl_windows_WindowsNative_peerInitialized___long_int_int_int_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_LONG peer, JAVA_INT slot, JAVA_INT x, JAVA_INT y, + JAVA_INT w, JAVA_INT h) { HWND hwnd = cn1PeerHwnd(peer); + HWND host; if (!hwnd || !IsWindow(hwnd)) return; - SetParent(hwnd, cn1Win.hwnd); + host = slot >= 0 ? cn1WinDesktopHwnd(slot) : NULL; + if (!host) { + host = cn1Win.hwnd; + } + SetParent(hwnd, host); LONG_PTR style = GetWindowLongPtrW(hwnd, GWL_STYLE); style = (style | WS_CHILD) & ~(WS_POPUP | WS_OVERLAPPED); SetWindowLongPtrW(hwnd, GWL_STYLE, style); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_screenshot.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_screenshot.cpp index bcdb494fb65..b6af54057eb 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_screenshot.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_screenshot.cpp @@ -249,6 +249,101 @@ static JAVA_OBJECT cn1WinWicSourceToPngBytes(CODENAME_ONE_THREAD_STATE, IWICBitm return result; } +/* + * Encodes one secondary window's client area to PNG bytes. + * + * Not the WIC path the main surface uses: a secondary window draws into an + * ID2D1HwndRenderTarget, which Direct2D gives no readback for, so there is no + * bitmap to hand the encoder. PrintWindow asks the window to render itself into + * a DC instead, and PW_RENDERFULLCONTENT is the part that matters -- without it + * a Direct2D / DirectComposition surface comes back blank. PW_CLIENTONLY keeps + * the frame out, so the result is the same rectangle the framework laid out and + * the goldens are sized to. + * + * The point of doing this at all is that it reads back what the window is + * actually showing, native peers and editors included. Window.capture() falls + * back to re-rendering the component hierarchy when this returns null, and that + * fallback draws what the window *should* show -- it cannot tell a correct + * window from one whose raster and hierarchy disagree, and it never contains a + * peer or a native editor at all. + */ +#ifndef PW_RENDERFULLCONTENT +/* Windows 8.1 and later. Defined here rather than assumed, because an older SDK + * header omits it and the flag is the difference between a captured Direct2D + * surface and a blank one -- a build that quietly dropped it would produce empty + * goldens rather than a compile error. */ +#define PW_RENDERFULLCONTENT 0x00000002 +#endif + +JAVA_OBJECT com_codename1_impl_windows_WindowsNative_captureDesktopWindowToPngBytes___int_R_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_INT __cn1Arg1) { + HWND hwnd = cn1WinDesktopHwnd((int) __cn1Arg1); + if (hwnd == NULL) { + cn1WindowsLog("captureDesktopWindowToPngBytes: no window for slot"); + return JAVA_NULL; + } + IWICImagingFactory* wic = cn1ShotWicFactory(); + if (wic == NULL) { + return JAVA_NULL; + } + RECT client; + if (!GetClientRect(hwnd, &client)) { + return JAVA_NULL; + } + int width = (int) (client.right - client.left); + int height = (int) (client.bottom - client.top); + if (width <= 0 || height <= 0) { + cn1WindowsLog("captureDesktopWindowToPngBytes: window has no client area yet"); + return JAVA_NULL; + } + + JAVA_OBJECT result = JAVA_NULL; + HDC windowDC = GetDC(hwnd); + if (windowDC != NULL) { + HDC memDC = CreateCompatibleDC(windowDC); + if (memDC != NULL) { + BITMAPINFO info; + ZeroMemory(&info, sizeof(info)); + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + /* Negative: a top-down DIB, matching the row order WIC expects. */ + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* bits = NULL; + HBITMAP dib = CreateDIBSection(memDC, &info, DIB_RGB_COLORS, &bits, NULL, 0); + if (dib != NULL && bits != NULL) { + HGDIOBJ previous = SelectObject(memDC, dib); + if (PrintWindow(hwnd, memDC, PW_CLIENTONLY | PW_RENDERFULLCONTENT)) { + /* GDI flushes lazily and the bits are read straight out of the + * section below, so the drawing has to be finished first. */ + GdiFlush(); + UINT stride = (UINT) width * 4; + UINT total = stride * (UINT) height; + IWICBitmap* bitmap = NULL; + if (SUCCEEDED(wic->CreateBitmapFromMemory((UINT) width, (UINT) height, + GUID_WICPixelFormat32bppBGRA, stride, total, + (BYTE*) bits, &bitmap)) && bitmap != NULL) { + result = cn1WinWicSourceToPngBytes(threadStateData, + (IWICBitmapSource*) bitmap); + bitmap->Release(); + } + } else { + cn1WindowsLog("captureDesktopWindowToPngBytes: PrintWindow failed"); + } + SelectObject(memDC, previous); + } + if (dib != NULL) { + DeleteObject(dib); + } + DeleteDC(memDC); + } + ReleaseDC(hwnd, windowDC); + } + return result; +} + /* * Encodes the current window's render target to PNG bytes. In headless / * offscreen mode the window target is a WIC bitmap (drawn into by the EDT's diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp index 661e2cf8f06..6fd41ce1f49 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp @@ -343,10 +343,182 @@ WCHAR* cn1WinJavaStringToWide(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str, UINT32 /* ------------------------------------------------------------ event queue */ void cn1WinPushEvent(CN1EventType type, int x, int y, int keyCode) { + cn1WinPushWindowEvent(0, type, x, y, keyCode); +} + +/* Events the framework cannot reconstruct if they are lost, of which there are two + * kinds. + * + * Lifecycle: a lost hide leaves a window the framework believes is on screen, painting + * and animating until something else happens to it, and a lost close leaves it + * registered with no native window behind it. + * + * Terminations: a release ends something a press started. Lose it and the component + * the press went to stays in that state for good -- the key goes on repeating, the + * button stays down, the drag never finishes -- and the focus change that would + * otherwise cancel a held gesture is no use as a backstop if it is droppable too. + * + * Note the asymmetry with presses, which stay droppable: a release that arrives with + * no press behind it finds no recorded target and is discarded harmlessly, so when + * something has to go it must never be the release. */ +static int cn1WinIsProtectedEvent(CN1EventType type) { + return type == CN1_EVENT_WINDOW_SHOWN || type == CN1_EVENT_WINDOW_HIDDEN + || type == CN1_EVENT_WINDOW_CLOSE + || type == CN1_EVENT_KEY_RELEASED + || type == CN1_EVENT_POINTER_RELEASED + || type == CN1_EVENT_WINDOW_FOCUS + || type == CN1_EVENT_SIZE_CHANGED; +} + +/* Visibility only. A close request is protected from eviction like any other + * lifecycle event, but it is not a state that a later one supersedes: WM_CLOSE does + * not destroy the window, so a close that a subsequent minimize overwrote would take + * the close listener and the close operation with it. */ +static int cn1WinStateClass(CN1EventType type) { + if (type == CN1_EVENT_WINDOW_SHOWN || type == CN1_EVENT_WINDOW_HIDDEN) { + return 1; + } + if (type == CN1_EVENT_SIZE_CHANGED) { + return 2; + } + return 0; +} + +/* Replaces a queued visibility event for the same window with this newer one. The + * latest state is the one that matters -- a hide followed by a show leaves the window + * shown -- so superseding costs nothing and needs no room. */ +static int cn1WinCoalesceLifecycleLocked(int windowId, CN1EventType type, int x, int y, + int keyCode) { + LONG idx = cn1Win.eventHead; + LONG newest = -1; + int cls = cn1WinStateClass(type); + if (cls == 0) { + return 0; + } + /* The *newest* match, not the first one found. A window can already have more than + * one transition queued -- a hide then a show -- and replacing the older of the two + * leaves the newer one as the last word, so the framework would end up believing a + * window that is natively hidden is on screen, and go on painting it. */ + while (idx != cn1Win.eventTail) { + CN1Event* e = &cn1Win.events[idx]; + if (e->windowId == windowId && cn1WinStateClass((CN1EventType) e->type) == cls) { + newest = idx; + } + idx = (idx + 1) % CN1_EVENT_QUEUE_CAPACITY; + } + if (newest < 0) { + return 0; + } + cn1Win.events[newest].type = (JAVA_INT) type; + cn1Win.events[newest].x = x; + cn1Win.events[newest].y = y; + cn1Win.events[newest].keyCode = keyCode; + return 1; +} + +/* Removes the oldest droppable event, closing the gap. Used to make room for a + * protected one: advancing the head instead would evict whatever is oldest, and that + * can be a protected event itself -- which is the very thing being kept. */ +static void cn1WinRemoveAtLocked(LONG idx) { + LONG cur = idx; + LONG follow = (cur + 1) % CN1_EVENT_QUEUE_CAPACITY; + while (follow != cn1Win.eventTail) { + cn1Win.events[cur] = cn1Win.events[follow]; + cur = follow; + follow = (follow + 1) % CN1_EVENT_QUEUE_CAPACITY; + } + cn1Win.eventTail = cur; +} + +static int cn1WinEvictInputLocked(void) { + LONG idx = cn1Win.eventHead; + while (idx != cn1Win.eventTail) { + if (!cn1WinIsProtectedEvent((CN1EventType) cn1Win.events[idx].type)) { + cn1WinRemoveAtLocked(idx); + return 1; + } + idx = (idx + 1) % CN1_EVENT_QUEUE_CAPACITY; + } + return 0; +} + +/* Last resort when the queue holds nothing but lifecycle events and so has no input to + * give up. A window that toggled visibility several times before the framework drained + * anything has more than one transition queued, and every one but its last is already + * superseded, so dropping the oldest of them frees a slot without changing what any + * window ends up as. Without this a close arriving for a *different* window has nowhere + * to go and is dropped, which is the one outcome this whole path exists to prevent. */ +static int cn1WinEvictSupersededVisibilityLocked(void) { + LONG idx = cn1Win.eventHead; + while (idx != cn1Win.eventTail) { + CN1Event* e = &cn1Win.events[idx]; + int cls = cn1WinStateClass((CN1EventType) e->type); + if (cls != 0) { + LONG scan = (idx + 1) % CN1_EVENT_QUEUE_CAPACITY; + while (scan != cn1Win.eventTail) { + CN1Event* later = &cn1Win.events[scan]; + if (later->windowId == e->windowId + && cn1WinStateClass((CN1EventType) later->type) == cls) { + cn1WinRemoveAtLocked(idx); + return 1; + } + scan = (scan + 1) % CN1_EVENT_QUEUE_CAPACITY; + } + } + idx = (idx + 1) % CN1_EVENT_QUEUE_CAPACITY; + } + return 0; +} + +/* Last resort before giving up an entry outright: drop the oldest *termination*. + * + * When the queue cannot grow, the question is only which loss costs least, and the + * order is droppable input, then a state a later event already supersedes, then a + * termination, then a lifecycle event. A lost release latches one component; a lost + * close or hide loses a whole window -- the close operation never runs, or the + * framework goes on painting a window that is not on screen. So a queued close or + * visibility transition outranks any number of releases behind it. */ +static int cn1WinEvictOldestTerminationLocked(void) { + LONG idx = cn1Win.eventHead; + while (idx != cn1Win.eventTail) { + CN1EventType t = (CN1EventType) cn1Win.events[idx].type; + if (t == CN1_EVENT_KEY_RELEASED || t == CN1_EVENT_POINTER_RELEASED + || t == CN1_EVENT_WINDOW_FOCUS) { + cn1WinRemoveAtLocked(idx); + return 1; + } + idx = (idx + 1) % CN1_EVENT_QUEUE_CAPACITY; + } + return 0; +} + +void cn1WinPushWindowEvent(int windowId, CN1EventType type, int x, int y, int keyCode) { EnterCriticalSection(&cn1Win.eventLock); LONG next = (cn1Win.eventTail + 1) % CN1_EVENT_QUEUE_CAPACITY; + if (next == cn1Win.eventHead && cn1WinIsProtectedEvent(type)) { + /* Full, and this one must not be the casualty. Supersede this window's own + * queued transition if it has one, otherwise take the room from an input event, + * and failing that from a transition that a later one already supersedes. Never + * from a transition that is still some window's last word. */ + if (cn1WinCoalesceLifecycleLocked(windowId, type, x, y, keyCode)) { + LeaveCriticalSection(&cn1Win.eventLock); + return; + } + if (cn1WinEvictInputLocked() || cn1WinEvictSupersededVisibilityLocked() + || cn1WinEvictOldestTerminationLocked()) { + next = (cn1Win.eventTail + 1) % CN1_EVENT_QUEUE_CAPACITY; + } else { + /* Nothing left but lifecycle events -- closes and visibility transitions + * for more distinct windows than the queue can hold, which needs more + * windows open than any application has. Giving up the oldest is all that + * remains, and the newer event at least describes the more recent state. */ + cn1Win.eventHead = (cn1Win.eventHead + 1) % CN1_EVENT_QUEUE_CAPACITY; + next = (cn1Win.eventTail + 1) % CN1_EVENT_QUEUE_CAPACITY; + } + } if (next != cn1Win.eventHead) { CN1Event* e = &cn1Win.events[cn1Win.eventTail]; + e->windowId = windowId; e->type = (JAVA_INT) type; e->x = x; e->y = y; @@ -354,8 +526,9 @@ void cn1WinPushEvent(CN1EventType type, int x, int y, int keyCode) { cn1Win.eventTail = next; SetEvent(cn1Win.eventSignal); } - /* On overflow the oldest unread events are kept and the newest dropped; - * the EDT drains continuously so this is only a backstop. */ + /* On overflow a droppable event is simply lost -- the newest is dropped and the + * queued ones kept, and the EDT drains continuously so this is only a backstop. A + * protected event never reaches here without room, having taken it above. */ LeaveCriticalSection(&cn1Win.eventLock); } @@ -410,7 +583,9 @@ static int cn1WinMoveMask(WPARAM wParam) { * MI_WP_SIGNATURE 0xFF515700, with bit 0x80 distinguishing pen from touch). We * use it to flag the synthesized mouse event as a touch / pen so the * cross-platform PointerEvent type is correct on touch-enabled PCs. */ -static int cn1WinTouchFlag(void) { +/* Shared with the desktop window proc so a touch or pen contact keeps its source + * inside a Window, rather than arriving classified as a mouse. */ +int cn1WinTouchFlag(void) { LONG_PTR extra = GetMessageExtraInfo(); if ((extra & 0xFFFFFF00) == 0xFF515700) { return (extra & 0x80) ? CN1_PE_PEN_FLAG : CN1_PE_TOUCH_FLAG; @@ -422,11 +597,16 @@ static int cn1WinTouchFlag(void) { /* macOS-style trackpad / touchscreen pinch and rotate via the Win32 gesture API. * GID_ZOOM reports the absolute distance between the fingers and GID_ROTATE the * absolute angle (radians); we forward the incremental scale / radians since the - * cross-platform pinch() / rotation() callbacks expect deltas like the Mac. */ + * cross-platform pinch() / rotation() callbacks expect deltas like the Mac. + * The baselines are process wide rather than per window, which is correct because + * there is one touchpad: a gesture that starts over another window sends GF_BEGIN + * first, which is what resets them. Shared by the main window proc and the desktop + * window proc, so a pinch over a secondary window produces a gesture too -- the + * windowId is what decides whose component tree it reaches. */ static double cn1WinZoomLast = 0.0; static double cn1WinRotateLast = 0.0; -static int cn1WinHandleGesture(HWND hwnd, LPARAM lParam) { +int cn1WinHandleGesture(HWND hwnd, int windowId, LPARAM lParam) { GESTUREINFO gi; ZeroMemory(&gi, sizeof(gi)); gi.cbSize = sizeof(gi); @@ -445,7 +625,8 @@ static int cn1WinHandleGesture(HWND hwnd, LPARAM lParam) { } else if (cn1WinZoomLast > 0.0 && dist > 0.0) { double scale = dist / cn1WinZoomLast; cn1WinZoomLast = dist; - cn1WinPushEvent(CN1_EVENT_PINCH, pt.x, pt.y, (int) (scale * CN1_GESTURE_FIXED + 0.5)); + cn1WinPushWindowEvent(windowId, CN1_EVENT_PINCH, pt.x, pt.y, + (int) (scale * CN1_GESTURE_FIXED + 0.5)); } handled = 1; } else if (gi.dwID == GID_ROTATE) { @@ -455,7 +636,7 @@ static int cn1WinHandleGesture(HWND hwnd, LPARAM lParam) { double angle = GID_ROTATE_ANGLE_FROM_ARGUMENT(gi.ullArguments); double delta = angle - cn1WinRotateLast; cn1WinRotateLast = angle; - cn1WinPushEvent(CN1_EVENT_ROTATE, pt.x, pt.y, + cn1WinPushWindowEvent(windowId, CN1_EVENT_ROTATE, pt.x, pt.y, (int) (delta * CN1_GESTURE_FIXED + (delta >= 0 ? 0.5 : -0.5))); } handled = 1; @@ -527,7 +708,7 @@ LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam } #ifdef WM_GESTURE case WM_GESTURE: - if (cn1WinHandleGesture(hwnd, lParam)) { + if (cn1WinHandleGesture(hwnd, 0, lParam)) { return 0; } return DefWindowProcW(hwnd, msg, wParam, lParam); @@ -553,6 +734,12 @@ LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam case WM_KEYUP: cn1WinPushEvent(CN1_EVENT_KEY_RELEASED, 0, 0, (int) wParam); return 0; + case WM_DISPLAYCHANGE: + /* Handled on the main window too, not only on the desktop windows: an + * application can attach a monitor listener before it has opened any + * secondary window, and a display change has to reach it either way. */ + cn1WinPushEvent(CN1_EVENT_MONITORS_CHANGED, 0, 0, 0); + return 0; case WM_SIZE: cn1Win.width = LOWORD(lParam); cn1Win.height = HIWORD(lParam); @@ -603,6 +790,14 @@ LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam * while the printing worker blocks in SendMessage * (cn1_windows_print.cpp). */ return cn1WinPrintDialogHandleMessage(wParam); + case WM_CN1_DESKTOPWINDOW: + /* Additional desktop window create/destroy, marshaled from the EDT. + * The pump thread must own the HWND, so this is where they are made + * (cn1_windows_desktopwindow.cpp). Secondary windows have their own + * WndProc; only the op dispatch lives here, because an op arrives + * before its window exists. */ + cn1WinDesktopHandleMessage(wParam, lParam); + return 0; case WM_CN1_WIDGET: /* Floating widget window op (create/pixels/pos/hit-rects/destroy) * marshaled from the EDT (cn1_windows_widgets.cpp). The widget @@ -943,6 +1138,7 @@ JAVA_BOOLEAN com_codename1_impl_windows_WindowsNative_pollEvent___int_1ARRAY_R_b if (len > 1) { out[1] = ev.x; } if (len > 2) { out[2] = ev.y; } if (len > 3) { out[3] = ev.keyCode; } + if (len > 4) { out[4] = ev.windowId; } return JAVA_TRUE; } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsBrowserComponent.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsBrowserComponent.java index 4025ac37f27..5789611420c 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsBrowserComponent.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsBrowserComponent.java @@ -1,3 +1,25 @@ +/* + * 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.windows; import com.codename1.ui.BrowserComponent; @@ -60,9 +82,17 @@ protected Dimension calcPreferredSize() { @Override protected void initComponent() { super.initComponent(); + // Resolved here rather than at construction: a BrowserComponent is routinely + // built while detached, and the WebView2 controller was created against the + // main window's HWND regardless, so the view appeared and took input over the + // main window instead of the one the browser is in. + WindowsNative.browserSetHost(peer, WindowsWindowManager.slotForComponent(this)); WindowsNative.browserSetBounds(peer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); - if (poller == null && getComponentForm() != null) { - poller = UITimer.timer(60, true, getComponentForm(), new Runnable() { + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so this timer never started for a peer hosted in one. + com.codename1.ui.TopLevelContainer peerTop = getTopLevelContainer(); + if (poller == null && peerTop != null) { + poller = UITimer.timer(60, true, peerTop, new Runnable() { public void run() { poll(); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsCameraImpl.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsCameraImpl.java index a9a55073ff3..7b3ade46770 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsCameraImpl.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsCameraImpl.java @@ -1,3 +1,25 @@ +/* + * 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.windows; import com.codename1.camera.CameraFacing; @@ -298,10 +320,13 @@ protected Dimension calcPreferredSize() { @Override protected void initComponent() { super.initComponent(); - if (poller == null && getComponentForm() != null) { + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so this timer never started for a peer hosted in one. + com.codename1.ui.TopLevelContainer peerTop = getTopLevelContainer(); + if (poller == null && peerTop != null) { int fps = Math.max(1, frameMaxFps); int periodMs = Math.max(33, 1000 / fps); - poller = UITimer.timer(periodMs, true, getComponentForm(), new Runnable() { + poller = UITimer.timer(periodMs, true, peerTop, new Runnable() { @Override public void run() { refresh(); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGLSurface.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGLSurface.java index 7c473c04d05..8f24f381830 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGLSurface.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGLSurface.java @@ -6,6 +6,19 @@ * 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.windows; @@ -50,8 +63,11 @@ long getContextPeer() { void setContinuous(boolean continuous) { this.continuous = continuous; if (continuous) { - if (animationTimer == null && getComponentForm() != null) { - animationTimer = UITimer.timer(16, true, getComponentForm(), new Runnable() { + // The top level rather than the form: getComponentForm() is null by design + // inside a Window, so this timer never started for a peer hosted in one. + com.codename1.ui.TopLevelContainer peerTop = getTopLevelContainer(); + if (animationTimer == null && peerTop != null) { + animationTimer = UITimer.timer(16, true, peerTop, new Runnable() { public void run() { repaint(); } diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGenericPeer.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGenericPeer.java index 3d7d497e00b..93245fc86be 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGenericPeer.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsGenericPeer.java @@ -1,3 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + package com.codename1.impl.windows; import com.codename1.ui.Display; @@ -32,7 +55,11 @@ long peer() { @Override protected void initComponent() { super.initComponent(); - WindowsNative.peerInitialized(peer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); + // The owning window, resolved here rather than at construction: a peer is + // routinely built while detached, and every peer was previously reparented + // onto the main window's HWND regardless of the window it is in. + WindowsNative.peerInitialized(peer, WindowsWindowManager.slotForComponent(this), + getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); } @Override diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index 90b9189f870..1f9e49f1bea 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -22,6 +22,7 @@ */ package com.codename1.impl.windows; +import com.codename1.ui.Desktop; import com.codename1.impl.CodenameOneImplementation; import com.codename1.impl.WebSocketImpl; import com.codename1.io.Util; @@ -67,6 +68,11 @@ */ public class WindowsImplementation extends CodenameOneImplementation { + /// Slot value meaning "the application's main window" for the native editor. + /// Matches the -1 the native side tests for. + static final int MAIN_WINDOW_SLOT = -1; + + @Override public boolean isHighContrastEnabled() { return WindowsNative.isHighContrastEnabled(); @@ -96,6 +102,14 @@ public boolean isScreenReaderEnabled() { private static final int EVENT_PINCH = 10; private static final int EVENT_ROTATE = 11; private static final int EVENT_ACCESSIBILITY_ACTION = 12; + // Additional desktop windows. These always carry a non-zero window id. + private static final int EVENT_WINDOW_CLOSE = 13; + private static final int EVENT_WINDOW_FOCUS = 14; + private static final int EVENT_WINDOW_MONITOR = 15; + private static final int EVENT_WINDOW_SHOWN = 16; + private static final int EVENT_WINDOW_HIDDEN = 17; + private static final int EVENT_WINDOW_MOVED = 18; + private static final int EVENT_MONITORS_CHANGED = 19; // The native gesture events encode their float (incremental scale / radians) as // an int in 1/10000 units; see CN1_GESTURE_FIXED in cn1_windows.h. @@ -112,7 +126,7 @@ public boolean isScreenReaderEnabled() { private Long defaultFont; private L10NManager l10n; private com.codename1.ui.util.ImageIO imageIO; - private final int[] eventScratch = new int[4]; + private final int[] eventScratch = new int[5]; private final Map accessibilityActionTokens = new HashMap(); private final Map accessibilityActionTargets = new HashMap(); @@ -130,6 +144,19 @@ public WindowsImplementation() { * The single live implementation instance, or {@code null} before the port * has been constructed. */ + private WindowsWindowManager windowManager; + + /** + * @inheritDoc + */ + @Override + public com.codename1.impl.WindowManager getWindowManager() { + if (windowManager == null) { + windowManager = new WindowsWindowManager(); + } + return windowManager; + } + public static WindowsImplementation getInstance() { return INSTANCE; } @@ -761,47 +788,75 @@ private void drainInput() { int x = eventScratch[1]; int y = eventScratch[2]; int key = eventScratch[3]; + // Zero is the main window, which is every event this port produced + // before desktop windows existed. + int windowId = eventScratch[4]; switch (type) { case EVENT_POINTER_PRESSED: markPointer(key); - pointerPressed(x, y); + windowPointerPressed(windowId, x, y); break; case EVENT_POINTER_RELEASED: markPointer(key); - pointerReleased(x, y); + windowPointerReleased(windowId, x, y); break; case EVENT_POINTER_DRAGGED: markPointer(key); - pointerDragged(x, y); + windowPointerDragged(windowId, x, y); break; case EVENT_KEY_PRESSED: - keyPressed(key); + windowKeyPressed(windowId, key); break; case EVENT_KEY_RELEASED: - keyReleased(key); + windowKeyReleased(windowId, key); break; case EVENT_SIZE_CHANGED: - sizeChanged(x, y); + if (windowId == 0) { + sizeChanged(x, y); + } else { + Desktop.getInstance().windowSizeChanged(windowId, x, y); + } + break; + case EVENT_WINDOW_CLOSE: + Desktop.getInstance().windowCloseRequested(windowId); + break; + case EVENT_WINDOW_FOCUS: + Desktop.getInstance().windowFocusChanged(windowId, key != 0); + break; + case EVENT_WINDOW_MONITOR: + Desktop.getInstance().windowMonitorChanged(windowId); + break; + case EVENT_WINDOW_SHOWN: + Desktop.getInstance().windowShowNotify(windowId); + break; + case EVENT_WINDOW_HIDDEN: + Desktop.getInstance().windowHideNotify(windowId); + break; + case EVENT_WINDOW_MOVED: + Desktop.getInstance().windowMoved(windowId); + break; + case EVENT_MONITORS_CHANGED: + Desktop.getInstance().monitorsChanged(); break; case EVENT_MOUSE_WHEEL: // key carries the signed wheel delta (multiple of WHEEL_DELTA). // A forward (positive) notch reveals content above, i.e. drags // the finger down -> positive scrollY. Map through the shared // CodenameOneImplementation.pointerWheelMoved scroll gesture. - pointerWheelMoved(x, y, 0, wheelUnits(key)); + windowPointerWheelMoved(windowId, x, y, 0, wheelUnits(key), false, 0); break; case EVENT_MOUSE_HWHEEL: // A positive horizontal notch tilts right (scrolls content // left), i.e. drags the finger left -> negative scrollX. - pointerWheelMoved(x, y, -wheelUnits(key), 0); + windowPointerWheelMoved(windowId, x, y, -wheelUnits(key), 0, false, 0); break; case EVENT_PINCH: // key is the incremental scale multiplier in 1/10000 units. - Display.getInstance().fireMagnifyGesture(x, y, key / GESTURE_FIXED); + com.codename1.ui.Desktop.getInstance().windowMagnifyGesture(windowId, x, y, key / GESTURE_FIXED); break; case EVENT_ROTATE: // key is the incremental rotation in 1/10000 radians. - Display.getInstance().fireRotationGesture(x, y, key / GESTURE_FIXED); + com.codename1.ui.Desktop.getInstance().windowRotationGesture(windowId, x, y, key / GESTURE_FIXED); break; case EVENT_CLOSE: Display.getInstance().exitApplication(); @@ -2024,8 +2079,12 @@ public void editString(final Component cmp, int maxSize, int constraint, String fontPeer = ((Long) f.getNativeFont()).longValue(); } + // The editor is parented to the window the field lives in; without the slot + // the EDIT control appeared over the main window while the user typed into a + // secondary one. long peer = WindowsNative.editStringAt(x, y, w, h, text == null ? "" : text, - singleLine, maxSize, fontPeer, s.getFgColor(), s.getBgColor(), 0); + singleLine, maxSize, fontPeer, s.getFgColor(), s.getBgColor(), 0, + WindowsWindowManager.slotForComponent(cmp)); if (peer == 0) { // No native window (headless) -> nothing to edit; complete with the // existing text so a caller awaiting the callback still proceeds. @@ -2034,9 +2093,13 @@ public void editString(final Component cmp, int maxSize, int constraint, String } editPeer = peer; editCmp = cmp; - com.codename1.ui.Form form = cmp.getComponentForm(); - if (form != null) { - editPoller = com.codename1.ui.util.UITimer.timer(30, true, form, new Runnable() { + // The top level, not the Form: getComponentForm() is null inside a Window, so + // binding the poller to it meant no timer ran at all there -- the native + // control's text was never streamed back into the field and the edit never + // auto-committed. + com.codename1.ui.TopLevelContainer top = cmp.getTopLevelContainer(); + if (top != null) { + editPoller = com.codename1.ui.util.UITimer.timer(30, true, top, new Runnable() { public void run() { if (editPeer == 0) { return; diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index c023ef7c74a..d342018f022 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -97,6 +97,13 @@ public static native long videoWriterOpen(String outPath, boolean hevc, int widt /** Creates a WebView2-backed browser peer; returns an opaque native handle. */ public static native long browserCreate(int width, int height); + /** + * Re-hosts the browser in the given window. A BrowserComponent is routinely + * constructed while detached, so the window it belongs to is only known once the + * component is initialized in its real hierarchy. + */ + public static native void browserSetHost(long peer, int slot); + public static native void browserSetHtml(long peer, String html); public static native void browserSetUrl(long peer, String url); @@ -137,12 +144,97 @@ public static native void setTransform(long graphics, float m00, float m10, floa float m11, float m02, float m12); /** - * Drains one queued input event into {@code out} ([type, x, y, keyCode]); - * returns true if an event was dequeued. See the {@code CN1_EVENT_*} - * constants in cn1_windows.h for the type codes. + * Drains one queued input event into {@code out} + * ([type, x, y, keyCode, windowId]); returns true if an event was dequeued. + * See the {@code CN1_EVENT_*} constants in cn1_windows.h for the type codes. + * + *

{@code windowId} is zero for the application's main window, which is every + * event this port produced before desktop windows existed. A shorter array is + * still accepted and simply drops the trailing fields.

*/ public static native boolean pollEvent(int[] out); + // ---- additional desktop windows (cn1_windows_desktopwindow.cpp) ---------- + // + // A window is addressed by the slot index returned from desktopWindowCreate; + // the windowId passed in is the framework's own id, which the native layer + // stores and echoes back on every event so input can be routed without a + // lookup on the pump thread. + + /** Creates a hidden native window; returns its slot, or -1 on failure. */ + public static native int desktopWindowCreate(int windowId, String title, int x, int y, + int width, int height, boolean decorated, boolean resizable, int ownerSlot, + boolean positionSet); + + /** Destroys a native window and releases its render target. */ + public static native void desktopWindowDestroy(int slot); + + /** Maps or unmaps a native window. */ + public static native void desktopWindowShow(int slot, boolean visible); + + public static native void desktopWindowSetTitle(int slot, String title); + + public static native void desktopWindowSetBounds(int slot, int x, int y, int width, int height); + + /** Fills {@code out} with x, y, width and height in desktop coordinates. */ + public static native void desktopWindowGetBounds(int slot, int[] out); + + public static native boolean mainWindowGetBounds(int[] out); + + /** Width of the window's drawable area in pixels. */ + public static native int desktopWindowGetWidth(int slot); + + /** Height of the window's drawable area in pixels. */ + public static native int desktopWindowGetHeight(int slot); + + /** + * The window's CN1Graphics pointer. Also applies any resize the pump thread + * recorded, on the calling (drawing) thread and between frames. + */ + public static native long desktopWindowGraphics(int slot); + + public static native void desktopWindowSetResizable(int slot, boolean resizable); + + /// Adds or removes the window's title bar and border. + public static native void desktopWindowSetDecorated(int slot, boolean decorated); + + public static native void desktopWindowSetAlwaysOnTop(int slot, boolean onTop); + + /** The smallest frame the user may drag the window to; 0 clears the constraint. */ + public static native void desktopWindowSetMinimumSize(int slot, int width, int height); + + /** Applies WS_EX_TOOLWINDOW, keeping a palette out of the task bar and Alt-Tab. */ + public static native void desktopWindowSetUtility(int slot, boolean utility); + + /** Enables or disables input, which is how native modality is applied. */ + public static native void desktopWindowSetEnabled(int slot, boolean enabled); + + /** Enables or disables the main window, for an application-modal window. */ + public static native void mainWindowSetEnabled(boolean enabled); + + public static native void desktopWindowFocus(int slot); + + /** 0 restores, 1 minimizes, 2 toggles maximized. */ + public static native void desktopWindowSetState(int slot, int state); + + // ---- monitors ---- + + public static native int monitorCount(); + + public static native int primaryMonitor(); + + /** Fills {@code out} with a monitor's bounds, or its work area when asked. */ + public static native void monitorBounds(int monitor, boolean workArea, int[] out); + + public static native int monitorDpi(int monitor); + + /** The monitor a window currently sits on. */ + public static native int monitorForWindow(int slot); + + /// The monitor the application's main window sits on. The main window has no + /// desktop-window slot, so `#monitorForWindow(int)` cannot answer for it. + public static native int monitorForMainWindow(); + /** Rebuilds the Windows UI Automation virtual fragment tree. */ public static native void accessibilityBegin(); public static native void accessibilityNode(long id, long parentId, String role, String label, @@ -238,6 +330,18 @@ public static native void accessibilityNode(long id, long parentId, String role, */ public static native byte[] captureWindowToPngBytes(); + /** + * PNG bytes of one secondary window's client area, or null when it cannot be + * read. Unlike {@link #captureWindowToPngBytes()} this is a real readback of + * what the window is showing, native peers and editors included -- a secondary + * window draws into an ID2D1HwndRenderTarget, which has no WIC bitmap behind it, + * so it is captured through PrintWindow rather than through the encoder. + * + * @param slot the window's slot in the native window table + * @return the PNG bytes, or null + */ + public static native byte[] captureDesktopWindowToPngBytes(int slot); + /* ----------------------------------------------------- graphics state */ public static native int getColor(long graphics); @@ -381,7 +485,8 @@ public static native void accessibilityNode(long id, long parentId, String role, * {@link #editIsDone(long)}. */ public static native long editStringAt(int x, int y, int w, int h, String text, - boolean singleLine, int maxSize, long fontPeer, int fgColor, int bgColor, int align); + boolean singleLine, int maxSize, long fontPeer, int fgColor, int bgColor, int align, + int slot); /** True once the user has committed the native edit (Enter / focus loss). */ public static native boolean editIsDone(long peer); @@ -868,7 +973,7 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, * {@code @NativeInterface}; these reparent it onto the host window and * move/size/show it to track the lightweight {@link com.codename1.ui.PeerComponent}. */ - public static native void peerInitialized(long peer, int x, int y, int w, int h); + public static native void peerInitialized(long peer, int slot, int x, int y, int w, int h); /** Repositions / resizes the peer HWND to the component's absolute bounds. */ public static native void peerSetBounds(long peer, int x, int y, int w, int h); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWindowManager.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWindowManager.java new file mode 100644 index 00000000000..fcfdfa7bee5 --- /dev/null +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsWindowManager.java @@ -0,0 +1,426 @@ +/* + * 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.windows; + +import com.codename1.io.Log; +import com.codename1.impl.WindowManager; +import com.codename1.ui.Display; +import com.codename1.ui.Image; + +/** + * The native Windows implementation of the desktop windowing contract. + * + *

Each Codename One window is a slot in the native table in + * {@code cn1_windows_desktopwindow.cpp}, with its own HWND, its own + * {@code ID2D1HwndRenderTarget} and its own {@code CN1Graphics}. The application's + * main window is deliberately not part of that table -- it stays in {@code cn1Win} + * exactly as before -- so nothing about the single-window path changes.

+ * + * @author Shai Almog + */ +public class WindowsWindowManager extends WindowManager { + + /** One native window, identified by its slot in the native table. */ + static final class Peer { + final int slot; + final int windowId; + + Peer(int slot, int windowId) { + this.slot = slot; + this.windowId = windowId; + } + } + + private static Peer peer(Object p) { + return p instanceof Peer ? (Peer) p : null; + } + + /// Slot of the desktop window hosting the given component, or + /// `WindowsImplementation#MAIN_WINDOW_SLOT` when it lives in the application's + /// main window. The native editor needs this to parent its EDIT control to the + /// right window rather than always to the main one. + static int slotForComponent(com.codename1.ui.Component cmp) { + Object peer = com.codename1.ui.Desktop.getInstance().getWindowPeerForComponent(cmp); + if (peer == null) { + return WindowsImplementation.MAIN_WINDOW_SLOT; + } + int s = slot(peer); + return s < 0 ? WindowsImplementation.MAIN_WINDOW_SLOT : s; + } + + private static int slot(Object p) { + Peer w = peer(p); + return w == null ? -1 : w.slot; + } + + // ---- lifecycle ----------------------------------------------------------- + + @Override + public Object createWindow(int windowId, String title, int x, int y, int width, int height, + boolean decorated, boolean resizable, Object parentPeer, boolean positionSet, + boolean ownedByMainWindow) { + // The owner HWND is what makes an owned window stay above its owner and + // minimize with it. -1 is another window's slot being absent: -2 asks for the + // application's main window, and anything else leaves the window unowned, so + // an unowned window is not silently made a child of the main one. + int ownerSlot = parentPeer != null ? slot(parentPeer) : (ownedByMainWindow ? -2 : -1); + int slot = WindowsNative.desktopWindowCreate(windowId, title == null ? "" : title, + x, y, width, height, decorated, resizable, ownerSlot, positionSet); + if (slot < 0) { + return null; + } + return new Peer(slot, windowId); + } + + @Override + public void show(Object peerObj) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowShow(s, true); + } + } + + @Override + public void hide(Object peerObj) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowShow(s, false); + } + } + + @Override + public void dispose(Object peerObj) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowDestroy(s); + } + } + + // ---- attributes ------------------------------------------------------------ + + @Override + public void setTitle(Object peerObj, String title) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetTitle(s, title == null ? "" : title); + } + } + + @Override + public void setBounds(Object peerObj, int x, int y, int width, int height) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetBounds(s, x, y, width, height); + } + } + + /// {@inheritDoc} + /// + /// A Form lives in the application's own window, so centring a window over a + /// Form means centring over that window. Left unimplemented the framework fell + /// back to the monitor work area, which is a different place whenever the main + /// window has been moved, resized or simply does not fill the screen. + @Override + public int[] getMainWindowBounds(int[] out) { + if (out == null || out.length < 4) { + return null; + } + return WindowsNative.mainWindowGetBounds(out) ? out : null; + } + + @Override + public int[] getBounds(Object peerObj, int[] out) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowGetBounds(s, out); + } + return out; + } + + @Override + public int getWidth(Object peerObj) { + int s = slot(peerObj); + return s < 0 ? 0 : WindowsNative.desktopWindowGetWidth(s); + } + + @Override + public int getHeight(Object peerObj) { + int s = slot(peerObj); + return s < 0 ? 0 : WindowsNative.desktopWindowGetHeight(s); + } + + @Override + public void setResizable(Object peerObj, boolean resizable) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetResizable(s, resizable); + } + } + + @Override + public void setDecorated(Object peerObj, boolean decorated) { + // Without this the SPI's empty default ran on this port alone, so the Java + // state reported the window as undecorated while it kept its chrome. + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetDecorated(s, decorated); + } + } + + @Override + public void setAlwaysOnTop(Object peerObj, boolean alwaysOnTop) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetAlwaysOnTop(s, alwaysOnTop); + } + } + + @Override + public void setMinimumSize(Object peerObj, int width, int height) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetMinimumSize(s, width, height); + } + } + + @Override + public void setUtilityWindow(Object peerObj, boolean utility) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetUtility(s, utility); + } + } + + @Override + public void setModal(Object peerObj, boolean modal, boolean applicationWide, Object ownerPeer) { + // Nothing to do: which windows are blocked is decided by the framework and + // delivered through setInputEnabled/setMainWindowInputEnabled. Deriving it + // here from one call cannot express nesting, scope or ownership -- it left + // the other secondary windows enabled under application modality and + // re-enabled everything an outer modal was still blocking. + } + + @Override + public void setInputEnabled(Object peerObj, boolean enabled) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetEnabled(s, enabled); + } + } + + @Override + public void setMainWindowInputEnabled(boolean enabled) { + WindowsNative.mainWindowSetEnabled(enabled); + } + + @Override + public void setIcon(Object peerObj, Image icon) { + // Not supported yet: the port has no HICON conversion for a CN1 image. + } + + @Override + public void requestFocus(Object peerObj) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowFocus(s); + } + } + + @Override + public void minimize(Object peerObj) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetState(s, 1); + } + } + + @Override + public void restore(Object peerObj) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetState(s, 0); + } + } + + @Override + public void toggleMaximize(Object peerObj) { + int s = slot(peerObj); + if (s >= 0) { + WindowsNative.desktopWindowSetState(s, 2); + } + } + + // ---- rendering ------------------------------------------------------------------ + + @Override + public Object getNativeGraphics(Object peerObj) { + int s = slot(peerObj); + if (s < 0) { + return null; + } + return Long.valueOf(WindowsNative.desktopWindowGraphics(s)); + } + + @Override + public void flushGraphics(Object peerObj, int x, int y, int width, int height) { + int s = slot(peerObj); + if (s < 0) { + return; + } + long g = WindowsNative.desktopWindowGraphics(s); + if (g != 0) { + WindowsNative.flushGraphics(g, x, y, width, height); + } + } + + /// Whether the capture fallback has already been reported. One line per process is + /// enough to notice it; one per frame would bury the run it appears in. + private boolean captureFallbackReported; + + /// Reads this window's own contents back, rather than letting + /// `com.codename1.ui.Window#capture()` fall back to re-rendering the component + /// tree. The fallback produces the content the window *should* be showing, so it + /// can neither tell a correct window from one whose raster and hierarchy + /// disagree, nor show a native peer or editor at all -- and those are exactly + /// what the windowed screenshot goldens exist to check. + /// + /// #### Parameters + /// + /// - `peer`: the window's native peer + /// + /// #### Returns + /// + /// the native image, or null when the window has no surface to read + @Override + public Object capture(Object peer) { + int s = slot(peer); + if (s < 0) { + return null; + } + byte[] png = WindowsNative.captureDesktopWindowToPngBytes(s); + if (png == null || png.length == 0) { + // Said out loud, once per window, because the alternative is silent: + // Window.capture() falls back to re-rendering the component hierarchy, which + // produces a plausible image of the right size and hides the fact that the + // real surface was never read. That is the exact failure this override + // exists to remove, so it must not be able to come back unnoticed. + if (!captureFallbackReported) { + captureFallbackReported = true; + Log.p("WindowsWindowManager: window capture returned no pixels; " + + "Window.capture() is falling back to re-rendering the " + + "component tree, so peers and native editors will be absent"); + } + return null; + } + long img = WindowsNative.createImageFromBytes(png, 0, png.length); + if (img == 0) { + return null; + } + return Long.valueOf(img); + } + + @Override + public void setPaintDirtyRegionClip(Object peerObj, int x, int y, int width, int height) { + int s = slot(peerObj); + if (s < 0) { + return; + } + long g = WindowsNative.desktopWindowGraphics(s); + if (g != 0) { + // Direct2D retains the surface between presents, so a clip set while a + // component paints has to be confined to the region about to be flushed + // or a fill escapes into pixels nothing repainted (issue #5273). + WindowsNative.setFlushRect(g, x, y, width, height); + } + } + + // ---- monitors ---------------------------------------------------------------------- + + @Override + public int getMonitorCount() { + return Math.max(1, WindowsNative.monitorCount()); + } + + @Override + public int[] getMonitorBounds(int monitor, int[] out) { + WindowsNative.monitorBounds(monitor, false, out); + return out; + } + + @Override + public int[] getMonitorWorkArea(int monitor, int[] out) { + WindowsNative.monitorBounds(monitor, true, out); + return out; + } + + @Override + public int getMonitorDensity(int monitor) { + int dpi = getMonitorDotsPerInch(monitor); + if (dpi >= 280) { + return Display.DENSITY_VERY_HIGH; + } + if (dpi >= 200) { + return Display.DENSITY_HIGH; + } + if (dpi >= 140) { + return Display.DENSITY_MEDIUM; + } + return Display.DENSITY_LOW; + } + + @Override + public double getMonitorScale(int monitor) { + // Windows expresses per-monitor scaling as DPI against a 96dpi baseline. + return getMonitorDotsPerInch(monitor) / 96.0; + } + + @Override + public int getMonitorDotsPerInch(int monitor) { + int dpi = WindowsNative.monitorDpi(monitor); + return dpi > 0 ? dpi : 96; + } + + @Override + public String getMonitorName(int monitor) { + return "display-" + monitor; + } + + @Override + public int getPrimaryMonitor() { + return Math.max(0, WindowsNative.primaryMonitor()); + } + + @Override + public int getMonitorForWindow(Object peerObj) { + int s = slot(peerObj); + if (s < 0) { + return getPrimaryMonitor(); + } + return Math.max(0, WindowsNative.monitorForWindow(s)); + } + + @Override + public int getMonitorForMainWindow() { + return Math.max(0, WindowsNative.monitorForMainWindow()); + } +} diff --git a/Ports/iOSPort/nativeSources/CN1MacWindows.h b/Ports/iOSPort/nativeSources/CN1MacWindows.h new file mode 100644 index 00000000000..6d2b01a695c --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1MacWindows.h @@ -0,0 +1,140 @@ +/* + * 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. + */ + +/* + * Additional desktop windows for the Mac Catalyst slice of the iOS port. + * + * The whole implementation is inside #if TARGET_OS_MACCATALYST, so the object + * file an iPhone or iPad build produces from CN1MacWindows.m is empty and the + * plain iOS binary is byte-for-byte what it was. + * + * A Codename One Window becomes a UIWindowScene. The scene hosts a plain UIView + * whose layer contents are set from a raster the framework renders on its own + * side, rather than a second Metal or GL surface. That is deliberate: the render + * path caches its device, pipeline state and glyph atlas against the one + * rendering view, and making those per-scene is a large refactor of the hottest + * code in the product -- with manual retain and release, since this port builds + * without ARC. Because the scene still owns a real UIView hierarchy, native peers + * and native text editing work normally inside a window; only the Codename One + * drawing arrives as a bitmap. + * + * Multiple scenes must be enabled in Info.plist for any of this to work. That key + * is process-wide and changing it once destabilised the Catalyst screenshot + * suite, so the builder only emits it when an application explicitly asks for + * multi-window through the macNative.multiWindow build hint. + */ + +#ifndef CN1_MAC_WINDOWS_H +#define CN1_MAC_WINDOWS_H + +#import +#include + +#if TARGET_OS_MACCATALYST + +#import + +/* Creates a window scene and returns its slot, or -1 on failure. windowId is the + * framework's own id, stored so every callback can echo it back. */ +int CN1MacWindowCreate(int windowId, NSString* title, int x, int y, int width, int height, + BOOL decorated, BOOL resizable, BOOL positionSet); +void CN1MacWindowDestroy(int slot); +void CN1MacWindowShow(int slot, BOOL visible); +/* The token of the scene request currently outstanding for this slot, or 0. */ +int CN1MacWindowRequestSeq(int slot); +void CN1MacWindowSetTitle(int slot, NSString* title); +void CN1MacWindowSetBounds(int slot, int x, int y, int width, int height); +void CN1MacWindowGetBounds(int slot, int* out); +int CN1MacWindowGetWidth(int slot); +int CN1MacWindowGetHeight(int slot); +void CN1MacWindowFocus(int slot); +void CN1MacWindowSetState(int slot, int state); + +/* Presents one frame. The bytes are premultiplied BGRA in the window's own size, + * which is what the framework's mutable image hands back. */ +void CN1MacWindowPresent(int slot, void* argb, int width, int height); + +/* The UIView a native peer or text editor should be added to. */ +UIView* CN1MacWindowContentView(int slot); +BOOL CN1MacWindowAttachPeer(int slot, UIView* peer, int x, int y, int width, int height); + +/* True when the app's Info.plist actually enables multiple scenes. This is the + * single source of truth for whether windows can work: without the key the + * system refuses to activate a second scene, so the API must report unsupported + * rather than hand back windows that never appear. */ +BOOL CN1MacMultiWindowSupported(void); + +int CN1MacMonitorCount(void); +int CN1MacPrimaryMonitor(void); +void CN1MacMonitorBounds(int monitor, BOOL workArea, int* out); +int CN1MacMonitorDpi(int monitor); +double CN1MacMonitorScale(int monitor); +int CN1MacMonitorForWindow(int slot); + +/** The screen the application's own scene is on; the main window has no slot. */ +int CN1MacMonitorForMainWindow(void); + +/** Applies a resizability change to a window that may already have a scene. */ +void CN1MacWindowSetResizable(int slot, BOOL resizable); + +/** Applies a decoration change to a window that may already have a scene. */ +void CN1MacWindowSetDecorated(int slot, BOOL decorated); + +/** Records a minimum size and applies it to an existing scene. */ +void CN1MacWindowSetMinimumSize(int slot, int width, int height); + +/** Records which window is being edited, so the native editor lands in its view. */ +void CN1MacWindowSetEditingSlot(int slot); + +/** The view the native editor belongs in, or nil for the application's main view. */ +UIView* CN1MacWindowEditingHostView(void); + +/* Invoked from the scene delegate when a Codename One window scene connects, so + * a scene the system restored on launch is adopted rather than orphaned. */ +void CN1MacWindowSceneConnected(UIWindowScene* scene); +void CN1MacWindowSceneConnectedFor(UIWindowScene* scene, int requested); + +/* Claims a newly connected scene for a Codename One window if one is waiting for + * it. Returns NO when the scene belongs to the application's main form. */ +BOOL CN1MacWindowAdoptScene(UIWindowScene* scene, NSSet* activities); + +/** Requests a scene again for a window whose scene was destroyed unasked. */ +BOOL CN1MacWindowReopen(int slot); + +/** Enables or disables touch input for a window, used while a modal blocks it. */ +void CN1MacWindowSetInputEnabled(int slot, BOOL enabled); +void CN1MacMainWindowSetInputEnabled(BOOL enabled); +BOOL CN1MacMainWindowGetBounds(int* out); + +/** Starts reporting display attach/remove/mode changes; idempotent. */ +void CN1MacWindowWatchScreens(void); + +/** The window id a scene belongs to, or -1 for the application's main scene. */ +int CN1MacWindowIdForScene(UIWindowScene* scene); + +/** The scene of a Codename One window disconnected; reported as a close request. */ +void CN1MacWindowSceneDisconnected(UIWindowScene* scene); + +#endif /* TARGET_OS_MACCATALYST */ + +#endif /* CN1_MAC_WINDOWS_H */ diff --git a/Ports/iOSPort/nativeSources/CN1MacWindows.m b/Ports/iOSPort/nativeSources/CN1MacWindows.m new file mode 100644 index 00000000000..227cebcea06 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1MacWindows.m @@ -0,0 +1,2147 @@ +/* + * 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. + */ + +#import "CN1MacWindows.h" + +#if TARGET_OS_MACCATALYST + +#import +#include +#include +#include + +/* + * Delivery into the framework. Defined in IOSNative.m alongside the existing + * pointerPressed / screenSizeChanged bridges, so all the ParparVM thread-state + * handling stays in one place. + */ +extern void CN1MacWindowDeliverClose(int windowId); +extern void CN1MacWindowDeliverClosed(int windowId); +extern void CN1MacWindowDeliverMonitorsChanged(void); +extern void CN1MacWindowDeliverFocus(int windowId, BOOL gained); +extern void CN1MacWindowDeliverVisibility(int windowId, BOOL shown); +extern void CN1MacWindowDeliverActivationFailed(int windowId, int requestSeq); +extern void CN1MacWindowDeliverResize(int windowId, int width, int height); +extern void CN1MacWindowDeliverPointer(int windowId, int type, int x, int y); +extern void CN1MacWindowDeliverKey(int windowId, int keyCode, BOOL pressed); +extern void CN1MacWindowDeliverHover(int windowId, int type, int x, int y); +extern void CN1MacWindowDeliverWheel(int windowId, int x, int y, int scrollX, int scrollY); +extern void CN1MacWindowDeliverPinch(int windowId, float scale, int x, int y); +extern void CN1MacWindowDeliverRotation(int windowId, float radians, int x, int y); +extern void cn1CapturePointerMetadata(UITouch* touch); + +/* The main view controller's UIKey mapping, shared so the two cannot drift. */ +extern int cn1MapUIKeyToKeyCode(UIKey* key) API_AVAILABLE(ios(13.4)); + +#define CN1_MAC_MAX_WINDOWS 32 + +/* + * The view a Codename One window's content is presented in. The framework + * renders into its own raster and hands it here; setting layer.contents is the + * cheapest way to get that on screen without standing up a second Metal surface. + */ +@interface CN1MacWindowView : UIView +@property (nonatomic, assign) int windowId; +@end + +@implementation CN1MacWindowView + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self != nil) { + self.opaque = YES; + self.layer.magnificationFilter = kCAFilterNearest; + self.multipleTouchEnabled = NO; + self.userInteractionEnabled = YES; + } + return self; +} + +- (void)presentImage:(CGImageRef)image { + /* Assigning to layer.contents must happen on the main thread; the caller + * dispatches, so this is only reached there. */ + self.layer.contents = (__bridge id) image; +} + +- (void)deliver:(NSSet*)touches type:(int)type { + UITouch* t = [touches anyObject]; + if (t == nil) { + return; + } + /* Capture the pointer type, pressure and tilt before the event is queued, the + * same way the main surface's touch handlers do. Without it the queued event + * carries the defaults, so a pen reads as an ordinary touch -- and the stylus + * listeners the framework dispatches in a Window stayed silent no matter what + * the Java side did. */ + cn1CapturePointerMetadata(t); + /* UIKit reports the location in points while the window is laid out in device + * pixels -- the resize path multiplies by the screen scale -- so an unscaled + * coordinate arrives at half its rendered position on a Retina display and only + * the top left corner of the window is clickable where it looks like it is. */ + CGPoint p = [t locationInView:self]; + CGFloat scale = self.window != nil ? self.window.screen.scale : 1.0; + CN1MacWindowDeliverPointer(self.windowId, type, + (int) (p.x * scale), (int) (p.y * scale)); +} + +- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event { + [self deliver:touches type:1]; +} + +- (void)touchesMoved:(NSSet*)touches withEvent:(UIEvent*)event { + [self deliver:touches type:3]; +} + +- (void)touchesEnded:(NSSet*)touches withEvent:(UIEvent*)event { + [self deliver:touches type:2]; +} + +- (void)touchesCancelled:(NSSet*)touches withEvent:(UIEvent*)event { + [self deliver:touches type:2]; +} + +@end + +static void CN1MacWindowReportLayout(int windowId, int width, int height); +static void CN1MacWindowApplyDecoration(UIWindowScene* scene, int decorated); + +/* The view controller a Codename One window scene is rooted at. */ +@interface CN1MacWindowController : UIViewController +@property (nonatomic, assign) int windowId; +@property (nonatomic, assign) CN1MacWindowView* content; +@end + +@implementation CN1MacWindowController + +/* + * Hover, indirect scroll, magnify and rotate. A secondary scene is rooted at this + * controller rather than at CodenameOne_GLViewController, and every one of these is + * delivered by a gesture recognizer installed on that controller's view -- none of + * them arrive as touches. Without the same recognizers here, a mouse hover, a wheel + * or trackpad scroll and a trackpad pinch or rotation over a secondary window + * produced no Codename One event at all. Each one is the main controller's handler + * with the window id carried through, so the two cannot disagree about what a + * gesture means. + * + * Codename One geometry is in device pixels and UIKit reports points, hence the + * screen scale, exactly as the touch path does. + */ +- (CGFloat)cn1Scale { + return self.view.window != nil ? self.view.window.screen.scale : 1.0; +} + +- (void)cn1InstallWindowRecognizers { + if (@available(macCatalyst 13.0, *)) { + UIHoverGestureRecognizer* hover = [[UIHoverGestureRecognizer alloc] + initWithTarget:self action:@selector(cn1WindowHover:)]; + /* Hover is independent of touch and must not preempt a tap, the same + * reasoning as the main controller's. */ + hover.cancelsTouchesInView = NO; + hover.delaysTouchesBegan = NO; + hover.delaysTouchesEnded = NO; + [self.view addGestureRecognizer:hover]; + [hover release]; + } + if (@available(macCatalyst 13.4, *)) { + /* maximumNumberOfTouches 0 restricts this to indirect-pointer scrolling, so + * it never competes with the touch recognizers. */ + UIPanGestureRecognizer* scroll = [[UIPanGestureRecognizer alloc] + initWithTarget:self action:@selector(cn1WindowScroll:)]; + scroll.allowedScrollTypesMask = UIScrollTypeMaskAll; + scroll.maximumNumberOfTouches = 0; + scroll.cancelsTouchesInView = NO; + scroll.delaysTouchesBegan = NO; + scroll.delaysTouchesEnded = NO; + [self.view addGestureRecognizer:scroll]; + [scroll release]; + } + UIPinchGestureRecognizer* pinch = [[UIPinchGestureRecognizer alloc] + initWithTarget:self action:@selector(cn1WindowPinch:)]; + pinch.cancelsTouchesInView = NO; + pinch.delaysTouchesBegan = NO; + pinch.delaysTouchesEnded = NO; + [self.view addGestureRecognizer:pinch]; + [pinch release]; + + UIRotationGestureRecognizer* rotate = [[UIRotationGestureRecognizer alloc] + initWithTarget:self action:@selector(cn1WindowRotate:)]; + rotate.cancelsTouchesInView = NO; + rotate.delaysTouchesBegan = NO; + rotate.delaysTouchesEnded = NO; + [self.view addGestureRecognizer:rotate]; + [rotate release]; +} + +- (void)cn1WindowHover:(UIGestureRecognizer*)recognizer { + CGPoint p = [recognizer locationInView:self.view]; + CGFloat scale = [self cn1Scale]; + int x = (int) (p.x * scale); + int y = (int) (p.y * scale); + switch (recognizer.state) { + case UIGestureRecognizerStateBegan: + CN1MacWindowDeliverHover(self.windowId, 1, x, y); + break; + case UIGestureRecognizerStateChanged: + CN1MacWindowDeliverHover(self.windowId, 3, x, y); + break; + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: + case UIGestureRecognizerStateFailed: + CN1MacWindowDeliverHover(self.windowId, 2, x, y); + break; + default: + break; + } +} + +- (void)cn1WindowScroll:(UIPanGestureRecognizer*)recognizer { + if (recognizer.state != UIGestureRecognizerStateBegan + && recognizer.state != UIGestureRecognizerStateChanged) { + return; + } + CGPoint loc = [recognizer locationInView:self.view]; + CGPoint t = [recognizer translationInView:self.view]; + CGFloat scale = [self cn1Scale]; + int dx = (int) (t.x * scale); + int dy = (int) (t.y * scale); + if (dx != 0 || dy != 0) { + CN1MacWindowDeliverWheel(self.windowId, (int) (loc.x * scale), + (int) (loc.y * scale), dx, dy); + /* Reset so each callback carries an incremental delta. */ + [recognizer setTranslation:CGPointZero inView:self.view]; + } +} + +- (void)cn1WindowPinch:(UIPinchGestureRecognizer*)recognizer { + if (recognizer.state == UIGestureRecognizerStateChanged && recognizer.scale > 0) { + CGPoint loc = [recognizer locationInView:self.view]; + CGFloat scale = [self cn1Scale]; + CN1MacWindowDeliverPinch(self.windowId, (float) recognizer.scale, + (int) (loc.x * scale), (int) (loc.y * scale)); + /* Incremental relative to 1.0, as the main controller does it. */ + recognizer.scale = 1.0; + } +} + +- (void)cn1WindowRotate:(UIRotationGestureRecognizer*)recognizer { + if (recognizer.state == UIGestureRecognizerStateChanged && recognizer.rotation != 0) { + CGPoint loc = [recognizer locationInView:self.view]; + CGFloat scale = [self cn1Scale]; + CN1MacWindowDeliverRotation(self.windowId, (float) recognizer.rotation, + (int) (loc.x * scale), (int) (loc.y * scale)); + recognizer.rotation = 0; + } +} + +/* + * Hardware keyboard. A secondary scene is rooted at this controller rather than at + * CodenameOne_GLViewController, so without these the window's focused component + * never receives a key: UIKit delivers presses up the responder chain of the window + * that has focus, and only the main controller implements them. + */ +- (void)deliverPresses:(NSSet*)presses pressed:(BOOL)pressed + event:(UIPressesEvent*)event { + if (@available(iOS 13.4, *)) { + BOOL handled = NO; + for (UIPress* press in presses) { + UIKey* key = press.key; + int code = key != nil ? cn1MapUIKeyToKeyCode(key) : 0; + if (code != 0) { + CN1MacWindowDeliverKey(self.windowId, code, pressed); + handled = YES; + } + } + if (handled) { + return; + } + } + if (pressed) { + [super pressesBegan:presses withEvent:event]; + } else { + [super pressesEnded:presses withEvent:event]; + } +} + +- (void)pressesBegan:(NSSet*)presses withEvent:(UIPressesEvent*)event { + [self deliverPresses:presses pressed:YES event:event]; +} + +- (void)pressesEnded:(NSSet*)presses withEvent:(UIPressesEvent*)event { + [self deliverPresses:presses pressed:NO event:event]; +} + +- (void)pressesCancelled:(NSSet*)presses withEvent:(UIPressesEvent*)event { + /* Treated as a release: leaving a key latched down in the framework is worse + * than an extra release the focused component ignores. */ + [self deliverPresses:presses pressed:NO event:event]; +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + /* Pin the content view to the controller's view rather than relying on the + * autoresizing mask. The mask distributes a resize *delta*, so a content view + * created while the window still had zero bounds stays at zero forever -- and + * the size query reads the content view, so the window then reports nothing. */ + if (self.content != nil) { + self.content.frame = self.view.bounds; + } + CGSize size = self.view.bounds.size; + CGFloat scale = self.view.window != nil ? self.view.window.screen.scale : 1.0; + CN1MacWindowReportLayout(self.windowId, + (int) (size.width * scale), (int) (size.height * scale)); +} + +@end + +typedef struct { + UIWindowScene* scene; + UIWindow* window; + CN1MacWindowController* controller; + CN1MacWindowView* content; + int windowId; + /* Bumped every time the slot is taken. A block queued on the main thread + * captures the value it saw, so a request left over from a window that was + * disposed cannot enqueue or adopt a scene for whoever took the slot next. */ + int generation; + int inUse; + /* A scene request is outstanding for this slot. Set before the request is made + * rather than when the slot reaches the pending queue: the queueing happens on + * the main queue, so between creation returning and that block running the slot + * looked like it had no request at all, and a show() in that gap asked for a + * second scene. Two adoptions then raced for one window. */ + int scenePending; + /* Identifies which request an asynchronous failure belongs to. The error handler + * captures the value at request time, so a retry that has already replaced the + * request makes the older failure recognisable and droppable. */ + int requestSeq; + int pendingWidth; + int pendingHeight; + /* The origin asked for, in pixels, and whether one was asked for at all. The + * creation bridge carries x and y but adoption used to hardcode (0,0), so a + * window positioned before its first show() -- a restored layout, or an explicit + * setWindowLocation -- opened somewhere else. Zero positionSet means "no opinion", + * which leaves the placement to the platform. */ + int pendingX; + int pendingY; + int positionSet; + /* The visibility the framework last asked for. A scene is requested + * asynchronously, so a show()/hide() pair can both land before one exists; + * without recording it the hide is dropped and adoption shows the window + * anyway, leaving a native window on screen the framework no longer paints. */ + int pendingVisible; + /* Whether the application asked for a resizable window. Recorded rather than + * consulted only at creation, because the scene arrives later and the size + * restrictions can only be applied once it exists. */ + int resizable; + /* The requested minimum, in pixels, or 0 for none. Recorded for the same reason + * as resizable: the scene does not exist when the request is made. */ + int minWidth; + int minHeight; + /* Whether the application asked for a decorated window. Catalyst cannot remove + * the frame, but it can hide the title bar's title and toolbar, which is the + * part an application replacing the chrome cares about. */ + int decorated; + /* Whether input is allowed inside the window. Recorded for the same reason as + * pendingVisible: a modal can block a window whose scene has not connected yet, + * and the request would otherwise be delivered to a nil window and dropped, + * leaving the newly connected window's native peers interactive underneath a + * modal that is supposed to be blocking them. 1 means enabled. */ + int inputEnabled; + /* The geometry asked for but not yet granted, in pixels, or 0 when nothing is + * outstanding. A recycled scene reports the *previous* window's size the moment + * it is adopted, before the new geometry request lands, and delivering that would + * lay the window out at the wrong size -- which a capture then records. Layout + * sizes are suppressed until one matches what was asked for. */ + int awaitingWidth; + int awaitingHeight; + int staleLayoutDropped; + NSString* pendingTitle; +} CN1MacWindow; + +static CN1MacWindow g_macWindows[CN1_MAC_MAX_WINDOWS]; + +/* Defined further down, beside the main-window helpers that first needed it. */ +static void CN1MacRunOnMainSync(void (^block)(void)); +extern void CN1MacWindowReattachEditor(UIView* host); +extern void CN1MacWindowDeliverContentReady(int windowId); +/* Defined below, beside the editing host lookup. */ +static int g_editingSlot; + +static CN1MacWindow* slotAt(int slot) { + if (slot < 0 || slot >= CN1_MAC_MAX_WINDOWS) { + return NULL; + } + if (!g_macWindows[slot].inUse) { + return NULL; + } + return &g_macWindows[slot]; +} + +/* + * A layout size from UIKit, filtered against any geometry still being asked for. + * + * A recycled scene reports the previous window's size the instant it is adopted -- + * before the new geometry request has landed -- and delivering that lays the window + * out at the wrong size, which a capture then records. While a request is + * outstanding only the size that was asked for is delivered; anything else is the + * old geometry on its way out. Once it matches, the window is free again and every + * later layout (a user resize) passes straight through. + */ +/* Guards the pending queue and the slot table's lifecycle fields. Both are + * touched from two threads: scene requests and deliveries run on UIKit's main + * queue, while create and destroy are called from the Codename One event + * dispatch thread. Without it a dispose racing a scene delivery could pop a + * half-compacted queue, or adopt a scene into a slot as it was being cleared, + * misassigning or orphaning a native scene. + * + * The event dispatch thread never blocks on the main queue while holding this, + * so it cannot deadlock against UIKit. The …Locked helpers assume it is held. */ +static pthread_mutex_t g_slotLock = PTHREAD_MUTEX_INITIALIZER; + +static void CN1MacWindowReportLayout(int windowId, int width, int height) { + int iter; + int drop = 0; + /* Same critical section as the writer in CN1MacWindowSetBounds: the decision to + * drop this layout and the clearing of the request have to see one consistent + * set of the handshake fields. */ + pthread_mutex_lock(&g_slotLock); + for (iter = 0; iter < CN1_MAC_MAX_WINDOWS; iter++) { + CN1MacWindow* w = &g_macWindows[iter]; + if (!w->inUse || w->windowId != windowId) { + continue; + } + if (w->awaitingWidth > 0 && w->awaitingHeight > 0) { + if (width != w->awaitingWidth || height != w->awaitingHeight) { + /* Only the first differing layout is dropped -- that is the + * recycled scene reporting the previous window's size before the + * request lands. A second one is the system's settled answer, which + * may legitimately differ from what was asked for (an oversized + * window, or one the window manager constrained), and discarding it + * forever would leave the framework laying out at a size the window + * does not have. */ + if (!w->staleLayoutDropped) { + w->staleLayoutDropped = 1; + drop = 1; + break; + } + } + w->awaitingWidth = 0; + w->awaitingHeight = 0; + w->staleLayoutDropped = 0; + } + /* Whatever the window actually ended up as, including a size the user dragged + * it to. Without this pendingWidth/Height only ever held the last size the + * application asked for, so setResizable(false) pinned the restrictions to + * that and snapped a user-resized window back to it. */ + w->pendingWidth = width; + w->pendingHeight = height; + break; + } + pthread_mutex_unlock(&g_slotLock); + if (drop) { + return; + } + /* Outside the lock: this re-enters Codename One, which must never happen while + * holding a lock the event dispatch thread can be waiting on. */ + CN1MacWindowDeliverResize(windowId, width, height); +} + +/* Assumes g_slotLock is held: the table it scans is mutated by both threads. */ +static int slotForSceneLocked(UIWindowScene* scene) { + int iter; + for (iter = 0; iter < CN1_MAC_MAX_WINDOWS; iter++) { + if (g_macWindows[iter].inUse && g_macWindows[iter].scene == scene) { + return iter; + } + } + return -1; +} + +/* + * Slots that have asked for a scene and are still waiting for one, oldest first. + * + * The system hands scenes back asynchronously, so a slot cannot simply take "the + * next arrival": open two windows in quick succession and picking the first + * unattached slot each time would swap their identities. Scenes are delivered in + * the order they were requested, so a FIFO matches them correctly. Only touched + * on the main thread, where both the request and the delivery happen. + */ +static int g_pendingSlots[CN1_MAC_MAX_WINDOWS]; +static int g_pendingCount; + + + +static void pushPendingSlotLocked(int slot) { + if (g_pendingCount < CN1_MAC_MAX_WINDOWS) { + g_pendingSlots[g_pendingCount++] = slot; + } +} + +static int popPendingSlotLocked(void) { + int slot; + int iter; + if (g_pendingCount <= 0) { + return -1; + } + slot = g_pendingSlots[0]; + for (iter = 1; iter < g_pendingCount; iter++) { + g_pendingSlots[iter - 1] = g_pendingSlots[iter]; + } + g_pendingCount--; + return slot; +} + +/* + * Asks the system to give a scene the supplied frame, in points. Catalyst has no + * direct window-move or window-resize API; a geometry preference is the supported + * way to ask, and the window manager remains free to adjust the result -- which is + * why every caller re-reads the delivered size rather than assuming it was granted. + * Must be called on the main thread. + */ +static void CN1MacWindowRequestGeometry(UIWindowScene* scene, CGRect frame) { + if (scene == nil) { + return; + } + if (@available(macCatalyst 16.0, *)) { + UIWindowSceneGeometryPreferencesMac* prefs = + [[UIWindowSceneGeometryPreferencesMac alloc] initWithSystemFrame:frame]; + [scene requestGeometryUpdateWithPreferences:prefs errorHandler:^(NSError* error) { + NSLog(@"CN1: window geometry request failed: %@", error); + }]; + [prefs release]; + } +} + +/* + * Gets a window to the content size Codename One asked for, and keeps it there. + * + * Two separate problems, and an earlier attempt at each made things worse. + * + * A geometry preference is a request, not an instruction. When the window manager + * ignores one the scene keeps the size it already had -- Catalyst's 1024x768 default + * -- and nothing asked again, so the window stayed wrong for good and the windowed + * screenshot suite came up short of captures. + * + * And the preference is a *system* frame, which encloses the title bar, while what + * has to come out right is the content area. Whether the chrome is charged against a + * request depends on when it arrives, so the same window came back 900x700 on one run + * and 900x684 on the next -- fine for a live application, fatal for a golden. + * + * So: sample twice and only act on a settled reading, then apply **one** correction, + * capped. The cap is what makes this safe. Correcting from an unsettled reading is + * how a 400x300 window was once driven to its 120x120 minimum and a 1000x400 window + * overshot to 1700x400; a correction that can only ever move the frame by the width + * of some chrome cannot do either, whatever it measures. + */ +#define CN1_GEOMETRY_CHROME_SLACK 64.0 +#define CN1_GEOMETRY_ATTEMPTS 10 + +static CGSize CN1MacWindowContentSize(UIWindow* window) { + /* The root view controller's view, because that is the same thing + * viewDidLayoutSubviews reports to the framework as the window's size. */ + UIView* rootView = window.rootViewController.view; + return rootView != nil ? rootView.bounds.size : window.bounds.size; +} + +static void CN1MacWindowSettleGeometry(int slot, int generation, UIWindowScene* scene, + UIWindow* window, CGSize wantedContent, CGRect request, CGSize lastSample, + int attemptsLeft); + +/* + * True while this settler still owns the window it was started for. + * + * A settler runs for up to a couple of seconds after the request, and a disposed + * window's scene goes back to the recycling pool inside that window. Without this a + * settler left over from the closed window would go on sampling -- and re-requesting + * geometry -- against the scene now hosting a *different* window, resizing the + * replacement out from under it. + */ +static BOOL CN1MacWindowSettlerStillOwns(int slot, int generation, UIWindowScene* scene) { + BOOL owns = NO; + if (slot < 0 || slot >= CN1_MAC_MAX_WINDOWS) { + return NO; + } + pthread_mutex_lock(&g_slotLock); + owns = g_macWindows[slot].inUse + && g_macWindows[slot].generation == generation + && g_macWindows[slot].scene == scene; + pthread_mutex_unlock(&g_slotLock); + return owns; +} + +static void CN1MacWindowSettleGeometryStep(int slot, int generation, + UIWindowScene* scene, UIWindow* window, CGSize wantedContent, CGRect request, + CGSize lastSample, int attemptsLeft) { + if (!CN1MacWindowSettlerStillOwns(slot, generation, scene)) { + return; + } + CGSize got = CN1MacWindowContentSize(window); + if (got.width <= 0 || got.height <= 0) { + CN1MacWindowSettleGeometry(slot, generation, scene, window, wantedContent, + request, got, attemptsLeft - 1); + return; + } + CGFloat dw = wantedContent.width - got.width; + CGFloat dh = wantedContent.height - got.height; + if (fabs(dw) <= 1.0 && fabs(dh) <= 1.0) { + return; /* content is what was asked for */ + } + BOOL settled = fabs(got.width - lastSample.width) <= 1.0 + && fabs(got.height - lastSample.height) <= 1.0; + if (!settled) { + /* Still laying out. Look again without touching the request -- acting on a + * reading that is still moving is what caused the two earlier regressions. */ + CN1MacWindowSettleGeometry(slot, generation, scene, window, wantedContent, + request, got, attemptsLeft - 1); + return; + } + if (fabs(dw) > CN1_GEOMETRY_CHROME_SLACK || fabs(dh) > CN1_GEOMETRY_CHROME_SLACK) { + /* Nowhere near: the request was ignored rather than adjusted for chrome. Ask + * again for exactly the same frame -- never a computed one, which could not + * be trusted at this distance. */ + CN1MacWindowRequestGeometry(scene, request); + CN1MacWindowSettleGeometry(slot, generation, scene, window, wantedContent, + request, got, attemptsLeft - 1); + return; + } + /* Chrome-sized shortfall on a settled window: correct once, by that much. */ + CGRect next = CGRectMake(request.origin.x, request.origin.y, + request.size.width + dw, request.size.height + dh); + CN1MacWindowRequestGeometry(scene, next); + CN1MacWindowSettleGeometry(slot, generation, scene, window, wantedContent, next, + got, attemptsLeft - 1); +} + +static void CN1MacWindowSettleGeometry(int slot, int generation, UIWindowScene* scene, + UIWindow* window, CGSize wantedContent, CGRect request, CGSize lastSample, + int attemptsLeft) { + if (scene == nil || window == nil || attemptsLeft <= 0) { + return; + } + /* Retained across the delay: this port builds without ARC, and a disconnect can + * release either of these while the check is queued. */ + UIWindowScene* heldScene = [scene retain]; + UIWindow* heldWindow = [window retain]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t) (0.25 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + CN1MacWindowSettleGeometryStep(slot, generation, heldScene, heldWindow, + wantedContent, request, lastSample, attemptsLeft); + [heldWindow release]; + [heldScene release]; + }); +} + +static void dropPendingSlotLocked(int slot) { + int read; + int write = 0; + for (read = 0; read < g_pendingCount; read++) { + if (g_pendingSlots[read] != slot) { + g_pendingSlots[write++] = g_pendingSlots[read]; + } + } + g_pendingCount = write; +} + +/* + * Identity carried on an activation request so the scene that answers it can be + * matched to the window that asked, instead of to the oldest outstanding request. + * + * The FIFO this used to rely on assumes UIKit connects scenes in request order, which + * it does not promise: two windows opened together could be handed each other's scene + * and with it each other's title, geometry, content and lifecycle events. The token is + * the request's slot and its generation, so a request left over from a disposed window + * is recognised as stale rather than matched to whoever holds the slot now. + */ +/* No token of ours on the connection: the request queue decides, as it always did. */ +#define CN1_MAC_SLOT_NO_TOKEN (-1) +/* Our token, for a window that has since gone. The scene belongs to nobody. */ +#define CN1_MAC_SLOT_STALE (-2) + +static NSString* const CN1_MAC_WINDOW_ACTIVITY = @"com.codenameone.window.activation"; +static NSString* const CN1_MAC_WINDOW_SLOT_KEY = @"cn1WindowSlot"; +static NSString* const CN1_MAC_WINDOW_GENERATION_KEY = @"cn1WindowGeneration"; + +/* Caller owns the result. */ +static NSUserActivity* CN1MacWindowRequestActivity(int slot, int generation) { + NSUserActivity* activity = + [[NSUserActivity alloc] initWithActivityType:CN1_MAC_WINDOW_ACTIVITY]; + activity.userInfo = @{ CN1_MAC_WINDOW_SLOT_KEY : @(slot), + CN1_MAC_WINDOW_GENERATION_KEY : @(generation) }; + return activity; +} + +/* + * The slot a connecting scene was requested for, or -1 when it carries no token of + * ours -- a scene the system created on its own, or a system that did not deliver the + * activity back. The caller then falls back to the request queue, which is what this + * always did. + */ +static int CN1MacWindowSlotForActivities(NSSet* activities) { + BOOL stale = NO; + if (activities == nil) { + return CN1_MAC_SLOT_NO_TOKEN; + } + for (NSUserActivity* activity in activities) { + if (![activity.activityType isEqualToString:CN1_MAC_WINDOW_ACTIVITY]) { + continue; + } + NSNumber* slot = activity.userInfo[CN1_MAC_WINDOW_SLOT_KEY]; + NSNumber* generation = activity.userInfo[CN1_MAC_WINDOW_GENERATION_KEY]; + if (slot == nil || generation == nil) { + continue; + } + int s = [slot intValue]; + int g = [generation intValue]; + if (s < 0 || s >= CN1_MAC_MAX_WINDOWS) { + continue; + } + /* Ours, but the window that asked has been disposed -- and the slot may belong + * to another window now, so this scene is not for it either. Reported as stale + * rather than as no token at all: falling back to the queue would hand this + * scene to an unrelated pending window, and with an empty queue the delegate + * would install a second copy of the application's main root into it. */ + if (!g_macWindows[s].inUse || g_macWindows[s].generation != g) { + stale = YES; + continue; + } + return s; + } + return stale ? CN1_MAC_SLOT_STALE : CN1_MAC_SLOT_NO_TOKEN; +} + +static BOOL isPendingSlotLocked(int slot) { + int iter; + for (iter = 0; iter < g_pendingCount; iter++) { + if (g_pendingSlots[iter] == slot) { + return YES; + } + } + return NO; +} + +/* + * A scene activation the system refused. The slot was queued before the request and + * scenes are matched to queued slots in arrival order, so leaving a failed slot in + * the queue hands the *next* window's scene to this one -- and that window then waits + * for a scene that has already been consumed. Dropping the slot keeps the queue + * aligned with the requests still outstanding. + * + * The framework is told the activation failed, which is not the same as reporting a + * minimize: the minimize path keeps a modal window's registration on purpose, so a + * modal that never appeared would go on blocking input to every other window while + * showModal() waited for it. It is not a close either -- the window stays registered + * so a later show() can ask for a scene again, where a close would dispose the + * application's window object. + */ +static void CN1MacWindowActivationFailed(int slot, int generation, int requestSeq) { + int windowId; + CN1MacWindow* w; + pthread_mutex_lock(&g_slotLock); + w = slotAt(slot); + if (w != NULL && w->generation == generation && w->requestSeq != requestSeq) { + /* A later request has replaced the one that failed. It owns the slot now, and + * applying this failure would take down a window that request may be about to + * bring up. Captured with the request rather than read here, because the + * replacement can be started before this handler runs. */ + pthread_mutex_unlock(&g_slotLock); + return; + } + if (w == NULL || w->generation != generation) { + /* The window was disposed and the slot handed to another one before this + * arrived. The failure belongs to a window that no longer exists, and + * applying it here would drop the *replacement's* pending request and report + * the replacement hidden -- the same corruption this function exists to + * prevent, one window along. */ + pthread_mutex_unlock(&g_slotLock); + return; + } + dropPendingSlotLocked(slot); + windowId = w->windowId; + /* Otherwise a scene adopted later would map a window the framework has been + * told is down. */ + w->pendingVisible = 0; + /* No request outstanding any more, so a later show() may ask again. */ + w->scenePending = 0; + pthread_mutex_unlock(&g_slotLock); + /* The token travels with the failure so the Java side can tell whether a retry has + * replaced this request in the window between here and the event dispatch thread + * running the notification. Sampling it over there instead would sample the retry. */ + CN1MacWindowDeliverActivationFailed(windowId, requestSeq); +} + +/* + * Scenes that a closed window gave back, kept alive for the next one. + * + * A scene session is not a cheap object and the system does not hand them out on + * demand: asking for one while a previous destruction is still in flight fails with + * "scene invalidated before create completion", and the window that asked is then + * left with no scene at all. Closing one window and opening another is completely + * ordinary, so recycling is the only way that sequence can be reliable. Only touched + * on the main thread. + */ +static UIWindowScene* g_freeScenes[CN1_MAC_MAX_WINDOWS]; +static int g_freeSceneCount; + +static UIWindowScene* takeFreeScene(void) { + if (g_freeSceneCount <= 0) { + return NULL; + } + return g_freeScenes[--g_freeSceneCount]; +} + +int CN1MacWindowCreate(int windowId, NSString* title, int x, int y, int width, int height, + BOOL decorated, BOOL resizable, BOOL positionSet) { + int slot = -1; + int iter; + for (iter = 0; iter < CN1_MAC_MAX_WINDOWS; iter++) { + if (!g_macWindows[iter].inUse) { + slot = iter; + break; + } + } + if (slot < 0) { + return -1; + } + { + int generation = g_macWindows[slot].generation + 1; + memset(&g_macWindows[slot], 0, sizeof(CN1MacWindow)); + g_macWindows[slot].generation = generation; + } + g_macWindows[slot].inUse = 1; + g_macWindows[slot].windowId = windowId; + g_macWindows[slot].pendingWidth = width; + g_macWindows[slot].pendingHeight = height; + g_macWindows[slot].pendingX = x; + g_macWindows[slot].pendingY = y; + /* Carried through rather than inferred from the coordinates: a window explicitly + * placed at 0,0 is placed, and guessing from the numbers made it look unplaced so + * the window server put it wherever it liked. */ + g_macWindows[slot].positionSet = positionSet ? 1 : 0; + g_macWindows[slot].pendingTitle = [title retain]; + g_macWindows[slot].resizable = resizable ? 1 : 0; + g_macWindows[slot].decorated = decorated ? 1 : 0; + /* Set explicitly because the slot was just memset to zero, and zero here would + * mean "input disabled" -- every new window would come up inert. */ + g_macWindows[slot].inputEnabled = 1; + /* Before this function returns, not inside the block below: the block runs on the + * main queue, and a show() in the gap saw a slot with no scene and nothing pending + * and asked for a second one. */ + g_macWindows[slot].scenePending = 1; + g_macWindows[slot].requestSeq++; + + const int generation = g_macWindows[slot].generation; + const int requestSeq = g_macWindows[slot].requestSeq; + dispatch_async(dispatch_get_main_queue(), ^{ + pthread_mutex_lock(&g_slotLock); + if (!g_macWindows[slot].inUse || g_macWindows[slot].generation != generation) { + /* The window was disposed before this ran; the slot may already belong to + * another one, and requesting a scene for it would leave an orphan. */ + pthread_mutex_unlock(&g_slotLock); + return; + } + UIWindowScene* recycled = takeFreeScene(); + if (recycled != nil) { + /* Adopt it straight away rather than going through the pending queue: + * there is no asynchronous delivery to wait for. */ + pushPendingSlotLocked(slot); + pthread_mutex_unlock(&g_slotLock); + CN1MacWindowSceneConnected(recycled); + [recycled release]; + return; + } + // Enqueue and request on the same main-thread turn, so the queue order is + // exactly the request order the system will deliver scenes in. + pushPendingSlotLocked(slot); + pthread_mutex_unlock(&g_slotLock); + if (@available(macCatalyst 13.0, *)) { + UISceneActivationRequestOptions* options = + [[UISceneActivationRequestOptions alloc] init]; + options.requestingScene = [UIApplication sharedApplication].connectedScenes.anyObject; + NSUserActivity* identity = CN1MacWindowRequestActivity(slot, generation); + [[UIApplication sharedApplication] requestSceneSessionActivation:nil + userActivity:identity + options:options + errorHandler:^(NSError* error) { + NSLog(@"CN1: window scene activation failed: %@", error); + CN1MacWindowActivationFailed(slot, generation, requestSeq); + }]; + [identity release]; + [options release]; + } + }); + return slot; +} + +/* + * Adopts a newly connected scene into the slot that asked for it. Called from the + * scene delegate, which is the only place a scene object becomes available. + */ +BOOL CN1MacWindowAdoptScene(UIWindowScene* scene, NSSet* activities) { + BOOL claimed; + int requested; + pthread_mutex_lock(&g_slotLock); + // The slot this scene was actually requested for, when the system handed our token + // back. NO_TOKEN means it did not, and the request queue decides instead -- which + // is what this always did. + requested = CN1MacWindowSlotForActivities(activities); + if (requested == CN1_MAC_SLOT_STALE) { + /* Ours, for a window that no longer exists. Claimed so the delegate does not + * treat it as the application's own scene and install a second main root into + * it, and parked rather than dropped, for the same reason a scene whose slot + * has gone is parked below: it is a live scene and the system will not hand + * out unlimited numbers of them. */ + if (scene != nil && g_freeSceneCount < CN1_MAC_MAX_WINDOWS) { + scene.title = @""; + g_freeScenes[g_freeSceneCount++] = [scene retain]; + } + pthread_mutex_unlock(&g_slotLock); + return YES; + } + // Nothing is waiting, or this scene was already adopted: it belongs to the + // application's main form. + claimed = (requested >= 0 || g_pendingCount > 0) && slotForSceneLocked(scene) < 0; + pthread_mutex_unlock(&g_slotLock); + if (!claimed) { + return NO; + } + CN1MacWindowSceneConnectedFor(scene, requested); + return YES; +} + +/* + * The window a scene belongs to, or -1 for the application's main scene. Lets the + * scene delegate route activation and disconnection without knowing about slots. + */ +int CN1MacWindowIdForScene(UIWindowScene* scene) { + int windowId; + pthread_mutex_lock(&g_slotLock); + { + int slot = slotForSceneLocked(scene); + windowId = slot < 0 ? -1 : g_macWindows[slot].windowId; + } + pthread_mutex_unlock(&g_slotLock); + return windowId; +} + +/* + * The user closed the window with the native close control. + * + * Reported as a close that has already happened, not as a request. UIKit hands the + * disconnect over after the scene is gone, so there is nothing left to veto: asking + * would let DO_NOTHING_ON_CLOSE leave a registered window painting into a surface + * that no longer exists, and HIDE_ON_CLOSE keep a window with no scene to show + * again. An application that needs to intervene closes the window itself, which is + * a request and is vetoable. + */ +void CN1MacWindowSceneDisconnected(UIWindowScene* scene) { + /* Locked against CN1MacWindowDestroy, which snapshots this same slot and + * clears it. Unsynchronized, the two interleaved badly in both directions: + * teardown could snapshot w->scene after this released it but before it was + * nilled, and then message or release a deallocated scene, or this could read + * w->windowId after teardown had zeroed the slot and report the close under a + * window id that no longer meant anything. */ + UIWindowScene* dead = nil; + int windowId = -1; + pthread_mutex_lock(&g_slotLock); + { + int slot = slotForSceneLocked(scene); + if (slot >= 0) { + CN1MacWindow* w = &g_macWindows[slot]; + /* The scene is gone, so it must not be recycled or presented into. */ + dead = w->scene; + w->scene = nil; + windowId = w->windowId; + } + } + pthread_mutex_unlock(&g_slotLock); + /* Both outside the lock: -release can run arbitrary teardown, and delivering + * the close re-enters Codename One, which must never happen while holding a + * lock the event dispatch thread can be waiting on. */ + [dead release]; + if (windowId >= 0) { + CN1MacWindowDeliverClosed(windowId); + } +} + +void CN1MacWindowSceneConnected(UIWindowScene* scene) { + CN1MacWindowSceneConnectedFor(scene, -1); +} + +/* + * Adopts a scene into the window it was requested for. + * + * `requested` is the slot the arriving scene's own token names, or -1 when it carries + * none -- a scene the system produced without one, or a system that did not hand the + * activity back. Only then does the request queue decide, which is what this did for + * every scene before the token existed. + */ +void CN1MacWindowSceneConnectedFor(UIWindowScene* scene, int requested) { + int contentReadyWindowId = -1; + int slot; + CN1MacWindow* w; + pthread_mutex_lock(&g_slotLock); + if (requested >= 0) { + slot = requested; + /* Its queue entry is spent either way; leaving it would hand the next scene + * to a window that already has one. */ + dropPendingSlotLocked(slot); + } else { + slot = popPendingSlotLocked(); + } + if (slot < 0 || !g_macWindows[slot].inUse) { + /* The window this scene was requested for is already gone. Park the scene + * rather than dropping it on the floor: it is a live native scene, and + * leaking one per raced dispose eventually exhausts what the system will + * hand out. */ + if (scene != nil && g_freeSceneCount < CN1_MAC_MAX_WINDOWS) { + scene.title = @""; + g_freeScenes[g_freeSceneCount++] = [scene retain]; + } + pthread_mutex_unlock(&g_slotLock); + return; + } + w = &g_macWindows[slot]; + /* The request this scene answers is done. A later show() may ask again if the + * window is closed and reopened. */ + w->scenePending = 0; + w->scene = [scene retain]; + /* Held across the rest of the adoption so a dispose cannot clear the slot + * from under it. Only UIKit calls follow -- nothing re-enters Codename One -- + * so this cannot deadlock against the event dispatch thread. */ + + /* A slot can be adopted more than once. A close that a modal blocks is put + * back through CN1MacWindowReopen, and disconnection releases only the scene: + * the window, controller and view built for the previous scene are still + * here. Overwriting the three pointers below without releasing them leaks an + * entire native window and view hierarchy on every blocked close. Nothing in + * this file implements dealloc, so none of these releases re-enters Codename + * One and they are safe under the lock. */ + if (w->window != nil || w->controller != nil || w->content != nil) { + UIWindow* staleWindow = w->window; + CN1MacWindowController* staleController = w->controller; + CN1MacWindowView* staleContent = w->content; + w->window = nil; + w->controller = nil; + w->content = nil; + [staleContent removeFromSuperview]; + staleWindow.rootViewController = nil; + staleWindow.hidden = YES; + [staleContent release]; + [staleController release]; + [staleWindow release]; + } + + w->window = [[UIWindow alloc] initWithWindowScene:scene]; + w->controller = [[CN1MacWindowController alloc] init]; + w->controller.windowId = w->windowId; + + w->content = [[CN1MacWindowView alloc] initWithFrame:w->window.bounds]; + w->content.windowId = w->windowId; + w->content.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + w->controller.view.backgroundColor = [UIColor blackColor]; + [w->controller.view addSubview:w->content]; + w->controller.content = w->content; + /* After the view exists, since every recognizer attaches to it. */ + [w->controller cn1InstallWindowRecognizers]; + CN1MacWindowApplyDecoration(scene, w->decorated); + + w->window.rootViewController = w->controller; + /* An editor that had to start before this scene existed went to the main view; + * now that there is a content view for the window it was meant for, move it. + * Peers created in that same window get the same treatment. */ + if (g_editingSlot == slot) { + CN1MacWindowReattachEditor(w->content); + } + contentReadyWindowId = w->windowId; + /* As with visibility: honour the input state last asked for. A window opened + * while an application modal is already up has its blocking requested before the + * scene exists, and without this the peers inside it stayed interactive. */ + w->window.userInteractionEnabled = w->inputEnabled ? YES : NO; + /* Honour the visibility last asked for rather than always showing: a window + * hidden before its scene arrived must not appear now. */ + if (w->pendingVisible) { + [w->window makeKeyAndVisible]; + } else { + w->window.hidden = YES; + } + + if (w->pendingTitle != nil) { + scene.title = w->pendingTitle; + } + if (w->pendingWidth > 0 && w->pendingHeight > 0) { + /* Ask for the size the window was created with. Without this the system + * hands the scene whatever size it feels like -- in practice the main + * scene's size -- and the Codename One window then lays out into a raster + * that does not match what was requested. Codename One geometry is in + * pixels and UIKit's is in points, hence the divide by the screen scale. + * The restrictions have to be relaxed first, because the system clamps the + * requested frame against them and the default minimum is larger than a + * small window. They are lowered rather than pinned to the requested size, + * so the window stays resizable by hand afterwards. */ + CGFloat scale = w->window.screen != nil ? w->window.screen.scale : 1.0; + CGFloat pointWidth = w->pendingWidth / scale; + CGFloat pointHeight = w->pendingHeight / scale; + /* Until this lands, a layout report is the old geometry -- see + * CN1MacWindowReportLayout. */ + w->awaitingWidth = w->pendingWidth; + w->awaitingHeight = w->pendingHeight; + w->staleLayoutDropped = 0; + if (scene.sizeRestrictions != nil) { + if (w->resizable) { + CGFloat minW = w->minWidth > 0 ? w->minWidth / scale : MIN(pointWidth, 120); + CGFloat minH = w->minHeight > 0 ? w->minHeight / scale : MIN(pointHeight, 120); + scene.sizeRestrictions.minimumSize = CGSizeMake(minW, minH); + scene.sizeRestrictions.maximumSize = CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX); + } else { + /* Pinned both ways, which is how Catalyst expresses a fixed size. + * The flag reached creation and was then dropped, so a window the + * framework reported as non-resizable could still be dragged out of + * shape by the user. */ + scene.sizeRestrictions.minimumSize = CGSizeMake(pointWidth, pointHeight); + scene.sizeRestrictions.maximumSize = CGSizeMake(pointWidth, pointHeight); + } + } + /* The requested origin, when one was asked for. Hardcoding (0,0) here threw + * away an explicitly positioned or restored window's placement. */ + CGFloat pointX = w->positionSet ? w->pendingX / scale : 0; + CGFloat pointY = w->positionSet ? w->pendingY / scale : 0; + CGRect wantedFrame = CGRectMake(pointX, pointY, pointWidth, pointHeight); + CN1MacWindowRequestGeometry(scene, wantedFrame); + CN1MacWindowSettleGeometry(slot, w->generation, scene, w->window, + CGSizeMake(pointWidth, pointHeight), wantedFrame, + CGSizeMake(-1, -1), CN1_GEOMETRY_ATTEMPTS); + } + pthread_mutex_unlock(&g_slotLock); + /* Outside the lock, and it re-enters Codename One. Peers created before this + * scene existed were left on the main surface; the framework walks the window's + * tree and re-attaches them now that there is somewhere to put them. Driving it + * from the component tree rather than from a native queue means there is no + * retained view to purge when a peer or its window goes away, no fixed table to + * overflow, and no stale entry to reattach into a recycled slot. */ + if (contentReadyWindowId >= 0) { + CN1MacWindowDeliverContentReady(contentReadyWindowId); + } +} + +void CN1MacWindowDestroy(int slot) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + /* The whole teardown is one critical section: removing the slot from the + * pending queue, taking ownership of the native objects and clearing the slot + * have to be indivisible against a scene delivery arriving on the main queue, + * which pops that same queue and adopts into that same slot. Splitting them + * let a delivery adopt a scene into a slot that was half cleared. */ + pthread_mutex_lock(&g_slotLock); + dropPendingSlotLocked(slot); + UIWindowScene* scene = w->scene; + UIWindow* window = w->window; + CN1MacWindowController* controller = w->controller; + CN1MacWindowView* content = w->content; + NSString* title = w->pendingTitle; + /* Bumped across the clear, so a request still queued for this window sees a + * different generation and does nothing. */ + int generation = w->generation + 1; + memset(w, 0, sizeof(CN1MacWindow)); + w->generation = generation; + pthread_mutex_unlock(&g_slotLock); + + dispatch_async(dispatch_get_main_queue(), ^{ + if (window != nil) { + window.hidden = YES; + window.rootViewController = nil; + } + if (scene != nil && g_freeSceneCount < CN1_MAC_MAX_WINDOWS) { + /* Park the scene instead of destroying it -- see g_freeScenes. Its + * ownership moves from the slot to the pool, so it is deliberately not + * released here. */ + scene.title = @""; + g_freeScenes[g_freeSceneCount++] = scene; + } else if (scene != nil) { + UISceneDestructionRequestOptions* opts = + [[UISceneDestructionRequestOptions alloc] init]; + [[UIApplication sharedApplication] requestSceneSessionDestruction:scene.session + options:opts + errorHandler:nil]; + [opts release]; + [scene release]; + } + /* No ARC in this port: everything retained above is released here, after + * UIKit has finished with it on the main thread. */ + [content release]; + [controller release]; + [window release]; + [title release]; + }); +} + +/* + * Asks the system for a scene again after one was disconnected without the app + * getting a say. Reuses the slot, so the framework's peer and raster stay valid. + */ +BOOL CN1MacWindowReopen(int slot) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL || w->scene != nil) { + return NO; + } + /* Under the lock for the same reason as the other pending state: adoption + * reads this field while holding it. */ + pthread_mutex_lock(&g_slotLock); + w->pendingVisible = 1; + /* Marked in flight here for the same reason creation does it. */ + w->scenePending = 1; + w->requestSeq++; + pthread_mutex_unlock(&g_slotLock); + const int generation = w->generation; + const int requestSeq = w->requestSeq; + dispatch_async(dispatch_get_main_queue(), ^{ + pthread_mutex_lock(&g_slotLock); + if (!g_macWindows[slot].inUse || g_macWindows[slot].generation != generation) { + pthread_mutex_unlock(&g_slotLock); + return; + } + UIWindowScene* recycled = takeFreeScene(); + pushPendingSlotLocked(slot); + pthread_mutex_unlock(&g_slotLock); + if (recycled != nil) { + CN1MacWindowSceneConnected(recycled); + [recycled release]; + return; + } + if (@available(macCatalyst 13.0, *)) { + UISceneActivationRequestOptions* options = + [[UISceneActivationRequestOptions alloc] init]; + options.requestingScene = [UIApplication sharedApplication].connectedScenes.anyObject; + NSUserActivity* identity = CN1MacWindowRequestActivity(slot, generation); + [[UIApplication sharedApplication] requestSceneSessionActivation:nil + userActivity:identity + options:options + errorHandler:^(NSError* error) { + NSLog(@"CN1: window scene reopen failed: %@", error); + CN1MacWindowActivationFailed(slot, generation, requestSeq); + }]; + [identity release]; + [options release]; + } + }); + return YES; +} + +void CN1MacWindowSetInputEnabled(int slot, BOOL enabled) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + UIWindow* window; + /* Recorded before it is applied, so a request that arrives before the scene + * exists is honoured at adoption instead of being delivered to a nil window -- + * and recorded under the slot lock, because CN1MacWindowSceneConnected reads + * this field and installs the window while holding it. Unsynchronized, adoption + * could enable the hierarchy from the value this call is in the middle of + * replacing while this call sees a nil window and returns, leaving the peers + * interactive underneath the modal. */ + pthread_mutex_lock(&g_slotLock); + w->inputEnabled = enabled ? 1 : 0; + window = [w->window retain]; + pthread_mutex_unlock(&g_slotLock); + if (window == nil) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + /* Covers every touch inside the window while a modal blocks it. The scene's + * own title bar is AppKit chrome the app does not own, so its close button + * stays live -- see the note on CN1MacWindowSceneDisconnected. */ + window.userInteractionEnabled = enabled; + [window release]; + }); +} + +/* + * Displays being attached, removed or reconfigured. UIScreen posts all three, and + * the app has no other way to learn about them, so a Codename One monitor listener + * depends entirely on these. + */ +@interface CN1MacScreenWatch : NSObject +@end + +@implementation CN1MacScreenWatch +- (void)screensChanged:(NSNotification*)note { + CN1MacWindowDeliverMonitorsChanged(); +} +@end + +static CN1MacScreenWatch* g_screenWatch; + +void CN1MacWindowWatchScreens(void) { + if (g_screenWatch != nil) { + return; + } + g_screenWatch = [[CN1MacScreenWatch alloc] init]; + NSNotificationCenter* nc = [NSNotificationCenter defaultCenter]; + [nc addObserver:g_screenWatch selector:@selector(screensChanged:) + name:UIScreenDidConnectNotification object:nil]; + [nc addObserver:g_screenWatch selector:@selector(screensChanged:) + name:UIScreenDidDisconnectNotification object:nil]; + [nc addObserver:g_screenWatch selector:@selector(screensChanged:) + name:UIScreenModeDidChangeNotification object:nil]; +} + +int CN1MacWindowRequestSeq(int slot) { + int seq = 0; + CN1MacWindow* w; + pthread_mutex_lock(&g_slotLock); + w = slotAt(slot); + if (w != NULL) { + seq = w->requestSeq; + } + pthread_mutex_unlock(&g_slotLock); + return seq; +} + +void CN1MacWindowShow(int slot, BOOL visible) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + UIWindow* window; + /* Recorded whether or not a scene exists yet, so adoption can honour it, and + * under the slot lock for the same reason as the input state: adoption reads + * pendingVisible while holding it. */ + BOOL needsScene; + pthread_mutex_lock(&g_slotLock); + w->pendingVisible = visible ? 1 : 0; + window = [w->window retain]; + /* An earlier activation was refused, so this window has no scene and none is on + * the way. Without asking again, showing it would only set pendingVisible on a + * window that can never appear, while the framework painted it as visible. The + * pending-queue test is what keeps this from firing during a normal first show, + * where the scene is simply still in flight. */ + needsScene = visible && w->scene == nil && !w->scenePending + && !isPendingSlotLocked(slot); + pthread_mutex_unlock(&g_slotLock); + if (needsScene) { + [window release]; + CN1MacWindowReopen(slot); + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (window != nil) { + window.hidden = visible ? NO : YES; + if (visible) { + [window makeKeyAndVisible]; + } + } + [window release]; + }); +} + +void CN1MacWindowSetTitle(int slot, NSString* title) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + NSString* retained = [title retain]; + NSString* old; + NSString* forBlock; + UIWindowScene* scene; + /* Swapped under the slot lock. This runs on the event dispatch thread while + * CN1MacWindowSceneConnected can be adopting the same slot on UIKit's main + * queue, and adoption reads pendingTitle under this lock. Unsynchronized, a + * title change could release the very string adoption was about to assign -- + * this file builds without ARC, so that is a use after free rather than a + * missed update. */ + pthread_mutex_lock(&g_slotLock); + old = w->pendingTitle; + w->pendingTitle = retained; + scene = w->scene; + /* An extra reference for the block: the slot's reference belongs to the slot, + * and a later setTitle can replace and release it before the block runs. The + * scene needs the same treatment -- CN1MacWindowSceneDisconnected can clear and + * release it between this snapshot and the block, and the block would then + * message a deallocated UIWindowScene. */ + forBlock = [retained retain]; + scene = [scene retain]; + pthread_mutex_unlock(&g_slotLock); + /* Released outside the lock, because -release can run arbitrary teardown. */ + [old release]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (scene != nil) { + scene.title = forBlock == nil ? @"" : forBlock; + } + [forBlock release]; + [scene release]; + }); +} + +void CN1MacWindowSetBounds(int slot, int x, int y, int width, int height) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + /* The five geometry handshake fields move together and are read by + * CN1MacWindowReportLayout on UIKit's main thread. Updated unlocked, a layout + * callback landing mid-update could accept a recycled scene's old size, or clear + * a request that had only half arrived -- either way the framework lays out at + * dimensions the native window does not have. */ + UIWindowScene* scene; + UIWindow* window; + pthread_mutex_lock(&g_slotLock); + w->pendingWidth = width; + w->pendingHeight = height; + /* The origin too, and the fact that one was given. Scene activation is + * asynchronous, so a move made straight after show() usually finds no scene at + * all: only the size was recorded, the request went to a nil scene and was + * dropped, and adoption then placed the window at the origin it was created with. + * Recording it here means adoption applies the move when the scene arrives. */ + w->pendingX = x; + w->pendingY = y; + w->positionSet = 1; + w->awaitingWidth = width; + w->awaitingHeight = height; + w->staleLayoutDropped = 0; + /* Retained for the block: a native disconnection can clear and release the + * scene between this snapshot and the block running on the main queue, and the + * block would then message a deallocated object. */ + scene = [w->scene retain]; + window = [w->window retain]; + pthread_mutex_unlock(&g_slotLock); + dispatch_async(dispatch_get_main_queue(), ^{ + /* Codename One geometry is in pixels, UIKit's is in points. */ + CGFloat scale = (window != nil && window.screen != nil) ? window.screen.scale : 1.0; + // Deliberately a single request. setBounds is defined in native coordinates + // including chrome, so the system frame *is* what was asked for -- converging + // on a content size here would silently redefine the API. + CN1MacWindowRequestGeometry(scene, + CGRectMake(x / scale, y / scale, width / scale, height / scale)); + [scene release]; + [window release]; + }); +} + +void CN1MacWindowGetBounds(int slot, int* out) { + CN1MacWindow* w = slotAt(slot); + UIWindow* window; + int pendingBounds[4]; + if (w == NULL || out == NULL) { + return; + } + /* Snapshotted and retained under the lock, then read on the main queue. This + * runs on the event dispatch thread while CN1MacWindowSceneConnected can be + * replacing and releasing this very window under the lock, and UIKit geometry + * must not be read off the main thread anyway. */ + pthread_mutex_lock(&g_slotLock); + window = [w->window retain]; + /* Copied under the same lock rather than read afterwards. These are the fallback + * answer when no scene has connected yet, and CN1MacWindowReportLayout updates + * pendingWidth and pendingHeight under this lock from UIKit's main queue -- so + * reading them unlocked could return a rectangle mixing an old origin with a new + * size, which setWindowSize() then feeds straight back to the platform. */ + pendingBounds[0] = w->pendingX; + pendingBounds[1] = w->pendingY; + pendingBounds[2] = w->pendingWidth; + pendingBounds[3] = w->pendingHeight; + pthread_mutex_unlock(&g_slotLock); + if (window != nil) { + __block CGFloat scale = 1.0; + __block CGRect f = CGRectZero; + CN1MacRunOnMainSync(^{ + /* Reported in pixels, matching CN1MacWindowGetWidth/Height and the pixel + * geometry Codename One passes in; UIKit frames are in points. */ + scale = window.screen != nil ? window.screen.scale : 1.0; + f = window.frame; + }); + [window release]; + out[0] = (int) (f.origin.x * scale); + out[1] = (int) (f.origin.y * scale); + out[2] = (int) (f.size.width * scale); + out[3] = (int) (f.size.height * scale); + } else { + /* The requested origin, not (0,0). A window that has returned from show() + * but whose scene has not connected yet is still readable, and a + * setWindowSize() reads these bounds and writes the whole rectangle back -- + * so reporting a zero origin here overwrote an explicitly placed window's + * position before it ever appeared. The size below already worked this way. */ + out[0] = pendingBounds[0]; + out[1] = pendingBounds[1]; + out[2] = pendingBounds[2]; + out[3] = pendingBounds[3]; + } +} + +/* + * The scene's size once it exists, and the requested size until then. + * + * The system grants a scene asynchronously and can refuse outright, so a window has + * to be usable before one arrives: falling back to the request is what lets it lay + * out and render meanwhile. Once a scene attaches, viewDidLayoutSubviews delivers + * the real size and the framework re-lays out against it. + */ +/* + * One axis of the laid-out size, synchronized the same way the bounds read is: the + * controller, its view and the window are snapshotted and retained under the slot + * lock, and their UIKit geometry is read on the main queue. Adoption replaces all + * three, so reading them here unsynchronized could message a deallocated object or + * mix a new view's bounds with an old window's scale. + */ +static int CN1MacWindowLayoutExtent(int slot, int wantWidth) { + CN1MacWindow* w = slotAt(slot); + CN1MacWindowController* controller; + UIWindow* window; + int pending; + __block CGFloat extent = 0; + __block CGFloat scale = 1.0; + if (w == NULL) { + return 0; + } + pthread_mutex_lock(&g_slotLock); + controller = [w->controller retain]; + window = [w->window retain]; + pending = wantWidth ? w->pendingWidth : w->pendingHeight; + pthread_mutex_unlock(&g_slotLock); + if (controller == nil) { + [window release]; + return pending; + } + CN1MacRunOnMainSync(^{ + /* The controller's view, not the content subview: that is what the window + * manager lays out and what viewDidLayoutSubviews reports back. */ + CGSize size = controller.view.bounds.size; + extent = wantWidth ? size.width : size.height; + scale = window != nil ? window.screen.scale : 1.0; + }); + [controller release]; + [window release]; + return (int) (extent * scale); +} + +int CN1MacWindowGetWidth(int slot) { + return CN1MacWindowLayoutExtent(slot, 1); +} + +int CN1MacWindowGetHeight(int slot) { + return CN1MacWindowLayoutExtent(slot, 0); +} + +void CN1MacWindowFocus(int slot) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + UIWindow* window; + /* Under the lock and retained, like every other snapshot handed to a block in + * this file: scene re-adoption replaces and releases the window, and the block + * would otherwise message a deallocated object. */ + pthread_mutex_lock(&g_slotLock); + window = [w->window retain]; + pthread_mutex_unlock(&g_slotLock); + dispatch_async(dispatch_get_main_queue(), ^{ + [window makeKeyAndVisible]; + [window release]; + }); +} + +void CN1MacWindowSetState(int slot, int state) { + /* Catalyst exposes no programmatic minimize or zoom to a UIKit app; the + * window manager owns those. Focus is the one that is available. */ + if (state == 3) { + CN1MacWindowFocus(slot); + } +} + +/* + * Puts a native peer inside the window that owns it, at the given Codename One + * rectangle. + * + * Peer attachment hard-coded the main controller's view, so a browser, camera or + * video view inside a Codename One window appeared over the main surface and took + * its input there -- while the developer guide promises peers live in the owning + * scene's hierarchy. The frame is converted with that window's own screen scale + * rather than the global one, since two scenes can sit on displays of different + * backing scale. + * + * Returns NO when the slot has no content view yet, so the caller can leave the peer + * where it is rather than lose it. + */ +BOOL CN1MacWindowAttachPeer(int slot, UIView* peer, int x, int y, int width, int height) { + __block BOOL attached = NO; + if (peer == nil || slot < 0) { + return NO; + } + CN1MacRunOnMainSync(^{ + CN1MacWindow* w; + UIView* host; + CGFloat scale; + pthread_mutex_lock(&g_slotLock); + w = slotAt(slot); + host = w == NULL ? nil : [w->content retain]; + scale = (w != NULL && w->window != nil && w->window.screen != nil) + ? w->window.screen.scale : 1.0; + pthread_mutex_unlock(&g_slotLock); + if (host == nil) { + /* Not an error: a peer can be created in the same event dispatch turn as + * show(), before the scene is granted. The framework re-attaches every + * peer in the window when adoption reports the content view ready, so + * there is nothing to remember here. */ + return; + } + if (peer.superview != host) { + [peer removeFromSuperview]; + [host addSubview:peer]; + } + if (width > 0 && height > 0) { + [peer setFrame:CGRectMake(x / scale, y / scale, width / scale, height / scale)]; + [peer setNeedsDisplay]; + } + [host release]; + attached = YES; + }); + return attached; +} + +UIView* CN1MacWindowContentView(int slot) { + CN1MacWindow* w = slotAt(slot); + return w == NULL ? nil : w->content; +} + +/* Frees a frame's pixels once Core Graphics has finished with the image. */ +static void cn1MacReleasePixels(void* info, const void* data, size_t size) { + (void) info; + (void) size; + free((void*) data); +} + +void CN1MacWindowPresent(int slot, void* argb, int width, int height) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL || argb == NULL || width <= 0 || height <= 0) { + return; + } + CN1MacWindowView* view; + /* Retained for the block below for the same reason as the window and the scene: + * adoption releases the previous content view when it builds a new one. */ + pthread_mutex_lock(&g_slotLock); + view = [w->content retain]; + pthread_mutex_unlock(&g_slotLock); + if (view == nil) { + return; + } + { + size_t bytes = (size_t) width * (size_t) height * 4; + /* Copy before wrapping, and do not be tempted to wrap the caller's memory to + * save the copy. The caller hands us a Java int[]'s data pointer, and that + * array is garbage the moment this returns -- the collector is free to reclaim + * or move it while the image below is still referencing the memory on a later + * main-queue turn. MacWindowManager now reuses one frame buffer per window + * rather than allocating per frame, which makes wrapping strictly worse: the + * next frame overwrites the very array a still-live CGImage would point at. + * This is C heap with a deterministic lifetime (cn1MacReleasePixels below), + * not garbage-collected memory, so it is not the per-frame GC pressure the + * Java-side reuse was there to remove. */ + void* pixels = malloc(bytes); + if (pixels == NULL) { + [view release]; + return; + } + memcpy(pixels, argb, bytes); + { + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + /* A data provider with a release callback, rather than a bitmap context: + * CGBitmapContextCreateImage is copy-on-write, so it is not defined when + * the backing buffer becomes free to release. Here the buffer's lifetime is + * explicit -- Core Graphics calls cn1MacReleasePixels once the image is + * finished with it. */ + CGDataProviderRef provider = + CGDataProviderCreateWithData(NULL, pixels, bytes, cn1MacReleasePixels); + CGImageRef image = NULL; + if (provider != NULL) { + /* Codename One hands back straight (non-premultiplied) ARGB and a + * window's content is opaque, so skip the alpha channel rather than + * declaring it premultiplied -- claiming premultiplied would darken + * every pixel that is not fully opaque. ByteOrder32Little pairs with + * ARGB ints on a little-endian host. */ + image = CGImageCreate(width, height, 8, 32, (size_t) width * 4, cs, + kCGImageAlphaNoneSkipFirst | kCGBitmapByteOrder32Little, + provider, NULL, false, kCGRenderingIntentDefault); + CGDataProviderRelease(provider); + } else { + free(pixels); + } + CGColorSpaceRelease(cs); + if (image != NULL) { + dispatch_async(dispatch_get_main_queue(), ^{ + [view presentImage:image]; + CGImageRelease(image); + [view release]; + }); + } else { + [view release]; + } + } + } +} + +BOOL CN1MacMultiWindowSupported(void) { + id value = [[NSBundle mainBundle] + objectForInfoDictionaryKey:@"UIApplicationSceneManifest"]; + if (![value isKindOfClass:[NSDictionary class]]) { + return NO; + } + { + id multi = [(NSDictionary*) value + objectForKey:@"UIApplicationSupportsMultipleScenes"]; + return [multi respondsToSelector:@selector(boolValue)] + && [multi boolValue] ? YES : NO; + } +} + +/* ------------------------------------------------------------- monitors */ + +/* + * Every one of these is called from the Codename One event dispatch thread and every + * one of them reads UIKit state -- the screen list, its members and their geometry. + * UIScreen is main-thread state and the collection mutates as displays connect and + * disconnect, so each takes its whole answer inside one CN1MacRunOnMainSync rather + * than reading across the boundary. + */ +int CN1MacMonitorCount(void) { + __block int count = 0; + CN1MacRunOnMainSync(^{ + /* UIKit on Catalyst reports the screens the app can see. */ + count = (int) [UIScreen screens].count; + }); + return count; +} + +int CN1MacPrimaryMonitor(void) { + __block int primary = 0; + CN1MacRunOnMainSync(^{ + NSArray* screens = [UIScreen screens]; + NSUInteger iter; + for (iter = 0; iter < screens.count; iter++) { + if (screens[iter] == [UIScreen mainScreen]) { + primary = (int) iter; + break; + } + } + }); + return primary; +} + +/* Callers must already be on the main queue: the screen it returns is only valid + * there, and reading a property off it later would move the race rather than fix it. */ +static UIScreen* screenAt(int monitor) { + NSArray* screens = [UIScreen screens]; + if (monitor >= 0 && monitor < (int) screens.count) { + return screens[monitor]; + } + return [UIScreen mainScreen]; +} + +void CN1MacMonitorBounds(int monitor, BOOL workArea, int* out) { + __block CGRect r = CGRectZero; + __block CGFloat scale = 1.0; + if (out == NULL) { + return; + } + CN1MacRunOnMainSync(^{ + UIScreen* screen = screenAt(monitor); + r = screen.bounds; + scale = screen.scale; + }); + /* UIScreen has no work-area concept; the menu bar and dock are excluded from + * a Catalyst app's usable area by the window server rather than reported, so + * the bounds are the best available answer for both. */ + (void) workArea; + /* In pixels, like CN1MacWindowGetBounds. Monitor and window rectangles are + * combined by centerOnDesktop() and compared when a position is persisted, so + * two coordinate systems would silently place windows wrong on a Retina + * display. */ + out[0] = (int) (r.origin.x * scale); + out[1] = (int) (r.origin.y * scale); + out[2] = (int) (r.size.width * scale); + out[3] = (int) (r.size.height * scale); +} + +double CN1MacMonitorScale(int monitor) { + __block double scale = 1.0; + CN1MacRunOnMainSync(^{ + scale = (double) screenAt(monitor).scale; + }); + return scale; +} + +int CN1MacMonitorDpi(int monitor) { + /* Catalyst reports a backing scale rather than a physical resolution; 72 + * points per inch times that scale is the conventional macOS mapping. */ + __block double scale = 1.0; + CN1MacRunOnMainSync(^{ + scale = (double) screenAt(monitor).scale; + }); + return (int) (72.0 * scale + 0.5); +} + +int CN1MacMonitorForWindow(int slot) { + CN1MacWindow* w = slotAt(slot); + UIWindow* window; + __block int found = -1; + if (w == NULL) { + return CN1MacPrimaryMonitor(); + } + /* Snapshotted and retained under the lock and inspected on the main queue, like + * the geometry reads: adoption replaces and releases this window, and UIScreen + * association is UIKit state that does not belong to the event dispatch thread. */ + pthread_mutex_lock(&g_slotLock); + window = [w->window retain]; + pthread_mutex_unlock(&g_slotLock); + if (window == nil) { + return CN1MacPrimaryMonitor(); + } + CN1MacRunOnMainSync(^{ + NSArray* screens = [UIScreen screens]; + NSUInteger iter; + for (iter = 0; iter < screens.count; iter++) { + if (screens[iter] == window.screen) { + found = (int) iter; + break; + } + } + }); + [window release]; + return found >= 0 ? found : CN1MacPrimaryMonitor(); +} + +/* The screen the application's own scene is on. The main window has no slot, so + * CN1MacMonitorForWindow cannot answer for it, and without this everything + * positioned against the main form reported the primary screen even after the user + * had dragged the application to an external display. */ +/* Applies a resizability change to a window that already has a scene. The flag is + * also recorded so a scene adopted later (a reopen, or a request still in flight) + * picks up the current value rather than the one from creation. */ +void CN1MacWindowSetResizable(int slot, BOOL resizable) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + pthread_mutex_lock(&g_slotLock); + w->resizable = resizable ? 1 : 0; + /* Retained for the block, as elsewhere in this file. */ + UIWindowScene* scene = [w->scene retain]; + int pixelWidth = w->pendingWidth; + int pixelHeight = w->pendingHeight; + /* Snapshotted under the lock with the rest: re-enabling resize has to put the + * application's own minimum back, not the fallback floor below. Replacing it left + * the window resizable below a minimum its Java getter still reported. */ + int minPixelWidth = w->minWidth; + int minPixelHeight = w->minHeight; + pthread_mutex_unlock(&g_slotLock); + if (scene == nil) { + /* No scene yet: adoption reads the flag. */ + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (scene.sizeRestrictions == nil) { + [scene release]; + return; + } + CGFloat scale = scene.screen != nil ? scene.screen.scale : 1.0; + CGFloat pointWidth = pixelWidth / scale; + CGFloat pointHeight = pixelHeight / scale; + if (resizable) { + if (minPixelWidth > 0 && minPixelHeight > 0) { + /* A configured minimum wins: it is what the framework reports, so it + * has to be what the platform enforces. */ + scene.sizeRestrictions.minimumSize = + CGSizeMake(minPixelWidth / scale, minPixelHeight / scale); + } else { + /* No minimum configured, so fall back to a floor small enough not to + * be a constraint in practice while keeping the window grabbable. */ + scene.sizeRestrictions.minimumSize = + CGSizeMake(MIN(pointWidth, 120), MIN(pointHeight, 120)); + } + scene.sizeRestrictions.maximumSize = CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX); + } else { + scene.sizeRestrictions.minimumSize = CGSizeMake(pointWidth, pointHeight); + scene.sizeRestrictions.maximumSize = CGSizeMake(pointWidth, pointHeight); + } + [scene release]; + }); +} + +/* Hides or shows the scene's title bar chrome. Catalyst cannot remove the window + * frame the way an undecorated desktop window does, but it can hide the title and + * the toolbar, which is what an application supplying its own chrome needs -- and + * without it setDecorated(false) changed the framework's state while the window + * kept a standard title bar, and could show two sets of chrome at once. */ +static void CN1MacWindowApplyDecoration(UIWindowScene* scene, int decorated) { + if (scene == nil) { + return; + } + if (@available(macCatalyst 13.0, *)) { + UITitlebar* bar = scene.titlebar; + if (bar != nil) { + bar.titleVisibility = decorated ? UITitlebarTitleVisibilityVisible + : UITitlebarTitleVisibilityHidden; + if (!decorated) { + bar.toolbar = nil; + } + } + } +} + +/* Applies a decoration change to a window that may already have a scene, and + * records it for a scene adopted later. */ +void CN1MacWindowSetDecorated(int slot, BOOL decorated) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + pthread_mutex_lock(&g_slotLock); + w->decorated = decorated ? 1 : 0; + /* Retained for the block, as elsewhere in this file. */ + UIWindowScene* scene = [w->scene retain]; + pthread_mutex_unlock(&g_slotLock); + if (scene == nil) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + CN1MacWindowApplyDecoration(scene, decorated ? 1 : 0); + [scene release]; + }); +} + +/* Records a minimum size and applies it to an existing scene. Codename One + * geometry is in pixels and UIKit's in points, hence the screen scale. */ +void CN1MacWindowSetMinimumSize(int slot, int width, int height) { + CN1MacWindow* w = slotAt(slot); + if (w == NULL) { + return; + } + pthread_mutex_lock(&g_slotLock); + w->minWidth = width > 0 ? width : 0; + w->minHeight = height > 0 ? height : 0; + /* Retained for the block, as elsewhere in this file. */ + UIWindowScene* scene = [w->scene retain]; + int resizable = w->resizable; + pthread_mutex_unlock(&g_slotLock); + if (scene == nil || !resizable) { + [scene release]; + /* A fixed window's restrictions are pinned to its size; a minimum would + * fight that. */ + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (scene.sizeRestrictions == nil) { + [scene release]; + return; + } + CGFloat scale = scene.screen != nil ? scene.screen.scale : 1.0; + if (width > 0 && height > 0) { + scene.sizeRestrictions.minimumSize = CGSizeMake(width / scale, height / scale); + } else { + /* The SPI expresses "no minimum" as non-positive dimensions. Skipping the + * update left the previous native minimum in force while the getter + * reported no constraint, so a cleared minimum silently kept applying. + * CGSizeZero is what a scene starts with. */ + scene.sizeRestrictions.minimumSize = CGSizeZero; + } + [scene release]; + }); +} + +/* The window whose field is being edited, or -1 for the application's main + * surface. There is one native editor at a time -- IOSImplementation keeps a single + * currentEditing -- so a single slot is enough to route it, and it avoids threading + * a window through the twenty-odd argument editStringAt bridge. */ +static int g_editingSlot = -1; + +void CN1MacWindowSetEditingSlot(int slot) { + pthread_mutex_lock(&g_slotLock); + g_editingSlot = slot; + pthread_mutex_unlock(&g_slotLock); +} + +/* The view the native editor should be added to. Returns nil for the main surface, + * which leaves the caller on its existing path. */ +/* The backing scale of the window an edit belongs to, or 0 when the edit is on the + * application's own surface. The editor's frame is otherwise converted with the + * process-global scaleValue, which is the main scene's -- on a mixed-DPI desktop that + * left the native field oversized or undersized and displaced from the lightweight + * one, exactly as it did for peers before they were given the owning window's scale. */ +double CN1MacWindowEditingScale(void) { + __block double scale = 0; + int slot; + pthread_mutex_lock(&g_slotLock); + slot = g_editingSlot; + pthread_mutex_unlock(&g_slotLock); + if (slot < 0) { + return 0; + } + CN1MacRunOnMainSync(^{ + CN1MacWindow* w; + UIWindow* window; + pthread_mutex_lock(&g_slotLock); + w = slotAt(slot); + window = w == NULL ? nil : [w->window retain]; + pthread_mutex_unlock(&g_slotLock); + if (window != nil && window.screen != nil) { + scale = window.screen.scale; + } + [window release]; + }); + return scale; +} + +UIView* CN1MacWindowEditingHostView(void) { + CN1MacWindow* w; + UIView* content; + /* Under the lock like every other slot read: this runs on the event dispatch + * thread while adoption can be replacing the content view. */ + pthread_mutex_lock(&g_slotLock); + w = g_editingSlot < 0 ? NULL : slotAt(g_editingSlot); + content = w == NULL ? nil : [[w->content retain] autorelease]; + pthread_mutex_unlock(&g_slotLock); + return content; +} + +/* + * Blocks input to the application's own window while a Codename One window holds an + * application modal. + * + * The framework's event filter drops packed input events before they reach a + * component, but a UIKit peer -- a native editor, a web view, a media control -- + * receives its touches directly from the window server and never passes through + * that filter. Without this the main window's peers stayed fully interactive + * underneath a modal that was supposed to be blocking them. + * + * The main scene is the connected window scene that no Codename One window claims, + * the same way CN1MacMonitorForMainWindow finds it. + */ +/* Runs a block on the main queue and waits. Reads that have to return a value + * cannot dispatch_async, and UIKit geometry must be read on the main thread. */ +static void CN1MacRunOnMainSync(void (^block)(void)) { + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_sync(dispatch_get_main_queue(), block); + } +} + +/* + * The application's own window in pixels, or NO when it cannot be found. + * + * centerOn(Form) needs this: a Form lives in the main window, so centring over one + * means centring over that window. Without it the framework falls back to the + * monitor work area, which is a different place whenever the main window has been + * moved, resized or simply does not fill the screen. + * + * The main scene is the connected window scene no Codename One window claims, the + * same way CN1MacMonitorForMainWindow finds it. + */ +BOOL CN1MacMainWindowGetBounds(int* out) { + __block BOOL found = NO; + if (out == NULL) { + return NO; + } + CN1MacRunOnMainSync(^{ + for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + UIWindowScene* windowScene = (UIWindowScene*) scene; + if (CN1MacWindowIdForScene(windowScene) >= 0) { + continue; + } + for (UIWindow* window in windowScene.windows) { + CGFloat scale = window.screen != nil ? window.screen.scale : 1.0; + CGRect f = window.frame; + out[0] = (int) (f.origin.x * scale); + out[1] = (int) (f.origin.y * scale); + out[2] = (int) (f.size.width * scale); + out[3] = (int) (f.size.height * scale); + found = YES; + break; + } + if (found) { + break; + } + } + }); + return found; +} + +void CN1MacMainWindowSetInputEnabled(BOOL enabled) { + dispatch_async(dispatch_get_main_queue(), ^{ + for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + UIWindowScene* windowScene = (UIWindowScene*) scene; + if (CN1MacWindowIdForScene(windowScene) >= 0) { + continue; + } + for (UIWindow* window in windowScene.windows) { + window.userInteractionEnabled = enabled; + } + } + }); +} + +int CN1MacMonitorForMainWindow(void) { + __block int found = -1; + /* connectedScenes and the screen list are both main-thread state, and both + * mutate as scenes and displays come and go -- so the scene lookup and the + * screen match happen inside one pass rather than across the boundary. Nesting + * is safe: CN1MacRunOnMainSync runs the block inline when already on the main + * thread, which is also why the CN1MacPrimaryMonitor() fallbacks below cannot + * deadlock. */ + CN1MacRunOnMainSync(^{ + UIScreen* main = nil; + NSArray* screens; + NSUInteger iter; + for (UIScene* scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + /* Skip the scenes belonging to Codename One windows; what is left is the + * application's own. */ + if (CN1MacWindowIdForScene((UIWindowScene*) scene) >= 0) { + continue; + } + main = ((UIWindowScene*) scene).screen; + break; + } + if (main == nil) { + return; + } + screens = [UIScreen screens]; + for (iter = 0; iter < screens.count; iter++) { + if (screens[iter] == main) { + found = (int) iter; + return; + } + } + }); + return found >= 0 ? found : CN1MacPrimaryMonitor(); +} + +#endif /* TARGET_OS_MACCATALYST */ diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m index ebe3c8d67af..1ebeead4240 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLSceneDelegate.m @@ -25,6 +25,16 @@ #import "CodenameOne_GLSceneDelegate.h" +#if TARGET_OS_MACCATALYST +#import "CN1MacWindows.h" +/* True when the scene was claimed by a Codename One Window. */ +extern BOOL CN1MacWindowAdoptScene(UIWindowScene* scene, NSSet* activities); +extern int CN1MacWindowIdForScene(UIWindowScene* scene); +extern void CN1MacWindowSceneDisconnected(UIWindowScene* scene); +extern void CN1MacWindowDeliverFocus(int windowId, BOOL gained); +extern void CN1MacWindowDeliverVisibility(int windowId, BOOL shown); +#endif + #ifdef CN1_USE_UI_SCENE @implementation CodenameOne_GLSceneDelegate @@ -35,6 +45,43 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op if (![scene isKindOfClass:[UIWindowScene class]]) { return; } +#if TARGET_OS_MACCATALYST + // A second scene of the app role belongs to a com.codename1.ui.Window, not to + // the application's main form. Hand it to the window layer, which owns it from + // here; only the first scene installs the main root view controller. + // The connection's activities carry the token the activation request was stamped + // with, so the window layer can take the scene its own request produced rather + // than assuming scenes connect in the order they were asked for. + if (CN1MacWindowAdoptScene((UIWindowScene *)scene, connectionOptions.userActivities)) { + return; + } +#endif +#if !TARGET_OS_MACCATALYST + /* One main surface, so one scene may own it. Codename One has a single global + * current form and a single rendering surface off Catalyst, and installing a root + * view controller into a second scene gives two live main surfaces competing for + * that one state -- which shows up as a rendering fault, not as an error. + * + * A plain iOS build never gets here twice: it declares + * UIApplicationSupportsMultipleScenes false, so the system creates one scene. The + * case this covers is the iOS destination of a project generated for Mac Catalyst, + * which shares that project's Info.plist and therefore its true value, plus any + * scene the system restores on its own. + * + * Asked of the connected scenes rather than latched in a static, so a scene that + * disconnects and reconnects -- which iOS does on its own schedule -- is still + * allowed to take the main surface back. */ + for (UIScene *eachScene in [UIApplication sharedApplication].connectedScenes) { + if (eachScene == scene) { + continue; + } + id eachDelegate = eachScene.delegate; + if ([eachDelegate isKindOfClass:[CodenameOne_GLSceneDelegate class]] + && ((CodenameOne_GLSceneDelegate *)eachDelegate).window != nil) { + return; + } + } +#endif UIWindow *window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene]; CodenameOne_GLAppDelegate *appDelegate = (CodenameOne_GLAppDelegate *)[UIApplication sharedApplication].delegate; [appDelegate cn1InstallRootViewControllerIntoWindow:window]; @@ -82,26 +129,205 @@ - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session op } } +#if TARGET_OS_MACCATALYST +/* + * The window id of a Codename One window's scene, or -1 for the application's own + * scene. Every scene lifecycle callback has to ask this before running the global + * application path: a Codename One window is one window of the application, not the + * application, so treating its scene as the app's suspends everything -- including + * the still-visible main window -- and a disconnected scene never comes back to undo + * it. Shared rather than repeated, because two of these callbacks were missing the + * check while the other two had it. + */ +static int cn1MacCodenameOneWindowScene(UIScene *scene) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + return CN1MacWindowIdForScene((UIWindowScene *)scene); + } + return -1; +} + +/* + * Whether the global "application is active" path is currently in effect. Focus + * moving between the application's own scenes is not the application resigning: + * running the global path there marks the implementation inactive and fires the + * application's pause hook, which left the app paused -- main window included -- + * until the main window happened to be focused again. Suppressing the resign means + * the matching resume has to be suppressed too, or the application would be resumed + * from a pause it never entered, so both go through this one flag. + */ +static BOOL cn1MacApplicationActive = NO; + +/* + * Whether the application as a whole has been put in the background. Minimizing one + * window is not the application backgrounding, so the global path is gated on every + * scene being backgrounded -- and the matching foreground has to be gated the same + * way, or the application would be resumed from a suspension it never entered. + * Starts YES so the first scene entering the foreground at launch still runs the + * global path exactly as it did before any of this existed. + */ +static BOOL cn1MacApplicationBackgrounded = YES; + +/* YES while any of the application's scenes is still in the foreground at all. */ +static BOOL cn1MacAnySceneForeground(void) { + for (UIScene *each in [UIApplication sharedApplication].connectedScenes) { + UISceneActivationState state = each.activationState; + if (state == UISceneActivationStateForegroundActive + || state == UISceneActivationStateForegroundInactive) { + return YES; + } + } + return NO; +} + +/* YES while any of the application's scenes is foreground-active. */ +static BOOL cn1MacAnySceneActive(void) { + for (UIScene *each in [UIApplication sharedApplication].connectedScenes) { + if (each.activationState == UISceneActivationStateForegroundActive) { + return YES; + } + } + return NO; +} + +/* + * Resigns the application, but only once it is clear the application itself is no + * longer active. The scene gaining focus has not necessarily reached + * ForegroundActive by the time the losing scene reports its resign, so reading the + * activation states here would see none active and pause the app on every + * window-to-window focus change. Deferring one runloop turn lets the gaining scene + * settle first: by then either one of our scenes is active, and this does nothing, + * or none is and the application really did resign. + */ +static void cn1MacResignActiveIfApplicationInactive(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (!cn1MacApplicationActive || cn1MacAnySceneActive()) { + return; + } + cn1MacApplicationActive = NO; + CodenameOne_GLAppDelegate *appDelegate = + (CodenameOne_GLAppDelegate *)[UIApplication sharedApplication].delegate; + [appDelegate cn1ApplicationWillResignActive]; + }); +} +#endif + - (void)sceneDidBecomeActive:(UIScene *)scene API_AVAILABLE(ios(13.0)) { +#if TARGET_OS_MACCATALYST + // A Codename One window's scene: report the focus rather than treating it as the + // application becoming active, which would run the main form's resume path. + { + int windowId = cn1MacCodenameOneWindowScene(scene); + if (windowId >= 0) { + CN1MacWindowDeliverFocus(windowId, YES); + // Clicking one of our windows is still how the user brings a resigned + // application back, so resume it here -- but only from a real resign, + // which is what the flag distinguishes. + if (!cn1MacApplicationActive) { + cn1MacApplicationActive = YES; + CodenameOne_GLAppDelegate *windowSceneDelegate = + (CodenameOne_GLAppDelegate *)[UIApplication sharedApplication].delegate; + [windowSceneDelegate cn1ApplicationDidBecomeActive]; + } + return; + } + } + // Focus arriving from one of our own window scenes never resigned the + // application, so there is nothing to resume. + if (cn1MacApplicationActive) { + return; + } + cn1MacApplicationActive = YES; +#endif CodenameOne_GLAppDelegate *appDelegate = (CodenameOne_GLAppDelegate *)[UIApplication sharedApplication].delegate; [appDelegate cn1ApplicationDidBecomeActive]; } - (void)sceneWillResignActive:(UIScene *)scene API_AVAILABLE(ios(13.0)) { +#if TARGET_OS_MACCATALYST + { + int windowId = cn1MacCodenameOneWindowScene(scene); + if (windowId >= 0) { + CN1MacWindowDeliverFocus(windowId, NO); + } + } + // Every Catalyst resign goes through the deferred check, main scene included: the + // main window losing focus to one of our windows is not the application resigning + // either, and the check is what tells the two apart. + cn1MacResignActiveIfApplicationInactive(); +#else CodenameOne_GLAppDelegate *appDelegate = (CodenameOne_GLAppDelegate *)[UIApplication sharedApplication].delegate; [appDelegate cn1ApplicationWillResignActive]; +#endif +} + +- (void)sceneDidDisconnect:(UIScene *)scene API_AVAILABLE(ios(13.0)) +{ +#if TARGET_OS_MACCATALYST + // The user closed a Codename One window with the native close control. Without + // this the framework never learns: close listeners and setCloseOperation are + // skipped and the window stays registered and painted with no scene behind it. + if ([scene isKindOfClass:[UIWindowScene class]]) { + CN1MacWindowSceneDisconnected((UIWindowScene *)scene); + } +#endif } - (void)sceneWillEnterForeground:(UIScene *)scene API_AVAILABLE(ios(13.0)) { +#if TARGET_OS_MACCATALYST + // A Codename One window coming back from minimized is not the application + // returning to the foreground; running the global resume path here would + // resume an application that was never suspended. The per-window restore is + // still reported, as the matching background branch reports the minimize. + { + int windowId = cn1MacCodenameOneWindowScene(scene); + // Window id 0 is the main window: reporting it is what cascades the windows + // it owns back, and core ignores the id-0 lifecycle notification itself. + CN1MacWindowDeliverVisibility(windowId >= 0 ? windowId : 0, YES); + } + // Restoring any window resumes an application that really was backgrounded, but + // one that never was has nothing to resume. This cannot ask the scenes, because + // the scene entering the foreground has not got there yet when this fires. + if (!cn1MacApplicationBackgrounded) { + return; + } + cn1MacApplicationBackgrounded = NO; +#endif CodenameOne_GLAppDelegate *appDelegate = (CodenameOne_GLAppDelegate *)[UIApplication sharedApplication].delegate; [appDelegate cn1ApplicationWillEnterForeground]; } - (void)sceneDidEnterBackground:(UIScene *)scene API_AVAILABLE(ios(13.0)) { +#if TARGET_OS_MACCATALYST + // Minimizing or closing one Codename One window is not the application going to + // the background. The global path sets isAppSuspended, stops the garbage + // collector and notifies the application of suspension -- and if this scene is + // then disconnected it never enters the foreground again to undo any of it, + // leaving the still-visible main window suspended for good. + // + // Suppressing the global path is not the same as reporting nothing, though: + // without the per-window notification the framework kept the window visible, + // painting it and running its animations while it was minimized. + { + int windowId = cn1MacCodenameOneWindowScene(scene); + // Window id 0 is the main window. The main scene minimizing is no more the + // application backgrounding than one of ours is, and reporting it is also + // what takes the windows it owns down with it. + CN1MacWindowDeliverVisibility(windowId >= 0 ? windowId : 0, NO); + } + // Only once nothing of ours is left in the foreground is the application really + // backgrounding. Reaching the global path early sets isAppSuspended, stops the + // garbage collector and fires the suspend callback while another window is still + // perfectly usable -- and a scene that is then disconnected never comes back to + // undo any of it. + if (cn1MacAnySceneForeground() || cn1MacApplicationBackgrounded) { + return; + } + cn1MacApplicationBackgrounded = YES; +#endif CodenameOne_GLAppDelegate *appDelegate = (CodenameOne_GLAppDelegate *)[UIApplication sharedApplication].delegate; [appDelegate cn1ApplicationDidEnterBackground]; } diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 03d511405f8..b7141f6a621 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -23,6 +23,9 @@ #import #import #import "CodenameOne_GLViewController.h" +#if TARGET_OS_MACCATALYST +#import "CN1MacWindows.h" +#endif #import "EAGLView.h" #ifdef CN1_USE_METAL #import "METALView.h" @@ -283,6 +286,30 @@ JAVA_INT getSafeTop() { #if !TARGET_OS_WATCH UIView *editingComponent; +#if TARGET_OS_MACCATALYST +/* + * Moves an editor that had to start before its window's scene existed. + * + * Editing can begin in the same event dispatch turn as Window.show(), and the scene + * is granted asynchronously -- so CN1MacWindowEditingHostView() can be nil while a + * secondary window is genuinely the requested host. The editor went to the main + * controller's view and stayed there, visible and typeable in the wrong window, + * because adoption never revisited it. Called from CN1MacWindowSceneConnected. + */ +void CN1MacWindowReattachEditor(UIView* host) { + if (host == nil || editingComponent == nil) { + return; + } + if (editingComponent.superview == host) { + return; + } + [editingComponent removeFromSuperview]; + [host addSubview:editingComponent]; + [editingComponent becomeFirstResponder]; + [editingComponent setNeedsDisplay]; +} +#endif + // Currently used only for datepicker but could be used for // other things. A persistent reference to the action sheet // so that it can be resized and manipulated as necessary @@ -584,7 +611,9 @@ static int cn1MapUIPressTypeToKeyCode(UIPressType type) { // expects: a negative sentinel for non-printable keys, a unicode codepoint for // printable characters, or 0 if we don't recognize the key. #if !TARGET_OS_WATCH -static int cn1MapUIKeyToKeyCode(UIKey *key) API_AVAILABLE(ios(13.4)) { +/* Not static: the Mac Catalyst window controller needs the same mapping, and + * duplicating a hundred-case switch would let the two drift apart. */ +int cn1MapUIKeyToKeyCode(UIKey *key) API_AVAILABLE(ios(13.4)) { switch (key.keyCode) { case UIKeyboardHIDUsageKeyboardReturnOrEnter: case UIKeyboardHIDUsageKeypadEnter: @@ -839,6 +868,22 @@ void cn1_setStyleDoneButton(CN1_THREAD_STATE_MULTI_ARG UIBarButtonItem* btn) { } } float scale = scaleValue; +#if TARGET_OS_MACCATALYST + { + /* The owning window's backing scale when this edit belongs to a Codename + * One window, since scaleValue is the *main* scene's. Two Catalyst scenes + * can sit on displays of different scale, and converting with the wrong + * one left the native field oversized or undersized and displaced from + * the lightweight field it replaces -- the same defect peers had before + * they were given the owning window's scale. Zero means the main surface, + * which keeps scaleValue. */ + extern double CN1MacWindowEditingScale(void); + double windowScale = CN1MacWindowEditingScale(); + if (windowScale > 0) { + scale = (float) windowScale; + } + } +#endif editCompoentX = (x + padLeft) / scale; editCompoentY = (y + padTop) / scale; editComponentPadTop = padTop; @@ -1222,9 +1267,27 @@ void cn1_setStyleDoneButton(CN1_THREAD_STATE_MULTI_ARG UIBarButtonItem* btn) { #endif } editingComponent.opaque = NO; - [[CodenameOne_GLViewController instance].view addSubview:editingComponent]; + UIView* editHost = [CodenameOne_GLViewController instance].view; +#if TARGET_OS_MACCATALYST + { + /* A field inside a Codename One window belongs in that window's view. + * Added to the main controller's view unconditionally, the editor stayed + * on the main surface while the user typed into a secondary window. iOS + * keeps the original path exactly. */ + UIView* windowHost = CN1MacWindowEditingHostView(); + if (windowHost != nil) { + editHost = windowHost; + } + /* windowHost can be nil for a window whose scene has not been granted + * yet, since editing can start in the same turn as show(). The editor + * goes to the main view for now and CN1MacWindowSceneConnected moves it + * across through CN1MacWindowReattachEditor once the content view + * exists -- without that it stayed on the main surface for good. */ + } +#endif + [editHost addSubview:editingComponent]; [editingComponent becomeFirstResponder]; - [[CodenameOne_GLViewController instance].view resignFirstResponder]; + [editHost resignFirstResponder]; [editingComponent setNeedsDisplay]; }); @@ -4447,12 +4510,26 @@ - (void)drawFrame:(CGRect)rect allowInactive:(BOOL)allowInactive JAVA_OBJECT comp = impl->com_codename1_impl_ios_IOSImplementation_currentEditing; #endif if(comp != NULL) { + /* The same scale the editor was created with. Creation was corrected + * to use the owning window's, and leaving this on the main scene's + * meant the editor jumped back to the wrong offset the first time + * scrolling or layout moved the field. */ + float editScale = scaleValue; +#if TARGET_OS_MACCATALYST + { + extern double CN1MacWindowEditingScale(void); + double windowScale = CN1MacWindowEditingScale(); + if (windowScale > 0) { + editScale = (float) windowScale; + } + } +#endif #ifndef NEW_CODENAME_ONE_VM - float newEditCompoentX = (com_codename1_ui_Component_getAbsoluteX__(comp) + com_codename1_ui_Component_getScrollX__(comp) + editComponentPadLeft) / scaleValue; - float newEditCompoentY = (com_codename1_ui_Component_getAbsoluteY__(comp) + com_codename1_ui_Component_getScrollY__(comp) + editComponentPadTop) / scaleValue; + float newEditCompoentX = (com_codename1_ui_Component_getAbsoluteX__(comp) + com_codename1_ui_Component_getScrollX__(comp) + editComponentPadLeft) / editScale; + float newEditCompoentY = (com_codename1_ui_Component_getAbsoluteY__(comp) + com_codename1_ui_Component_getScrollY__(comp) + editComponentPadTop) / editScale; #else - float newEditCompoentX = (com_codename1_ui_Component_getAbsoluteX___R_int(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_OBJECT)comp) + com_codename1_ui_Component_getScrollX___R_int(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_OBJECT)comp) + editComponentPadLeft) / scaleValue; - float newEditCompoentY = (com_codename1_ui_Component_getAbsoluteY___R_int(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_OBJECT)comp) + com_codename1_ui_Component_getScrollY___R_int(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_OBJECT)comp) + editComponentPadTop) / scaleValue; + float newEditCompoentX = (com_codename1_ui_Component_getAbsoluteX___R_int(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_OBJECT)comp) + com_codename1_ui_Component_getScrollX___R_int(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_OBJECT)comp) + editComponentPadLeft) / editScale; + float newEditCompoentY = (com_codename1_ui_Component_getAbsoluteY___R_int(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_OBJECT)comp) + com_codename1_ui_Component_getScrollY___R_int(CN1_THREAD_GET_STATE_PASS_ARG (JAVA_OBJECT)comp) + editComponentPadTop) / editScale; #endif if(newEditCompoentX != editCompoentX || newEditCompoentY != editCompoentY) { for (UIWindow *window in [[UIApplication sharedApplication] windows]) diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index a336a88846b..d23dccc5638 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -2086,6 +2086,70 @@ void cn1CapturePointerMetadata(UITouch* touch) { } #endif // !TARGET_OS_WATCH +#if TARGET_OS_MACCATALYST +/* + * Mac Catalyst desktop windows. These marshal a window's own events into the + * framework, mirroring the pointerPressed / screenSizeChanged bridges below so + * all the ParparVM thread-state handling stays in one file. + */ +void CN1MacWindowDeliverClose(int windowId) { + com_codename1_impl_ios_IOSImplementation_windowCloseCallback___int(CN1_THREAD_GET_STATE_PASS_ARG windowId); +} + +void CN1MacWindowDeliverClosed(int windowId) { + com_codename1_impl_ios_IOSImplementation_windowClosedNativelyCallback___int(CN1_THREAD_GET_STATE_PASS_ARG windowId); +} + + +void CN1MacWindowDeliverMonitorsChanged(void) { + com_codename1_impl_ios_IOSImplementation_monitorsChangedCallback__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +} + +void CN1MacWindowDeliverFocus(int windowId, BOOL gained) { + com_codename1_impl_ios_IOSImplementation_windowFocusCallback___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG windowId, gained ? JAVA_TRUE : JAVA_FALSE); +} + +void CN1MacWindowDeliverContentReady(int windowId) { + com_codename1_impl_ios_IOSImplementation_windowContentReadyCallback___int(CN1_THREAD_GET_STATE_PASS_ARG windowId); +} + +void CN1MacWindowDeliverActivationFailed(int windowId, int requestSeq) { + com_codename1_impl_ios_IOSImplementation_windowActivationFailedCallback___int_int(CN1_THREAD_GET_STATE_PASS_ARG windowId, requestSeq); +} + +void CN1MacWindowDeliverVisibility(int windowId, BOOL shown) { + com_codename1_impl_ios_IOSImplementation_windowVisibilityCallback___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG windowId, shown ? JAVA_TRUE : JAVA_FALSE); +} + +void CN1MacWindowDeliverResize(int windowId, int width, int height) { + com_codename1_impl_ios_IOSImplementation_windowSizeCallback___int_int_int(CN1_THREAD_GET_STATE_PASS_ARG windowId, width, height); +} + +void CN1MacWindowDeliverPointer(int windowId, int type, int x, int y) { + com_codename1_impl_ios_IOSImplementation_windowPointerCallback___int_int_int_int(CN1_THREAD_GET_STATE_PASS_ARG windowId, type, x, y); +} + +void CN1MacWindowDeliverHover(int windowId, int type, int x, int y) { + com_codename1_impl_ios_IOSImplementation_windowHoverCallback___int_int_int_int(CN1_THREAD_GET_STATE_PASS_ARG windowId, type, x, y); +} + +void CN1MacWindowDeliverWheel(int windowId, int x, int y, int scrollX, int scrollY) { + com_codename1_impl_ios_IOSImplementation_windowWheelCallback___int_int_int_int_int(CN1_THREAD_GET_STATE_PASS_ARG windowId, x, y, scrollX, scrollY); +} + +void CN1MacWindowDeliverPinch(int windowId, float scale, int x, int y) { + com_codename1_impl_ios_IOSImplementation_windowPinchCallback___int_float_int_int(CN1_THREAD_GET_STATE_PASS_ARG windowId, scale, x, y); +} + +void CN1MacWindowDeliverRotation(int windowId, float radians, int x, int y) { + com_codename1_impl_ios_IOSImplementation_windowRotationCallback___int_float_int_int(CN1_THREAD_GET_STATE_PASS_ARG windowId, radians, x, y); +} + +void CN1MacWindowDeliverKey(int windowId, int keyCode, BOOL pressed) { + com_codename1_impl_ios_IOSImplementation_windowKeyCallback___int_int_boolean(CN1_THREAD_GET_STATE_PASS_ARG windowId, keyCode, pressed ? JAVA_TRUE : JAVA_FALSE); +} +#endif + void pointerPressed(int* x, int* y, int length) { if(length == 1) { com_codename1_impl_ios_IOSImplementation_pointerPressedCallback___int_int(CN1_THREAD_GET_STATE_PASS_ARG x[0], y[0]); @@ -2346,6 +2410,239 @@ void com_codename1_impl_ios_IOSNative_setMacWindowUndecorated___boolean(CN1_THRE #endif } +/* ---- Mac Catalyst desktop windows (CN1MacWindows.m) --------------------- */ +#if TARGET_OS_MACCATALYST +#import "CN1MacWindows.h" +#endif + +JAVA_INT com_codename1_impl_ios_IOSNative_macWindowCreate___int_java_lang_String_int_int_int_int_boolean_boolean_boolean_R_int( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT windowId, JAVA_OBJECT title, + JAVA_INT x, JAVA_INT y, JAVA_INT width, JAVA_INT height, + JAVA_BOOLEAN decorated, JAVA_BOOLEAN resizable, JAVA_BOOLEAN positionSet) { +#if TARGET_OS_MACCATALYST + POOL_BEGIN(); + NSString* t = toNSString(CN1_THREAD_STATE_PASS_ARG title); + int slot = CN1MacWindowCreate(windowId, t == nil ? @"" : t, x, y, width, height, + decorated ? YES : NO, resizable ? YES : NO, positionSet ? YES : NO); + POOL_END(); + return slot; +#else + return -1; +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowDestroy___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot) { +#if TARGET_OS_MACCATALYST + CN1MacWindowDestroy(slot); +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macWindowRequestSeq___int_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot) { +#if TARGET_OS_MACCATALYST + return CN1MacWindowRequestSeq(slot); +#else + return 0; +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowShow___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_BOOLEAN visible) { +#if TARGET_OS_MACCATALYST + CN1MacWindowShow(slot, visible ? YES : NO); +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowSetDecorated___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_BOOLEAN decorated) { +#if TARGET_OS_MACCATALYST + CN1MacWindowSetDecorated(slot, decorated == JAVA_TRUE ? YES : NO); +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowSetMinimumSize___int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_INT width, JAVA_INT height) { +#if TARGET_OS_MACCATALYST + CN1MacWindowSetMinimumSize(slot, width, height); +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowSetEditingSlot___int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot) { +#if TARGET_OS_MACCATALYST + CN1MacWindowSetEditingSlot(slot); +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowSetResizable___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_BOOLEAN resizable) { +#if TARGET_OS_MACCATALYST + CN1MacWindowSetResizable(slot, resizable == JAVA_TRUE ? YES : NO); +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_macWindowReopen___int_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot) { +#if TARGET_OS_MACCATALYST + return CN1MacWindowReopen(slot) ? JAVA_TRUE : JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowSetInputEnabled___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_BOOLEAN enabled) { +#if TARGET_OS_MACCATALYST + CN1MacWindowSetInputEnabled(slot, enabled == JAVA_TRUE); +#endif +} + +void com_codename1_impl_ios_IOSNative_macMainWindowSetInputEnabled___boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_BOOLEAN enabled) { +#if TARGET_OS_MACCATALYST + CN1MacMainWindowSetInputEnabled(enabled == JAVA_TRUE); +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_macWindowAttachPeer___long_int_int_int_int_int_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG peer, JAVA_INT slot, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h) { +#if TARGET_OS_MACCATALYST + UIView* v = (BRIDGE_CAST UIView*)((void *)peer); + return CN1MacWindowAttachPeer(slot, v, x, y, w, h) ? JAVA_TRUE : JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowWatchScreens__(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if TARGET_OS_MACCATALYST + CN1MacWindowWatchScreens(); +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowSetTitle___int_java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_OBJECT title) { +#if TARGET_OS_MACCATALYST + POOL_BEGIN(); + NSString* t = toNSString(CN1_THREAD_STATE_PASS_ARG title); + CN1MacWindowSetTitle(slot, t == nil ? @"" : t); + POOL_END(); +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowSetBounds___int_int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_INT x, JAVA_INT y, JAVA_INT width, JAVA_INT height) { +#if TARGET_OS_MACCATALYST + CN1MacWindowSetBounds(slot, x, y, width, height); +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_macMainWindowGetBounds___int_1ARRAY_R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT out) { +#if TARGET_OS_MACCATALYST + if (out == JAVA_NULL || ((JAVA_ARRAY) out)->length < 4) { + return JAVA_FALSE; + } + return CN1MacMainWindowGetBounds((int*) ((JAVA_ARRAY) out)->data) ? JAVA_TRUE : JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowGetBounds___int_int_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_OBJECT out) { +#if TARGET_OS_MACCATALYST + if (out == JAVA_NULL || ((JAVA_ARRAY) out)->length < 4) { + return; + } + CN1MacWindowGetBounds(slot, (int*) ((JAVA_ARRAY) out)->data); +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macWindowGetWidth___int_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot) { +#if TARGET_OS_MACCATALYST + return CN1MacWindowGetWidth(slot); +#else + return 0; +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macWindowGetHeight___int_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot) { +#if TARGET_OS_MACCATALYST + return CN1MacWindowGetHeight(slot); +#else + return 0; +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowSetState___int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_INT state) { +#if TARGET_OS_MACCATALYST + CN1MacWindowSetState(slot, state); +#endif +} + +void com_codename1_impl_ios_IOSNative_macWindowPresent___int_int_1ARRAY_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot, JAVA_OBJECT argb, JAVA_INT width, JAVA_INT height) { +#if TARGET_OS_MACCATALYST + if (argb == JAVA_NULL) { + return; + } + CN1MacWindowPresent(slot, ((JAVA_ARRAY) argb)->data, width, height); +#endif +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_macMultiWindowSupported___R_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if TARGET_OS_MACCATALYST + return CN1MacMultiWindowSupported() ? JAVA_TRUE : JAVA_FALSE; +#else + return JAVA_FALSE; +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macMonitorCount___R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if TARGET_OS_MACCATALYST + return CN1MacMonitorCount(); +#else + return 1; +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macPrimaryMonitor___R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if TARGET_OS_MACCATALYST + return CN1MacPrimaryMonitor(); +#else + return 0; +#endif +} + +void com_codename1_impl_ios_IOSNative_macMonitorBounds___int_boolean_int_1ARRAY(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT monitor, JAVA_BOOLEAN workArea, JAVA_OBJECT out) { +#if TARGET_OS_MACCATALYST + if (out == JAVA_NULL || ((JAVA_ARRAY) out)->length < 4) { + return; + } + CN1MacMonitorBounds(monitor, workArea ? YES : NO, (int*) ((JAVA_ARRAY) out)->data); +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macMonitorDpi___int_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT monitor) { +#if TARGET_OS_MACCATALYST + return CN1MacMonitorDpi(monitor); +#else + return 96; +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macMonitorScaleTimes100___int_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT monitor) { +#if TARGET_OS_MACCATALYST + /* Scaled by a hundred because the bridge carries ints; the Java side divides + * it back out. */ + return (JAVA_INT) (CN1MacMonitorScale(monitor) * 100.0 + 0.5); +#else + return 100; +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macMonitorForWindow___int_R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT slot) { +#if TARGET_OS_MACCATALYST + return CN1MacMonitorForWindow(slot); +#else + return 0; +#endif +} + +JAVA_INT com_codename1_impl_ios_IOSNative_macMonitorForMainWindow___R_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject) { +#if TARGET_OS_MACCATALYST + return CN1MacMonitorForMainWindow(); +#else + return 0; +#endif +} + JAVA_LONG com_codename1_impl_ios_IOSNative_createNSData___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT file) { POOL_BEGIN(); NSString* ns = toNSString(CN1_THREAD_STATE_PASS_ARG file); @@ -16528,6 +16825,26 @@ void cn1_watch_activate_connectivity(void) { } #endif +// Declared rather than left implicit. These six are the translated form of the static +// Java methods on IOSWearableCallbacks, and ParparVM emits their definitions -- but it +// emits no header this file includes, so every call below was an implicit declaration. +// C99 dropped those, and a clang that enforces it turns all six into build errors: +// "call to undeclared function ... ISO C99 and later do not support implicit function +// declarations". That is a toolchain change away from breaking every iOS target at +// once, which is exactly what happened, so the declarations are written out here. +extern JAVA_VOID com_codename1_impl_ios_IOSWearableCallbacks_nativeMessageReceived___java_lang_String_byte_1ARRAY_int( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT payload, JAVA_INT replyToken); +extern JAVA_VOID com_codename1_impl_ios_IOSWearableCallbacks_nativeReplyReceived___int_byte_1ARRAY_java_lang_String( + CODENAME_ONE_THREAD_STATE, JAVA_INT replyToken, JAVA_OBJECT payload, JAVA_OBJECT error); +extern JAVA_VOID com_codename1_impl_ios_IOSWearableCallbacks_nativeDataChanged___java_lang_String_byte_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT payload); +extern JAVA_VOID com_codename1_impl_ios_IOSWearableCallbacks_nativeDataChangedTracked___java_lang_String_byte_1ARRAY_java_lang_String( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path, JAVA_OBJECT payload, JAVA_OBJECT token); +extern JAVA_VOID com_codename1_impl_ios_IOSWearableCallbacks_nativeDataRemoved___java_lang_String( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT path); +extern JAVA_VOID com_codename1_impl_ios_IOSWearableCallbacks_nativeStateChanged__( + CODENAME_ONE_THREAD_STATE); + // Callbacks the delegate calls when the peer sends something. Each hops into the Java callback // surface, which owns EDT dispatch and the cold-start queue. diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 231707fe9cd..1d15422a609 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -22,6 +22,7 @@ */ package com.codename1.impl.ios; +import com.codename1.ui.Desktop; import com.codename1.background.BackgroundFetch; import com.codename1.capture.VideoCaptureConstraints; import com.codename1.codescan.CodeScanner; @@ -803,8 +804,11 @@ private static void updateNativeTextEditorFrame() { private static void updateNativeTextEditorFrame(boolean requestFocus) { if (instance.currentEditing != null) { TextArea cmp = instance.currentEditing; - Form form = cmp.getComponentForm(); - if (form == null || form != CN.getCurrentForm() ) { + // A field in a Window has no Form; the equivalent check is that its top + // level is still the one being displayed. + com.codename1.ui.TopLevelContainer top = cmp.getTopLevelContainer(); + if (top == null + || (top instanceof Form && top != CN.getCurrentForm())) { //NOPMD CompareObjectsWithEquals instance.stopTextEditing(); return; } @@ -861,14 +865,23 @@ private static void updateNativeTextEditorFrame(boolean requestFocus) { } } */ - Container contentPane = form.getContentPane(); + Container contentPane = top.getContentPane(); if (!contentPane.contains(cmp)) { - contentPane = form; + contentPane = top.asContainer(); } Style contentPaneStyle = contentPane.getStyle(); int minY = contentPane.getAbsoluteY() + contentPane.getScrollY() + contentPaneStyle.getPaddingTop(); - int maxH = Display.getInstance().getDisplayHeight() - minY - nativeInstance.getVKBHeight(); + // A window's coordinates are its own, so the main surface height is the + // wrong ceiling to measure one against: a field lower than the main window + // is tall clipped to a negative height and stopped editing outright, and a + // window shorter than the main surface got an editor running past its + // bottom edge. Same resolution as Container.snapToSafeAreaInternal, and the + // main window keeps reading the display exactly as before. + int surfaceHeight = top instanceof com.codename1.ui.Window + ? top.asContainer().getHeight() + : Display.getInstance().getDisplayHeight(); + int maxH = surfaceHeight - minY - nativeInstance.getVKBHeight(); if (y < minY) { h -= (minY - y); @@ -1016,6 +1029,34 @@ public boolean isNativeTitle() { return isDesktop() && "native".equals(getDesktopTitleBarMode()); } + private MacWindowManager windowManager; + + /** + * @inheritDoc + * + * Desktop windows exist only on the Mac Catalyst slice, where the builder writes + * {@code UIApplicationSupportsMultipleScenes} into Info.plist. That key is what + * actually makes a second scene possible, so this reads it back out of the bundle + * rather than trusting a build flag -- the API and the plist then cannot disagree, + * including in a hand-edited project. + */ + @Override + public com.codename1.impl.WindowManager getWindowManager() { + if (!isDesktop()) { + return null; + } + // The Info.plist key is the single source of truth: without multiple scenes + // enabled the system refuses to activate a second one, so reporting supported + // here would hand back windows that never appear. + if (!nativeInstance.macMultiWindowSupported()) { + return null; + } + if (windowManager == null) { + windowManager = new MacWindowManager(this); + } + return windowManager; + } + // Tracks the last desktop title-bar mode pushed to the native window chrome so the (idempotent) // native call is only made when the mode actually changes. private String lastMacChromeMode; @@ -1215,11 +1256,21 @@ public void run() { // Check if the form has any setting for asyncEditing that should override // the application defaults. - Form parentForm = cmp.getComponentForm(); - if (parentForm == null) { - //Log.p("Attempt to edit text area that is not on a form. This is not supported"); + // The top level, not the Form. getComponentForm() is null for a component + // in a Window, and returning here meant a Catalyst window -- which this + // port advertises as supporting windows -- could not edit any text field + // at all. + com.codename1.ui.TopLevelContainer parentTop = cmp.getTopLevelContainer(); + if (parentTop == null) { + //Log.p("Attempt to edit text area that is not on a top level. This is not supported"); return; } + Container parentForm = parentTop.asContainer(); + // Tell the native side which window is being edited, so the editor is + // added to that window's view rather than the main surface's. Cleared to + // -1 for a field on the main form, since the slot is process wide. + nativeInstance.macWindowSetEditingSlot( + parentTop instanceof Form ? -1 : MacWindowManager.slotForComponent(cmp)); if (parentForm.getClientProperty("asyncEditing") != null) { Object async = parentForm.getClientProperty("asyncEditing"); if (async instanceof Boolean) { @@ -1241,7 +1292,11 @@ public void run() { // the form to make sure that it is scrollable. If it is not // scrollable, then this field should default to Non-async // editing - and should instead revert to legacy editing mode. - if(asyncEdit && !parentForm.isFormBottomPaddingEditingMode()) { + // Bottom padding editing mode is a Form concept tied to the virtual + // keyboard; a desktop Window has neither, so it reads as false there. + boolean bottomPaddingMode = parentTop instanceof Form + && ((Form) parentTop).isFormBottomPaddingEditingMode(); + if(asyncEdit && !bottomPaddingMode) { Container p = cmp.getParent(); // A crude estimate of how far the component needs to be able to scroll to make @@ -1258,7 +1313,7 @@ public void run() { asyncEdit = p != null; //Log.p("Overriding asyncEdit due to form scrollability: "+asyncEdit); - } else if (parentForm.isFormBottomPaddingEditingMode()){ + } else if (bottomPaddingMode){ // If form uses bottom padding mode, then we will always // use async edit (unless the field explicitly overrides it). asyncEdit = true; @@ -1317,9 +1372,17 @@ public void run() { final boolean rtl = UIManager.getInstance().getLookAndFeel().isRTL(); final Style hintStyle = currentEditing.getHintLabel() != null ? currentEditing.getHintLabel().getStyle() : stl; - if (current != null) { - Component nextComponent = current.getNextComponent(cmp); - TextEditUtil.setNextEditComponent(nextComponent); + // Through the editing component's own top level, not the current form. In a + // Window cmp is absent from the main form's tab order, so TabIterator + // treated it as an unknown start and handed back the main form's first + // focusable component -- the keyboard's Next action jumped to an unrelated + // field, or in a window-only application left a stale destination in place. + // getNextComponent is Form-only but is defined as exactly this call, and + // getTabIterator is on TopLevelContainer. + if (parentTop != null) { + TextEditUtil.setNextEditComponent(parentTop.getTabIterator(cmp).getNext()); + } else if (current != null) { + TextEditUtil.setNextEditComponent(current.getNextComponent(cmp)); } Display.getInstance().callSerially(new Runnable() { @Override @@ -1416,9 +1479,9 @@ public void run() { }); if(cmp instanceof TextArea && !((TextArea)cmp).isSingleLineTextArea()) { - Form form = cmp.getComponentForm(); - if (form != null) { - form.revalidate(); + com.codename1.ui.TopLevelContainer revalidateTop = cmp.getTopLevelContainer(); + if (revalidateTop != null) { + revalidateTop.asContainer().revalidate(); } } if(editNext) { @@ -1842,6 +1905,228 @@ public void flushGraphics(int x, int y, int width, int height) { private final static int[] singleDimensionX = new int[1]; private final static int[] singleDimensionY = new int[1]; + // ---- Mac Catalyst desktop windows ------------------------------------- + // + // Invoked from CN1MacWindows.m by way of the delivery bridge in IOSNative.m. + // Every one of these arrives on the platform's own thread; Display marshals + // onto the event dispatch thread where that matters. + + /// Invoked when the user activates a window's close control. + public static void windowCloseCallback(int windowId) { + Desktop.getInstance().windowCloseRequested(windowId); + } + + /// Invoked when the platform refuses to give a window a scene, so it will never + /// appear. Reported separately from a minimize because a modal window that never + /// appeared has to release its blocker. + public static void windowActivationFailedCallback(final int windowId, final int requestSeq) { + // The token identifies the request that failed and arrives with the failure. + // Sampling the current one here instead would sample a retry that started after + // the port released the slot, and the stale failure would then be applied to it. + // + // Both halves in one EDT unit. Updating the peer here on UIKit's thread while + // the framework half waited in the queue let a concurrent show() slip between + // them and be undone by a failure that no longer applied to it. + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (!MacWindowManager.activationFailed(windowId, requestSeq)) { + return; + } + Desktop.getInstance().windowActivationFailed(windowId); + } + }); + } + + /// Invoked once the platform has destroyed a window's scene. Catalyst hands the + /// disconnect over after the fact, so there is nothing left to veto. + public static void windowClosedNativelyCallback(int windowId) { + Desktop.getInstance().windowClosedNatively(windowId); + } + + /// Invoked when a display is attached, removed or changes mode. + public static void monitorsChangedCallback() { + Desktop.getInstance().monitorsChanged(); + } + + /// Invoked for a mouse or trackpad hover over a secondary window. Catalyst + /// delivers hover through a gesture recognizer rather than as a touch, so a + /// secondary scene reports nothing without one installed on its own controller. + public static void windowHoverCallback(int windowId, int type, int x, int y) { + if (dropEvents) { + return; + } + int[] xs = new int[]{x}; + int[] ys = new int[]{y}; + switch (type) { + case 1: + com.codename1.ui.Desktop.getInstance().windowPointerHoverPressed(windowId, xs, ys); + break; + case 2: + com.codename1.ui.Desktop.getInstance().windowPointerHoverReleased(windowId, xs, ys); + break; + default: + Desktop.getInstance().windowPointerHover(windowId, xs, ys); + break; + } + } + + /// Invoked for an indirect scroll (wheel or trackpad) over a secondary window. + public static void windowWheelCallback(int windowId, int x, int y, int scrollX, int scrollY) { + if (dropEvents) { + return; + } + instance.windowPointerWheelMoved(windowId, x, y, scrollX, scrollY, false, 0); + } + + /// Invoked for a trackpad magnify over a secondary window. + public static void windowPinchCallback(final int windowId, final float scale, + final int x, final int y) { + if (dropEvents) { + return; + } + // On the event dispatch thread, not UIKit's main thread: this hit tests the + // window's hierarchy and runs application pinch handlers. + Display.getInstance().callSerially(new Runnable() { + public void run() { + com.codename1.ui.Desktop.getInstance().windowMagnifyGesture(windowId, x, y, scale); + } + }); + } + + /// Invoked for a trackpad rotation over a secondary window. + public static void windowRotationCallback(final int windowId, final float radians, + final int x, final int y) { + if (dropEvents) { + return; + } + // Marshalled for the same reason as windowPinchCallback. + Display.getInstance().callSerially(new Runnable() { + public void run() { + com.codename1.ui.Desktop.getInstance().windowRotationGesture(windowId, x, y, radians); + } + }); + } + + /// Invoked when a window gains or loses keyboard focus. + public static void windowFocusCallback(int windowId, boolean gained) { + Desktop.getInstance().windowFocusChanged(windowId, gained); + } + + /// Invoked when a window's scene enters or leaves the background, which on Mac + /// Catalyst is how minimizing and restoring one window is reported. Distinct from + /// focus: an unfocused window is still on screen and still painted, a minimized + /// one is neither. + /// Invoked once a Catalyst window's scene has been granted and its content view + /// exists. + /// + /// A peer created in the same event dispatch turn as `Window.show()` had nowhere + /// to go and stayed on the main surface. Rather than queue those natively -- which + /// means retained views to purge when a peer or its window goes away, a table to + /// size, and stale entries that could land in a recycled slot -- the window's own + /// component tree is walked here, which is the authoritative list of what belongs + /// in it. + public static void windowContentReadyCallback(final int windowId) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + com.codename1.ui.Window[] all = com.codename1.ui.Desktop.getInstance().getWindows(); + for (int iter = 0; iter < all.length; iter++) { + if (all[iter].getWindowId() == windowId) { + reattachPeers(all[iter]); + return; + } + } + } + }); + } + + private static void reattachPeers(com.codename1.ui.Container c) { + int count = c.getComponentCount(); + for (int iter = 0; iter < count; iter++) { + com.codename1.ui.Component cmp = c.getComponentAt(iter); + if (cmp instanceof NativeIPhoneView) { + // Only the heavyweight ones. peerSetVisible(false) removed the native + // view on purpose, and re-adding it here would put a lightweight + // component's live view back over its own snapshot, taking native + // input with it. + NativeIPhoneView peer = (NativeIPhoneView) cmp; + if (!peer.lightweightMode) { + peer.attachToOwningWindow(); + } + } else if (cmp instanceof com.codename1.ui.Container) { + reattachPeers((com.codename1.ui.Container) cmp); + } + } + } + + public static void windowVisibilityCallback(final int windowId, final boolean shown) { + // UIKit reports this on its main thread, but every other peer visibility + // mutation runs on the EDT. Cascading straight from here races an EDT dispose: + // the cascade holds a Peer and then uses its numeric slot, and a slot freed and + // reused in between would make this hide or show an unrelated window. Queueing + // the cascade and the lifecycle notification together also keeps them in that + // order, so the framework never sees a child reported before its owner. + Display.getInstance().callSerially(new Runnable() { + public void run() { + // The other desktop platforms take a window's owned windows down with + // it and report each one; Catalyst scenes have no owner relation, so + // the cascade is emulated here. Doing it for the user-driven minimize + // as well as for hide() is what makes the two paths agree -- an owner + // becomes hidden both ways. + // The owner's own transition is delivered first. Both notify methods + // queue through callSerially rather than running here, so cascading + // first would put every descendant ahead of the owner in that queue + // and a child listener asking whether its owner is showing would read + // stale state. + if (shown) { + Desktop.getInstance().windowShowNotify(windowId); + } else { + Desktop.getInstance().windowHideNotify(windowId); + } + MacWindowManager.windowVisibilityChanged(windowId, shown); + } + }); + } + + /// Invoked when a window's drawable area changes size. + public static void windowSizeCallback(int windowId, int width, int height) { + Desktop.getInstance().windowSizeChanged(windowId, width, height); + } + + /// Invoked for a pointer event inside a window. The type is 1 for a press, + /// 2 for a release and 3 for a drag, matching CN1MacWindowView. + public static void windowPointerCallback(int windowId, int type, int x, int y) { + if (dropEvents) { + return; + } + int[] xs = new int[]{x}; + int[] ys = new int[]{y}; + switch (type) { + case 1: + Desktop.getInstance().windowPointerPressed(windowId, xs, ys); + break; + case 2: + Desktop.getInstance().windowPointerReleased(windowId, xs, ys); + break; + default: + Desktop.getInstance().windowPointerDragged(windowId, xs, ys); + break; + } + } + + /// Invoked for a hardware keyboard event inside a window. Pressed is true for a + /// key down and false for a key up. + public static void windowKeyCallback(int windowId, int keyCode, boolean pressed) { + if (dropEvents) { + return; + } + if (pressed) { + Desktop.getInstance().windowKeyPressed(windowId, keyCode); + } else { + com.codename1.ui.Desktop.getInstance().windowKeyReleased(windowId, keyCode); + } + } + public static void pointerPressedCallback(int x, int y) { if(dropEvents) { return; @@ -1891,20 +2176,33 @@ public static void pointerWheelMovedCallback(int x, int y, int scrollX, int scro /// Invoked from the native magnify (pinch) gesture recognizer, used by the Mac Catalyst trackpad /// pinch and the iOS two finger pinch. Routes to the cross-platform pinch gesture dispatch. - public static void pinchMagnifyCallback(float scale, int x, int y) { + public static void pinchMagnifyCallback(final float scale, final int x, final int y) { if (dropEvents || instance == null) { return; } - com.codename1.ui.Display.getInstance().fireMagnifyGesture(x, y, scale); + // Marshalled: the recognizer fires on UIKit's main thread while this hit tests + // the hierarchy and runs application pinch handlers, which would race the + // event dispatch thread's painting and layout. The pointer path is safe + // without this only because it enqueues rather than dispatching in place. + com.codename1.ui.Display.getInstance().callSerially(new Runnable() { + public void run() { + com.codename1.ui.Display.getInstance().fireMagnifyGesture(x, y, scale); + } + }); } /// Invoked from the native rotation gesture recognizer (Mac Catalyst trackpad rotate / iOS two /// finger rotate). Routes to the cross-platform rotation gesture dispatch. - public static void rotationGestureCallback(float radians, int x, int y) { + public static void rotationGestureCallback(final float radians, final int x, final int y) { if (dropEvents || instance == null) { return; } - com.codename1.ui.Display.getInstance().fireRotationGesture(x, y, radians); + // Marshalled for the same reason as pinchMagnifyCallback. + com.codename1.ui.Display.getInstance().callSerially(new Runnable() { + public void run() { + com.codename1.ui.Display.getInstance().fireRotationGesture(x, y, radians); + } + }); } protected void pointerPressed(final int[] x, final int[] y) { @@ -9345,6 +9643,9 @@ protected Dimension calcPreferredSize() { protected void onPositionSizeChange() { if(nativePeer != 0) { nativeInstance.updatePeerPositionSize(nativePeer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); + // Re-applies the frame in the window's own scale as well as re-homing + // it, since updatePeerPositionSize converts with the global one. + attachToOwningWindow(); } } @@ -9352,6 +9653,26 @@ protected void initComponent() { super.initComponent(); if(nativePeer != 0) { nativeInstance.peerInitialized(nativePeer, getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); + attachToOwningWindow(); + } + } + + /// Moves this peer into the Catalyst window that owns it. + /// + /// peerInitialized attaches every native view to the main controller's view, + /// so without this a browser, camera or video view inside a Window appeared + /// over the main surface and took its input there. A no-op for a component on + /// the main surface, and on every platform that is not Catalyst. + void attachToOwningWindow() { + int slot = MacWindowManager.slotForComponent(this); + if (slot >= 0) { + // The result is false when the window's scene has not been granted + // yet, which is ordinary rather than an error: a peer can be created + // in the same event dispatch turn as show(). The native side queues + // it in that case and scene adoption attaches it, so there is nothing + // to retry from here. + nativeInstance.macWindowAttachPeer(nativePeer, slot, + getAbsoluteX(), getAbsoluteY(), getWidth(), getHeight()); } } @@ -9368,10 +9689,17 @@ protected void setLightweightMode(boolean l) { if(lightweightMode != l) { lightweightMode = l; nativeInstance.peerSetVisible(nativePeer, !lightweightMode); + if (!lightweightMode) { + // peerSetVisible re-adds to the main view, so the peer has to + // be put back in its own window each time it becomes heavy. + attachToOwningWindow(); + } // fix for https://groups.google.com/d/msg/codenameone-discussions/LKxy16PhYEY/bvusdq-ICwAJ - Form f = getComponentForm(); + // Through the top level: getComponentForm() is null inside a Window, + // so the repaint this fix exists for was skipped there. + com.codename1.ui.TopLevelContainer f = getTopLevelContainer(); if(f != null) { - f.repaint(); + f.asContainer().repaint(); } } } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 7bdec382b03..df8afba07d0 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -209,6 +209,89 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre // Toolbar acts as the window title bar, and make the window movable by its background so the // toolbar drags it. Passing false restores the standard titled window. A no-op on iOS/iPadOS. native void setMacWindowUndecorated(boolean undecorated); + + // ---- Mac Catalyst desktop windows (CN1MacWindows.m) --------------------- + // + // A window is addressed by the slot returned from macWindowCreate. The + // windowId passed in is the framework's own id, stored natively and echoed + // back on every callback so events route without a lookup. Every one of these + // is a no-op on iOS proper, where the implementation is compiled out. + + native int macWindowCreate(int windowId, String title, int x, int y, int width, int height, + boolean decorated, boolean resizable, boolean positionSet); + + native void macWindowDestroy(int slot); + + native void macWindowShow(int slot, boolean visible); + + /** The token of the scene request currently outstanding for this window, or 0. */ + native int macWindowRequestSeq(int slot); + + native void macWindowSetTitle(int slot, String title); + + native void macWindowSetBounds(int slot, int x, int y, int width, int height); + + native void macWindowGetBounds(int slot, int[] out); + + native boolean macMainWindowGetBounds(int[] out); + + native int macWindowGetWidth(int slot); + + native int macWindowGetHeight(int slot); + + native void macWindowSetState(int slot, int state); + + /** Requests a scene again after one was destroyed without the app getting a say. */ + /// Applies a resizability change to a window that may already have a scene. + /// Records which window is being edited, so the native editor lands in its view. + native void macWindowSetEditingSlot(int slot); + + native void macWindowSetResizable(int slot, boolean resizable); + + /// Applies a decoration change to a window that may already have a scene. + native void macWindowSetDecorated(int slot, boolean decorated); + + /// Records a minimum size and applies it to an existing scene. + native void macWindowSetMinimumSize(int slot, int width, int height); + + native boolean macWindowReopen(int slot); + + /** Enables or disables touch input, used while a modal window blocks this one. */ + native void macWindowSetInputEnabled(int slot, boolean enabled); + + native void macMainWindowSetInputEnabled(boolean enabled); + + /** Starts reporting display attach/remove/mode changes; idempotent. */ + native void macWindowWatchScreens(); + + /** + * Presents one rendered frame. The pixels are the window's own raster; the + * native side wraps them in a CGImage and assigns it to the view's layer. + */ + native void macWindowPresent(int slot, int[] argb, int width, int height); + + /** + * True when the app's Info.plist actually enables multiple scenes. Without it + * the system refuses to activate a second scene, so this is what decides + * whether the windowing API reports itself supported. + */ + native boolean macMultiWindowSupported(); + + native int macMonitorCount(); + + native int macPrimaryMonitor(); + + native void macMonitorBounds(int monitor, boolean workArea, int[] out); + + native int macMonitorDpi(int monitor); + + native int macMonitorScaleTimes100(int monitor); + + native int macMonitorForWindow(int slot); + + /// The monitor the application's own Catalyst scene is on. The main window has + /// no slot, so `#macMonitorForWindow(int)` cannot answer for it. + native int macMonitorForMainWindow(); native void setImageName(long nativeImage, String name); @@ -306,6 +389,10 @@ native void fillGradient(int kind, int stopCount, float[] positions, float[] pre native void peerInitialized(long peer, int x, int y, int w, int h); + /// Attaches a peer to the Catalyst window that owns it. Returns false when the + /// window has no content view yet, so the caller keeps the peer where it is. + native boolean macWindowAttachPeer(long peer, int slot, int x, int y, int w, int h); + native void peerDeinitialized(long peer); native void peerSetVisible(long peer, boolean v); native long createPeerImage(long peer, int[] wh); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/MacWindowManager.java b/Ports/iOSPort/src/com/codename1/impl/ios/MacWindowManager.java new file mode 100644 index 00000000000..148e51e94f2 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/MacWindowManager.java @@ -0,0 +1,653 @@ +/* + * 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.ios; + +import com.codename1.impl.WindowManager; +import com.codename1.ui.Desktop; +import com.codename1.ui.Display; +import com.codename1.ui.Image; +import com.codename1.ui.Window; + +/** + * The Mac Catalyst implementation of the desktop windowing contract. + * + *

Each Codename One window becomes a {@code UIWindowScene}. Unlike the other + * desktop ports, the window's content is rendered into a mutable image and the + * finished raster is handed to the scene's view, rather than the window owning a + * second Metal surface. That is deliberate: the render path caches its device, + * pipeline state and glyph atlas against the single rendering view, and making + * those per-scene is a large refactor of the hottest code in the product, without + * ARC. The scene still owns a real UIKit view hierarchy, so native peers and + * native text editing work normally inside a window.

+ * + *

Multiple scenes have to be enabled in Info.plist for any of this to work. The + * builder emits that key for the Catalyst slice and nowhere else, and + * {@code IOSImplementation.getWindowManager()} reads it back out of the bundle, so + * this manager is never offered to a build that could not actually open a + * window.

+ * + *

Operations Catalyst cannot express. These stay on the SPI's no-op + * defaults, and are listed here rather than left to be discovered one at a time: + * {@code setAlwaysOnTop}, {@code setUtilityWindow}, {@code minimize}, + * {@code restore} and {@code toggleMaximize} have no public UIKit equivalent for a + * {@code UIWindowScene} -- AppKit owns that behaviour and Catalyst does not expose + * it. {@code setModal} is a no-op because modality is decided by the framework and + * enforced through {@code setInputEnabled}, which this port does implement, so + * there is no native flag to set. {@code setPaintDirtyRegionClip} is an + * optimisation the Java SE port also leaves out.

+ * + *

{@code setDecorated} is partial by necessity: Catalyst cannot remove the + * window frame, so it hides the title bar's title and toolbar, which is the part an + * application supplying its own chrome needs.

+ * + * @author Shai Almog + */ +public class MacWindowManager extends WindowManager { + + private final IOSImplementation impl; + + MacWindowManager(IOSImplementation impl) { + this.impl = impl; + // UIScreen notifications are the only way a Catalyst app learns that a + // display was attached, removed or changed mode, so a monitor listener + // depends entirely on this being installed. + IOSImplementation.nativeInstance.macWindowWatchScreens(); + } + + /** One native window: its slot, and the raster it is rendered through. */ + static final class Peer { + final int slot; + final int windowId; + Object mutableImage; + int rasterWidth; + int rasterHeight; + /// Reused frame transfer buffer. A fresh int[] per flush allocated the whole + /// window's raster every repaint -- about 33MB for a 4K window, so roughly + /// 2GB/s at 60fps -- which is enough garbage to stall an animating window or + /// have the system kill the app. Reallocated only when the raster resizes. + int[] frameBuffer; + /// The peer of the window that owns this one, or null. + /// + /// Catalyst scenes have no owner relation of their own, so the promise that an + /// owned window follows its owner -- which the other three desktop ports get + /// from the platform -- has to be kept here instead. + Object owner; + /// True while this window is hidden only because its owner is. + boolean hiddenByOwner; + boolean visible; + /// The port's token for the scene request currently outstanding for this + /// window. An activation failure carries the token of the request it belongs + /// to, so one that a later request has already replaced is discarded instead + /// of taking down the window that request is bringing up. + int requestSeq; + + Peer(int slot, int windowId) { + this.slot = slot; + this.windowId = windowId; + } + } + + /// Every live window, so an owner can find the windows it owns. + private static final java.util.List peers = new java.util.ArrayList(); + + /// Stands in for the application's main scene, which has no `Peer` of its own. + /// A window owned by the main `Form` records this, so it still has an owner the + /// cascade below can start from. + private static final Object MAIN_WINDOW = new Object(); + + + private static Peer peer(Object p) { + return p instanceof Peer ? (Peer) p : null; + } + + /// Slot of the window hosting the given component, or -1 for the application's + /// main scene. The native editor needs it to land in the right window's view. + static int slotForComponent(com.codename1.ui.Component cmp) { + Object peer = com.codename1.ui.Desktop.getInstance().getWindowPeerForComponent(cmp); + return peer == null ? -1 : slot(peer); + } + + private static int slot(Object p) { + Peer w = peer(p); + return w == null ? -1 : w.slot; + } + + // ---- lifecycle ----------------------------------------------------------- + + @Override + public Object createWindow(int windowId, String title, int x, int y, int width, int height, + boolean decorated, boolean resizable, Object parentPeer, boolean positionSet, + boolean ownedByMainWindow) { + // Catalyst scenes have no native owner relation, so ownership is tracked here + // and the cascade is emulated. A window owned by the main Form has a null + // parentPeer -- the main scene has no Peer -- so ownedByMainWindow is the only + // signal that it is owned at all; recording MAIN_WINDOW for it is what lets + // the main scene take its owned windows down with it. positionSet matters too: + // inferring it from the coordinates makes a window explicitly placed at 0,0 + // look unplaced, and the window server then puts it wherever it likes. + int s = IOSImplementation.nativeInstance.macWindowCreate(windowId, + title == null ? "" : title, x, y, width, height, decorated, resizable, + positionSet); + if (s < 0) { + return null; + } + Peer created = new Peer(s, windowId); + // Creation asks for the first scene, so the window already has a request + // outstanding before anything calls show(). + created.requestSeq = IOSImplementation.nativeInstance.macWindowRequestSeq(s); + created.owner = ownedByMainWindow ? MAIN_WINDOW : parentPeer; + synchronized (peers) { + peers.add(created); + } + return created; + } + + @Override + public void show(Object p) { + Peer w = peer(p); + if (w == null) { + return; + } + // An owned window cannot be on screen while its owner is not. Window.show() + // restores a non-showing owner through its own lifecycle before reaching any + // port, which is the only thing that can make the owner's component hierarchy + // visible again and reacquire its modality, so by here the owner is already + // up and there is deliberately no second mechanism doing it again. + w.visible = true; + w.hiddenByOwner = false; + IOSImplementation.nativeInstance.macWindowShow(w.slot, true); + // Read back rather than counted here: the port bumps its own token when the + // call above actually asks for a scene, and only it knows whether it did. + w.requestSeq = IOSImplementation.nativeInstance.macWindowRequestSeq(w.slot); + // Only the ones this owner took down. A child hidden by the application stays + // hidden, exactly as AWT and GTK behave when an owner is shown again. + cascadeFrom(w, true); + } + + /// The live windows owned by the given peer. + private static java.util.List ownedBy(Object ownerPeer) { + java.util.List out = new java.util.ArrayList(); + synchronized (peers) { + for (Peer each : peers) { + if (each.owner == ownerPeer) { //NOPMD CompareObjectsWithEquals + out.add(each); + } + } + } + return out; + } + + /// Records a window's new visibility and takes the windows it owns with it, which + /// is how a Catalyst window follows its owner being minimized by the user -- there + /// is no scene-level owner relation to do it for us. + /// + /// Runs on the EDT. The caller marshals it there so a native slot cannot be freed + /// by a concurrent dispose and handed to a new window between the lookup here and + /// the native call. + /// + /// #### Parameters + /// + /// - `windowId`: the window whose visibility changed, 0 for the main window + /// + /// - `shown`: true when it became visible, false when it went away + static void windowVisibilityChanged(int windowId, boolean shown) { + // Window id 0 is the main window, which has no Peer; windows owned by the main + // Form carry the MAIN_WINDOW sentinel instead. + Object owner = windowId == 0 ? MAIN_WINDOW : peerForWindowId(windowId); + if (owner == null) { + return; + } + if (owner instanceof Peer) { + // Record what the platform just did to this window before cascading from + // it. Without this, a window the user minimized on its own still looks + // visible here, so a later owner hide marks it hidden-by-owner and the + // owner's restore brings back a window the user had put away. The flag is + // cleared either way: this change came from the platform, not from an + // owner, so no owner may undo it. + Peer self = (Peer) owner; + self.visible = shown; + self.hiddenByOwner = false; + } + cascadeFrom(owner, shown); + } + + /// Clears a window's visibility bookkeeping after the platform refused it a scene. + /// + /// `show()` had already recorded the window as visible. Left that way, hiding and + /// restoring its owner makes the cascade treat it as a window the owner took down + /// and map it again, reporting it shown without going through `Window#show()` -- + /// so it would come back unpainted, taking no input and no longer modal. + static boolean activationFailed(int windowId, int requestSeq) { + Peer w = peerForWindowId(windowId); + if (w == null || w.requestSeq != requestSeq) { + // The failure belongs to a request a later one has already replaced. That + // request owns the window's state now, and applying this failure would take + // down a window it may be about to bring up. The token comes from the port + // with the failure, so this compares the request that failed rather than + // whatever is outstanding at the moment the notification is handled. + return false; + } + w.visible = false; + w.hiddenByOwner = false; + // The windows this one owns go down with it. Catalyst has no scene-level owner + // relation, so this cascade is the only thing keeping an owned window with its + // owner -- and an owner whose scene was refused while a child's succeeded would + // otherwise leave the child on screen, and painting, with nothing behind it. + // Runs on the EDT, like every other cascade, because the caller queues it there. + cascadeFrom(w, false, true); + return true; + } + + private static Peer peerForWindowId(int windowId) { + synchronized (peers) { + for (Peer each : peers) { + if (each.windowId == windowId) { + return each; + } + } + } + return null; + } + + /// Applies an owner's visibility to every window it owns, to any depth, and tells + /// the framework about each window that actually changed. + /// + /// Ownership is only ever assigned when a window is created, so the graph is a + /// tree and this cannot cycle. + private static void cascadeFrom(Object owner, boolean shown) { + cascadeFrom(owner, shown, false); + } + + /// As above, but `activationFailed` marks the case where the owner never appeared + /// at all rather than being taken down. + /// + /// The distinction matters to the framework: the hide notification is the minimize + /// path, which keeps a modal window's blocker on purpose because a minimized window + /// is still open. A modal child of an owner whose scene was refused would then go on + /// blocking input to every other window, with both it and its owner off screen and + /// `showModal()` parked forever. + private static void cascadeFrom(Object owner, boolean shown, boolean activationFailed) { + for (Peer child : ownedBy(owner)) { + boolean changed = false; + if (shown) { + if (child.hiddenByOwner) { + child.hiddenByOwner = false; + child.visible = true; + IOSImplementation.nativeInstance.macWindowShow(child.slot, true); + changed = true; + } + } else if (child.visible) { + // Hidden-by-owner only when the owner was taken down. A child whose + // owner never appeared went through Window.activationFailed(), which + // made its component hierarchy invisible and released its modality -- + // and the only thing an owner's restore can send is showNotify(), which + // undoes neither. Marking it hidden-by-owner would therefore have the + // owner's next show() map a window that paints nothing and hold a modal + // registration it no longer has. A window that never appeared comes back + // through its own show(), which runs the whole lifecycle. + child.hiddenByOwner = !activationFailed; + child.visible = false; + IOSImplementation.nativeInstance.macWindowShow(child.slot, false); + changed = true; + } + if (changed) { + // Setting the native hidden flag alone leaves the framework believing + // the window is still up: it keeps painting it and fires no lifecycle + // event. macWindowShow reports nothing back, so report it here. + if (shown) { + com.codename1.ui.Desktop.getInstance().windowShowNotify(child.windowId); + } else if (activationFailed) { + com.codename1.ui.Desktop.getInstance().windowActivationFailed(child.windowId); + } else { + com.codename1.ui.Desktop.getInstance().windowHideNotify(child.windowId); + } + } + // Going down, a descendant has to follow even when its own parent was + // already hidden by the application. Coming back up, only a child that + // actually reappeared may restore the windows it owns. + if (!shown || child.visible) { + cascadeFrom(child, shown, activationFailed); + } + } + } + + @Override + public void hide(Object p) { + Peer w = peer(p); + if (w == null) { + return; + } + w.visible = false; + // An explicit hide takes the window's visibility over from any owner. Leaving + // this set would let the owner's restore show a window the application had + // deliberately hidden, and report it restored while its component hierarchy is + // still invisible. + w.hiddenByOwner = false; + IOSImplementation.nativeInstance.macWindowShow(w.slot, false); + // An owned window cannot stay on screen without its owner. Recorded as + // hidden-by-owner so showing the owner again brings back exactly the children + // it took down, and not ones the application hid itself. + cascadeFrom(w, false); + } + + @Override + public void dispose(Object p) { + Peer w = peer(p); + if (w != null) { + synchronized (peers) { + peers.remove(w); + // An owned window outliving its owner would keep a dangling reference + // and could be matched against a later peer at the same address. + for (Peer each : peers) { + if (each.owner == w) { //NOPMD CompareObjectsWithEquals + each.owner = null; + } + } + } + } + if (w == null) { + return; + } + w.mutableImage = null; + // Released with the raster it belongs to; a disposed window has no frames left + // to present, and on a large display this is a few tens of megabytes. + w.frameBuffer = null; + IOSImplementation.nativeInstance.macWindowDestroy(w.slot); + } + + // ---- attributes ------------------------------------------------------------ + + @Override + public void setTitle(Object p, String title) { + int s = slot(p); + if (s >= 0) { + IOSImplementation.nativeInstance.macWindowSetTitle(s, title == null ? "" : title); + } + } + + @Override + public void setBounds(Object p, int x, int y, int width, int height) { + int s = slot(p); + if (s >= 0) { + IOSImplementation.nativeInstance.macWindowSetBounds(s, x, y, width, height); + } + } + + @Override + public int[] getBounds(Object p, int[] out) { + int s = slot(p); + if (s >= 0) { + IOSImplementation.nativeInstance.macWindowGetBounds(s, out); + } + return out; + } + + @Override + public int getWidth(Object p) { + int s = slot(p); + return s < 0 ? 0 : IOSImplementation.nativeInstance.macWindowGetWidth(s); + } + + @Override + public int getHeight(Object p) { + int s = slot(p); + return s < 0 ? 0 : IOSImplementation.nativeInstance.macWindowGetHeight(s); + } + + @Override + public void requestFocus(Object p) { + int s = slot(p); + if (s >= 0) { + // 3 == present, the one window-state operation Catalyst exposes to a + // UIKit app; minimize and zoom belong to the window manager there. + IOSImplementation.nativeInstance.macWindowSetState(s, 3); + } + } + + @Override + public void setIcon(Object p, Image icon) { + // A Mac window has no per-window icon. + } + + @Override + public boolean reopen(Object p) { + int s = slot(p); + return s >= 0 && IOSImplementation.nativeInstance.macWindowReopen(s); + } + + /// {@inheritDoc} + /// + /// Implemented rather than left as the inherited no-op. The framework's event + /// filter drops packed input before it reaches a component, but a UIKit peer -- + /// a native editor, a web view, a media control -- is handed its touches + /// directly by the window server and never passes through that filter, so the + /// main window's peers stayed interactive under an application modal. + /// {@inheritDoc} + /// + /// A Form lives in the application's own window, so centring a window over a + /// Form means centring over that window. Left unimplemented the framework fell + /// back to the monitor work area, which is a different place whenever the main + /// window has been moved, resized or simply does not fill the screen. + @Override + public int[] getMainWindowBounds(int[] out) { + if (out == null || out.length < 4) { + return null; + } + return IOSImplementation.nativeInstance.macMainWindowGetBounds(out) ? out : null; + } + + @Override + public void setMainWindowInputEnabled(boolean enabled) { + IOSImplementation.nativeInstance.macMainWindowSetInputEnabled(enabled); + } + + @Override + public void setInputEnabled(Object p, boolean enabled) { + int s = slot(p); + if (s >= 0) { + // Covers input inside the window. The scene's title bar belongs to + // AppKit rather than to the application, so its close button stays live + // even while the window is blocked -- Catalyst offers no way to disable + // it, and a close there is reported after the fact as a disposal. + IOSImplementation.nativeInstance.macWindowSetInputEnabled(s, enabled); + } + } + + // ---- rendering ------------------------------------------------------------------ + + @Override + public Object getNativeGraphics(Object p) { + Peer w = peer(p); + if (w == null) { + return null; + } + // Sized from the framework's window rather than the scene's drawable. The two + // agree once the scene has settled, but the scene arrives and resizes + // asynchronously, so a raster allocated from the drawable can be left holding + // an intermediate size that nothing later reconciles -- the framework paints + // into it at its own size and the capture then disagrees with the window. + // The window is what was laid out and painted, so it is what the raster has + // to match; the drawable is only the fallback until a window exists. + Window window = Desktop.getInstance().windowById(w.windowId); + int width = Math.max(1, window != null ? window.getWidth() : getWidth(p)); + int height = Math.max(1, window != null ? window.getHeight() : getHeight(p)); + if (w.mutableImage == null || w.rasterWidth != width || w.rasterHeight != height) { + w.mutableImage = impl.createMutableImage(width, height, 0xff000000); + w.rasterWidth = width; + w.rasterHeight = height; + } + return impl.getNativeGraphics(w.mutableImage); + } + + @Override + public void flushGraphics(Object p, int x, int y, int width, int height) { + Peer w = peer(p); + if (w == null || w.mutableImage == null) { + return; + } + // Read the finished frame back and hand it to the scene's view. The whole + // raster is presented rather than the dirty rect because the view holds one + // image; the dirty region still bounds what was actually redrawn into it. + int needed = w.rasterWidth * w.rasterHeight; + if (w.frameBuffer == null || w.frameBuffer.length < needed) { + w.frameBuffer = new int[needed]; + } + int[] argb = w.frameBuffer; + impl.getRGB(w.mutableImage, argb, 0, 0, 0, w.rasterWidth, w.rasterHeight); + IOSImplementation.nativeInstance.macWindowPresent(w.slot, argb, + w.rasterWidth, w.rasterHeight); + } + + @Override + public Object capture(Object p) { + // The window's content already lives in a mutable image -- that is how it is + // rendered on this platform -- but that raster is the live one the next frame + // paints into, so it cannot be handed out directly. Returning it made a + // retained capture change under the caller as the window repainted, and let + // anyone who asked it for a Graphics paint straight into what the window + // presents. Every other port returns an independent readback and + // Window.capture() is documented as the contents at the moment it is called. + Peer w = peer(p); + if (w == null || w.mutableImage == null) { + return null; + } + int width = w.rasterWidth; + int height = w.rasterHeight; + if (width <= 0 || height <= 0) { + return null; + } + // Read the pixels out and build a separate image from them. Immutable on + // purpose: a snapshot has no writable graphics, so the previous failure mode + // cannot come back through the copy either. + Image live = Image.createImage(w.mutableImage); + return Image.createImage(live.getRGB(), width, height).getImage(); + } + + // ---- monitors ---------------------------------------------------------------------- + + @Override + public int getMonitorCount() { + return Math.max(1, IOSImplementation.nativeInstance.macMonitorCount()); + } + + @Override + public int[] getMonitorBounds(int monitor, int[] out) { + IOSImplementation.nativeInstance.macMonitorBounds(monitor, false, out); + return out; + } + + @Override + public int[] getMonitorWorkArea(int monitor, int[] out) { + IOSImplementation.nativeInstance.macMonitorBounds(monitor, true, out); + return out; + } + + @Override + public int getMonitorDensity(int monitor) { + int dpi = getMonitorDotsPerInch(monitor); + if (dpi >= 280) { + return Display.DENSITY_VERY_HIGH; + } + if (dpi >= 200) { + return Display.DENSITY_HIGH; + } + if (dpi >= 140) { + return Display.DENSITY_MEDIUM; + } + return Display.DENSITY_LOW; + } + + @Override + public double getMonitorScale(int monitor) { + // Carried across the bridge as an int, so scaled by a hundred. + return IOSImplementation.nativeInstance.macMonitorScaleTimes100(monitor) / 100.0; + } + + @Override + public int getMonitorDotsPerInch(int monitor) { + int dpi = IOSImplementation.nativeInstance.macMonitorDpi(monitor); + return dpi > 0 ? dpi : 96; + } + + @Override + public String getMonitorName(int monitor) { + return "display-" + monitor; + } + + @Override + public int getPrimaryMonitor() { + return Math.max(0, IOSImplementation.nativeInstance.macPrimaryMonitor()); + } + + @Override + public int getMonitorForWindow(Object p) { + int s = slot(p); + if (s < 0) { + return getPrimaryMonitor(); + } + return Math.max(0, IOSImplementation.nativeInstance.macMonitorForWindow(s)); + } + + @Override + public void setResizable(Object p, boolean resizable) { + // Without this the SPI's no-op ran, so a window shown and then made + // non-resizable stayed draggable while the framework reported it fixed. + int s = slot(p); + if (s >= 0) { + IOSImplementation.nativeInstance.macWindowSetResizable(s, resizable); + } + } + + @Override + public void setDecorated(Object p, boolean decorated) { + // Catalyst cannot remove the window frame the way an undecorated desktop + // window does, but it can hide the title bar's title and toolbar, which is + // what an application supplying its own chrome needs. Without this the + // framework reported the window as undecorated while it kept a standard + // title bar -- and could show two sets of chrome at once. + int s = slot(p); + if (s >= 0) { + IOSImplementation.nativeInstance.macWindowSetDecorated(s, decorated); + } + } + + @Override + public void setMinimumSize(Object p, int width, int height) { + // Window.sizeChangedInternal deliberately does not clamp, so without this + // the constraint existed only in the getter and the user could resize below + // it. + int s = slot(p); + if (s >= 0) { + IOSImplementation.nativeInstance.macWindowSetMinimumSize(s, width, height); + } + } + + @Override + public int getMonitorForMainWindow() { + // The default answers the primary monitor, which is wrong here: the + // application's own scene moves between displays like any other window, so a + // Form positioned against reported the wrong work area, scale and density + // once it had been dragged to an external screen. + return Math.max(0, IOSImplementation.nativeInstance.macMonitorForMainWindow()); + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava001Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava001Snippet.java new file mode 100644 index 00000000000..d7eef821664 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava001Snippet.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava001Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::desktop-windows-java-001[] + if (Desktop.isSupported()) { + Window inspector = new Window("Inspector", new BorderLayout()); + inspector.add(BorderLayout.CENTER, new Label("Hello from a second window")); + inspector.setWindowSize(420, 320); + inspector.centerOnDesktop(); + inspector.show(); + } + // end::desktop-windows-java-001[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava002Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava002Snippet.java new file mode 100644 index 00000000000..e2d0d565c0c --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava002Snippet.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava002Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::desktop-windows-java-002[] + Window w = new Window("Tools"); // throws on a phone + w.show(); + // end::desktop-windows-java-002[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava003Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava003Snippet.java new file mode 100644 index 00000000000..2d4fd8a10e1 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava003Snippet.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava003Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::desktop-windows-java-003[] + TopLevelContainer top = component.getTopLevelContainer(); + top.getContentPane().add(new Label("works in either")); + top.registerAnimated(component); + Component focused = top.getFocused(); + // end::desktop-windows-java-003[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava004Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava004Snippet.java new file mode 100644 index 00000000000..f8d24141ac6 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava004Snippet.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava004Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void snippet() throws Exception { + // tag::desktop-windows-java-004[] + Window window = new Window("Inspector", new BorderLayout()); + Label l = new Label("hi"); + window.add(BorderLayout.CENTER, l); + + l.getTopLevelContainer(); // the Window + l.getComponentForm(); // null -- a Window is not a Form + // end::desktop-windows-java-004[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava005Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava005Snippet.java new file mode 100644 index 00000000000..6f9eb5a7e4e --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava005Snippet.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava005Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + boolean hasUnsavedWork() { return false; } + void snippet() throws Exception { + // tag::desktop-windows-java-005[] + boolean unsavedChanges = hasUnsavedWork(); + Window w = new Window("Preferences", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("Preferences")); + w.setWindowSize(500, 400); + w.setCloseOperation(Window.DISPOSE_ON_CLOSE); + + w.addCloseListener(evt -> { + if (unsavedChanges) { + evt.consume(); // veto the close, then prompt the user to save + } + }); + + w.show(); + // end::desktop-windows-java-005[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava006Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava006Snippet.java new file mode 100644 index 00000000000..a61c751e08d --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava006Snippet.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava006Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + Window w = new Window("Tools"); + void snippet() throws Exception { + // tag::desktop-windows-java-006[] + w.setResizable(false); + w.setDecorated(false); // no native title bar; draw your own with a Toolbar + w.setAlwaysOnTop(true); // a floating palette + w.setUtilityWindow(true); // keep it out of the task bar where the platform allows + w.setMinimumWindowSize(new Dimension(320, 240)); + // end::desktop-windows-java-006[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava007Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava007Snippet.java new file mode 100644 index 00000000000..d035768f2ee --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava007Snippet.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava007Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + Window w = new Window("Tools"); + void snippet() throws Exception { + // tag::desktop-windows-java-007[] + w.setUIID("PaletteWindow"); + w.getContentPane().setUIID("PaletteWindowContentPane"); + // end::desktop-windows-java-007[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava008Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava008Snippet.java new file mode 100644 index 00000000000..e007c56eaad --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava008Snippet.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava008Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + Window mainWindow = new Window("Main"); + void snippet() throws Exception { + // tag::desktop-windows-java-008[] + Window dialog = new Window("Confirm", new BorderLayout()); + dialog.add(BorderLayout.CENTER, new Label("Really delete everything?")); + dialog.setOwnerWindow(mainWindow); + dialog.setModalityType(Window.MODALITY_WINDOW); + dialog.showModal(); // blocks here until the window is disposed + // end::desktop-windows-java-008[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava009Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava009Snippet.java new file mode 100644 index 00000000000..41a83588a1b --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava009Snippet.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava009Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + int pointerX; + int pointerY; + void snippet() throws Exception { + // tag::desktop-windows-java-009[] + for (Monitor m : Desktop.getInstance().getMonitors()) { + System.out.println(m.getName() + + " bounds=" + m.getBounds() + + " workArea=" + m.getWorkArea() + + " scale=" + m.getScale() + + " dpi=" + m.getDotsPerInch() + + (m.isPrimary() ? " (primary)" : "")); + } + + Monitor under = Desktop.getInstance().getMonitorAt(pointerX, pointerY); + Rectangle whole = Desktop.getInstance().getDesktopBounds(); // union of every monitor + // end::desktop-windows-java-009[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava010Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava010Snippet.java new file mode 100644 index 00000000000..36cddeab50c --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava010Snippet.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava010Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + Window w = new Window("Tools"); + void snippet() throws Exception { + // tag::desktop-windows-java-010[] + w.getMonitor(); // the Monitor this window currently sits on + w.getScale(); // that monitor's backing scale, e.g. 1.0 or 2.0 + w.getDensity(); // that monitor's density bucket + // end::desktop-windows-java-010[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava011Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava011Snippet.java new file mode 100644 index 00000000000..365c5288946 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava011Snippet.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava011Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + void refreshWindowPlacement() { } + void snippet() throws Exception { + // tag::desktop-windows-java-011[] + Desktop.getInstance().addMonitorListener(evt -> { + // a monitor was added or removed, or one changed resolution + refreshWindowPlacement(); + }); + // end::desktop-windows-java-011[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava012Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava012Snippet.java new file mode 100644 index 00000000000..27f0e981e27 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava012Snippet.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava012Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + Window w = new Window("Tools"); + void rememberWindowGeometry(Object src) { } + void auditWindowEvent(ActionEvent evt) { } + void snippet() throws Exception { + // tag::desktop-windows-java-012[] + w.addWindowListener(evt -> { + WindowEvent we = (WindowEvent) evt; + if (we.getType() == WindowEvent.Type.Resized) { + rememberWindowGeometry(we.getSource()); + } + }); + + // Every window, from one place + Desktop.getInstance().addWindowListener(evt -> auditWindowEvent(evt)); + // end::desktop-windows-java-012[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava013Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava013Snippet.java new file mode 100644 index 00000000000..cc379dc9665 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava013Snippet.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava013Snippet { + + Object context; + Object url; + Object value; + Object body; + Object event; + String apiKey = "test-key"; + String myHttpsURL = "https://example.com"; + java.util.List validKeysList = new java.util.ArrayList<>(); + Image myImage; + Graphics graphics; + Graphics g; + GraphicsDevice device; + Form form; + Form hi; + Container cnt; + Container myForm; + Component component; + Button button; + MultiButton myMultiButton; + Label label; + BrowserComponent browserComponent; + Resources theme; + Window w = new Window("Tools"); + static class MyOverlay { } + Component buildOverlayContent() { return new Label("overlay"); } + void snippet() throws Exception { + // tag::desktop-windows-java-013[] + Container overlay = w.getFormLayeredPane(MyOverlay.class, true); + overlay.setLayout(new LayeredLayout()); + overlay.add(buildOverlayContent()); + w.revalidate(); + // end::desktop-windows-java-013[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava014Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava014Snippet.java new file mode 100644 index 00000000000..4fd13496b83 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava014Snippet.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava014Snippet { + + InteractionDialog dialog = new InteractionDialog("Details"); + Button buttonInsideTheWindow = new Button("Open"); + void snippet() throws Exception { + // tag::desktop-windows-java-014[] + dialog.showPopupDialog(buttonInsideTheWindow); + // end::desktop-windows-java-014[] + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava015Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava015Snippet.java new file mode 100644 index 00000000000..5c02db33a31 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava015Snippet.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets.generated; + +import com.codename1.gpu.*; +import com.codename1.ui.*; +import com.codename1.ui.animations.*; +import com.codename1.ui.events.*; +import com.codename1.ui.geom.*; +import com.codename1.ui.layouts.*; +import com.codename1.ui.list.*; +import com.codename1.ui.plaf.*; +import com.codename1.ui.util.*; +import com.codename1.components.*; +import com.codename1.charts.models.*; +import com.codename1.charts.renderers.*; +import com.codename1.charts.views.*; +import com.codename1.capture.*; +import com.codename1.io.*; +import com.codename1.l10n.*; +import com.codename1.location.*; +import com.codename1.maps.*; +import com.codename1.media.*; +import com.codename1.messaging.*; +import com.codename1.payment.*; +import com.codename1.processing.*; +import com.codename1.properties.*; +import com.codename1.push.*; +import com.codename1.security.*; +import com.codename1.social.*; +import com.codename1.ui.spinner.*; +import java.io.*; +import java.util.*; + + +class DesktopWindowsJava015Snippet { + + InteractionDialog dialog = new InteractionDialog("Details"); + Window window = new Window("Tools"); + int top = 10; + int bottom = 10; + int left = 10; + int right = 10; + void snippet() throws Exception { + // tag::desktop-windows-java-015[] + dialog.setTopLevelHost(window); + dialog.show(top, bottom, left, right); + // end::desktop-windows-java-015[] + } +} diff --git a/docs/developer-guide/Desktop-Windows.asciidoc b/docs/developer-guide/Desktop-Windows.asciidoc new file mode 100644 index 00000000000..ac32cd5e9b3 --- /dev/null +++ b/docs/developer-guide/Desktop-Windows.asciidoc @@ -0,0 +1,432 @@ +== Desktop Windows + +A phone application has one window, because the operating system owns the screen and +the application fills it. A desktop application usually doesn't: an inspector panel, a +preferences window, a second document, a tool palette and a detached console are all +ordinary desktop expectations. + +`com.codename1.ui.Window` is how you open them. A `Window` is a separate native +operating-system window with its own Codename One component hierarchy inside it, its +own focus owner, its own animations and its own repaint region. The application's main +surface is a `Form` and is unaffected by any of this. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava001Snippet.java[tag=desktop-windows-java-001,indent=0] +---- + +=== Where windows exist + +Windows are a desktop feature, and the API says so plainly: + +[cols="2,1,4"] +|=== +|Platform |Windows |Notes + +|Java SE desktop app +|Yes +|The packaged desktop build and the simulator in desktop-skin mode. + +|Java SE simulator with a phone skin +|No +|A skin simulates one device screen; a real window inside that simulation is incoherent. + +|Native Windows +|Yes +| + +|Native Linux +|Yes +| + +|macOS (Mac Catalyst) +|Yes +|See <> for how a window is rendered there. + +|iOS, Android, JavaScript +|No +|These platforms have no windowing system. +|=== + +Always guard with `Desktop.isSupported()` (or the shorthand `CN.isMultiWindowSupported()`). +Constructing a `Window` where windows are unsupported throws +`UnsupportedOperationException` immediately: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava002Snippet.java[tag=desktop-windows-java-002,indent=0] +---- + +There's no fallback to showing a `Form` instead. A pretend window produces layout and +lifecycle bugs that are far harder to find than an exception +on the line that asked for it. The exception is thrown by the constructor rather than by +`show()`, so a wrong assumption surfaces at the point it was made. + +Everything else degrades cleanly. `Desktop.getWindows()` returns an empty array rather +than null, `Desktop.getFocusedWindow()` returns null, and `Desktop.getMonitors()` still +reports the main display, so portable code that loops over windows or positions against +a monitor compiles and runs everywhere. + +=== Window and Form + +`Form` and `Window` are siblings. Both extend `Container`, and both implement the new +`TopLevelContainer` interface, which is the contract shared by anything that can sit at +the root of a component hierarchy: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava003Snippet.java[tag=desktop-windows-java-003,indent=0] +---- + +`TopLevelContainer` carries only what genuinely applies to both. It has the +content pane, the layered panes, the title, commands, animation registration, focus, +editing state, the theme manager and the show/size listeners. It does *not* carry +`Form`'s mobile surface - form transitions, the back command, `previousForm`, the +`Toolbar` or the tint that dims a form behind a dialog - because none of those mean +anything for a desktop window, whose title and menus belong to the platform chrome. Members that already exist on `Component` or +`Container` are reached through `asContainer()`. + +==== getComponentForm() returns null inside a Window + +This is the one behavioral sharp edge, and it's worth stating plainly: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava004Snippet.java[tag=desktop-windows-java-004,indent=0] +---- + +`getComponentForm()` means what it says: it names the enclosing `Form`, and inside a +window the honest answer is none. Codename One's own components ask +`getTopLevelContainer()` instead, so the framework works inside a window - apart from +the handful listed under <>, which depend on something +that isn't window-aware rather than on the call itself. A third-party component that +calls `getComponentForm()` works inside a window only once it asks +`getTopLevelContainer()` too. + +The failure mode is usually silence rather than an exception, because most of that code +is written as `Form f = getComponentForm(); if (f != null) { ... }`. If a component +behaves as though it's detached when you put it in a window - it doesn't scroll, doesn't +take focus, or doesn't animate - that's almost always the cause. + +=== Window lifecycle + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava005Snippet.java[tag=desktop-windows-java-005,indent=0] +---- + +`show()` creates the native window the first time it's called and maps it on screen. +`hide()` hides the window while keeping it alive, so it can be shown again. `dispose()` destroys +the native window and releases everything behind it; calling it twice is harmless. + +`setOwnerWindow()` has to be called before the window is shown. Native ownership is +established when the window is created -- the owner window handle on Windows, the +transient parent on GTK, the owner passed to the dialog on Java SE -- and no platform +lets it be re-pointed once the window exists, so changing it later throws rather than +pretending. + +`setCloseOperation()` decides what the platform's own close control does: + +[cols="1,4"] +|=== +|`DISPOSE_ON_CLOSE` |Destroy the window. The default. +|`HIDE_ON_CLOSE` |Hide it, so it can be shown again later. +|`DO_NOTHING_ON_CLOSE` |Do nothing; the application calls `dispose()` itself. +|=== + +Consuming the event in a close listener vetoes the close regardless of the operation, +which is how a window asks the user to save first. + +That veto isn't available on Mac Catalyst. UIKit hands a scene disconnection over +after the scene is already gone, so there is nothing left to refuse: the window is +disposed, the close listeners don't run, and `HIDE_ON_CLOSE` and +`DO_NOTHING_ON_CLOSE` have no effect for the title-bar control. Don't rely on a save +prompt there. An application that has to intervene should drive the close itself: ask +the user from its own menu item or button, and call `dispose()` once they've answered. + +Note that `dispose()` isn't a close request on any platform. It destroys the window +directly and doesn't run the close listeners, so a listener can't veto it - the +confirmation belongs in the code that decides to call `dispose()`, not in a listener +behind it. Close listeners fire for the platform's own close control, which is exactly +the control Catalyst doesn't let anyone refuse. + +Every open window is disposed when the application shuts down, so a window can't outlive +the event dispatch thread that paints it. + +=== Window chrome and geometry + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava006Snippet.java[tag=desktop-windows-java-006,indent=0] +---- + +Geometry has two coordinate systems and two sets of names, which is deliberate: + +* `getWindowBounds()`, `setWindowBounds()`, `setWindowSize()` and `setWindowLocation()` + are *native* coordinates and include the platform's own chrome. +* `getWidth()` and `getHeight()`, inherited from `Component`, are the Codename One + content size in Codename One pixels. + +A size is a *request*. Every window system is free to adjust it -- a minimum size, a +constraint from the window manager, or a screen too small for what you asked -- so read +`getWidth()` and `getHeight()` back rather than assuming the request was granted, and +listen for `Resized` if it matters. + +`centerOnDesktop()` centers the window on the *work area* of the monitor it's on, so it +doesn't land under the task bar or the dock. `centerOn(other)` centers it over another +top level. `minimize()`, `restore()` and `toggleMaximize()` do what they say, where the +platform allows a program to ask. + +=== Styling + +A window is a top level surface, so it starts out with the styles a theme already +defines for one: `Form` for the window itself, `ContentPane` for its content pane, +`TitleArea` and `Title` for its title. That means every existing theme styles a window +correctly without being updated, which is the point -- a top level with no style entry +paints nothing at all and comes up as an unpainted rectangle. + +To make windows look different from forms, give them a UIID of your own and style that: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava007Snippet.java[tag=desktop-windows-java-007,indent=0] +---- + +=== Modal windows + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava008Snippet.java[tag=desktop-windows-java-008,indent=0] +---- + +`showModal()` parks the calling code until the window is disposed, exactly as a modal +`Dialog` does. It doesn't freeze the application: the event dispatch thread keeps +running, so every other window carries on painting and animating while the modal is up. + +Three modality types are available: + +[cols="1,4"] +|=== +|`MODALITY_NONE` |Blocks nothing. The default. +|`MODALITY_WINDOW` |Blocks input to the window that owns it, and nothing at all when +the window has no owner. +|`MODALITY_APPLICATION` |Blocks input to every other window and to the main form. +|=== + +Codename One decides which windows a modal blocks, so modality behaves identically +everywhere whether the underlying window system implements its own or not. That decision +depends on the whole stack of open modal windows, not just the newest one, which is why +it isn't left to the ports: a window modal opened from inside an application modal +narrows what *it* blocks without lifting anything the outer one still blocks. + +The framework then tells each port which windows to disable natively. That matters +beyond appearances, because a blocked window's own title bar is outside the input filter +- without it, the close button of a window you've blocked still reaches your application. + +=== Monitors and per-monitor DPI + +`Desktop` is the front door for the display side of a windowing system, alongside +`Display` rather than replacing any of it. `Display` answers how big the application's main surface is, which is the only question a +phone has. `Desktop` answers which screens exist and which windows are open. + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava009Snippet.java[tag=desktop-windows-java-009,indent=0] +---- + +Prefer `getWorkArea()` over `getBounds()` when placing or maximizing a window: the work +area excludes the task bar, the dock and any reserved panels, and the bounds don't. + +Note that desktop coordinates span every monitor, so a display placed to the left of or +above the primary one legitimately has a negative origin. + +==== A window reports its own monitor's characteristics + +On a desktop with mixed displays - a high-resolution laptop panel next to a conventional +external monitor is the common case - two windows of the same application can correctly +render at different scales: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava010Snippet.java[tag=desktop-windows-java-010,indent=0] +---- + +These answer for the window's own monitor, not for the global display. When the user +drags a window onto a display with a different scale, Codename One re-reads the scale, +marks the hierarchy's preferred sizes stale and lays it out again. Skipping that step is +what leaves a window blurry or the wrong physical size after a move. + +`Display.convertToPixels()` keeps its existing global meaning, the main window's monitor, +so no existing application changes behavior. + +To react to displays being attached, removed or reconfigured: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava011Snippet.java[tag=desktop-windows-java-011,indent=0] +---- + +=== Window events + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava012Snippet.java[tag=desktop-windows-java-012,indent=0] +---- + +`Display.addWindowListener()` is unchanged and still reports only the application's main +window, so existing code that casts `getSource()` to `Display` keeps working. + +Close listeners and window events answer different questions, and the distinction +matters when a listener does real work: + +* A *close listener* is the user asking to close the window. It runs before anything is + destroyed and consuming it vetoes the close, which is how a window prompts the user to + save first. It fires once per close attempt. +* `WindowEvent.Type.Disposed` reports that the window is already gone. Nothing can veto + it, and it's what to listen for when the work has to happen whether the close came + from the user or from a call to `dispose()`. + +=== Peer components and native text editing + +Both work inside a window. A `BrowserComponent`, a video player, a map or a native text +field placed in a `Window` is attached to *that* window's native view hierarchy, not to +the main window's. + +This is worth knowing about because it was the single most common way an early +multi-window implementation looks correct and isn't: the peer or the text caret appears +on the main window while the content it belongs to is in another one. + +=== Overlays inside a window + +A window has the same layered panes a form does, and that's what an overlay attaches to: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava013Snippet.java[tag=desktop-windows-java-013,indent=0] +---- + +`InteractionDialog` is window-aware. A dialog anchored to a component works with no +extra ceremony, because `showPopupDialog(Component)` takes its host from the component +you hand it - and it has to, since the rectangle it points at is in that component's +coordinate space: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava014Snippet.java[tag=desktop-windows-java-014,indent=0] +---- + +A dialog with no anchor can't infer anything, because it isn't attached to the hierarchy +at the moment `show()` runs. Tell it which top level to appear on: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/DesktopWindowsJava015Snippet.java[tag=desktop-windows-java-015,indent=0] +---- + +Leave the host unset and the dialog uses the current form, which is the right answer for +an application with one window and is what every existing single-window application +already relies on. + +The other ready-made overlays aren't window-aware yet. `Dialog`, `Sheet` and `ToastBar` +resolve their host through `CN.getCurrentForm()` or `getComponentForm()`, so from inside +a window they either attach to the main form or find nothing at all - `getComponentForm()` +returns null in a window by design. Showing one from a window puts it on the main window, +which isn't what the calling code meant. Build the overlay against the window's own +layered pane, as above, until those components gain a top-level-aware attachment path. + +=== Unsupported inside a window + +* `Dialog`, `Sheet` and `ToastBar`, for the reason just described. +* `ComboBox`. Its popup is a `Dialog`, so it inherits that limitation: clicking one + inside a window has no effect at all rather than opening the list. Use a + `Picker`, or a button that opens your own overlay on the window's layered pane. + A `Picker` in a window always uses its lightweight popup, even on a platform with + a native one, because every native picker attaches to the main surface and would + open over the wrong window. +* `FloatingActionButton` with sub-buttons, for the same reason - the submenu is a + `Dialog` too. A plain floating action button with no submenu works normally; + only the submenu form is affected, and releasing one in a window opens nothing + rather than throwing. +* System sheets on Mac Catalyst - sharing (`ShareButton` and `Display.share()`), camera + capture, the photo gallery, the file chooser and the full-screen video player. All + are presented by the application's main scene, so invoking one from a secondary + window shows it over the main window instead; the share sheet also anchors its + popover to the main window's coordinates, so it lands in the wrong place as well. + These work normally from the main window. +* Tooltips. `TooltipManager` schedules against `getComponentForm()` and displays through + an `InteractionDialog` on the current form, so it has the same limitation even though + `InteractionDialog` itself doesn't. +* `Form` transitions into or out of a window. +* `HTMLComponent`. +* Accessibility on secondary windows. + +`Display.getDisplayWidth()` and `getDisplayHeight()` continue to report the *main* +window. Components inside a window should size against their own top level. + +[[window-capture]] +=== Capturing a window + +`Display.screenshot()` can only see the application's main surface, so a second +operating-system window is simply not in it. `Window.capture()` exists for that and +returns an image of the window itself. + +Every desktop port reads the window's own pixels back, so what you get is what the +window is showing -- native peers and editors included. The JavaSE simulator, Mac +Catalyst and native Linux read their rasters directly. Native Windows takes a different +route to the same place: a secondary window renders into a Direct2D `HWND` target, +which Direct2D gives no readback for, so the port asks the window to render itself into +a device context with `PrintWindow`. That's a real readback of the client area rather +than a re-render of the hierarchy. + +`Window.capture()` falls back to re-rendering the component tree if a port returns +nothing. That fallback draws what the window *should* be showing: it never contains a +peer or a native editor, and it can't reveal a disagreement between a window's raster +and its component hierarchy. Because a plausible image of the right size is exactly +what a silent fallback looks like, the Windows port logs when it happens rather than +letting a re-render pass for a readback. + +[[mac-catalyst-windows]] +=== Mac Catalyst + +Windows work on the macOS (Mac Catalyst) target with no configuration. A second +Codename One window is a second `UIWindowScene`, which needs the +`UIApplicationSupportsMultipleScenes` key in `Info.plist`; the builder emits that key +for Catalyst builds and for no other target, so iPhone and iPad apps are unaffected. + +`Desktop.isSupported()` reads the key back out of the running bundle rather than +trusting a build flag, so the API and the `Info.plist` can never disagree -- including +in a project that was generated once and then hand-edited. + +Catalyst differs in one implementation detail worth knowing: a window's Codename One +content is rendered into an off-screen raster and presented to the scene's view, rather +than the window owning a second GPU surface. Native peers and text editing still use the +scene's real view hierarchy, so they behave normally. + +Some window controls have no Mac Catalyst equivalent, because AppKit owns the +behaviour and Catalyst doesn't expose it to a `UIWindowScene`. `setAlwaysOnTop`, +`setUtilityWindow`, `minimize`, `restore` and `toggleMaximize` do nothing there; the +getters keep reporting whatever the application set, so treat them as requests the +platform may decline. `setDecorated(false)` is partial: it hides the title bar's +title and toolbar -- which is what an application supplying its own chrome needs -- +but the window frame itself stays. Modality, the minimum window size and +resizability all work. + +Scenes also arrive asynchronously. `show()` asks the system to activate one and is +handed it back later, so a window exists for a moment before its scene does, and a closed +window's scene is kept for the next one rather than destroyed -- asking for a scene +while a destruction is still in flight is refused by the system. + +=== Threading + +Everything runs on the event dispatch thread, as usual. Showing, moving and disposing +a window are all EDT operations; calling them from a background thread is marshalled +for you the same way `Form.show()` is. + +Constructing one is the exception, because a constructor has to return its object and +so can't be deferred. `new Window(...)` runs on the thread that calls it, and that's +safe from any thread -- but the window doesn't exist for the framework until it's +shown. + +There is one event dispatch thread for the whole application no matter how many windows +are open. A window that animates keeps the thread awake; a minimized or hidden window +does no work at all. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 8aa4a18af74..18d6cbb5f31 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -181,6 +181,8 @@ include::App-Store-Submission.asciidoc[] include::Desktop-Integration.asciidoc[] +include::Desktop-Windows.asciidoc[] + include::Working-With-Windows.asciidoc[] include::Working-With-Linux.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index fd68730e9e6..3c339662c44 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -372,6 +372,7 @@ preloads prerendered querystring rasterizer +readback recurse redeclaring redeclaration @@ -665,6 +666,9 @@ unretraceable # The value a thermostat is aiming for, as the HVAC industry and both # platforms name it. [Ss]etpoints? +# A close request a listener can refuse, which is how the window close operation +# is described throughout the desktop windows chapter. +[Vv]etoable # ----------------------------------------------------------------------------- # Nearby devices (Nearby-Devices.asciidoc) terminology. diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json index 5cf03690b7c..2d1343189b4 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -765,8 +765,67 @@ "Media360PanoramaScreenshotTest", "VRStereoSceneScreenshotTest" ] + }, + { + "id": "multi-window", + "category": "Desktop integration", + "name": "Multiple native windows", + "description": "Opens additional native operating-system windows, each rendering its own component hierarchy, and checks layout, scrolling, graphics, layered overlays, native text editing and modality inside one.", + "tests": [ + "MultiWindowApiTest", + "WindowLayoutTest", + "WindowScrollTest", + "WindowGraphicsTest", + "WindowEditingTest", + "WindowOverlayTest", + "WindowModalTest" + ] } ], + "test_scopes": { + "WindowLayoutTest": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "mac-native" + ], + "WindowScrollTest": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "mac-native" + ], + "WindowGraphicsTest": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "mac-native" + ], + "WindowEditingTest": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "mac-native" + ], + "WindowOverlayTest": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "mac-native" + ], + "WindowModalTest": [ + "linux-x64", + "linux-arm64", + "windows-x64", + "windows-arm64", + "mac-native" + ] + }, "screenshot_mappings": [ { "pattern": "kotlin", @@ -1311,6 +1370,30 @@ { "pattern": "Media360Panorama", "test": "Media360PanoramaScreenshotTest" + }, + { + "pattern": "Window-Layout*", + "test": "WindowLayoutTest" + }, + { + "pattern": "Window-Scroll*", + "test": "WindowScrollTest" + }, + { + "pattern": "Window-Graphics*", + "test": "WindowGraphicsTest" + }, + { + "pattern": "Window-Editing*", + "test": "WindowEditingTest" + }, + { + "pattern": "Window-Overlay*", + "test": "WindowOverlayTest" + }, + { + "pattern": "Window-Modal*", + "test": "WindowModalTest" } ] } diff --git a/docs/website/layouts/partials/port-status-feature-status.html b/docs/website/layouts/partials/port-status-feature-status.html index 55379cea772..487114f55fc 100644 --- a/docs/website/layouts/partials/port-status-feature-status.html +++ b/docs/website/layouts/partials/port-status-feature-status.html @@ -14,9 +14,24 @@ {{- $skipped := 0 -}} {{- $notRun := 0 -}} {{- $awaiting := 0 -}} + {{- /* The tests this port is actually on the hook for. A feature whose baselines + are scoped away from this port is answered by whatever is left -- reading + one of seven when six of them never applied made a fully covered port look + mostly uncovered. */ -}} + {{- $applicable := 0 -}} {{- $failedTests := slice -}} {{- $skippedTests := slice -}} {{- range $feature.tests -}} + {{- /* A test scoped to other ports does not apply here: this port has no + windowing system, was never asked for the capability, and will never + carry the test in its report. Counting it as awaiting would leave a + permanent "waiting for this port's next run" against something that + is never coming, which reads as a gap rather than as not applicable. */ -}} + {{- $scope := index $contract.test_scopes . -}} + {{- if and $scope (not (in $scope $port.id)) -}} + {{- continue -}} + {{- end -}} + {{- $applicable = add $applicable 1 -}} {{- $result := index $report.tests . -}} {{- /* Absent and "not-run" are different claims. "not-run" means this port ran the suite with the test in its contract and nothing reported @@ -91,7 +106,7 @@ {{- end -}} {{- $bootstrapComplete := and (eq $report.bootstrap_source "successful-master-workflow") (eq $report.workflow_conclusion "success") -}} {{- $complete := or $report.suite_finished $bootstrapComplete -}} - {{- $total := len $feature.tests -}} + {{- $total := $applicable -}} {{- $state = "partial" -}} {{- $mark = "−" -}} {{- $awaitingNote := cond (gt $awaiting 0) (printf ", %d awaiting this port's next run" $awaiting) "" -}} diff --git a/docs/website/layouts/partials/port-status-port-state.html b/docs/website/layouts/partials/port-status-port-state.html index 710e38c85e9..58603e6dd9c 100644 --- a/docs/website/layouts/partials/port-status-port-state.html +++ b/docs/website/layouts/partials/port-status-port-state.html @@ -21,6 +21,15 @@ computed that way. */ -}} {{- range $contract.features -}} {{- range .tests -}} + {{- /* A test scoped to other ports does not apply here: this port has no + windowing system, was never asked for the capability, and will never + carry the test in its report. Counting it as awaiting would leave a + permanent "waiting for this port's next run" against something that + is never coming, which reads as a gap rather than as not applicable. */ -}} + {{- $scope := index $contract.test_scopes . -}} + {{- if and $scope (not (in $scope $port.id)) -}} + {{- continue -}} + {{- end -}} {{- $result := index $report.tests . -}} {{- if not $result -}} {{- $awaiting = add $awaiting 1 -}} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 220e12e1129..12d9ff10c6b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -10653,9 +10653,13 @@ static String userActivityTypesKey(List> intents) { } static String mergeUserActivityTypes(String inject, List> intents) { - int key = inject.indexOf("NSUserActivityTypes"); - int open = key < 0 ? -1 : inject.indexOf("", key); - int close = open < 0 ? -1 : inject.indexOf("", open); + // The same structural reading the rest of the plist parsing uses: this walks a + // fragment the application supplied, so "" and "" are shapes it + // has to accept. Found by enumerating every literal closing tag left in this + // file rather than waiting for the next one to be reported. + int key = plistKeyIndex(inject, "NSUserActivityTypes"); + int open = key < 0 ? -1 : plistElementIndex(inject, "array", key); + int close = open < 0 ? -1 : plistCloseElementIndex(inject, "array", open); if (close < 0) { return inject; } @@ -11387,7 +11391,967 @@ private File[] extractAppExtensions(File sourceDirectory, File targetDirectory) return out.toArray(new File[out.size()]); } - private void injectToPlist(File tmpFile, File resDir, BuildRequest request) throws IOException { + /// The text of the `UIApplicationSceneManifest` value element, or the empty string + /// when the fragment does not declare one. + /// + /// Scoping the manifest checks to this rather than to the whole injection is what + /// stops an unrelated dictionary answering for the manifest. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// #### Returns + /// + /// the manifest's value element, or "" + static String plistManifestScope(String plist) { + if (plist == null) { + return ""; + } + // The fragment's own level. A manifest parked inside some other dictionary is + // not a member of the plist UIKit reads, so it configures nothing -- and + // answering from it accepts a build whose windows are unsupported on the + // device, which is the whole failure this validation exists to catch. + int[] range = plistMemberRange(plist, 0, plist.length(), "UIApplicationSceneManifest"); + return range == null ? "" : plist.substring(range[0], range[1]); + } + + /// Whether the fragment actually declares the given key. + /// + /// The rest of this method tests the injected plist with plain `contains`, which is + /// fine where the answer only decides whether to add a key of our own -- matching + /// too eagerly there just skips an injection. These two checks are different: they + /// fail the build, so a key named inside a comment or quoted in some unrelated + /// string value would stop a build that was going to work. Hence a declared + /// `` element, and not one that only exists inside a comment. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `key`: the key to look for + /// + /// #### Returns + /// + /// true if the fragment declares the key outside any comment + static boolean plistDeclaresKey(String plist, String key) { + return plistKeyIndex(plist, key) >= 0; + } + + /// Index of the declared key element, skipping any occurrence inside a comment, or + /// -1 when the fragment does not declare it. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `key`: the key to look for + /// + /// #### Returns + /// + /// the index of the `` element, or -1 + static int plistKeyIndex(String plist, String key) { + return plistKeyIndex(plist, key, 0); + } + + /// As above, starting the search at `from`. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `key`: the key name to look for + /// + /// - `from`: index to start looking from + /// + /// #### Returns + /// + /// the index of the key element, or -1 + static int plistKeyIndex(String plist, String key, int from) { + int at = plistElementIndex(plist, "key", from); + while (at >= 0) { + int contentStart = plistOpenTagEnd(plist, at); + // Structural, like the container tags: "" closes a key just as + // "" does, and matching the literal reported such a key absent -- + // which makes the injection append a second one beside the application's + // own and the validation reject a correctly configured build. + int close = plistCloseElementIndex(plist, "key", at); + if (close < 0 || contentStart < 0) { + return -1; + } + // The element's text, trimmed. "\n UIApplicationSceneManifest\n" + // is the same key as the contiguous spelling, and requiring the tags and the + // name to be adjacent reported it absent -- which made the build append a + // second manifest beside the application's own, leaving duplicate keys. + if (key.equals(plist.substring(contentStart, close).trim())) { + return at; + } + at = plistElementIndex(plist, "key", close); + } + return -1; + } + + /// Index just past the `` closing the key element that starts at `keyIndex`, + /// or -1. Callers need this rather than adding a fixed tag length, since the + /// element may carry whitespace around its name. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `keyIndex`: index of the key element's `` + /// + /// #### Returns + /// + /// the index just past ``, or -1 + static int plistKeyEnd(String plist, int keyIndex) { + int close = plistCloseElementIndex(plist, "key", keyIndex); + return close < 0 ? -1 : plistOpenTagEnd(plist, close); + } + + /// Index of the next live element with this name at or after `from`, or -1. + /// + /// Matches the element rather than a literal tag, so ``, `` and + /// `` are all that element and `` is not. The rest of this + /// guard used literal tags, which is true of every Info.plist in practice but is an + /// assumption about formatting rather than about the document. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `name`: the element name + /// + /// - `from`: index to start looking from + /// + /// #### Returns + /// + /// the index of the element's `<`, or -1 + static int plistElementIndex(String plist, String name, int from) { + int at = plistIndexOfLive(plist, "<" + name, from); + while (at >= 0) { + int after = at + 1 + name.length(); + if (after < plist.length()) { + char c = plist.charAt(after); + if (c == '>' || c == '/' || c == ' ' || c == '\t' || c == '\n' || c == '\r') { + return at; + } + } + at = plistIndexOfLive(plist, "<" + name, at + 1); + } + return -1; + } + + /// Index just past the `>` closing the opening tag that starts at `elementIndex`, + /// or -1. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `elementIndex`: index of the element's `<` + /// + /// #### Returns + /// + /// the index just past the opening tag, or -1 + static int plistOpenTagEnd(String plist, int elementIndex) { + int gt = plist.indexOf('>', elementIndex); + return gt < 0 ? -1 : gt + 1; + } + + /// Index of the first occurrence of `needle` at or after `from` that is not inside + /// a comment, or -1. + /// + /// Every one of these checks needs the same thing -- text that is really there, + /// rather than text someone commented out -- so they share this rather than each + /// deciding for itself. Three separate `indexOf` calls is how the role ended up + /// comment-aware while the delegate name beneath it was not. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `needle`: the text to find + /// + /// - `from`: index to start looking from + /// + /// #### Returns + /// + /// the index of the first live occurrence, or -1 + static int plistIndexOfLive(String plist, String needle, int from) { + int at = plist.indexOf(needle, from); + while (at >= 0) { + if (!plistIndexIsCommented(plist, at)) { + return at; + } + at = plist.indexOf(needle, at + needle.length()); + } + return -1; + } + + private static boolean plistIndexIsCommented(String plist, int index) { + int open = plist.lastIndexOf("", open); + return close < 0 || close > index; + } + + /// The name of the next element opening at or after `from`, or null if there is + /// none. + /// + /// Comments, declarations and closing tags are stepped over so the answer is the + /// next element that actually opens. Enough structure to read a plist value without + /// pretending to parse a document we did not write. + /// + /// #### Parameters + /// + /// - `plist`: the fragment to read + /// + /// - `from`: index to start looking from + /// + /// #### Returns + /// + /// the element name, or null when no element opens after `from` + static String nextElementName(String plist, int from) { + int lt = plist.indexOf('<', from); + while (lt >= 0 && lt + 1 < plist.length()) { + if (plist.startsWith("", lt); + if (close < 0) { + return null; + } + lt = plist.indexOf('<', close + 3); + continue; + } + char kind = plist.charAt(lt + 1); + if (kind == '!' || kind == '?' || kind == '/') { + lt = plist.indexOf('<', lt + 1); + continue; + } + int end = lt + 1; + while (end < plist.length()) { + char c = plist.charAt(end); + if (c == '>' || c == '/' || c == ' ' || c == '\t' || c == '\n' || c == '\r') { + break; + } + end++; + } + return plist.substring(lt + 1, end); + } + return null; + } + + /// Whether a plist fragment wires the application window scene role to Codename + /// One's scene delegate. + /// + /// A manifest can declare the role and hand it to somebody else's delegate, in + /// which case the secondary scenes a window needs are never adopted. The delegate + /// has to appear inside this role's own configuration, which is why the search + /// stops at the next scene role -- a CarPlay role naming its own delegate must not + /// be mistaken for this one. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// #### Returns + /// + /// true if the window scene role names `CodenameOne_GLSceneDelegate` + /// Why this build cannot give the Mac slice a usable scene manifest, or null when + /// it can. + /// + /// Only reached when the application supplied its own `UIApplicationSceneManifest` + /// through `ios.plistInject`, in which case this build must not write a second + /// one. What it must not do either is demand that manifest already support + /// multiple scenes: that fragment goes into the plist the iPhone/iPad slice reads, + /// and true there is the iPad multi-window opt-in the Mac-specific copy exists to + /// avoid. A single-scene iOS manifest is the right thing to inject, and + /// `#plistForMacSlice(String)` adds the support key and the window role to the Mac + /// copy. + /// + /// So what is rejected is only what cannot be repaired without discarding + /// something the application wrote: + /// + /// - a manifest, or a `UISceneConfigurations`, that is not a dictionary. There is + /// nothing to add a member to, and left alone the Mac copy is a silent no-op -- + /// a build that succeeds and a window that is unsupported on the device. + /// - a window role already wired to somebody else's delegate. Adding ours beside + /// it would not help, since UIKit reads the role rather than a list of + /// candidates. + /// + /// Separate from the throw so it can be tested: this decides what a build with + /// windows will accept, and every part of it has been wrong at least once. + /// + /// #### Parameters + /// + /// - `inject`: the injected plist fragment + /// + /// #### Returns + /// + /// the reason to refuse, or null to proceed + static String sceneManifestRejection(String inject) { + if (!plistDeclaresKey(inject, "UIApplicationSceneManifest")) { + return null; + } + // Two of the same reserved key at one level. A property list resolves + // duplicates to the LAST value, while every lookup here answers with the + // first -- so validating and rewriting the first would leave the second in + // force on the device: a build that succeeds and a Window that is + // unsupported. Fragments composed by more than one injector are how this + // arises, and there is no safe pick between them, so it is reported. + if (plistMemberDuplicated(inject, 0, inject.length(), "UIApplicationSceneManifest")) { + return "macNative.enabled=true asks for com.codename1.ui.Window support, but " + + "ios.plistInject declares UIApplicationSceneManifest twice. A property " + + "list takes the last of a duplicated key, so the two disagree about " + + "what the bundle ends up with. Compose them into one manifest."; + } + String manifest = plistManifestScope(inject); + if (!"dict".equals(nextElementName(manifest, 0))) { + return "macNative.enabled=true asks for com.codename1.ui.Window support, but the " + + "UIApplicationSceneManifest in ios.plistInject is not a . UIKit " + + "reads it as a dictionary of scene settings, so it has to be one."; + } + if (plistDictDuplicated(manifest, "UIApplicationSupportsMultipleScenes")) { + return "macNative.enabled=true asks for com.codename1.ui.Window support, but the " + + "UIApplicationSceneManifest in ios.plistInject declares " + + "UIApplicationSupportsMultipleScenes twice. A property list takes the " + + "last of a duplicated key, so setting the first to true would leave a " + + "later false in force. Compose them into one entry."; + } + if (plistDictDuplicated(manifest, "UISceneConfigurations")) { + return "macNative.enabled=true asks for com.codename1.ui.Window support, but the " + + "UIApplicationSceneManifest in ios.plistInject declares " + + "UISceneConfigurations twice. A property list takes the last of a " + + "duplicated key, so the two disagree about which scenes the bundle " + + "configures. Compose them into one dictionary."; + } + String configurations = plistDictMember(manifest, "UISceneConfigurations"); + if (configurations != null && !"dict".equals(nextElementName(configurations, 0))) { + return "macNative.enabled=true asks for com.codename1.ui.Window support, but the " + + "UISceneConfigurations in the UIApplicationSceneManifest from " + + "ios.plistInject is not a . UIKit reads it as a dictionary keyed " + + "by scene role, so it has to be one."; + } + if (plistDictDuplicated(configurations, "UIWindowSceneSessionRoleApplication")) { + return "macNative.enabled=true asks for com.codename1.ui.Window support, but the " + + "UISceneConfigurations in ios.plistInject declares " + + "UIWindowSceneSessionRoleApplication twice. A property list takes the " + + "last of a duplicated key, so the two disagree about which delegate " + + "adopts a window. Compose them into one role."; + } + String role = plistDictMember(configurations, "UIWindowSceneSessionRoleApplication"); + for (String configuration : plistArrayMembers(role)) { + if (plistDictDuplicated(configuration, "UISceneDelegateClassName")) { + return "macNative.enabled=true asks for com.codename1.ui.Window support, but a " + + "UIWindowSceneSessionRoleApplication configuration in " + + "ios.plistInject declares UISceneDelegateClassName twice. A property " + + "list takes the last of a duplicated key, so naming " + + "CodenameOne_GLSceneDelegate first would leave a later delegate in " + + "force and no scene adopting a window. Compose them into one entry."; + } + } + if (role != null && !plistManifestWiresWindowScene(inject)) { + return "macNative.enabled=true asks for com.codename1.ui.Window support, but the " + + "UIApplicationSceneManifest in ios.plistInject already declares a " + + "UIWindowSceneSessionRoleApplication that does not name " + + "CodenameOne_GLSceneDelegate. A window is a scene of that role, so it is " + + "the delegate that has to be Codename One's. Point that configuration at " + + "CodenameOne_GLSceneDelegate, or drop the window role from your manifest " + + "and let the build add one for the Mac slice."; + } + return null; + } + + /// The Mac slice's version of a finished plist: one that supports multiple scenes + /// and declares the window role to create them with. + /// + /// The shared plist is left exactly as the iOS slice needs it, which for a default + /// Catalyst build means it carries no scene manifest at all -- declaring one + /// activates the UIScene lifecycle, and the iPhone/iPad artifact still carries its + /// main NIB, which is a window with no scene and a launch FrontBoard terminates. + /// So this adds whatever is missing, and only the Mac slice ever reads the result: + /// + /// - no manifest at all: a whole one is added to the root dictionary; + /// - a manifest without multiple-scene support: the key is set, or added; + /// - a manifest whose scene configurations have no window role -- which is what a + /// CarPlay build with ios.uiscene off produces -- the role is added to them. + /// + /// That last case is why this cannot simply flip a boolean: a manifest can exist + /// and still describe no window UIKit could create. + /// + /// Returns the input unchanged when there is nothing to do. + /// + /// #### Parameters + /// + /// - `plist`: the finished plist text + /// + /// #### Returns + /// + /// the plist text for the Mac slice + static String plistForMacSlice(String plist) { + if (plist == null) { + return null; + } + plist = plistWithExpandedDict(plist, 0); + int[] root = plistRootDictBody(plist); + if (root == null) { + return plist; + } + int[] manifest = plistMemberRange(plist, root[0], root[1], "UIApplicationSceneManifest"); + if (manifest == null) { + return plist.substring(0, root[1]) + MAC_SCENE_MANIFEST + plist.substring(root[1]); + } + String updated = plist.substring(manifest[0], manifest[1]); + updated = plistWithMultipleScenes(updated); + updated = plistWithWindowRole(updated); + return plist.substring(0, manifest[0]) + updated + plist.substring(manifest[1]); + } + + /// The same text with the dictionary element at or after `from` expanded from the + /// self-closing spelling to an empty pair, so members can be added to it. + /// + /// `` is a valid empty dictionary and an application is free to write one. + /// Everything that reads a plist here copes with it, but nothing can add a member + /// to it: there is no closing tag to insert before. Expanding it first is what + /// lets the Mac slice give an empty manifest, or empty scene configurations, the + /// content it needs -- without it the copy was returned unchanged and windows + /// stayed unsupported on the device. + /// + /// #### Parameters + /// + /// - `text`: the text to normalize + /// + /// - `from`: where to look for the dictionary + /// + /// #### Returns + /// + /// the text, with that dictionary expanded if it needed it + private static String plistWithExpandedDict(String text, int from) { + int open = plistElementIndex(text, "dict", from); + if (open < 0) { + return text; + } + int gt = text.indexOf('>', open); + if (gt <= open || text.charAt(gt - 1) != '/') { + return text; + } + return text.substring(0, gt - 1) + ">" + text.substring(gt + 1); + } + + /// The `{start, end}` offsets of the root dictionary's body -- just inside its + /// `` and just before the matching close. + /// + /// #### Parameters + /// + /// - `plist`: a whole plist document + /// + /// #### Returns + /// + /// the body's offsets, or null when there is no root dictionary + private static int[] plistRootDictBody(String plist) { + int open = plistElementIndex(plist, "dict", 0); + if (open < 0) { + return null; + } + int bodyStart = plistOpenTagEnd(plist, open); + int afterClose = plistValueElementEnd(plist, open); + if (bodyStart < 0 || afterClose < 0) { + return null; + } + // Back up over the closing tag itself. afterClose is immediately past the + // root's own close, so the last "" + manifest.substring(member[1]); + } + return manifest.substring(0, body[1]) + + " UIApplicationSupportsMultipleScenes\n \n" + + manifest.substring(body[1]); + } + + /// The same manifest with a window scene role wired to Codename One's delegate, + /// adding the scene configurations dictionary when there is none. + /// + /// Left alone when a window role is already there, whatever it names: rewriting a + /// role the application wrote would silently replace its choice, and the caller + /// refuses that case rather than overruling it. + private static String plistWithWindowRole(String manifestElement) { + String manifest = plistWithExpandedDict(manifestElement, 0); + int[] body = plistRootDictBody(manifest); + if (body == null) { + return manifest; + } + int[] configurations = plistMemberRange(manifest, body[0], body[1], + "UISceneConfigurations"); + if (configurations == null) { + return manifest.substring(0, body[1]) + + " UISceneConfigurations\n \n" + + WINDOW_SCENE_ROLE + + " \n" + + manifest.substring(body[1]); + } + String dict = plistWithExpandedDict( + manifest.substring(configurations[0], configurations[1]), 0); + int[] dictBody = plistRootDictBody(dict); + if (dictBody == null) { + return manifest; + } + if (plistMemberRange(dict, dictBody[0], dictBody[1], + "UIWindowSceneSessionRoleApplication") != null) { + return manifest; + } + String widened = dict.substring(0, dictBody[1]) + WINDOW_SCENE_ROLE + + dict.substring(dictBody[1]); + return manifest.substring(0, configurations[0]) + widened + + manifest.substring(configurations[1]); + } + + /// The value element of `key` when it is an immediate member of `dict`, which is + /// itself a whole `...` element, or null when this dictionary has no + /// such member. + /// + /// Membership is what UIKit reads. A reserved key sitting inside some unrelated + /// metadata dictionary nested in this one is not a member of it and is ignored on + /// the device, so answering from anywhere inside would accept a manifest that + /// enables and configures nothing -- the green build with an unsupported `Window` + /// that this validation exists to prevent. + /// + /// Each member's value element is skipped whole, which is what keeps the walk at + /// the dictionary's own level without counting nesting separately. + /// + /// #### Parameters + /// + /// - `dict`: a `` element, as returned by `#plistManifestScope(String)` + /// + /// - `key`: the member name to look for + /// + /// #### Returns + /// + /// the member's value element, or null + static String plistDictMember(String dict, String key) { + int[] range = plistDictMemberRange(dict, key); + return range == null ? null : dict.substring(range[0], range[1]); + } + + /// The `{start, end}` offsets of a member's value element within `dict`, or null + /// when this dictionary has no such member. `#plistDictMember(String, String)` + /// documents what membership means and why it is the only question worth asking; + /// this form exists so a caller can rewrite the value in the original text. + /// + /// #### Parameters + /// + /// - `dict`: a `` element + /// + /// - `key`: the member name to look for + /// + /// #### Returns + /// + /// the value element's offsets, or null + static int[] plistDictMemberRange(String dict, String key) { + if (dict == null || !"dict".equals(nextElementName(dict, 0))) { + return null; + } + int open = plistElementIndex(dict, "dict", 0); + if (open < 0) { + return null; + } + int at = plistOpenTagEnd(dict, open); + int end = plistValueElementEnd(dict, 0); + if (at < 0) { + return null; + } + if (end < 0) { + end = dict.length(); + } + return plistMemberRange(dict, at, end, key); + } + + /// Whether `key` appears more than once as a member at the level between `from` + /// and `to`. + /// + /// #### Parameters + /// + /// - `plist`: the text to search + /// + /// - `from`: where this level starts + /// + /// - `to`: where this level ends + /// + /// - `key`: the member name to count + /// + /// #### Returns + /// + /// true when there are two or more + static boolean plistMemberDuplicated(String plist, int from, int to, String key) { + int[] first = plistMemberRange(plist, from, to, key); + return first != null && plistMemberRange(plist, first[1], to, key) != null; + } + + /// Whether `key` appears more than once as a member of the dictionary `dict`. + /// + /// #### Parameters + /// + /// - `dict`: a `` element, or null + /// + /// - `key`: the member name to count + /// + /// #### Returns + /// + /// true when there are two or more + static boolean plistDictDuplicated(String dict, String key) { + if (dict == null) { + return false; + } + int[] body = plistRootDictBody(dict); + return body != null && plistMemberDuplicated(dict, body[0], body[1], key); + } + + /// The `{start, end}` offsets of `key`'s value element between `from` and `to`, + /// searching only that level: each member's value element is skipped whole, so a + /// key of the same name nested inside one of them is never mistaken for a member + /// here. + /// + /// Serves both shapes the plist code deals in -- a `` element's body, and an + /// injected fragment, which is a run of key and value elements with no wrapper. + /// + /// #### Parameters + /// + /// - `plist`: the text to search + /// + /// - `from`: where this level starts + /// + /// - `to`: where this level ends + /// + /// - `key`: the member name to look for + /// + /// #### Returns + /// + /// the value element's offsets, or null + static int[] plistMemberRange(String plist, int from, int to, String key) { + int at = from; + while (at < to) { + int keyIndex = plistElementIndex(plist, "key", at); + if (keyIndex < 0 || keyIndex >= to) { + return null; + } + int contentStart = plistOpenTagEnd(plist, keyIndex); + int close = plistCloseElementIndex(plist, "key", keyIndex); + int valueStart = plistKeyEnd(plist, keyIndex); + if (contentStart < 0 || close < 0 || valueStart < 0) { + return null; + } + int valueEnd = plistValueElementEnd(plist, valueStart); + if (valueEnd < 0) { + return null; + } + if (key.equals(plist.substring(contentStart, close).trim())) { + return new int[] {valueStart, valueEnd}; + } + at = valueEnd; + } + return null; + } + + /// Whether an application supplied manifest declares multiple-scene support at the + /// level UIKit reads it -- as a member of the manifest dictionary itself, not + /// somewhere nested inside it. + /// + /// #### Parameters + /// + /// - `plist`: the whole injected fragment + /// + /// #### Returns + /// + /// true when the manifest itself sets the key true + static boolean plistManifestSupportsMultipleScenes(String plist) { + String value = plistDictMember( + plistManifestScope(plist), "UIApplicationSupportsMultipleScenes"); + // The value of a key is the element that follows it, so read that element's + // name rather than matching a spelling: "", "" and + // "" are the same element. + return value != null && "true".equals(nextElementName(value, 0)); + } + + /// Whether an application supplied manifest wires the window scene role to + /// Codename One's delegate, at the levels UIKit reads them: the role has to be a + /// member of the manifest's `UISceneConfigurations` dictionary, which in turn has + /// to be a member of the manifest. + /// + /// #### Parameters + /// + /// - `plist`: the whole injected fragment + /// + /// #### Returns + /// + /// true when the window role names `CodenameOne_GLSceneDelegate` + static boolean plistManifestWiresWindowScene(String plist) { + String manifest = plistManifestScope(plist); + String configurations = plistDictMember(manifest, "UISceneConfigurations"); + String role = plistDictMember(configurations, "UIWindowSceneSessionRoleApplication"); + // The shape UIKit requires, not merely the names it uses. The role's value is + // an array of configuration dictionaries; a role written as a dictionary, or + // one whose delegate key is buried in a nested dictionary rather than owned by + // a configuration, describes no window UIKit can create -- and accepting it + // passes the build and leaves Window unsupported on the device, which is what + // this check exists to prevent. + for (String configuration : plistArrayMembers(role)) { + if (!"dict".equals(nextElementName(configuration, 0))) { + continue; + } + String delegate = plistDictMember(configuration, "UISceneDelegateClassName"); + if (delegate != null && "string".equals(nextElementName(delegate, 0)) + && "CodenameOne_GLSceneDelegate".equals( + plistStringValueAfter(delegate, 0))) { + return true; + } + } + return false; + } + + /// The immediate members of an `` element, in order, or an empty list when + /// this is not an array. + /// + /// Each member's own element is skipped whole, so a nested array or dictionary + /// contributes one member rather than its contents. + /// + /// #### Parameters + /// + /// - `array`: an `` element + /// + /// #### Returns + /// + /// the member elements + static java.util.List plistArrayMembers(String array) { + java.util.List members = new java.util.ArrayList(); + if (array == null || !"array".equals(nextElementName(array, 0))) { + return members; + } + int open = plistElementIndex(array, "array", 0); + if (open < 0) { + return members; + } + int at = plistOpenTagEnd(array, open); + if (at < 0) { + return members; + } + int end = plistValueElementEnd(array, 0); + if (end < 0) { + end = array.length(); + } + while (at < end) { + String name = nextElementName(array, at); + if (name == null) { + break; + } + int start = plistElementIndex(array, name, at); + // Past the array's own close, so nextElementName found something after it. + if (start < 0 || start >= end) { + break; + } + int memberEnd = plistValueElementEnd(array, start); + if (memberEnd < 0 || memberEnd > end) { + break; + } + members.add(array.substring(start, memberEnd)); + at = memberEnd; + } + return members; + } + + /// Index just past the element that is the value at `from`, honouring nesting, or + /// -1 when there is no element there. + /// + /// This is what bounds a scene role: the role's value is its own array, so the + /// configurations belonging to it are exactly the ones inside that element. Bounding + /// by the next key whose *name* looks like a role instead was wrong twice over -- a + /// string mentioning the words ended the role early, and so did an unrelated key + /// like "MySceneSessionRoleMetadata" declared inside it, which made the build reject + /// a manifest that was correctly wired. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `from`: index just past the key whose value is wanted + /// + /// #### Returns + /// + /// the index just past the value element, or -1 + static int plistValueElementEnd(String plist, int from) { + String name = nextElementName(plist, from); + if (name == null) { + return -1; + } + int open = plistElementIndex(plist, name, from); + if (open < 0) { + return -1; + } + int gt = plist.indexOf('>', open); + if (gt < 0) { + return -1; + } + if (plist.charAt(gt - 1) == '/') { + return gt + 1; + } + int depth = 0; + int scan = gt; + while (true) { + // Structural, not spelled-out tags. "" and "" are + // the same elements as "" and "", and the rest of this parser + // already accepts them -- matching literals here missed a nested opening and + // let the first inner "" close the outer one, truncating the role + // before its delegate. A closing tag written "" was missed the + // other way and left no close at all. + // + // Live tags only. A comment containing "" would otherwise close the + // element early and truncate the range, so validation would read a prefix of + // the manifest and reject a build that is correctly configured -- the same + // rule the key and element searches already follow. + int nextOpen = plistNestedElementIndex(plist, name, scan + 1); + int nextClose = plistCloseElementIndex(plist, name, scan + 1); + if (nextClose < 0) { + return -1; + } + if (nextOpen >= 0 && nextOpen < nextClose) { + depth++; + scan = nextOpen; + } else if (depth == 0) { + return plistOpenTagEnd(plist, nextClose); + } else { + depth--; + scan = nextClose; + } + } + } + + /// The next opening element of the given name that actually opens a nesting level. + /// + /// A self-closing "" is an element but not a level: counting it as one would + /// leave the depth permanently ahead and swallow the real closing tag, so the value + /// would run to the end of the fragment. The literal matching this replaced happened + /// to get that right by not matching self-closing tags at all; matching structurally + /// means excluding them on purpose. + /// + /// #### Parameters + /// + /// - `plist`: the fragment + /// + /// - `name`: the element name + /// + /// - `from`: where to start + /// + /// #### Returns + /// + /// the index of the opening tag, or -1 + private static int plistNestedElementIndex(String plist, String name, int from) { + int at = plistElementIndex(plist, name, from); + while (at >= 0) { + int end = plistOpenTagEnd(plist, at); + if (end < 0) { + return -1; + } + if (end < 2 || plist.charAt(end - 2) != '/') { + return at; + } + at = plistElementIndex(plist, name, end); + } + return -1; + } + + /// The next live closing element of the given name. + /// + /// The counterpart of `plistElementIndex` for "", tolerating the whitespace + /// XML allows before the ">" so that "" closes an array. + /// + /// #### Parameters + /// + /// - `plist`: the fragment + /// + /// - `name`: the element name + /// + /// - `from`: where to start + /// + /// #### Returns + /// + /// the index of the closing tag, or -1 + static int plistCloseElementIndex(String plist, String name, int from) { + int at = plistIndexOfLive(plist, "= 0) { + int after = at + 2 + name.length(); + int scan = after; + while (scan < plist.length()) { + char c = plist.charAt(scan); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + scan++; + continue; + } + break; + } + // Only whitespace may sit between the name and the ">". Anything else means + // this was a different element whose name merely starts the same way. + if (scan < plist.length() && plist.charAt(scan) == '>' && scan >= after) { + return at; + } + at = plistIndexOfLive(plist, "` element that follows `from`, or null when the next + /// element is not a string. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `from`: index to read the value from, normally just past a key + /// + /// #### Returns + /// + /// the string's text, trimmed, or null + static String plistStringValueAfter(String plist, int from) { + if (!"string".equals(nextElementName(plist, from))) { + return null; + } + int open = plistElementIndex(plist, "string", from); + int contentStart = open < 0 ? -1 : plistOpenTagEnd(plist, open); + if (contentStart < 0) { + return null; + } + // Structural, like every other closing tag this parser reads. "" ends + // a string just as "" does, and matching the literal made the window + // role check report the delegate missing and abort a build that was correctly + // configured. + int close = plistCloseElementIndex(plist, "string", open); + if (close < 0) { + return null; + } + return plist.substring(contentStart, close).trim(); + } + + private void injectToPlist(File tmpFile, File resDir, BuildRequest request) + throws IOException, BuildException { File buildinRes = new File(tmpFile, "btres"); File mat = new File(buildinRes, "material-design-font.ttf"); if(mat.exists()) { @@ -11566,12 +12530,36 @@ public boolean accept(File file, String string) { } } boolean useUISceneManifest = "true".equalsIgnoreCase(request.getArg("ios.uiscene", "true")); + // com.codename1.ui.Window needs multiple scenes, and a Window only exists on + // the Mac Catalyst slice, so the key follows macNative.enabled exactly. + boolean multiWindow = "true".equals(request.getArg("macNative.enabled", "false")); // CarPlay requires the UIScene lifecycle and a dedicated // CPTemplateApplicationSceneSessionRoleApplication scene wired to // CodenameOne_CarPlaySceneDelegate. Emit the manifest when either UIScene is on or the app // uses CarPlay; include the phone window role only under UIScene, and the CarPlay role only // when the app references com.codename1.car. - if ((useUISceneManifest || usesCar) && !inject.contains("UIApplicationSceneManifest")) { + // multiWindow is in the condition as well as the value below. A Catalyst build + // with ios.uiscene=false and no CarPlay skipped the whole block, so the bundle + // got neither UIApplicationSupportsMultipleScenes nor a scene configuration -- + // and getWindowManager() reads that key back out of the bundle, so windows were + // reported unsupported and constructing one threw, in the very build that had + // just asked for them. + if (multiWindow) { + String rejection = sceneManifestRejection(inject); + if (rejection != null) { + throw new BuildException(rejection); + } + } + // multiWindow is deliberately NOT in this condition. Declaring + // UIApplicationSceneManifest activates the UIScene lifecycle, and the + // NSMainNibFile removal above runs only under ios.uiscene -- so putting a + // manifest in the shared plist for a Catalyst build would hand the iPhone/iPad + // artifact a scene lifecycle while it still carries its main NIB, which is a + // window with no scene and a launch FrontBoard terminates on iOS 26. The Mac + // slice's copy is where a manifest appears for windows; see + // plistForMacSlice. + if ((useUISceneManifest || usesCar) + && !plistDeclaresKey(inject, "UIApplicationSceneManifest")) { String carPlayScene = usesCar ? " CPTemplateApplicationSceneSessionRoleApplication\n" + " \n" @@ -11583,24 +12571,26 @@ public boolean accept(File file, String string) { + " \n" + " \n" : ""; - String windowScene = useUISceneManifest - ? " UIWindowSceneSessionRoleApplication\n" - + " \n" - + " \n" - + " UISceneConfigurationName\n" - + " Default Configuration\n" - + " UISceneDelegateClassName\n" - + " CodenameOne_GLSceneDelegate\n" - + " \n" - + " \n" - : ""; + String windowScene = useUISceneManifest ? WINDOW_SCENE_ROLE : ""; inject += "\nUIApplicationSceneManifest\n" + "\n" + " UIApplicationSupportsMultipleScenes\n" - // Keep single-scene (false): the CarPlay scene is a distinct scene ROLE - // (CPTemplateApplicationSceneSessionRoleApplication), not a second window of the - // app role, so it does not need multiple-scene support. Setting this true changed - // Mac Catalyst windowing and crashed the screenshot suite (26 GB / signal loop). + // False here, in every build, including the one that asked for + // windows. This is ONE Info.plist: macNative.enabled sets + // SUPPORTS_MACCATALYST=YES on the app target rather than making a + // second target, and the same build still ships the iPhone/iPad + // slice, so both destinations read this file. Writing true here + // would opt every iPad build into the multi-window behaviour it + // never asked for. + // + // The Mac slice gets its own copy of the finished plist, differing + // in exactly this key, selected by INFOPLIST_FILE[sdk=macosx*] -- + // see MacNativeBuilder.writeCatalystInfoPlist. A copy generated + // from this file cannot drift from it. + // + // The CarPlay scene is a distinct scene ROLE + // (CPTemplateApplicationSceneSessionRoleApplication), not a second + // window of the app role, so it never needed the key either. + " \n" + " UISceneConfigurations\n" + " \n" @@ -11996,6 +12986,87 @@ public boolean accept(File file, String string) { try(FileOutputStream fo = new FileOutputStream(infoPlist)) { fo.write(b.toString().getBytes(StandardCharsets.UTF_8)); } + if (macNativeBuilder.isEnabled()) { + writeCatalystInfoPlist(tmpFile, request.getMainClass(), b.toString()); + } + } + + /// The window scene role, wired to Codename One's scene delegate. A + /// `com.codename1.ui.Window` is a second scene of the app role, so this is the + /// configuration UIKit creates one from; without it a manifest can say multiple + /// scenes are supported and still describe nothing to create them with. + static final String WINDOW_SCENE_ROLE = + " UIWindowSceneSessionRoleApplication\n" + + " \n" + + " \n" + + " UISceneConfigurationName\n" + + " Default Configuration\n" + + " UISceneDelegateClassName\n" + + " CodenameOne_GLSceneDelegate\n" + + " \n" + + " \n"; + + /// The whole scene manifest a Mac slice needs: multiple scenes supported, and the + /// window role to create them with. + static final String MAC_SCENE_MANIFEST = + "UIApplicationSceneManifest\n" + + "\n" + + " UIApplicationSupportsMultipleScenes\n" + + " \n" + + " UISceneConfigurations\n" + + " \n" + + WINDOW_SCENE_ROLE + + " \n" + + "\n"; + + /// The Mac slice's Info.plist, relative to the Xcode project directory, which is + /// the form `INFOPLIST_FILE` is resolved against and the one the generated project + /// already uses. + /// + /// #### Parameters + /// + /// - `mainClass`: the application's main class, which names the file + /// + /// #### Returns + /// + /// the project-relative path + static String catalystInfoPlistRelativePath(String mainClass) { + return mainClass + "-src/" + mainClass + "-MacCatalyst-Info.plist"; + } + + /// Writes the Mac slice its own Info.plist: the finished one with + /// `UIApplicationSupportsMultipleScenes` set true, which + /// `com.codename1.ui.Window` needs and the iOS slice must not have. + /// + /// This exists because a Mac build is one target with `SUPPORTS_MACCATALYST=YES` + /// and still ships the iPhone/iPad slice, so both destinations read one plist. + /// `INFOPLIST_FILE[sdk=macosx*]` is the only place they can be told apart. + /// + /// Written for every Mac build, not only the ones that need the key changed. + /// `MacNativeBuilder` selects this file before the plist exists in one of the two + /// builders that share this code, so it cannot decide by looking; a build whose + /// application already asked for multiple scenes simply gets an identical copy. + /// + /// Generated from the finished text rather than maintained beside it, so it + /// carries everything the build put there and cannot drift. + /// + /// #### Parameters + /// + /// - `tmpFile`: the build's working directory + /// + /// - `mainClass`: the application's main class, which names the file + /// + /// - `plist`: the finished plist text + private void writeCatalystInfoPlist(File tmpFile, String mainClass, String plist) + throws IOException { + File distSrc = new File(new File(tmpFile, "dist"), mainClass + "-src"); + if (!distSrc.isDirectory()) { + return; + } + File target = new File(distSrc, mainClass + "-MacCatalyst-Info.plist"); + try (FileOutputStream fo = new FileOutputStream(target)) { + fo.write(plistForMacSlice(plist).getBytes(StandardCharsets.UTF_8)); + } } /** diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java index 9e4d7e86c40..89fe5084809 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/MacNativeBuilder.java @@ -602,7 +602,25 @@ void applyXcodeSettings(BuildRequest request, File tmpFile, String buildVersion) .append("') unless target\n") .append("target.build_configurations.each do |config|\n") .append(" bs = config.build_settings\n") - .append(" bs['SUPPORTS_MACCATALYST'] = 'YES'\n") + .append(" bs['SUPPORTS_MACCATALYST'] = 'YES'\n"); + // SDK-qualified, like PRODUCT_BUNDLE_IDENTIFIER and DEVELOPMENT_TEAM below. + // This is one target and the same build still ships the iPhone/iPad slice, so + // it is the only place the two destinations can be told apart: + // com.codename1.ui.Window needs UIApplicationSupportsMultipleScenes true, and + // turning that on in the shared plist would opt every iPad build into + // multi-window behaviour it never asked for. + // + // Set unconditionally rather than only when the key needs flipping, because + // this runs before the plist is written in one of the two builders that share + // this code and so cannot look at it. IPhoneBuilder.writeCatalystInfoPlist + // always writes the file for a Mac build, copying the finished plist and + // ensuring the key -- so when the application already asked for multiple + // scenes the copy is simply identical. + s.append(" bs['INFOPLIST_FILE[sdk=macosx*]'] = '") + .append(IPhoneBuilder.escapeRubyStr( + IPhoneBuilder.catalystInfoPlistRelativePath(mainClass))) + .append("'\n"); + s .append(" bs['SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD'] = 'NO'\n") .append(" bs['TARGETED_DEVICE_FAMILY'] = '1,2,6'\n") .append(" bs['DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER'] = '") diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java new file mode 100644 index 00000000000..54247e97227 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java @@ -0,0 +1,1055 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A Catalyst build that asks for windows and supplies its own scene manifest has to be + * told at build time when that manifest cannot support them. Checking only that the key + * names appear accepts a manifest that says the opposite of what is needed, and the + * failure then happens on the device: getWindowManager() reads the bundle, reports + * unsupported, and the first new Window(...) throws. + */ +class IPhoneBuilderSceneManifestValidationTest { + + private static final String WINDOW_ROLE = + " UIWindowSceneSessionRoleApplication\n" + + " \n" + + " \n" + + " UISceneDelegateClassName\n" + + " CodenameOne_GLSceneDelegate\n" + + " \n" + + " \n"; + + private static String manifest(String body) { + return "UIApplicationSceneManifest\n\n" + body + ""; + } + + /// The root dictionary's body of a whole document, which is the level the scene + /// manifest is a member of. The validators take a fragment at that level, so a + /// document has to be unwrapped before they can answer about it -- which is itself + /// the point of the manifest having to be a root member. + private static String rootBody(String document) { + int open = document.indexOf(""); + int close = document.lastIndexOf(""); + return document.substring(open + "".length(), close); + } + + /// A whole plist document, which is what the Mac-slice transform is handed: it + /// reads the root dictionary, and a bare fragment has none. + private static String document(String body) { + return "\n" + + "\n\n" + + " CFBundleName\n Demo\n" + + body + + "\n\n"; + } + + private static String wellFormedManifest() { + return manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n"); + } + + @Test + void aWellFormedManifestSatisfiesBothQuestions() { + String plist = wellFormedManifest(); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(plist)); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(plist)); + } + + @Test + void theSupportKeyHasToBeAMemberOfTheManifestNotBuriedInIt() { + // The key is inside an unrelated metadata dictionary nested in the manifest. + // UIKit reads members of the manifest dictionary, so it ignores this one and + // the app has no multi-scene support -- but a search that only asks whether + // the key appears anywhere in the manifest says yes. + String plist = manifest( + " CN1Metadata\n \n" + + " UIApplicationSupportsMultipleScenes\n" + + " \n" + + " \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n"); + assertFalse(IPhoneBuilder.plistManifestSupportsMultipleScenes(plist), + "a key nested in another dictionary is not a member of the manifest"); + // And this is exactly what the unscoped question would have answered. + assertTrue(keyIsTrueAnywhere( + IPhoneBuilder.plistManifestScope(plist), + "UIApplicationSupportsMultipleScenes"), + "the search-anywhere question is what accepted it"); + } + + @Test + void theWindowRoleHasToBeAMemberOfUISceneConfigurations() { + // The role sits in an unrelated dictionary rather than under + // UISceneConfigurations, so UIKit has no configuration to create a window with. + String plist = manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " CN1Metadata\n \n" + + WINDOW_ROLE + + " \n" + + " UISceneConfigurations\n \n \n"); + assertFalse(IPhoneBuilder.plistManifestWiresWindowScene(plist), + "a role outside UISceneConfigurations configures nothing"); + assertTrue(wiresWindowSceneDelegateAnywhere( + IPhoneBuilder.plistManifestScope(plist)), + "the search-anywhere question is what accepted it"); + } + + @Test + void aManifestWithNoSceneConfigurationsAtAllWiresNothing() { + String plist = manifest( + " UIApplicationSupportsMultipleScenes\n \n"); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(plist)); + assertFalse(IPhoneBuilder.plistManifestWiresWindowScene(plist)); + } + + @Test + void aCommentedMemberIsNotAMember() { + String plist = manifest( + " \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n"); + assertFalse(IPhoneBuilder.plistManifestSupportsMultipleScenes(plist)); + } + + @Test + void whitespaceInTheContainerTagsDoesNotHideMembership() { + String plist = wellFormedManifest() + .replace("", "") + .replace("", ""); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(plist)); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(plist)); + } + + @Test + void theMacSliceGetsMultipleScenesAndTheSharedPlistDoesNot() { + // One Info.plist serves both destinations of one target, so the shared file + // stays false and the Mac copy is what differs. Getting this backwards opts + // every iPad build into multi-window behaviour it never asked for. + String shared = document(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n")); + String mac = IPhoneBuilder.plistForMacSlice(shared); + assertFalse(IPhoneBuilder.plistManifestSupportsMultipleScenes(rootBody(shared)), + "the shared plist the iOS slice reads must stay false"); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(rootBody(mac)), + "the Mac slice's copy must say true"); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(rootBody(mac)), + "and must keep the scene configuration"); + } + + @Test + void theFlipTouchesOnlyTheManifestsOwnMember() { + // A key of the same name in an unrelated dictionary is somebody else's, and + // rewriting it would change a setting the application chose. + String unrelated = " CN1Metadata\n \n" + + " UIApplicationSupportsMultipleScenes\n \n" + + " \n"; + String plist = document(unrelated + + manifest(" UIApplicationSupportsMultipleScenes\n \n")); + String mac = IPhoneBuilder.plistForMacSlice(plist); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes( + rootBody(mac)), + "the manifest's own member is what gets set"); + assertTrue(mac.contains(unrelated), + "the unrelated dictionary is left exactly as it was"); + } + + @Test + void aPlistWithNoSceneManifestIsReturnedUnchanged() { + String plist = "CFBundleNamex"; + assertTrue(plist.equals(IPhoneBuilder.plistForMacSlice(plist))); + } + + @Test + void theFlipIsIdempotent() { + String once = IPhoneBuilder.plistForMacSlice(document(wellFormedManifest())); + assertTrue(once.equals(IPhoneBuilder.plistForMacSlice(once)), + "a manifest that already says true needs no second copy"); + } + + @Test + void aPlistWithNoManifestGetsAWholeOneForTheMacSlice() { + // The default Catalyst build: ios.uiscene is off and there is no CarPlay, so + // the shared plist carries no manifest at all -- declaring one there would + // activate the UIScene lifecycle for the iPhone/iPad artifact while it still + // carries its main NIB, which FrontBoard terminates at launch. The manifest + // has to appear only in the Mac slice's copy. + String shared = "\n" + + "\n\n" + + " CFBundleName\n Demo\n" + + "\n\n"; + assertFalse(IPhoneBuilder.plistDeclaresKey(shared, "UIApplicationSceneManifest"), + "the shared plist must stay free of a scene manifest"); + String mac = IPhoneBuilder.plistForMacSlice(shared); + assertTrue(IPhoneBuilder.plistDeclaresKey(mac, "UIApplicationSceneManifest"), + "the Mac copy is where the manifest appears"); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(rootBody(mac))); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(rootBody(mac))); + assertTrue(mac.contains("CFBundleName"), + "and everything the build already put there is kept"); + assertTrue(mac.trim().endsWith(""), + "the manifest goes inside the root dictionary, not after it"); + } + + @Test + void theWindowRoleHasToBeAnArrayOfConfigurations() { + // A role written as a dictionary rather than an array of configuration + // dictionaries describes no window UIKit can create. + String plist = manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + " UIWindowSceneSessionRoleApplication\n" + + " \n" + + " UISceneDelegateClassName\n" + + " CodenameOne_GLSceneDelegate\n" + + " \n" + + " \n"); + assertFalse(IPhoneBuilder.plistManifestWiresWindowScene(plist), + "the role's value has to be an array of configurations"); + } + + @Test + void theDelegateHasToBeOwnedByAConfigurationNotBuriedUnderIt() { + // The delegate key sits in a metadata dictionary inside the configuration, so + // the configuration itself names no delegate. + String plist = manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + " UIWindowSceneSessionRoleApplication\n" + + " \n" + + " \n" + + " CN1Metadata\n" + + " \n" + + " UISceneDelegateClassName\n" + + " CodenameOne_GLSceneDelegate\n" + + " \n" + + " \n" + + " \n" + + " \n"); + assertFalse(IPhoneBuilder.plistManifestWiresWindowScene(plist), + "a configuration that does not itself name the delegate wires nothing"); + } + + @Test + void oneValidConfigurationAmongSeveralIsEnough() { + String plist = manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + " UIWindowSceneSessionRoleApplication\n" + + " \n" + + " \n" + + " UISceneDelegateClassName\n" + + " SomebodyElse\n" + + " \n" + + " \n" + + " UISceneDelegateClassName\n" + + " CodenameOne_GLSceneDelegate\n" + + " \n" + + " \n" + + " \n"); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(plist)); + } + + @Test + void anArraysMembersAreItsOwnElements() { + java.util.List members = IPhoneBuilder.plistArrayMembers( + "\n a\n \n b\n" + + " \n \n kv\n \n" + + ""); + assertEquals(3, members.size(), "a nested array is one member, not its contents"); + assertTrue(members.get(1).contains("b")); + assertTrue(members.get(2).startsWith("")); + } + + @Test + void aCarPlayOnlyManifestGainsTheWindowRoleOnTheMacSlice() { + // macNative with CarPlay and ios.uiscene off: the build emits a manifest for + // CarPlay's sake, and it carries only the CarPlay role. Flipping the support + // key is not enough -- the Catalyst bundle would say multiple scenes are + // supported and describe no configuration to create a window from. + String carPlayRole = + " CPTemplateApplicationSceneSessionRoleApplication\n" + + " \n" + + " \n" + + " UISceneDelegateClassName\n" + + " CodenameOne_CarPlaySceneDelegate\n" + + " \n" + + " \n"; + String shared = document(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + carPlayRole + + " \n")); + assertFalse(IPhoneBuilder.plistManifestWiresWindowScene(rootBody(shared)), + "the shared plist has no window role, and must not gain one"); + + String mac = IPhoneBuilder.plistForMacSlice(shared); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(rootBody(mac))); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(rootBody(mac)), + "the Mac copy has to gain the window role, not just the support key"); + assertTrue(mac.contains("CodenameOne_CarPlaySceneDelegate"), + "and CarPlay's own role survives beside it"); + } + + @Test + void aManifestWithNoSceneConfigurationsGainsThemOnTheMacSlice() { + String shared = document(manifest( + " UIApplicationSupportsMultipleScenes\n \n")); + String mac = IPhoneBuilder.plistForMacSlice(shared); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(rootBody(mac)), + "the configurations dictionary is added when there is none"); + } + + @Test + void aManifestMissingTheSupportKeyGainsItOnTheMacSlice() { + String shared = document(manifest( + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n")); + String mac = IPhoneBuilder.plistForMacSlice(shared); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(rootBody(mac)), + "the support key is added when the manifest never declared it"); + } + + @Test + void aManifestNestedInAnotherDictionaryIsNotTheManifest() { + // UIKit reads members of the plist root. A manifest parked inside some other + // dictionary configures nothing, so accepting it would pass a build whose + // windows are unsupported on the device. + String fragment = "CN1Metadata\n\n" + + manifest(" UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n") + + "\n"; + assertFalse(IPhoneBuilder.plistManifestSupportsMultipleScenes(fragment), + "a manifest nested in another dictionary is not the plist's manifest"); + assertFalse(IPhoneBuilder.plistManifestWiresWindowScene(fragment), + "and neither is the role inside it"); + } + + @Test + void aSelfClosingManifestStillGetsItsContentOnTheMacSlice() { + // "" is a valid empty dictionary and an application may well write one. + // Nothing can add a member to it as written -- there is no closing tag to + // insert before -- so it has to be expanded first, or the Mac copy comes back + // unchanged and windows stay unsupported on the device. + String shared = document(" UIApplicationSceneManifest\n \n"); + String mac = IPhoneBuilder.plistForMacSlice(shared); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(rootBody(mac)), + "an empty manifest still has to gain multiple-scene support"); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(rootBody(mac)), + "and the window role"); + } + + @Test + void selfClosingSceneConfigurationsStillGainTheWindowRole() { + String shared = document(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n")); + String mac = IPhoneBuilder.plistForMacSlice(shared); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(rootBody(mac))); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(rootBody(mac)), + "empty scene configurations still have to gain the window role"); + } + + @Test + void aSelfClosingDictionaryElsewhereIsLeftAlone() { + // Only the dictionary being added to is expanded; an unrelated one keeps the + // spelling the application chose. + String shared = document(" CN1Metadata\n \n"); + String mac = IPhoneBuilder.plistForMacSlice(shared); + assertTrue(mac.contains("CN1Metadata\n "), + "an unrelated empty dictionary is not rewritten"); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(rootBody(mac)), + "and the manifest is still added"); + } + + @Test + void anInjectionWithNoManifestOfItsOwnIsAccepted() { + assertNull(IPhoneBuilder.sceneManifestRejection( + "CFBundleNameDemo"), + "the build writes its own manifest for the Mac slice; there is nothing " + + "here to object to"); + } + + @Test + void aSingleSceneIosManifestIsAccepted() { + // The point of the Mac-specific copy: an application injecting a manifest for + // its iOS slice must not be forced to put true in the plist that slice reads. + assertNull(IPhoneBuilder.sceneManifestRejection(manifest( + " UIApplicationSupportsMultipleScenes\n \n")), + "a single-scene iOS manifest is the right thing to inject"); + } + + @Test + void aManifestThatIsNotADictionaryIsRejected() { + // Nothing can be added to it, and left alone the Mac copy is a silent no-op: + // a build that succeeds and a window unsupported on the device. + String rejection = IPhoneBuilder.sceneManifestRejection( + "UIApplicationSceneManifest\nyes please"); + assertNotNull(rejection, "a manifest that is not a dictionary has to be refused"); + assertTrue(rejection.contains("is not a "), rejection); + } + + @Test + void sceneConfigurationsThatAreNotADictionaryAreRejected() { + String rejection = IPhoneBuilder.sceneManifestRejection(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n")); + assertNotNull(rejection, "configurations that are not a dictionary have to be refused"); + assertTrue(rejection.contains("UISceneConfigurations"), rejection); + } + + @Test + void aWindowRoleNamingAnotherDelegateIsRejected() { + String foreign = WINDOW_ROLE.replace("CodenameOne_GLSceneDelegate", "SomebodyElse"); + String rejection = IPhoneBuilder.sceneManifestRejection(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + foreign + + " \n")); + assertNotNull(rejection, "we cannot add ours beside theirs; UIKit reads the role"); + assertTrue(rejection.contains("CodenameOne_GLSceneDelegate"), rejection); + } + + @Test + void aWindowRoleAlreadyNamingOurDelegateIsAccepted() { + assertNull(IPhoneBuilder.sceneManifestRejection(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n")), + "an application that already wired our delegate has done nothing wrong"); + } + + @Test + void twoSceneManifestsAreRejectedRatherThanHalfHandled() { + // A property list resolves a duplicated key to the LAST value, while every + // lookup here answers with the first -- so validating and rewriting the first + // would leave the second in force on the device. + String plist = manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n") + + "\n" + + manifest(" UIApplicationSupportsMultipleScenes\n \n"); + // The first one is perfectly good, which is what makes this dangerous: every + // question below answers yes while the bundle ends up with the second. + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(plist)); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(plist)); + + String rejection = IPhoneBuilder.sceneManifestRejection(plist); + assertNotNull(rejection, "two manifests have to be refused, not half handled"); + assertTrue(rejection.contains("twice"), rejection); + } + + @Test + void twoSceneConfigurationsAreRejected() { + String rejection = IPhoneBuilder.sceneManifestRejection(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n" + + " UISceneConfigurations\n \n \n")); + assertNotNull(rejection, "two scene configuration dictionaries have to be refused"); + assertTrue(rejection.contains("UISceneConfigurations"), rejection); + } + + @Test + void twoWindowRolesAreRejected() { + String foreign = WINDOW_ROLE.replace("CodenameOne_GLSceneDelegate", "SomebodyElse"); + String rejection = IPhoneBuilder.sceneManifestRejection(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + foreign + + " \n")); + assertNotNull(rejection, "two window roles have to be refused"); + assertTrue(rejection.contains("UIWindowSceneSessionRoleApplication"), rejection); + } + + @Test + void twoSupportKeysAreRejected() { + // True first, false last. The rewrite sets the first and the bundle takes the + // last, so the build succeeds without the support a Window needs. + String plist = manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n"); + assertTrue(IPhoneBuilder.plistManifestSupportsMultipleScenes(plist), + "the first entry answers yes, which is why answering from it was unsafe"); + String rejection = IPhoneBuilder.sceneManifestRejection(plist); + assertNotNull(rejection, "two support keys have to be refused"); + assertTrue(rejection.contains("UIApplicationSupportsMultipleScenes"), rejection); + } + + @Test + void twoSceneDelegatesInOneConfigurationAreRejected() { + // Ours first, somebody else's last. The wiring check reads the first and the + // bundle takes the last, so no scene adopts a window. + String plist = manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + " UIWindowSceneSessionRoleApplication\n" + + " \n" + + " \n" + + " UISceneDelegateClassName\n" + + " CodenameOne_GLSceneDelegate\n" + + " UISceneDelegateClassName\n" + + " SomebodyElse\n" + + " \n" + + " \n" + + " \n"); + assertTrue(IPhoneBuilder.plistManifestWiresWindowScene(plist), + "the first delegate answers yes, which is why answering from it was unsafe"); + String rejection = IPhoneBuilder.sceneManifestRejection(plist); + assertNotNull(rejection, "two delegates in one configuration have to be refused"); + assertTrue(rejection.contains("UISceneDelegateClassName"), rejection); + } + + @Test + void oneOfEachReservedKeyIsStillAccepted() { + // The duplicate checks must not fire on a well formed manifest, which is the + // way a rejection rule usually goes wrong. + assertNull(IPhoneBuilder.sceneManifestRejection(manifest( + " UIApplicationSupportsMultipleScenes\n \n" + + " UISceneConfigurations\n \n" + + WINDOW_ROLE + + " \n"))); + } + + @Test + void aKeySetToFalseIsNotAcceptedAsTrue() { + assertFalse(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes", + "UIApplicationSupportsMultipleScenes"), + "the key is present but says false, which is the case that has to be caught"); + } + + @Test + void aKeySetToTrueIsAccepted() { + assertTrue(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes\n ", + "UIApplicationSupportsMultipleScenes")); + } + + @Test + void aLaterUnrelatedTrueDoesNotVouchForThisKey() { + // The value of a key is the element that follows it. A belonging to + // some other key further down says nothing about this one. + assertFalse(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes\n" + + "UISomethingElse", + "UIApplicationSupportsMultipleScenes"), + "a true further down the plist belongs to a different key"); + assertFalse(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes\n" + + "UISomethingElse", + "UIApplicationSupportsMultipleScenes"), + "another key intervenes, so this one has no true of its own"); + } + + @Test + void anAbsentKeyIsNotTrue() { + assertFalse(keyIsTrueAnywhere("UIOther", + "UIApplicationSupportsMultipleScenes")); + } + + @Test + void theWindowRoleHasToNameCodenameOnesDelegate() { + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate")); + assertFalse(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate"), + "the role is declared but handed to another delegate, so the secondary " + + "scenes a window needs are never adopted"); + } + + @Test + void whitespaceOnContainerTagsDoesNotRunTheRoleIntoTheNextOne() { + // "" and "" are the same elements as "" and + // "", and plistElementIndex already accepts them. Matching literal tags in + // the nesting scan found no closing tag at all, so the role fell back to the rest + // of the fragment -- and a later role's delegate then vouched for a window role + // that names somebody else. Which is the same hole the CarPlay case above closes, + // reopened by a space. + assertFalse(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate" + + "CPTemplateApplicationSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "a space in the array tags must not extend the window role into CarPlay's"); + } + + @Test + void whitespaceOnContainerTagsStillAcceptsAValidManifest() { + // The other direction: the same formatting on a correctly wired manifest has to + // keep passing, so the rule above cannot be satisfied by rejecting everything. + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneNesteda" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "valid XML formatting on container tags must not truncate the role"); + } + + @Test + void aSelfClosingContainerDoesNotOpenANestingLevel() { + // "" is an element but not a level. Counting it as one leaves the depth + // permanently ahead, the real closing tag is swallowed, and the role runs to the + // end of the fragment -- which would let a later role's delegate vouch for it. + assertFalse(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneEmptyThing" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate" + + "CPTemplateApplicationSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "a self-closing array must not extend the window role into the CarPlay role"); + } + + @Test + void aCloseTagNameThatMerelyStartsTheSameDoesNotClose() { + // "" starts with "". + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate")); + } + + @Test + void aKeyClosedWithWhitespaceIsStillThatKey() { + // "" closes a key exactly as "" does. Matching the literal reported + // the key as absent, and the two callers fail in opposite directions from there: + // the injection path appends a second UIApplicationSceneManifest beside the + // application's own, and the validation path rejects a build that is correctly + // configured. + assertTrue(IPhoneBuilder.plistDeclaresKey( + "UIApplicationSceneManifest", "UIApplicationSceneManifest"), + "a key whose closing tag carries whitespace is still declared"); + assertTrue(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes", + "UIApplicationSupportsMultipleScenes"), + "and its value is still readable, which is what plistKeyEnd decides"); + } + + @Test + void aWholeManifestSurvivesWhitespaceInEveryClosingTag() { + // The two structural parsers together, over a fragment where every closing tag + // is spaced. This is valid XML and a build using it must not be rejected. + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "spacing in the closing tags must not stop the delegate being found"); + } + + @Test + void aStringClosedWithWhitespaceStillHoldsItsValue() { + // "" ends a string exactly as "" does. Matching the literal + // made the delegate look absent and aborted a correctly configured build. + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "a delegate whose string tag closes with whitespace is still that " + + "delegate"); + } + + @Test + void anotherRolesDelegateDoesNotCountAsTheWindowRoles() { + // CarPlay declares its own role and its own delegate. Searching the whole + // fragment for the delegate name would let CarPlay's configuration vouch for a + // window role that names nobody. + assertFalse(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate" + + "CPTemplateApplicationSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "the matching delegate belongs to the CarPlay role, not the window role"); + } + + @Test + void theValidXmlSpellingsOfTrueAreAllAccepted() { + // and and are the same element. Rejecting the + // spaced form would fail a build over valid XML, which is worse than the + // misconfiguration this check exists to catch. + for (String spelling : new String[]{"", "", "", + "\n ", ""}) { + assertTrue(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes" + spelling, + "UIApplicationSupportsMultipleScenes"), + "should accept " + spelling); + } + } + + @Test + void theValidXmlSpellingsOfFalseAreAllRejected() { + for (String spelling : new String[]{"", "", ""}) { + assertFalse(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes" + spelling, + "UIApplicationSupportsMultipleScenes"), + "should reject " + spelling); + } + } + + @Test + void aManifestNamedOnlyInACommentIsNotADeclaredManifest() { + // These two checks fail the build, so matching a mention rather than a + // declaration would stop a build that was going to work -- and the builder + // would skip generating the manifest it should have generated. + assertFalse(IPhoneBuilder.plistDeclaresKey( + "", + "UIApplicationSceneManifest"), + "a key named inside a comment is not declared"); + assertFalse(IPhoneBuilder.plistDeclaresKey( + "CFBundleNameUIApplicationSceneManifest", + "UIApplicationSceneManifest"), + "a key quoted as a string value is not declared either"); + } + + @Test + void aRealDeclarationIsFoundEvenWhenACommentMentionsItFirst() { + assertTrue(IPhoneBuilder.plistDeclaresKey( + "\n" + + "UIApplicationSceneManifest", + "UIApplicationSceneManifest"), + "the commented mention must not hide the declaration that follows it"); + } + + @Test + void aCommentedKeyDoesNotVouchForItsValue() { + assertFalse(keyIsTrueAnywhere( + "", + "UIApplicationSupportsMultipleScenes"), + "a key and value that exist only inside a comment enable nothing"); + } + + @Test + void aCommentedOutValueIsNotTheKeysValue() { + // Found by re-reading the check rather than reported: stepping over a comment's + // opening and resuming at the next '<' lands inside the comment, so the value + // someone commented out would be read as the live one -- in the direction that + // silently enables multi-window on a manifest that disables it. + assertFalse(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes" + + "", + "UIApplicationSupportsMultipleScenes"), + "the commented-out true must not stand in for the real false"); + assertTrue(keyIsTrueAnywhere( + "UIApplicationSupportsMultipleScenes" + + "", + "UIApplicationSupportsMultipleScenes"), + "and the commented-out false must not hide the real true"); + } + + @Test + void aCommentedOutWindowRoleDoesNotVouchForTheLiveOne() { + // A commented-out Codename One configuration sitting above a live role that + // names another delegate: matching the mention would accept a manifest whose + // real scene configuration cannot adopt a window, and the failure then happens + // at run time. + assertFalse(wiresWindowSceneDelegateAnywhere( + "" + + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate"), + "the live role names another delegate, so the commented one must not " + + "answer for it"); + } + + @Test + void theClassNameHasToBeTheDelegateNotJustPresent() { + // Legal manifest: the configuration is *named* after our delegate while the + // delegate class is somebody else's. Matching the text anywhere in the role + // accepts it, and that build cannot adopt a secondary window. + assertFalse(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneConfigurationName" + + "CodenameOne_GLSceneDelegate" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate"), + "the class name appears, but not as the delegate"); + } + + @Test + void oneMatchingConfigurationAmongSeveralIsEnough() { + // A role may declare more than one configuration; windows can be adopted as + // long as one of them is ours. + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate")); + } + + @Test + void aStringMentioningSceneSessionRoleDoesNotEndTheRole() { + // Found by re-reading the guard rather than reported. The role's range was + // bounded by the *text* "SceneSessionRole", so a string value containing those + // words cut it short and hid a delegate that really is wired -- failing a build + // that was going to work, which is the expensive direction. + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "CFBundleName" + + "notes about SceneSessionRole handling" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "a string mentioning the words must not end the role's range"); + } + + @Test + void aKeyElementMayCarryWhitespaceAroundItsName() { + // Valid XML. Requiring the tags and the name to be contiguous reported the key + // absent, and the build then appended a second UIApplicationSceneManifest beside + // the application's own -- duplicate keys in an ordinary iOS build, not just a + // Catalyst one. + assertTrue(IPhoneBuilder.plistDeclaresKey( + "\n UIApplicationSceneManifest\n", + "UIApplicationSceneManifest"), + "a key element with whitespace around its name is still that key"); + assertTrue(keyIsTrueAnywhere( + " UIApplicationSupportsMultipleScenes ", + "UIApplicationSupportsMultipleScenes"), + "and its value is still readable"); + assertTrue(wiresWindowSceneDelegateAnywhere( + "\n UIWindowSceneSessionRoleApplication \n" + + " UISceneDelegateClassName " + + "CodenameOne_GLSceneDelegate"), + "and so is the delegate beneath it"); + } + + @Test + void aDifferentKeyIsStillNotAMatch() { + // The trim must not turn every key into every other key. + assertFalse(IPhoneBuilder.plistDeclaresKey( + "UIApplicationSceneManifestOther", + "UIApplicationSceneManifest")); + } + + @Test + void aKeyWhoseNameMerelyContainsSceneSessionRoleDoesNotEndTheRole() { + // A custom key declared inside the role, whose name happens to contain the + // words. Ending the role there stops the search before the delegate and rejects + // a manifest that is correctly wired. + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "MySceneSessionRoleMetadatax" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "a key inside the role must not be taken for the next role"); + } + + @Test + void theRoleStillEndsAtItsOwnArray() { + // The boundary still has to hold: a CarPlay role after this one, naming our + // delegate, must not vouch for a window role that names somebody else. + assertFalse(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate" + + "CPTemplateApplicationSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "the delegate in the CarPlay role is outside this role's array"); + } + + @Test + void nestedArraysInsideTheRoleDoNotEndItEarly() { + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "SomeLista" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "a nested array must not be mistaken for the role's closing array"); + } + + @Test + void elementsMayCarryAttributesOrTagWhitespace() { + // Named as an assumption a round ago and closed here rather than left to be + // found: these are elements, so "" and "" are the + // same elements as their bare spellings. + assertTrue(IPhoneBuilder.plistDeclaresKey( + "UIApplicationSceneManifest", + "UIApplicationSceneManifest")); + assertTrue(wiresWindowSceneDelegateAnywhere( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate" + + ""), + "an attribute on the string must not hide the delegate"); + } + + @Test + void anElementWhoseNameMerelyStartsTheSameIsNotAMatch() { + // The tolerance must not turn into . + assertFalse(IPhoneBuilder.plistDeclaresKey( + "UIApplicationSceneManifest", + "UIApplicationSceneManifest")); + } + + @Test + void anUnrelatedDictionaryDoesNotAnswerForTheManifest() { + // A custom dictionary that happens to carry the multiple-scenes key as true, in + // front of a manifest that sets it to false. Asking the whole fragment accepts + // the build; asking the manifest rejects it, which is the truth of what the + // bundle will say. + String plist = "MyCustomConfig" + + "UIApplicationSupportsMultipleScenes" + + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate" + + "UIApplicationSceneManifest" + + "UIApplicationSupportsMultipleScenes"; + String scope = IPhoneBuilder.plistManifestScope(plist); + // The whole fragment answers true here, which is the false accept being fixed. + assertTrue(keyIsTrueAnywhere(plist, + "UIApplicationSupportsMultipleScenes"), + "unscoped, the unrelated dictionary answers -- this is the bug"); + assertFalse(keyIsTrueAnywhere(scope, + "UIApplicationSupportsMultipleScenes"), + "the manifest says false, whatever the unrelated dictionary says"); + assertFalse(wiresWindowSceneDelegateAnywhere(scope), + "and the role in the unrelated dictionary is not the manifest's"); + } + + @Test + void theManifestsOwnConfigurationIsStillFound() { + String plist = "UIApplicationSceneManifest" + + "UIApplicationSupportsMultipleScenes" + + "UISceneConfigurations" + + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"; + String scope = IPhoneBuilder.plistManifestScope(plist); + assertTrue(keyIsTrueAnywhere(scope, + "UIApplicationSupportsMultipleScenes")); + assertTrue(wiresWindowSceneDelegateAnywhere(scope), + "a nested UISceneConfigurations dictionary must not put the role out of " + + "scope"); + } + + @Test + void aCommentedClosingTagDoesNotEndTheManifest() { + // A comment containing "" ahead of the live configuration. Treating it as + // the close truncates the range, so validation reads a prefix and rejects a + // build that is correctly configured. + String plist = "UIApplicationSceneManifest" + + "" + + "UIApplicationSupportsMultipleScenes" + + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"; + String scope = IPhoneBuilder.plistManifestScope(plist); + assertTrue(keyIsTrueAnywhere(scope, + "UIApplicationSupportsMultipleScenes"), + "the commented closing tag must not end the manifest"); + assertTrue(wiresWindowSceneDelegateAnywhere(scope), + "and the role after it is still inside the manifest"); + } + /** + * The unscoped questions this validation used to ask, kept here rather than in the + * builder because nothing there asks them any more: every real question is scoped + * to the dictionary UIKit reads it from. They stay because the cases below are + * really about the parser underneath -- whitespace in closing tags, comments, + * self-closing containers, nesting, attributes -- and asking it through the + * simplest possible wrapper is the clearest way to reach it. + * + *

Leaving them in the builder would have been worse than dead weight: an + * "is this key true anywhere in here" helper sitting beside the scoped ones is an + * invitation to reach for it again, and reaching for it is what produced three + * rounds of nesting defects.

+ */ + private static boolean keyIsTrueAnywhere(String plist, String key) { + int at = IPhoneBuilder.plistKeyIndex(plist, key); + if (at < 0) { + return false; + } + // The value of a key is the element that follows it, so read that element's + // name rather than matching a spelling: "", "" and + // "" are the same element. + return "true".equals( + IPhoneBuilder.nextElementName(plist, IPhoneBuilder.plistKeyEnd(plist, at))); + } + + /** The unscoped window-role question; see {@link #keyIsTrueAnywhere}. */ + private static boolean wiresWindowSceneDelegateAnywhere(String plist) { + int role = IPhoneBuilder.plistKeyIndex(plist, "UIWindowSceneSessionRoleApplication"); + if (role < 0) { + return false; + } + int afterKey = IPhoneBuilder.plistKeyEnd(plist, role); + // Bounded by this role's own value element, so a CarPlay configuration cannot + // answer for it and nothing declared inside the role can end it early. + int end = IPhoneBuilder.plistValueElementEnd(plist, afterKey); + if (end < 0) { + end = plist.length(); + } + // Bound to its key rather than found anywhere in the role: the class name can + // appear as some other live value while UISceneDelegateClassName names + // somebody else. + int at = IPhoneBuilder.plistKeyIndex(plist, "UISceneDelegateClassName", afterKey); + while (at >= 0 && at < end) { + if ("CodenameOne_GLSceneDelegate".equals(IPhoneBuilder.plistStringValueAfter( + plist, IPhoneBuilder.plistKeyEnd(plist, at)))) { + return true; + } + at = IPhoneBuilder.plistKeyIndex(plist, "UISceneDelegateClassName", + IPhoneBuilder.plistKeyEnd(plist, at)); + } + return false; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/components/InteractionDialogTest.java b/maven/core-unittests/src/test/java/com/codename1/components/InteractionDialogTest.java index 714dad93476..72950ee58a4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/InteractionDialogTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/InteractionDialogTest.java @@ -29,6 +29,7 @@ import com.codename1.ui.Container; import com.codename1.ui.Form; import com.codename1.ui.Label; +import com.codename1.ui.Window; import com.codename1.ui.geom.Rectangle; import com.codename1.ui.layouts.BorderLayout; import com.codename1.ui.layouts.GridLayout; @@ -608,4 +609,114 @@ private T getPrivateField(Object target, String name, Class type) throws field.setAccessible(true); return type.cast(field.get(target)); } + + /// Builds a shown window holding a single laid-out anchor button. + private Window windowWithAnchor(String title, Button anchor) { + Window w = new Window(title, new BorderLayout()); + w.setWindowSize(400, 300); + w.add(BorderLayout.CENTER, anchor); + w.show(); + w.asContainer().revalidate(); + return w; + } + + @FormTest + void popupHostInferredFromItsAnchorDoesNotOutliveThePopup() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + implementation.setCurrentForm(main); + + Button anchor = new Button("anchor"); + Window w = windowWithAnchor("secondary", anchor); + + InteractionDialog dialog = new InteractionDialog("popup"); + dialog.add(new Label("body")); + dialog.showPopupDialog(anchor); + assertSame(w, dialog.getTopLevelHost(), + "the popup is anchored inside the window, so the window is what it shows on"); + + dialog.dispose(); + assertNull(dialog.getTopLevelHost(), + "a host worked out from the anchor must not outlive the popup -- showing this " + + "dialog again would target a window the application may have disposed"); + // A window left registered outlives the manager the next test resets, and + // paintOpenWindows then runs every tick against a window with no manager. + w.dispose(); + } + + @FormTest + void popupDoesNotDiscardAHostTheApplicationSetItself() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + implementation.setCurrentForm(main); + + Button anchor = new Button("anchor"); + Window anchorWindow = windowWithAnchor("anchor window", anchor); + Window chosen = new Window("chosen", new BorderLayout()); + chosen.setWindowSize(300, 200); + chosen.show(); + + InteractionDialog dialog = new InteractionDialog("popup"); + dialog.add(new Label("body")); + dialog.setTopLevelHost(chosen); + dialog.showPopupDialog(anchor); + assertSame(anchorWindow, dialog.getTopLevelHost(), + "while the popup is up the anchor's own top level wins, since the rectangle is " + + "in that coordinate space"); + + dialog.dispose(); + assertSame(chosen, dialog.getTopLevelHost(), + "the host the application set explicitly comes back once the popup is gone"); + anchorWindow.dispose(); + chosen.dispose(); + } + + @FormTest + void aTimeoutSetBeforeShowingBindsToTheHostItIsShownOn() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + implementation.setCurrentForm(main); + + Button anchor = new Button("anchor"); + Window w = windowWithAnchor("secondary", anchor); + + InteractionDialog dialog = new InteractionDialog("popup"); + dialog.add(new Label("body")); + // Set before showing: at this point the dialog has no host, so binding the timer + // now picks the current form -- the wrong one for a popup that resolves to a + // window, and null in an application that has no form at all. + dialog.setTimeout(5000); + dialog.showPopupDialog(anchor); + + assertSame(w, dialog.getTopLevelHost(), + "the popup resolved to the window it was anchored in"); + assertEquals(0, pendingTimeoutOf(dialog), + "and the timeout was bound once that host was known, not before"); + + dialog.dispose(); + w.dispose(); + } + + /// The timeout still waiting for a host, via reflection. + private static long pendingTimeoutOf(InteractionDialog d) { + try { + java.lang.reflect.Field f = + InteractionDialog.class.getDeclaredField("pendingTimeout"); + f.setAccessible(true); + return f.getLong(d); + } catch (Exception err) { + throw new IllegalStateException(err); + } + } + + @FormTest + void aTimeoutSetWithNoFormAtAllDoesNotThrow() { + implementation.setMultiWindowSupported(true); + implementation.setCurrentForm(null); + InteractionDialog dialog = new InteractionDialog("popup"); + // Used to throw inside UITimer.schedule() because resolveHost() answered null. + dialog.setTimeout(5000); + assertEquals(5000L, pendingTimeoutOf(dialog), + "it is held until there is somewhere to bind it"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/components/MediaPlayerTest.java b/maven/core-unittests/src/test/java/com/codename1/components/MediaPlayerTest.java index 6ffe18b9000..2451acb9da2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/MediaPlayerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/MediaPlayerTest.java @@ -1,3 +1,25 @@ +/* + * 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.components; import com.codename1.junit.FormTest; @@ -57,6 +79,42 @@ void testHideNativeVideoControls() { assertTrue(player.isHideNativeVideoControls()); } + @FormTest + void theProgressTimerBindsToTheWindowThePlayerLivesIn() throws Exception { + com.codename1.testing.TestWindowManager wm = + implementation.setMultiWindowSupported(true); + assertNotNull(wm); + com.codename1.ui.Window w = new com.codename1.ui.Window("player", + new com.codename1.ui.layouts.BorderLayout()); + w.setWindowSize(400, 300); + MediaPlayer player = new MediaPlayer(new MockMedia()); + w.add(com.codename1.ui.layouts.BorderLayout.CENTER, player); + w.show(); + flushSerialCalls(); + + // checkProgressSlider() handed getComponentForm() to UITimer, which dereferences + // what it is bound to -- so starting playback threw on the event dispatch thread + // after the media had already begun. + java.lang.reflect.Method check = + MediaPlayer.class.getDeclaredMethod("checkProgressSlider"); + check.setAccessible(true); + check.invoke(player); + + java.lang.reflect.Field updater = + MediaPlayer.class.getDeclaredField("progressUpdater"); + updater.setAccessible(true); + Object timer = updater.get(player); + assertNotNull(timer, "playback should have created a progress timer"); + + java.lang.reflect.Field bound = + com.codename1.ui.util.UITimer.class.getDeclaredField("bound"); + bound.setAccessible(true); + assertSame(w, bound.get(timer), + "the progress timer must be bound to the window the player lives in"); + + w.dispose(); + } + private static class MockMedia implements Media { @Override public void play() {} diff --git a/maven/core-unittests/src/test/java/com/codename1/components/SwitchTest.java b/maven/core-unittests/src/test/java/com/codename1/components/SwitchTest.java index e4c589ae015..2f58e7e53ab 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/SwitchTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/SwitchTest.java @@ -1,3 +1,25 @@ +/* + * 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.components; import com.codename1.junit.FormTest; @@ -110,4 +132,49 @@ private Method getFireActionMethod() { throw new AssertionError(e); } } + + /// The animations a form currently has registered. + @SuppressWarnings("unchecked") + private static java.util.List registeredAnimations( + com.codename1.ui.Form f) throws Exception { + java.lang.reflect.Field fld = + com.codename1.ui.Form.class.getDeclaredField("animatableComponents"); + fld.setAccessible(true); + Object v = fld.get(f); + return v == null ? new java.util.ArrayList() + : (java.util.List) v; + } + + @FormTest + void aSwitchRemovedMidAnimationStillComesOffTheFormThatRegisteredIt() throws Exception { + com.codename1.ui.Form f = new com.codename1.ui.Form("host", + new com.codename1.ui.layouts.BorderLayout()); + Switch sw = new Switch(); + f.add(com.codename1.ui.layouts.BorderLayout.CENTER, sw); + f.show(); + + Method animateTo = Switch.class.getDeclaredMethod("animateTo", + boolean.class, int.class, int.class, int.class); + animateTo.setAccessible(true); + animateTo.invoke(sw, true, 0, 10, 10); + + assertEquals(1, registeredAnimations(f).size(), + "the switch registers its animation on the form hosting it"); + com.codename1.ui.animations.Animation a = registeredAnimations(f).get(0); + + // The switch goes away before the animation finishes. Resolving the top level + // again at that point answers null, or answers a different one after a + // reparent. + f.removeComponent(sw); + + long deadline = System.currentTimeMillis() + 3000; + while (!registeredAnimations(f).isEmpty() && System.currentTimeMillis() < deadline) { + a.animate(); + } + + assertTrue(registeredAnimations(f).isEmpty(), + "the animation has to come off the form that registered it: left on, that " + + "form never reports itself idle, so the event dispatch thread " + + "cannot sleep and the finished branch runs on every frame"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java b/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java index 3f188b7a5a0..041688a8dc4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java +++ b/maven/core-unittests/src/test/java/com/codename1/junit/UITestBase.java @@ -137,9 +137,25 @@ protected void tearDownDisplay() throws Exception { // short-circuits when Display.hasDragOccured() is true). resetDisplayBooleanField("dragOccured", false); resetDisplayBooleanField("pointerPressedAndNotReleasedOrDragged", false); + // The per-window half of the same state. Leaving it set would carry a held + // press from a window one test opened into the next test's assertions, which + // the singleton reset above cannot reach. + resetDisplayBooleanArrayField("selectionPressed"); resetDisplayIntField("dragPathLength", 0); } + private void resetDisplayBooleanArrayField(String name) { + try { + Field f = Display.class.getDeclaredField(name); + f.setAccessible(true); + boolean[] values = (boolean[]) f.get(display); + if (values != null) { + java.util.Arrays.fill(values, false); + } + } catch (Exception ignored) { + } + } + private void resetDisplayBooleanField(String name, boolean value) { try { Field f = Display.class.getDeclaredField(name); diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java index 35986cd8d3a..2b3a361badd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestCodenameOneImplementation.java @@ -825,6 +825,27 @@ public AsyncResource createMediaAsync(InputStream stream, String mimeType return mediaAsync; } + private TestWindowManager windowManager; + + /// Returns the fake window manager, or null when multi-window support is off. + /// Null is the capability query, so the default -- no manager -- is the + /// unsupported platform every mobile port reports. + @Override + public com.codename1.impl.WindowManager getWindowManager() { + return windowManager; + } + + /// Turns desktop windowing on or off for a test. + public TestWindowManager setMultiWindowSupported(boolean supported) { + windowManager = supported ? new TestWindowManager() : null; + return windowManager; + } + + /// Returns the fake window manager as its concrete type, for assertions. + public TestWindowManager getTestWindowManager() { + return windowManager; + } + @Override public Object createNativeBrowserWindow(String startURL) { return nativeBrowserWindow; @@ -1247,6 +1268,7 @@ public void clearFileSystem() { } public void reset() { + windowManager = null; desktop = false; nativeTitle = false; desktopTitleBarMode = "toolbar"; @@ -5311,4 +5333,22 @@ public String toString() { '}'; } } + + /// Delivers a pointer press into one of the additional native windows, the way a + /// desktop port would. The port entry points are protected, so a test reaches them + /// through here rather than by going straight to Display -- which would skip the + /// drag activation filter that lives in the implementation. + public void windowPointerPressedForTest(int windowId, int x, int y) { + windowPointerPressed(windowId, x, y); + } + + /// Delivers a pointer drag into one of the additional native windows. + public void windowPointerDraggedForTest(int windowId, int x, int y) { + windowPointerDragged(windowId, x, y); + } + + /// Delivers a pointer release into one of the additional native windows. + public void windowPointerReleasedForTest(int windowId, int x, int y) { + windowPointerReleased(windowId, x, y); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/testing/TestWindowManager.java b/maven/core-unittests/src/test/java/com/codename1/testing/TestWindowManager.java new file mode 100644 index 00000000000..129e1388f45 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestWindowManager.java @@ -0,0 +1,692 @@ +/* + * 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.testing; + +import com.codename1.impl.WindowManager; +import com.codename1.ui.Display; +import com.codename1.ui.Image; + +import java.util.ArrayList; +import java.util.List; + +/** + * A window manager with no operating system behind it, so the desktop windowing + * API can be driven from a headless unit test. + * + *

The monitor table is scriptable, which is the point: it lets a test describe a + * three monitor desktop at mixed scale factors and assert that a window picks up the + * characteristics of the one it sits on, without needing a second physical + * display.

+ * + * @author Shai Almog + */ +public class TestWindowManager extends WindowManager { + + /** One fake native window. */ + public static final class FakeWindow { + private int windowId; + private String title; + private int x; + private int y; + private int width; + private int height; + private boolean decorated; + private boolean resizable; + private boolean visible; + private int restoreCount; + private boolean disposed; + private boolean modal; + private boolean alwaysOnTop; + private boolean focusRequested; + private int monitor; + private int paintCount; + private int modalCalls; + private boolean modalApplicationWide; + private FakeWindow modalOwner; + private boolean utility; + private boolean inputEnabled = true; + private FakeWindow owner; + private boolean positionSet; + private boolean ownedByMainWindow; + private int minimumWidth; + private int minimumHeight; + + public int getWindowId() { + return windowId; + } + + public String getTitle() { + return title; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + /// How many times the port was asked to restore this window, which is the + /// only call that clears a native window's iconic state. + public int getRestoreCount() { + return restoreCount; + } + + public boolean isVisible() { + return visible; + } + + public boolean isDisposed() { + return disposed; + } + + /** + * How many times setModal() was called on this window. An unbalanced count is + * exactly the bug that left a native modal blocking after its window closed, + * so the tests assert on the number of calls rather than only the final state. + */ + public int getModalCalls() { + return modalCalls; + } + + /** True when the last modal call declared application wide scope. */ + public boolean isModalApplicationWide() { + return modalApplicationWide; + } + + /** The window the last modal call named as blocked, or null for the main one. */ + public FakeWindow getModalOwner() { + return modalOwner; + } + + /** True when the framework asked for a tool/palette window. */ + public boolean isUtility() { + return utility; + } + + /** Whether the framework currently allows native input to this window. */ + public boolean isInputEnabled() { + return inputEnabled; + } + + /** The owning window handed to createWindow(), or null when there was none. */ + public FakeWindow getOwner() { + return owner; + } + + /** True when the application chose the window's position. */ + public boolean isPositionSet() { + return positionSet; + } + + /** True when the owner is the application's main window, which has no peer. */ + public boolean isOwnedByMainWindow() { + return ownedByMainWindow; + } + + /** The minimum size the framework forwarded, or zero when none was set. */ + public int getMinimumWidth() { + return minimumWidth; + } + + public int getMinimumHeight() { + return minimumHeight; + } + + public boolean isModal() { + return modal; + } + + public boolean isAlwaysOnTop() { + return alwaysOnTop; + } + + public boolean isFocusRequested() { + return focusRequested; + } + + public boolean isDecorated() { + return decorated; + } + + public boolean isResizable() { + return resizable; + } + + /** Number of times this window's surface has been flushed. */ + public int getPaintCount() { + return paintCount; + } + + /** Which monitor this window currently sits on. */ + public void setMonitor(int monitor) { + this.monitor = monitor; + } + + public int getMonitor() { + return monitor; + } + } + + /** One fake monitor. */ + public static final class FakeMonitor { + private final int[] bounds; + private final int[] workArea; + private final double scale; + private final int dpi; + private final String name; + + public FakeMonitor(int x, int y, int w, int h, double scale, int dpi, String name) { + this.bounds = new int[]{x, y, w, h}; + this.workArea = new int[]{x, y, w, h}; + this.scale = scale; + this.dpi = dpi; + this.name = name; + } + + /** Reserves space at the bottom, standing in for a task bar or dock. */ + public FakeMonitor withReservedBottom(int px) { + workArea[3] = bounds[3] - px; + return this; + } + + public double getScale() { + return scale; + } + } + + private final List windows = new ArrayList(); + private final List monitors = new ArrayList(); + private int primaryMonitor; + + public TestWindowManager() { + monitors.add(new FakeMonitor(0, 0, 1440, 900, 1.0, 96, "primary")); + } + + /** Replaces the monitor table. */ + public void setMonitors(List replacement) { + monitors.clear(); + monitors.addAll(replacement); + } + + /// Which monitor the application's main window sits on. Scriptable because a + /// `Form` has no window peer, so this is the only way to describe a main window + /// that has been dragged to a second display. + private int mainWindowMonitor; + + public void setMainWindowMonitor(int index) { + mainWindowMonitor = index; + } + + @Override + public int getMonitorForMainWindow() { + return mainWindowMonitor; + } + + public void setPrimaryMonitor(int index) { + primaryMonitor = index; + } + + /** Every window created through this manager, including disposed ones. */ + public List getWindows() { + return new ArrayList(windows); + } + + /** The most recently created window, which is what most tests assert against. */ + public FakeWindow getLastWindow() { + if (windows.isEmpty()) { + return null; + } + return windows.get(windows.size() - 1); + } + + public FakeWindow findWindow(int windowId) { + for (FakeWindow w : windows) { + if (w.windowId == windowId) { + return w; + } + } + return null; + } + + /** + * Makes createWindow() answer null, which is what every port does once its fixed + * native window table is exhausted or the platform refuses. + */ + public void setCreateFails(boolean createFails) { + this.createFails = createFails; + } + + private boolean createFails; + + /// The native image the next capture() should hand back, or null to model a port + /// that cannot read its own window back. Ports differ here -- JavaSE, Catalyst and + /// Linux read the window's real raster, while a port with no readback leaves + /// Window.capture() to re-render -- and both paths need covering. + private Object captureResult; + + /// Counts capture() calls, so a test can tell "the port was asked and declined" + /// from "the port was never asked at all". + private int captureCalls; + + public void setCaptureResult(Object nativeImage) { + captureResult = nativeImage; + } + + public int getCaptureCalls() { + return captureCalls; + } + + /// The commands last published for each window, so a test can tell "the port was + /// told about this command" from "it was only added to a private list". + private final java.util.Map> + publishedCommands = + new java.util.HashMap>(); + + @Override + public void setCommands(Object peer, com.codename1.ui.Command[] commands) { + java.util.List copy = + new java.util.ArrayList(); + if (commands != null) { + for (com.codename1.ui.Command c : commands) { + copy.add(c); + } + } + publishedCommands.put(peer, copy); + } + + /// The commands last published for the given window peer, never null. + public java.util.List getPublishedCommands(Object peer) { + java.util.List c = publishedCommands.get(peer); + return c == null + ? new java.util.ArrayList() + : new java.util.ArrayList(c); + } + + @Override + public Object capture(Object peer) { + captureCalls++; + return win(peer) == null ? null : captureResult; + } + + public void reset() { + mainWindowBounds = null; + publishedCommands.clear(); + captureResult = null; + captureCalls = 0; + createFails = false; + mainWindowInputEnabled = true; + windows.clear(); + monitors.clear(); + monitors.add(new FakeMonitor(0, 0, 1440, 900, 1.0, 96, "primary")); + primaryMonitor = 0; + mainWindowMonitor = 0; + } + + private static FakeWindow win(Object peer) { + return peer instanceof FakeWindow ? (FakeWindow) peer : null; + } + + // ---- lifecycle ----------------------------------------------------------- + + @Override + public Object createWindow(int windowId, String title, int x, int y, int width, int height, + boolean decorated, boolean resizable, Object parentPeer, boolean positionSet, + boolean ownedByMainWindow) { + if (createFails) { + return null; + } + FakeWindow w = new FakeWindow(); + w.owner = win(parentPeer); + w.positionSet = positionSet; + w.ownedByMainWindow = ownedByMainWindow; + w.windowId = windowId; + w.title = title; + w.x = x; + w.y = y; + w.width = width; + w.height = height; + w.decorated = decorated; + w.resizable = resizable; + windows.add(w); + return w; + } + + @Override + public void show(Object peer) { + FakeWindow w = win(peer); + if (w != null) { + w.visible = true; + } + } + + @Override + public void restore(Object peer) { + FakeWindow w = win(peer); + if (w != null) { + w.restoreCount++; + } + } + + @Override + public void hide(Object peer) { + FakeWindow w = win(peer); + if (w != null) { + w.visible = false; + } + } + + @Override + public void dispose(Object peer) { + FakeWindow w = win(peer); + if (w != null) { + w.visible = false; + w.disposed = true; + } + } + + // ---- attributes ----------------------------------------------------------- + + @Override + public void setTitle(Object peer, String title) { + recordThread("setTitle"); + FakeWindow w = win(peer); + if (w != null) { + w.title = title; + } + } + + @Override + public void setBounds(Object peer, int x, int y, int width, int height) { + FakeWindow w = win(peer); + if (w != null) { + w.x = x; + w.y = y; + w.width = width; + w.height = height; + } + } + + @Override + public int[] getBounds(Object peer, int[] out) { + FakeWindow w = win(peer); + if (w != null) { + out[0] = w.x; + out[1] = w.y; + out[2] = w.width; + out[3] = w.height; + } + return out; + } + + @Override + public int getWidth(Object peer) { + FakeWindow w = win(peer); + return w == null ? 0 : w.width; + } + + @Override + public int getHeight(Object peer) { + FakeWindow w = win(peer); + return w == null ? 0 : w.height; + } + + @Override + public void setResizable(Object peer, boolean resizable) { + recordThread("setResizable"); + FakeWindow w = win(peer); + if (w != null) { + w.resizable = resizable; + } + } + + @Override + public void setDecorated(Object peer, boolean decorated) { + recordThread("setDecorated"); + FakeWindow w = win(peer); + if (w != null) { + w.decorated = decorated; + } + } + + @Override + public void setAlwaysOnTop(Object peer, boolean alwaysOnTop) { + FakeWindow w = win(peer); + if (w != null) { + w.alwaysOnTop = alwaysOnTop; + } + } + + @Override + public void setModal(Object peer, boolean modal, boolean applicationWide, Object ownerPeer) { + recordThread("setModal"); + FakeWindow w = win(peer); + if (w != null) { + w.modal = modal; + w.modalCalls++; + w.modalApplicationWide = applicationWide; + w.modalOwner = win(ownerPeer); + } + } + + @Override + public void setInputEnabled(Object peer, boolean enabled) { + FakeWindow w = win(peer); + if (w != null) { + w.inputEnabled = enabled; + } + } + + @Override + public void setMainWindowInputEnabled(boolean enabled) { + mainWindowInputEnabled = enabled; + } + + /** Whether the framework currently allows native input to the main window. */ + public boolean isMainWindowInputEnabled() { + return mainWindowInputEnabled; + } + + private boolean mainWindowInputEnabled = true; + + @Override + public void setUtilityWindow(Object peer, boolean utility) { + recordThread("setUtilityWindow"); + FakeWindow w = win(peer); + if (w != null) { + w.utility = utility; + } + } + + @Override + public void setMinimumSize(Object peer, int width, int height) { + recordThread("setMinimumSize"); + FakeWindow w = win(peer); + if (w != null) { + w.minimumWidth = width; + w.minimumHeight = height; + } + } + + @Override + public void setIcon(Object peer, Image icon) { + recordThread("setIcon"); + } + + /// Names of window-manager calls that arrived on a thread other than the event + /// dispatch thread. + /// + /// The SPI is defined on the EDT and the ports take that literally -- the Windows + /// one resolves a peer to a slot index on whatever thread calls it -- so "which + /// thread called this" is the property worth asserting, not whether the call + /// eventually happened. + private final java.util.List offEdtCalls = + java.util.Collections.synchronizedList(new java.util.ArrayList()); + + /// Window-manager calls seen on a background thread, in order. + public java.util.List getOffEdtCalls() { + return new java.util.ArrayList(offEdtCalls); + } + + private void recordThread(String call) { + if (!com.codename1.ui.Display.getInstance().isEdt()) { + offEdtCalls.add(call); + } + } + + @Override + public void minimize(Object peer) { + recordThread("minimize"); + } + + @Override + public void toggleMaximize(Object peer) { + recordThread("toggleMaximize"); + } + + + @Override + public void requestFocus(Object peer) { + recordThread("requestFocus"); + FakeWindow w = win(peer); + if (w != null) { + w.focusRequested = true; + } + } + + // ---- rendering --------------------------------------------------------------- + + @Override + public Object getNativeGraphics(Object peer) { + // A real TestGraphics rather than a marker object, sized to the window: the + // paint pass sets a clip on it, so anything else makes flushing the event + // dispatch thread with a window open blow up. + FakeWindow w = win(peer); + int width = w == null ? 1 : Math.max(1, w.width); + int height = w == null ? 1 : Math.max(1, w.height); + return new TestCodenameOneImplementation.TestGraphics(width, height); + } + + @Override + public void flushGraphics(Object peer, int x, int y, int width, int height) { + FakeWindow w = win(peer); + if (w != null) { + w.paintCount++; + } + } + + // ---- monitors ------------------------------------------------------------------ + + @Override + public int getMonitorCount() { + return monitors.size(); + } + + /// Scriptable main-window bounds, so a test can place the application's main + /// native window somewhere other than the monitor's work area. + private int[] mainWindowBounds; + + public void setMainWindowBounds(int x, int y, int w, int h) { + mainWindowBounds = new int[]{x, y, w, h}; + } + + @Override + public int[] getMainWindowBounds(int[] out) { + if (mainWindowBounds == null || out == null || out.length < 4) { + return null; + } + System.arraycopy(mainWindowBounds, 0, out, 0, 4); + return out; + } + + @Override + public int[] getMonitorBounds(int monitor, int[] out) { + int[] b = monitors.get(clamp(monitor)).bounds; + System.arraycopy(b, 0, out, 0, 4); + return out; + } + + @Override + public int[] getMonitorWorkArea(int monitor, int[] out) { + int[] b = monitors.get(clamp(monitor)).workArea; + System.arraycopy(b, 0, out, 0, 4); + return out; + } + + @Override + public int getMonitorDensity(int monitor) { + int dpi = getMonitorDotsPerInch(monitor); + if (dpi >= 280) { + return Display.DENSITY_VERY_HIGH; + } + if (dpi >= 200) { + return Display.DENSITY_HIGH; + } + if (dpi >= 140) { + return Display.DENSITY_MEDIUM; + } + return Display.DENSITY_LOW; + } + + @Override + public double getMonitorScale(int monitor) { + return monitors.get(clamp(monitor)).scale; + } + + @Override + public int getMonitorDotsPerInch(int monitor) { + return monitors.get(clamp(monitor)).dpi; + } + + @Override + public String getMonitorName(int monitor) { + return monitors.get(clamp(monitor)).name; + } + + @Override + public int getPrimaryMonitor() { + return primaryMonitor; + } + + @Override + public int getMonitorForWindow(Object peer) { + FakeWindow w = win(peer); + return w == null ? primaryMonitor : w.monitor; + } + + private int clamp(int monitor) { + if (monitor < 0 || monitor >= monitors.size()) { + return 0; + } + return monitor; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/DesktopMonitorTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/DesktopMonitorTest.java new file mode 100644 index 00000000000..bfa62cacccf --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/DesktopMonitorTest.java @@ -0,0 +1,144 @@ +/* + * 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.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.testing.TestWindowManager; +import com.codename1.ui.geom.Rectangle; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DesktopMonitorTest extends UITestBase { + + /// A laptop panel at 2x with a dock reserved at the bottom, plus a conventional + /// external display placed to its right. The mixed scale is the point: it is what + /// makes per-monitor density observable. + private TestWindowManager twoMonitors() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + List monitors = + new ArrayList(Arrays.asList( + new TestWindowManager.FakeMonitor(0, 0, 1440, 900, 2.0, 220, "laptop") + .withReservedBottom(60), + new TestWindowManager.FakeMonitor(1440, 0, 1920, 1080, 1.0, 96, "external"))); + wm.setMonitors(monitors); + return wm; + } + + @FormTest + void monitorsAreEnumeratedWithTheirCharacteristics() { + twoMonitors(); + Monitor[] all = Desktop.getInstance().getMonitors(); + assertEquals(2, all.length); + + assertEquals("laptop", all[0].getName()); + assertTrue(all[0].isPrimary()); + assertEquals(2.0, all[0].getScale(), 0.001); + assertEquals(220, all[0].getDotsPerInch()); + + assertEquals("external", all[1].getName()); + assertFalse(all[1].isPrimary()); + assertEquals(1.0, all[1].getScale(), 0.001); + } + + @FormTest + void workAreaExcludesReservedSpace() { + twoMonitors(); + Monitor laptop = Desktop.getInstance().getMonitors()[0]; + assertEquals(900, laptop.getBounds().getHeight()); + assertEquals(840, laptop.getWorkArea().getHeight(), + "The dock's 60px must be excluded from the usable area"); + } + + @FormTest + void monitorAtResolvesByDesktopCoordinate() { + twoMonitors(); + assertEquals("laptop", Desktop.getInstance().getMonitorAt(100, 100).getName()); + assertEquals("external", Desktop.getInstance().getMonitorAt(1500, 100).getName(), + "A coordinate past the primary's width belongs to the display beside it"); + } + + @FormTest + void desktopBoundsSpanEveryMonitor() { + twoMonitors(); + Rectangle all = Desktop.getInstance().getDesktopBounds(); + assertEquals(0, all.getX()); + assertEquals(0, all.getY()); + assertEquals(3360, all.getWidth(), "1440 + 1920"); + assertEquals(1080, all.getHeight(), "the taller of the two"); + } + + @FormTest + void aWindowReportsItsOwnMonitorsDensityNotTheGlobalOne() { + TestWindowManager wm = twoMonitors(); + Window w = new Window("scaled"); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + + peer.setMonitor(0); + w.monitorChanged(); + assertEquals(2.0, w.getScale(), 0.001); + assertEquals("laptop", w.getMonitor().getName()); + int hiDensity = w.getDensity(); + + // drag it onto the conventional display + peer.setMonitor(1); + w.monitorChanged(); + assertEquals(1.0, w.getScale(), 0.001); + assertEquals("external", w.getMonitor().getName()); + assertTrue(w.getDensity() < hiDensity, + "Moving to a lower resolution display must lower the reported density"); + w.dispose(); + } + + @FormTest + void movingToADifferentScaleInvalidatesTheLayout() { + TestWindowManager wm = twoMonitors(); + Window w = new Window("relayout"); + w.show(); + w.shouldCalcPreferredSize = false; + + wm.getLastWindow().setMonitor(1); + w.monitorChanged(); + + assertTrue(w.shouldCalcPreferredSize, + "A scale change must mark preferred sizes stale, or the window renders " + + "at the size it was measured for on the previous display"); + w.dispose(); + } + + @FormTest + void windowingOffStillReportsOneUsableMonitor() { + assertFalse(Desktop.isSupported()); + Monitor[] all = Desktop.getInstance().getMonitors(); + assertEquals(1, all.length); + assertTrue(all[0].isPrimary()); + assertEquals(1.0, all[0].getScale(), 0.001); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/PointerMetadataTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/PointerMetadataTest.java index b6c303b39b0..b0233e17c10 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/PointerMetadataTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/PointerMetadataTest.java @@ -102,4 +102,161 @@ void convenienceSettersUpdateIndividualFields() { void getCurrentPointerEventNeverNull() { assertNotNull(display.getCurrentPointerEvent()); } + + @Test + void eachQueuedPointerEventKeepsTheMetadataThatArrivedWithIt() { + // A port reports pointer metadata into a single mutable record immediately + // before queueing the event, but the PointerEvent is not built until the event + // is dispatched. A port that drains a burst -- the Win32 pump translates queued + // messages before returning, and the GTK drain does the same -- therefore + // overwrote that record several times before any of the burst was dispatched, + // and every event came out carrying the *last* one's button and device type. + // A secondary window's right click or pen event read as a left mouse click, + // which loses a context menu or a stylus callback. + Form f = new Form("burst"); + final java.util.List buttons = new java.util.ArrayList(); + final java.util.List types = new java.util.ArrayList(); + f.addPointerPressedListener(new com.codename1.ui.events.ActionListener() { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + buttons.add(Integer.valueOf(display.getPointerButton())); + types.add(Integer.valueOf(display.getPointerType())); + } + }); + f.show(); + flushSerialCalls(); + + // Two presses queued back to back, exactly as a drained burst arrives, with no + // dispatch in between. + implementation.setPointerEventMetadata(PointerEvent.BUTTON_SECONDARY, + PointerEvent.MASK_SECONDARY, PointerEvent.TYPE_STYLUS, 0.5f, 0, 0, 0, 0, false); + display.pointerPressed(new int[]{10}, new int[]{10}); + implementation.setPointerEventMetadata(PointerEvent.BUTTON_PRIMARY, + PointerEvent.MASK_PRIMARY, PointerEvent.TYPE_MOUSE, 1f, 0, 0, 0, 0, false); + display.pointerPressed(new int[]{20}, new int[]{20}); + + flushSerialCalls(); + + assertEquals(2, buttons.size(), "both queued presses should have dispatched"); + assertEquals(PointerEvent.BUTTON_SECONDARY, buttons.get(0).intValue(), + "the first press must keep its own button; taking the record as it " + + "stands at dispatch time gives it the second press's"); + assertEquals(PointerEvent.TYPE_STYLUS, types.get(0).intValue(), + "and its own device type"); + assertEquals(PointerEvent.BUTTON_PRIMARY, buttons.get(1).intValue()); + assertEquals(PointerEvent.TYPE_MOUSE, types.get(1).intValue()); + + implementation.resetPointerEventMetadata(); + } + + @Test + void coalescedDragsDoNotConsumeSnapshotSlotsFromOtherQueuedEvents() { + // Coalescing keeps ONE queued drag packet however many updates arrive. Taking a + // fresh snapshot slot per update therefore runs the ring forward without bound + // while the number of live packets stays tiny -- and with the event dispatch + // thread blocked the ring wraps onto slots belonging to packets that are still + // queued. The press below is the victim: it dispatches with the drag's button. + Form f = new Form("coalesce"); + final java.util.List pressButtons = new java.util.ArrayList(); + f.addPointerPressedListener(new com.codename1.ui.events.ActionListener() { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + pressButtons.add(Integer.valueOf(display.getPointerButton())); + } + }); + f.show(); + flushSerialCalls(); + + // Queued from the event dispatch thread itself, so nothing can be dispatched + // part way through. Queueing from the test thread let the event dispatch + // thread drain the press before the drags arrived, and the test then passed + // against the un-fixed code because the ring never had a chance to wrap onto a + // slot that was still live. + display.callSeriallyAndWait(new Runnable() { + @Override + public void run() { + implementation.setPointerEventMetadata(PointerEvent.BUTTON_SECONDARY, + PointerEvent.MASK_SECONDARY, PointerEvent.TYPE_STYLUS, + 0.5f, 0, 0, 0, 0, false); + display.pointerPressed(new int[]{10}, new int[]{10}); + + // More updates than the ring has slots, all collapsing into one packet. + implementation.setPointerEventMetadata(PointerEvent.BUTTON_PRIMARY, + PointerEvent.MASK_PRIMARY, PointerEvent.TYPE_MOUSE, + 1f, 0, 0, 0, 0, false); + for (int iter = 0; iter < 600; iter++) { + display.pointerDragged(new int[]{11 + iter}, new int[]{11}); + } + } + }); + + flushSerialCalls(); + + assertEquals(1, pressButtons.size(), "the press should have dispatched once"); + assertEquals(PointerEvent.BUTTON_SECONDARY, pressButtons.get(0).intValue(), + "a coalesced drag must reuse its own snapshot slot; taking a new one " + + "per update wraps the ring onto the still-queued press"); + + implementation.resetPointerEventMetadata(); + } + + @Test + void theSnapshotRingCoversBothLiveEventStacks() throws Exception { + // Display double buffers the input event stack: the event dispatch thread swaps + // a full batch out and dispatches it while the native input thread fills the + // other, so both are live at once. The ring has to cover both, or it wraps onto + // packets that have not been dispatched yet. + // + // Asserted against the arithmetic rather than against the number, so that + // growing the event stack without growing the ring fails here instead of + // producing a rare wrong-button dispatch under load. + java.lang.reflect.Field stack = + Display.class.getDeclaredField("inputEventStack"); + stack.setAccessible(true); + int stackInts = ((int[]) stack.get(display)).length; + + java.lang.reflect.Field slots = com.codename1.impl.CodenameOneImplementation.class + .getDeclaredField("POINTER_METADATA_SLOTS"); + slots.setAccessible(true); + int ring = slots.getInt(null); + + // Smallest pointer packet is three ints: the type word, x and y. + int maxLivePackets = (stackInts / 3) * 2; + assertTrue(ring >= maxLivePackets, + "the metadata ring (" + ring + " slots) must cover both live event " + + "stacks (" + maxLivePackets + " packets); a smaller ring wraps " + + "onto packets that are still queued"); + } + + @Test + void dispatchingAnEventDoesNotDisturbWhatThePortHasStaged() { + // The restore that gives a dispatched packet its own metadata used to write + // back into the same fields a port fills in before queueing. Those are written + // on the port's thread and read when the packet is queued, so the restore could + // land between a port's setPointerEventMetadata and the capture that follows + // it, handing the *next* packet the previous event's button. It showed up as + // the reverse of the bug the snapshot was added to fix, and only under CI + // timing. + implementation.setPointerEventMetadata(PointerEvent.BUTTON_SECONDARY, + PointerEvent.MASK_SECONDARY, PointerEvent.TYPE_STYLUS, 0.5f, 0, 0, 0, 0, false); + int first = implementation.capturePointerEventMetadata(); + + // A port stages the next event's metadata... + implementation.setPointerEventMetadata(PointerEvent.BUTTON_PRIMARY, + PointerEvent.MASK_PRIMARY, PointerEvent.TYPE_MOUSE, 1f, 0, 0, 0, 0, false); + + // ...and the event dispatch thread restores the earlier packet's snapshot in + // between, which is the interleaving that used to corrupt the staging. + implementation.selectPointerEventMetadata(first); + + // The staged metadata must be untouched, so the packet queued next still + // carries what the port asked for. + int second = implementation.capturePointerEventMetadata(); + implementation.selectPointerEventMetadata(second); + assertEquals(PointerEvent.BUTTON_PRIMARY, display.getPointerButton(), + "a dispatch must not overwrite the metadata a port has staged"); + assertEquals(PointerEvent.TYPE_MOUSE, display.getPointerType()); + + implementation.resetPointerEventMetadata(); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/SheetSwipeToDismissTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/SheetSwipeToDismissTest.java index 7353c2101bf..0d59b300446 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/SheetSwipeToDismissTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/SheetSwipeToDismissTest.java @@ -1,3 +1,25 @@ +/* + * 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.ui; import com.codename1.junit.FormTest; @@ -332,18 +354,24 @@ private void awaitAnimationsFlushingPaintQueue(Form form) throws Exception { } private void clearPaintQueue() throws Exception { + // The dirty queue lives on the implementation's main PaintSurface -- each + // surface (the main one, and one per native desktop window) owns its own. Class implClass = Class.forName("com.codename1.impl.CodenameOneImplementation"); - Field fillField = implClass.getDeclaredField("paintQueueFill"); - Field queueField = implClass.getDeclaredField("paintQueue"); + Field mainSurfaceField = implClass.getDeclaredField("mainSurface"); + mainSurfaceField.setAccessible(true); + Object surface = mainSurfaceField.get(implementation); + Class surfaceClass = surface.getClass(); + Field fillField = surfaceClass.getDeclaredField("paintQueueFill"); + Field queueField = surfaceClass.getDeclaredField("paintQueue"); fillField.setAccessible(true); queueField.setAccessible(true); synchronized (implementation) { - Object queue = queueField.get(implementation); + Object queue = queueField.get(surface); int len = java.lang.reflect.Array.getLength(queue); for (int i = 0; i < len; i++) { java.lang.reflect.Array.set(queue, i, null); } - fillField.setInt(implementation, 0); + fillField.setInt(surface, 0); } } @@ -358,12 +386,16 @@ private void assertPaintScheduledOrAnimating(Form form, String message) throws E return; } Class implClass = Class.forName("com.codename1.impl.CodenameOneImplementation"); - Field fillField = implClass.getDeclaredField("paintQueueFill"); - Field queueField = implClass.getDeclaredField("paintQueue"); + Field mainSurfaceField = implClass.getDeclaredField("mainSurface"); + mainSurfaceField.setAccessible(true); + Object surface = mainSurfaceField.get(implementation); + Class surfaceClass = surface.getClass(); + Field fillField = surfaceClass.getDeclaredField("paintQueueFill"); + Field queueField = surfaceClass.getDeclaredField("paintQueue"); fillField.setAccessible(true); queueField.setAccessible(true); - int fill = fillField.getInt(implementation); - Object queue = queueField.get(implementation); + int fill = fillField.getInt(surface); + Object queue = queueField.get(surface); Component content = form.getContentPane(); boolean covered = false; diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/WindowSelectionStateTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/WindowSelectionStateTest.java new file mode 100644 index 00000000000..8ce4d43d5b5 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/WindowSelectionStateTest.java @@ -0,0 +1,151 @@ +/* + * 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.ui; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.testing.TestWindowManager; +import com.codename1.ui.layouts.BorderLayout; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Guards the pressed-selection state against leaking between windows. + * + *

In pureTouch mode a component shows its selection only while a contact is down + * on it, which {@code Display.shouldRenderSelection(Component)} answers. That used to + * read one singleton flag and one pair of global pointer coordinates, so with a + * contact down in two windows whichever window's packet ran last owned both: a + * release in one window dropped the other's still-held selection, and a component in + * one window ended up tested against the other's coordinates -- which are window + * relative, so the two are not even the same origin.

+ * + * @author Shai Almog + */ +class WindowSelectionStateTest extends UITestBase { + + /// Every window this test opened, so none outlives it. A window left showing + /// keeps work queued on the event dispatch thread, and the next test's setup then + /// times out waiting for a queue that never drains -- which is a failure in a + /// test that has nothing to do with windows. + private final java.util.List opened = new java.util.ArrayList(); + + private void disposeAll() { + for (int iter = 0; iter < opened.size(); iter++) { + opened.get(iter).dispose(); + } + opened.clear(); + DisplayTest.flushEdt(); + } + + /// A shown window with one component filling it, and its real laid-out geometry -- + /// set coordinates by hand and the layout pass just overwrites them. + private Component content(Window w) { + Label l = new Label("x"); + w.add(BorderLayout.CENTER, l); + w.show(); + opened.add(w); + DisplayTest.flushEdt(); + return l; + } + + /// A point inside the component, in its own window's coordinates. + private int[] insideX(Component c) { + return new int[] { c.getAbsoluteX() + c.getWidth() / 2 }; + } + + private int[] insideY(Component c) { + return new int[] { c.getAbsoluteY() + c.getHeight() / 2 }; + } + + @FormTest + void aReleaseInOneWindowLeavesTheOtherWindowsSelectionAlone() { + implementation.setMultiWindowSupported(true); + Display d = Display.getInstance(); + d.setPureTouch(true); + try { + Window a = new Window("a", new BorderLayout()); + a.setWindowSize(400, 300); + Component inA = content(a); + Window b = new Window("b", new BorderLayout()); + b.setWindowSize(400, 300); + Component inB = content(b); + + // A contact goes down on the component in A and stays down. + com.codename1.ui.Desktop.getInstance().windowPointerPressed(a.getWindowId(), insideX(inA), insideY(inA)); + DisplayTest.flushEdt(); + assertTrue(d.shouldRenderSelection(inA), + "the component under the held contact must show its selection"); + + // A whole press/release cycle happens in B while A is still held. + com.codename1.ui.Desktop.getInstance().windowPointerPressed(b.getWindowId(), insideX(inB), insideY(inB)); + DisplayTest.flushEdt(); + com.codename1.ui.Desktop.getInstance().windowPointerReleased(b.getWindowId(), insideX(inB), insideY(inB)); + DisplayTest.flushEdt(); + + assertFalse(d.shouldRenderSelection(inB), + "B was released, so its component must stop showing selection"); + assertTrue(d.shouldRenderSelection(inA), + "A is still held: releasing in another window must not clear it"); + } finally { + disposeAll(); + d.setPureTouch(false); + } + } + + @FormTest + void aComponentIsTestedAgainstItsOwnWindowsCoordinates() { + implementation.setMultiWindowSupported(true); + Display d = Display.getInstance(); + d.setPureTouch(true); + try { + Window a = new Window("a", new BorderLayout()); + a.setWindowSize(200, 200); + Component inA = content(a); + // Deliberately much larger, so a press in the middle of B lands well + // outside A's component. Window coordinates are window relative, so the + // two windows' coordinate spaces are not comparable. + Window b = new Window("b", new BorderLayout()); + b.setWindowSize(900, 700); + Component inB = content(b); + + com.codename1.ui.Desktop.getInstance().windowPointerPressed(a.getWindowId(), insideX(inA), insideY(inA)); + DisplayTest.flushEdt(); + assertTrue(d.shouldRenderSelection(inA), "held in A, inside its component"); + + int bx = insideX(inB)[0]; + int by = insideY(inB)[0]; + assertFalse(inA.contains(bx, by), + "the test is only meaningful if B's press point is outside A's component"); + com.codename1.ui.Desktop.getInstance().windowPointerPressed(b.getWindowId(), new int[] { bx }, new int[] { by }); + DisplayTest.flushEdt(); + + assertTrue(d.shouldRenderSelection(inA), + "A's component must still be tested against A's own press, not B's"); + } finally { + disposeAll(); + d.setPureTouch(false); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/WindowTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/WindowTest.java new file mode 100644 index 00000000000..787fd855e86 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/WindowTest.java @@ -0,0 +1,5043 @@ +/* + * 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.ui; + +import com.codename1.ui.Desktop; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.testing.TestWindowManager; +import com.codename1.ui.animations.Animation; +import com.codename1.ui.animations.Motion; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.events.WindowEvent; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.DefaultLookAndFeel; +import com.codename1.ui.plaf.LookAndFeel; +import com.codename1.ui.plaf.UIManager; + +import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WindowTest extends UITestBase { + + @FormTest + void unsupportedPlatformThrowsOnConstruction() { + // the default: no window manager, which is what every mobile port reports + assertFalse(Desktop.isSupported(), + "Desktop windowing should be off unless a test turns it on"); + assertThrows(UnsupportedOperationException.class, new org.junit.jupiter.api.function.Executable() { + @Override + public void execute() { + new Window("nope"); + } + }, "Constructing a Window without a windowing system must throw, not degrade"); + } + + @FormTest + void unsupportedPlatformStillAnswersDesktopQueriesSafely() { + assertEquals(0, Desktop.getInstance().getWindows().length, + "getWindows() must be empty rather than null where there are no windows"); + assertNull(Desktop.getInstance().getFocusedWindow()); + assertEquals(1, Desktop.getInstance().getMonitors().length, + "A platform with no windowing system still reports its single display"); + assertNotNull(Desktop.getInstance().getPrimaryMonitor()); + } + + @FormTest + void showCreatesExactlyOneNativeWindowAndDisposeReleasesIt() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("Inspector", new BorderLayout()); + w.setWindowSize(640, 480); + w.show(); + + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertNotNull(peer, "show() should have created a native window"); + assertEquals(1, wm.getWindows().size(), "show() must not create a second window"); + assertTrue(peer.isVisible()); + assertEquals("Inspector", peer.getTitle()); + assertEquals(1, Desktop.getInstance().getWindows().length); + + w.dispose(); + assertTrue(peer.isDisposed()); + assertFalse(peer.isVisible()); + assertEquals(0, Desktop.getInstance().getWindows().length, + "A disposed window must leave the desktop registry"); + + // disposing twice is harmless + w.dispose(); + assertEquals(1, wm.getWindows().size()); + } + + @FormTest + void titleAndBoundsReachTheNativeWindow() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("first"); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + + w.setTitle("second"); + assertEquals("second", peer.getTitle()); + + w.setWindowBounds(new com.codename1.ui.geom.Rectangle(10, 20, 300, 200)); + assertEquals(10, peer.getX()); + assertEquals(20, peer.getY()); + assertEquals(300, peer.getWidth()); + assertEquals(200, peer.getHeight()); + w.dispose(); + } + + @FormTest + void componentsInAWindowResolveTheWindowNotAForm() { + implementation.setMultiWindowSupported(true); + Window w = new Window("host", new BorderLayout()); + Label content = new Label("hello"); + w.add(BorderLayout.CENTER, content); + w.show(); + + assertSame(w, content.getTopLevelContainer(), + "A component in a Window must resolve that Window as its top level"); + assertNull(content.getComponentForm(), + "getComponentForm() keeps its meaning and is null inside a Window"); + assertSame(w.getContentPane(), content.getParent(), + "add() on a Window should reach the content pane, as it does on a Form"); + w.dispose(); + } + + @FormTest + void formStillResolvesItselfAsTopLevel() { + Form f = new Form("main", new BorderLayout()); + Label content = new Label("hello"); + f.add(BorderLayout.CENTER, content); + f.show(); + flushSerialCalls(); + + assertSame(f, content.getTopLevelContainer()); + assertSame(f, content.getComponentForm(), + "The Form path must be completely unaffected"); + } + + @FormTest + void closeRequestHonoursTheCloseOperation() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("closable"); + w.setCloseOperation(Window.HIDE_ON_CLOSE); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + + w.closeRequested(); + assertFalse(peer.isDisposed(), "HIDE_ON_CLOSE must not destroy the window"); + assertFalse(peer.isVisible()); + + w.setCloseOperation(Window.DISPOSE_ON_CLOSE); + w.closeRequested(); + assertTrue(peer.isDisposed()); + } + + @FormTest + void aCloseListenerCanVetoTheClose() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("vetoed"); + w.show(); + final AtomicInteger calls = new AtomicInteger(); + w.addCloseListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + calls.incrementAndGet(); + evt.consume(); + } + }); + + w.closeRequested(); + assertEquals(1, calls.get()); + assertFalse(wm.getLastWindow().isDisposed(), + "Consuming the close event must veto the close"); + w.dispose(); + } + + @FormTest + void windowChromeReachesTheNativeWindow() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("chrome"); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + + assertTrue(peer.isDecorated(), "windows are decorated by default"); + w.setDecorated(false); + assertFalse(peer.isDecorated()); + + w.setResizable(false); + assertFalse(peer.isResizable()); + + w.setAlwaysOnTop(true); + assertTrue(peer.isAlwaysOnTop()); + + w.requestWindowFocus(); + assertTrue(peer.isFocusRequested()); + w.dispose(); + } + + @FormTest + void modalityMarksTheNativeWindow() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("modal"); + w.show(); + assertEquals(Window.MODALITY_NONE, w.getModalityType()); + + w.setModalityType(Window.MODALITY_APPLICATION); + assertEquals(Window.MODALITY_APPLICATION, w.getModalityType()); + assertTrue(wm.getLastWindow().isModal()); + w.dispose(); + } + + @FormTest + void windowsGetIndependentIds() { + implementation.setMultiWindowSupported(true); + Window a = new Window("a"); + Window b = new Window("b"); + a.show(); + b.show(); + + assertEquals(2, Desktop.getInstance().getWindows().length); + assertSame(a, Desktop.getInstance().windowById(a.getWindowId())); + assertSame(b, Desktop.getInstance().windowById(b.getWindowId())); + assertTrue(a.getWindowId() != b.getWindowId(), + "Each window needs its own id, since events are routed by it"); + a.dispose(); + b.dispose(); + } + + @FormTest + void showInitializesTheHierarchy() { + implementation.setMultiWindowSupported(true); + Window w = new Window("Preferences", new BorderLayout()); + Label added = new Label("before show"); + w.add(BorderLayout.CENTER, added); + assertFalse(added.isInitialized(), + "nothing should be initialized before the window is shown"); + + w.show(); + + // Without this a Window is the one top level whose children never receive + // initComponent(), so look and feel binding and peer attachment never happen. + assertTrue(added.isInitialized(), + "show() must initialize the hierarchy the way setCurrent() does for a Form"); + assertTrue(w.isInitialized()); + w.dispose(); + assertFalse(added.isInitialized(), "dispose() must deinitialize it again"); + } + + @FormTest + void aFailedNativeWindowIsReportedRatherThanShown() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + wm.setCreateFails(true); + final Window w = new Window("too many"); + assertThrows(IllegalStateException.class, new org.junit.jupiter.api.function.Executable() { + @Override + public void execute() { + w.show(); + } + }, "A window the platform could not create must not become a phantom window"); + assertEquals(0, Desktop.getInstance().getWindows().length, + "a window that failed to open must not be registered"); + } + + @FormTest + void modalityIsAcquiredByShowAndReleasedByDispose() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("modal"); + w.setModalityType(Window.MODALITY_APPLICATION); + w.show(); + + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertTrue(peer.isModal(), + "a window shown with a modality type blocks, whether or not showModal was used"); + assertEquals(1, peer.getModalCalls()); + + w.dispose(); + assertFalse(peer.isModal(), + "the native modal flag must be dropped: on Windows it disables the main " + + "window, and leaving it set makes the application unusable"); + assertEquals(2, peer.getModalCalls(), + "the flag has to be set and cleared exactly once each"); + } + + @FormTest + void aFailedActivationReleasesTheWindowsModality() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("never appears"); + w.setModalityType(Window.MODALITY_APPLICATION); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertTrue(peer.isModal(), "it starts this blocking"); + + Desktop.getInstance().windowActivationFailed(w.getWindowId()); + DisplayTest.flushEdt(); + + // Not the minimize path: that keeps the modal registration on purpose, because + // a minimized window is still open. A window that never appeared would then + // block input everywhere while showModal() waited for a window nobody can see. + assertFalse(peer.isModal(), + "a window the platform could not create must stop blocking the others"); + assertFalse(w.isWindowShowing(), + "and must not be reported as showing"); + assertFalse(w.isWindowDisposed(), + "but it stays registered, so a later show() can ask the platform again"); + + // Disposed here rather than left behind: a window that outlives its test stays + // in the desktop registry, and the next test to paint reaches it without a + // window manager configured. + w.dispose(); + } + + @FormTest + void theMainSurfacesNotchDoesNotPadAWindowsContent() { + implementation.setMultiWindowSupported(true); + // A device whose main surface has a notch: 40px inset at the top. + implementation.setDisplaySafeArea(new Rectangle(0, 40, + Display.getInstance().getDisplayWidth(), + Display.getInstance().getDisplayHeight() - 40)); + try { + Window w = new Window("safe", new BorderLayout()); + w.setWindowSize(300, 200); + Container inner = new Container(new BorderLayout()); + inner.getAllStyles().setPadding(0, 0, 0, 0); + inner.setSafeArea(true); + Label child = new Label("child"); + child.getAllStyles().setPadding(0, 0, 0, 0); + child.getAllStyles().setMargin(0, 0, 0, 0); + inner.add(BorderLayout.CENTER, child); + w.add(BorderLayout.CENTER, inner); + w.show(); + w.revalidate(); + + // Asserted on the laid-out child rather than on the padding: the snap puts + // its insets on the style only for the duration of the layout and restores + // them straight after, so the padding reads the same either way and only + // where the child landed shows what happened. + // + // A desktop window has no notch and says so through getSafeArea(), but the + // snap read the display's insets directly, so the main surface's notch was + // applied to content in every window. + assertEquals(0, child.getY() - inner.getY(), + "nothing in a window may be pushed down by the main surface's notch"); + + w.dispose(); + } finally { + implementation.setDisplaySafeArea(null); + } + } + + @FormTest + void theAnimateAndReplaceFamilyTargetsTheContentPane() { + implementation.setMultiWindowSupported(true); + Window w = new Window("delegating", new com.codename1.ui.layouts.FlowLayout()); + Label a = new Label("a"); + Label b = new Label("b"); + w.add(a); + + // getComponentIndex looks in the content pane, where the application's + // components actually are -- the window root holds only the title area and the + // content pane, so asking it would answer -1 for every child. + assertEquals(0, w.getComponentIndex(a), + "getComponentIndex has to look where the children are"); + + w.replace(a, b, null); + + assertSame(b, w.getContentPane().getComponentAt(0), + "replace has to work on the content pane, as it does on a Form"); + assertEquals(-1, w.getContentPane().getComponentIndex(a)); + } + + @FormTest + void indexedAddsReachTheWindowsContentPane() { + implementation.setMultiWindowSupported(true); + Window w = new Window("indexed", new com.codename1.ui.layouts.FlowLayout()); + Label first = new Label("first"); + Label second = new Label("second"); + + w.addComponent(first); + // The indexed overloads are separate methods, not paths through the plain one, + // so they needed delegating in their own right. Without it the component landed + // in the window root beside the title area and the content pane could not see + // it. + w.addComponent(0, second); + + assertEquals(2, w.getContentPane().getComponentCount(), + "an indexed add belongs in the content pane, as it does on a Form"); + assertSame(second, w.getContentPane().getComponentAt(0), + "and at the index it asked for"); + assertSame(first, w.getContentPane().getComponentAt(1)); + } + + @FormTest + void aFabBindsToAWindowsContentPane() { + implementation.setMultiWindowSupported(true); + Window w = new Window("fab", new BorderLayout()); + w.setWindowSize(300, 200); + com.codename1.components.FloatingActionButton fab = + com.codename1.components.FloatingActionButton.createFAB( + com.codename1.ui.FontImage.MATERIAL_ADD); + + Container wrapper = fab.bindFabToContainer(w.getContentPane()); + + // Binding to a top level's content pane installs the button on that top level's + // layered pane and answers null. Resolving through getComponentForm() -- null in + // a window by design -- fell through to the wrapper branch instead and returned + // an unattached container, so the button never appeared. + assertNull(wrapper, + "binding to a window's content pane installs the button, as it does on a Form"); + assertSame(w, fab.getTopLevelContainer(), + "and the button ends up inside that window"); + + w.dispose(); + } + + @FormTest + void monitorsDifferingOnlyInScaleAreNotEqual() { + Rectangle bounds = new Rectangle(0, 0, 1920, 1080); + Rectangle work = new Rectangle(0, 0, 1920, 1040); + Monitor at1x = new Monitor(0, bounds, work, 160, 1.0, 96, "primary", true); + Monitor at2x = new Monitor(0, bounds, work, 320, 2.0, 192, "primary", true); + + // Neither the index nor the bounds move when a display is rescaled, which is + // exactly what Desktop reports as a reconfiguration. Comparing only those two + // made the new snapshot equal to the old one, so anything caching + // getMonitors() and diffing by equality kept the stale scale. + assertNotEquals(at1x, at2x, "a rescaled monitor is not the same snapshot"); + assertNotEquals(at1x.hashCode(), at2x.hashCode(), + "and its hash has to move with it"); + + // A taskbar switching to auto-hide changes only the work area. + Monitor fullWork = new Monitor(0, bounds, bounds, 160, 1.0, 96, "primary", true); + assertNotEquals(at1x, fullWork, "a changed work area is a changed snapshot"); + + // Same values means same snapshot, so equality stays useful. + assertEquals(at1x, new Monitor(0, bounds, work, 160, 1.0, 96, "primary", true)); + } + + @FormTest + void aPickerInAWindowUsesItsLightweightPopup() { + implementation.setMultiWindowSupported(true); + // A platform that does have a native picker: without the window check the + // native path would be taken, and every native picker attaches to the main + // surface rather than to the window the component is in. + implementation.setNativePickerTypeSupported(Boolean.TRUE, Boolean.TRUE, Boolean.TRUE); + Window w = new Window("picks", new BorderLayout()); + com.codename1.ui.spinner.Picker p = new com.codename1.ui.spinner.Picker(); + p.setType(Display.PICKER_TYPE_STRINGS); + p.setStrings("A", "B", "C"); + w.add(BorderLayout.CENTER, p); + w.setWindowSize(300, 200); + w.show(); + w.revalidate(); + + p.pressed(); + p.released(); + DisplayTest.flushEdt(); + + // The lightweight popup is an InteractionDialog, which resolves its host from + // the component, so it lands in this window's own hierarchy. The native path + // would have put nothing here. + assertTrue(containsInteractionDialog(w.asContainer()), + "a picker in a window has to open its popup in that window"); + + w.dispose(); + } + + private static boolean containsInteractionDialog(Container c) { + int count = c.getComponentCount(); + for (int iter = 0; iter < count; iter++) { + Component cmp = c.getComponentAt(iter); + if (cmp instanceof com.codename1.components.InteractionDialog) { + return true; + } + if (cmp instanceof Container && containsInteractionDialog((Container) cmp)) { + return true; + } + } + return false; + } + + @FormTest + void aWindowsContentPaneScrollsVerticallyByDefault() { + implementation.setMultiWindowSupported(true); + Window w = new Window("default"); + + // scrollableYFlag() rather than isScrollableY(): the latter is a computed + // predicate -- it also requires content taller than the container, or always + // tensile -- so on an empty pane it reads false whether or not the constructor + // set the default, and the assertion would have proved nothing either way. + boolean scrolls = w.getContentPane().scrollableYFlag(); + + // The same default a Form's content pane gets. Without it, content taller than + // the window is clipped and unreachable, and identical content moved from a + // Form silently stopped scrolling. + assertTrue(scrolls, + "a window's content pane scrolls vertically by default, as a Form's does"); + } + + @FormTest + void settingRTLOnAWindowReachesItsContentPane() { + implementation.setMultiWindowSupported(true); + Window w = new Window("rtl", new com.codename1.ui.layouts.FlowLayout()); + + w.setRTL(true); + + // The application's layout runs in the content pane, so setting it on the root + // alone left directional layouts reversed while isRTL() reported true. + assertTrue(w.getContentPane().isRTL(), + "setRTL has to reach the content pane, as it does on a Form"); + assertTrue(w.isRTL()); + } + + @FormTest + void scrollSettingsReachTheWindowsContentPane() { + implementation.setMultiWindowSupported(true); + // Deliberately not a BorderLayout content pane: setScrollableY forces false + // for one, on a Form just the same, so a BorderLayout would make this pass or + // fail for a reason that has nothing to do with the delegation. + Window w = new Window("scrolls", new com.codename1.ui.layouts.FlowLayout()); + + w.setScrollableY(true); + w.setScrollableX(false); + w.setAlwaysTensile(true); + w.setScrollAnimationSpeed(123); + + // The content pane scrolls, not the window root, which is a fixed BorderLayout + // holding the title area and the content. Set on the root these reach nothing, + // while the same calls on a Form reach its content pane -- so code moved from a + // Form to a Window would silently stop scrolling. + assertTrue(w.getContentPane().isScrollableY(), + "setScrollableY has to reach the content pane, as it does on a Form"); + assertFalse(w.getContentPane().isScrollableX()); + assertTrue(w.getContentPane().isAlwaysTensile()); + assertEquals(123, w.getContentPane().getScrollAnimationSpeed()); + // And the getters read back from the same place. + assertTrue(w.isScrollableY()); + assertFalse(w.isScrollableX()); + } + + @FormTest + void aWindowPaintsItsBackgroundOncePerPaint() { + implementation.setMultiWindowSupported(true); + Window w = new Window("bg", new BorderLayout()); + w.setWindowSize(120, 90); + w.show(); + final int[] painted = new int[1]; + w.getAllStyles().setBgPainter(new Painter() { + @Override + public void paint(Graphics g, Rectangle rect) { + painted[0]++; + } + }); + + w.internalPaintImpl(Image.createImage(120, 90).getGraphics(), true); + + // internalPaintImpl paints the background before it invokes paint(), so + // without the guard a custom painter runs twice per frame and a translucent + // one is composited on top of itself. + assertEquals(1, painted[0], + "the window's background painter must run once per paint"); + + w.dispose(); + } + + @FormTest + void closeListenersFireOnceForOneClose() { + implementation.setMultiWindowSupported(true); + Window w = new Window("closes"); + final AtomicInteger closes = new AtomicInteger(); + w.addCloseListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + closes.incrementAndGet(); + } + }); + w.show(); + w.closeRequested(); + + // dispose() used to fire them a second time, so one user close ran a + // listener's save or cleanup work twice. + assertEquals(1, closes.get(), "one close must notify a close listener once"); + assertTrue(w.isWindowDisposed()); + } + + @FormTest + void anOwnedWindowIsDisposedWithItsOwner() { + implementation.setMultiWindowSupported(true); + Window owner = new Window("owner"); + owner.show(); + Window child = new Window("child"); + child.setOwnerWindow(owner); + child.show(); + assertEquals(2, Desktop.getInstance().getWindows().length); + + owner.dispose(); + + assertTrue(child.isWindowDisposed(), + "an owned window cannot outlive its owner: the platform would leave it " + + "open with nothing behind it"); + assertEquals(0, Desktop.getInstance().getWindows().length); + } + + @FormTest + void showingAWindowRestoresAnOwnerTheApplicationHid() { + implementation.setMultiWindowSupported(true); + Window owner = new Window("owner"); + owner.show(); + Window child = new Window("child"); + child.setOwnerWindow(owner); + child.show(); + owner.hide(); + child.hide(); + assertFalse(owner.isWindowShowing(), "the owner starts this hidden"); + + child.show(); + + assertTrue(owner.isWindowShowing(), + "an owned window cannot be on screen without its owner, so showing it " + + "has to bring the owner back"); + assertTrue(child.isWindowShowing()); + + owner.dispose(); + } + + @FormTest + void restoringAHiddenOwnerGoesThroughItsOwnLifecycle() { + implementation.setMultiWindowSupported(true); + Window owner = new Window("owner"); + owner.show(); + Window child = new Window("child"); + child.setOwnerWindow(owner); + child.show(); + owner.hide(); + child.hide(); + + child.show(); + + // Mapping the owner's native window is not enough: hide() made the component + // hierarchy invisible, and only the framework show() path puts it back. A + // window restored by the port alone would repaint nothing and take no input. + assertTrue(owner.asContainer().isVisible(), + "the owner's component hierarchy has to be visible again, not merely " + + "its native window mapped"); + + owner.dispose(); + } + + @FormTest + void aWholeHiddenOwnerChainComesBack() { + implementation.setMultiWindowSupported(true); + Window grandparent = new Window("grandparent"); + grandparent.show(); + Window parent = new Window("parent"); + parent.setOwnerWindow(grandparent); + parent.show(); + Window child = new Window("child"); + child.setOwnerWindow(parent); + child.show(); + grandparent.hide(); + parent.hide(); + child.hide(); + + child.show(); + + assertTrue(grandparent.isWindowShowing(), + "the restore has to walk the whole owner chain, not just one level: a " + + "child cannot be on screen through a visible parent whose own " + + "owner is hidden"); + assertTrue(parent.isWindowShowing()); + assertTrue(child.isWindowShowing()); + + grandparent.dispose(); + } + + @FormTest + void restoringAWindowBringsItsHiddenOwnerBackToo() { + implementation.setMultiWindowSupported(true); + Window owner = new Window("owner"); + owner.show(); + Window child = new Window("child"); + child.setOwnerWindow(owner); + child.show(); + child.hideNotify(); + owner.hide(); + assertFalse(owner.isWindowShowing(), "the owner starts this hidden"); + + child.restore(); + + // restore() owes the same invariant show() does: un-minimizing a window while + // its owner is away puts it on screen without the owner, or lets the window + // system suppress it while the framework counts it back. + assertTrue(owner.isWindowShowing(), + "restoring an owned window has to bring its owner back as well"); + + owner.dispose(); + } + + @FormTest + void showingAMinimizedWindowAsksThePortToRestoreIt() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("minimized"); + w.show(); + w.hideNotify(); + int before = wm.getLastWindow().getRestoreCount(); + + w.show(); + + // Mapping is not enough: setVisible(true) on AWT and SW_SHOW on Win32 leave a + // window iconic, so only the restore path actually brings it back. + assertEquals(before + 1, wm.getLastWindow().getRestoreCount(), + "showing a minimized window has to go through the port's restore path, " + + "not just map it again"); + + w.dispose(); + } + + @FormTest + void showingAWindowRestoresAnIconifiedOwner() { + TestWindowManager ownerWm = implementation.setMultiWindowSupported(true); + Window owner = new Window("owner"); + owner.show(); + Window child = new Window("child"); + child.setOwnerWindow(owner); + child.show(); + child.hide(); + // Native minimization arrives through hideNotify, not hide(). + owner.hideNotify(); + assertFalse(owner.isWindowShowing(), "the owner starts this minimized"); + + child.show(); + + assertTrue(owner.isWindowShowing(), + "a minimized owner has to be restored too: only one port did it " + + "itself, so everywhere else the child was mapped against an " + + "owner still minimized"); + assertTrue(child.isWindowShowing()); + assertNotNull(ownerWm, "the fake manager is what records the restore"); + + owner.dispose(); + } + + @FormTest + void aRestoredWindowIsNoLongerMarkedMinimized() { + implementation.setMultiWindowSupported(true); + Window w = new Window("restored"); + w.show(); + w.hideNotify(); + + w.show(); + + // Only hide() and showNotify() cleared this before, so a window restored + // through show() stayed marked minimized while it was on screen. There is no + // public predicate for it, so the field is read directly, as the timer test + // above reads animatableComponents. + assertTrue(w.isWindowShowing()); + boolean stillIconified; + try { + java.lang.reflect.Field f = Window.class.getDeclaredField("iconified"); + f.setAccessible(true); + stillIconified = f.getBoolean(w); + } catch (Exception err) { + throw new RuntimeException(err); + } + assertFalse(stillIconified, + "a window that is on screen cannot still be marked minimized"); + + w.dispose(); + } + + @FormTest + void theMinimumSizeReachesThePortAndClampsAResize() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("clamped", new BorderLayout()); + w.setWindowSize(800, 600); + w.setMinimumWindowSize(new com.codename1.ui.geom.Dimension(320, 240)); + w.show(); + + assertEquals(320, wm.getLastWindow().getMinimumWidth(), + "the constraint has to reach the port, which is where it can be enforced"); + assertEquals(240, wm.getLastWindow().getMinimumHeight()); + + // The framework deliberately does not re-clamp what the port reports. The + // minimum is native geometry and includes the platform's chrome, while a + // resize reports content dimensions, so clamping one against the other mixes + // two coordinate spaces -- on a decorated window it laid the hierarchy out + // larger than the canvas it is drawn into, clipping controls and putting hit + // testing out of step with what is on screen. The window lays out to what it + // was actually given; enforcing the minimum belongs to the platform that owns + // the frame, which is why the assertions above matter. + w.sizeChangedInternal(100, 80); + assertEquals(100, w.getWidth()); + assertEquals(80, w.getHeight()); + w.dispose(); + } + + @FormTest + void keysReachTheWindowsFocusedComponent() { + implementation.setMultiWindowSupported(true); + Window w = new Window("keys", new BorderLayout()); + final AtomicInteger pressed = new AtomicInteger(); + Button b = new Button("target") { + @Override + public void keyPressed(int keyCode) { + super.keyPressed(keyCode); + pressed.incrementAndGet(); + } + }; + w.add(BorderLayout.CENTER, b); + w.show(); + w.setFocused(b); + + // Container's inherited handler only forwards to a lead component, so without + // Window dispatching keys itself the focused component never sees one. + w.keyPressed('a'); + assertEquals(1, pressed.get(), "the focused component must receive the key"); + + final AtomicInteger listened = new AtomicInteger(); + w.addKeyListener('b', new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + listened.incrementAndGet(); + } + }); + w.keyReleased('b'); + assertEquals(1, listened.get(), "addKeyListener must fire in a window too"); + w.dispose(); + } + + @FormTest + void hidingAModalWindowStopsItBlocking() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("modal"); + w.setModalityType(Window.MODALITY_APPLICATION); + w.setCloseOperation(Window.HIDE_ON_CLOSE); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertTrue(peer.isModal()); + + w.closeRequested(); + + // The user can no longer reach it, so it must not go on blocking what is + // behind it -- natively or in the framework. + assertFalse(w.isWindowShowing()); + assertFalse(peer.isModal(), + "a hidden modal window must release the block it holds"); + w.dispose(); + } + + @FormTest + void modalityTellsThePortWhatItBlocks() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window owner = new Window("owner"); + owner.show(); + TestWindowManager.FakeWindow ownerPeer = wm.getLastWindow(); + + Window child = new Window("child"); + child.setOwnerWindow(owner); + child.setModalityType(Window.MODALITY_WINDOW); + child.show(); + TestWindowManager.FakeWindow childPeer = wm.getLastWindow(); + + // A port applies modality by disabling the blocked window, so window scoped + // modality naming the main window would make an unrelated part of the + // application unusable. + assertFalse(childPeer.isModalApplicationWide()); + assertSame(ownerPeer, childPeer.getModalOwner()); + assertSame(ownerPeer, childPeer.getOwner(), + "the owner has to reach createWindow, or no platform knows the window " + + "should stay above it"); + owner.dispose(); + } + + @FormTest + void aNarrowerModalDoesNotLiftABroaderOne() { + implementation.setMultiWindowSupported(true); + Window appModal = new Window("application modal"); + appModal.setModalityType(Window.MODALITY_APPLICATION); + appModal.show(); + + Window child = new Window("window modal"); + child.setOwnerWindow(appModal); + child.setModalityType(Window.MODALITY_WINDOW); + child.show(); + + // The window modal on top blocks only its owner. Consulting just the newest + // blocker would answer "not blocked" for the main form and for every unrelated + // window, silently letting input back in while an application modal is still up. + // The wheel entry point is the one that reports the answer synchronously. + Desktop d = Desktop.getInstance(); + assertTrue(d.windowMouseWheelEvent(0, 5, 5, 0, 120, false, 0), + "the main form stays blocked while an application modal is registered"); + + child.dispose(); + assertTrue(d.windowMouseWheelEvent(0, 5, 5, 0, 120, false, 0), + "and stays blocked once the narrower one is gone"); + + appModal.dispose(); + assertFalse(d.windowMouseWheelEvent(0, 5, 5, 0, 120, false, 0), + "input returns once no modal window is registered"); + } + + @FormTest + void theOwnerCannotBeChangedOnceTheWindowExists() { + implementation.setMultiWindowSupported(true); + Window a = new Window("a"); + a.show(); + final Window b = new Window("b"); + b.show(); + final Window child = new Window("child"); + child.setOwnerWindow(a); + child.show(); + + // Native ownership is fixed when the window is created, and the port was told + // to block a specific owner. Repointing the field would strand the modal + // blocker on the previous owner and leave the platform relation on it too. + assertThrows(IllegalStateException.class, new org.junit.jupiter.api.function.Executable() { + @Override + public void execute() { + child.setOwnerWindow(b); + } + }); + a.dispose(); + b.dispose(); + } + + @FormTest + void minimizingAModalWindowDoesNotEndItsModality() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("modal"); + w.setModalityType(Window.MODALITY_APPLICATION); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + + // The platform minimizing the window also clears nativeVisible. Reading that + // as "the modal is over" would end the wait and drop the block, so restoring + // the window would put a modal back on screen with input flowing behind it. + w.hideNotify(); + assertTrue(peer.isModal(), "a minimized modal window is still modal"); + + w.showNotify(); + assertTrue(w.isWindowShowing()); + assertTrue(peer.isModal()); + w.dispose(); + assertFalse(peer.isModal()); + } + + @FormTest + void aMoveIsReportedToWindowListeners() { + implementation.setMultiWindowSupported(true); + Window w = new Window("moves"); + w.show(); + final AtomicInteger moves = new AtomicInteger(); + w.addWindowListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + if (((com.codename1.ui.events.WindowEvent) evt).getType() + == com.codename1.ui.events.WindowEvent.Type.Moved) { + moves.incrementAndGet(); + } + } + }); + + // Only a monitor change was reported before, so an ordinary move within one + // display never reached a listener and nothing could persist a position. + Desktop.getInstance().windowMoved(w.getWindowId()); + flushSerialCalls(); + assertEquals(1, moves.get()); + w.dispose(); + } + + @FormTest + void thePortIsToldWhetherAPositionWasChosen() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window placed = new Window("placed"); + // Negative is an ordinary coordinate: a monitor left of or above the primary + // display has a negative origin, so it cannot double as "no position given". + placed.setWindowBounds(new com.codename1.ui.geom.Rectangle(-1400, -200, 300, 200)); + placed.show(); + assertTrue(wm.getLastWindow().isPositionSet()); + assertEquals(-1400, wm.getLastWindow().getX()); + placed.dispose(); + + Window unplaced = new Window("unplaced"); + unplaced.show(); + assertFalse(wm.getLastWindow().isPositionSet(), + "a window that named no position must be placed by the platform"); + unplaced.dispose(); + } + + @FormTest + void aFormOwnedWindowIsNotConfusedWithAnUnownedOne() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window unowned = new Window("unowned"); + unowned.show(); + assertNull(wm.getLastWindow().getOwner()); + assertFalse(wm.getLastWindow().isOwnedByMainWindow(), + "an unowned window must not become a child of the main window"); + unowned.dispose(); + + Window ownedByForm = new Window("owned by the form"); + ownedByForm.setOwnerWindow(Display.getInstance().getCurrent()); + ownedByForm.show(); + assertNull(wm.getLastWindow().getOwner(), + "the main form has no window peer"); + assertTrue(wm.getLastWindow().isOwnedByMainWindow(), + "but the port still has to be told it owns this window"); + ownedByForm.dispose(); + } + + @FormTest + void draggingANonScrollableChildDoesNotRecurse() { + implementation.setMultiWindowSupported(true); + Window w = new Window("drags", new BorderLayout()); + Label plain = new Label("not scrollable"); + w.add(BorderLayout.CENTER, plain); + w.show(); + + // A drag bubbles up looking for something scrollable and used to stop only at + // a Form. A Window dispatches drags to the pressed child itself, so bubbling + // past it came straight back and recursed until the stack ran out. + w.pointerPressed(10, 10); + w.pointerDragged(12, 14); + w.pointerReleased(12, 14); + w.dispose(); + } + + @FormTest + void anUnownedWindowModalBlocksNothing() { + implementation.setMultiWindowSupported(true); + Window w = new Window("unowned modal"); + w.setModalityType(Window.MODALITY_WINDOW); + w.show(); + + // Window modality blocks the owning window. There is none, so it blocks + // nothing -- treating that as main-form ownership would block the main form + // on a window that never claimed it. + assertFalse(Desktop.getInstance().windowMouseWheelEvent(0, 5, 5, 0, 120, false, 0), + "the main form is not the owner, so it must not be blocked"); + w.dispose(); + } + + @FormTest + void showingAChildFirstStillEstablishesTheRealOwner() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window owner = new Window("owner"); + Window child = new Window("child"); + child.setOwnerWindow(owner); + + // The owner has never been shown, so it has no peer. Creating the child now + // would fix the wrong native owner permanently, since every port establishes + // the relation at creation. + child.show(); + + assertNotNull(wm.getLastWindow().getOwner(), + "the owner's native window has to exist before the child's"); + assertFalse(wm.getLastWindow().isOwnedByMainWindow()); + owner.dispose(); + } + + @FormTest + void nativeBlockingFollowsTheWholeModalStack() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window plain = new Window("plain"); + plain.show(); + TestWindowManager.FakeWindow plainPeer = wm.getLastWindow(); + + Window appModal = new Window("application modal"); + appModal.setModalityType(Window.MODALITY_APPLICATION); + appModal.show(); + TestWindowManager.FakeWindow appPeer = wm.getLastWindow(); + + // Application modality blocks every other window natively, not just the main + // one: a blocked window's own title bar is outside the framework's filter. + assertFalse(wm.isMainWindowInputEnabled()); + assertFalse(plainPeer.isInputEnabled()); + assertTrue(appPeer.isInputEnabled(), "the modal window itself stays usable"); + + Window inner = new Window("window modal"); + inner.setOwnerWindow(appModal); + inner.setModalityType(Window.MODALITY_WINDOW); + inner.show(); + inner.dispose(); + + // Releasing the inner modal must not re-enable what the outer one still + // blocks. A port counting its own depth got this wrong. + assertFalse(wm.isMainWindowInputEnabled(), + "the application modal is still up"); + assertFalse(plainPeer.isInputEnabled()); + + appModal.dispose(); + assertTrue(wm.isMainWindowInputEnabled(), "nothing blocks any more"); + assertTrue(plainPeer.isInputEnabled()); + plain.dispose(); + } + + @FormTest + void aWindowShownUnderAnApplicationModalIsBlockedNatively() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window appModal = new Window("application modal"); + appModal.setModalityType(Window.MODALITY_APPLICATION); + appModal.show(); + + // Shown *after* the modal, so it registers no blocker of its own and + // acquireModal() does nothing for it. Ports enable a native window by + // default, so without a resync at registration its title bar stayed live + // -- focusable, movable, closable -- under a modal meant to block it. + Window later = new Window("opened while blocked"); + later.show(); + TestWindowManager.FakeWindow laterPeer = wm.getLastWindow(); + assertFalse(laterPeer.isInputEnabled(), + "a window opened under an application modal must start blocked"); + + appModal.dispose(); + assertTrue(laterPeer.isInputEnabled(), "the modal is gone"); + later.dispose(); + } + + @FormTest + void aWindowCannotOwnItself() { + implementation.setMultiWindowSupported(true); + final Window w = new Window("self owned"); + // show() creates an unshown owner's native window first, so a cycle here + // recurses until the stack runs out before either peer exists -- and a + // StackOverflowError names none of the windows involved. + assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() { + @Override + public void execute() { + w.setOwnerWindow(w); + } + }); + w.dispose(); + } + + @FormTest + void aCycleThroughTheOwnerChainIsRejected() { + implementation.setMultiWindowSupported(true); + final Window a = new Window("a"); + final Window b = new Window("b"); + final Window c = new Window("c"); + b.setOwnerWindow(a); + c.setOwnerWindow(b); + // a -> c would close the loop a -> c -> b -> a. Only a walk of the whole + // chain sees it; comparing against the immediate owner does not. + assertThrows(IllegalArgumentException.class, new org.junit.jupiter.api.function.Executable() { + @Override + public void execute() { + a.setOwnerWindow(c); + } + }); + c.dispose(); + b.dispose(); + a.dispose(); + } + + @FormTest + void aGestureIsDispatchedToItsOwnWindow() { + implementation.setMultiWindowSupported(true); + final int[] mainPinches = new int[1]; + final int[] windowPinches = new int[1]; + + Form main = new Form("main", new BorderLayout()); + main.add(BorderLayout.CENTER, new PinchCountingComponent(mainPinches)); + main.show(); + + Window w = new Window("windowed", new BorderLayout()); + w.add(BorderLayout.CENTER, new PinchCountingComponent(windowPinches)); + w.setWindowSize(300, 200); + w.show(); + + // Aimed at the middle of the content, not the corner: the window's title + // area covers the top rows and would answer the hit test instead. + Desktop.getInstance().windowMagnifyGesture(w.getWindowId(), 150, 120, 1.5f); + Desktop.getInstance().windowMagnifyGesture(0, 150, 120, 1.5f); + // Disposed before asserting: a window left open by a failing assertion is + // painted for the rest of the class and times out every later test. + w.dispose(); + + assertEquals(1, windowPinches[0], "the gesture belongs to the window it arrived on"); + assertEquals(1, mainPinches[0], "window 0 is still the main surface"); + } + + @FormTest + void aWheelGestureIntoAWindowHiddenByItsOwnListenerIsDropped() { + implementation.setMultiWindowSupported(true); + final Window w = new Window("self hiding", new BorderLayout()); + WheelHidingComponent target = new WheelHidingComponent(w); + w.add(BorderLayout.CENTER, target); + w.setWindowSize(300, 200); + w.show(); + w.revalidate(); + + // The window is showing when the wheel arrives, so it passes the check on the + // way in. The listener then hides it and does not consume, which is what leaves + // the queued press, drags and release aimed at a hierarchy nobody can see. + // Through the port entry point, not Display.windowMouseWheelEvent: the wrapper + // is what queues the synthetic gesture after an unconsumed listener, so calling + // the inner method directly would never reach the code under test. + Display.impl.windowPointerWheelMoved(w.getWindowId(), + target.getAbsoluteX() + 2, target.getAbsoluteY() + 2, 0, 3, false, 0); + DisplayTest.flushEdt(); + DisplayTest.flushEdt(); + + assertTrue(target.sawWheel(), "the listener has to have run for this to mean anything"); + // Counted rather than observed through isScrollWheeling(): that flag is set by + // the first queued step and cleared by the last, and flushEdt drains all four, + // so it reads false either way. The pointer events the gesture dispatches are + // what persist. + assertEquals(0, target.pointerEvents(), + "no scroll gesture may be played into a window the wheel listener hid"); + + w.dispose(); + } + + /// Hides the window it is given when it sees a wheel event, without consuming it. + private static final class WheelHidingComponent extends Component { + private final Window target; + private boolean sawWheel; + private int pointerEvents; + + WheelHidingComponent(Window target) { + this.target = target; + } + + boolean sawWheel() { + return sawWheel; + } + + int pointerEvents() { + return pointerEvents; + } + + @Override + public void pointerPressed(int x, int y) { + pointerEvents++; + } + + @Override + public void pointerDragged(int x, int y) { + pointerEvents++; + } + + @Override + public void pointerReleased(int x, int y) { + pointerEvents++; + } + + @Override + public boolean fireMouseWheelEvent(com.codename1.ui.events.WheelEvent ev) { + sawWheel = true; + target.hide(); + return false; + } + } + + @FormTest + void aGestureOverABlockedWindowIsDropped() { + implementation.setMultiWindowSupported(true); + final int[] pinches = new int[1]; + Window blocked = new Window("blocked", new BorderLayout()); + blocked.add(BorderLayout.CENTER, new PinchCountingComponent(pinches)); + blocked.setWindowSize(300, 200); + blocked.show(); + + Window appModal = new Window("application modal"); + appModal.setModalityType(Window.MODALITY_APPLICATION); + appModal.show(); + + // Gestures are filtered like every other input event: pinching a window a + // modal is blocking has to do nothing, the same way clicking it does. + Desktop.getInstance().windowMagnifyGesture(blocked.getWindowId(), 150, 120, 1.5f); + int whileBlocked = pinches[0]; + + appModal.dispose(); + Desktop.getInstance().windowMagnifyGesture(blocked.getWindowId(), 150, 120, 1.5f); + int afterRelease = pinches[0]; + blocked.dispose(); + + assertEquals(0, whileBlocked, "a blocked window must not see the gesture"); + assertEquals(1, afterRelease, "and resume once nothing blocks it"); + } + + /// Counts the pinches it is handed, so a test can tell which tree a gesture + /// reached. + private static final class PinchCountingComponent extends Component { + private final int[] counter; + + PinchCountingComponent(int[] counter) { + this.counter = counter; + } + + @Override + public boolean pinch(float scale) { + counter[0]++; + return true; + } + } + + @FormTest + void aKeyReleaseIsNotStolenByAClickInAnotherWindow() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + final KeyCountingComponent mainKeys = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, mainKeys); + main.show(); + main.setFocused(mainKeys); + + Window w = new Window("other", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + // Press a key on the main form, then click the window before releasing it. + // The two sequences tracked one shared target while there was only ever one + // form; with windows this interleaving is ordinary, and the click used to + // overwrite the key's target so the release never arrived -- leaving the + // component latched in its pressed state. + Display.getInstance().keyPressed(-90); + int[] px = new int[]{150}; + int[] py = new int[]{120}; + Desktop.getInstance().windowPointerPressed(w.getWindowId(), px, py); + Desktop.getInstance().windowPointerReleased(w.getWindowId(), px, py); + Display.getInstance().keyReleased(-90); + DisplayTest.flushEdt(); + w.dispose(); + + assertEquals(1, mainKeys.pressed, "the press reached the main form"); + assertEquals(1, mainKeys.released, + "and so must the release, despite the click on another window"); + } + + /// Counts the key events it receives, so a test can prove a release was matched + /// to the component that saw the press. + private static final class KeyCountingComponent extends Component { + private int pressed; + private int released; + + @Override + public void keyPressed(int code) { + pressed++; + } + + @Override + public void keyReleased(int code) { + released++; + } + + @Override + public boolean isFocusable() { + return true; + } + } + + @FormTest + void repeatedMonitorReportsCollapseIntoOneNotification() { + implementation.setMultiWindowSupported(true); + final int[] fired = new int[1]; + Desktop.getInstance().addMonitorListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + fired[0]++; + } + }); + + // One physical display change is reported many times over: Windows + // broadcasts WM_DISPLAYCHANGE to every top level window, and GTK fires + // geometry, work-area and scale-factor notifications separately per monitor. + for (int iter = 0; iter < 5; iter++) { + Desktop.getInstance().monitorsChanged(); + } + DisplayTest.flushEdt(); + assertEquals(1, fired[0], "five reports of one change must notify once"); + + // A later change is a new change, not a duplicate of the one already drained. + Desktop.getInstance().monitorsChanged(); + DisplayTest.flushEdt(); + assertEquals(2, fired[0]); + } + + @FormTest + void overlappingKeyPressesAcrossWindowsEachReachTheirOwnTarget() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + final KeyCountingComponent mainKeys = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, mainKeys); + main.show(); + main.setFocused(mainKeys); + + Window w = new Window("other", new BorderLayout()); + final KeyCountingComponent windowKeys = new KeyCountingComponent(); + w.add(BorderLayout.CENTER, windowKeys); + w.setWindowSize(300, 200); + w.show(); + w.setFocused(windowKeys); + + // Hold a key on the main form, press a different key in the window before + // releasing it, then release both. One target for the whole keyboard is not + // enough: the second press overwrote the first, so the first release matched + // nothing and cleared the field, and the second release then matched nothing + // either -- latching a component in each window. + Display.getInstance().keyPressed(-91); + Desktop.getInstance().windowKeyPressed(w.getWindowId(), -92); + Display.getInstance().keyReleased(-91); + Desktop.getInstance().windowKeyReleased(w.getWindowId(), -92); + DisplayTest.flushEdt(); + w.dispose(); + + assertEquals(1, mainKeys.pressed); + assertEquals(1, windowKeys.pressed); + assertEquals(1, mainKeys.released, + "the main form's release must survive a press in another window"); + assertEquals(1, windowKeys.released, + "and so must the window's own"); + } + + @FormTest + void theMainFormReportsTheMonitorItIsActuallyOn() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + // Two displays, with the application's main window on the second one. + java.util.List two = + new java.util.ArrayList(); + two.add(new TestWindowManager.FakeMonitor(0, 0, 1440, 900, 1.0, 96, "primary")); + two.add(new TestWindowManager.FakeMonitor(1440, 0, 2560, 1440, 2.0, 192, "second")); + wm.setMonitors(two); + wm.setMainWindowMonitor(1); + + Form main = new Form("main"); + main.show(); + + Monitor m = Desktop.getInstance().getMonitorFor(main); + assertEquals(1, m.getIndex(), + "a Form has no window peer, but its monitor is still answerable"); + assertFalse(m.isPrimary(), + "reporting the primary monitor here gave the wrong work area and scale"); + } + + @FormTest + void aKeyReleaseArrivingOnAnotherWindowStillReachesThePressTarget() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + final KeyCountingComponent mainKeys = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, mainKeys); + main.show(); + main.setFocused(mainKeys); + + Window w = new Window("other", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + // Press on the main form, then have the *window* report the release. That is + // what a desktop window system does: key-up goes to whatever holds focus at + // the time, so it names the window the user moved to rather than the one the + // key went down in. Recording the press target is not enough on its own -- + // it has to be where the release is delivered, not merely something the + // packet is checked against. + Display.getInstance().keyPressed(-93); + Desktop.getInstance().windowKeyReleased(w.getWindowId(), -93); + DisplayTest.flushEdt(); + w.dispose(); + + assertEquals(1, mainKeys.pressed); + assertEquals(1, mainKeys.released, + "the release belongs to the component that saw the press"); + } + + @FormTest + void aDisabledComponentInAWindowIsNotActivatable() { + implementation.setMultiWindowSupported(true); + Window w = new Window("disabled", new BorderLayout()); + final int[] fired = new int[1]; + Button b = new Button("nope"); + b.setEnabled(false); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + fired[0]++; + } + }); + w.add(BorderLayout.CENTER, b); + w.setWindowSize(300, 200); + w.show(); + + // Button.pointerPressed has no enabled check of its own -- it relies on the + // top level never calling it -- so dispatching unconditionally let a disabled + // button enter its pressed state and fire on release. + w.pointerPressed(150, 120); + w.pointerReleased(150, 120); + int firedCount = fired[0]; + boolean stillReleased = b.getState() == Button.STATE_DEFAULT; + w.dispose(); + + assertEquals(0, firedCount, "a disabled button must not fire inside a window"); + assertTrue(stillReleased, "and must not be left in a pressed state"); + } + + @FormTest + void aPressDraggedOutOfAButtonInAWindowIsCancelled() { + implementation.setMultiWindowSupported(true); + Window w = new Window("drag out", new BorderLayout()); + final int[] fired = new int[1]; + Button b = new Button("press me"); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + fired[0]++; + } + }); + w.add(BorderLayout.CENTER, b); + w.setWindowSize(300, 200); + w.show(); + + // Press on the button, drag well clear of it, release there. Form cancels the + // press through its awaiting-release list; the window never consumed that list + // because Button registered through getComponentForm(), which is null here. + w.pointerPressed(150, 120); + // Outside the window, not merely near its top left. A window has no title area + // any more -- its title is native chrome -- so the content pane fills it and a + // centred button covers every in-window point, including the (2,2) this used to + // treat as outside. + w.pointerDragged(-20, -20); + w.pointerReleased(-20, -20); + int firedCount = fired[0]; + w.dispose(); + + assertEquals(0, firedCount, + "releasing outside the button must not fire its action"); + } + + @FormTest + void aReleaseFinishingAPressSurvivesAModalOpenedByThatPress() { + implementation.setMultiWindowSupported(true); + Window w = new Window("presser", new BorderLayout()); + final KeyCountingComponent keys = new KeyCountingComponent(); + w.add(BorderLayout.CENTER, keys); + w.setWindowSize(300, 200); + w.show(); + w.setFocused(keys); + + Desktop.getInstance().windowKeyPressed(w.getWindowId(), -94); + DisplayTest.flushEdt(); + + // The press handler opens an application modal, so the release arrives with + // its own window blocked. Dropping it strands the component in its pressed + // state for good and never clears the recorded target, so the *next* release + // matches the wrong thing. Modality is there to stop new interaction, not to + // abandon a gesture already under way. + Window modal = new Window("modal"); + modal.setModalityType(Window.MODALITY_APPLICATION); + modal.show(); + + Desktop.getInstance().windowKeyReleased(w.getWindowId(), -94); + DisplayTest.flushEdt(); + int released = keys.released; + + // A press that never happened stays blocked: this one leaves no record. + Desktop.getInstance().windowKeyPressed(w.getWindowId(), -95); + DisplayTest.flushEdt(); + int pressedWhileBlocked = keys.pressed; + + modal.dispose(); + w.dispose(); + + assertEquals(1, released, + "the release completing an accepted press must reach its target"); + assertEquals(1, pressedWhileBlocked, + "but a new press on a blocked window must still be dropped"); + } + + @FormTest + void focusChangesInAWindowRunTheFocusLifecycle() { + implementation.setMultiWindowSupported(true); + Window w = new Window("focus", new BorderLayout()); + final FocusCountingComponent a = new FocusCountingComponent(); + final FocusCountingComponent b = new FocusCountingComponent(); + Container box = new Container(new BorderLayout()); + box.add(BorderLayout.NORTH, a); + box.add(BorderLayout.SOUTH, b); + w.add(BorderLayout.CENTER, box); + w.setWindowSize(300, 200); + w.show(); + + // Toggling the focus flag and repainting is not the same as running the + // lifecycle: components build real behaviour on these notifications -- + // TextArea enables its input handling in focusGainedInternal -- so without + // them an arrow key traversed away from a field instead of moving its caret. + w.setFocused(a); + w.setFocused(b); + int aGained = a.gained; + int aLost = a.lost; + int bGained = b.gained; + w.dispose(); + + assertEquals(1, aGained, "the first component must be told it gained focus"); + assertEquals(1, aLost, "and told when it loses it"); + assertEquals(1, bGained, "and the second must be told it gained it"); + } + + /// Counts the focus notifications it receives. + private static final class FocusCountingComponent extends Component { + private int gained; + private int lost; + + @Override + public boolean isFocusable() { + return true; + } + + @Override + public void fireFocusGained() { + super.fireFocusGained(); + gained++; + } + + @Override + public void fireFocusLost() { + super.fireFocusLost(); + lost++; + } + } + + @FormTest + void aLongPressInAWindowReachesThePressedComponent() { + implementation.setMultiWindowSupported(true); + Window w = new Window("long press", new BorderLayout()); + final int[] longPresses = new int[1]; + Button b = new Button("hold me") { + @Override + public void longPointerPress(int x, int y) { + longPresses[0]++; + } + }; + w.add(BorderLayout.CENTER, b); + w.setWindowSize(300, 200); + w.show(); + + w.pointerPressed(150, 120); + w.longPointerPress(150, 120); + int count = longPresses[0]; + w.dispose(); + + assertEquals(1, count, + "Component's implementation only fires the window's own listeners, so " + + "a long press reached nothing inside a window"); + } + + @FormTest + void aLongKeyPressInAWindowReachesTheFocusedComponent() { + implementation.setMultiWindowSupported(true); + Window w = new Window("long key", new BorderLayout()); + final int[] longKeys = new int[1]; + Component target = new Component() { + @Override + public boolean isFocusable() { + return true; + } + + @Override + protected void longKeyPress(int keyCode) { + longKeys[0]++; + } + }; + w.add(BorderLayout.CENTER, target); + w.setWindowSize(300, 200); + w.show(); + w.setFocused(target); + + // Display dispatches a long key press to the top level and Component's + // implementation is empty, so without an override it reached nothing -- the + // keyboard twin of the long-press defect. + w.longKeyPress(-95); + int count = longKeys[0]; + w.dispose(); + + assertEquals(1, count, "a long key press must reach the focused component"); + } + + @FormTest + void overlappingPointerPressesInTwoWindowsEachGetTheirRelease() { + implementation.setMultiWindowSupported(true); + Window a = new Window("a", new BorderLayout()); + final PressCountingComponent ca = new PressCountingComponent(); + a.add(BorderLayout.CENTER, ca); + a.setWindowSize(300, 200); + a.show(); + + Window b = new Window("b", new BorderLayout()); + final PressCountingComponent cb = new PressCountingComponent(); + b.add(BorderLayout.CENTER, cb); + b.setWindowSize(300, 200); + b.show(); + + int[] px = new int[]{150}; + int[] py = new int[]{120}; + // Two contacts down in two windows at once -- the Linux port deliberately + // tracks a touch sequence per window, so this is reachable on a touchscreen. + // A single shared target let B's press erase A's, after which both releases + // were dropped and both components stayed latched. + Desktop.getInstance().windowPointerPressed(a.getWindowId(), px, py); + Desktop.getInstance().windowPointerPressed(b.getWindowId(), px, py); + Desktop.getInstance().windowPointerReleased(a.getWindowId(), px, py); + Desktop.getInstance().windowPointerReleased(b.getWindowId(), px, py); + DisplayTest.flushEdt(); + int ra = ca.released; + int rb = cb.released; + b.dispose(); + a.dispose(); + + assertEquals(1, ra, "the first window's release must reach its component"); + assertEquals(1, rb, "and so must the second window's"); + } + + /// Counts pointer releases, so a test can prove each window's press was matched. + private static final class PressCountingComponent extends Component { + private int released; + + @Override + public void pointerReleased(int x, int y) { + released++; + } + } + + @FormTest + void aPressInOneWindowDoesNotCancelAnothersLongPress() throws Exception { + implementation.setMultiWindowSupported(true); + Window a = new Window("a", new BorderLayout()); + a.add(BorderLayout.CENTER, new Label("a")); + a.setWindowSize(300, 200); + a.show(); + Window b = new Window("b", new BorderLayout()); + b.add(BorderLayout.CENTER, new Label("b")); + b.setWindowSize(300, 200); + b.show(); + + int[] px = new int[]{150}; + int[] py = new int[]{120}; + // Press in A, then in B. The long-press timer was a single set of fields, so + // B's press replaced A's coordinates and clock, and releasing either one + // cancelled the other's pending long press. + Desktop.getInstance().windowPointerPressed(a.getWindowId(), px, py); + Desktop.getInstance().windowPointerPressed(b.getWindowId(), px, py); + Desktop.getInstance().windowPointerReleased(a.getWindowId(), px, py); + DisplayTest.flushEdt(); + + boolean bStillArmed = longPressArmedFor(b.getWindowId()); + b.dispose(); + a.dispose(); + + assertTrue(bStillArmed, + "releasing one window's contact must not cancel another window's " + + "pending long press"); + } + + /// Reads Display's per-window long-press table, which is private state with no + /// public accessor. + private static boolean longPressArmedFor(int windowId) throws Exception { + if (windowId == 0) { + java.lang.reflect.Field f = Display.class.getDeclaredField("longPointerCharged"); + f.setAccessible(true); + return f.getBoolean(Display.getInstance()); + } + Window w = Desktop.getInstance().windowById(windowId); + return w != null && w.hasLongPointerArmed(); + } + + @FormTest + void aBlockedWindowsPressDoesNotLeaveALongPressArmed() throws Exception { + implementation.setMultiWindowSupported(true); + Window blocked = new Window("blocked", new BorderLayout()); + blocked.add(BorderLayout.CENTER, new Label("content")); + blocked.setWindowSize(300, 200); + blocked.show(); + + Window modal = new Window("modal"); + modal.setModalityType(Window.MODALITY_APPLICATION); + modal.show(); + + int[] px = new int[]{150}; + int[] py = new int[]{120}; + // The timer is charged when the press is queued, before modality has had a + // say, and the event dispatch thread fires longPointerPress directly without + // re-checking -- so a context menu could open behind the modal for a press + // the component never received. + Desktop.getInstance().windowPointerPressed(blocked.getWindowId(), px, py); + DisplayTest.flushEdt(); + boolean armed = longPressArmedFor(blocked.getWindowId()); + + modal.dispose(); + blocked.dispose(); + assertFalse(armed, + "a press rejected by modality must not leave its long press armed"); + } + + @FormTest + void windowLevelPointerListenersFire() { + implementation.setMultiWindowSupported(true); + Window w = new Window("listeners", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + final int[] counts = new int[3]; + w.addPointerPressedListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + counts[0]++; + } + }); + w.addPointerDraggedListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + counts[1]++; + } + }); + w.addPointerReleasedListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + counts[2]++; + } + }); + + // These dispatchers were never fired, so listeners attached to a Window did + // nothing -- and material pull to refresh went with them, since Component + // installs its refresh listeners on the top level. + w.pointerPressed(150, 120); + w.pointerDragged(150, 130); + w.pointerReleased(150, 130); + w.dispose(); + + assertEquals(1, counts[0], "the window's pointer pressed listener must fire"); + assertEquals(1, counts[1], "and its dragged listener"); + assertEquals(1, counts[2], "and its released listener"); + } + + @FormTest + void aPressInOneWindowDoesNotClearAnothersDragOccurred() { + implementation.setMultiWindowSupported(true); + final boolean[] seen = new boolean[1]; + Window a = new Window("a", new BorderLayout()); + a.add(BorderLayout.CENTER, new Component() { + @Override + public void pointerReleased(int x, int y) { + seen[0] = Display.getInstance().hasDragOccured(); + } + }); + a.setWindowSize(300, 200); + a.show(); + Window b = new Window("b", new BorderLayout()); + b.add(BorderLayout.CENTER, new Label("b")); + b.setWindowSize(300, 200); + b.show(); + + int[] px = new int[]{150}; + int[] py = new int[]{120}; + int[] py2 = new int[]{160}; + Desktop.getInstance().windowPointerPressed(a.getWindowId(), px, py); + Desktop.getInstance().windowPointerDragged(a.getWindowId(), px, py2); + DisplayTest.flushEdt(); + // B's press cleared the global flag after A had already dragged, so releasing + // A made List and friends read hasDragOccured() as false and treat a + // completed drag as a click. + Desktop.getInstance().windowPointerPressed(b.getWindowId(), px, py); + DisplayTest.flushEdt(); + Desktop.getInstance().windowPointerReleased(a.getWindowId(), px, py2); + DisplayTest.flushEdt(); + b.dispose(); + a.dispose(); + + // Read from inside A's own release dispatch, which is where List and + // ContainerList consult it -- and the only context where the answer is + // defined, now that the selector is restored when dispatch unwinds. + assertTrue(seen[0], + "a press in another window must not erase this window's drag state"); + } + + @FormTest + void aDraggableComponentInAWindowGetsDragAndDropPrimed() { + implementation.setMultiWindowSupported(true); + Window w = new Window("dnd", new BorderLayout()); + Label draggable = new Label("drag me"); + draggable.setDraggable(true); + w.add(BorderLayout.CENTER, draggable); + w.setWindowSize(300, 200); + w.show(); + + // Component.pointerDragged checks dragAndDropInitialized and silently does + // nothing without it, so drag and drop was unusable in a window. + w.pointerPressed(150, 120); + boolean primed = draggable.isDragAndDropInitialized(); + w.dispose(); + + assertTrue(primed, "a press must prime drag and drop, as Form does"); + } + + @FormTest + void multiTouchDragsStillNotifyWindowListeners() { + implementation.setMultiWindowSupported(true); + Window w = new Window("multi", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + final int[] drags = new int[1]; + w.addPointerDraggedListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + drags[0]++; + } + }); + + // The listener block was added to the scalar overload only, so a gesture + // stopped notifying the moment it became multi touch. + w.pointerDragged(new int[]{150, 160}, new int[]{120, 130}); + int count = drags[0]; + w.dispose(); + + assertEquals(1, count, "a multi touch drag must notify window listeners too"); + } + + @FormTest + void aMinimizedWindowStopsQueueingRepaints() { + implementation.setMultiWindowSupported(true); + Window w = new Window("minimized", new BorderLayout()); + Label content = new Label("content"); + w.add(BorderLayout.CENTER, content); + w.setWindowSize(300, 200); + w.show(); + DisplayTest.flushEdt(); + + w.hideNotify(); + // paintOpenWindows skips a window that is not showing while hasPendingPaints + // still counts its queue, so anything queued here can never drain and the + // event dispatch thread spins until the window is restored. + content.repaint(); + w.repaint(); + boolean pendingWhileMinimized = Display.impl.hasPendingPaints(); + w.dispose(); + + assertFalse(pendingWhileMinimized, + "a minimized window must not queue paint work that cannot drain"); + } + + @FormTest + void manyWindowsGesturingDoNotStarveALaterOne() { + implementation.setMultiWindowSupported(true); + // The concern this replaces was a fixed table of drag-history slots: a handful + // of long-lived windows exhausted it and a later window could record neither + // drag state nor velocity. Each window now owns its history, so there is no + // table to exhaust -- but the behaviour is worth pinning down regardless of how + // it is stored. + Window[] windows = new Window[10]; + try { + for (int iter = 0; iter < windows.length; iter++) { + windows[iter] = new Window("w" + iter, new BorderLayout()); + windows[iter].add(BorderLayout.CENTER, new Label("c")); + windows[iter].setWindowSize(300, 200); + windows[iter].show(); + int[] px = new int[] { 150 }; + int[] py = new int[] { 120 }; + Desktop.getInstance().windowPointerPressed(windows[iter].getWindowId(), px, py); + Desktop.getInstance().windowPointerReleased(windows[iter].getWindowId(), px, py); + DisplayTest.flushEdt(); + } + + Window later = new Window("later", new BorderLayout()); + later.setWindowSize(400, 300); + DragCountingComponent c = new DragCountingComponent(); + later.add(BorderLayout.CENTER, c); + later.show(); + later.asContainer().revalidate(); + try { + implementation.windowPointerPressedForTest(later.getWindowId(), 100, 100); + for (int iter = 0; iter < 12; iter++) { + implementation.windowPointerDraggedForTest(later.getWindowId(), + 100 + iter * 20, 100); + } + DisplayTest.flushEdt(); + assertTrue(c.drags > 0, + "a window opened after ten others have gestured must still be " + + "able to drag"); + } finally { + later.dispose(); + } + } finally { + for (int iter = 0; iter < windows.length; iter++) { + if (windows[iter] != null) { + windows[iter].dispose(); + } + } + DisplayTest.flushEdt(); + } + } + + @FormTest + void aNestedPressSurvivesTheOuterReleaseTeardown() { + implementation.setMultiWindowSupported(true); + Window w = new Window("nested", new BorderLayout()); + final Window[] self = new Window[]{w}; + Component target = new Component() { + @Override + public void pointerReleased(int x, int y) { + // Stands in for a handler that enters invokeAndBlock and has a fresh + // press dispatched to the same window before it returns. + self[0].pointerPressed(150, 120); + } + }; + w.add(BorderLayout.CENTER, target); + w.setWindowSize(300, 200); + w.show(); + + w.pointerPressed(150, 120); + w.pointerReleased(150, 120); + // The replacement press must still be installed: tearing down by window + // rather than by gesture erased it. + boolean replacementHeld = w.getCurrentPointerPress() != null; + w.dispose(); + + assertTrue(replacementHeld, + "a press installed during the outer release must survive its teardown"); + } + + @FormTest + void anActivatedDragIsFinishedWhenReleasedInAWindow() { + implementation.setMultiWindowSupported(true); + Window w = new Window("dnd release", new BorderLayout()); + final int[] finished = new int[1]; + Label draggable = new Label("drag me") { + @Override + protected void dragFinishedImpl(int x, int y) { + super.dragFinishedImpl(x, y); + finished[0]++; + } + }; + draggable.setDraggable(true); + w.add(BorderLayout.CENTER, draggable); + w.setWindowSize(300, 200); + w.show(); + + w.pointerPressed(150, 120); + // Enough movement to activate the drag rather than merely scroll. + w.pointerDragged(150, 121); + w.pointerDragged(160, 140); + w.pointerDragged(170, 160); + w.pointerReleased(170, 160); + int count = finished[0]; + w.dispose(); + + // Component hides the component when the drag activates and only + // dragFinishedImpl restores it and runs the drop, so releasing through the + // ordinary path left it invisible with the drop unfinished. + assertEquals(1, count, "an activated drag must be finished, not merely released"); + } + + @FormTest + void aBlockedWindowsKeyPressDoesNotLeaveTimersArmed() throws Exception { + implementation.setMultiWindowSupported(true); + Window blocked = new Window("blocked keys", new BorderLayout()); + blocked.add(BorderLayout.CENTER, new Label("content")); + blocked.setWindowSize(300, 200); + blocked.show(); + + Window modal = new Window("modal"); + modal.setModalityType(Window.MODALITY_APPLICATION); + modal.show(); + + // keyPressedImpl arms the repeat and long-key timers before modality has had + // a say, and the paint loop fires them directly without re-checking, so + // holding a key could drive a component behind the modal. + // A positive key code, because the timers are only armed for a code that can + // repeat -- with a negative one the test would pass without testing anything, + // which is what the first version of it did. + Desktop.getInstance().windowKeyPressed(blocked.getWindowId(), 65); + DisplayTest.flushEdt(); + boolean armed = keyRepeatArmedFor(blocked.getWindowId()); + + modal.dispose(); + blocked.dispose(); + assertFalse(armed, + "a key press rejected by modality must not leave its timers armed"); + } + + /// Reads Display's per-window key repeat table, which has no public accessor. + private static boolean keyRepeatArmedFor(int windowId) throws Exception { + // Asked of the surface that owns the timer rather than reflected out of a table + // in Display. The main surface keeps its own fields; a window keeps its own. + if (windowId == 0) { + java.lang.reflect.Field f = Display.class.getDeclaredField("keyRepeatCharged"); + f.setAccessible(true); + return f.getBoolean(Display.getInstance()); + } + Window w = Desktop.getInstance().windowById(windowId); + return w != null && w.hasKeyRepeatArmed(); + } + + @FormTest + void anAutorepeatInAnotherWindowKeepsTheOriginalPressTarget() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + final KeyCountingComponent mainKeys = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, mainKeys); + main.show(); + main.setFocused(mainKeys); + + Window w = new Window("other", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + Display.getInstance().keyPressed(-97); + // The native ports forward every autorepeat as another press; this one + // arrives while the other window has focus. + Desktop.getInstance().windowKeyPressed(w.getWindowId(), -97); + Display.getInstance().keyReleased(-97); + DisplayTest.flushEdt(); + int released = mainKeys.released; + w.dispose(); + + assertEquals(1, released, + "a repeat elsewhere must not steal the original press's target"); + } + + @FormTest + void theWindowAnimationLockBehavesLikeAForms() { + implementation.setMultiWindowSupported(true); + Window w = new Window("anim lock", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + // An idle window must grant the lock, a second caller must be refused while + // it is held, and releasing must not throw. The previous implementation + // returned isAnimating() -- so it granted the lock only when something else + // was already animating -- and released by handing null to flushAnimation, + // which either invoked it on the spot or queued it for the event dispatch + // thread to invoke: an NPE either way. + boolean first = w.grabAnimationLock(); + boolean second = w.grabAnimationLock(); + w.releaseAnimationLock(); + boolean afterRelease = w.grabAnimationLock(); + w.releaseAnimationLock(); + DisplayTest.flushEdt(); + w.dispose(); + + assertTrue(first, "an idle window must grant the lock"); + assertFalse(second, "a second caller must be refused while it is held"); + assertTrue(afterRelease, "and it must be grantable again after release"); + } + + @FormTest + void anUnrelatedModalIsStillBlockedByAnApplicationModal() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window appModal = new Window("application modal"); + appModal.setModalityType(Window.MODALITY_APPLICATION); + appModal.show(); + + // Unowned, so not nested inside the first one. Being modal itself must not + // exempt it from application modality -- the self check used to return for + // any modal, which let this one accept input. + Window unrelated = new Window("unrelated modal"); + unrelated.setModalityType(Window.MODALITY_APPLICATION); + unrelated.show(); + TestWindowManager.FakeWindow unrelatedPeer = wm.getLastWindow(); + boolean blocked = !unrelatedPeer.isInputEnabled(); + + // Disposed before the nested case: while it is up, the nested modal is + // legitimately blocked *by it* -- an unrelated application modal blocks + // everything outside its own chain, this window included. Leaving it open + // made the first version of this test assert the opposite. + unrelated.dispose(); + + // A modal nested inside the first is still exempt from it. + Window nested = new Window("nested modal"); + nested.setOwnerWindow(appModal); + nested.setModalityType(Window.MODALITY_APPLICATION); + nested.show(); + boolean nestedUsable = wm.getLastWindow().isInputEnabled(); + + nested.dispose(); + appModal.dispose(); + + assertTrue(blocked, "an unrelated modal must still be blocked by an application modal"); + assertTrue(nestedUsable, "but a modal nested inside it must stay usable"); + } + + @FormTest + void aKeyReleasedAfterFocusMovedCancelsThePressingWindowsRepeat() throws Exception { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + final KeyCountingComponent mainKeys = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, mainKeys); + main.show(); + main.setFocused(mainKeys); + + Window w = new Window("other", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + // Pressed on the main surface, released while the other window has focus. + // Cancelling by the releasing window left the main surface repeating every + // 10ms with the key physically up. + Display.getInstance().keyPressed(66); + Desktop.getInstance().windowKeyReleased(w.getWindowId(), 66); + DisplayTest.flushEdt(); + boolean stillArmed = keyRepeatArmedFor(0) || keyRepeatArmedFor(w.getWindowId()); + w.dispose(); + + assertFalse(stillArmed, + "releasing a key must cancel the repeat armed by its press"); + } + + @FormTest + void aReleaseDuringAPressCallbackFindsItsAcceptedPress() { + implementation.setMultiWindowSupported(true); + final Window w = new Window("nested press", new BorderLayout()); + final int[] released = new int[1]; + final boolean[] reentered = new boolean[1]; + Component target = new Component() { + @Override + public void pointerPressed(int x, int y) { + if (!reentered[0]) { + reentered[0] = true; + // Stands in for a callback entering a nested loop (showModal) + // during which the physical release is processed. + int[] px = new int[]{150}; + int[] py = new int[]{120}; + Desktop.getInstance().windowPointerReleased(w.getWindowId(), px, py); + DisplayTest.flushEdt(); + } + } + + @Override + public void pointerReleased(int x, int y) { + released[0]++; + } + }; + w.add(BorderLayout.CENTER, target); + w.setWindowSize(300, 200); + w.show(); + w.setFocused(target); + + int[] px = new int[]{150}; + int[] py = new int[]{120}; + Desktop.getInstance().windowPointerPressed(w.getWindowId(), px, py); + DisplayTest.flushEdt(); + int count = released[0]; + w.dispose(); + + // Recording the press only after the callback returned meant a release + // processed inside it saw no accepted press. + assertEquals(1, count, "a release during the press callback must find its press"); + } + + @FormTest + void hidingAWindowCancelsItsKeyTimers() throws Exception { + implementation.setMultiWindowSupported(true); + Window w = new Window("hide timers", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + // A key handler can hide its own window. The window stays registered, so a + // repeat armed by the press that got us here would keep firing into a tree + // the user cannot see -- and the key-up may never arrive once the native + // window has lost focus. + Desktop.getInstance().windowKeyPressed(w.getWindowId(), 67); + DisplayTest.flushEdt(); + w.hide(); + boolean armed = keyRepeatArmedFor(w.getWindowId()); + w.dispose(); + + assertFalse(armed, "hiding a window must cancel the timers armed for it"); + } + + @FormTest + void settingOnlyTheSizeLeavesPlacementToTheWindowManager() throws Exception { + implementation.setMultiWindowSupported(true); + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("size only", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + // The documented pre-show call. Routing it through setWindowBounds handed the + // port the placeholder (0,0) as though the application had chosen it. + w.setWindowSize(420, 260); + w.show(); + boolean positionSet = wm.getLastWindow().isPositionSet(); + w.dispose(); + + assertFalse(positionSet, + "setting only the size must leave the position unspecified"); + } + + @FormTest + void hidingAWindowUnlatchesTheComponentThatTookThePress() { + implementation.setMultiWindowSupported(true); + Window w = new Window("latched", new BorderLayout()); + Button b = new Button("fire me"); + w.add(BorderLayout.CENTER, b); + w.setWindowSize(300, 200); + w.show(); + w.setFocused(b); + + // The scenario the hide cleanup exists for: the component takes the press and + // the window goes away before the release. Dropping the records without + // telling the component left it in STATE_PRESSED, still latched when the + // window was shown again. + // The test implementation maps a key code straight to its game action, so + // GAME_FIRE is the code that reaches Button.pressed(). + b.keyPressed(Display.GAME_FIRE); + int pressedState = b.getState(); + w.hide(); + int afterHide = b.getState(); + w.dispose(); + + assertEquals(Button.STATE_PRESSED, pressedState, "the press must latch it first"); + assertTrue(afterHide != Button.STATE_PRESSED, + "hiding the window must unlatch the component that took the press"); + } + + @FormTest + void minimizingAWindowCancelsItsTimersToo() throws Exception { + implementation.setMultiWindowSupported(true); + Window w = new Window("minimize timers", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + Desktop.getInstance().windowKeyPressed(w.getWindowId(), 68); + DisplayTest.flushEdt(); + // Native minimization arrives through hideNotify, not hide(), and bypassed + // the cleanup entirely -- the window stays registered either way. + w.hideNotify(); + boolean armed = keyRepeatArmedFor(w.getWindowId()); + w.dispose(); + + assertFalse(armed, "minimizing must cancel the timers armed for the window"); + } + + @FormTest + void hidingDuringADragRestoresTheDraggedComponent() { + implementation.setMultiWindowSupported(true); + Window w = new Window("drag hide", new BorderLayout()); + Label draggable = new Label("drag me"); + draggable.setDraggable(true); + w.add(BorderLayout.CENTER, draggable); + w.setWindowSize(300, 200); + w.show(); + + w.pointerPressed(150, 120); + w.pointerDragged(150, 121); + w.pointerDragged(165, 145); + w.pointerDragged(175, 165); + // Component hides the dragged component when the drag activates; only + // dragFinishedImpl restores it, and dragInitiated does not. + w.hide(); + boolean visible = draggable.isVisible(); + boolean stillInitialized = draggable.isDragAndDropInitialized(); + w.dispose(); + + assertTrue(visible, "hiding mid-drag must restore the dragged component"); + assertFalse(stillInitialized, "and must clear its drag-and-drop state"); + } + + @FormTest + void losingFocusCancelsHeldInput() throws Exception { + implementation.setMultiWindowSupported(true); + Window w = new Window("focus loss", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + Desktop.getInstance().windowKeyPressed(w.getWindowId(), 69); + DisplayTest.flushEdt(); + // The user switches to another application while holding the key. The + // key-up goes to whoever has focus now, so nothing else would ever stop + // this window repeating. + Desktop.getInstance().windowFocusChanged(w.getWindowId(), false); + DisplayTest.flushEdt(); + boolean armed = keyRepeatArmedFor(w.getWindowId()); + w.dispose(); + + assertFalse(armed, "losing focus must cancel input held in the window"); + } + + @FormTest + void aPacketQueuedBeforeHideDoesNotRestartTheGesture() throws Exception { + implementation.setMultiWindowSupported(true); + Window w = new Window("late packet", new BorderLayout()); + Button b = new Button("press me"); + w.add(BorderLayout.CENTER, b); + w.setWindowSize(300, 200); + w.show(); + + // Queued, then the window is hidden before the event dispatch thread drains + // it. Dispatching the press re-latches the component the hide just + // unlatched, with no release coming -- the timer is armed at queue time, so + // the observable damage is the component's state rather than the timer's. + Desktop.getInstance().windowPointerPressed(w.getWindowId(), + new int[]{150}, new int[]{120}); + w.hide(); + DisplayTest.flushEdt(); + boolean latched = b.getState() == Button.STATE_PRESSED; + w.dispose(); + + assertFalse(latched, + "a packet queued before the hide must not re-latch the component"); + } + + @FormTest + void aOneShotTimerBoundToAWindowStopsAfterFiring() { + implementation.setMultiWindowSupported(true); + Window w = new Window("one shot", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.setWindowSize(300, 200); + w.show(); + + final int[] fired = new int[1]; + // A one-shot deregistered itself from the *current form* rather than from the + // window it was bound to, so it stayed in the window's animation list and + // fired again every interval, forever. + com.codename1.ui.util.UITimer t = + com.codename1.ui.util.UITimer.timer(1, false, w, new Runnable() { + @Override + public void run() { + fired[0]++; + } + }); + // Driven directly rather than through the paint loop: what changed is which + // top level the one-shot deregisters itself from, and the animation pass is + // not what this needs to observe. + int registeredBefore; + int registeredAfter; + try { + java.lang.reflect.Field af = Window.class.getDeclaredField("animatableComponents"); + af.setAccessible(true); + java.util.ArrayList anims = (java.util.ArrayList) af.get(w); + registeredBefore = anims.size(); + java.lang.reflect.Method tick = + com.codename1.ui.util.UITimer.class.getDeclaredMethod("testEllapse"); + tick.setAccessible(true); + Thread.sleep(3); + tick.invoke(t); + registeredAfter = ((java.util.ArrayList) af.get(w)).size(); + } catch (Exception err) { + throw new RuntimeException(err); + } + w.dispose(); + + // Firing is what deregisters a one-shot. It used to deregister from the + // current form instead, leaving it in the window's list to fire forever. + assertEquals(1, fired[0], "the timer must have fired"); + assertEquals(1, registeredBefore, "it registers with the window it is bound to"); + assertEquals(0, registeredAfter, "and deregisters from that same window"); + } + + @FormTest + void theUtilityWindowFlagReachesThePort() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("palette"); + w.setUtilityWindow(true); + w.show(); + assertTrue(wm.getLastWindow().isUtility(), + "setUtilityWindow only stored a field before; nothing reached the port"); + w.dispose(); + } + + @FormTest + void anIconifiedWindowStopsBeingPainted() { + implementation.setMultiWindowSupported(true); + Window w = new Window("iconified", new BorderLayout()); + w.add(BorderLayout.CENTER, new Label("content")); + w.show(); + assertTrue(w.isWindowShowing()); + + // What a port reports when the platform minimizes the window. Container's + // implementation is inert, which would leave it counted as visible: still + // painted, and its animations still keeping the event dispatch thread awake. + w.hideNotify(); + assertFalse(w.isWindowShowing(), + "a minimized window must stop counting as visible"); + + w.showNotify(); + assertTrue(w.isWindowShowing(), "restoring must resume painting"); + w.dispose(); + } + + @FormTest + void showModalOnAWindowAlreadyUpStillBlocks() throws Exception { + implementation.setMultiWindowSupported(true); + final Window w = new Window("late modal", new BorderLayout()); + w.show(); + DisplayTest.flushEdt(); + assertFalse(Desktop.getInstance().isWindowInputBlocked(0), + "it starts non-modal, which is what makes this the interesting case"); + + // showModal() on a window shown earlier. show() is a no-op for a window + // already on screen, so the blocker it would have taken has to come from + // somewhere -- without it the caller waits while the user carries on using + // everything underneath. + final Thread caller = new Thread(new Runnable() { + @Override + public void run() { + w.showModal(); + } + }); + caller.start(); + for (int iter = 0; iter < 100 && !Desktop.getInstance().isWindowInputBlocked(0); iter++) { + DisplayTest.flushEdt(); + Thread.sleep(5); + } + try { + assertTrue(Desktop.getInstance().isWindowInputBlocked(0), + "a window made modal after it was shown still has to block"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + caller.join(2000); + } + assertFalse(caller.isAlive(), "and disposing it releases the caller"); + } + + @FormTest + void modalityChangedOffTheEdtReachesThePortOnIt() throws Exception { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + final Window w = new Window("modality", new BorderLayout()); + w.show(); + DisplayTest.flushEdt(); + try { + // The release and the acquire mutate Desktop's modal stack and call the + // port, so they belong on the same thread as everything else that does. + Thread background = new Thread(new Runnable() { + @Override + public void run() { + w.setModalityType(Window.MODALITY_APPLICATION); + } + }); + background.start(); + background.join(); + DisplayTest.flushEdt(); + + assertEquals(java.util.Collections.emptyList(), wm.getOffEdtCalls(), + "a modality change has to reach the port on the event dispatch " + + "thread, however it was made"); + assertEquals(Window.MODALITY_APPLICATION, w.getModalityType(), + "and it takes effect once the hop has run"); + assertTrue(Desktop.getInstance().isWindowInputBlocked(0), + "the blocker is taken, not merely recorded"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void showingAWindowThatIsAlreadyUpFiresNothing() { + implementation.setMultiWindowSupported(true); + Window w = new Window("already up", new BorderLayout()); + final int[] shown = new int[1]; + w.addWindowListener(new ActionListener() { + @Override + public void actionPerformed(WindowEvent evt) { + if (evt.getType() == WindowEvent.Type.Shown) { + shown[0]++; + } + } + }); + w.show(); + DisplayTest.flushEdt(); + assertEquals(1, shown[0], "showing it once is one showing"); + + // Nothing about the window changed, so nothing happened: repeating the + // transition tells listeners doing initialization or persistence to do it + // again for what the user sees as a single showing. + w.show(); + DisplayTest.flushEdt(); + assertEquals(1, shown[0], "showing it again while it is up is not a second showing"); + + // And a real transition still counts. + w.hide(); + DisplayTest.flushEdt(); + w.show(); + DisplayTest.flushEdt(); + assertEquals(2, shown[0], "hiding and showing it again is a second showing"); + w.dispose(); + } + + @FormTest + void focusLeavingTheMainSurfaceDisarmsItsHeldKeys() throws Exception { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + KeyCountingComponent c = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, c); + main.show(); + DisplayTest.flushEdt(); + main.setFocused(c); + + // A key held down on the main form, and then focus moves away -- to a secondary + // window, or to another application. The key-up goes wherever focus went, so + // nothing else disarms the repeat. + Display.getInstance().keyPressed(70); + DisplayTest.flushEdt(); + assertTrue(keyRepeatArmedFor(0), + "the press arms the repeat, which is the state under test"); + + Desktop.getInstance().windowFocusChanged(0, false); + DisplayTest.flushEdt(); + assertFalse(keyRepeatArmedFor(0), + "focus leaving the main surface has to disarm its held keys; window " + + "zero is not a registered Window, so the window-keyed path " + + "skipped it and the form repeated for as long as it stayed open"); + Display.getInstance().keyReleased(70); + DisplayTest.flushEdt(); + } + + @FormTest + void arrowKeysMoveFocusBetweenAWindowsControls() { + implementation.setMultiWindowSupported(true); + Window w = new Window("keyboard", new BoxLayout(BoxLayout.Y_AXIS)); + Button top = new Button("top"); + Button middle = new Button("middle"); + Button bottom = new Button("bottom"); + w.add(top); + w.add(middle); + w.add(bottom); + w.setWindowSize(400, 300); + w.show(); + w.asContainer().revalidate(); + DisplayTest.flushEdt(); + + w.setFocused(top); + // The fake implementation maps a key code to a game action identically, so the + // game action is what gets sent. + // + // Container's directional lookups answer null, which is right for an ordinary + // container and wrong for a top level: every arrow key resolved through them, + // so a window could not be navigated from the keyboard at all. + Desktop.getInstance().windowKeyPressed(w.getWindowId(), Display.GAME_DOWN); + Desktop.getInstance().windowKeyReleased(w.getWindowId(), Display.GAME_DOWN); + DisplayTest.flushEdt(); + assertSame(middle, w.getFocused(), + "the down arrow has to move focus to the next control down"); + + Desktop.getInstance().windowKeyPressed(w.getWindowId(), Display.GAME_DOWN); + Desktop.getInstance().windowKeyReleased(w.getWindowId(), Display.GAME_DOWN); + DisplayTest.flushEdt(); + assertSame(bottom, w.getFocused(), "and again to the one after it"); + + Desktop.getInstance().windowKeyPressed(w.getWindowId(), Display.GAME_UP); + Desktop.getInstance().windowKeyReleased(w.getWindowId(), Display.GAME_UP); + DisplayTest.flushEdt(); + assertSame(middle, w.getFocused(), "and the up arrow comes back"); + w.dispose(); + } + + @FormTest + void anExplicitNextFocusWinsOverThePositionalScanInAWindow() { + implementation.setMultiWindowSupported(true); + Window w = new Window("explicit", new BoxLayout(BoxLayout.Y_AXIS)); + Button top = new Button("top"); + Button middle = new Button("middle"); + Button bottom = new Button("bottom"); + w.add(top); + w.add(middle); + w.add(bottom); + // Skips the middle, which the positional scan would have chosen. + top.setNextFocusDown(bottom); + w.setWindowSize(400, 300); + w.show(); + w.asContainer().revalidate(); + DisplayTest.flushEdt(); + + w.setFocused(top); + Desktop.getInstance().windowKeyPressed(w.getWindowId(), Display.GAME_DOWN); + Desktop.getInstance().windowKeyReleased(w.getWindowId(), Display.GAME_DOWN); + DisplayTest.flushEdt(); + assertSame(bottom, w.getFocused(), + "a component's own next-focus has to win over the positional scan, as " + + "it does on a form"); + w.dispose(); + } + + @FormTest + void reShowingAHiddenWindowHasNotPaintedItsNewContentYet() { + implementation.setMultiWindowSupported(true); + Window w = new Window("re-shown", new BorderLayout()); + Label l = new Label("before"); + w.add(BorderLayout.CENTER, l); + w.show(); + w.markPainted(); + assertTrue(w.hasPaintedOnce(), "it painted while it was up"); + + w.hide(); + DisplayTest.flushEdt(); + // Free to change while nothing was painting it, which is the whole point: + // whatever the surface still holds is a picture of the old content. + l.setText("after"); + w.show(); + DisplayTest.flushEdt(); + + assertFalse(w.hasPaintedOnce(), + "a window brought back has not painted its current content yet; saying " + + "otherwise lets a waiter capture the raster from before the hide"); + w.markPainted(); + assertTrue(w.hasPaintedOnce(), "and it counts again once it really has painted"); + w.dispose(); + } + + @FormTest + void anInterruptedModalWaitKeepsBlockingUntilTheWindowGoesAway() throws Exception { + implementation.setMultiWindowSupported(true); + final Window w = new Window("modal", new BorderLayout()); + w.setModalityType(Window.MODALITY_APPLICATION); + w.show(); + DisplayTest.flushEdt(); + assertTrue(Desktop.getInstance().isWindowInputBlocked(0), + "an application modal blocks the main form while it is up"); + + // showModal() from another thread: the window is already showing, so the show + // it performs is a no-op and the thread goes straight into the wait, which + // invokeAndBlock runs inline off the event dispatch thread. Interrupting it + // there is the case under test. + final Thread waiter = new Thread(new Runnable() { + @Override + public void run() { + w.showModal(); + } + }); + waiter.start(); + for (int iter = 0; iter < 100 && waiter.isAlive(); iter++) { + DisplayTest.flushEdt(); + Thread.sleep(5); + // Repeated rather than once: an interrupt that lands before the wait starts + // is consumed by whatever was running, and the flag has to be set again for + // the wait itself to see it. Harmless once the thread is gone. + waiter.interrupt(); + } + waiter.join(2000); + assertFalse(waiter.isAlive(), + "an interrupted showModal() has to return rather than park for good"); + + assertTrue(Desktop.getInstance().isWindowInputBlocked(0), + "the window is still on screen and still modal, so it must go on " + + "blocking; dropping its blocker here leaves a modal window " + + "visible with input reaching the windows behind it"); + + w.dispose(); + DisplayTest.flushEdt(); + assertFalse(Desktop.getInstance().isWindowInputBlocked(0), + "and the blocker goes when the window really does"); + } + + @FormTest + void aResizeDropsPaintWorkQueuedAgainstTheOldSize() { + implementation.setMultiWindowSupported(true); + Window w = new Window("resizes", new BorderLayout()); + Label l = new Label("content"); + w.add(BorderLayout.CENTER, l); + w.show(); + w.markPainted(); + l.repaint(); + + w.sizeChangedInternal(900, 700); + + // Those rectangles were computed against the old geometry. A port that + // reallocates its buffer on resize would paint them into a fresh, larger one + // and leave the rest unpainted -- which is exactly what a capture caught. + assertFalse(w.hasPaintedOnce(), + "frames painted at the old size do not count once the window resized"); + assertEquals(900, w.getWidth()); + w.dispose(); + } + + @FormTest + void hidingAWindowStopsItPinningPaintWork() { + implementation.setMultiWindowSupported(true); + Window w = new Window("hides", new BorderLayout()); + Label l = new Label("content"); + w.add(BorderLayout.CENTER, l); + w.show(); + w.hide(); + + // A hidden window is never painted, so anything queued on its surface would + // never drain -- and an undrained queue keeps the event dispatch thread awake. + assertFalse(w.isVisible(), + "hiding must mark the hierarchy invisible so its components stop enqueuing"); + l.repaint(); + assertFalse(Display.impl.hasPendingPaints(), + "a hidden window must not leave paint work that nothing will ever drain"); + w.dispose(); + } + + @FormTest + void captureUsesThePortsReadbackWhenItHasOneAndRendersWhenItDoesNot() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("shot", new BorderLayout()); + w.setWindowSize(300, 200); + w.show(); + + // A port that can read its own window back must be used: re-rendering the + // hierarchy produces the content the window *should* be showing, so it cannot + // tell a correct window from one whose raster and hierarchy disagree -- which + // is the whole thing the windowed screenshot goldens exist to catch. + Object readback = implementation.createImage(new int[64 * 32], 64, 32); + wm.setCaptureResult(readback); + Image shot = w.capture(); + assertNotNull(shot); + assertEquals(1, wm.getCaptureCalls(), "the port must be asked first"); + assertEquals(64, shot.getWidth(), + "capture() must hand back the port's readback, not a re-render at the " + + "window's size"); + assertEquals(32, shot.getHeight()); + + // A port with no readback still owes a capture, so the re-render remains -- + // at the window's own size rather than the main display's. + wm.setCaptureResult(null); + Image rendered = w.capture(); + assertNotNull(rendered, "a port that cannot read back still owes a capture"); + assertEquals(w.getWidth(), rendered.getWidth()); + assertEquals(w.getHeight(), rendered.getHeight()); + + w.dispose(); + } + + @FormTest + void animatedComponentsRegisterWithTheWindowTheyLiveIn() { + implementation.setMultiWindowSupported(true); + Window w = new Window("animated", new BorderLayout()); + w.setWindowSize(400, 300); + com.codename1.components.Switch sw = new com.codename1.components.Switch(); + w.add(BorderLayout.CENTER, sw); + w.show(); + w.revalidate(); + + // getComponentForm() is null by design inside a Window, so every component that + // registered its animation through it threw on an ordinary interaction there -- + // a tapped Switch could not toggle at all. The registration has to resolve + // through the top level instead. + boolean before = sw.isValue(); + sw.pointerPressed(sw.getAbsoluteX() + 2, sw.getAbsoluteY() + 2); + sw.pointerReleased(sw.getAbsoluteX() + 2, sw.getAbsoluteY() + 2); + + // The toggle completes on the animation, so the observable result here is that + // the interaction was accepted and an animation was registered against the + // window rather than throwing on the way. + assertTrue(w.isWindowShowing()); + assertEquals(before, sw.isValue(), + "the value flips when the animation finishes, not on release"); + + // The guard around a registration matters as much as the registration: several + // of these sites sat inside an `if (getComponentForm() != null)`, so moving only + // the call left it unreachable in a Window -- a fix that changed nothing. That + // is now Component.registerForAnimation()'s problem rather than each caller's. + + // ImageViewer registers the same way from its animated setZoom path, which is + // an ordinary operation rather than an edge case. + com.codename1.components.ImageViewer viewer = + new com.codename1.components.ImageViewer(Image.createImage(32, 32)); + w.add(BorderLayout.NORTH, viewer); + w.revalidate(); + viewer.setZoom(2f); + assertNotNull(viewer.getImage(), "zooming in a window must not throw"); + + w.dispose(); + } + + @FormTest + void anInfiniteProgressAnimatesAndTearsDownInsideAWindow() { + implementation.setMultiWindowSupported(true); + Window w = new Window("busy", new BorderLayout()); + w.setWindowSize(300, 200); + com.codename1.components.InfiniteProgress progress = + new com.codename1.components.InfiniteProgress(); + w.add(BorderLayout.CENTER, progress); + w.show(); + w.revalidate(); + + // The spinner decided whether to animate by comparing Display.getCurrent() -- + // which only ever names a Form -- with its own getComponentForm(), null inside + // a Window. That is false for every spinner in a window, so it registered + // nothing and sat completely static. + assertTrue(progress.animate(false), + "an infinite progress in a shown window must animate"); + + // And teardown resolved the form with a fallback to the current form, which + // threw in a window-only application -- during Window.dispose(), before the + // native peer and paint surface were released. + w.dispose(); + assertTrue(w.isWindowDisposed(), + "dispose must complete rather than throw on the way through teardown"); + } + + @FormTest + void builtInComponentsInitialiseAndAnimateInsideAWindow() { + implementation.setMultiWindowSupported(true); + Window w = new Window("built ins", new BorderLayout()); + w.setWindowSize(400, 300); + + // AutoCompleteTextField registered its pointer listeners straight off + // getComponentForm() in initComponent, so the first show() of a window + // containing one threw before anything else could run. + AutoCompleteTextField auto = + new AutoCompleteTextField("alpha", "beta", "gamma"); + w.add(BorderLayout.NORTH, auto); + + // A Label with an animated icon registered through a local Form variable in + // checkAnimation() -- the indirect form the first sweep's direct-call grep did + // not match -- so the icon stayed frozen. + Label animated = new Label(makeAnimatedImage()); + w.add(BorderLayout.CENTER, animated); + + w.show(); + w.revalidate(); + + assertTrue(w.isWindowShowing(), + "showing a window with built-in components must not throw"); + + // The label's registration is silently skipped rather than throwing when it + // resolves a null form, so "did not throw" proves nothing about it. The window's + // own animation list is the observable: the icon animates only if the label is + // in it. + assertTrue(windowAnimates(w, animated), + "a label with an animated icon must register with the window it lives " + + "in; resolving the form instead leaves the icon frozen"); + + w.dispose(); + } + + /// True when the window has the given component in its animation list. Read + /// reflectively because the list is private -- and it is the only observable that + /// separates "registered with the window" from "silently skipped", which is what + /// the null-form guard does. + private static boolean windowAnimates(Window w, Object cmp) { + try { + java.lang.reflect.Field f = + Window.class.getDeclaredField("animatableComponents"); + f.setAccessible(true); + return ((java.util.List) f.get(w)).contains(cmp); + } catch (Exception err) { + throw new IllegalStateException(err); + } + } + + @FormTest + void selectingACalendarDayInsideAWindowDoesNotThrow() { + implementation.setMultiWindowSupported(true); + Window w = new Window("calendar", new BorderLayout()); + w.setWindowSize(400, 400); + Calendar cal = new Calendar(); + w.add(BorderLayout.CENTER, cal); + w.show(); + w.revalidate(); + + // MonthView.actionPerformed() asked getComponentForm().isSingleFocusMode() + // unconditionally, so every ordinary day selection updated the date, fired its + // listeners, and then threw on the way out. + final boolean[] fired = new boolean[1]; + cal.addActionListener(new com.codename1.ui.events.ActionListener() { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + fired[0] = true; + } + }); + Button day = findFirstDayButton(cal); + assertNotNull(day, "the month view should contain day buttons"); + day.pressed(); + day.released(); + + assertTrue(fired[0], "selecting a day must fire its listeners"); + assertTrue(w.isWindowShowing(), "and must not throw on the way out"); + w.dispose(); + } + + @FormTest + void groupedRadioButtonsAndTextAreasWorkInsideAWindow() { + implementation.setMultiWindowSupported(true); + Window w = new Window("radios", new BorderLayout()); + w.setWindowSize(400, 300); + + // initNamedGroup() stored the ButtonGroup as a client property on the form and + // dereferenced it without a guard, so showing a window containing a grouped + // radio button threw before the native window was even mapped. + Container box = new Container(new com.codename1.ui.layouts.BoxLayout( + com.codename1.ui.layouts.BoxLayout.Y_AXIS)); + RadioButton first = new RadioButton("first"); + RadioButton second = new RadioButton("second"); + first.setGroup("choice"); + second.setGroup("choice"); + box.add(first); + box.add(second); + w.add(BorderLayout.CENTER, box); + w.show(); + w.revalidate(); + + assertTrue(w.isWindowShowing(), + "a window with a grouped radio button must show"); + + // The group has to actually work, not merely not throw: selecting the second + // must clear the first, which only happens if both joined the same group. + first.setSelected(true); + second.setSelected(true); + assertTrue(second.isSelected()); + assertFalse(first.isSelected(), + "both radio buttons must have joined the same named group"); + + w.dispose(); + } + + @FormTest + void aWindowTaggedPressReachesAnEditingTextAreaWithoutThrowing() { + implementation.setMultiWindowSupported(true); + Window w = new Window("editing", new BorderLayout()); + w.setWindowSize(400, 300); + TextArea area = new TextArea("text"); + Button other = new Button("elsewhere"); + w.add(BorderLayout.NORTH, area); + w.add(BorderLayout.SOUTH, other); + w.show(); + w.revalidate(); + + final boolean[] fired = new boolean[1]; + area.addActionListener(new com.codename1.ui.events.ActionListener() { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + fired[0] = true; + } + }); + + // The press listener is registered on the window, but its body resolved + // getComponentForm() -- null there -- and did nothing. So the documented + // pre-click action event never fired and the other component's handler could + // observe an uncommitted value. + implementation.setFocusedEditingText(area); + assertTrue(area.isEditing(), "the area should be in editing state"); + + // Tagged with the window's id: the untagged entry point is window 0, the main + // surface, and the press would never reach this window at all. + Desktop.getInstance().windowPointerPressed(w.getWindowId(), + new int[]{other.getAbsoluteX() + 2}, + new int[]{other.getAbsoluteY() + 2}); + flushSerialCalls(); + + // Deliberately not asserting that *this* listener fired the early event. The + // press path fires an action event and sets suppressActionEvent by another + // route as well, so both assertions pass against the un-fixed listener and + // would prove nothing. What this does pin down is that a window-tagged press + // reaches an editing text area in a window and is handled without throwing; + // the listener body's own fix is covered by reading, and stated as such. + assertTrue(fired[0], "the press must be handled and its action event delivered"); + assertTrue(w.isWindowShowing(), "and must not throw on the way"); + w.dispose(); + } + + @FormTest + void editingATextFieldInsideAWindowReachesThePort() throws Exception { + implementation.setMultiWindowSupported(true); + Window w = new Window("editor", new BorderLayout()); + w.setWindowSize(400, 300); + TextField field = new TextField("hello"); + w.add(BorderLayout.CENTER, field); + w.show(); + w.revalidate(); + + // Display.editString() resolved getComponentForm() and returned outright when + // it was null -- which it always is inside a Window. That guard rejected every + // editor in a window before impl.editStringImpl() was reached, so none of the + // port level editor routing could run however correct the ports were. The + // windowed screenshot goldens could not see it either: a field that never + // enters editing still renders. + Display.getInstance().editString(field, 20, TextArea.ANY, "hello", 0); + + java.lang.reflect.Field active = com.codename1.testing.TestCodenameOneImplementation + .class.getDeclaredField("activeTextEditor"); + active.setAccessible(true); + assertSame(field, active.get(implementation), + "editing a text field in a window must reach the port"); + + w.dispose(); + } + + @FormTest + void commandsAddedToAWindowReachThePort() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("commands", new BorderLayout()); + w.setWindowSize(300, 200); + + // Added before show(): the peer does not exist yet, so these have to be + // published when it does rather than silently lost. + Command before = new Command("Before"); + w.addCommand(before); + w.show(); + + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertNotNull(peer); + + // The command list used to be private bookkeeping that nothing consumed, so a + // command added to a window was never displayed and never activated -- unlike + // the identical call on a Form. + assertEquals(1, wm.getPublishedCommands(peer).size(), + "commands added before show() must reach the port once it exists"); + assertSame(before, wm.getPublishedCommands(peer).get(0)); + + Command after = new Command("After"); + w.addCommand(after); + assertEquals(2, wm.getPublishedCommands(peer).size(), + "and a command added afterwards must be published too"); + + w.removeCommand(before); + assertEquals(1, wm.getPublishedCommands(peer).size()); + assertSame(after, wm.getPublishedCommands(peer).get(0)); + + w.removeAllCommands(); + assertEquals(0, wm.getPublishedCommands(peer).size()); + + w.dispose(); + } + + @FormTest + void anAnimationInBothRegistriesRunsOncePerFrame() { + implementation.setMultiWindowSupported(true); + Window w = new Window("anim", new BorderLayout()); + w.setWindowSize(300, 200); + w.show(); + + final int[] ticks = new int[1]; + com.codename1.ui.animations.Animation a = + new com.codename1.ui.animations.Animation() { + @Override + public boolean animate() { + ticks[0]++; + return false; + } + + @Override + public void paint(com.codename1.ui.Graphics g) { + } + }; + + // A component can legitimately sit in both registries -- an explicitly animated + // scrollable whose fading scrollbar is also running. Form skips entries already + // handled by the public list; without the same exclusion the motion advances at + // double speed and any side effect happens twice per frame. + w.registerAnimated(a); + w.registerAnimatedInternal(a); + + w.repaintAnimations(); + assertEquals(1, ticks[0], + "an animation in both registries must run once per frame, not twice"); + + w.dispose(); + } + + @FormTest + void emblemValidationInstallsItsGlassPaneInsideAWindow() throws Exception { + implementation.setMultiWindowSupported(true); + Window w = new Window("validate", new BorderLayout()); + w.setWindowSize(300, 200); + TextField field = new TextField(""); + w.add(BorderLayout.CENTER, field); + w.show(); + w.revalidate(); + assertNull(w.getGlassPane(), "no glass pane before validation runs"); + + // setValid() is driven directly rather than through addConstraint: the full + // constraint path pulls in listener wiring that wedges the event dispatch + // thread in this harness, and the glass pane installation is what is under + // test. It is package private, hence the reflective call. + com.codename1.ui.validation.Validator v = new com.codename1.ui.validation.Validator(); + v.setValidationFailureHighlightMode( + com.codename1.ui.validation.Validator.HighlightMode.EMBLEM); + java.lang.reflect.Method setValid = com.codename1.ui.validation.Validator.class + .getDeclaredMethod("setValid", Component.class, boolean.class); + setValid.setAccessible(true); + setValid.invoke(v, field, false); + + // The emblem is drawn by a glass pane, and the guard that installed it resolved + // the form -- null inside a Window -- so EMBLEM validation showed nothing there. + assertNotNull(w.getGlassPane(), + "emblem validation must install its glass pane on the window"); + + w.dispose(); + } + + @FormTest + void aBlockedWindowStillRefusesItsCloseRequest() { + implementation.setMultiWindowSupported(true); + Window owner = new Window("owner", new BorderLayout()); + owner.setWindowSize(400, 300); + owner.show(); + + final int[] closes = new int[1]; + owner.addCloseListener(new com.codename1.ui.events.ActionListener() { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + closes[0]++; + } + }); + + Window modal = new Window("modal", new BorderLayout()); + modal.setWindowSize(200, 150); + modal.setModalityType(Window.MODALITY_APPLICATION); + modal.show(); + flushSerialCalls(); + + // A close arrives outside the packed input queue, so it bypasses the modality + // filter that guards every other event. The check moved onto the event dispatch + // thread -- the modal stack is mutated there, so reading it from the port's + // callback thread raced -- and this asserts the move kept the behaviour. + Desktop.getInstance().windowCloseRequested(owner.getWindowId()); + flushSerialCalls(); + assertEquals(0, closes[0], + "a window blocked by an application modal must not close"); + + modal.dispose(); + flushSerialCalls(); + + Desktop.getInstance().windowCloseRequested(owner.getWindowId()); + flushSerialCalls(); + assertEquals(1, closes[0], + "and must close again once the modal is gone"); + + owner.dispose(); + } + + @FormTest + void aCommandBackedButtonNotifiesTheWindowsCommandListeners() { + implementation.setMultiWindowSupported(true); + Window w = new Window("cmd button", new BorderLayout()); + w.setWindowSize(300, 200); + + final int[] commandRuns = new int[1]; + Command cmd = new Command("Go") { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + commandRuns[0]++; + } + }; + final int[] listenerSaw = new int[1]; + w.addCommandListener(new com.codename1.ui.events.ActionListener() { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + listenerSaw[0]++; + } + }); + + Button b = new Button(cmd); + w.add(BorderLayout.CENTER, b); + w.show(); + w.revalidate(); + + // Button.fireActionEvent forwarded the post-command event through the form, + // null in a window, so the window's command listeners never saw the activation. + b.pressed(); + b.released(); + flushSerialCalls(); + + assertEquals(1, commandRuns[0], "the command runs exactly once"); + assertEquals(1, listenerSaw[0], + "and the window's command listeners must be notified once"); + + w.dispose(); + } + + @FormTest + void enablingTextSelectionInsideAWindowDoesNotThrow() { + implementation.setMultiWindowSupported(true); + Window w = new Window("selection", new BorderLayout()); + w.setWindowSize(300, 200); + TextArea area = new TextArea("selectable"); + w.add(BorderLayout.CENTER, area); + w.show(); + w.revalidate(); + + // TextSelection is exposed on every TopLevelContainer, but setEnabled resolved + // the root's form and dereferenced the null result, so enabling it threw in + // every secondary window. + TextSelection sel = w.getTextSelection(); + assertNotNull(sel); + sel.setEnabled(true); + assertTrue(sel.isEnabled(), "text selection must enable inside a window"); + + sel.setEnabled(false); + assertFalse(sel.isEnabled()); + + w.dispose(); + } + + @FormTest + void centeringOnAFormUsesTheMainWindowNotTheWorkArea() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + // The main window deliberately does not fill the work area, which is the case + // that separates the two behaviours. + wm.setMainWindowBounds(200, 100, 600, 400); + + Window w = new Window("centred", new BorderLayout()); + w.setWindowSize(200, 100); + w.show(); + + // A Form lives in the application's main native window, so centring over a Form + // has to centre over that window. Falling through to centerOnDesktop() centred + // on the monitor work area instead -- a different place whenever the main + // window has been moved or does not fill the screen. + w.centerOn(Display.getInstance().getCurrent()); + + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertEquals(200 + (600 - 200) / 2, peer.getX(), + "centred horizontally over the main window"); + assertEquals(100 + (400 - 100) / 2, peer.getY(), + "centred vertically over the main window"); + + w.dispose(); + } + + @FormTest + void aCommandListInsideAWindowNotifiesItsCommandListeners() { + implementation.setMultiWindowSupported(true); + Window w = new Window("cmd list", new BorderLayout()); + w.setWindowSize(300, 200); + + final int[] commandRuns = new int[1]; + Command cmd = new Command("Pick") { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + commandRuns[0]++; + } + }; + final int[] listenerSaw = new int[1]; + w.addCommandListener(new com.codename1.ui.events.ActionListener() { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + listenerSaw[0]++; + } + }); + + List list = new List(new Command[]{cmd}); + list.setCommandList(true); + w.add(BorderLayout.CENTER, list); + w.show(); + w.revalidate(); + + // List.fireActionEvent invoked the command and then dispatched the follow-up + // through the form, null in a window, so the window's command listeners never + // saw the activation. + list.setSelectedIndex(0); + list.fireActionEvent(); + flushSerialCalls(); + + assertEquals(1, commandRuns[0], "the command runs exactly once"); + assertEquals(1, listenerSaw[0], + "and the window's command listeners must be notified once"); + + w.dispose(); + } + + /// The text a toolbar is currently showing as its title, or null. + private static String titleTextOf(Toolbar tb) { + Component cmp = tb.getTitleComponent(); + return cmp instanceof Label ? ((Label) cmp).getText() : null; + } + + /// The first day cell in a calendar's month view. + /// Runs the animation manager until nothing is in progress and its post-animation + /// queue has drained, which is where work handed to `AnimationManager#flushAnimation` + /// during a layout animation ends up. + private void pumpAnimations(Window w) { + AnimationManager mgr = w.getAnimationManager(); + long deadline = System.currentTimeMillis() + 5000; + while (mgr.isAnimating() && System.currentTimeMillis() < deadline) { + mgr.updateAnimations(); + flushSerialCalls(); + try { + Thread.sleep(10); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + break; + } + } + // updateAnimations() drains the post-animation queue only on a pass that finds + // the animation list already empty, and the pass that empties it is not that + // pass -- so a single trailing call is one short. + for (int iter = 0; iter < 5; iter++) { + mgr.updateAnimations(); + flushSerialCalls(); + } + } + + /// Finds the button a `Command` was rendered into, anywhere under the top level. + private static Button findButtonForCommand(Container c, Command cmd) { + for (int iter = 0; iter < c.getComponentCount(); iter++) { + Component cmp = c.getComponentAt(iter); + if (cmp instanceof Button && ((Button) cmp).getCommand() == cmd) { + return (Button) cmp; + } + if (cmp instanceof Container) { + Button b = findButtonForCommand((Container) cmp, cmd); + if (b != null) { + return b; + } + } + } + return null; + } + + /// Finds the first button carrying a `Command`, which is how the test reaches a + /// toolbar's back arrow without a public accessor for it. + private static Button findFirstCommandButton(Container c) { + for (int iter = 0; iter < c.getComponentCount(); iter++) { + Component cmp = c.getComponentAt(iter); + if (cmp instanceof Button && ((Button) cmp).getCommand() != null) { + return (Button) cmp; + } + if (cmp instanceof Container) { + Button b = findFirstCommandButton((Container) cmp); + if (b != null) { + return b; + } + } + } + return null; + } + + private static Button findFirstDayButton(Container c) { + for (int iter = 0; iter < c.getComponentCount(); iter++) { + Component cmp = c.getComponentAt(iter); + if (cmp instanceof Button && ((Button) cmp).getText().length() > 0 + && Character.isDigit(((Button) cmp).getText().charAt(0))) { + return (Button) cmp; + } + if (cmp instanceof Container) { + Button b = findFirstDayButton((Container) cmp); + if (b != null) { + return b; + } + } + } + return null; + } + + /// An image that reports itself as an animation, which is what drives the + /// registration path under test. + private static Image makeAnimatedImage() { + return new Image(null) { + @Override + public boolean isAnimation() { + return true; + } + + @Override + public boolean animate() { + return false; + } + + @Override + public int getWidth() { + return 8; + } + + @Override + public int getHeight() { + return 8; + } + }; + } + + @FormTest + void legacyPullToRefreshRegistersItsAnimationOnTheWindow() { + implementation.setMultiWindowSupported(true); + final AtomicInteger registered = new AtomicInteger(); + Window w = new Window("pull", new BorderLayout()) { + @Override + public void registerAnimated(Animation cmp) { + registered.incrementAndGet(); + super.registerAnimated(cmp); + } + }; + Container scrollable = new Container(new BorderLayout()); + scrollable.setScrollableY(true); + w.add(BorderLayout.CENTER, scrollable); + w.show(); + + LookAndFeel laf = UIManager.getInstance().getLookAndFeel(); + assertTrue(laf instanceof DefaultLookAndFeel, + "This test drives the default look and feel's legacy pull-to-refresh path"); + try { + // Also initializes the pull container the legacy path draws through. + int threshold = laf.getPullToRefreshHeight(); + Graphics g = Image.createImage(60, 60).getGraphics(); + + // First pass swaps the "pull down to refresh" label into the container. + laf.drawPullToRefresh(g, scrollable, false); + // Crossing the threshold swaps in "release to refresh", and that swap is + // what registers the icon rotation animation on the top level. Before the + // migration this went through getComponentForm(), which is null in a + // Window, so the gesture threw on the EDT instead of animating. + scrollable.setScrollY(-(threshold + 1)); + laf.drawPullToRefresh(g, scrollable, false); + + assertTrue(registered.get() > 0, + "Pull-to-refresh must register its animation on the Window that hosts it"); + } finally { + // The look and feel keeps the pull container in a field shared by every + // test in this JVM, so a failure here must still tear the window down or + // it cascades into unrelated tests. + w.dispose(); + } + } + + + @FormTest + void movingAShownWindowInvalidatesItsCachedMonitor() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + java.util.List two = + new ArrayList(); + two.add(new TestWindowManager.FakeMonitor(0, 0, 1440, 900, 1.0, 96, "primary")); + two.add(new TestWindowManager.FakeMonitor(1440, 0, 2560, 1440, 2.0, 192, "second")); + wm.setMonitors(two); + + Window w = new Window("mover", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertNotNull(peer); + + // Populate the cache while the window is still on the primary display. + assertEquals(0, w.getMonitor().getIndex()); + assertEquals(1.0, w.getScale(), 0.0001); + + // Now it sits on the second display. A real port reports this through the + // monitor-change callback, which is queued back to the event dispatch thread + // -- so a move followed by a query in the same turn has to answer correctly + // without it, or centerOnDesktop() sends the window back where it came from. + peer.setMonitor(1); + w.setWindowLocation(1500, 100); + + assertEquals(1, w.getMonitor().getIndex(), + "a move must invalidate the cached monitor"); + assertEquals(2.0, w.getScale(), 0.0001, + "and the scale that is answered from it"); + w.dispose(); + } + + @FormTest + void aWindowMoveBetweenMonitorsIsNotADesktopTopologyEvent() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + java.util.List two = + new ArrayList(); + two.add(new TestWindowManager.FakeMonitor(0, 0, 1440, 900, 1.0, 96, "primary")); + two.add(new TestWindowManager.FakeMonitor(1440, 0, 2560, 1440, 2.0, 192, "second")); + wm.setMonitors(two); + + final int[] fired = new int[1]; + Desktop.getInstance().addMonitorListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + fired[0]++; + } + }); + + Window w = new Window("dragged", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertNotNull(peer); + assertEquals(0, w.getMonitor().getIndex()); + + // Dragging one window onto another display is not a change of topology. + // addMonitorListener is documented for a monitor being attached, removed or + // reconfigured, so firing it here made every move across a mixed-DPI desktop + // re-run whatever display reconfiguration work the application does. + try { + peer.setMonitor(1); + Desktop.getInstance().windowMonitorChanged(w.getWindowId()); + DisplayTest.flushEdt(); + + assertEquals(0, fired[0], + "moving a window between monitors must not notify monitor listeners"); + assertEquals(1, w.getMonitor().getIndex(), + "but the window itself must follow the display it is now on"); + assertEquals(2.0, w.getScale(), 0.0001); + + // A real topology change still notifies. + Desktop.getInstance().monitorsChanged(); + DisplayTest.flushEdt(); + assertEquals(1, fired[0], "an attach, removal or reconfiguration still notifies"); + } finally { + // A window left undisposed by a failing assertion keeps the event dispatch + // thread busy and times out whatever runs next, which buries the real + // failure under an unrelated one. + w.dispose(); + } + } + + @FormTest + void terminalEventsReportTheGeometryTheWindowActuallyHad() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("mover", new BorderLayout()); + w.setWindowBounds(new Rectangle(10, 20, 400, 300)); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertNotNull(peer); + + final java.util.List disposedBounds = new ArrayList(); + w.addWindowListener(new ActionListener() { + @Override + public void actionPerformed(WindowEvent evt) { + if (evt.getType() == WindowEvent.Type.Disposed) { + disposedBounds.add(evt.getBounds()); + } + } + }); + + // The user drags and resizes it natively. Nothing in the application asked for + // this geometry, so it lives only in the peer. + wm.setBounds(peer, 640, 480, 900, 700); + w.dispose(); + + assertEquals(1, disposedBounds.size(), "disposal must report exactly once"); + Rectangle r = disposedBounds.get(0); + // Before the snapshot, disposal nulled the peer first and getWindowBounds() + // fell back to the originally requested rectangle -- so a listener persisting + // geometry across runs restored the window to where it was never left. + assertEquals(640, r.getX(), "the final native position, not the requested one"); + assertEquals(480, r.getY()); + assertEquals(900, r.getWidth()); + assertEquals(700, r.getHeight()); + } + + @FormTest + void anInteractionDialogShowsOnTheWindowItWasGiven() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + flushSerialCalls(); + + Window w = new Window("host", new BorderLayout()); + w.setWindowSize(500, 400); + w.show(); + w.revalidate(); + + com.codename1.components.InteractionDialog dlg = + new com.codename1.components.InteractionDialog("in the window"); + dlg.setAnimateShow(false); + try { + // Without a host the dialog resolves Display.getCurrent() and lands in the + // main form's layered pane -- so it appears on the main window while the + // window that asked for it is merely dimmed. + dlg.setTopLevelHost(w); + dlg.show(10, 10, 10, 10); + flushSerialCalls(); + + assertSame(w, dlg.getTopLevelContainer(), + "the dialog must be attached to the window it was given"); + assertNull(dlg.getComponentForm(), + "and therefore to no form at all"); + } finally { + dlg.dispose(); + w.dispose(); + } + } + + @FormTest + void anInteractionDialogWithNoHostStillUsesTheCurrentForm() { + Form main = new Form("main", new BorderLayout()); + main.show(); + flushSerialCalls(); + + com.codename1.components.InteractionDialog dlg = + new com.codename1.components.InteractionDialog("on the form"); + dlg.setAnimateShow(false); + try { + // The historical behaviour, which every single-window application relies on. + dlg.show(10, 10, 10, 10); + flushSerialCalls(); + assertSame(main, dlg.getComponentForm(), + "an unhosted dialog must still land on the current form"); + } finally { + dlg.dispose(); + } + } + + private static int invokeHostGeometry(Toolbar tb, String method) throws Exception { + java.lang.reflect.Method m = Toolbar.class.getDeclaredMethod(method); + m.setAccessible(true); + return ((Integer) m.invoke(tb)).intValue(); + } + + /// The side menu's dialog, found by walking the window rather than through an + /// accessor the toolbar does not expose. + private static com.codename1.components.InteractionDialog findSideMenuDialog(Container c) { + for (int iter = 0; iter < c.getComponentCount(); iter++) { + Component cmp = c.getComponentAt(iter); + if (cmp instanceof com.codename1.components.InteractionDialog) { + return (com.codename1.components.InteractionDialog) cmp; + } + if (cmp instanceof Container) { + com.codename1.components.InteractionDialog inner = + findSideMenuDialog((Container) cmp); + if (inner != null) { + return inner; + } + } + } + return null; + } + + @FormTest + void aPopupForAWindowComponentOpensInThatWindow() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + flushSerialCalls(); + + Window w = new Window("host", new BorderLayout()); + w.setWindowSize(500, 400); + Button anchor = new Button("anchor"); + w.add(BorderLayout.CENTER, anchor); + w.show(); + w.revalidate(); + + com.codename1.components.InteractionDialog dlg = + new com.codename1.components.InteractionDialog("popup"); + dlg.setAnimateShow(false); + try { + // The popup is anchored to a component in the window, and its rectangle is + // in that window's coordinate space. Resolving the current form instead + // opened it over the main window at coordinates that mean nothing there. + dlg.showPopupDialog(anchor); + flushSerialCalls(); + + assertSame(w, dlg.getTopLevelContainer(), + "a popup anchored in a window must open in that window"); + assertNull(dlg.getComponentForm()); + } finally { + dlg.dispose(); + w.dispose(); + } + } + + @FormTest + void aModalityChangeMadeWhileMinimizedSurvivesRestoration() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + flushSerialCalls(); + + Window w = new Window("modal", new BorderLayout()); + w.setWindowSize(400, 300); + w.setModalityType(Window.MODALITY_APPLICATION); + w.show(); + flushSerialCalls(); + try { + assertTrue(Desktop.getInstance().isWindowInputBlocked(0), + "an application modal window blocks the main window"); + + // The platform minimizes it. A minimized window is still open and still + // modal -- isModalFinished() says so itself. + w.hideNotify(); + assertTrue(Desktop.getInstance().isWindowInputBlocked(0), + "minimizing a modal window does not end the modal"); + + // Changing modality here released the old blocker and declined to take the + // new one, and showNotify() never reacquires, so the window came back + // visibly non-modal while getModalityType() still said otherwise. + w.setModalityType(Window.MODALITY_APPLICATION); + assertEquals(Window.MODALITY_APPLICATION, w.getModalityType()); + assertTrue(Desktop.getInstance().isWindowInputBlocked(0), + "a modality change while minimized must keep the window modal"); + + w.showNotify(); + assertTrue(Desktop.getInstance().isWindowInputBlocked(0), + "and it must still be modal once restored"); + } finally { + w.dispose(); + } + } + + @FormTest + void aPickerInsideAWindowOpensItsPopupThere() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + flushSerialCalls(); + + Window w = new Window("host", new BorderLayout()); + w.setWindowSize(500, 400); + com.codename1.ui.spinner.Picker picker = new com.codename1.ui.spinner.Picker(); + picker.setType(com.codename1.ui.Display.PICKER_TYPE_STRINGS); + picker.setStrings("one", "two", "three"); + picker.setSelectedString("one"); + w.add(BorderLayout.CENTER, picker); + w.show(); + w.revalidate(); + try { + // The lightweight popup is the default wherever the platform has no native + // picker, which is every desktop port. This threw "Attempt to show + // interaction dialog while button is not on form" because it insisted on a + // Form, making a standard component unusable in every secondary window. + picker.pressed(); + picker.released(); + flushSerialCalls(); + + com.codename1.components.InteractionDialog popup = findDialogIn(w); + assertNotNull(popup, "the picker's popup must open inside the window"); + } finally { + w.dispose(); + } + } + + /// The first InteractionDialog anywhere under the given container. + private static com.codename1.components.InteractionDialog findDialogIn(Container c) { + for (int iter = 0; iter < c.getComponentCount(); iter++) { + Component cmp = c.getComponentAt(iter); + if (cmp instanceof com.codename1.components.InteractionDialog) { + return (com.codename1.components.InteractionDialog) cmp; + } + if (cmp instanceof Container) { + com.codename1.components.InteractionDialog inner = findDialogIn((Container) cmp); + if (inner != null) { + return inner; + } + } + } + return null; + } + + @FormTest + void anOpenPickerInAWindowIsEditableAndCanBeStopped() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + flushSerialCalls(); + + Window w = new Window("host", new BorderLayout()); + w.setWindowSize(500, 400); + com.codename1.ui.spinner.Picker picker = new com.codename1.ui.spinner.Picker(); + picker.setType(com.codename1.ui.Display.PICKER_TYPE_STRINGS); + picker.setStrings("one", "two", "three"); + picker.setSelectedString("one"); + w.add(BorderLayout.CENTER, picker); + w.show(); + w.revalidate(); + try { + picker.pressed(); + picker.released(); + flushSerialCalls(); + + // registerAsInputDevice resolved a Form and so skipped every registration + // inside a window: the popup opened but reported isEditing() false, which + // is what window-level input-device replacement and stopEditing() both go + // through -- so nothing could dismiss it. + assertTrue(picker.isEditing(), + "an open picker in a window must report itself as editing"); + assertNotNull(w.getCurrentInputDevice(), + "and must register as the window's current input device"); + + final boolean[] stopped = new boolean[1]; + picker.stopEditing(new Runnable() { + @Override + public void run() { + stopped[0] = true; + } + }); + flushSerialCalls(); + // The popup closes with a dispose animation, so the callback is queued + // behind it rather than running inline. + pumpAnimations(w); + assertTrue(stopped[0], "stopEditing must close it and run the callback"); + } finally { + w.dispose(); + } + } + + @FormTest + void aValidationErrorPopupAppearsInsideItsWindow() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + flushSerialCalls(); + + Window w = new Window("host", new BorderLayout()); + w.setWindowSize(500, 400); + TextField field = new TextField(""); + w.add(BorderLayout.CENTER, field); + w.show(); + w.revalidate(); + try { + com.codename1.ui.validation.Validator v = + new com.codename1.ui.validation.Validator(); + v.setShowErrorMessageForFocusedComponent(true); + v.addConstraint(field, + new com.codename1.ui.validation.LengthConstraint(3, "too short")); + assertFalse(v.isValid(), "an empty field must fail the length constraint"); + + // Driven the way the framework drives it. Going through setFocused() does + // not work here: showing the window already focused its only focusable + // child, so setFocused() short-circuits and nothing fires -- a test built + // that way passes whatever the code does. + // + // The listener compared getComponentForm() with the current Form, and in a + // window that is null against a non-null form, so it returned every time + // and the configured popup never appeared. + field.fireFocusGained(); + flushSerialCalls(); + pumpAnimations(w); + + assertNotNull(findDialogIn(w), + "the validation error popup must appear inside the window"); + } finally { + w.dispose(); + } + } + + @FormTest + void geometryChangedOffTheEdtIsMarshalled() throws Exception { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + final Window w = new Window("marshalled", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertNotNull(peer); + + try { + // The developer guide promises that moving a window from a background + // thread is marshalled the way Form.show() is. Unmarshalled, this mutated + // the pending geometry and the cached monitor while the event dispatch + // thread was reading them, and drove the window manager concurrently with + // the callbacks reporting the result. + final boolean[] onEdt = new boolean[]{true}; + Thread t = new Thread(new Runnable() { + @Override + public void run() { + onEdt[0] = Display.getInstance().isEdt(); + w.setWindowLocation(120, 90); + w.setWindowSize(640, 480); + } + }); + t.start(); + t.join(5000); + assertFalse(onEdt[0], "the mutation has to be made off the EDT to prove anything"); + + // The point of the fix is that the work is *deferred*, so that is what is + // asserted. Checking only the end state proves nothing: an unmarshalled + // mutation reaches the same numbers, just on the wrong thread. + Rectangle before = w.getWindowBounds(); + assertEquals(400, before.getWidth(), + "the background call must not have touched the window yet"); + assertEquals(300, before.getHeight()); + + flushSerialCalls(); + + Rectangle b = w.getWindowBounds(); + assertEquals(120, b.getX(), "the queued move must have been applied"); + assertEquals(90, b.getY()); + assertEquals(640, b.getWidth(), "and the queued resize with it"); + assertEquals(480, b.getHeight()); + } finally { + w.dispose(); + } + } + + @FormTest + void aBackgroundResizeThenMoveKeepsBothChanges() throws Exception { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + final Window w = new Window("resize then move", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + assertNotNull(wm.getLastWindow()); + + try { + Thread t = new Thread(new Runnable() { + @Override + public void run() { + w.setWindowSize(800, 600); + w.setWindowLocation(10, 10); + } + }); + t.start(); + t.join(5000); + flushSerialCalls(); + + // setWindowLocation used to read the bounds before entering the event + // dispatch thread, so it queued a full rectangle carrying the size from + // *before* the queued resize -- the resize was applied and then silently + // undone by the move. + Rectangle b = w.getWindowBounds(); + assertEquals(10, b.getX()); + assertEquals(10, b.getY()); + assertEquals(800, b.getWidth(), + "the move must not carry a size read before the queued resize"); + assertEquals(600, b.getHeight()); + } finally { + w.dispose(); + } + } + + @FormTest + void windowsCreatedConcurrentlyGetDistinctIds() throws Exception { + implementation.setMultiWindowSupported(true); + final int threads = 8; + final int perThread = 25; + final java.util.List ids = + java.util.Collections.synchronizedList(new ArrayList()); + final java.util.List made = + java.util.Collections.synchronizedList(new ArrayList()); + Thread[] workers = new Thread[threads]; + for (int iter = 0; iter < threads; iter++) { + workers[iter] = new Thread(new Runnable() { + @Override + public void run() { + for (int i = 0; i < perThread; i++) { + Window w = new Window("concurrent"); + made.add(w); + ids.add(Integer.valueOf(w.getWindowId())); + } + } + }); + } + try { + for (Thread t : workers) { + t.start(); + } + for (Thread t : workers) { + t.join(10000); + } + + // A constructor cannot be marshalled, so two background threads really do + // allocate ids concurrently. A collision gives two native windows one id, + // and windowById() returns the first match -- so every input and lifecycle + // callback for the second would land on the first. + assertEquals(threads * perThread, ids.size(), "every window must be built"); + java.util.Set unique = new java.util.HashSet(ids); + assertEquals(ids.size(), unique.size(), "window ids must be unique"); + } finally { + for (Window w : made) { + w.dispose(); + } + } + } + + @FormTest + void heldInputTimersStopAtAWindowThatBecameBlocked() throws Exception { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + flushSerialCalls(); + + Window w = new Window("target", new BorderLayout()); + w.setWindowSize(400, 300); + RepeatCountingComponent c = new RepeatCountingComponent(); + c.setFocusable(true); + w.add(BorderLayout.CENTER, c); + w.show(); + w.asContainer().revalidate(); + w.setFocused(c); + flushSerialCalls(); + + Window modal = new Window("modal", new BorderLayout()); + modal.setWindowSize(200, 150); + try { + // A key held down in the window. The timers are driven with an explicit + // clock rather than by waiting out the 800ms first-repeat delay, so the + // test is deterministic instead of timing-dependent. + Desktop.getInstance().windowKeyPressed(w.getWindowId(), 65); + DisplayTest.flushEdt(); + w.serviceInputTimers(System.currentTimeMillis() + 5000, 500); + assertTrue(c.repeats > 0, "a held key repeats into the window that saw it"); + + // A modal goes up over everything. The held key has not been released, so + // the timer is still armed -- but its repeats must stop reaching a window + // the user can no longer interact with. + modal.setModalityType(Window.MODALITY_APPLICATION); + modal.show(); + flushSerialCalls(); + int before = c.repeats; + w.serviceInputTimers(System.currentTimeMillis() + 10000, 500); + assertEquals(before, c.repeats, + "a window blocked by a modal must not receive repeats or long presses"); + } finally { + modal.dispose(); + w.dispose(); + DisplayTest.flushEdt(); + } + } + + /// Counts key repeats reaching a component. + private static final class RepeatCountingComponent extends Component { + private int repeats; + + @Override + public void keyRepeated(int keyCode) { + repeats++; + } + + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + return new com.codename1.ui.geom.Dimension(120, 90); + } + } + + @FormTest + void aBurstOfResizesDeliversTheFinalSize() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("resized", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + assertNotNull(wm.getLastWindow()); + try { + // Live resizing produces far more notifications than the packed stack + // holds, and the one it drops can be the last -- leaving the hierarchy + // laid out for a size the native surface has already moved past. + for (int iter = 0; iter < 500; iter++) { + Desktop.getInstance().windowSizeChanged(w.getWindowId(), 500 + iter, 400 + iter); + } + flushSerialCalls(); + + assertEquals(999, w.getWidth(), "the final size must survive the burst"); + assertEquals(899, w.getHeight()); + } finally { + w.dispose(); + } + } + + @FormTest + void restoringAWindowTheApplicationHidDoesNotPutItBackOnScreen() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("Hidden", new BorderLayout()); + w.setWindowSize(320, 240); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + w.hide(); + assertFalse(w.isWindowShowing(), "hide() leaves the window not showing"); + + w.restore(); + // The native window would come back with the framework still counting it + // hidden: the hierarchy stays invisible and the paint loop skips it, so the + // platform would show a blank or stale window that isWindowShowing() denies. + assertEquals(0, peer.getRestoreCount(), + "restore() must not put back a window the application hid -- that is " + + "show()'s job, which restores the whole lifecycle"); + assertFalse(w.isWindowShowing(), + "and the framework's own view of it must not change either"); + w.dispose(); + } + + @FormTest + void restoringAMinimizedWindowStillReachesThePlatform() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("Minimized", new BorderLayout()); + w.setWindowSize(320, 240); + w.show(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + w.minimize(); + // The guard is deliberately not "iconified only": the platform has not reported + // the minimize yet at this point, and an application that minimizes and + // immediately restores still has to get its window back. + w.restore(); + assertEquals(1, peer.getRestoreCount(), + "a minimized window must still be restorable, including before the " + + "platform has reported the minimize"); + w.dispose(); + } + + @FormTest + void aKeyRepeatFollowsTheWindowThatSawThePressNotTheFocusedOne() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + final KeyCountingComponent mainKeys = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, mainKeys); + main.show(); + main.setFocused(mainKeys); + + Window w = new Window("other", new BorderLayout()); + final KeyCountingComponent windowKeys = new KeyCountingComponent(); + w.add(BorderLayout.CENTER, windowKeys); + w.setWindowSize(300, 200); + w.show(); + w.setFocused(windowKeys); + + // Hold a key on the main form, then let the autorepeat arrive tagged with the + // window, which is what happens when focus moves while the key is still down -- + // the platform sends every repeat to whatever is focused now. + Display.getInstance().keyPressed(-93); + Desktop.getInstance().windowKeyPressed(w.getWindowId(), -93); + Desktop.getInstance().windowKeyPressed(w.getWindowId(), -93); + DisplayTest.flushEdt(); + + assertEquals(3, mainKeys.pressed, + "every repeat of a held key belongs to the top level that saw it go down"); + assertEquals(0, windowKeys.pressed, + "the newly focused window must not enter a pressed state for a key it " + + "never saw go down -- no release is coming for it"); + + // And the release still resolves to the same place, so nothing is left latched. + Desktop.getInstance().windowKeyReleased(w.getWindowId(), -93); + DisplayTest.flushEdt(); + assertEquals(1, mainKeys.released); + assertEquals(0, windowKeys.released); + w.dispose(); + } + + /// Counts the terminal visibility events a window reports. + private static int[] countHiddenAndDisposed(Window w) { + final int[] counts = new int[2]; + w.addWindowListener(new ActionListener() { + @Override + public void actionPerformed(WindowEvent evt) { + if (evt.getType() == WindowEvent.Type.Hidden) { + counts[0]++; + } else if (evt.getType() == WindowEvent.Type.Disposed) { + counts[1]++; + } + } + }); + return counts; + } + + @FormTest + void disposingAShownWindowReportsHiddenExactlyOnce() { + implementation.setMultiWindowSupported(true); + Window w = new Window("shown", new BorderLayout()); + w.setWindowSize(320, 240); + w.show(); + int[] counts = countHiddenAndDisposed(w); + w.dispose(); + assertEquals(1, counts[0], "taking a window off screen is one Hidden"); + assertEquals(1, counts[1]); + } + + @FormTest + void disposingAnAlreadyHiddenWindowDoesNotReportHiddenTwice() { + implementation.setMultiWindowSupported(true); + Window w = new Window("hidden", new BorderLayout()); + w.setWindowSize(320, 240); + w.show(); + int[] counts = countHiddenAndDisposed(w); + w.hide(); + assertEquals(1, counts[0], "hide() reports the transition"); + w.dispose(); + // A listener persisting geometry or running teardown off Hidden would do it + // twice for one disappearance. + assertEquals(1, counts[0], + "dispose() must not repeat a transition hide() already reported"); + assertEquals(1, counts[1], "and must still report Disposed"); + } + + @FormTest + void disposingAWindowThatWasNeverShownReportsNoHidden() { + implementation.setMultiWindowSupported(true); + Window w = new Window("never shown", new BorderLayout()); + w.setWindowSize(320, 240); + int[] counts = countHiddenAndDisposed(w); + w.dispose(); + assertEquals(0, counts[0], + "a window that was never on screen cannot have gone off it"); + assertEquals(1, counts[1]); + } + + @FormTest + void anActivationFailureReportedOnTheEdtAppliesInThatSameTurn() { + implementation.setMultiWindowSupported(true); + Window w = new Window("refused", new BorderLayout()); + w.setWindowSize(320, 240); + w.setModalityType(Window.MODALITY_APPLICATION); + w.show(); + assertTrue(w.isWindowShowing()); + + // A port validates the failure against the request token it belongs to and then + // reports it. Queueing the framework half into a later turn lets a retrying + // show() run in between and be undone by a failure that no longer applies to + // it, so the report has to take effect in the turn that validated it. + Desktop.getInstance().windowActivationFailed(w.getWindowId()); + + assertFalse(w.isWindowShowing(), + "the failure has to take effect in the caller's own turn, not a later " + + "one, or a retry started in between is the thing it lands on"); + assertFalse(w.isWindowDisposed(), + "the window is still registered -- a later show() may ask the platform " + + "again; only its visibility and modality are given up"); + w.dispose(); + } + + @FormTest + void hidingAnOwnerReleasesItsModalChildsGripOnTheApplication() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Display d = Display.getInstance(); + Window owner = new Window("owner", new BorderLayout()); + owner.setWindowSize(400, 300); + owner.show(); + Window modal = new Window("modal", new BorderLayout()); + modal.setWindowSize(200, 150); + modal.setOwnerWindow(owner); + modal.setModalityType(Window.MODALITY_APPLICATION); + modal.show(); + DisplayTest.flushEdt(); + // An unrelated window, because what an application modal costs when it will not + // let go is every other surface, not just the one that opened it. + Window bystander = new Window("bystander", new BorderLayout()); + bystander.setWindowSize(300, 200); + bystander.show(); + DisplayTest.flushEdt(); + TestWindowManager.FakeWindow bystanderPeer = wm.getLastWindow(); + try { + assertFalse(bystanderPeer.isInputEnabled(), + "an application modal blocks unrelated windows while it is up"); + + // The owner is hidden by the application. Every port cascades that to the + // children it owns and reports them through windowHideNotify, which is the + // minimize path -- so the child keeps its modal registration on purpose. + owner.hide(); + Desktop.getInstance().windowHideNotify(modal.getWindowId()); + DisplayTest.flushEdt(); + + assertTrue(bystanderPeer.isInputEnabled(), + "with its owner hidden the modal is on nobody's screen, so it must " + + "not go on blocking every other window with nothing " + + "available to dismiss it"); + + // And it takes the block back when the owner returns, rather than being + // permanently disarmed. + owner.show(); + Desktop.getInstance().windowShowNotify(modal.getWindowId()); + DisplayTest.flushEdt(); + assertFalse(bystanderPeer.isInputEnabled(), + "the modal blocks again once its owner is back on screen"); + } finally { + bystander.dispose(); + modal.dispose(); + owner.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void centeringFromABackgroundThreadUsesTheSizeItWillActuallyHave() throws Exception { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + final Window w = new Window("centre", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + DisplayTest.flushEdt(); + try { + // Both calls made off the event dispatch thread, in this order, while the + // queue is not draining -- which is the sequence the bug needs. setWindowSize + // marshals, so the resize is still pending when centerOnDesktop runs; reading + // the bounds before marshalling therefore centred for the size the window is + // about to stop having. + Thread background = new Thread(new Runnable() { + @Override + public void run() { + w.setWindowSize(200, 100); + w.centerOnDesktop(); + } + }); + background.start(); + background.join(); + DisplayTest.flushEdt(); + + int[] work = wm.getMonitorWorkArea(0, new int[4]); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + assertEquals(work[0] + (work[2] - 200) / 2, peer.getX(), + "centred for the new width, not the width the resize replaced"); + assertEquals(work[1] + (work[3] - 100) / 2, peer.getY(), + "centred for the new height, not the height the resize replaced"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void restoringFromABackgroundThreadDoesNotReachThePortBeforeTheOwner() throws Exception { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + final Window w = new Window("restore-me", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + DisplayTest.flushEdt(); + try { + final TestWindowManager.FakeWindow peer = wm.getLastWindow(); + w.minimize(); + DisplayTest.flushEdt(); + int before = peer.getRestoreCount(); + + Thread background = new Thread(new Runnable() { + @Override + public void run() { + w.restore(); + } + }); + background.start(); + background.join(); + + // The point of marshalling: nothing has reached the port yet, so the owner + // chain showOwnerChain() may have queued still runs first. Calling straight + // through from the background thread restored the child ahead of its owner + // and put a WindowManager call outside the only context the SPI is defined + // in. + assertEquals(before, peer.getRestoreCount(), + "restore() from a background thread must be queued, not handed " + + "straight to the port"); + DisplayTest.flushEdt(); + assertEquals(before + 1, peer.getRestoreCount(), + "and it must still happen once the queue drains"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + } + } + + /// A scrollable container that can be put into a glide, so a press can be made to + /// land on something still moving. + private static final class GlidingContainer extends Container { + GlidingContainer() { + super(new BorderLayout()); + setScrollableY(true); + } + + void startGliding() { + // The same field Form and Window both read to decide a container is still + // moving; setting it directly is what makes the state reachable in a test. + draggedMotionY = Motion.createLinearMotion(0, 1000, 10000); + draggedMotionY.start(); + } + + boolean isGliding() { + return draggedMotionY != null; + } + } + + @FormTest + void aPressOnAGlidingContainerHandsTheScrollOverInsteadOfSwallowingTheGesture() { + implementation.setMultiWindowSupported(true); + Window w = new Window("glide", new BorderLayout()); + w.setWindowSize(400, 300); + GlidingContainer scroller = new GlidingContainer(); + DragCountingComponent c = new DragCountingComponent(); + scroller.add(BorderLayout.CENTER, c); + w.add(BorderLayout.CENTER, scroller); + w.show(); + w.asContainer().revalidate(); + scroller.startGliding(); + + try { + // A press onto the moving container, then the rest of the physical gesture. + implementation.windowPointerPressedForTest(w.getWindowId(), 100, 100); + DisplayTest.flushEdt(); + assertFalse(scroller.isGliding(), "the press has to stop the momentum scroll"); + for (int iter = 0; iter < 12; iter++) { + implementation.windowPointerDraggedForTest(w.getWindowId(), 100, 100 + iter * 20); + } + DisplayTest.flushEdt(); + assertTrue(c.drags > 0, + "the same gesture must be able to take the scroll over; stopping the " + + "motion with no drag target made the user lift and press again"); + } finally { + // In a finally so a failure here cannot leave a window showing and time out + // the next test's setup. + w.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void aPressListenerThatBlocksDoesNotLeaveTheGestureLatched() { + implementation.setMultiWindowSupported(true); + final Window w = new Window("nested", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + w.asContainer().revalidate(); + + // The handle has to exist by the time a listener runs, because a listener can + // enter a nested event loop and the matching release is processed inside it. + final Object[] seen = new Object[1]; + w.addPointerPressedListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + seen[0] = w.getCurrentPointerPress(); + } + }); + try { + implementation.windowPointerPressedForTest(w.getWindowId(), 100, 100); + DisplayTest.flushEdt(); + assertNotNull(seen[0], + "the press handle must be recorded before listeners run, or a listener " + + "that blocks sees no gesture and the release inside it clears " + + "nothing"); + assertSame(seen[0], w.getCurrentPointerPress(), + "and it must be the same gesture the rest of the press path uses"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void pullToRefreshOverlayFollowsTheComponentToANewHost() { + implementation.setMultiWindowSupported(true); + boolean material = com.codename1.components.InfiniteProgress.isDefaultMaterialDesignMode(); + com.codename1.components.InfiniteProgress.setDefaultMaterialDesignMode(true); + Form origin = new Form("origin", new BorderLayout()); + Window later = new Window("later", new BorderLayout()); + try { + // Built while the component lives in a Form: that is where the drag listener + // is created, and where it used to capture its host once and for all. + // Taller than the window on purpose: the pull gesture is gated on the + // container actually being scrollable, which needs content that overflows. + Container scroller = new Container(new com.codename1.ui.layouts.BoxLayout( + com.codename1.ui.layouts.BoxLayout.Y_AXIS)); + scroller.setScrollableY(true); + for (int iter = 0; iter < 40; iter++) { + scroller.add(new Label("row " + iter)); + } + scroller.setPullToRefresh(new Runnable() { + @Override + public void run() { + } + }); + origin.add(BorderLayout.CENTER, scroller); + origin.show(); + DisplayTest.flushEdt(); + + // Moved to a window, which re-registers the same listener instance on it. + origin.removeComponent(scroller); + later.setWindowSize(400, 300); + later.add(BorderLayout.CENTER, scroller); + later.show(); + later.asContainer().revalidate(); + DisplayTest.flushEdt(); + + // A pull gesture on the window. + int x = scroller.getAbsoluteX() + 10; + later.pointerPressed(x, scroller.getAbsoluteY() + 2); + later.pointerDragged(x, scroller.getAbsoluteY() + 60); + DisplayTest.flushEdt(); + + assertTrue(later.getLayeredPane( + com.codename1.components.InfiniteProgress.class, true) + .getComponentCount() > 0, + "the refresh overlay belongs to the top level the component is in " + + "now, not the one it was initialised in"); + assertEquals(0, origin.getLayeredPane( + com.codename1.components.InfiniteProgress.class, true) + .getComponentCount(), + "and nothing may be added to the top level it left"); + } finally { + later.dispose(); + com.codename1.components.InfiniteProgress.setDefaultMaterialDesignMode(material); + DisplayTest.flushEdt(); + } + } + + @FormTest + void aWindowKeepsPaintingWhileTheMainFormTransitions() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Window w = new Window("independent", new BorderLayout()); + w.setWindowSize(320, 240); + w.add(BorderLayout.CENTER, new Label("alive")); + w.show(); + DisplayTest.flushEdt(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + try { + // Put the main surface into a transition. The event loop takes an early + // return while one is running, which is right for the main surface and + // wrong for a window that has no part in it. + Form next = new Form("next", new BorderLayout()); + next.setTransitionInAnimator( + com.codename1.ui.animations.CommonTransitions.createFade(300)); + next.show(); + + int before = peer.getPaintCount(); + for (int iter = 0; iter < 6; iter++) { + w.repaint(); + DisplayTest.flushEdt(); + } + + assertTrue(peer.getPaintCount() > before, + "a secondary window is an independent native window and must keep " + + "painting while the main form transitions; it painted " + + before + " times before and " + peer.getPaintCount() + + " after"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void windowAttributesSetOffTheEdtReachThePortOnIt() throws Exception { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + final Window w = new Window("attributes", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + DisplayTest.flushEdt(); + try { + // Same reason as the window controls: the Linux and Windows ports resolve a + // peer to a slot in a native table on whatever thread calls them, and a + // slot is reused once its window is disposed -- so an attribute set from a + // background thread can land on whichever window took the slot, or race the + // teardown freeing it. + Thread background = new Thread(new Runnable() { + @Override + public void run() { + w.setDecorated(false); + w.setTitle("renamed"); + w.setUtilityWindow(true); + w.setWindowIcon(null); + w.setMinimumWindowSize(new com.codename1.ui.geom.Dimension(120, 90)); + w.setResizable(false); + } + }); + background.start(); + background.join(); + DisplayTest.flushEdt(); + + assertEquals(java.util.Collections.emptyList(), wm.getOffEdtCalls(), + "every window attribute has to reach the port on the event dispatch " + + "thread, however it was set"); + // The field is assigned on the calling thread, so the getter answers what + // the caller asked for whether or not the platform has caught up. + assertEquals("renamed", w.getTitle(), + "the getter stays consistent with the call that set it"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void windowControlsCalledOffTheEdtReachThePortOnIt() throws Exception { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + final Window w = new Window("controls", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + DisplayTest.flushEdt(); + try { + // The window manager SPI is defined on the event dispatch thread, and the + // ports take that literally: the Windows one resolves the peer to a slot + // index on whatever thread calls it and hands that index to the native + // layer, so a background caller can read a slot an EDT disposal is tearing + // down. What matters is therefore which thread the call arrives on. + Thread background = new Thread(new Runnable() { + @Override + public void run() { + w.minimize(); + w.toggleMaximize(); + w.requestWindowFocus(); + } + }); + background.start(); + background.join(); + DisplayTest.flushEdt(); + + assertEquals(java.util.Collections.emptyList(), wm.getOffEdtCalls(), + "every window control has to reach the port on the event dispatch " + + "thread, however it was called"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void aMovedListenerSeesTheMonitorTheWindowMovedTo() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + wm.setMonitors(java.util.Arrays.asList( + new TestWindowManager.FakeMonitor(0, 0, 1440, 900, 1.0, 96, "left"), + new TestWindowManager.FakeMonitor(1440, 0, 1920, 1080, 2.0, 192, "right"))); + final Window w = new Window("travelling", new BorderLayout()); + w.setWindowSize(400, 300); + w.show(); + DisplayTest.flushEdt(); + TestWindowManager.FakeWindow peer = wm.getLastWindow(); + try { + assertEquals("left", w.getMonitor().getName(), + "it starts on the first monitor"); + + final String[] seen = new String[1]; + w.addWindowListener(new ActionListener() { + @Override + public void actionPerformed(WindowEvent evt) { + if (evt.getType() == WindowEvent.Type.Moved && seen[0] == null) { + seen[0] = w.getMonitor().getName(); + } + } + }); + + // The platform moves it to the other monitor and reports the move. The + // monitor-changed notification is a separate one, queued after this. + peer.setMonitor(1); + Desktop.getInstance().windowMoved(w.getWindowId()); + DisplayTest.flushEdt(); + + assertEquals("right", seen[0], + "a Moved listener must see the monitor the window moved to, not the " + + "one it came from"); + } finally { + w.dispose(); + DisplayTest.flushEdt(); + } + } + + /// Counts pointer drags reaching a component. + private static final class DragCountingComponent extends Component { + private int drags; + + @Override + public void pointerDragged(int x, int y) { + drags++; + } + + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + return new com.codename1.ui.geom.Dimension(200, 150); + } + } + + @FormTest + void disposingWindowsMidPressDoesNotExhaustTheDragFilter() { + implementation.setMultiWindowSupported(true); + // Comfortably more than the fixed table this used to be tracked in, so a leak + // of one entry per window would have used the whole table up by the end. + int windows = 12; + for (int iter = 0; iter < windows; iter++) { + Window doomed = new Window("doomed" + iter, new BorderLayout()); + doomed.setWindowSize(400, 300); + doomed.add(BorderLayout.CENTER, new DragCountingComponent()); + doomed.show(); + doomed.asContainer().revalidate(); + // Pressed and then disposed without a release, which is what happens when + // a window is closed from inside its own pressed handler or the platform + // takes the pointer away. The filter state belongs to the window, so it + // goes with it; a table keyed by window id would keep the entry forever, + // since window ids are not reused. + implementation.windowPointerPressedForTest(doomed.getWindowId(), 100, 100); + doomed.dispose(); + DisplayTest.flushEdt(); + } + + // A fresh window must still get the activation filter. Starved of entries, + // the filter falls through and delivers the jitter as a real drag. + Window w = new Window("after", new BorderLayout()); + w.setWindowSize(400, 300); + DragCountingComponent c = new DragCountingComponent(); + w.add(BorderLayout.CENTER, c); + w.show(); + w.asContainer().revalidate(); + implementation.windowPointerPressedForTest(w.getWindowId(), 100, 100); + implementation.windowPointerDraggedForTest(w.getWindowId(), 101, 100); + DisplayTest.flushEdt(); + assertEquals(0, c.drags, + "a pixel of jitter is still not a drag after windows were disposed " + + "mid-press; the filter must not be starved"); + + // And the filter still passes a real drag, so this is not just a dead window. + for (int iter = 0; iter < 12; iter++) { + implementation.windowPointerDraggedForTest(w.getWindowId(), 100 + iter * 20, 100); + } + DisplayTest.flushEdt(); + assertTrue(c.drags > 0, "real movement must still reach the component"); + w.dispose(); + } + + @FormTest + void everyOpenWindowFiltersItsOwnDragNoMatterHowMany() { + implementation.setMultiWindowSupported(true); + // More than the fixed table of eight this used to be tracked in. Past that, + // the ninth window onwards got no filter at all and jitter reached it as a + // drag, which is the failure a per-window filter cannot have. + int count = 16; + java.util.List windows = new java.util.ArrayList(); + java.util.List targets = + new java.util.ArrayList(); + try { + for (int iter = 0; iter < count; iter++) { + Window w = new Window("concurrent" + iter, new BorderLayout()); + w.setWindowSize(400, 300); + DragCountingComponent c = new DragCountingComponent(); + w.add(BorderLayout.CENTER, c); + w.show(); + w.asContainer().revalidate(); + windows.add(w); + targets.add(c); + } + DisplayTest.flushEdt(); + + // Every one of them is pressed and jittered, all at once. + for (Window w : windows) { + implementation.windowPointerPressedForTest(w.getWindowId(), 100, 100); + } + for (Window w : windows) { + implementation.windowPointerDraggedForTest(w.getWindowId(), 101, 100); + } + DisplayTest.flushEdt(); + for (int iter = 0; iter < count; iter++) { + assertEquals(0, targets.get(iter).drags, + "window " + iter + " must filter its own jitter even with " + + count + " windows dragging at once"); + } + + // And a real drag in the last one still gets through, so the filter is + // doing its job rather than swallowing everything. + Window last = windows.get(count - 1); + for (int iter = 0; iter < 12; iter++) { + implementation.windowPointerDraggedForTest(last.getWindowId(), + 100 + iter * 20, 100); + } + DisplayTest.flushEdt(); + assertTrue(targets.get(count - 1).drags > 0, + "real movement still reaches the last window"); + } finally { + for (Window w : windows) { + w.dispose(); + } + DisplayTest.flushEdt(); + } + } + + @FormTest + void jitterInAWindowIsNotADragButRealMovementIs() { + implementation.setMultiWindowSupported(true); + Window w = new Window("drag", new BorderLayout()); + w.setWindowSize(400, 300); + DragCountingComponent c = new DragCountingComponent(); + w.add(BorderLayout.CENTER, c); + w.show(); + w.asContainer().revalidate(); + + // A press then a pixel of movement. Unfiltered this reached the component + // straight away, activating drag and drop and moving a draggable component on + // what the user meant as a click. + implementation.windowPointerPressedForTest(w.getWindowId(), 100, 100); + implementation.windowPointerDraggedForTest(w.getWindowId(), 101, 100); + DisplayTest.flushEdt(); + assertEquals(0, c.drags, + "a pixel of jitter after a press is not a drag"); + + // Movement well past the threshold is. + for (int iter = 0; iter < 12; iter++) { + implementation.windowPointerDraggedForTest(w.getWindowId(), 100 + iter * 20, 100); + } + DisplayTest.flushEdt(); + assertTrue(c.drags > 0, + "movement across the window is a drag and still has to get through"); + w.dispose(); + } + + @FormTest + void aKeyReleaseSurvivesAFloodOfPointerTraffic() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + final KeyCountingComponent keys = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, keys); + main.show(); + main.setFocused(keys); + + Display.getInstance().keyPressed(-97); + + // Enough pointer traffic to fill the input stack while the event dispatch + // thread has not drained it. Hover rather than drag: drags coalesce into a + // single slot, so any number of them would never fill anything -- which is + // also why the first version of this test passed without the reserve and + // proved nothing. + for (int iter = 0; iter < 400; iter++) { + Display.getInstance().pointerHover(new int[]{iter % 100}, new int[]{iter % 100}); + } + Display.getInstance().keyReleased(-97); + DisplayTest.flushEdt(); + + assertEquals(1, keys.pressed, "the press was accepted before the flood"); + assertEquals(1, keys.released, + "and its release has to get through, or the component it went to stays " + + "pressed and the key goes on repeating"); + } + + /// Whether any key-repeat slot is currently armed. + private static boolean anyKeyRepeatArmed() throws Exception { + if (keyRepeatArmedFor(0)) { + return true; + } + Window[] open = Desktop.getInstance().getWindows(); + for (int iter = 0; iter < open.length; iter++) { + if (open[iter].hasKeyRepeatArmed()) { + return true; + } + } + return false; + } + + @FormTest + void aRejectedKeyPressArmsNoRepeat() throws Exception { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + final KeyCountingComponent keys = new KeyCountingComponent(); + main.add(BorderLayout.CENTER, keys); + main.show(); + main.setFocused(keys); + + // Refused for certain, rather than by filling the stack to an exact boundary: + // drop mode is the same rejection the queue makes when it is full, and the + // question here is what happens to the timers when a press is refused, not how + // it came to be refused. + java.lang.reflect.Field drop = Display.class.getDeclaredField("dropEvents"); + drop.setAccessible(true); + drop.setBoolean(Display.getInstance(), true); + try { + Display.getInstance().keyPressed(65); + } finally { + drop.setBoolean(Display.getInstance(), false); + } + DisplayTest.flushEdt(); + + assertEquals(0, keys.pressed, + "the press was refused, so the component never saw it"); + // The repeat and long-press timers fire straight into the top level, so arming + // them off a refused press sends keyRepeated() to a component that never got + // keyPressed() -- and with the key still held, sends it every frame. + assertFalse(anyKeyRepeatArmed(), + "and nothing may be armed off a press that was never accepted"); + } + + @FormTest + void aRejectedPointerPressArmsNoLongPress() throws Exception { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + + java.lang.reflect.Field drop = Display.class.getDeclaredField("dropEvents"); + drop.setAccessible(true); + drop.setBoolean(Display.getInstance(), true); + try { + Display.getInstance().pointerPressed(new int[]{40}, new int[]{40}); + } finally { + drop.setBoolean(Display.getInstance(), false); + } + DisplayTest.flushEdt(); + + // longPointerPress() is delivered straight to the top level, so arming it off a + // refused press sends a long press to a component that never got + // pointerPressed(). + boolean anyArmed = longPressArmedFor(0); + Window[] open = Desktop.getInstance().getWindows(); + for (int iter = 0; iter < open.length; iter++) { + if (open[iter].hasLongPointerArmed()) { + anyArmed = true; + } + } + assertFalse(anyArmed, + "nothing may be armed off a pointer press that was never accepted"); + } + + @FormTest + void desktopAnswersBeforeDisplayHasAnImplementation() throws Exception { + // The fallback monitor and the monitor listener are both documented to work + // during startup, which is when an application knows it wants them. Both used + // to dereference Display.impl and throw at exactly that moment. + java.lang.reflect.Field impl = Display.class.getDeclaredField("impl"); + impl.setAccessible(true); + Object saved = impl.get(null); + impl.set(null, null); + try { + Monitor[] monitors = Desktop.getInstance().getMonitors(); + assertEquals(1, monitors.length, + "an uninitialized platform still reports its single display"); + assertNotNull(Desktop.getInstance().getPrimaryMonitor()); + Desktop.getInstance().addMonitorListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + } + }); + } finally { + impl.set(null, saved); + } + } + + @FormTest + void aLiveResizeDoesNotFillTheInputStack() throws Exception { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + DisplayTest.flushEdt(); + + java.lang.reflect.Field sp = Display.class.getDeclaredField("inputEventStackPointer"); + sp.setAccessible(true); + int before = sp.getInt(Display.getInstance()); + + // A drag-resize produces hundreds of these. Queueing each one fills the stack, + // and then the final size is dropped -- leaving the hierarchy laid out for a + // size the surface no longer has -- along with any release behind it. + for (int iter = 1; iter <= 400; iter++) { + Display.getInstance().sizeChanged(300 + iter, 200 + iter); + } + int after = sp.getInt(Display.getInstance()); + + assertTrue(after - before <= 3, + "a live resize must cost one queued packet, not one per notification; " + + "grew by " + (after - before) + " slots"); + DisplayTest.flushEdt(); + } + + @FormTest + void twoWindowsDraggingAtOnceKeepSeparateActivationState() { + implementation.setMultiWindowSupported(true); + Window a = new Window("a", new BorderLayout()); + a.setWindowSize(400, 300); + DragCountingComponent ca = new DragCountingComponent(); + a.add(BorderLayout.CENTER, ca); + a.show(); + a.asContainer().revalidate(); + + Window b = new Window("b", new BorderLayout()); + b.setWindowSize(400, 300); + DragCountingComponent cb = new DragCountingComponent(); + b.add(BorderLayout.CENTER, cb); + b.show(); + b.asContainer().revalidate(); + + // A touchscreen can have a contact down in two windows at once, and the + // framework keys press targets and drag histories per window already. Shared + // activation state lets a gesture in one window carry the other past its + // threshold, or reset it. + implementation.windowPointerPressedForTest(a.getWindowId(), 100, 100); + implementation.windowPointerPressedForTest(b.getWindowId(), 100, 100); + for (int iter = 0; iter < 12; iter++) { + implementation.windowPointerDraggedForTest(a.getWindowId(), 100 + iter * 20, 100); + } + // b has only jittered, so it must not be dragging just because a is. + implementation.windowPointerDraggedForTest(b.getWindowId(), 101, 100); + DisplayTest.flushEdt(); + + assertTrue(ca.drags > 0, "the window that actually moved is dragging"); + assertEquals(0, cb.drags, + "a drag activated in one window must not carry another window's jitter " + + "past its own threshold"); + a.dispose(); + b.dispose(); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewSessionTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewSessionTest.java index 18bab3e2073..eddac08ab37 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewSessionTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewSessionTest.java @@ -203,4 +203,30 @@ void compositionOverSelectionUndoesBackToTheSelection() { v.performUndo(); assertEquals("xyz", v.getText(), "undo restores the text the composition replaced"); } + + @FormTest + void theCaretAnimationRegistersInsideAWindow() throws Exception { + implementation.setMultiWindowSupported(true); + com.codename1.ui.Window w = + new com.codename1.ui.Window("editor", new BorderLayout()); + w.setWindowSize(400, 300); + EditorView v = new EditorView(new CountingHost(), true); + w.add(BorderLayout.CENTER, v); + w.show(); + w.revalidate(); + + // The registration itself was migrated to the top level, but it sat inside an + // `if (getComponentForm() != null)` -- and that form is null by design inside a + // Window, so the guard skipped it and the caret never blinked. Migrating the + // call without the guard around it changed nothing. + v.requestFocus(); + flushSerialCalls(); + + java.lang.reflect.Field f = EditorView.class.getDeclaredField("animRegistered"); + f.setAccessible(true); + assertTrue(f.getBoolean(v), + "a focused editor inside a window must register its caret animation"); + + w.dispose(); + } } diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerCreateFailureTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerCreateFailureTest.java new file mode 100644 index 00000000000..25315865176 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerCreateFailureTest.java @@ -0,0 +1,88 @@ +/* + * 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.javase; + +import com.codename1.testing.junit.CodenameOneTest; + +import org.junit.jupiter.api.Test; + +import javax.swing.SwingUtilities; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** + * Guards the failure report from {@link JavaSEWindowManager}'s AWT hop. + * + *

{@code runOnAwtAndWait} logs whatever the AWT task threw and returns normally, + * which is right for the callers that only adjust an existing window. It was not right + * for {@code createWindow}: allocating a native window peer can fail -- an exhausted + * or headless window server is the ordinary way -- and the peer object was returned + * anyway, with its frame and canvas still null.

+ * + *

{@code Window.show()} checks only for null, so it would register that window, + * publish it through {@code Desktop} and fire {@code Shown} for a window with no frame + * and no surface behind it. Every later call into the manager would then quietly do + * nothing against the null frame: a window that exists to the application and to + * nobody else, failing far from the call that asked for it.

+ */ +@CodenameOneTest +class JavaSEWindowManagerCreateFailureTest { + + @Test + void aTaskThatThrowsIsReportedRatherThanLoggedAndForgotten() throws Exception { + // Off the AWT thread, which is the path that swallows. The whole point of the + // return value is that the caller can tell this apart from success. + assumeFalse(SwingUtilities.isEventDispatchThread(), + "this asserts the invokeAndWait path, which only exists off the AWT thread"); + final AtomicBoolean ran = new AtomicBoolean(); + boolean completed = JavaSEWindowManager.runOnAwtAndWait(new Runnable() { + @Override + public void run() { + ran.set(true); + throw new IllegalStateException("no native peer available"); + } + }); + assertTrue(ran.get(), "the task must have been attempted"); + assertFalse(completed, + "a task that threw did not complete, and saying otherwise is what let " + + "createWindow return a peer with no window behind it"); + } + + @Test + void aTaskThatCompletesIsReportedAsSuccess() throws Exception { + assumeFalse(SwingUtilities.isEventDispatchThread(), + "this asserts the invokeAndWait path, which only exists off the AWT thread"); + final AtomicBoolean ran = new AtomicBoolean(); + boolean completed = JavaSEWindowManager.runOnAwtAndWait(new Runnable() { + @Override + public void run() { + ran.set(true); + } + }); + assertTrue(ran.get()); + assertTrue(completed, "an ordinary task must not be reported as a failure"); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerIconTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerIconTest.java new file mode 100644 index 00000000000..993fdc5081e --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerIconTest.java @@ -0,0 +1,95 @@ +/* + * 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.javase; + +import com.codename1.testing.junit.CodenameOneTest; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfSystemProperty; + +import javax.swing.SwingUtilities; +import java.awt.GraphicsEnvironment; +import java.awt.image.BufferedImage; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** + * Guards clearing a window's icon on the JavaSE port. + * + *

{@code setIcon} treated a null image as a missing argument and returned, so + * {@code setWindowIcon(null)} changed the framework's own state and left the previous + * image on the {@code JFrame}: the title bar and taskbar went on showing an icon the + * application had removed, and {@code getWindowIcon()} disagreed with what was on + * screen. A null icon is a request to clear one.

+ * + *

Needs a display, because the icon lives on a real frame. Skipped headless, as the + * other frame-backed tests here are.

+ */ +@CodenameOneTest +@DisabledIfSystemProperty(named = "java.awt.headless", matches = "true") +class JavaSEWindowManagerIconTest { + + @Test + void aNullIconTakesTheOldOneOffTheFrame() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "the icon lives on a real frame"); + JavaSEWindowManager wm = new JavaSEWindowManager(JavaSEPort.instance); + Object peer = wm.createWindow(1, "icon", 40, 40, 320, 240, true, true, null, false, false); + assertNotNull(peer, "the window manager has to produce a peer to test against"); + try { + wm.setIcon(peer, imageOf(0xff0000)); + flushAwt(); + assertNotNull(frameIcon(peer), "the icon it was given is on the frame"); + + wm.setIcon(peer, null); + flushAwt(); + assertNull(frameIcon(peer), + "clearing the icon has to take it off the frame; leaving it there " + + "shows an icon the application has already removed"); + } finally { + wm.dispose(peer); + flushAwt(); + } + } + + private static java.awt.Image frameIcon(Object peer) { + java.awt.Frame f = ((JavaSEWindowManager.Peer) peer).asFrame(); + return f == null ? null : f.getIconImage(); + } + + private static com.codename1.ui.Image imageOf(int rgb) { + BufferedImage buffered = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB); + buffered.setRGB(0, 0, rgb); + return com.codename1.ui.Image.createImage(buffered); + } + + /** setIcon hops to the AWT thread, so the assertion has to wait for it. */ + private static void flushAwt() throws Exception { + SwingUtilities.invokeAndWait(new Runnable() { + @Override + public void run() { + } + }); + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerLazyInitTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerLazyInitTest.java new file mode 100644 index 00000000000..a964d9c9c6a --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/JavaSEWindowManagerLazyInitTest.java @@ -0,0 +1,133 @@ +/* + * 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.javase; + +import com.codename1.testing.junit.CodenameOneTest; + +import org.junit.jupiter.api.Test; + +import java.awt.GraphicsEnvironment; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Guards the lazy creation of the port's {@link JavaSEWindowManager}. + * + *

The manager is created on first use, and both of its entry points are reachable + * off the EDT: {@code Desktop.isSupported()} and the {@code Window} constructor are + * callable from any thread. An unsynchronized lazy init therefore lets two threads + * both see a null field and both construct a manager.

+ * + *

The cost is not a wasted allocation. The constructor starts the monitor-topology + * poller, a daemon timer that wakes every two seconds. Only the last manager stays + * reachable through the field, so only that one can ever be stopped -- by + * {@code deinitialize()} or anything else. The loser's poller keeps running for the + * life of the process, reporting every monitor change a second time and outliving the + * teardown that was supposed to end it.

+ * + *

Asserting that concurrent callers get one identical manager is what rules + * that out: a second instance is precisely a second poller.

+ * + * @author Shai Almog + */ +@CodenameOneTest +class JavaSEWindowManagerLazyInitTest { + + /** Racers per round. Comfortably more than the cores on a CI box, to force overlap. */ + private static final int THREADS = 12; + + /** + * Rounds. The window is wide -- the constructor samples the graphics environment -- + * but it is still a race, so one round is not a measurement. + */ + private static final int ROUNDS = 25; + + @Test + void concurrentCallersAllGetTheSameManager() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + final JavaSEPort port = JavaSEPort.instance; + assertNotNull(port, "the port should be booted by CodenameOneTest"); + assumeTrue(port.getWindowManager() != null, "this port reports no window manager"); + + Field field = JavaSEPort.class.getDeclaredField("windowManager"); + field.setAccessible(true); + // Never stopped or replaced: it is put back at the end so the rest of the suite + // keeps the manager, and its poller, that it started with. + JavaSEWindowManager original = (JavaSEWindowManager) field.get(port); + try { + for (int round = 0; round < ROUNDS; round++) { + field.set(port, null); + final Set created = Collections.newSetFromMap( + Collections.synchronizedMap( + new IdentityHashMap())); + final AtomicReference failure = new AtomicReference(); + final CyclicBarrier startTogether = new CyclicBarrier(THREADS); + final CountDownLatch finished = new CountDownLatch(THREADS); + for (int i = 0; i < THREADS; i++) { + Thread t = new Thread(new Runnable() { + public void run() { + try { + startTogether.await(); + created.add((JavaSEWindowManager) port.getWindowManager()); + } catch (Throwable err) { + failure.compareAndSet(null, err); + } finally { + finished.countDown(); + } + } + }, "window-manager-racer"); + t.setDaemon(true); + t.start(); + } + finished.await(); + try { + if (failure.get() != null) { + throw new AssertionError("a racing caller failed", failure.get()); + } + assertEquals(1, created.size(), + "round " + round + ": concurrent getWindowManager() calls built " + + created.size() + " managers, so " + (created.size() - 1) + + " monitor poller(s) are now unreachable and unstoppable"); + } finally { + // Stop every poller this round started, the orphans included -- the + // field can only ever reach one of them. + for (JavaSEWindowManager manager : created) { + manager.stopWatchingMonitorTopology(); + } + } + } + } finally { + field.set(port, original); + } + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/MultiWindowGraphicsRoutingTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/MultiWindowGraphicsRoutingTest.java new file mode 100644 index 00000000000..60d7113eeff --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/MultiWindowGraphicsRoutingTest.java @@ -0,0 +1,751 @@ +/* + * 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.javase; + +import com.codename1.testing.junit.CodenameOneTest; +import com.codename1.ui.Image; + +import org.junit.jupiter.api.Test; + +import java.awt.Graphics2D; +import java.awt.GraphicsEnvironment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Guards the riskiest edit in the desktop-window work: the JavaSE port used to resolve + * all screen graphics to a single canvas. + * + *

{@code getGraphics(Object)} fell through to the primary canvas's buffer for any + * screen graphics, so a second window would have drawn into the first window's pixels. + * {@code isScreenGraphics} was an identity comparison against that one buffer, and + * {@code drawNativePeerImpl} uses its answer to decide whether to undo the zoom scale -- + * so a wrong answer for a second window mis-scales its peer components.

+ * + *

Neither had any test coverage before, and both are in the paint path where a + * regression shows up as wrong pixels rather than an exception.

+ * + * @author Shai Almog + */ +@CodenameOneTest +class MultiWindowGraphicsRoutingTest { + + @Test + void eachCanvasResolvesItsOwnScreenGraphics() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port, "the port should be booted by CodenameOneTest"); + + JavaSEPort.C first = port.createWindowCanvas(1); + JavaSEPort.C second = port.createWindowCanvas(2); + try { + first.setSize(320, 240); + second.setSize(400, 300); + + Object gFirst = port.getNativeGraphics(first); + Object gSecond = port.getNativeGraphics(second); + assertNotNull(gFirst); + assertNotNull(gSecond); + + Graphics2D awtFirst = port.getGraphics(gFirst); + Graphics2D awtSecond = port.getGraphics(gSecond); + assertNotNull(awtFirst); + assertNotNull(awtSecond); + assertNotSame(awtFirst, awtSecond, + "two canvases must not share one screen buffer, or a second window " + + "would draw into the first window's pixels"); + } finally { + // The canvas holds a Toolkit-global wheel listener for the life of the VM + // unless it is released, so a test that drops one leaks into every test + // after it. + first.disposeGestureListeners(); + second.disposeGestureListeners(); + } + } + + @Test + void screenGraphicsIsRecognisedForEveryCanvasButNotForAMutableImage() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port); + + JavaSEPort.C canvas = port.createWindowCanvas(3); + try { + canvas.setSize(200, 150); + Graphics2D windowGraphics = port.getGraphics(port.getNativeGraphics(canvas)); + assertTrue(port.isScreenGraphics(windowGraphics), + "a secondary window's buffer is still a screen buffer; answering false " + + "here mis-scales its native peers"); + + // The primary canvas must keep answering true -- that is the behaviour the + // old identity comparison had, and every existing baseline depends on it. + Graphics2D primaryGraphics = port.getGraphics(port.getNativeGraphics()); + assertTrue(port.isScreenGraphics(primaryGraphics), + "the primary canvas's buffer must still be recognised"); + + // A mutable image is not a screen buffer and must not be mistaken for one. + Image mutable = Image.createImage(64, 64); + Graphics2D imageGraphics = port.getGraphics(port.getNativeGraphics(mutable.getImage())); + assertFalse(port.isScreenGraphics(imageGraphics), + "a mutable image's graphics must never be treated as a screen buffer"); + } finally { + canvas.disposeGestureListeners(); + } + } + + @Test + void disposingAWindowReleasesItsGlobalGestureListener() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port); + + java.awt.Toolkit toolkit = java.awt.Toolkit.getDefaultToolkit(); + JavaSEPort.C canvas = port.createWindowCanvas(11); + + // The canvas's own listener, read off the field that holds it, rather than + // whatever appeared in the Toolkit's list while this test ran. That list is + // global to the VM and the simulator's event dispatch thread is live alongside + // this test, so both a count and a before/after diff can attribute an unrelated + // registration to this canvas and then demand that it be removed. + java.lang.reflect.Field field = + JavaSEPort.C.class.getDeclaredField("magnificationWheelFallbackListener"); + field.setAccessible(true); + java.awt.event.AWTEventListener own = (java.awt.event.AWTEventListener) field.get(canvas); + assertNotNull(own, "the canvas registers a global wheel listener"); + assertTrue(wheelListeners(toolkit).contains(own), "and hands it to the Toolkit"); + + canvas.disposeGestureListeners(); + + // The Toolkit holds its listeners for the life of the VM, so a window that + // never releases one leaks the canvas and its whole hierarchy, and keeps + // inspecting every wheel event in the application. + assertFalse(wheelListeners(toolkit).contains(own), + "disposing must hand that listener back"); + } + + @Test + void layingOutASecondaryCanvasDoesNotResizeTheMainSurface() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port); + + // C.setBounds and both branches of ancestorResized are primary-canvas logic + // that a secondary canvas runs too, being the same class and the same + // listener. They funnel into queueSizeChangeEvent, which resizes the *main* + // surface -- so merely laying out a secondary frame resized the main form's + // hierarchy to the secondary canvas's dimensions. + JavaSEPort.C secondary = port.createWindowCanvas(7); + try { + + // Driven through the funnel rather than through setBounds. setBounds only + // reaches it when no skin is loaded, so a setBounds-based test passes with + // the guard removed and proves nothing -- which is exactly what the first + // version of this test did. + java.lang.reflect.Method queue = JavaSEPort.C.class.getDeclaredMethod( + "queueSizeChangeEvent", int.class, int.class, + boolean.class, boolean.class, boolean.class, boolean.class); + queue.setAccessible(true); + queue.invoke(secondary, 137, 91, false, false, false, false); + + // The queued flag is cleared again once the queued runnable runs, so it is + // not a reliable probe from here. The recorded width is not cleared, so it + // still shows whether the main-surface resize was staged at all. + java.lang.reflect.Field width = + JavaSEPort.C.class.getDeclaredField("pendingSizeChangeWidth"); + width.setAccessible(true); + // -1 is the field's initial value, i.e. nothing was ever staged. + assertEquals(-1, width.getInt(secondary), + "a secondary canvas must not stage a main-surface resize; its own size " + + "is reported window-tagged through componentResized"); + } finally { + secondary.disposeGestureListeners(); + } + } + + @Test + void aPeerIsScaledForItsOwnWindowsMonitorNotTheMainDisplay() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port); + + // A peer inside a secondary window used the global retinaScale -- the main + // display's -- while everything positioning it around it used the owning + // canvas's scale. On a desktop whose monitors have different backing scales + // the peer was then offset and sized by the ratio between the two, so a + // browser or a native editor drifted away from the component it belongs to. + // + // A single-monitor CI machine cannot show that by itself: every monitor has + // the same scale, so the two agree by accident. Forcing the global scale to a + // value the canvas's monitor does not have is what makes the difference + // observable here, and it is exactly the disagreement a second monitor + // produces on a real desktop. + JavaSEPort.C canvas = port.createWindowCanvas(21); + javax.swing.JFrame frame = new javax.swing.JFrame("peer scale"); + frame.getContentPane().add(canvas); + frame.setSize(320, 240); + // Displayable, so the canvas resolves a real GraphicsConfiguration and + // canvasScale() answers from its monitor rather than falling back. + frame.addNotify(); + + double monitorScale = canvas.canvasScale(); + double originalRetina = JavaSEPort.retinaScale; + try { + JavaSEPort.retinaScale = monitorScale + 3.0; + + javax.swing.JPanel native1 = new javax.swing.JPanel(); + native1.setPreferredSize(new java.awt.Dimension(100, 50)); + JavaSEPort.Peer peer = new JavaSEPort.Peer(frame, native1); + + java.lang.reflect.Field owning = + JavaSEPort.Peer.class.getDeclaredField("owningCanvas"); + owning.setAccessible(true); + owning.set(peer, canvas); + + com.codename1.ui.geom.Dimension pref = peer.calcPreferredSize(); + + int expected = (int) (100 * monitorScale / port.zoomLevel); + int ifItUsedTheMainDisplay = (int) (100 * (monitorScale + 3.0) / port.zoomLevel); + assertNotEquals(expected, ifItUsedTheMainDisplay, + "the two scales have to differ or this test proves nothing"); + assertEquals(expected, pref.getWidth(), + "a peer must be sized by its own window's monitor scale; sizing it " + + "by the main display's stretches it by the ratio between " + + "the two monitors"); + } finally { + JavaSEPort.retinaScale = originalRetina; + frame.dispose(); + canvas.disposeGestureListeners(); + } + } + + @Test + void theUtilityWindowTypeStillChangesAfterTheWindowIsShown() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port); + + // Swing only allows the window type to change while the frame is + // undisplayable, and the setter used to skip the change outright once the + // window was up. Window.isUtilityWindow() reported the requested value while + // the platform stayed on the old taskbar behaviour, so the setter silently did + // nothing for the only case that matters -- a palette toggled at runtime. + JavaSEWindowManager wm = new JavaSEWindowManager(port); + Object peerObj = wm.createWindow(31, "utility", 40, 40, 300, 200, + true, true, null, false, false); + assertNotNull(peerObj); + try { + wm.show(peerObj); + flushAwt(); + + wm.setUtilityWindow(peerObj, true); + flushAwt(); + assertEquals(java.awt.Window.Type.UTILITY, frameOf(peerObj).getType(), + "a shown window must still be able to become a utility window"); + assertTrue(frameOf(peerObj).isVisible(), + "and must still be on screen afterwards"); + + wm.setUtilityWindow(peerObj, false); + flushAwt(); + assertEquals(java.awt.Window.Type.NORMAL, frameOf(peerObj).getType(), + "and must be able to change back"); + } finally { + wm.dispose(peerObj); + flushAwt(); + } + } + + private static java.awt.Window frameOf(Object peerObj) throws Exception { + java.lang.reflect.Field f = + JavaSEWindowManager.Peer.class.getDeclaredField("frame"); + f.setAccessible(true); + return (java.awt.Window) f.get(peerObj); + } + + private static void flushAwt() throws Exception { + javax.swing.SwingUtilities.invokeAndWait(new Runnable() { + @Override + public void run() { + } + }); + } + + @Test + void aWindowMenuDispatchesThroughItsWindowAndOmitsTheMcpMenu() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port); + + final boolean[] listenerSaw = new boolean[1]; + final boolean[] commandRan = new boolean[1]; + com.codename1.ui.Command cmd = new com.codename1.ui.Command("Save") { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + commandRan[0] = true; + } + }; + + // A stand-in for the owning window: the builder only needs something whose + // dispatchCommand it can call, and building a real native window here would + // drag in the whole show() path for what is a menu-wiring question. + com.codename1.ui.Window owner = new com.codename1.ui.Window("owner"); + owner.addCommandListener(new com.codename1.ui.events.ActionListener() { + @Override + public void actionPerformed(com.codename1.ui.events.ActionEvent evt) { + listenerSaw[0] = true; + } + }); + + java.util.List cmds = + new java.util.ArrayList(); + cmds.add(cmd); + javax.swing.JMenuBar bar = port.buildWindowMenuBar(cmds, owner); + assertNotNull(bar); + + // The MCP menu carries development-only controls ("Expose This Tool To Agents", + // host installation). It belongs to the application's main frame, not to every + // window that happens to carry a command. + for (int iter = 0; iter < bar.getMenuCount(); iter++) { + assertNotEquals("MCP", bar.getMenu(iter).getText(), + "a secondary window's menu must not carry the MCP tooling menu"); + } + + // Activating the item must go through the window, so listeners registered with + // addCommandListener see it -- invoking the command directly bypasses them. + javax.swing.JMenuItem item = bar.getMenu(0).getItem(0); + assertEquals("Save", item.getText()); + item.doClick(); + com.codename1.ui.Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + } + }); + + assertTrue(commandRan[0], "the command itself must run"); + assertTrue(listenerSaw[0], + "and the window's command listeners must be notified"); + } + + @Test + void aCanvasResolvesHitTestsAndEditorFocusFromItsOwnWindow() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port); + + // The main canvas answers with the current form, exactly as before. + JavaSEPort.C main = port.canvas; + if (main != null) { + java.lang.reflect.Method top = + JavaSEPort.C.class.getDeclaredMethod("canvasTopLevel"); + top.setAccessible(true); + assertSame(com.codename1.ui.CN.getCurrentForm(), top.invoke(main), + "the main canvas must still resolve the current form"); + } + + // A secondary canvas must answer with the window it renders, not with whatever + // form happens to be current. Resolving the current form here is what made a + // peer in a window fail its hit test -- an unrelated main-form component at + // those window-local coordinates set cn1GrabbedDrag and swallowed the event -- + // and made an editor focused in a window invisible to isPureEditorFocused(). + assumeTrue(com.codename1.ui.Desktop.isSupported(), "needs a windowing system"); + com.codename1.ui.Window w = new com.codename1.ui.Window("hit test"); + w.setWindowSize(300, 200); + w.show(); + // The desktop registry is populated on the event dispatch thread, so the id is + // not resolvable until it has run. + com.codename1.ui.Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + } + }); + JavaSEPort.C secondary = port.createWindowCanvas(w.getWindowId()); + try { + java.lang.reflect.Method top = + JavaSEPort.C.class.getDeclaredMethod("canvasTopLevel"); + top.setAccessible(true); + Object resolved = top.invoke(secondary); + assertSame(w, resolved, + "a secondary canvas must resolve the window it renders"); + assertNotSame(com.codename1.ui.CN.getCurrentForm(), resolved, + "and not the main form"); + } finally { + secondary.disposeGestureListeners(); + w.dispose(); + } + } + + @Test + void theMonitorFingerprintSeesTheWorkArea() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + + // A taskbar or dock that moves edge, changes size or toggles auto-hide + // reconfigures the work area while leaving the monitor's bounds and scale + // identical. A fingerprint built only from bounds and scale is byte-identical + // across that change, so monitorsChanged() never fires: windows keep a stale + // work area and centerOnDesktop() can place one under the taskbar that just + // appeared. + java.lang.reflect.Method sig = + JavaSEWindowManager.class.getDeclaredMethod("topologySignature"); + sig.setAccessible(true); + String actual = (String) sig.invoke(null); + assumeFalse("unavailable".equals(actual), "display was mid-reconfiguration"); + + // Rebuild the bounds-and-scale-only fingerprint the code used to produce, and + // require that the real one carries strictly more than it -- otherwise a + // work-area change is invisible to the poller. + StringBuilder boundsOnly = new StringBuilder(); + for (java.awt.GraphicsDevice device + : java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().getScreenDevices()) { + java.awt.GraphicsConfiguration cfg = device.getDefaultConfiguration(); + java.awt.Rectangle b = cfg.getBounds(); + boundsOnly.append(device.getIDstring()).append(':') + .append(b.x).append(',').append(b.y).append(',') + .append(b.width).append('x').append(b.height).append('@') + .append(cfg.getDefaultTransform().getScaleX()).append(';'); + } + assertNotEquals(boundsOnly.toString(), actual, + "the fingerprint must carry more than bounds and scale, or a taskbar " + + "change never reaches monitorsChanged()"); + + java.awt.Insets in = java.awt.Toolkit.getDefaultToolkit().getScreenInsets( + java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment() + .getDefaultScreenDevice().getDefaultConfiguration()); + assertTrue(actual.contains(in.top + "," + in.left + "," + in.bottom + "," + in.right), + "the primary display's screen insets must appear in the fingerprint"); + } + + @Test + void aSecondaryCanvasResizeLeavesTheMainCanvasAlone() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port); + JavaSEPort.C main = port.canvas; + assumeFalse(main == null, "needs a booted primary canvas"); + + java.lang.reflect.Field forced = + JavaSEPort.C.class.getDeclaredField("forcedSize"); + forced.setAccessible(true); + Object before = forced.get(main); + + // ancestorResized is primary-surface logic that a secondary canvas also runs, + // being the same class on the same listener. Its body reaches + // canvas.setForcedSize() -- the *port's* canvas, not the one the event arrived + // on -- so a secondary window's resize stamped the main canvas with the + // secondary window's dimensions and a later Swing layout could resize or clip + // the main surface. queueSizeChangeEvent's own guard is too late: by then the + // main canvas has already been mutated. + JavaSEPort.C secondary = port.createWindowCanvas(51); + // In a real frame, so the handler runs the same path it would in production + // rather than tripping over a null ancestor -- otherwise the test fails for the + // wrong reason and never reaches the assertion that matters. + javax.swing.JFrame frame = new javax.swing.JFrame("secondary"); + frame.getContentPane().setLayout(new java.awt.BorderLayout()); + frame.getContentPane().add(java.awt.BorderLayout.CENTER, secondary); + frame.setSize(137, 91); + frame.addNotify(); + try { + secondary.ancestorResized(new java.awt.event.HierarchyEvent( + secondary, java.awt.event.HierarchyEvent.ANCESTOR_RESIZED, + secondary, secondary.getParent())); + assertSame(before, forced.get(main), + "a secondary canvas's resize must not stamp the main canvas's " + + "forced size"); + } finally { + frame.dispose(); + secondary.disposeGestureListeners(); + } + } + + @Test + void aPeerHitTestConvertsWithItsOwnCanvasScale() { + // The companion to aPeerIsScaledForItsOwnWindowsMonitorNotTheMainDisplay: the + // peer is positioned with peerScale(), so the hit test that decides whether a + // mouse event belongs to it has to use the same scale. Converting with the + // global retinaScale instead tests a different point on a mixed-DPI desktop, + // and the lookup can then find an unrelated component, set cn1GrabbedDrag and + // swallow input meant for a browser or native editor. + // + // The conversion is isolated in a helper precisely so this can be checked + // without showing a real window on a second monitor. + int screenCoordinate = 400; + int canvasOriginOnScreen = 100; + int canvasOffset = 10; + int screenCoordsOffset = 5; + double zoom = 1.0; + double canvasMonitorScale = 2.0; + double mainDisplayScale = 1.0; + + int withOwnCanvas = JavaSEPort.CN1JPanel.toCn1Coordinate(screenCoordinate, + canvasOriginOnScreen, canvasOffset, screenCoordsOffset, zoom, canvasMonitorScale); + int withMainDisplay = JavaSEPort.CN1JPanel.toCn1Coordinate(screenCoordinate, + canvasOriginOnScreen, canvasOffset, screenCoordsOffset, zoom, mainDisplayScale); + + assertNotEquals(withMainDisplay, withOwnCanvas, + "the two scales have to disagree or this test proves nothing"); + assertEquals((int) ((screenCoordinate - canvasOriginOnScreen + - (canvasOffset + screenCoordsOffset) * zoom / canvasMonitorScale) + / zoom * canvasMonitorScale), + withOwnCanvas, + "a hit test must convert with the owning canvas's backing scale"); + } + + /// The Toolkit's wheel listeners, unwrapped from the proxies it hands out. + /// + /// `getAWTEventListeners(mask)` builds a fresh `AWTEventListenerProxy` per call, so + /// comparing the returned objects by identity never matches. The listener inside + /// the proxy is the stable one. + private static java.util.List wheelListeners( + java.awt.Toolkit toolkit) { + java.util.List out = + new java.util.ArrayList(); + for (java.awt.event.AWTEventListener l + : toolkit.getAWTEventListeners(java.awt.AWTEvent.MOUSE_WHEEL_EVENT_MASK)) { + out.add(l instanceof java.awt.event.AWTEventListenerProxy + ? ((java.awt.event.AWTEventListenerProxy) l).getListener() : l); + } + return out; + } + + @Test + void aFrameReportsAutomaticVisibilityChangesToTheFramework() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + assumeTrue(com.codename1.ui.Desktop.isSupported(), "needs a windowing system"); + + com.codename1.ui.Window w = new com.codename1.ui.Window("owned visibility"); + w.setWindowSize(320, 240); + w.show(); + com.codename1.ui.Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + } + }); + try { + java.awt.Window frame = findAwtWindowTitled("owned visibility"); + assertNotNull(frame, "the window manager should have created a native frame"); + + // AWT hides a window's owned dialogs with it and shows them again with it, + // reporting only componentHidden/componentShown on the child -- no window + // event. Listening for those is what tells the framework; without it an + // owned window kept reporting itself shown with no surface behind it. + java.awt.event.ComponentEvent hidden = new java.awt.event.ComponentEvent( + frame, java.awt.event.ComponentEvent.COMPONENT_HIDDEN); + boolean delivered = false; + for (java.awt.event.ComponentListener l : frame.getComponentListeners()) { + l.componentHidden(hidden); + delivered = true; + } + assertTrue(delivered, "the frame must carry a component listener"); + // The notification is marshalled to the event dispatch thread. + com.codename1.ui.Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + } + }); + assertFalse(w.isWindowShowing(), + "a componentHidden from the platform must reach the framework"); + + java.awt.event.ComponentEvent shown = new java.awt.event.ComponentEvent( + frame, java.awt.event.ComponentEvent.COMPONENT_SHOWN); + for (java.awt.event.ComponentListener l : frame.getComponentListeners()) { + l.componentShown(shown); + } + com.codename1.ui.Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + } + }); + assertTrue(w.isWindowShowing(), "and componentShown must bring it back"); + } finally { + w.dispose(); + } + } + + /// The AWT window with the given title, or null. + private static java.awt.Window findAwtWindowTitled(String title) { + for (java.awt.Window each : java.awt.Window.getWindows()) { + if (each instanceof java.awt.Frame + && title.equals(((java.awt.Frame) each).getTitle())) { + return each; + } + if (each instanceof java.awt.Dialog + && title.equals(((java.awt.Dialog) each).getTitle())) { + return each; + } + } + return null; + } + + /** + * The registry that answers {@code isScreenGraphics} keys a {@code Graphics2D} to + * its owning canvas strongly, so a disposed window that never unregisters keeps its + * canvas and its {@code BufferedImage}s reachable for the life of the application. + * At a large window size that is tens of megabytes per window ever opened. + */ + @Test + void disposingACanvasReleasesItsScreenGraphics() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port, "the port should be booted by CodenameOneTest"); + + JavaSEWindowManager wm = new JavaSEWindowManager(port); + Object peer = wm.createWindow(41, "leak", 0, 0, 320, 240, true, true, null, + false, false); + assumeTrue(peer != null, "needs a native window"); + JavaSEPort.C canvas = ((JavaSEWindowManager.Peer) peer).canvas; + assertNotNull(canvas, "the window should have a canvas"); + canvas.setSize(320, 240); + Graphics2D g = port.getGraphics(port.getNativeGraphics(canvas)); + assertNotNull(g); + assertTrue(port.isScreenGraphics(g), + "a painted canvas registers its screen graphics"); + + // Through the real dispose path, not the release method directly: what this + // guards is that disposal is wired to it at all. + wm.dispose(peer); + for (int i = 0; i < 100 && port.isScreenGraphics(g); i++) { + try { + java.awt.EventQueue.invokeAndWait(new Runnable() { + public void run() { + } + }); + } catch (Exception err) { + break; + } + } + + assertFalse(port.isScreenGraphics(g), + "a disposed window must not leave its canvas in the registry: the " + + "registry holds it strongly, so it would never be collected"); + } + + /** + * The blit and paint transforms use {@code canvasScale()} rather than the global + * {@code retinaScale}, so a window on a display of a different scale is not + * stretched by the ratio between the two. That is only safe for the main window + * because its {@code canvasScale()} is {@code retinaScale} by definition -- if + * that stops being true, the main window's rendering changes with it, which no + * screenshot in the suite would attribute to this. + */ + @Test + void theMainCanvasScaleIsTheGlobalRetinaScale() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port, "the port should be booted by CodenameOneTest"); + + JavaSEPort.C main = port.createWindowCanvas(0); + + assertEquals(JavaSEPort.retinaScale, main.canvasScale(), 0.0001, + "window 0 is the main window and must keep using the global scale"); + } + + /** + * hide() has to complete before it returns, exactly as show() does. Queued, a + * hide followed by a show in the same event dispatch thread turn ran after the + * show had already put the window back, so the frame's componentHidden arrived + * with the window visible and was reported as a minimize -- firing minimize + * listeners for a window that is on screen. + */ + @Test + void hidingAWindowCompletesBeforeItReturns() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + assumeFalse(java.awt.EventQueue.isDispatchThread(), + "the point of this is the cross-thread hand-off"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port, "the port should be booted by CodenameOneTest"); + + JavaSEWindowManager wm = new JavaSEWindowManager(port); + Object peer = wm.createWindow(57, "sync hide", 0, 0, 320, 240, true, true, null, + false, false); + assumeTrue(peer != null, "needs a native window"); + try { + wm.show(peer); + java.awt.Window frame = ((JavaSEWindowManager.Peer) peer).frame; + assumeTrue(frame.isVisible(), "the window has to be up for this to mean anything"); + + wm.hide(peer); + + assertFalse(frame.isVisible(), + "hide must have taken effect by the time it returns, or a show in " + + "the same turn races the queued hide"); + } finally { + wm.dispose(peer); + } + } + + /** + * A window frame lays its content pane out with a BorderLayout and holds the + * Codename One canvas in CENTER. An unconstrained add() takes that slot, so a + * native peer replaced the canvas as the managed centre component: the canvas + * stopped being resized with the window while the peer was laid out over all of + * it. + */ + @Test + void aWindowFrameKeepsItsCanvasAsTheManagedCenter() { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + JavaSEPort port = JavaSEPort.instance; + assertNotNull(port, "the port should be booted by CodenameOneTest"); + + JavaSEWindowManager wm = new JavaSEWindowManager(port); + Object peer = wm.createWindow(73, "peers", 0, 0, 320, 240, true, true, null, + false, false); + assumeTrue(peer != null, "needs a native window"); + try { + JavaSEWindowManager.Peer p = (JavaSEWindowManager.Peer) peer; + java.awt.Window frame = p.frame; + assumeTrue(frame instanceof javax.swing.RootPaneContainer, "needs a root pane"); + + java.awt.Container content = ((javax.swing.RootPaneContainer) frame).getContentPane(); + java.awt.LayoutManager lm = content.getLayout(); + assertTrue(lm instanceof java.awt.BorderLayout, + "this test only means something while the frame uses a BorderLayout"); + java.awt.BorderLayout border = (java.awt.BorderLayout) lm; + + // The hazard, demonstrated rather than asserted from memory: an + // unconstrained add to the frame takes the centre slot away from the canvas. + javax.swing.JPanel unconstrained = new javax.swing.JPanel(); + frame.add(unconstrained, 0); + assertNotSame(p.canvas, border.getLayoutComponent(java.awt.BorderLayout.CENTER), + "an unconstrained add displaces the canvas -- this is what the peer " + + "attach must not do"); + frame.remove(unconstrained); + content.add(p.canvas, java.awt.BorderLayout.CENTER); + + // And the attachment the peer path uses instead leaves it alone. + javax.swing.JPanel layered = new javax.swing.JPanel(); + ((javax.swing.RootPaneContainer) frame).getLayeredPane() + .add(layered, javax.swing.JLayeredPane.PALETTE_LAYER); + assertSame(p.canvas, border.getLayoutComponent(java.awt.BorderLayout.CENTER), + "the canvas has to stay the frame's centre component once a peer is " + + "attached, or it is no longer resized with the window"); + } finally { + wm.dispose(peer); + } + } +} diff --git a/maven/javase/src/test/java/com/codename1/impl/javase/WindowVisibilityEventCorrelationTest.java b/maven/javase/src/test/java/com/codename1/impl/javase/WindowVisibilityEventCorrelationTest.java new file mode 100644 index 00000000000..f00dc0b0379 --- /dev/null +++ b/maven/javase/src/test/java/com/codename1/impl/javase/WindowVisibilityEventCorrelationTest.java @@ -0,0 +1,113 @@ +/* + * 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.javase; + +import com.codename1.testing.junit.CodenameOneTest; +import com.codename1.ui.Desktop; +import com.codename1.ui.Display; +import com.codename1.ui.Window; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.events.WindowEvent; +import com.codename1.ui.layouts.BorderLayout; + +import org.junit.jupiter.api.Test; + +import java.awt.GraphicsEnvironment; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Guards the JavaSE port against reporting its own show and hide back to the framework + * as a minimize and a restore. + * + *

AWT delivers componentShown / componentHidden for every visibility change, however + * it was caused, and the port turns those into windowShowNotify / windowHideNotify. + * Those are queued onto the Codename One event dispatch thread, so a show and a + * hide performed in the same turn both run afterwards, against the state the second one + * left. The pair reads as a minimize followed by a restore, and in the show-then-hide + * order the window ends up hidden while still marked iconified -- the state + * {@code showModal()} waits on.

+ * + * @author Shai Almog + */ +@CodenameOneTest +class WindowVisibilityEventCorrelationTest { + + @Test + void anExplicitShowAndHideIsNotReportedAsMinimizeAndRestore() throws Exception { + assumeFalse(GraphicsEnvironment.isHeadless(), "needs a display"); + assumeTrue(Desktop.isSupported(), "needs a windowing system"); + + final List lifecycle = new ArrayList(); + Window w = new Window("visibility", new BorderLayout()); + w.setWindowSize(320, 240); + w.addWindowListener(new ActionListener() { + @Override + public void actionPerformed(WindowEvent evt) { + lifecycle.add(String.valueOf(evt.getType())); + } + }); + try { + w.show(); + w.hide(); + // Drain twice: the AWT callbacks queue onto this thread, so the reports + // arrive after the two explicit calls have already finished. + drain(); + drain(); + + assertNotNull(lifecycle); + for (int iter = 0; iter < lifecycle.size(); iter++) { + String type = lifecycle.get(iter); + assertTrue(!"Minimized".equals(type) && !"Restored".equals(type), + "a show followed by a hide is not a minimize or a restore, but " + + "the window reported: " + lifecycle); + } + assertTrue(!w.isWindowShowing(), "and the window is hidden at the end"); + } finally { + w.dispose(); + drain(); + } + } + + /// Lets the event dispatch thread run whatever the AWT callbacks queued onto it. + private static void drain() throws Exception { + final Object done = new Object(); + synchronized (done) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + synchronized (done) { + done.notifyAll(); + } + } + }); + done.wait(2000); + } + Thread.sleep(120); + } +} diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index 77a600506a3..35fa46dd25f 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -23,6 +23,7 @@ Ports/JavaScriptPort/src/main/webapp/js/videojs/videojs.record.min.css | videojs Ports/JavaScriptPort/src/main/webapp/js/videojs/videojs.record.min.js | videojs-record 3.5.0 third-party bundle Ports/JavaScriptPort/src/main/webapp/sw.js | Codename One service-worker adapter containing the UpUp 1.0.0 MIT-licensed service worker vm/JavaAPI/src/java/util/TimeZone.java | Apache Harmony source retaining its original Apache-2.0 notice +CodenameOne/src/com/codename1/maps/MapComponent.java | Itiner.pl contribution retaining its original copyright line above the standard GPLv2 + Classpath Exception text vm/ByteCodeTranslator/src/cn1_sqlite3.h | SQLite3 Multiple Ciphers public header, upstream MIT notice over public-domain SQLite vm/ByteCodeTranslator/src/cn1_sqlite3_amalgamation.h | SQLite3 Multiple Ciphers amalgamation, upstream MIT notice over public-domain SQLite Ports/JavaScriptPort/src/main/webapp/js/sqlite3mc.js | SQLite3 Multiple Ciphers WebAssembly loader, Emscripten generated, MIT over public-domain SQLite diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveCssTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveCssTest.java index d80c40b9a74..d035bd06ab7 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveCssTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveCssTest.java @@ -65,9 +65,21 @@ void editingCssRestylesTheLivePreview() throws Exception { System.setProperty("guibuilder.input", input.toString()); System.setProperty("guibuilder.canvasMode", "desktop"); - CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); - builder.init(null); - builder.runApp(); + final CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + // On the event dispatch thread, because that is where Codename One invokes an + // application's lifecycle and therefore what the code being tested is written + // against. Called from the test thread instead, this ran the whole of runApp() + // -- building the form, opening a file, showing a ToastBar -- concurrently with + // a live EDT, and the two raced over Form's animation registry: an ArrayList + // whose size went negative and then threw ArrayIndexOutOfBoundsException: -1 + // out of Tabs.initComponentImpl, intermittently and nowhere near the cause. + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + builder.init(null); + builder.runApp(); + } + }); settle(); assertNull(onEdt(() -> builder.mcpOpenForm("com.example.StyledForm"))); settle(); @@ -181,9 +193,21 @@ private static CodenameOneGUIBuilder builderFor(String css) throws Exception { System.setProperty("guibuilder.input", input.toString()); System.setProperty("guibuilder.canvasMode", "desktop"); - CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); - builder.init(null); - builder.runApp(); + final CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + // On the event dispatch thread, because that is where Codename One invokes an + // application's lifecycle and therefore what the code being tested is written + // against. Called from the test thread instead, this ran the whole of runApp() + // -- building the form, opening a file, showing a ToastBar -- concurrently with + // a live EDT, and the two raced over Form's animation registry: an ArrayList + // whose size went negative and then threw ArrayIndexOutOfBoundsException: -1 + // out of Tabs.initComponentImpl, intermittently and nowhere near the cause. + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + builder.init(null); + builder.runApp(); + } + }); settle(); assertNull(onEdt(() -> builder.mcpOpenForm("com.example.StyledForm"))); settle(); diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveTypingTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveTypingTest.java index 2bc8de5dc61..1970fdb8fbe 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveTypingTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveTypingTest.java @@ -282,9 +282,21 @@ private static CodenameOneGUIBuilder workspace() throws Exception { input.toFile().deleteOnExit(); System.setProperty("guibuilder.input", input.toString()); System.setProperty("guibuilder.canvasMode", "desktop"); - CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); - builder.init(null); - builder.runApp(); + final CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + // On the event dispatch thread, because that is where Codename One invokes an + // application's lifecycle and therefore what the code being tested is written + // against. Called from the test thread instead, this ran the whole of runApp() + // -- building the form, opening a file, showing a ToastBar -- concurrently with + // a live EDT, and the two raced over Form's animation registry: an ArrayList + // whose size went negative and then threw ArrayIndexOutOfBoundsException: -1 + // out of Tabs.initComponentImpl, intermittently and nowhere near the cause. + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + builder.init(null); + builder.runApp(); + } + }); settle(); return builder; } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveWorkspaceDragTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveWorkspaceDragTest.java index 51aa953978f..1a1c408a18b 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveWorkspaceDragTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/LiveWorkspaceDragTest.java @@ -198,9 +198,21 @@ private static String onEdt(java.util.function.Supplier work) { private static CodenameOneGUIBuilder workspace() throws Exception { System.setProperty("guibuilder.input", demoBinding().toString()); System.setProperty("guibuilder.canvasMode", "desktop"); - CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); - builder.init(null); - builder.runApp(); + final CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + // On the event dispatch thread, because that is where Codename One invokes an + // application's lifecycle and therefore what the code being tested is written + // against. Called from the test thread instead, this ran the whole of runApp() + // -- building the form, opening a file, showing a ToastBar -- concurrently with + // a live EDT, and the two raced over Form's animation registry: an ArrayList + // whose size went negative and then threw ArrayIndexOutOfBoundsException: -1 + // out of Tabs.initComponentImpl, intermittently and nowhere near the cause. + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + builder.init(null); + builder.runApp(); + } + }); flushEdt(); return builder; } diff --git a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssStylesPreviewTest.java b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssStylesPreviewTest.java index aea7541b668..22dff6b5774 100644 --- a/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssStylesPreviewTest.java +++ b/scripts/guibuilder/javase/src/test/java/com/codename1/guibuilder/ProjectCssStylesPreviewTest.java @@ -143,9 +143,21 @@ private static CodenameOneGUIBuilder workspace(Path project) throws Exception { input.toFile().deleteOnExit(); System.setProperty("guibuilder.input", input.toString()); System.setProperty("guibuilder.canvasMode", "desktop"); - CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); - builder.init(null); - builder.runApp(); + final CodenameOneGUIBuilder builder = new CodenameOneGUIBuilder(); + // On the event dispatch thread, because that is where Codename One invokes an + // application's lifecycle and therefore what the code being tested is written + // against. Called from the test thread instead, this ran the whole of runApp() + // -- building the form, opening a file, showing a ToastBar -- concurrently with + // a live EDT, and the two raced over Form's animation registry: an ArrayList + // whose size went negative and then threw ArrayIndexOutOfBoundsException: -1 + // out of Tabs.initComponentImpl, intermittently and nowhere near the cause. + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + builder.init(null); + builder.runApp(); + } + }); settle(); return builder; } diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 9f9714d1559..94a00293cf5 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -480,6 +480,22 @@ private static int testTimeoutMs(BaseTest testClass) { // on the Mac native build it enables desktop mode (commands move to the native menu // bar, interactive always-visible scrollbar), reverting its global toggles after capture. new DesktopModeScreenshotTest(), + // Desktop windowing. MultiWindowApiTest asserts behaviour on every target -- + // where there is no windowing system it asserts the capability query says so + // and that constructing a Window throws. The Window* cases re-run + // representative UI INSIDE a real operating-system window at several sizes + // and capture that window rather than the main surface, which is the only + // way to prove layout, scrolling, graphics, overlays, native editing and + // modality actually work on a non-primary surface. They skip without + // emitting a golden where windows are unsupported, so mobile baselines never + // contain a picture of something the platform cannot do. + new MultiWindowApiTest(), + new WindowLayoutTest(), + new WindowScrollTest(), + new WindowGraphicsTest(), + new WindowEditingTest(), + new WindowOverlayTest(), + new WindowModalTest(), // VideoIO animation screenshot: encodes a 6-frame counting clip (digits // 1..6), decodes it back with the video decoder, and lays the decoded // frames out as a 2x3 grid -- so a decode regression is visible. Placed diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MultiWindowApiTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MultiWindowApiTest.java new file mode 100644 index 00000000000..55fe379bef5 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/MultiWindowApiTest.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Desktop; +import com.codename1.ui.Label; +import com.codename1.ui.Monitor; +import com.codename1.ui.Window; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; + +/** + * The behavioural half of the multi-window suite: no screenshot, pass or fail, and it + * runs on every target. + * + *

This is what actually proves a real operating-system window exists, because it + * asserts against state the port reports rather than against pixels. On a + * platform with no windowing system it asserts the opposite: that the capability query + * says so and that constructing a window throws rather than silently degrading.

+ * + * @author Shai Almog + */ +public class MultiWindowApiTest extends BaseTest { + + @Override + public boolean shouldTakeScreenshot() { + return false; + } + + @Override + public boolean isRetrySafe() { + return false; + } + + @Override + public boolean runTest() throws Exception { + if (!Desktop.isSupported()) { + assertUnsupported(); + } else { + assertSupported(); + } + done(); + return true; + } + + private void assertUnsupported() { + // Degrading safely still has to hold: portable code loops over these. + if (Desktop.getInstance().getWindows().length != 0) { + fail("getWindows() must be empty where there is no windowing system"); + return; + } + if (Desktop.getInstance().getFocusedWindow() != null) { + fail("getFocusedWindow() must be null where there is no windowing system"); + return; + } + if (Desktop.getInstance().getMonitors().length < 1) { + fail("getMonitors() must still report the main display"); + return; + } + boolean threw = false; + try { + new Window("should not open"); + } catch (UnsupportedOperationException expected) { + threw = true; + } + if (!threw) { + fail("Constructing a Window must throw where there is no windowing system"); + } + } + + private void assertSupported() { + Monitor[] monitors = Desktop.getInstance().getMonitors(); + if (monitors.length < 1) { + fail("A windowing platform must report at least one monitor"); + return; + } + for (Monitor m : monitors) { + Rectangle bounds = m.getBounds(); + if (bounds.getWidth() <= 0 || bounds.getHeight() <= 0) { + fail("Monitor " + m.getName() + " reported empty bounds"); + return; + } + Rectangle work = m.getWorkArea(); + if (work.getWidth() > bounds.getWidth() || work.getHeight() > bounds.getHeight()) { + fail("Monitor " + m.getName() + " work area is larger than its bounds"); + return; + } + if (m.getScale() <= 0) { + fail("Monitor " + m.getName() + " reported a non-positive scale"); + return; + } + } + + int before = Desktop.getInstance().getWindows().length; + Window w = new Window("api", new BorderLayout()); + Component content = new Label("content"); + w.add(BorderLayout.CENTER, content); + w.setWindowSize(500, 360); + w.show(); + + try { + if (Desktop.getInstance().getWindows().length != before + 1) { + fail("show() must register exactly one window"); + return; + } + if (Desktop.getInstance().windowById(w.getWindowId()) != w) { + fail("A window must be resolvable by the id its events are routed with"); + return; + } + // The load-bearing property of the whole design: a component inside a + // window resolves that window, and is honestly not in any Form. + if (content.getTopLevelContainer() != w) { + fail("A component in a Window must resolve that Window as its top level"); + return; + } + if (content.getComponentForm() != null) { + fail("getComponentForm() must be null inside a Window"); + return; + } + if (w.getMonitor() == null) { + fail("A shown window must report the monitor it is on"); + return; + } + if (w.getScale() <= 0) { + fail("A shown window must report its monitor's scale"); + return; + } + // Content is laid out to the window, not to the main display. + if (w.getWidth() <= 0 || w.getHeight() <= 0) { + fail("A shown window must have a laid-out size"); + return; + } + if (w.getWidth() == CN.getDisplayWidth() && w.getHeight() == CN.getDisplayHeight()) { + fail("A window sized 500x360 must not report the main display's size"); + return; + } + w.setTitle("renamed"); + if (!"renamed".equals(w.getTitle())) { + fail("setTitle must be readable back"); + return; + } + } finally { + w.dispose(); + } + + if (!w.isWindowDisposed()) { + fail("dispose() must mark the window disposed"); + return; + } + if (Desktop.getInstance().getWindows().length != before) { + fail("A disposed window must leave the desktop registry"); + } + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowEditingTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowEditingTest.java new file mode 100644 index 00000000000..8e3eb0243bb --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowEditingTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BoxLayout; + +/** + * Text input inside a desktop window. + * + *

Native editing is the case that used to attach the platform's editor to the main + * window's canvas unconditionally, so a field inside a window put its caret on the wrong + * window entirely. The port now resolves the owning window for both the editor and its + * bounds; this golden is what would catch a regression, since a misplaced native editor + * is invisible in the window's own capture.

+ * + * @author Shai Almog + */ +public class WindowEditingTest extends WindowHostTest { + + @Override + protected String baseImageName() { + return "Window-Editing"; + } + + @Override + protected Component createWindowContent(int width, int height) { + Container root = new Container(BoxLayout.y()); + root.add(new Label("Text input")); + + TextField single = new TextField("Single line"); + single.setHint("Type here"); + root.add(single); + + TextField password = new TextField("", "Password", 20, TextField.PASSWORD); + root.add(password); + + TextArea multi = new TextArea("Multi-line content\nsecond line\nthird line", 4, 30); + root.add(multi); + return root; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowGraphicsTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowGraphicsTest.java new file mode 100644 index 00000000000..808a98bd510 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowGraphicsTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Component; +import com.codename1.ui.Graphics; + +/** + * Direct drawing inside a desktop window. + * + *

This is the case that exercises the port's graphics pipeline on a non-primary + * surface: the window has its own render target, its own dirty queue and its own clip + * universe. Getting the clip clamp wrong here is what leaves stale pixels on a retained + * surface, so the shapes deliberately reach the window's edges.

+ * + * @author Shai Almog + */ +public class WindowGraphicsTest extends WindowHostTest { + + @Override + protected String baseImageName() { + return "Window-Graphics"; + } + + @Override + protected Component createWindowContent(final int width, final int height) { + return new Component() { + @Override + public void paint(Graphics g) { + int w = getWidth(); + int h = getHeight(); + g.setColor(0x102030); + g.fillRect(getX(), getY(), w, h); + + g.setColor(0xe94f37); + g.fillArc(getX() + w / 10, getY() + h / 10, w / 3, h / 3, 0, 270); + + g.setColor(0x44bba4); + g.drawRect(getX() + 1, getY() + 1, w - 3, h - 3); + + g.setColor(0xf6f7eb); + for (int iter = 0; iter < 8; iter++) { + int y = getY() + h * iter / 8; + g.drawLine(getX(), y, getX() + w, y + h / 8); + } + + g.setColor(0xffd166); + g.fillRect(getX() + w / 2, getY() + h / 2, w / 3, h / 3); + } + }; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowHostTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowHostTest.java new file mode 100644 index 00000000000..291014b22f1 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowHostTest.java @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.CN; +import com.codename1.ui.Component; +import com.codename1.ui.Desktop; +import com.codename1.ui.Image; +import com.codename1.ui.Window; +import com.codename1.ui.layouts.BorderLayout; + +/** + * Base class for the windowed screenshot suite: it hosts a piece of UI inside a real + * desktop {@link Window} at a given size, captures that window, and emits the + * result as a golden. + * + *

This is the part of the test story that actually demonstrates windowing. A picture + * of a window proves nothing; re-running representative UI inside one and comparing it + * against its own baseline proves that layout, theming, scrolling, graphics, peers and + * native editing all behave on a non-primary surface.

+ * + *

Capture goes through {@link Window#capture()} rather than + * {@code Display.screenshot}, because the ordinary path can only see the application's + * main framebuffer and a second operating-system window simply is not in it.

+ * + *

Ports with no windowing system report that through {@link Desktop#isSupported()}; + * those skip without emitting a golden, so their baselines never contain a picture of + * something the platform cannot do.

+ * + * @author Shai Almog + */ +public abstract class WindowHostTest extends BaseTest { + + /// How far below the requested size a window may legitimately be. + /// Chrome costs tens of pixels; a window still carrying another + /// window's geometry is out by hundreds. + private static final int CHROME_ALLOWANCE = 64; + + /// How many readiness polls between re-asserting the requested size. The polls run + /// as fast as the event dispatch thread will re-queue them, so this is a rough + /// throttle rather than a duration -- often enough to rescue a refused request, + /// rare enough not to fight a platform that is mid-resize. + private static final int RESIZE_RETRY_POLLS = 25; + + /// Counts readiness polls for the size re-assert above. + private int resizeAttempts; + + /// How many windows one size may burn before the case is called a failure. + /// + /// Re-asserting the size into a window the platform has already refused does not + /// always rescue it: on Mac Catalyst a scene can stay pinned to another window's + /// geometry -- a native editor is one way to pin it -- and every later request on + /// that scene comes back as the system default. A window that never reached its + /// size is therefore discarded and asked for again from scratch, which releases + /// the scene and gets a fresh one. + private static final int MAX_WINDOW_ATTEMPTS = 2; + + /// Windows opened for the size currently being captured. + private int windowAttempts; + + /** Window sizes every windowed case is captured at. */ + protected static final int[][] SIZES = new int[][]{ + {400, 300}, // small + {900, 700}, // large + {1000, 400}, // deliberately non-square: proves layout follows the window + }; + + /** How long to wait for a newly shown window to become renderable. */ + private static final int WINDOW_READY_TIMEOUT_MS = 10000; + + private Window window; + /// Previous poll's window size, so readiness can tell a settled window from one + /// that is still being resized by the platform. + private int lastWidth = -1; + private int lastHeight = -1; + + /** + * The content to host in the window. Invoked once per window opened -- which is + * once per size, and again for each retry when the platform refuses the size -- + * so an implementation must build a fresh component tree every time rather than + * caching one. The same component cannot live in two hierarchies, and the window + * this content went into has already been disposed by the time it is asked for + * again. + */ + protected abstract Component createWindowContent(int width, int height); + + /** Golden name stem; the size is appended by the harness. */ + protected abstract String baseImageName(); + + /** + * Sizes this case is captured at. Override to narrow it -- a case that only proves + * one behaviour does not need three goldens. + */ + protected int[][] sizes() { + return SIZES; + } + + @Override + public boolean shouldTakeScreenshot() { + return true; + } + + /** + * The window and its content outlive nothing here, but a retry would leave the + * previous attempt's window open and a second one would then be captured. + */ + @Override + public boolean isRetrySafe() { + return false; + } + + @Override + public boolean runTest() throws Exception { + if (!Desktop.isSupported()) { + // Nothing is reported here at all. The windowed baselines are scoped in + // port_status.json to the ports that have a windowing system, so this + // test is not part of the contract for the port running it now and the + // report will not carry a row for it either way. Reporting a skip would + // put a row on the public table inviting the reader to count a capability + // this port was never asked for as something it failed to do; reporting a + // pass would put a tick against multi-window on a port that cannot open a + // window at all. + done(); + return true; + } + captureNext(0); + return true; + } + + private void captureNext(final int index) { + int[][] all = sizes(); + if (index >= all.length) { + done(); + return; + } + final int width = all[index][0]; + final int height = all[index][1]; + + windowAttempts = 0; + openWindowFor(index, width, height); + } + + /// Opens a window for one size and starts waiting for it to become renderable. + /// Called again when a window has to be discarded and asked for from scratch. + private void openWindowFor(final int index, final int width, final int height) { + closeWindow(); + lastWidth = -1; + lastHeight = -1; + resizeAttempts = 0; + windowAttempts++; + window = new Window(baseImageName(), new BorderLayout()); + window.setResizable(true); + window.add(BorderLayout.CENTER, createWindowContent(width, height)); + window.setWindowSize(width, height); + window.show(); + + // Wait for the window to actually be renderable rather than for a fixed + // delay. Some platforms create the native window asynchronously -- Mac + // Catalyst has to ask the system to activate a scene and is handed one back + // later -- so a fixed sleep is both too long on the fast ports and too short + // on the slow ones. The window is also not the current form, so the suite's + // usual "current form has settled" gate does not apply to it. + awaitRenderable(index, width, height, + System.currentTimeMillis() + WINDOW_READY_TIMEOUT_MS); + } + + /** + * Polls on the event dispatch thread until the window can actually be rendered. + * Re-queuing through callSerially rather than sleeping matters: the paint that + * makes the window renderable happens on this very thread, so blocking it here + * would prevent the condition from ever becoming true. + */ + private void awaitRenderable(final int index, final int width, final int height, + final long deadline) { + // Readiness has four parts, and dropping any one of them produces a golden + // that silently lies. + // + // The window has painted at least once: its raster exists from the moment it + // is shown, so a capture before the first paint is a blank frame of exactly + // the right dimensions. + // + // Its size has settled: some platforms create the native window + // asynchronously and only then report a real size back -- Mac Catalyst has to + // ask the system to activate a scene -- so a capture taken between the request + // and the answer catches the window mid-resize. + // + // The capture is the size the window laid out at. This is the real invariant, + // and the one that caught a window laying out at 400x300 inside a raster the + // size of the main display. + // + // The window is no larger than what was asked for. setWindowSize() is native + // geometry and includes the platform's chrome, so the content is legitimately + // smaller wherever a title bar and border exist -- but it can never be bigger, + // and a platform that ignored the request and handed back its own size is what + // that would mean. + Image probe = window == null ? null : window.capture(); + int windowWidth = window == null ? 0 : window.getWidth(); + int windowHeight = window == null ? 0 : window.getHeight(); + boolean settled = windowWidth == lastWidth && windowHeight == lastHeight; + lastWidth = windowWidth; + lastHeight = windowHeight; + boolean ready = window != null + && window.hasPaintedOnce() + && settled + && windowWidth > 0 && windowHeight > 0 + // Within a chrome-sized allowance of the size asked for, and never + // larger. The original rule allowed anything down to three quarters, + // which was meant to reject a window still reporting a previous + // window's geometry and did not: a 700x500 background came back at a + // recycled scene's 600x450 and passed. Requiring an exact match instead + // was worse -- it rejected every window on ports whose reported size is + // the content inside the chrome, which is most of them. + // + // An absolute allowance separates the two. Chrome costs tens of pixels + // (Windows takes 16 wide and 39 high, Catalyst about 16 high), while a + // stale geometry is out by hundreds. + && windowWidth <= width && windowHeight <= height + && windowWidth >= width - CHROME_ALLOWANCE + && windowHeight >= height - CHROME_ALLOWANCE + && probe != null + && probe.getWidth() == windowWidth + && probe.getHeight() == windowHeight; + if (ready || System.currentTimeMillis() >= deadline) { + captureAndAdvance(index, width, height, ready); + return; + } + // Ask again, periodically, for the size this test wants. + // + // A window size is a request the platform may refuse -- Mac Catalyst hands + // back its 1024x768 default when it ignores one -- and a window that lost the + // request otherwise stays wrong until the deadline, producing no capture at + // all. The port retries too, but only for a couple of seconds after creation; + // this covers a refusal that outlasts that, and costs nothing on a platform + // that granted the size, because the window is ready and never gets here. + resizeAttempts++; + if (window != null && resizeAttempts % RESIZE_RETRY_POLLS == 0 + && (windowWidth != width || windowHeight != height)) { + window.setWindowSize(width, height); + } + CN.callSerially(new Runnable() { + @Override + public void run() { + awaitRenderable(index, width, height, deadline); + } + }); + } + + /** + * True when a capture is mostly unpainted. A window's raster starts out black, so + * a frame that is largely black was never painted in full -- which is exactly what + * a window resized ahead of the platform produced: correct dimensions, correct + * content in one corner, and the rest of the surface untouched. Dimensions alone + * could not see it, and a green suite hid it twice. + */ + private static boolean mostlyUnpainted(Image img) { + int w = img.getWidth(); + int h = img.getHeight(); + if (w <= 0 || h <= 0) { + return true; + } + int[] rgb = img.getRGB(); + int black = 0; + int sampled = 0; + // Every eighth row is plenty to tell "half the window is missing" from + // "this design happens to use dark pixels", and keeps the check cheap. + for (int y = 0; y < h; y += 8) { + int offset = y * w; + for (int x = 0; x < w; x++) { + if ((rgb[offset + x] & 0xffffff) == 0) { + black++; + } + sampled++; + } + } + return sampled > 0 && black * 4 > sampled * 3; + } + + private void captureAndAdvance(final int index, int width, int height, boolean ready) { + String name = baseImageName() + "-" + width + "x" + height; + if (!ready && windowAttempts < MAX_WINDOW_ATTEMPTS) { + // Throw this window away and ask for another. Re-asserting the size into a + // window whose scene is pinned to someone else's geometry never wins; a new + // window gets a new scene. Reported rather than silent, so a port that only + // ever passes on the second attempt is visible in the log instead of + // looking clean. + println("CN1SS:INFO:test=" + getClass().getName().substring( + getClass().getName().lastIndexOf('.') + 1) + + " note=window-size-refused name=" + name + + " got=" + (window == null ? "none" + : window.getWidth() + "x" + window.getHeight()) + + " retrying-with-a-new-window"); + stopEditingThen(new Runnable() { + @Override + public void run() { + openWindowFor(index, width, height); + } + }); + return; + } + if (!ready) { + Image last = window == null ? null : window.capture(); + fail("Window never became renderable at the requested size for " + name + + " (showing=" + (window != null && window.isWindowShowing()) + + " painted=" + (window != null && window.hasPaintedOnce()) + + " size=" + (window == null ? "none" : window.getWidth() + "x" + window.getHeight()) + + " capture=" + (last == null ? "none" + : last.getWidth() + "x" + last.getHeight()) + ")"); + return; + } + Image shot = window.capture(); + if (shot == null) { + fail("Window capture returned null for " + name); + return; + } + if (mostlyUnpainted(shot)) { + fail("Window capture for " + name + " is mostly unpainted at " + + shot.getWidth() + "x" + shot.getHeight() + + "; the window was not painted over its whole surface"); + return; + } + Cn1ssDeviceRunnerHelper.emitImage(shot, name, new Runnable() { + @Override + public void run() { + stopEditingThen(new Runnable() { + @Override + public void run() { + captureNext(index + 1); + } + }); + } + }); + } + + /// Stops any native editor in the current window, then runs the continuation. + /// + /// A native editor holds platform state tied to the window it is in -- on Mac + /// Catalyst it pins the scene, so the *next* window came back at the system's + /// default size instead of the one requested and never became renderable, which is + /// why the editing case produced only its first size there. Stopping is + /// asynchronous, hence the continuation rather than a plain call. + /// + /// The window is closed either way before the continuation runs: releasing the + /// scene is the point, and a window left open would be captured by whatever comes + /// next. + private void stopEditingThen(final Runnable after) { + if (window != null && window.isEditing()) { + window.stopEditing(new Runnable() { + @Override + public void run() { + closeWindow(); + after.run(); + } + }); + return; + } + closeWindow(); + after.run(); + } + + private void closeWindow() { + if (window != null) { + window.dispose(); + window = null; + } + } + + private static void println(String s) { + System.out.println(s); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowLayoutTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowLayoutTest.java new file mode 100644 index 00000000000..3e744f4541d --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowLayoutTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Button; +import com.codename1.ui.CheckBox; +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.Slider; +import com.codename1.ui.TextField; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.layouts.GridLayout; + +/** + * Ordinary widgets laid out inside a desktop window. + * + *

This is the baseline case of the windowed suite: it proves that layout, theming + * and font metrics resolve against the window's size rather than the + * application's main surface. The three capture sizes are what make that visible -- + * the same content at 400x300, 900x700 and a deliberately non-square 1000x400 has to + * reflow, and a window that was still measuring itself against the main display would + * produce three near-identical goldens.

+ * + * @author Shai Almog + */ +public class WindowLayoutTest extends WindowHostTest { + + @Override + protected String baseImageName() { + return "Window-Layout"; + } + + @Override + protected Component createWindowContent(int width, int height) { + Container root = new Container(new BorderLayout()); + + Label heading = new Label("Codename One window " + width + "x" + height); + heading.setUIID("Title"); + root.add(BorderLayout.NORTH, heading); + + Container body = new Container(BoxLayout.y()); + body.add(new Label("Widgets in a native window")); + body.add(new Button("Button")); + body.add(new CheckBox("Check box")); + TextField field = new TextField("Editable text"); + body.add(field); + Slider slider = new Slider(); + slider.setProgress(40); + body.add(slider); + root.add(BorderLayout.CENTER, body); + + Container footer = new Container(new GridLayout(1, 3)); + footer.add(new Label("one")); + footer.add(new Label("two")); + footer.add(new Label("three")); + root.add(BorderLayout.SOUTH, footer); + return root; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowModalTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowModalTest.java new file mode 100644 index 00000000000..35199b78f26 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowModalTest.java @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.CN; +import com.codename1.ui.Desktop; +import com.codename1.ui.Image; +import com.codename1.ui.Label; +import com.codename1.ui.Window; +import com.codename1.ui.layouts.BorderLayout; + +/** + * A modal window over a second window. + * + *

Two things are being proved, and neither is visible from a single capture of one + * window. First, that a modal window blocks input to what it covers -- Codename One + * enforces that itself rather than relying on the platform, so it has to hold on every + * port. Second, and more easily broken: that the blocked window keeps painting. + * Modality parks the caller through invokeAndBlock, which re-enters the event loop, so a + * window that stopped repainting while a modal was up would mean the loop had stopped + * servicing it.

+ * + *

The background window is captured while the modal is open, which is exactly the + * state that would be blank if the second property regressed.

+ * + * @author Shai Almog + */ +public class WindowModalTest extends BaseTest { + + /// How far below the requested size a window may legitimately be. + /// Chrome costs tens of pixels; a window still carrying another + /// window's geometry is out by hundreds. + private static final int CHROME_ALLOWANCE = 64; + + /** Size of the window the golden is captured from. */ + private static final int BACKGROUND_WIDTH = 700; + private static final int BACKGROUND_HEIGHT = 500; + + private Window background; + private Window modal; + /// Previous poll's background window size, so readiness can tell a settled window + /// from one the platform is still resizing. + private int lastWidth = -1; + private int lastHeight = -1; + + @Override + public boolean shouldTakeScreenshot() { + return true; + } + + @Override + public boolean isRetrySafe() { + return false; + } + + @Override + public boolean runTest() throws Exception { + if (!Desktop.isSupported()) { + // Reports nothing: see WindowHostTest. + done(); + return true; + } + + background = new Window("Background", new BorderLayout()); + Label backdrop = new Label("Background window keeps painting"); + backdrop.setUIID("Title"); + background.add(BorderLayout.CENTER, backdrop); + background.setWindowSize(BACKGROUND_WIDTH, BACKGROUND_HEIGHT); + background.show(); + + // The modal window is opened only once the background one has settled. Asking + // a platform for two windows in the same breath is what makes their geometry + // race: Mac Catalyst answers a second scene request while the first is still + // being sized and can hand back a full screen window. + awaitBackground(System.currentTimeMillis() + 10000); + return true; + } + + private void awaitBackground(final long deadline) { + if (isSettled(background, BACKGROUND_WIDTH, BACKGROUND_HEIGHT)) { + showModalWindow(); + return; + } + if (System.currentTimeMillis() >= deadline) { + fail("The background window never became renderable (showing=" + + background.isWindowShowing() + " painted=" + background.hasPaintedOnce() + + " size=" + background.getWidth() + "x" + background.getHeight() + ")"); + return; + } + CN.callSerially(new Runnable() { + @Override + public void run() { + awaitBackground(deadline); + } + }); + } + + private void showModalWindow() { + modal = new Window("Modal", new BorderLayout()); + modal.add(BorderLayout.CENTER, new Label("Modal window")); + modal.setWindowSize(320, 200); + modal.setModalityType(Window.MODALITY_APPLICATION); + // Deliberately NOT showModal(): that parks this thread until the window is + // disposed, and the capture has to happen while it is still up. The window is + // still application-modal, so the framework blocks input to the one behind it. + modal.show(); + + // Wait for the windows to be renderable rather than for a fixed delay; a + // platform may create the native window asynchronously. + awaitRenderable(System.currentTimeMillis() + 10000); + } + + /** + * The readiness contract shared with {@link WindowHostTest}: painted at least once, + * size settled, no larger than the native geometry that was asked for (chrome makes + * the content legitimately smaller), and a capture matching the size the window + * actually laid out at. + */ + private boolean isSettled(Window w, int requestedWidth, int requestedHeight) { + Image probe = w.capture(); + int windowWidth = w.getWidth(); + int windowHeight = w.getHeight(); + boolean settled = windowWidth == lastWidth && windowHeight == lastHeight; + lastWidth = windowWidth; + lastHeight = windowHeight; + return w.hasPaintedOnce() + && settled + && windowWidth > 0 && windowHeight > 0 + // Within a chrome-sized allowance of the size asked for, and never + // larger. The original rule allowed anything down to three quarters, + // which was meant to reject a window still reporting a previous + // window's geometry and did not: a 700x500 background came back at a + // recycled scene's 600x450 and passed. Requiring an exact match instead + // was worse -- it rejected every window on ports whose reported size is + // the content inside the chrome, which is most of them. + // + // An absolute allowance separates the two. Chrome costs tens of pixels + // (Windows takes 16 wide and 39 high, Catalyst about 16 high), while a + // stale geometry is out by hundreds. + && windowWidth <= requestedWidth && windowHeight <= requestedHeight + && windowWidth >= requestedWidth - CHROME_ALLOWANCE + && windowHeight >= requestedHeight - CHROME_ALLOWANCE + && probe != null + && probe.getWidth() == windowWidth + && probe.getHeight() == windowHeight; + } + + /** + * Polls on the event dispatch thread rather than sleeping on it: the paint that + * makes the windows renderable happens on this thread. + */ + private void awaitRenderable(final long deadline) { + // The background window has to be renderable again -- showing the modal + // resizes nothing, but it does repaint -- and the modal has to be up, which is + // the state the capture is meant to prove. + if (isSettled(background, BACKGROUND_WIDTH, BACKGROUND_HEIGHT) + && modal.isWindowShowing()) { + capture(); + return; + } + if (System.currentTimeMillis() >= deadline) { + fail("Windows never became renderable (background showing=" + + background.isWindowShowing() + " painted=" + + background.hasPaintedOnce() + " size=" + background.getWidth() + + "x" + background.getHeight() + " modal showing=" + + modal.isWindowShowing() + ")"); + return; + } + CN.callSerially(new Runnable() { + @Override + public void run() { + awaitRenderable(deadline); + } + }); + } + + private void capture() { + Image shot = background.capture(); + if (shot == null) { + fail("Background window capture returned null while a modal window was open"); + return; + } + Cn1ssDeviceRunnerHelper.emitImage(shot, "Window-Modal-background", new Runnable() { + @Override + public void run() { + modal.dispose(); + background.dispose(); + done(); + } + }); + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowOverlayTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowOverlayTest.java new file mode 100644 index 00000000000..9381665f940 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowOverlayTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; + +/** + * A layered overlay inside a desktop window. + * + *

Sheet, InteractionDialog and ToastBar all attach themselves to their host's layered + * pane, and a window has to provide one that spans it exactly as a form's does. This case + * puts content into that pane directly, which is the same attachment point those + * components use, so a window whose layered pane was mis-sized or mis-stacked shows up as + * a pixel difference here.

+ * + * @author Shai Almog + */ +public class WindowOverlayTest extends WindowHostTest { + + /** One size is enough: this proves stacking, not reflow. */ + @Override + protected int[][] sizes() { + return new int[][]{{600, 450}}; + } + + @Override + protected String baseImageName() { + return "Window-Overlay"; + } + + @Override + protected Component createWindowContent(int width, int height) { + Container root = new Container(BoxLayout.y()) { + private boolean overlayInstalled; + + @Override + protected void initComponent() { + super.initComponent(); + if (overlayInstalled) { + return; + } + overlayInstalled = true; + com.codename1.ui.TopLevelContainer top = getTopLevelContainer(); + if (top == null) { + return; + } + Container layer = top.getFormLayeredPane(WindowOverlayTest.class, true); + Label banner = new Label("Overlay layer"); + banner.setUIID("Title"); + layer.setLayout(new BorderLayout()); + layer.add(BorderLayout.SOUTH, banner); + } + }; + root.add(new Label("Base content")); + root.add(new Label("sits under the overlay")); + return root; + } +} diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowScrollTest.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowScrollTest.java new file mode 100644 index 00000000000..68e54906f8d --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowScrollTest.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +package com.codenameone.examples.hellocodenameone.tests; + +import com.codename1.ui.Component; +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BoxLayout; + +/** + * A long scrollable list inside a desktop window. + * + *

Scrolling is one of the behaviours that goes silently dead if a component + * cannot resolve its top level: {@code isScrollableY} consults the enclosing top level + * for the area hidden by a virtual keyboard, and the smooth-scroll motion registers + * itself with that top level's internal animation registry. A window that failed to + * resolve either would render this content unscrolled and clipped rather than throwing, + * which is exactly why it earns a golden. + * + * @author Shai Almog + */ +public class WindowScrollTest extends WindowHostTest { + + @Override + protected String baseImageName() { + return "Window-Scroll"; + } + + @Override + protected Component createWindowContent(int width, int height) { + Container list = new Container(BoxLayout.y()); + list.setScrollableY(true); + for (int iter = 0; iter < 40; iter++) { + Label l = new Label("Row " + iter); + l.setUIID(iter % 2 == 0 ? "Label" : "MultiLine1"); + list.add(l); + } + return list; + } +} diff --git a/scripts/hellocodenameone/conformance/port_status.py b/scripts/hellocodenameone/conformance/port_status.py index 96ddb53f390..63301d7fd14 100755 --- a/scripts/hellocodenameone/conformance/port_status.py +++ b/scripts/hellocodenameone/conformance/port_status.py @@ -100,6 +100,38 @@ def test_to_feature(manifest: dict) -> dict[str, str]: return mapping +def test_scopes(manifest: dict) -> dict[str, set[str] | None]: + """Map every test to the ports it applies to, or None when that is all of them. + + Scopes live in a top-level ``test_scopes`` map rather than inside the feature's + test list, so that list stays a plain list of names: the website templates render, + count and search it, and giving some entries a different shape broke the site + build rather than the contract. + + A scope exists for a capability a port does not have -- a windowing system, say. + Without it such a test is absent from that port's report forever, which the + coverage gate reads as a test the port dropped, and the only way to quiet that is a + row of skips on the public table inviting the reader to count a capability the port + was never asked for as something it failed to do. + """ + scopes: dict[str, set[str] | None] = {} + scoped = manifest.get("test_scopes", {}) + for feature in manifest.get("features", []): + for test in feature.get("tests", []): + ports = scoped.get(test) + scopes[test] = None if ports is None else set(ports) + return scopes + + +def tests_for_port(manifest: dict, port_id: str) -> set[str]: + """The tests a given port is expected to report on.""" + return { + test + for test, ports in test_scopes(manifest).items() + if ports is None or port_id in ports + } + + def screenshot_test(manifest: dict, output_name: str) -> str | None: matches = [ item.get("test") @@ -133,6 +165,24 @@ def validate(manifest: dict) -> dict: problems.append(str(exc)) mapped = {} + scoped = manifest.get("test_scopes", {}) + if not isinstance(scoped, dict): + problems.append("test_scopes must be a map of test name to port list") + scoped = {} + for test, scoped_ports in sorted(scoped.items()): + if not isinstance(scoped_ports, list) or not scoped_ports: + problems.append(f"Scoped test {test} must list the ports it applies to") + continue + unknown_ports = sorted(set(scoped_ports) - set(port_ids)) + if unknown_ports: + problems.append( + f"Test {test} is scoped to unknown ports: " + ", ".join(unknown_ports) + ) + if test not in mapped: + # A scope on a name no feature registers is a scope that does nothing, and + # the test it was meant for is left unscoped. + problems.append(f"test_scopes names {test}, which no feature registers") + registered = registered_tests() duplicate_registrations = sorted( name for name, count in Counter(registered).items() if count > 1 @@ -554,7 +604,11 @@ def coverage_problems( problems.append(f"{port}: report has no usable generated_at") continue own_contract = (contracts or {}).get(port) - absent = set(mapped) - set(tests) + # Scoped to this port. A test that does not apply here -- a windowed + # baseline on a port with no windowing system -- is absent from every + # report this port will ever publish, so without this the first desktop + # run to carry it would make every other port look like it dropped it. + absent = tests_for_port(manifest, port) - set(tests) dropped = sorted( name for name in absent @@ -963,7 +1017,7 @@ def normalize( "compared": False, "comparison_passed": False, } - for test in mapped + for test in tests_for_port(manifest, port_id) } suite_finished = parse_logs(manifest, logs, states) performance = parse_performance( @@ -1099,14 +1153,16 @@ def publishable_report_problems( f"generated_at {generated_at!r} is in the future" ) - mapped = test_to_feature(manifest) + # Scoped to this port: a test that does not apply here is not something the + # report predates, it is something the report is right never to carry. + expected = tests_for_port(manifest, port_id) tests = report.get("tests") if not isinstance(tests, dict): malformed.append("report has no test result map") tests = {} else: - missing = sorted(set(mapped) - set(tests)) - unknown = sorted(set(tests) - set(mapped)) + missing = sorted(expected - set(tests)) + unknown = sorted(set(tests) - expected) if missing: drift.append("report predates tests: " + ", ".join(missing)) if unknown: diff --git a/scripts/hellocodenameone/conformance/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index 654d5644244..f3cd92595b9 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import copy import json import tempfile import unittest @@ -383,6 +384,77 @@ def test_coverage_ignores_a_retired_test_left_at_not_run(self): } self.assertEqual([], port_status.coverage_problems(self.manifest, reports)) + def test_a_scoped_test_is_expected_only_where_it_applies(self): + # The windowed baselines are the reason scoping exists. A port with no + # windowing system will never carry them, so without a scope they would be + # absent from its report forever -- and the first desktop run to publish one + # would make every other port look like it had dropped a test. + desktop = port_status.tests_for_port(self.manifest, "linux-x64") + phone = port_status.tests_for_port(self.manifest, "android") + self.assertIn("WindowLayoutTest", desktop) + self.assertNotIn("WindowLayoutTest", phone) + # MultiWindowApiTest is not scoped: it asserts the contract everywhere, + # including that the API throws where windows are unsupported. + self.assertIn("MultiWindowApiTest", desktop) + self.assertIn("MultiWindowApiTest", phone) + + def scoped_out_ports(self, test_name): + return { + port["id"] + for port in self.manifest["ports"] + if test_name not in port_status.tests_for_port(self.manifest, port["id"]) + } + + def coverage_with_one_port_carrying(self, port_id, test_name, feature): + reports = self.stored_reports() + reports[port_id]["tests"][test_name] = {"feature": feature, "status": "pass"} + return port_status.coverage_problems(self.manifest, reports) + + def test_coverage_does_not_call_a_scoped_test_dropped_where_it_cannot_run(self): + # The failure this prevents: one desktop port publishes WindowLayoutTest, + # which teaches known_since that the test exists, and every later run on a + # port that can never run it is then read as having dropped it. + problems = self.coverage_with_one_port_carrying( + "linux-x64", "WindowLayoutTest", "multi-window") + out = self.scoped_out_ports("WindowLayoutTest") + self.assertTrue(out) + blamed = [ + p for p in problems + if "WindowLayoutTest" in p and any(port in p for port in out) + ] + self.assertEqual([], blamed, problems) + + def test_coverage_still_catches_a_scoped_test_dropped_where_it_applies(self): + # Scoping must not become a way for a port that *does* have windows to stop + # reporting them: another desktop port that ran later without it is still a + # port that dropped it. + problems = self.coverage_with_one_port_carrying( + "linux-x64", "WindowLayoutTest", "multi-window") + in_scope = port_status.test_scopes(self.manifest)["WindowLayoutTest"] - {"linux-x64"} + self.assertTrue( + any("WindowLayoutTest" in p and any(port in p for port in in_scope) + for p in problems), + problems, + ) + + def test_a_test_scoped_to_an_unknown_port_is_rejected(self): + manifest = copy.deepcopy(self.manifest) + self.rescope(manifest, "WindowLayoutTest", ["linux-x64", "no-such-port"]) + with self.assertRaises(port_status.ContractError) as caught: + port_status.validate(manifest) + self.assertIn("no-such-port", str(caught.exception)) + + def test_a_scoped_test_must_name_at_least_one_port(self): + manifest = copy.deepcopy(self.manifest) + self.rescope(manifest, "WindowLayoutTest", []) + with self.assertRaises(port_status.ContractError) as caught: + port_status.validate(manifest) + self.assertIn("WindowLayoutTest", str(caught.exception)) + + @staticmethod + def rescope(manifest, test_name, ports): + manifest.setdefault("test_scopes", {})[test_name] = ports + def test_coverage_accepts_a_documented_skip(self): # The distinction the rule turns on: a port that genuinely cannot do something reports # "skip" from the suite itself, which is evidence rather than the absence of it -- but @@ -889,9 +961,12 @@ def test_provenance_ignores_an_untouched_report(self): def publishable_report(self, port_id, **overrides): mapped = port_status.test_to_feature(self.manifest) + # Only the tests this port is expected to report on. A test scoped to other + # ports is not something this report is missing, and carrying it here would + # make the fixture assert the opposite of the contract. tests = { - test: {"status": "pass", "feature": feature} - for test, feature in mapped.items() + test: {"status": "pass", "feature": mapped[test]} + for test in port_status.tests_for_port(self.manifest, port_id) } report = { "schema_version": self.manifest["schema_version"], diff --git a/scripts/javase/lib/SimulatorWindowModeVerifier.java b/scripts/javase/lib/SimulatorWindowModeVerifier.java index 0020f76cbd7..b721c0926a5 100644 --- a/scripts/javase/lib/SimulatorWindowModeVerifier.java +++ b/scripts/javase/lib/SimulatorWindowModeVerifier.java @@ -146,6 +146,7 @@ public static void main(String[] args) { BufferedImage image = captureDesktop(); Instant renderDeadline = Instant.now().plusSeconds(30); while ((isBlankOrFlat(image) || isSingleWindowDeviceMissing(parsed, image) + || isSingleWindowDeviceUnpainted(parsed, image) || isComponentInspectorDetailsUnsettled(parsed, image) || isComponentInspectorPropertiesUnpopulated(parsed, image)) && Instant.now().isBefore(renderDeadline)) { @@ -229,6 +230,105 @@ private static boolean isBlankOrFlat(BufferedImage image) { return sampleColorCount(image) < 3; } + /** + * Whether the device's screen -- the area inside the skin's bezel -- is still + * entirely dark, which is a capture taken before the first paint rather than a + * rendering difference. The javase-single-native-theme-ios-modern scenario is the + * one that shows it: it is the first capture after the CSS native themes are + * built, so it is the one that races the first paint, and it has come back with a + * solid black screen more than once while the scenarios after it passed. + * + * This is a WAIT condition and deliberately not an assertion. An earlier attempt + * asserted on a dark-pixel ratio measured over a region that included the skin's + * bezel, and the Nexus5X skin used by the Windows tooling run is mostly bezel: + * the threshold was never valid there and it turned a passing job red on its + * first run. Used only to wait, the worst a bad measurement can do is spend the + * 30 second deadline and then capture anyway, which costs time rather than a + * build. + * + * The bezel is measured rather than assumed, for the same reason: the dark body + * is found first and its interior is what gets tested, so nothing here depends on + * which skin is loaded or where the window sits. + */ + private static boolean isSingleWindowDeviceUnpainted(Args args, BufferedImage image) { + if (!"single".equals(args.mode)) { + return false; + } + Rectangle body = darkBodyBounds(image); + if (body == null) { + // No device body found, which isSingleWindowDeviceMissing already covers. + return false; + } + // Inset well inside the bezel. The screen is a large fraction of the body on + // every skin here, so a fifth in from each edge is inside the screen on all of + // them without needing to know which one this is. + int insetX = Math.max(1, body.width / 5); + int insetY = Math.max(1, body.height / 5); + int x0 = body.x + insetX; + int y0 = body.y + insetY; + int x1 = body.x + body.width - insetX; + int y1 = body.y + body.height - insetY; + if (x1 - x0 < 20 || y1 - y0 < 20) { + return false; + } + int dark = 0; + int total = 0; + for (int y = y0; y < y1; y++) { + for (int x = x0; x < x1; x++) { + int rgb = image.getRGB(x, y); + if (((rgb >> 16) & 0xff) < 45 && ((rgb >> 8) & 0xff) < 45 && (rgb & 0xff) < 45) { + dark++; + } + total++; + } + } + // Nearly all of it. A painted screen in any of these themes leaves plenty of + // light pixels; an unpainted one measured 99% here against 0% for the stored + // reference of the same scenario. + return total > 0 && dark * 100L / total >= 95; + } + + /** + * The bounding box of the near-black device body within the region the + * single-window scenarios place it, or null when there is not enough of one to + * be a device. + */ + private static Rectangle darkBodyBounds(BufferedImage image) { + int xMax = Math.min(image.getWidth(), 560); + int yMin = Math.min(image.getHeight(), 70); + int yMax = Math.min(image.getHeight(), 560); + if (xMax <= 0 || yMax <= yMin) { + return null; + } + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + int maxX = -1; + int maxY = -1; + for (int y = yMin; y < yMax; y++) { + for (int x = 0; x < xMax; x++) { + int rgb = image.getRGB(x, y); + if (((rgb >> 16) & 0xff) < 45 && ((rgb >> 8) & 0xff) < 45 && (rgb & 0xff) < 45) { + if (x < minX) { + minX = x; + } + if (y < minY) { + minY = y; + } + if (x > maxX) { + maxX = x; + } + if (y > maxY) { + maxY = y; + } + } + } + } + if (maxX < 0 || maxX - minX < 60 || maxY - minY < 60) { + return null; + } + return new Rectangle(minX, minY, maxX - minX + 1, maxY - minY + 1); + } + private static boolean isSingleWindowDeviceMissing(Args args, BufferedImage image) { int darkPixels = countSingleWindowDevicePixels(args, image); return darkPixels >= 0 && darkPixels < minimumSingleWindowDevicePixels(args); diff --git a/scripts/linux/screenshots-arm/Window-Editing-1000x400.png b/scripts/linux/screenshots-arm/Window-Editing-1000x400.png new file mode 100644 index 00000000000..6829fee4c5f Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Editing-1000x400.png differ diff --git a/scripts/linux/screenshots-arm/Window-Editing-400x300.png b/scripts/linux/screenshots-arm/Window-Editing-400x300.png new file mode 100644 index 00000000000..110bd7bdb09 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Editing-400x300.png differ diff --git a/scripts/linux/screenshots-arm/Window-Editing-900x700.png b/scripts/linux/screenshots-arm/Window-Editing-900x700.png new file mode 100644 index 00000000000..998c9e033f6 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Editing-900x700.png differ diff --git a/scripts/linux/screenshots-arm/Window-Graphics-1000x400.png b/scripts/linux/screenshots-arm/Window-Graphics-1000x400.png new file mode 100644 index 00000000000..d9ca21c7707 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Graphics-1000x400.png differ diff --git a/scripts/linux/screenshots-arm/Window-Graphics-400x300.png b/scripts/linux/screenshots-arm/Window-Graphics-400x300.png new file mode 100644 index 00000000000..239629b938a Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Graphics-400x300.png differ diff --git a/scripts/linux/screenshots-arm/Window-Graphics-900x700.png b/scripts/linux/screenshots-arm/Window-Graphics-900x700.png new file mode 100644 index 00000000000..a2f0d8c13de Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Graphics-900x700.png differ diff --git a/scripts/linux/screenshots-arm/Window-Layout-1000x400.png b/scripts/linux/screenshots-arm/Window-Layout-1000x400.png new file mode 100644 index 00000000000..666adc85253 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Layout-1000x400.png differ diff --git a/scripts/linux/screenshots-arm/Window-Layout-400x300.png b/scripts/linux/screenshots-arm/Window-Layout-400x300.png new file mode 100644 index 00000000000..518d7788ff6 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Layout-400x300.png differ diff --git a/scripts/linux/screenshots-arm/Window-Layout-900x700.png b/scripts/linux/screenshots-arm/Window-Layout-900x700.png new file mode 100644 index 00000000000..de6bff86530 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Layout-900x700.png differ diff --git a/scripts/linux/screenshots-arm/Window-Modal-background.png b/scripts/linux/screenshots-arm/Window-Modal-background.png new file mode 100644 index 00000000000..cb8bfc3d899 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Modal-background.png differ diff --git a/scripts/linux/screenshots-arm/Window-Overlay-600x450.png b/scripts/linux/screenshots-arm/Window-Overlay-600x450.png new file mode 100644 index 00000000000..5d9f7c82fe4 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Overlay-600x450.png differ diff --git a/scripts/linux/screenshots-arm/Window-Scroll-1000x400.png b/scripts/linux/screenshots-arm/Window-Scroll-1000x400.png new file mode 100644 index 00000000000..a6ad7a0b2b9 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Scroll-1000x400.png differ diff --git a/scripts/linux/screenshots-arm/Window-Scroll-400x300.png b/scripts/linux/screenshots-arm/Window-Scroll-400x300.png new file mode 100644 index 00000000000..49c2b58a893 Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Scroll-400x300.png differ diff --git a/scripts/linux/screenshots-arm/Window-Scroll-900x700.png b/scripts/linux/screenshots-arm/Window-Scroll-900x700.png new file mode 100644 index 00000000000..51412c91d0e Binary files /dev/null and b/scripts/linux/screenshots-arm/Window-Scroll-900x700.png differ diff --git a/scripts/linux/screenshots/Window-Editing-1000x400.png b/scripts/linux/screenshots/Window-Editing-1000x400.png new file mode 100644 index 00000000000..6829fee4c5f Binary files /dev/null and b/scripts/linux/screenshots/Window-Editing-1000x400.png differ diff --git a/scripts/linux/screenshots/Window-Editing-400x300.png b/scripts/linux/screenshots/Window-Editing-400x300.png new file mode 100644 index 00000000000..110bd7bdb09 Binary files /dev/null and b/scripts/linux/screenshots/Window-Editing-400x300.png differ diff --git a/scripts/linux/screenshots/Window-Editing-900x700.png b/scripts/linux/screenshots/Window-Editing-900x700.png new file mode 100644 index 00000000000..998c9e033f6 Binary files /dev/null and b/scripts/linux/screenshots/Window-Editing-900x700.png differ diff --git a/scripts/linux/screenshots/Window-Graphics-1000x400.png b/scripts/linux/screenshots/Window-Graphics-1000x400.png new file mode 100644 index 00000000000..d9ca21c7707 Binary files /dev/null and b/scripts/linux/screenshots/Window-Graphics-1000x400.png differ diff --git a/scripts/linux/screenshots/Window-Graphics-400x300.png b/scripts/linux/screenshots/Window-Graphics-400x300.png new file mode 100644 index 00000000000..239629b938a Binary files /dev/null and b/scripts/linux/screenshots/Window-Graphics-400x300.png differ diff --git a/scripts/linux/screenshots/Window-Graphics-900x700.png b/scripts/linux/screenshots/Window-Graphics-900x700.png new file mode 100644 index 00000000000..a2f0d8c13de Binary files /dev/null and b/scripts/linux/screenshots/Window-Graphics-900x700.png differ diff --git a/scripts/linux/screenshots/Window-Layout-1000x400.png b/scripts/linux/screenshots/Window-Layout-1000x400.png new file mode 100644 index 00000000000..666adc85253 Binary files /dev/null and b/scripts/linux/screenshots/Window-Layout-1000x400.png differ diff --git a/scripts/linux/screenshots/Window-Layout-400x300.png b/scripts/linux/screenshots/Window-Layout-400x300.png new file mode 100644 index 00000000000..518d7788ff6 Binary files /dev/null and b/scripts/linux/screenshots/Window-Layout-400x300.png differ diff --git a/scripts/linux/screenshots/Window-Layout-900x700.png b/scripts/linux/screenshots/Window-Layout-900x700.png new file mode 100644 index 00000000000..de6bff86530 Binary files /dev/null and b/scripts/linux/screenshots/Window-Layout-900x700.png differ diff --git a/scripts/linux/screenshots/Window-Modal-background.png b/scripts/linux/screenshots/Window-Modal-background.png new file mode 100644 index 00000000000..cb8bfc3d899 Binary files /dev/null and b/scripts/linux/screenshots/Window-Modal-background.png differ diff --git a/scripts/linux/screenshots/Window-Overlay-600x450.png b/scripts/linux/screenshots/Window-Overlay-600x450.png new file mode 100644 index 00000000000..5d9f7c82fe4 Binary files /dev/null and b/scripts/linux/screenshots/Window-Overlay-600x450.png differ diff --git a/scripts/linux/screenshots/Window-Scroll-1000x400.png b/scripts/linux/screenshots/Window-Scroll-1000x400.png new file mode 100644 index 00000000000..a6ad7a0b2b9 Binary files /dev/null and b/scripts/linux/screenshots/Window-Scroll-1000x400.png differ diff --git a/scripts/linux/screenshots/Window-Scroll-400x300.png b/scripts/linux/screenshots/Window-Scroll-400x300.png new file mode 100644 index 00000000000..49c2b58a893 Binary files /dev/null and b/scripts/linux/screenshots/Window-Scroll-400x300.png differ diff --git a/scripts/linux/screenshots/Window-Scroll-900x700.png b/scripts/linux/screenshots/Window-Scroll-900x700.png new file mode 100644 index 00000000000..51412c91d0e Binary files /dev/null and b/scripts/linux/screenshots/Window-Scroll-900x700.png differ diff --git a/scripts/mac-native/screenshots/Window-Editing-1000x400.png b/scripts/mac-native/screenshots/Window-Editing-1000x400.png new file mode 100644 index 00000000000..2d9d127b5d9 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Editing-1000x400.png differ diff --git a/scripts/mac-native/screenshots/Window-Editing-400x300.png b/scripts/mac-native/screenshots/Window-Editing-400x300.png new file mode 100644 index 00000000000..6758800e1e2 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Editing-400x300.png differ diff --git a/scripts/mac-native/screenshots/Window-Editing-900x700.png b/scripts/mac-native/screenshots/Window-Editing-900x700.png new file mode 100644 index 00000000000..d387db6cd8d Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Editing-900x700.png differ diff --git a/scripts/mac-native/screenshots/Window-Graphics-1000x400.png b/scripts/mac-native/screenshots/Window-Graphics-1000x400.png new file mode 100644 index 00000000000..2a332de6043 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Graphics-1000x400.png differ diff --git a/scripts/mac-native/screenshots/Window-Graphics-400x300.png b/scripts/mac-native/screenshots/Window-Graphics-400x300.png new file mode 100644 index 00000000000..9f4bd17dacf Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Graphics-400x300.png differ diff --git a/scripts/mac-native/screenshots/Window-Graphics-900x700.png b/scripts/mac-native/screenshots/Window-Graphics-900x700.png new file mode 100644 index 00000000000..f58c23a8b28 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Graphics-900x700.png differ diff --git a/scripts/mac-native/screenshots/Window-Layout-1000x400.png b/scripts/mac-native/screenshots/Window-Layout-1000x400.png new file mode 100644 index 00000000000..5d192132cd9 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Layout-1000x400.png differ diff --git a/scripts/mac-native/screenshots/Window-Layout-400x300.png b/scripts/mac-native/screenshots/Window-Layout-400x300.png new file mode 100644 index 00000000000..ee5a3b6c73a Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Layout-400x300.png differ diff --git a/scripts/mac-native/screenshots/Window-Layout-900x700.png b/scripts/mac-native/screenshots/Window-Layout-900x700.png new file mode 100644 index 00000000000..be4c166d160 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Layout-900x700.png differ diff --git a/scripts/mac-native/screenshots/Window-Modal-background.png b/scripts/mac-native/screenshots/Window-Modal-background.png new file mode 100644 index 00000000000..3e47d6f7fb8 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Modal-background.png differ diff --git a/scripts/mac-native/screenshots/Window-Overlay-600x450.png b/scripts/mac-native/screenshots/Window-Overlay-600x450.png new file mode 100644 index 00000000000..e2eb25ddd44 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Overlay-600x450.png differ diff --git a/scripts/mac-native/screenshots/Window-Scroll-1000x400.png b/scripts/mac-native/screenshots/Window-Scroll-1000x400.png new file mode 100644 index 00000000000..ca9fa3117a4 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Scroll-1000x400.png differ diff --git a/scripts/mac-native/screenshots/Window-Scroll-400x300.png b/scripts/mac-native/screenshots/Window-Scroll-400x300.png new file mode 100644 index 00000000000..836cad1acd0 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Scroll-400x300.png differ diff --git a/scripts/mac-native/screenshots/Window-Scroll-900x700.png b/scripts/mac-native/screenshots/Window-Scroll-900x700.png new file mode 100644 index 00000000000..8fd3fb80384 Binary files /dev/null and b/scripts/mac-native/screenshots/Window-Scroll-900x700.png differ diff --git a/scripts/windows/screenshots/Window-Editing-1000x400.png b/scripts/windows/screenshots/Window-Editing-1000x400.png new file mode 100644 index 00000000000..1ada770af95 Binary files /dev/null and b/scripts/windows/screenshots/Window-Editing-1000x400.png differ diff --git a/scripts/windows/screenshots/Window-Editing-400x300.png b/scripts/windows/screenshots/Window-Editing-400x300.png new file mode 100644 index 00000000000..16d359c4ffa Binary files /dev/null and b/scripts/windows/screenshots/Window-Editing-400x300.png differ diff --git a/scripts/windows/screenshots/Window-Editing-900x700.png b/scripts/windows/screenshots/Window-Editing-900x700.png new file mode 100644 index 00000000000..65094f353e1 Binary files /dev/null and b/scripts/windows/screenshots/Window-Editing-900x700.png differ diff --git a/scripts/windows/screenshots/Window-Graphics-1000x400.png b/scripts/windows/screenshots/Window-Graphics-1000x400.png new file mode 100644 index 00000000000..713c933d93a Binary files /dev/null and b/scripts/windows/screenshots/Window-Graphics-1000x400.png differ diff --git a/scripts/windows/screenshots/Window-Graphics-400x300.png b/scripts/windows/screenshots/Window-Graphics-400x300.png new file mode 100644 index 00000000000..ed9e1bb7e6e Binary files /dev/null and b/scripts/windows/screenshots/Window-Graphics-400x300.png differ diff --git a/scripts/windows/screenshots/Window-Graphics-900x700.png b/scripts/windows/screenshots/Window-Graphics-900x700.png new file mode 100644 index 00000000000..f4788d31af8 Binary files /dev/null and b/scripts/windows/screenshots/Window-Graphics-900x700.png differ diff --git a/scripts/windows/screenshots/Window-Layout-1000x400.png b/scripts/windows/screenshots/Window-Layout-1000x400.png new file mode 100644 index 00000000000..466d2fdc778 Binary files /dev/null and b/scripts/windows/screenshots/Window-Layout-1000x400.png differ diff --git a/scripts/windows/screenshots/Window-Layout-400x300.png b/scripts/windows/screenshots/Window-Layout-400x300.png new file mode 100644 index 00000000000..c53211d2b55 Binary files /dev/null and b/scripts/windows/screenshots/Window-Layout-400x300.png differ diff --git a/scripts/windows/screenshots/Window-Layout-900x700.png b/scripts/windows/screenshots/Window-Layout-900x700.png new file mode 100644 index 00000000000..b6432d7e8cc Binary files /dev/null and b/scripts/windows/screenshots/Window-Layout-900x700.png differ diff --git a/scripts/windows/screenshots/Window-Modal-background.png b/scripts/windows/screenshots/Window-Modal-background.png new file mode 100644 index 00000000000..d807b153867 Binary files /dev/null and b/scripts/windows/screenshots/Window-Modal-background.png differ diff --git a/scripts/windows/screenshots/Window-Overlay-600x450.png b/scripts/windows/screenshots/Window-Overlay-600x450.png new file mode 100644 index 00000000000..357ede3b56c Binary files /dev/null and b/scripts/windows/screenshots/Window-Overlay-600x450.png differ diff --git a/scripts/windows/screenshots/Window-Scroll-1000x400.png b/scripts/windows/screenshots/Window-Scroll-1000x400.png new file mode 100644 index 00000000000..0c1643d2b25 Binary files /dev/null and b/scripts/windows/screenshots/Window-Scroll-1000x400.png differ diff --git a/scripts/windows/screenshots/Window-Scroll-400x300.png b/scripts/windows/screenshots/Window-Scroll-400x300.png new file mode 100644 index 00000000000..429c368753a Binary files /dev/null and b/scripts/windows/screenshots/Window-Scroll-400x300.png differ diff --git a/scripts/windows/screenshots/Window-Scroll-900x700.png b/scripts/windows/screenshots/Window-Scroll-900x700.png new file mode 100644 index 00000000000..65009de89c7 Binary files /dev/null and b/scripts/windows/screenshots/Window-Scroll-900x700.png differ