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 1db52ef4163..0d0437edef2 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; @@ -166,14 +167,86 @@ 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. + private static final int TRACKED_WINDOW_DRAGS = 8; + + /// Drag activation state per window, kept apart from the main surface's because + /// nothing resets that one for a window gesture. Slot 0 of each array is used by + /// whichever window claims it; windowDragIds holds the owner, 0 meaning free. + private final int[] windowDragIds = new int[TRACKED_WINDOW_DRAGS]; + private final boolean[] windowDragStarted = new boolean[TRACKED_WINDOW_DRAGS]; + private final int[] windowDragActivationCounter = new int[TRACKED_WINDOW_DRAGS]; + private final int[] windowDragActivationX = new int[TRACKED_WINDOW_DRAGS]; + private final int[] windowDragActivationY = new int[TRACKED_WINDOW_DRAGS]; + + /// The slot tracking this window's drag gesture, claiming a free one when asked. + /// + /// #### Parameters + /// + /// - `windowId`: the window to look up + /// + /// - `claim`: whether to take a free slot when the window has none + /// + /// #### Returns + /// + /// the slot, or -1 when the window has none and none was claimed + private int windowDragSlot(int windowId, boolean claim) { + int free = -1; + for (int iter = 0; iter < TRACKED_WINDOW_DRAGS; iter++) { + if (windowDragIds[iter] == windowId) { + return iter; + } + if (free < 0 && windowDragIds[iter] == 0) { + free = iter; + } + } + if (!claim || free < 0) { + return -1; + } + windowDragIds[free] = windowId; + windowDragStarted[free] = false; + windowDragActivationCounter[free] = 0; + return free; + } + + /// Releases a window's drag slot. + private void releaseWindowDragSlot(int windowId) { + int slot = windowDragSlot(windowId, false); + if (slot >= 0) { + windowDragIds[slot] = 0; + windowDragStarted[slot] = false; + windowDragActivationCounter[slot] = 0; + } + } + + /// Drops any drag-activation state this implementation holds for a window. + /// + /// The slot is claimed on press and released on release, but a window can be + /// disposed or lose the native pointer while a press is still down, and then no + /// release ever arrives. Window ids are never reused, so the slot would be held by + /// a dead id for the life of the process -- and after eight of those there are no + /// slots left, `windowDragSlot` starts answering -1, and the passthrough it falls + /// into hands every window a pixel of jitter as a real drag. 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 -- has no slot + public void releaseWindowInputState(int windowId) { + if (windowId > 0) { + releaseWindowDragSlot(windowId); + } + } + 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(null); + private ArrayList windowSurfaces; private String packageName; private Component editingText; private String appArg; @@ -579,9 +652,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 +824,136 @@ public void saveTextEditingState() { /// /// false by default public boolean hasPendingPaints() { - return paintQueueFill != 0; + if (mainSurface.paintQueueFill != 0) { + return true; + } + if (windowSurfaces != null) { + int len = windowSurfaces.size(); + for (int iter = 0; iter < len; iter++) { + if (windowSurfaces.get(iter).paintQueueFill != 0) { + return true; + } + } + } + return false; + } + + /// Everything needed to paint one surface: its own dirty queue and its own + /// `Graphics`. The application's main surface is one of these, and each native + /// window adds another. + /// + /// Before desktop windows existed this state was four fields on + /// `CodenameOneImplementation`; grouping it is what lets the same paint routine + /// serve every surface instead of being copied per surface. + public static final class PaintSurface { + + /// 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; + + PaintSurface(Object nativeWindow) { + this.nativeWindow = nativeWindow; + } + + /// Returns the native window this surface draws into. + /// + /// #### Returns + /// + /// the native window peer, or null for the main surface + Object getNativeWindow() { + return nativeWindow; + } + } + + /// Creates the paint surface backing a native window. + /// + /// #### Parameters + /// + /// - `nativeWindow`: the window peer the surface draws into + /// + /// #### Returns + /// + /// the opaque surface handle + public final Object createPaintSurface(Object nativeWindow) { + PaintSurface surface = new PaintSurface(nativeWindow); + synchronized (displayLock) { + if (windowSurfaces == null) { + windowSurfaces = new ArrayList(); + } + windowSurfaces.add(surface); + } + return surface; + } + + /// Drops everything queued on a window's paint surface, keeping the surface + /// itself. A hidden window is not painted, so work queued on it would never + /// drain -- and an undrained queue keeps `#hasPendingPaints()` true, which keeps + /// the event dispatch thread awake spinning on it. + /// + /// #### Parameters + /// + /// - `surface`: the surface handle to clear + public final void clearPaintSurface(Object surface) { + PaintSurface s = asPaintSurface(surface); + if (s == null) { + return; + } + synchronized (displayLock) { + s.paintQueueFill = 0; + java.util.Arrays.fill(s.paintQueue, null); + java.util.Arrays.fill(s.paintQueueTemp, null); + } + } + + /// Releases a window's paint surface, dropping anything still queued on it so a + /// disposed window cannot pin its component tree. + /// + /// #### Parameters + /// + /// - `surface`: the surface handle to release + public final void disposePaintSurface(Object surface) { + if (surface == null) { + return; + } + PaintSurface s = asPaintSurface(surface); + if (s == null) { + return; + } + synchronized (displayLock) { + s.paintQueueFill = 0; + java.util.Arrays.fill(s.paintQueue, null); + java.util.Arrays.fill(s.paintQueueTemp, null); + s.graphics = null; + if (windowSurfaces != null) { + windowSurfaces.remove(s); + } + } + } + + /// Installs the `Graphics` a window's 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 + /// + /// - `surface`: the surface handle + /// + /// - `g`: the graphics to install + public final void setPaintSurfaceGraphics(Object surface, Graphics g) { + PaintSurface s = asPaintSurface(surface); + if (s != null) { + s.graphics = g; + } + } + + private PaintSurface asPaintSurface(Object surface) { + if (surface instanceof PaintSurface) { + return (PaintSurface) surface; + } + return null; } /// Return the number of alpha levels supported by the implementation. @@ -833,38 +1038,75 @@ 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() { + paintDirtySurface(mainSurface, getDisplayWidth(), getDisplayHeight()); + } + + /// Invoked by the EDT to paint the dirty regions of one native window. + /// + /// #### Parameters + /// + /// - `surface`: the window's paint surface, as returned by + /// `#createPaintSurface(java.lang.Object)` + public void paintDirtyWindow(Object surface, int width, int height) { + PaintSurface s = asPaintSurface(surface); + if (s == null || s.graphics == null) { + return; + } + if (width <= 0 || height <= 0) { + return; + } + // The clip universe is the *framework's* size for the window, which is also + // what the port sizes its raster from. Taking it from the native drawable + // instead let the two disagree: a window resized larger than the platform had + // yet reported got a full sized raster and a clip still the size of the old + // one, so everything outside the old bounds was never painted -- a capture + // showed the content in the corner of an otherwise black frame. + paintDirtySurface(s, width, height); + } + + /// Paints one surface's dirty regions. The main surface and every window share + /// this single routine, so the clip and paintable-bounds handling that issue + /// #5273 turns on cannot drift between them. + /// + /// #### Parameters + /// + /// - `s`: the surface to paint + /// + /// - `dwidth`: the surface width, used as the clip universe + /// + /// - `dheight`: the surface height, used as the clip universe + private void paintDirtySurface(PaintSurface s, int dwidth, int dheight) { int size = 0; synchronized (displayLock) { - size = paintQueueFill; - Animation[] array = paintQueue; - paintQueue = paintQueueTemp; - paintQueueTemp = array; - paintQueueFill = 0; + size = s.paintQueueFill; + Animation[] array = s.paintQueue; + s.paintQueue = s.paintQueueTemp; + s.paintQueueTemp = array; + s.paintQueueFill = 0; } if (size > 0) { - Graphics wrapper = getCodenameOneGraphics(); - int dwidth = getDisplayWidth(); - int dheight = getDisplayHeight(); + Graphics wrapper = s.graphics; int topX = dwidth; int topY = dheight; int bottomX = 0; int bottomY = 0; for (int iter = 0; iter < size; iter++) { - Animation ani = paintQueueTemp[iter]; + Animation ani = s.paintQueueTemp[iter]; // might happen due to paint queue removal if (ani == null) { continue; } - paintQueueTemp[iter] = null; + s.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); + setSurfaceDirtyRegionClip(s, 0, 0, dwidth, dheight); wrapper.setClip(0, 0, dwidth, dheight); if (ani instanceof Component) { Component cmp = (Component) ani; @@ -883,7 +1125,7 @@ public void paintDirty() { // (#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(), + setSurfaceDirtyRegionClip(s, paintDirtyTmpRect.getX(), paintDirtyTmpRect.getY(), paintDirtyTmpRect.getWidth(), paintDirtyTmpRect.getHeight()); cmp.paintComponent(wrapper); // Recompute the paintable bounds AFTER paint for the flush @@ -908,9 +1150,31 @@ public void paintDirty() { } } - paintOverlay(wrapper); - //Log.p("Flushing graphics : "+topX+","+topY+","+bottomX+","+bottomY); - flushGraphics(topX, topY, bottomX - topX, bottomY - topY); + if (s.nativeWindow == null) { + paintOverlay(wrapper); + //Log.p("Flushing graphics : "+topX+","+topY+","+bottomX+","+bottomY); + flushGraphics(topX, topY, bottomX - topX, bottomY - topY); + } else { + WindowManager wm = getWindowManager(); + if (wm != null) { + wm.flushGraphics(s.nativeWindow, topX, topY, bottomX - topX, bottomY - topY); + } + } + } + } + + /// Routes the flush-region hint to whichever surface is being painted. 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 setSurfaceDirtyRegionClip(PaintSurface s, int x, int y, int w, int h) { + if (s.nativeWindow == null) { + setPaintDirtyRegionClip(x, y, w, h); + return; + } + WindowManager wm = getWindowManager(); + if (wm != null) { + wm.setPaintDirtyRegionClip(s.nativeWindow, x, y, w, h); } } @@ -966,7 +1230,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.graphics; } /// Installs the Codename One graphics object into the implementation @@ -975,7 +1239,7 @@ protected Graphics getCodenameOneGraphics() { /// /// - `g`: graphics object for use by the implementation public void setCodenameOneGraphics(Graphics g) { - codenameOneGraphics = g; + mainSurface.graphics = g; } /// A flag that can be overridden by a platform to indicate that native @@ -1007,14 +1271,28 @@ 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 (cancelRepaint(mainSurface, cmp)) { + return; + } + if (windowSurfaces != null) { + int len = windowSurfaces.size(); + for (int iter = 0; iter < len; iter++) { + if (cancelRepaint(windowSurfaces.get(iter), cmp)) { + return; + } } } + } + } + private boolean cancelRepaint(PaintSurface s, Animation cmp) { + for (int iter = 0; iter < s.paintQueueFill; iter++) { + if (s.paintQueue[iter] == cmp) { //NOPMD CompareObjectsWithEquals + s.paintQueue[iter] = null; + return true; + } } + return false; } /// Invoked to add an element to the paintQueue @@ -1023,9 +1301,27 @@ public void cancelRepaint(Animation cmp) { /// /// - `cmp`: component or animation to push into the paint queue public void repaint(Animation cmp) { + repaintSurface(mainSurface, cmp); + } + + /// Queues a repaint against one native window's surface rather than the main one. + /// + /// #### Parameters + /// + /// - `surface`: the window's paint surface + /// + /// - `cmp`: the animation or component to repaint + public void repaintWindow(Object surface, Animation cmp) { + PaintSurface s = asPaintSurface(surface); + if (s != null) { + repaintSurface(s, cmp); + } + } + + private void repaintSurface(PaintSurface s, Animation cmp) { synchronized (displayLock) { - for (int iter = 0; iter < paintQueueFill; iter++) { - Animation ani = paintQueue[iter]; + for (int iter = 0; iter < s.paintQueueFill; iter++) { + Animation ani = s.paintQueue[iter]; if (ani == cmp) { //NOPMD CompareObjectsWithEquals return; } @@ -1041,13 +1337,13 @@ public void repaint(Animation cmp) { } } // overcrowding the queue don't try to grow the array! - if (paintQueueFill >= paintQueue.length) { + if (s.paintQueueFill >= s.paintQueue.length) { System.out.println("Warning paint queue size exceeded, please watch the amount of repaint calls"); return; } - paintQueue[paintQueueFill] = cmp; - paintQueueFill++; + s.paintQueue[s.paintQueueFill] = cmp; + s.paintQueueFill++; displayLock.notifyAll(); } } @@ -2901,6 +3197,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 +3344,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 +3403,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 +3485,168 @@ 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. + releaseWindowDragSlot(windowId); + windowDragSlot(windowId, true); + } + 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) { + releaseWindowDragSlot(windowId); + } + 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. + int slot = windowDragSlot(windowId, true); + if (slot < 0) { + // More windows dragging at once than there are slots. Filtering is a + // refinement, so let the gesture through rather than swallow it. + Desktop.getInstance().windowPointerDragged(windowId, x, y); + return; + } + boolean started = false; + if (!windowDragStarted[slot]) { + try { + started = hasWindowDragStarted(windowId, slot, 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 (windowDragStarted[slot] || started) { + windowDragStarted[slot] = 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; + } + Display.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 Display.getInstance().getWindowPeerForComponent(cmp); + } + /// Subclasses should invoke this method, it delegates the event to the display and into /// Codename One. /// @@ -3140,6 +3739,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 +3826,74 @@ 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 int slot, + 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 (windowDragActivationCounter[slot] == 0) { + windowDragActivationX[slot] = x; + windowDragActivationY[slot] = y; + windowDragActivationCounter[slot]++; + return false; + } + windowDragActivationCounter[slot]++; + if (dragPassedThreshold(Desktop.getInstance().windowDragRegionStatus(windowId, x, y), + surfaceWidth, surfaceHeight, + windowDragActivationX[slot], windowDragActivationY[slot], + windowDragActivationCounter[slot], x, y)) { + windowDragActivationCounter[slot] = 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 +3942,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. @@ -7500,6 +8168,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 @@ -8895,6 +9578,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; } @@ -8904,24 +9616,59 @@ 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 (d.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); } @@ -8930,7 +9677,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); } @@ -8939,7 +9686,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); } @@ -8948,7 +9695,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); } @@ -8961,7 +9710,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/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..6ab85e4a904 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 instanceof Form ? ((Form) f).getTitleArea().getHeight() : 0; + } + } diff --git a/CodenameOne/src/com/codename1/ui/Button.java b/CodenameOne/src/com/codename1/ui/Button.java index 2481962fb72..ef53b1bb8a6 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,16 @@ 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 instanceof Form) { + ((Form) top).actionCommandImplNoRecurseComponent(cmd, ev); + } else if (top instanceof Window) { + ((Window) top).dispatchCommandNoRecurse(cmd, ev); } } } else { @@ -837,10 +842,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 +863,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 +1069,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 +1118,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..3e3d1d0a061 100644 --- a/CodenameOne/src/com/codename1/ui/Container.java +++ b/CodenameOne/src/com/codename1/ui/Container.java @@ -24,6 +24,7 @@ package com.codename1.ui; import com.codename1.impl.CodenameOneImplementation; +import com.codename1.ui.animations.Animation; import com.codename1.ui.animations.AnimationTime; import com.codename1.ui.animations.ComponentAnimation; import com.codename1.ui.animations.Motion; @@ -1146,7 +1147,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 +1220,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 +1254,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 +1278,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 +1415,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 +1488,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 +1589,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 +1622,227 @@ 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; + } + + /// 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 +2625,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 instanceof Window) { + // 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 +2880,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 +2938,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 +2961,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 +2970,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 +2979,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 +2988,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 +3498,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 +4009,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 +4342,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 +4622,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 +4634,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 +4787,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..e0beaa07b86 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/Desktop.java @@ -0,0 +1,1196 @@ +/* + * 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) { + if (topLevel instanceof Window) { + Object peer = ((Window) topLevel).getNativePeer(); + if (peer != null) { + return readMonitor(wm, wm.getMonitorForWindow(peer), wm.getPrimaryMonitor()); + } + } else if (topLevel != null) { + // 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(); + } + 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 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(); + Display.impl.setPaintSurfaceGraphics(w.getPaintSurface(), 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(); + Display.impl.paintDirtyWindow(w.getPaintSurface(), 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 071adbc8abb..7fc3773d764 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 windowMouseWheelEvent(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(); - } + windowMagnifyGesture(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; + windowRotationGesture(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,51 @@ 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]; + /// 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. + /// + /// #### 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(); + if (top instanceof Window) { + return ((Window) top).getNativePeer(); + } + return null; + } + + 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 +2732,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; @@ -2509,28 +2783,409 @@ private void addSizeChangeEvent(int type, int w, int h) { } } - /// Notifies Codename One of display size changes, this method is invoked by the implementation - /// class and is for internal use - /// - /// #### Parameters + /// Notifies Codename One of display size changes, this method is invoked by the implementation + /// class and is for internal use + /// + /// #### Parameters + /// + /// - `w`: the width of the drawing surface + /// + /// - `h`: the height of the drawing surface + public void sizeChanged(int w, int h) { + Form current = impl.getCurrentForm(); + 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; + } + } + + 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. /// - /// - `w`: the width of the drawing surface + /// #### Returns /// - /// - `h`: the height of the drawing surface - public void sizeChanged(int w, int h) { - Form current = impl.getCurrentForm(); - if (current == null) { - return; + /// 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 (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 (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 +3202,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 +3222,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 +3279,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 instanceof Window && !((Window) f).isWindowShowing(); + 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 +3636,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 +3849,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 +4302,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 +4489,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 @@ -3619,7 +4507,18 @@ 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(); + if (top instanceof Window) { + return !pureTouch || lastInteractionWasKeypad + || ((Window) top).showsSelectionFor(c) + || c.shouldRenderComponentSelection(); + } + return !pureTouch || lastInteractionWasKeypad + || (pointerPressedAndNotReleasedOrDragged && c.contains(pointerX, pointerY)) + || c.shouldRenderComponentSelection(); } /// A pure touch device has no focus showing when the user is using the touch @@ -7529,4 +8428,202 @@ 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. + 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 + public boolean windowMouseWheelEvent(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 + public void windowMagnifyGesture(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 + public void windowRotationGesture(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 + public void windowKeyReleased(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 + public void windowPointerHoverPressed(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 + public void windowPointerHoverReleased(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..c28b42a865c 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,15 +1504,30 @@ 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); } + /// {@inheritDoc} + @Override + int getInitialPressX() { + return initialPressX; + } + + /// {@inheritDoc} + @Override + int getInitialPressY() { + return initialPressY; + } + + /// 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 @@ -1645,10 +1689,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 +1707,7 @@ public Container getContentPane() { /// #### Returns /// /// the LayeredPane + @Override public Container getLayeredPane() { return getLayeredPane(null, false); } @@ -1672,56 +1724,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 +1741,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 +1760,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 +1868,7 @@ private Container getLayeredPaneImpl() { return layeredPane; } + @Override Container getActualPane() { if (layeredPane != null) { return layeredPane.getParent(); @@ -1999,6 +1959,7 @@ public boolean isEditing() { /// #### Returns /// /// returns the form title + @Override public String getTitle() { if (toolbar != null) { Component cmp = toolbar.getTitleComponent(); @@ -2015,6 +1976,7 @@ public String getTitle() { /// #### Parameters /// /// - `title`: the form title + @Override public void setTitle(String title) { if (toolbar != null) { toolbar.setTitle(title); @@ -2218,6 +2180,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 +2204,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 +2225,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 +2244,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 +2434,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 +2448,7 @@ public void addCommandListener(ActionListener l) { /// #### Parameters /// /// - `l`: the command action listener + @Override public void removeCommandListener(ActionListener l) { commandListener.removeListener(l); } @@ -2502,6 +2470,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 +2573,7 @@ void initFocused() { } /// Displays the current form on the screen + @Override public void show() { Display.impl.onShow(this); show(false); @@ -2967,6 +2937,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 +2967,7 @@ void restoreMenu() { menuBar.installMenuBar(); } + @Override void setFocusedInternal(Component focused) { this.focused = focused; } @@ -3038,13 +3021,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 +3043,7 @@ private boolean changeFocusState(Component cmp, boolean gained) { /// #### Returns /// /// the current focus component for this form + @Override public Component getFocused() { return focused; } @@ -3067,6 +3053,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 +3124,7 @@ public void longPointerPress(int x, int y) { /// #### Returns /// /// false by default + @Override protected boolean shouldSendPointerReleaseToOtherForm() { return false; } @@ -3193,10 +3181,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 +3453,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 +3623,7 @@ private boolean isCurrentlyScrolling(Component cmp) { } + @Override public void addComponentAwaitingRelease(C c) { if (componentsAwaitingRelease == null) { componentsAwaitingRelease = new ArrayList(); @@ -3624,12 +3631,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 +3946,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 +4245,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 +4259,7 @@ public int getCommandCount() { /// #### Returns /// /// the command at the given index + @Override public Command getCommand(int index) { return menuBar.getCommand(index); } @@ -4267,6 +4279,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,6 +4290,7 @@ public final void addCommand(Command cmd) { /// #### Parameters /// /// - `cmd`: the Form command to be removed + @Override public void removeCommand(Command cmd) { menuBar.removeCommand(cmd); } @@ -4454,6 +4468,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 +4481,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 +4494,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 +4507,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 +4523,7 @@ Component findNextFocusLeft() { /// #### Returns /// /// true if focus should cycle + @Override public boolean isCyclicFocus() { return cyclicFocus; } @@ -4514,6 +4533,7 @@ public boolean isCyclicFocus() { /// #### Parameters /// /// - `cyclicFocus`: marks whether focus should cycle + @Override public void setCyclicFocus(boolean cyclicFocus) { this.cyclicFocus = cyclicFocus; } @@ -4650,6 +4670,7 @@ public void setMenuCellRenderer(ListCellRenderer menuCellRenderer) { } /// Clear menu commands from the menu bar + @Override public void removeAllCommands() { menuBar.removeAllCommands(); } @@ -4659,6 +4680,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..e4e6a99a324 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,16 @@ 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 instanceof Form) { + ((Form) top).actionCommandImpl((Command) i); + } else if (top instanceof Window) { + ((Window) top).dispatchCommandNoRecurse((Command) i, a); } } } @@ -2086,9 +2099,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..47f279cfbaa --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/PointerDragHistory.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.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); + } + + /// How many positions are recorded. Used to tell a real gesture from one that + /// never moved. + int recordedLength() { + return 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..5131b588b96 100644 --- a/CodenameOne/src/com/codename1/ui/SearchBar.java +++ b/CodenameOne/src/com/codename1/ui/SearchBar.java @@ -67,12 +67,16 @@ public void dataChanged(int type, int index) { } }); setUIIDFinal("ToolbarSearch"); - if (parent.getComponentForm() == Display.INSTANCE.getCurrent()) { //NOPMD CompareObjectsWithEquals + // A search bar swapped into a Window's toolbar is already on screen, so it + // starts editing straight away. setEditOnShow is a Form-only deferral for a + // toolbar being prepared before its form is shown, and there is no Window + // equivalent because a Window's search bar is only ever installed live. + TopLevelContainer top = parent.getTopLevelContainer(); + if (top == Display.INSTANCE.getCurrent() //NOPMD CompareObjectsWithEquals + || (top instanceof Window && ((Window) top).isWindowShowing())) { search.startEditingAsync(); - } else { - if (parent.getComponentForm() != null) { - parent.getComponentForm().setEditOnShow(search); - } + } else if (top instanceof Form) { + ((Form) top).setEditOnShow(search); } } @@ -87,14 +91,27 @@ public void actionPerformed(ActionEvent evt) { @Override public void run() { onSearch(""); - final Form f = (Form) SearchBar.this.getParent(); + // Through the top level rather than a cast of getParent() to + // Form: inside a Window the toolbar hangs off the title area, + // so that cast named the wrong type. ParparVM does not check + // CHECKCAST, so on Mac Catalyst that was a native crash rather + // than a ClassCastException anything could catch. + final TopLevelContainer f = SearchBar.this.getTopLevelContainer(); + if (f == null) { + return; + } f.getAnimationManager().flushAnimation(new Runnable() { @Override public void run() { - f.removeComponentFromForm(SearchBar.this); - f.setToolbar(parent); + if (!(f instanceof Form)) { + // A search bar lives in a Toolbar, and only a Form + // has one. + return; + } + ((Form) f).removeComponentFromForm(SearchBar.this); + ((Form) f).setToolbar(parent); parent.setHidden(false); - f.animateLayout(100); + f.asContainer().animateLayout(100); } }); } 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..704898531d2 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 { - if (f != null) { - f.setClearCommand(originalClearCommand); + // 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 instanceof Form) { + ((Form) f).setClearCommand(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..509275bb3b1 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/TopLevelSupport.java @@ -0,0 +1,330 @@ +/* + * 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; + +/// 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. 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) { + TopLevelContainer top = c == null ? null : c.getTopLevelContainer(); + if (top instanceof Form) { + ((Form) top).registerAnimatedInternal(a); + } else if (top instanceof Window) { + ((Window) top).registerAnimatedInternal(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) { + TopLevelContainer top = c == null ? null : c.getTopLevelContainer(); + if (top instanceof Form) { + ((Form) top).deregisterAnimatedInternal(a); + } else if (top instanceof Window) { + ((Window) top).deregisterAnimatedInternal(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 instanceof Form) { + ((Form) top).registerAnimatedInternal(a); + } else if (top instanceof Window) { + ((Window) top).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 instanceof Form) { + ((Form) top).deregisterAnimatedInternal(a); + } else if (top instanceof Window) { + ((Window) top).deregisterAnimatedInternal(a); + } + } + + /// The content area a top level lays its children out in. + /// + /// `getActualPane` is package private on both `Form` and `Window`, so like the + /// internal animation registration it cannot sit on the public interface. + /// + /// #### Parameters + /// + /// - `top`: the top level to ask, may be null + /// + /// #### Returns + /// + /// the actual pane, or null when there is no top level + static Container actualPaneOf(TopLevelContainer top) { + if (top instanceof Form) { + return ((Form) top).getActualPane(); + } + if (top instanceof Window) { + return ((Window) top).getActualPane(); + } + return null; + } + + /// 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. + /// + /// #### 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 instanceof Form) { + ((Form) top).addComponentToForm(constraints, cmp); + } else if (top instanceof Window) { + ((Window) top).addComponentToWindow(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 instanceof Form) { + ((Form) top).removeComponentFromForm(cmp); + } else if (top instanceof Window) { + ((Window) top).removeComponentFromWindow(cmp); + } + } +} diff --git a/CodenameOne/src/com/codename1/ui/Window.java b/CodenameOne/src/com/codename1/ui/Window.java new file mode 100644 index 00000000000..67162943d90 --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/Window.java @@ -0,0 +1,3577 @@ +/* + * 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 Object paintSurface; + 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; + } + + Object getPaintSurface() { + return paintSurface; + } + + 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; + } + + Container getWindowLayeredPaneIfExists() { + return windowLayeredPane; + } + + /// {@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(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; + if (nativePeer != null) { + 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 + final void addComponentToWindow(Object constraints, Component cmp) { + super.addComponent(constraints, cmp); + } + + /// Removes a component previously added with + /// `#addComponentToWindow(Object, Component)`. + /// + /// #### Parameters + /// + /// - `cmp`: the component to remove + void removeComponentFromWindow(Component cmp) { + super.removeComponent(cmp); + } + + @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) { + Display.impl.repaintWindow(paintSurface, 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; + 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().setResizable(nativePeer, resizable); + } else { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if (nativePeer != null) { + manager().setResizable(nativePeer, resizable); + } + } + }); + } + } + } + + /// 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(boolean decorated) { + this.decorated = decorated; + if (nativePeer != null) { + 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(boolean utility) { + this.utilityWindow = utility; + if (nativePeer != null) { + 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(Image icon) { + this.windowIcon = icon; + if (nativePeer != null) { + 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(Dimension d) { + minimumWindowSize = d; + if (nativePeer != null) { + 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); + Display.impl.clearPaintSurface(paintSurface); + 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. + Display.impl.clearPaintSurface(paintSurface); + 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; + } + 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 = ownerWindow instanceof Window + ? ((Window) ownerWindow).nativePeer : null; + // 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(); + nativeVisible = true; + // 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()) { + show(); + } else { + Display.getInstance().callSeriallyAndWait(new Runnable() { + @Override + public void run() { + show(); + } + }); + } + 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 { + releaseModal(); + } + } + + /// 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 instanceof Window ? ((Window) ownerWindow).nativePeer : null; + } + + /// 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(int type) { + // 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); + Display.impl.clearPaintSurface(paintSurface); + 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 + Display.impl.disposePaintSurface(paintSurface); + 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) { + Display.impl.repaintWindow(paintSurface, 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. + Display.impl.clearPaintSurface(paintSurface); + // 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. + 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. + Display.impl.clearPaintSurface(paintSurface); + 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; + } + } + } + + /// 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 d0b590befb4..cc9b12f19da 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. + Display.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 = Display.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 = Display.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; @@ -19333,9 +19847,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; } @@ -19505,7 +20019,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(); @@ -19665,12 +20179,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 = Display.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 @@ -19689,9 +20292,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 @@ -19761,8 +20365,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)); } @@ -19805,6 +20409,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() { @@ -19826,9 +20436,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) { @@ -19850,21 +20463,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) ); @@ -19892,6 +20505,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(); @@ -20921,6 +21541,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..fef9385ca77 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEWindowManager.java @@ -0,0 +1,1184 @@ +/* + * 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; + 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); + } + }); + 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 || icon == null) { + return; + } + final Object nativeImage = icon.getImage(); + if (!(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); + } + } + + private static void runOnAwtAndWait(Runnable r) { + if (SwingUtilities.isEventDispatchThread()) { + r.run(); + return; + } + try { + SwingUtilities.invokeAndWait(r); + } catch (Exception err) { + com.codename1.io.Log.e(err); + } + } +} 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..b23ca52172f --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c @@ -0,0 +1,1511 @@ +/* + * 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) { + double rad = pe->angle_delta * G_PI / 180.0; + 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..afdb664c05d 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; @@ -384,6 +614,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 +645,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 +864,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 +992,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 +1012,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 9cbf602cfee..19846f955a9 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(); @@ -559,6 +573,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(); @@ -733,47 +760,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); + Display.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); + Display.getInstance().windowRotationGesture(windowId, x, y, key / GESTURE_FIXED); break; case EVENT_CLOSE: Display.getInstance().exitApplication(); @@ -1981,8 +2041,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. @@ -1991,9 +2055,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..567e48ce69c --- /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.Display.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 8906a10f5e9..5fec860b452 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; } @@ -732,47 +759,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); + Display.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); + Display.getInstance().windowRotationGesture(windowId, x, y, key / GESTURE_FIXED); break; case EVENT_CLOSE: Display.getInstance().exitApplication(); @@ -1995,8 +2050,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. @@ -2005,9 +2064,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..188c62e7c33 --- /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.Display.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 1207524ac5b..76140cb4228 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -2079,6 +2079,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]); @@ -2339,6 +2403,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); @@ -16011,6 +16308,26 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_intentsIndexingSupported___R_boole #import "CN1WatchConnectivity.h" +// 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 4a14451df8f..5a5c293f71b 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; @@ -785,8 +786,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; } @@ -843,14 +847,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); @@ -998,6 +1011,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; @@ -1197,11 +1238,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) { @@ -1223,7 +1274,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 @@ -1240,7 +1295,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; @@ -1299,9 +1354,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 @@ -1398,9 +1461,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) { @@ -1824,6 +1887,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: + Display.getInstance().windowPointerHoverPressed(windowId, xs, ys); + break; + case 2: + Display.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() { + Display.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() { + Display.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 { + Display.getInstance().windowKeyReleased(windowId, keyCode); + } + } + public static void pointerPressedCallback(int x, int y) { if(dropEvents) { return; @@ -1873,20 +2158,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) { @@ -9327,6 +9625,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(); } } @@ -9334,6 +9635,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()); } } @@ -9350,10 +9671,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 b79db860fe6..1d78b7c20e8 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..950cbd4f096 --- /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.Display.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..7f32d6b365a --- /dev/null +++ b/docs/developer-guide/Desktop-Windows.asciidoc @@ -0,0 +1,426 @@ +== Desktop Windows + +Codename One applications have always had exactly one window. On a phone that's the +only thing that makes sense: the operating system owns the screen and the application +fills it. On a desktop it's a real limitation - an inspector panel, a preferences +window, a second document, a tool palette and a detached console are all ordinary +desktop expectations that a single-window framework can't express. + +`com.codename1.ui.Window` adds 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 stays a +`Form` and behaves exactly as it always has. + +[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, the toolbar and commands, animation +registration, focus, editing state, the theme manager and the show/size listeners. It +does *not* carry `Form`'s mobile navigation surface - form transitions, the back command, +`previousForm` or the tint used to dim a form behind a dialog - because none of those +mean anything for a desktop window. 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()` keeps its original meaning: it names the enclosing `Form`, and +inside a window the honest answer is none. Codename One's own components were migrated +to `getTopLevelContainer()`, so the framework works inside a window - apart from the +handful listed under <>, which depend on something that +isn't window-aware yet rather than on the call itself. Third-party components that +still call `getComponentForm()` won't work, until they adopt the new call. + +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 + the dialog it uses no longer does. +* `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. + +Where the platform can read a window's own pixels back, that's what you get: the +JavaSE simulator, Mac Catalyst and native Linux all return the window's real raster. +On native Windows a desktop window renders into a Direct2D `HWND` target, which has no +readable bitmap behind it, so `capture()` re-renders the component tree at the window's +current size instead. The image is the content the window is showing, which is what +almost every caller wants -- but it's a re-render rather than a readback, so it can't +reveal a disagreement between the window's raster and its component hierarchy. The main +window's own capture has the same limitation on that platform for the same reason. + +[[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 2a45b01e83a..c4c9cb6a06e 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -179,6 +179,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 4f0144ce536..0c8c5194cf8 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,3 +666,6 @@ 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 diff --git a/docs/website/data/port_status.json b/docs/website/data/port_status.json index 5cf03690b7c..23bc794f811 100644 --- a/docs/website/data/port_status.json +++ b/docs/website/data/port_status.json @@ -765,6 +765,21 @@ "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" + ] } ], "screenshot_mappings": [ @@ -1311,6 +1326,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/data/port_status_reports/android.json b/docs/website/data/port_status_reports/android.json index b73e72f53ff..57472b4f4e2 100644 --- a/docs/website/data/port_status_reports/android.json +++ b/docs/website/data/port_status_reports/android.json @@ -56,10 +56,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 179, "fail": 0, - "skip": 1, - "not-run": 0 + "not-run": 0, + "pass": 180, + "skip": 7 }, "tests": { "ARApiTest": { @@ -513,6 +513,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -784,6 +788,48 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowModalTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowScrollTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" } } } diff --git a/docs/website/data/port_status_reports/ios-gl.json b/docs/website/data/port_status_reports/ios-gl.json index 0782f7142e2..30c9acd6fac 100644 --- a/docs/website/data/port_status_reports/ios-gl.json +++ b/docs/website/data/port_status_reports/ios-gl.json @@ -48,10 +48,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 176, "fail": 0, - "skip": 4, - "not-run": 0 + "not-run": 0, + "pass": 177, + "skip": 10 }, "tests": { "ARApiTest": { @@ -505,6 +505,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -785,6 +789,48 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowModalTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowScrollTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" } } } diff --git a/docs/website/data/port_status_reports/ios-metal.json b/docs/website/data/port_status_reports/ios-metal.json index 90ac2753d2f..f765b5df702 100644 --- a/docs/website/data/port_status_reports/ios-metal.json +++ b/docs/website/data/port_status_reports/ios-metal.json @@ -48,10 +48,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 176, "fail": 0, - "skip": 4, - "not-run": 0 + "not-run": 0, + "pass": 177, + "skip": 10 }, "tests": { "ARApiTest": { @@ -505,6 +505,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -785,6 +789,48 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowModalTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowScrollTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" } } } diff --git a/docs/website/data/port_status_reports/javascript.json b/docs/website/data/port_status_reports/javascript.json index cac9ede04bb..e3903c3f718 100644 --- a/docs/website/data/port_status_reports/javascript.json +++ b/docs/website/data/port_status_reports/javascript.json @@ -56,10 +56,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 178, "fail": 0, - "skip": 2, - "not-run": 0 + "not-run": 0, + "pass": 179, + "skip": 8 }, "tests": { "ARApiTest": { @@ -513,6 +513,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -787,6 +791,48 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowModalTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowScrollTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" } } } diff --git a/docs/website/data/port_status_reports/linux-arm64.json b/docs/website/data/port_status_reports/linux-arm64.json index ec8f3a901c7..3a9562d22ac 100644 --- a/docs/website/data/port_status_reports/linux-arm64.json +++ b/docs/website/data/port_status_reports/linux-arm64.json @@ -56,10 +56,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 177, "fail": 0, - "skip": 3, - "not-run": 0 + "not-run": 0, + "pass": 184, + "skip": 3 }, "tests": { "ARApiTest": { @@ -516,6 +516,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -790,6 +794,30 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowModalTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowScrollTest": { + "feature": "multi-window", + "status": "pass" } } } diff --git a/docs/website/data/port_status_reports/linux-x64.json b/docs/website/data/port_status_reports/linux-x64.json index f2c2df8ab2b..07ff02b1cd4 100644 --- a/docs/website/data/port_status_reports/linux-x64.json +++ b/docs/website/data/port_status_reports/linux-x64.json @@ -56,10 +56,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 177, "fail": 0, - "skip": 3, - "not-run": 0 + "not-run": 0, + "pass": 184, + "skip": 3 }, "tests": { "ARApiTest": { @@ -516,6 +516,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -790,6 +794,30 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowModalTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowScrollTest": { + "feature": "multi-window", + "status": "pass" } } } diff --git a/docs/website/data/port_status_reports/mac-native.json b/docs/website/data/port_status_reports/mac-native.json index 5c39251d12d..c3be3c4fd75 100644 --- a/docs/website/data/port_status_reports/mac-native.json +++ b/docs/website/data/port_status_reports/mac-native.json @@ -56,10 +56,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 175, "fail": 0, - "skip": 5, - "not-run": 0 + "not-run": 0, + "pass": 182, + "skip": 5 }, "tests": { "ARApiTest": { @@ -516,6 +516,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -796,6 +800,30 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowModalTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowScrollTest": { + "feature": "multi-window", + "status": "pass" } } } diff --git a/docs/website/data/port_status_reports/tvos.json b/docs/website/data/port_status_reports/tvos.json index 8a2bec7e4fa..509b2329e90 100644 --- a/docs/website/data/port_status_reports/tvos.json +++ b/docs/website/data/port_status_reports/tvos.json @@ -48,10 +48,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 172, "fail": 0, - "skip": 8, - "not-run": 0 + "not-run": 0, + "pass": 173, + "skip": 14 }, "tests": { "ARApiTest": { @@ -508,6 +508,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -797,6 +801,48 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowModalTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowScrollTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" } } } diff --git a/docs/website/data/port_status_reports/watchos.json b/docs/website/data/port_status_reports/watchos.json index c46ad7ea75b..991175272d7 100644 --- a/docs/website/data/port_status_reports/watchos.json +++ b/docs/website/data/port_status_reports/watchos.json @@ -48,10 +48,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 167, "fail": 0, - "skip": 13, - "not-run": 0 + "not-run": 0, + "pass": 168, + "skip": 19 }, "tests": { "ARApiTest": { @@ -514,6 +514,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -812,6 +816,48 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowModalTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" + }, + "WindowScrollTest": { + "feature": "multi-window", + "reasons": [ + "no-windowing-system" + ], + "status": "skip" } } } diff --git a/docs/website/data/port_status_reports/windows-arm64.json b/docs/website/data/port_status_reports/windows-arm64.json index 4b9093e6fb8..b20745c9323 100644 --- a/docs/website/data/port_status_reports/windows-arm64.json +++ b/docs/website/data/port_status_reports/windows-arm64.json @@ -56,10 +56,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 177, "fail": 0, - "skip": 3, - "not-run": 0 + "not-run": 0, + "pass": 184, + "skip": 3 }, "tests": { "ARApiTest": { @@ -516,6 +516,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -790,6 +794,30 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowModalTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowScrollTest": { + "feature": "multi-window", + "status": "pass" } } } diff --git a/docs/website/data/port_status_reports/windows-x64.json b/docs/website/data/port_status_reports/windows-x64.json index 041d97e6062..ae02ddb5d7a 100644 --- a/docs/website/data/port_status_reports/windows-x64.json +++ b/docs/website/data/port_status_reports/windows-x64.json @@ -56,10 +56,10 @@ "schema_version": 1, "suite_finished": true, "summary": { - "pass": 177, "fail": 0, - "skip": 3, - "not-run": 0 + "not-run": 0, + "pass": 184, + "skip": 3 }, "tests": { "ARApiTest": { @@ -516,6 +516,10 @@ "feature": "native-theme-controls", "status": "pass" }, + "MultiWindowApiTest": { + "feature": "multi-window", + "status": "pass" + }, "MutableImageClipReadbackTest": { "feature": "mutable-images", "status": "pass" @@ -790,6 +794,30 @@ "VisionOnDeviceApiTest": { "feature": "on-device-vision", "status": "pass" + }, + "WindowEditingTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowGraphicsTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowLayoutTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowModalTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowOverlayTest": { + "feature": "multi-window", + "status": "pass" + }, + "WindowScrollTest": { + "feature": "multi-window", + "status": "pass" } } } diff --git a/docs/website/data/port_status_supplement.json b/docs/website/data/port_status_supplement.json index 411fbd73369..cc9cdd6a922 100644 --- a/docs/website/data/port_status_supplement.json +++ b/docs/website/data/port_status_supplement.json @@ -290,6 +290,120 @@ ] } ] + }, + { + "test": "WindowEditingTest", + "reason": "This baseline puts the keyboard focus in a text field hosted by a secondary window to prove native editing resolves to the owning window. Ports without a windowing system have one editing surface and nowhere else for an editor to go.", + "platform_support": "Every listed target remains a fully supported Codename One port. Multiple top-level windows are a desktop windowing-system capability rather than a framework feature, so a port with a single full-screen surface reports the capability as absent and the API throws instead of pretending. That contract is asserted on these targets by MultiWindowApiTest, which runs everywhere and passes.", + "verification": "The desktop ports open a native editor inside a real secondary window and compare the captured result against stored goldens, which is what proves the editor is parented to the right window.", + "reason_codes": [ + { + "prefix": "no-windowing-system", + "ports": [ + "android", + "ios-gl", + "ios-metal", + "javascript", + "tvos", + "watchos" + ] + } + ] + }, + { + "test": "WindowGraphicsTest", + "reason": "This baseline draws shapes and charts on a secondary window's surface to prove the graphics pipeline works off the main framebuffer. A single-surface port has no second surface to draw on.", + "platform_support": "Every listed target remains a fully supported Codename One port. Multiple top-level windows are a desktop windowing-system capability rather than a framework feature, so a port with a single full-screen surface reports the capability as absent and the API throws instead of pretending. That contract is asserted on these targets by MultiWindowApiTest, which runs everywhere and passes.", + "verification": "The desktop ports render the same drawing into a real window and compare it against stored goldens, covering the non-primary surface path on each windowing backend.", + "reason_codes": [ + { + "prefix": "no-windowing-system", + "ports": [ + "android", + "ios-gl", + "ios-metal", + "javascript", + "tvos", + "watchos" + ] + } + ] + }, + { + "test": "WindowLayoutTest", + "reason": "This baseline opens a real operating-system window at three sizes and checks that its content lays out to the window rather than to the main display. A port with no windowing system cannot open a second window, so there is nothing to capture.", + "platform_support": "Every listed target remains a fully supported Codename One port. Multiple top-level windows are a desktop windowing-system capability rather than a framework feature, so a port with a single full-screen surface reports the capability as absent and the API throws instead of pretending. That contract is asserted on these targets by MultiWindowApiTest, which runs everywhere and passes.", + "verification": "The desktop ports render this test at every size and compare the result against stored goldens, which is what proves layout follows the window's own dimensions.", + "reason_codes": [ + { + "prefix": "no-windowing-system", + "ports": [ + "android", + "ios-gl", + "ios-metal", + "javascript", + "tvos", + "watchos" + ] + } + ] + }, + { + "test": "WindowModalTest", + "reason": "This baseline raises a modal window over another window and checks that the blocked window keeps repainting. Both halves of that property need two operating-system windows, which a single-surface port cannot provide.", + "platform_support": "Every listed target remains a fully supported Codename One port. Multiple top-level windows are a desktop windowing-system capability rather than a framework feature, so a port with a single full-screen surface reports the capability as absent and the API throws instead of pretending. That contract is asserted on these targets by MultiWindowApiTest, which runs everywhere and passes.", + "verification": "The desktop ports capture the modal window and its live background window together and compare both against stored goldens, which is the evidence that the event loop keeps servicing the blocked window.", + "reason_codes": [ + { + "prefix": "no-windowing-system", + "ports": [ + "android", + "ios-gl", + "ios-metal", + "javascript", + "tvos", + "watchos" + ] + } + ] + }, + { + "test": "WindowOverlayTest", + "reason": "This baseline opens a sheet inside a secondary window to prove layered panes resolve to that window's top-level container. Without a second window the overlay has no separate top level to resolve to.", + "platform_support": "Every listed target remains a fully supported Codename One port. Multiple top-level windows are a desktop windowing-system capability rather than a framework feature, so a port with a single full-screen surface reports the capability as absent and the API throws instead of pretending. That contract is asserted on these targets by MultiWindowApiTest, which runs everywhere and passes.", + "verification": "The desktop ports capture the overlay inside a real window and compare it against stored goldens, so a top-level resolution regression is caught on the pixels.", + "reason_codes": [ + { + "prefix": "no-windowing-system", + "ports": [ + "android", + "ios-gl", + "ios-metal", + "javascript", + "tvos", + "watchos" + ] + } + ] + }, + { + "test": "WindowScrollTest", + "reason": "This baseline scrolls a list inside a secondary window to prove that scroll bounds come from the window's height. Without a windowing system there is no secondary window to scroll.", + "platform_support": "Every listed target remains a fully supported Codename One port. Multiple top-level windows are a desktop windowing-system capability rather than a framework feature, so a port with a single full-screen surface reports the capability as absent and the API throws instead of pretending. That contract is asserted on these targets by MultiWindowApiTest, which runs everywhere and passes.", + "verification": "The desktop ports capture the scrolled list in a window and compare it against stored goldens, so a regression in window-scoped scroll math still fails on the pixels.", + "reason_codes": [ + { + "prefix": "no-windowing-system", + "ports": [ + "android", + "ios-gl", + "ios-metal", + "javascript", + "tvos", + "watchos" + ] + } + ] } ], "features": [ 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 ce3318f169d..d0be41e819e 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 @@ -6369,9 +6369,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; } @@ -6939,7 +6943,509 @@ 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) { + int key = plistKeyIndex(plist, "UIApplicationSceneManifest"); + if (key < 0) { + return ""; + } + int start = plistKeyEnd(plist, key); + if (start < 0) { + return ""; + } + int end = plistValueElementEnd(plist, start); + return end < 0 ? "" : plist.substring(start, end); + } + + /// Whether a plist fragment sets the given key to ``. + /// + /// Deliberately not a plist parse. This is a fragment the application supplied, its + /// shape is not ours, and the question is narrow: does this one key say true. The + /// value of a key is the element that follows it, so this looks at that element and + /// nothing else. Anything unrecognised answers false, which is the safe direction -- + /// the build stops and says what is missing rather than producing a bundle whose + /// windows silently never work. + /// + /// #### Parameters + /// + /// - `plist`: the injected plist fragment + /// + /// - `key`: the key to look for + /// + /// #### Returns + /// + /// true if the key is present and its value is `` + static boolean plistKeyIsTrue(String plist, String key) { + int at = 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 "" and + // "" are the same element, and rejecting the ones with a space in + // them would fail a build over valid XML -- worse than the misconfiguration + // this check exists to catch, because it stops a build that would have worked. + return "true".equals(nextElementName(plist, plistKeyEnd(plist, at))); + } + + /// 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` + static boolean plistWiresWindowSceneDelegate(String plist) { + int role = plistKeyIndex(plist, "UIWindowSceneSessionRoleApplication"); + if (role < 0) { + return false; + } + int afterKey = 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 = 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 -- a UISceneConfigurationName of + // "CodenameOne_GLSceneDelegate" is legal -- while UISceneDelegateClassName + // names somebody else, and a manifest like that cannot adopt a window. + int at = plistKeyIndex(plist, "UISceneDelegateClassName", afterKey); + while (at >= 0 && at < end) { + if ("CodenameOne_GLSceneDelegate".equals( + plistStringValueAfter(plist, plistKeyEnd(plist, at)))) { + return true; + } + at = plistKeyIndex(plist, "UISceneDelegateClassName", plistKeyEnd(plist, at)); + } + return false; + } + + /// 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 + // plistWiresWindowSceneDelegate 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()) { @@ -7118,12 +7624,56 @@ 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 && plistDeclaresKey(inject, "UIApplicationSceneManifest")) { + // The application supplied its own manifest, so this build must not write a + // second one -- but a Catalyst build that asked for windows and whose own + // manifest does not support them fails at run time, not here: the bundle is + // what getWindowManager() reads, so windows come back unsupported and the + // first "new Window(...)" throws in a build that had just asked for them. + // Rather than merge into a fragment we did not write, and risk producing a + // plist neither side intended, say plainly what is missing. + // Both questions are asked of the manifest's own dictionary rather than of + // the whole fragment. An unrelated dictionary elsewhere in the injection can + // carry a UIApplicationSupportsMultipleScenes of true, or something shaped + // like a window role, and answering from those accepts a manifest that + // enables and configures nothing -- the build passes and Window is still + // unsupported on the device. + String manifest = plistManifestScope(inject); + boolean supportsMultiple = + plistKeyIsTrue(manifest, "UIApplicationSupportsMultipleScenes"); + boolean hasWindowRole = plistWiresWindowSceneDelegate(manifest); + if (!supportsMultiple || !hasWindowRole) { + throw new BuildException( + "macNative.enabled=true asks for com.codename1.ui.Window support, but " + + "ios.plistInject already supplies its own UIApplicationSceneManifest " + + "which is missing " + + (!supportsMultiple + ? "UIApplicationSupportsMultipleScenes set to " + : "") + + (!supportsMultiple && !hasWindowRole ? " and " : "") + + (!hasWindowRole + ? "a UIWindowSceneSessionRoleApplication configuration whose " + + "UISceneDelegateClassName is CodenameOne_GLSceneDelegate" : "") + + ". Add it to your injected manifest, or drop the manifest from " + + "ios.plistInject and let the build emit one."); + } + } + if ((useUISceneManifest || usesCar || multiWindow) + && !plistDeclaresKey(inject, "UIApplicationSceneManifest")) { String carPlayScene = usesCar ? " CPTemplateApplicationSceneSessionRoleApplication\n" + " \n" @@ -7135,7 +7685,11 @@ public boolean accept(File file, String string) { + " \n" + " \n" : ""; - String windowScene = useUISceneManifest + // A window is a second scene of the app role, so it needs the role declared + // and the delegate wired even where the application asked for the legacy + // lifecycle: without this the manifest would say multiple scenes are + // supported and then describe no configuration to create them with. + String windowScene = (useUISceneManifest || multiWindow) ? " UIWindowSceneSessionRoleApplication\n" + " \n" + " \n" @@ -7149,11 +7703,22 @@ public boolean accept(File file, String string) { 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). - + " \n" + // True on Mac Catalyst, false everywhere else. + // + // A com.codename1.ui.Window IS a second scene of the app role, so the key is + // what makes windows possible at all -- IOSImplementation.getWindowManager() + // reads it back out of the bundle and reports unsupported without it. + // + // It stays false for iPhone and iPad. The CarPlay scene is a distinct scene + // ROLE (CPTemplateApplicationSceneSessionRoleApplication), not a second window + // of the app role, so it never needed multiple-scene support, and turning the + // key on for iOS would opt every app into iPad multi-window behaviour it did + // not ask for. + // + // An earlier attempt at setting this true for every target destabilised the + // Catalyst screenshot suite. Scoping it to the Catalyst slice was verified by + // running that suite: 178 tests, zero failures, with windows in use. + + (multiWindow ? " \n" : " \n") + " UISceneConfigurations\n" + " \n" + windowScene 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..e4c14f91190 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/IPhoneBuilderSceneManifestValidationTest.java @@ -0,0 +1,481 @@ +/* + * 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.assertFalse; +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 { + + @Test + void aKeySetToFalseIsNotAcceptedAsTrue() { + assertFalse(IPhoneBuilder.plistKeyIsTrue( + "UIApplicationSupportsMultipleScenes", + "UIApplicationSupportsMultipleScenes"), + "the key is present but says false, which is the case that has to be caught"); + } + + @Test + void aKeySetToTrueIsAccepted() { + assertTrue(IPhoneBuilder.plistKeyIsTrue( + "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(IPhoneBuilder.plistKeyIsTrue( + "UIApplicationSupportsMultipleScenes\n" + + "UISomethingElse", + "UIApplicationSupportsMultipleScenes"), + "a true further down the plist belongs to a different key"); + assertFalse(IPhoneBuilder.plistKeyIsTrue( + "UIApplicationSupportsMultipleScenes\n" + + "UISomethingElse", + "UIApplicationSupportsMultipleScenes"), + "another key intervenes, so this one has no true of its own"); + } + + @Test + void anAbsentKeyIsNotTrue() { + assertFalse(IPhoneBuilder.plistKeyIsTrue("UIOther", + "UIApplicationSupportsMultipleScenes")); + } + + @Test + void theWindowRoleHasToNameCodenameOnesDelegate() { + assertTrue(IPhoneBuilder.plistWiresWindowSceneDelegate( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate")); + assertFalse(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistKeyIsTrue( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistKeyIsTrue( + "UIApplicationSupportsMultipleScenes" + spelling, + "UIApplicationSupportsMultipleScenes"), + "should accept " + spelling); + } + } + + @Test + void theValidXmlSpellingsOfFalseAreAllRejected() { + for (String spelling : new String[]{"", "", ""}) { + assertFalse(IPhoneBuilder.plistKeyIsTrue( + "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(IPhoneBuilder.plistKeyIsTrue( + "", + "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(IPhoneBuilder.plistKeyIsTrue( + "UIApplicationSupportsMultipleScenes" + + "", + "UIApplicationSupportsMultipleScenes"), + "the commented-out true must not stand in for the real false"); + assertTrue(IPhoneBuilder.plistKeyIsTrue( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "" + + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistKeyIsTrue( + " UIApplicationSupportsMultipleScenes ", + "UIApplicationSupportsMultipleScenes"), + "and its value is still readable"); + assertTrue(IPhoneBuilder.plistWiresWindowSceneDelegate( + "\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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "UIWindowSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "SomeoneElsesSceneDelegate" + + "CPTemplateApplicationSceneSessionRoleApplication" + + "UISceneDelegateClassName" + + "CodenameOne_GLSceneDelegate"), + "the delegate in the CarPlay role is outside this role's array"); + } + + @Test + void nestedArraysInsideTheRoleDoNotEndItEarly() { + assertTrue(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistWiresWindowSceneDelegate( + "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(IPhoneBuilder.plistKeyIsTrue(plist, + "UIApplicationSupportsMultipleScenes"), + "unscoped, the unrelated dictionary answers -- this is the bug"); + assertFalse(IPhoneBuilder.plistKeyIsTrue(scope, + "UIApplicationSupportsMultipleScenes"), + "the manifest says false, whatever the unrelated dictionary says"); + assertFalse(IPhoneBuilder.plistWiresWindowSceneDelegate(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(IPhoneBuilder.plistKeyIsTrue(scope, + "UIApplicationSupportsMultipleScenes")); + assertTrue(IPhoneBuilder.plistWiresWindowSceneDelegate(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(IPhoneBuilder.plistKeyIsTrue(scope, + "UIApplicationSupportsMultipleScenes"), + "the commented closing tag must not end the manifest"); + assertTrue(IPhoneBuilder.plistWiresWindowSceneDelegate(scope), + "and the role after it is still inside the manifest"); + } +} 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..6d1c861d7a6 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/testing/TestWindowManager.java @@ -0,0 +1,685 @@ +/* + * 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) { + 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) { + FakeWindow w = win(peer); + if (w != null) { + w.resizable = resizable; + } + } + + @Override + public void setDecorated(Object peer, boolean decorated) { + 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) { + 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) { + FakeWindow w = win(peer); + if (w != null) { + w.utility = utility; + } + } + + @Override + public void setMinimumSize(Object peer, int width, int height) { + FakeWindow w = win(peer); + if (w != null) { + w.minimumWidth = width; + w.minimumHeight = height; + } + } + + @Override + public void setIcon(Object peer, Image icon) { + } + + /// 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..a9f339da292 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/WindowTest.java @@ -0,0 +1,4680 @@ +/* + * 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.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. + Display d = Display.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(Display.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. + Display.getInstance().windowMagnifyGesture(w.getWindowId(), 150, 120, 1.5f); + Display.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. + Display.getInstance().windowMagnifyGesture(blocked.getWindowId(), 150, 120, 1.5f); + int whileBlocked = pinches[0]; + + appModal.dispose(); + Display.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); + Display.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); + Display.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(); + + Display.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); + Display.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 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. + Display.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 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 disposingWindowsMidPressDoesNotExhaustTheDragSlots() { + implementation.setMultiWindowSupported(true); + // One more than the implementation tracks, so a leak of one slot per window + // is certain to have used them all up by the end. + int slots = 8; + for (int iter = 0; iter < slots + 1; 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 slot is claimed here and nothing else will + // ever give it back: window ids are not reused. + implementation.windowPointerPressedForTest(doomed.getWindowId(), 100, 100); + doomed.dispose(); + DisplayTest.flushEdt(); + } + + // A fresh window must still get the activation filter. With the slots leaked, + // windowDragSlot answers -1 and the passthrough delivers jitter as a 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 of slots"); + + // 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 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/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/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..1a97d25f593 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowHostTest.java @@ -0,0 +1,394 @@ +/* + * 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()) { + // Reported as SKIPPED, not as a pass. This platform has no windowing + // system and emits no golden, and a pass here would put a tick against + // multi-window on the public port status table for a port that cannot + // open a window at all. + // Named by test class, not by golden name: the skip marker is parsed + // with [A-Za-z0-9_]+, which the hyphenated screenshot names would not + // match, and an unmatched marker is silently dropped -- leaving the + // very pass this is here to avoid. + println("CN1SS:INFO:test=" + getClass().getName().substring( + getClass().getName().lastIndexOf('.') + 1) + + " status=SKIPPED reason=no-windowing-system"); + 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..c1e2b7d2164 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/WindowModalTest.java @@ -0,0 +1,215 @@ +/* + * 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()) { + // SKIPPED rather than a pass: see WindowHostTest. + System.out.println("CN1SS:INFO:test=WindowModalTest " + + "status=SKIPPED reason=no-windowing-system"); + 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/test_port_status.py b/scripts/hellocodenameone/conformance/test_port_status.py index e589a901f76..330745bd60d 100755 --- a/scripts/hellocodenameone/conformance/test_port_status.py +++ b/scripts/hellocodenameone/conformance/test_port_status.py @@ -17,7 +17,7 @@ def setUpClass(cls): def test_contract_covers_registered_tests_and_goldens(self): counts = port_status.validate(self.manifest) - self.assertEqual(180, counts["tests"]) + self.assertEqual(187, counts["tests"]) self.assertEqual(1, counts["performance_tests"]) self.assertGreaterEqual(counts["features"], 54) self.assertEqual(11, counts["ports"]) 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