diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml index f802a624105..70e9cf4e3ac 100644 --- a/.github/workflows/ant.yml +++ b/.github/workflows/ant.yml @@ -68,7 +68,7 @@ jobs: # while Ant's own was still inside the outage. NOT added to # the test step below, where retrying a network-dependent test would # launder exactly the flake this repository requires to be root-caused. - RETRY_ONLY_MATCHING: 'status( code)?: (403|429|50[0-9])|Could not transfer artifact|Failed to read artifact descriptor|Unresolveable build extension|Non-resolvable import POM|Could not collect dependencies|UnknownHostException|Unknown host|Connection timed out|Connection reset|Premature end of Content-Length|Error getting https' + RETRY_ONLY_MATCHING: 'status( code)?: (403|429|50[0-9])|Could not transfer artifact|Failed to read artifact descriptor|Unresolveable build extension|Non-resolvable import POM|Could not collect dependencies|Could not resolve dependencies|UnknownHostException|Unknown host|Connection timed out|Connection reset|Premature end of Content-Length|Error getting https' run: | # The cn1{app,lib}-archetype modules are part of the maven/ reactor, # so this single install also stages them into the local repo. @@ -79,7 +79,7 @@ jobs: - name: Refresh archetype catalog env: - RETRY_ONLY_MATCHING: 'status( code)?: (403|429|50[0-9])|Could not transfer artifact|Failed to read artifact descriptor|Unresolveable build extension|Non-resolvable import POM|Could not collect dependencies|UnknownHostException|Unknown host|Connection timed out|Connection reset|Premature end of Content-Length|Error getting https' + RETRY_ONLY_MATCHING: 'status( code)?: (403|429|50[0-9])|Could not transfer artifact|Failed to read artifact descriptor|Unresolveable build extension|Non-resolvable import POM|Could not collect dependencies|Could not resolve dependencies|UnknownHostException|Unknown host|Connection timed out|Connection reset|Premature end of Content-Length|Error getting https' run: | cd maven bash ../scripts/ci/retry.sh xvfb-run -a mvn archetype:update-local-catalog -Plocal-dev-javase diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 545ad9b28e5..bdf49be15e6 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -2773,6 +2773,34 @@ public boolean isBaselineTextSupported() { /// the width of the given string in this font instance public abstract int stringWidth(Object nativeFont, String str); + /// Bulk caret measurement for a single directional text run. Ports with a shaping engine + /// should override this compatibility fallback to shape the run once. + public int[] getTextRunPositions(Object nativeFont, String text, boolean rtl) { + int[] positions = new int[text.length() + 1]; + for (int i = 0; i < positions.length; i++) { + positions[i] = stringWidth(nativeFont, rtl ? text.substring(i) : text.substring(0, i)); + } + return positions; + } + + /// Revalidates a pure text client's top level and reveals its caret after the keyboard changes + /// the available viewport. Called on the EDT, independently of legacy native text widgets. + protected void revealTextInputClient(com.codename1.ui.TextInputClient client) { + if (!(client instanceof Component)) { + return; + } + Component component = (Component) client; + com.codename1.ui.TopLevelContainer top = component.getTopLevelContainer(); + if (top == null) { + return; + } + component.requestFocus(); + top.asContainer().revalidate(); + int[] caret = client.getCaretRect(); + component.scrollRectToVisible(caret[0] - component.getAbsoluteX(), + caret[1] - component.getAbsoluteY(), Math.max(1, caret[2]), Math.max(1, caret[3]), component); + } + /// Return the width of the specific character when rendered alone /// /// #### Parameters @@ -5264,6 +5292,23 @@ public void toggleMaximizeNativeWindow() { public void closeNativeWindow() { } + /// Publishes the custom Toolbar's native title-bar hit regions. Coordinates are + /// absolute component pixels relative to its window. The first x/y/width/height + /// rectangle is the title band; subsequent rectangles exclude interactive children. + /// An empty array clears the regions. Ports with native event tracking can use this + /// to start an OS window drag before dispatching a component pointer event. + public void setNativeWindowTitleBarRegions(Component toolbar, int[] regions) { + } + + /// Returns reserved title-bar space for an integrated custom Toolbar, in component pixels. + /// The five entries are physical left, right, top, bottom insets and minimum band height. + /// Return null when native integration is unavailable. Called on the EDT during layout; + /// implementations must not call Java UI code while querying their native UI thread. + /// Mobile and browser ports retain the null implementation. + public int[] getNativeWindowTitleBarInsets(Component toolbar) { + return null; + } + /// Begins dragging the native desktop window (custom mode title bar). The arguments are /// absolute pointer coordinates at the start of the drag. No-op without a native window. /// diff --git a/CodenameOne/src/com/codename1/mcp/McpUiTools.java b/CodenameOne/src/com/codename1/mcp/McpUiTools.java index 49362c7ea88..24779353f88 100644 --- a/CodenameOne/src/com/codename1/mcp/McpUiTools.java +++ b/CodenameOne/src/com/codename1/mcp/McpUiTools.java @@ -26,7 +26,10 @@ import com.codename1.ai.ToolHandler; import com.codename1.io.JSONParser; import com.codename1.ui.Display; -import com.codename1.ui.Form; +import com.codename1.ui.Desktop; +import com.codename1.ui.Window; +import com.codename1.ui.Component; +import com.codename1.ui.TopLevelContainer; import com.codename1.ui.Graphics; import com.codename1.ui.Image; import com.codename1.ui.accessibility.AccessibilityAction; @@ -51,26 +54,52 @@ final class McpUiTools { private McpUiTools() { } + private static final String WINDOW_PROPERTY = "\"windowId\":{\"type\":\"integer\",\"description\":\"Optional window id from ui_windows; 0 is the main form, omitted uses current focus\"}"; + static List builtInTools() { List tools = new ArrayList(); + tools.add(new Tool("ui_windows", "List application windows with ids, titles, visibility, focus and modal blocking. Use windowId with UI tools to address a dialog explicitly.", + "{\"type\":\"object\",\"properties\":{}}", new ToolHandler() { + @Override + public String invoke(String argumentsJson) { + final List windows = new ArrayList(); + runOnEdt(new Runnable() { + @Override + public void run() { + TopLevelContainer main = Display.getInstance().getCurrent(); + if (main != null) { + windows.add(windowInfo(0, main)); + } + if (Desktop.isSupported()) { + for (Window window : Desktop.getInstance().getWindows()) { + windows.add(windowInfo(window.getWindowId(), window)); + } + } + } + }); + return JSONParser.toJson(windows); + } + })); + tools.add(new Tool("ui_snapshot", "Returns the accessibility semantics tree of the current screen as JSON: every " + "visible node with its id, role, label, value, state and the ids of the " + "actions that can be performed on it.", - "{\"type\":\"object\",\"properties\":{}}", + "{\"type\":\"object\",\"properties\":{" + WINDOW_PROPERTY + "}}", new ToolHandler() { @Override - public String invoke(String argumentsJson) { - return snapshotJson(); + public String invoke(String argumentsJson) throws Exception { + return snapshotJson((int) longArg(parse(argumentsJson), "windowId", -1)); } })); tools.add(new Tool("ui_perform_action", "Performs an accessibility action on a node from ui_snapshot. actionId is one of the " + "action ids listed on the node (for example activate, setText, increment, focus). " - + "Returns whether it succeeded and a fresh snapshot.", - "{\"type\":\"object\",\"properties\":{" + + "Returns success and a fresh snapshot. If a modal action is still running, " + + "returns pending:true without a completed success result; use subsequent UI calls to interact with the dialog.", + "{\"type\":\"object\",\"properties\":{" + WINDOW_PROPERTY + "," + "\"nodeId\":{\"type\":\"integer\",\"description\":\"id of the target node\"}," + "\"actionId\":{\"type\":\"string\",\"description\":\"id of the action to perform\"}," + "\"argument\":{\"type\":\"string\",\"description\":\"optional argument, e.g. text for setText\"}" @@ -82,14 +111,15 @@ public String invoke(String argumentsJson) throws Exception { long nodeId = longArg(args, "nodeId", -1); String actionId = JSONParser.getString(args, "actionId"); Object argument = args.get("argument"); - return JSONParser.toJson(performAction(nodeId, actionId, argument)); + return JSONParser.toJson(performAction(nodeId, actionId, argument, (int) longArg(args, "windowId", -1))); } })); tools.add(new Tool("ui_activate", "Convenience wrapper that performs the activate action on a node (equivalent to a tap " - + "or click). Returns whether it succeeded and a fresh snapshot.", - "{\"type\":\"object\",\"properties\":{" + + "or click). Returns success and a fresh snapshot. Modal actions return pending:true " + + "without a completed success result so subsequent calls can interact with the dialog.", + "{\"type\":\"object\",\"properties\":{" + WINDOW_PROPERTY + "," + "\"nodeId\":{\"type\":\"integer\",\"description\":\"id of the node to activate\"}" + "},\"required\":[\"nodeId\"]}", new ToolHandler() { @@ -97,14 +127,14 @@ public String invoke(String argumentsJson) throws Exception { public String invoke(String argumentsJson) throws Exception { Map args = parse(argumentsJson); return JSONParser.toJson(performAction(longArg(args, "nodeId", -1), - AccessibilityAction.ACTIVATE, null)); + AccessibilityAction.ACTIVATE, null, (int) longArg(args, "windowId", -1))); } })); tools.add(new Tool("ui_set_text", "Convenience wrapper that sets the text of an editable node (performs the setText " + "action with the given text). Returns whether it succeeded and a fresh snapshot.", - "{\"type\":\"object\",\"properties\":{" + "{\"type\":\"object\",\"properties\":{" + WINDOW_PROPERTY + "," + "\"nodeId\":{\"type\":\"integer\",\"description\":\"id of the editable node\"}," + "\"text\":{\"type\":\"string\",\"description\":\"the text to set\"}" + "},\"required\":[\"nodeId\",\"text\"]}", @@ -113,7 +143,7 @@ public String invoke(String argumentsJson) throws Exception { public String invoke(String argumentsJson) throws Exception { Map args = parse(argumentsJson); return JSONParser.toJson(performAction(longArg(args, "nodeId", -1), - AccessibilityAction.SET_TEXT, JSONParser.getString(args, "text"))); + AccessibilityAction.SET_TEXT, JSONParser.getString(args, "text"), (int) longArg(args, "windowId", -1))); } })); @@ -121,7 +151,7 @@ public String invoke(String argumentsJson) throws Exception { "Finds nodes on the current screen by application identifier, by a case insensitive " + "substring of their label, or by a screen coordinate. Returns a compact JSON array " + "of matches with their id, role, label and available action ids.", - "{\"type\":\"object\",\"properties\":{" + "{\"type\":\"object\",\"properties\":{" + WINDOW_PROPERTY + "," + "\"identifier\":{\"type\":\"string\",\"description\":\"exact application identifier\"}," + "\"label\":{\"type\":\"string\",\"description\":\"label substring to search for\"}," + "\"x\":{\"type\":\"integer\",\"description\":\"screen x coordinate\"}," @@ -134,7 +164,7 @@ public String invoke(String argumentsJson) throws Exception { return findNodes(JSONParser.getString(args, "identifier"), JSONParser.getString(args, "label"), (int) longArg(args, "x", Integer.MIN_VALUE), - (int) longArg(args, "y", Integer.MIN_VALUE)); + (int) longArg(args, "y", Integer.MIN_VALUE), (int) longArg(args, "windowId", -1)); } })); @@ -142,50 +172,124 @@ public String invoke(String argumentsJson) throws Exception { } static String snapshotJson() { + return snapshotJson(-1); + } + + private static String snapshotJson(final int windowId) { final String[] holder = new String[1]; runOnEdt(new Runnable() { @Override public void run() { - holder[0] = currentSnapshot().toJson(); + holder[0] = currentSnapshot(windowId).toJson(); } }); return holder[0]; } - static Map performAction(final long nodeId, final String actionId, final Object argument) { + static Map performAction(final long nodeId, final String actionId, final Object argument, final int windowId) { + final Map outcome = new LinkedHashMap(); + final boolean[] complete = new boolean[1]; + Runnable perform = new Runnable() { + @Override + public void run() { + try { + AccessibilityManager mgr = AccessibilityManager.getInstance(); + AccessibilityTreeSnapshot snap = currentSnapshot(windowId); + AccessibilityNodeSnapshot node = snap.getNode(nodeId); + AccessibilityAction action = node == null ? null : node.getAction(actionId); + boolean ok = false; + TopLevelContainer top = resolveWindow(windowId); + int targetId = top instanceof Window ? ((Window) top).getWindowId() : 0; + if (Desktop.isSupported() && Desktop.getInstance().isWindowInputBlocked(targetId)) { + throw new IllegalStateException("Window is blocked by a modal dialog; inspect ui_windows"); + } + if (node != null && action != null && action.isEnabled()) { + ok = action.perform(node.getComponent(), argument); + mgr.invalidate(node.getComponent(), AccessibilityManager.CHANGE_STATE + | AccessibilityManager.CHANGE_VALUE | AccessibilityManager.CHANGE_CONTENT + | AccessibilityManager.CHANGE_STRUCTURE); + } + outcome.put("success", Boolean.valueOf(ok)); + if (node == null) { + outcome.put("error", "no node with id " + nodeId); + } else if (action == null) { + outcome.put("error", "node " + nodeId + " has no action " + actionId); + } + } catch (RuntimeException ex) { + outcome.put("success", Boolean.FALSE); + outcome.put("error", ex.toString()); + } finally { + complete[0] = true; + } + } + }; + final Display display = Display.getInstance(); final Map out = new LinkedHashMap(); - runOnEdt(new Runnable() { + final Object lock = new Object(); + final boolean[] ready = new boolean[1]; + final Runnable snapshot = new Runnable() { @Override public void run() { - AccessibilityManager mgr = AccessibilityManager.getInstance(); - AccessibilityTreeSnapshot snap = currentSnapshot(); - AccessibilityNodeSnapshot node = snap.getNode(nodeId); - AccessibilityAction action = node == null ? null : node.getAction(actionId); - boolean ok = false; - if (node != null && action != null && action.isEnabled()) { - ok = action.perform(node.getComponent(), argument); - mgr.invalidate(node.getComponent(), AccessibilityManager.CHANGE_STATE - | AccessibilityManager.CHANGE_VALUE | AccessibilityManager.CHANGE_CONTENT - | AccessibilityManager.CHANGE_STRUCTURE); + try { + out.putAll(outcome); + out.put("pending", Boolean.valueOf(!complete[0])); + if (!complete[0]) { + out.put("success", null); + } + int snapshotWindow = windowId; + if (snapshotWindow > 0) { + Window window = Desktop.getInstance().windowById(snapshotWindow); + if (window == null || !window.isWindowShowing()) { + snapshotWindow = -1; + } + } + out.put("snapshot", JSONParser.rawJson(currentSnapshot(snapshotWindow).toJson())); + } catch (RuntimeException ex) { + out.put("success", Boolean.FALSE); + out.put("error", ex.toString()); + } finally { + synchronized (lock) { + ready[0] = true; + lock.notifyAll(); + } + } + } + }; + if (display.isEdt()) { + perform.run(); + snapshot.run(); + } else { + final Runnable action = perform; + display.callSerially(new Runnable() { + @Override + public void run() { + // Queue from INSIDE the action turn: otherwise both callbacks + // can land in one serial-call batch and the snapshot gets stuck + // behind the suspended modal action. Dialog's nested EDT loop + // can now run the snapshot and release the MCP reader. + display.callSerially(snapshot); + action.run(); } - out.put("success", Boolean.valueOf(ok)); - if (node == null) { - out.put("error", "no node with id " + nodeId); - } else if (action == null) { - out.put("error", "node " + nodeId + " has no action " + actionId); + }); + synchronized (lock) { + while (!ready[0]) { + try { + lock.wait(); + } catch (InterruptedException ex) { + throw new RuntimeException("Interrupted while waiting for UI action", ex); + } } - out.put("snapshot", JSONParser.rawJson(currentSnapshot().toJson())); } - }); + } return out; } - static String findNodes(final String identifier, final String label, final int x, final int y) { + static String findNodes(final String identifier, final String label, final int x, final int y, final int windowId) { final List matches = new ArrayList(); runOnEdt(new Runnable() { @Override public void run() { - AccessibilityTreeSnapshot snap = currentSnapshot(); + AccessibilityTreeSnapshot snap = currentSnapshot(windowId); if (identifier != null && identifier.length() > 0) { addNode(matches, snap.getNodeByIdentifier(identifier)); } else if (x != Integer.MIN_VALUE && y != Integer.MIN_VALUE) { @@ -204,7 +308,7 @@ public void run() { return JSONParser.toJson(matches); } - /// Renders the current form to a PNG. Returns null when no form is showing or the + /// Renders the focused top level to a PNG. Returns null when none is showing or the /// platform cannot encode PNG images. Runs on the EDT. static byte[] screenshotPng() { final byte[][] holder = new byte[1][]; @@ -218,7 +322,8 @@ public void run() { } private static byte[] renderCurrentFormPng() { - Form form = Display.getInstance().getCurrent(); + TopLevelContainer top = Display.getInstance().getCurrentTopLevel(); + Component form = top == null ? null : top.asContainer(); ImageIO io = ImageIO.getImageIO(); if (form == null || io == null) { return null; @@ -265,16 +370,53 @@ private static void addNode(List matches, AccessibilityNodeSnapshot node matches.add(out); } - private static AccessibilityTreeSnapshot currentSnapshot() { - return AccessibilityManager.getInstance().getSnapshot(Display.getInstance().getCurrent()); + private static TopLevelContainer resolveWindow(int windowId) { + if (windowId == -1) { + return Display.getInstance().getCurrentTopLevel(); + } + if (windowId == 0) { + return Display.getInstance().getCurrent(); + } + Window window = Desktop.getInstance().windowById(windowId); + if (window == null || !window.isWindowShowing()) { + throw new IllegalArgumentException("No visible window with id " + windowId); + } + return window; } - private static void runOnEdt(Runnable r) { + private static Map windowInfo(int id, TopLevelContainer top) { + Map info = new LinkedHashMap(); + info.put("windowId", Integer.valueOf(id)); + info.put("title", top.getTitle()); + info.put("visible", Boolean.valueOf(!(top instanceof Window) || ((Window) top).isWindowShowing())); + info.put("focused", Boolean.valueOf(top == Display.getInstance().getCurrentTopLevel())); // NOPMD CompareObjectsWithEquals - focus identifies the exact window instance + info.put("blocked", Boolean.valueOf(Desktop.isSupported() && Desktop.getInstance().isWindowInputBlocked(id))); + return info; + } + + private static AccessibilityTreeSnapshot currentSnapshot(int windowId) { + return AccessibilityManager.getInstance().getSnapshot(resolveWindow(windowId)); + } + + private static void runOnEdt(final Runnable r) { Display display = Display.getInstance(); if (display.isEdt()) { r.run(); } else { - display.callSeriallyAndWait(r); + final RuntimeException[] error = new RuntimeException[1]; + display.callSeriallyAndWait(new Runnable() { + @Override + public void run() { + try { + r.run(); + } catch (RuntimeException ex) { + error[0] = ex; + } + } + }); + if (error[0] != null) { + throw error[0]; + } } } diff --git a/CodenameOne/src/com/codename1/ui/CustomFont.java b/CodenameOne/src/com/codename1/ui/CustomFont.java index 0e5bb24e422..6d52b7112c2 100644 --- a/CodenameOne/src/com/codename1/ui/CustomFont.java +++ b/CodenameOne/src/com/codename1/ui/CustomFont.java @@ -308,6 +308,22 @@ public String getCharset() { return charsets; } + /// {@inheritDoc} + @Override + public int[] getTextRunPositions(String text, boolean rtl) { + int[] positions = new int[text.length() + 1]; + if (rtl) { + for (int i = text.length() - 1; i >= 0; i--) { + positions[i] = positions[i + 1] + charWidth(text.charAt(i)); + } + } else { + for (int i = 0; i < text.length(); i++) { + positions[i + 1] = positions[i] + charWidth(text.charAt(i)); + } + } + return positions; + } + /// {@inheritDoc} @Override public int charsWidth(char[] ch, int offset, int length) { diff --git a/CodenameOne/src/com/codename1/ui/Dialog.java b/CodenameOne/src/com/codename1/ui/Dialog.java index ff8de3029ee..94a3949c49d 100644 --- a/CodenameOne/src/com/codename1/ui/Dialog.java +++ b/CodenameOne/src/com/codename1/ui/Dialog.java @@ -158,7 +158,7 @@ public class Dialog extends Form implements AbstractDialog { private static boolean defaultInteractionDialogModeInitialized; private static boolean defaultTitleCentered; /// Indicates whether the dialog has been disposed - private boolean disposed; + private volatile boolean disposed; // NOPMD AvoidUsingVolatile - publishes EDT disposal to the modal waiting thread /// Indicates the time in which the alert should be disposed private long time; /// The off-surface half of the timeout, so it still arrives while the surface @@ -2041,6 +2041,12 @@ TopLevelContainer resolveHost() { if (hostTopLevel != null) { return hostTopLevel; } + if (nativeWindow != null) { + // Its payload is now parented to this new window. The owner still + // supplies the sizing context; measuring against the initial peer + // would cap wider content at that peer's default width forever. + return nativeWindow.getOwnerWindow(); + } if (getParent() != null) { TopLevelContainer attached = getTopLevelContainer(); if (attached != null && attached != this) { //NOPMD CompareObjectsWithEquals @@ -3929,7 +3935,11 @@ void addSelectCommand() { /// Allows us to indicate disposed state for dialogs @Override boolean isDisposed() { - return disposed || isTimedOut(); + // RunnableWrapper polls this from invokeAndBlock's worker. Expiring a + // timeout disposes UI and removes an animation; doing that here off the + // EDT races Form.loopAnimations(). The animation poll and timeout clock + // both run on the EDT, so the worker only needs to observe their result. + return disposed || (Display.getInstance().isEdt() && isTimedOut()); } /// Allows us to indicate disposed state for dialogs diff --git a/CodenameOne/src/com/codename1/ui/Font.java b/CodenameOne/src/com/codename1/ui/Font.java index e1edbae3219..d2251579f8c 100644 --- a/CodenameOne/src/com/codename1/ui/Font.java +++ b/CodenameOne/src/com/codename1/ui/Font.java @@ -660,6 +660,22 @@ public boolean isTTFNativeFont() { public void addContrast(byte value) { } + /// Returns visual caret x coordinates for a single directional text run, in pixels. + /// The result has one entry per UTF-16 insertion position, including the final endpoint. + /// Ports may shape the run once rather than measure every prefix independently. + /// + /// #### Parameters + /// + /// - `text`: text containing one directional run + /// - `rtl`: true for a right-to-left run + /// + /// #### Returns + /// + /// a new array of `text.length() + 1` positions relative to the run's left edge + public int[] getTextRunPositions(String text, boolean rtl) { + return Display.impl.getTextRunPositions(font, text, rtl); + } + /// Return the width of the given characters in this font instance /// /// #### Parameters diff --git a/CodenameOne/src/com/codename1/ui/List.java b/CodenameOne/src/com/codename1/ui/List.java index a41539a689c..770645646fc 100644 --- a/CodenameOne/src/com/codename1/ui/List.java +++ b/CodenameOne/src/com/codename1/ui/List.java @@ -1676,6 +1676,24 @@ public void removeActionListener(ActionListener l) { dispatcher.removeListener(l); } + /// Selects and activates an item on behalf of assistive technology. + /// This does not depend on hardware key mappings or open a ComboBox popup + /// after the user has already chosen a virtual accessibility item. + /// Must be called on the EDT. + /// + /// #### Parameters + /// - `index`: the item to select and activate + /// #### Returns + /// true if the enabled list accepted the item, false for an invalid index + public boolean accessibilityActivateItem(int index) { + if (!isEnabled() || index < 0 || index >= size()) { + return false; + } + setSelectedIndex(index); + dispatchActionEvent(new ActionEvent(eventSource, ActionEvent.Type.Other)); + return true; + } + /// Fires an action event for the current event source. @Override protected void fireActionEvent() { @@ -1688,7 +1706,13 @@ protected void fireActionEvent() { /// /// - `a`: the event to fire protected void fireActionEvent(ActionEvent a) { - if (isEnabled() && !Display.getInstance().hasDragOccured()) { + if (!Display.getInstance().hasDragOccured()) { + dispatchActionEvent(a); + } + } + + private void dispatchActionEvent(ActionEvent a) { + if (isEnabled()) { if (disposeDialogOnSelection) { popupSelectionFired = true; // The enclosing Dialog, found the same way an activated command finds diff --git a/CodenameOne/src/com/codename1/ui/TextInputGeometry.java b/CodenameOne/src/com/codename1/ui/TextInputGeometry.java new file mode 100644 index 00000000000..98f3b7d52ad --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/TextInputGeometry.java @@ -0,0 +1,172 @@ +/* + * 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; + +/// Immutable screen geometry for synchronous native keyboard queries. Construct on the EDT; +/// the native UI thread must never wait for the EDT, which can itself be waiting for drawing. +/// Coordinates are absolute screen pixels and document offsets are UTF-16 indices. +/// Input arrays and query results are copied so native readers never share mutable editor geometry. +public final class TextInputGeometry { + /// Optional optimized geometry provider for large custom editors. + public interface Provider { + /// Called on the EDT when the port starts or updates text input. Return a non-null snapshot + /// of the current visible insertion positions, selection rectangles and active caret. + TextInputGeometry getGeometrySnapshot(); + } + + private final int[] carets; + private final int[] selection; + private final int[] fallback; + private final int start; + private final int end; + private final int activeEnd; + + /// Each caret record is {UTF-16 offset, x, y, width, height}. Include visible insertion + /// positions, in document order, including the endpoint after each visible run. Selection + /// rectangles use {x, y, width, height} records. All arrays must be non-null; carets and + /// selection may be empty. The fallback must be the active caret's {x, y, width, height}. + /// The start and end offsets describe the captured selection and may be in either order. + public TextInputGeometry(int[] carets, int start, int end, int[] selection, int[] fallback) { + this.carets = copy(carets); + this.selection = copy(selection); + this.fallback = copy(fallback); + this.start = Math.min(start, end); + this.end = Math.max(start, end); + this.activeEnd = end; + } + + private static int[] copy(int[] value) { + int[] out = new int[value.length]; + System.arraycopy(value, 0, out, 0, value.length); + return out; + } + + /// Captures a bounded visible range for clients that do not provide a specialized snapshot. + /// Large editors can override TextInputGeometry.Provider to walk visual lines once. + public static TextInputGeometry capture(TextInputClient client) { + if (client instanceof Provider) { + return ((Provider) client).getGeometrySnapshot(); + } + TextInputState state = client.getEditingState(); + int caret = state.getSelectionEnd(); + int first = caret; + int last = caret; + if (client instanceof Component) { + Component c = (Component) client; + int x = c.getAbsoluteX() + c.getScrollX(); + int y = c.getAbsoluteY() + c.getScrollY(); + int[] offsets = new int[]{client.offsetAtPoint(x, y), + client.offsetAtPoint(x + c.getWidth(), y), + client.offsetAtPoint(x, y + c.getHeight()), + client.offsetAtPoint(x + c.getWidth(), y + c.getHeight())}; + for (int offset : offsets) { + first = Math.min(first, offset); + last = Math.max(last, offset); + } + } + first = Math.max(0, first - 1); + last = Math.min(client.getTextLength(), last + 1); + if (last - first > 4096) { + first = Math.max(first, caret - 2048); + last = Math.min(last, first + 4096); + } + int[] points = new int[(last - first + 1) * 5]; + int n = 0; + for (int i = first; i <= last; i++) { + int[] rect = client.rectForOffset(i); + points[n++] = i; + for (int j = 0; j < 4; j++) { + points[n++] = rect[j]; + } + } + return new TextInputGeometry(points, state.getSelectionStart(), caret, + client.selectionRects(state.getSelectionStart(), caret), client.getCaretRect()); + } + + /// Returns the last captured caret rectangle for an offset, or the active caret offscreen. + public int[] rectForOffset(int offset) { + for (int i = 0; i + 4 < carets.length; i += 5) { + if (carets[i] == offset) { + return new int[]{carets[i + 1], carets[i + 2], carets[i + 3], carets[i + 4]}; + } + } + return copy(fallback); + } + + /// Hit-tests only immutable visible geometry. Vertical distance takes priority across lines. + public int offsetAtPoint(int x, int y) { + int best = activeEnd; + long bestY = Long.MAX_VALUE; + long bestX = Long.MAX_VALUE; + for (int i = 0; i + 4 < carets.length; i += 5) { + long dy = Math.max(0, Math.max((long) carets[i + 2] - y, (long) y - carets[i + 2] - Math.max(1, carets[i + 4]) + 1)); + long dx = Math.abs((long) x - carets[i + 1]); + if (dy < bestY || dy == bestY && dx < bestX) { + bestY = dy; + bestX = dx; + best = carets[i]; + } + } + return best; + } + + /// Returns exact captured selection geometry for the active range, and visible character + /// rectangles for other native queries without touching a live editor off the EDT. + public int[] selectionRects(int a, int b) { + int low = Math.min(a, b); + int high = Math.max(a, b); + if (low == high) { + return new int[0]; + } + if (low == start && high == end) { + return copy(selection); + } + int[] out = new int[carets.length]; + int n = 0; + for (int i = 0; i + 9 < carets.length; i += 5) { + if (carets[i] < low || carets[i] >= high) { + continue; + } + int x = carets[i + 1]; + int y = carets[i + 2]; + int h = carets[i + 4]; + int w = carets[i + 3]; + if (carets[i + 7] == y) { + x = Math.min(x, carets[i + 6]); + w = Math.max(w, Math.abs(carets[i + 6] - carets[i + 1])); + } + if (n >= 4 && out[n - 3] == y && out[n - 1] == h && out[n - 4] + out[n - 2] == x) { + out[n - 2] += w; + } else { + out[n++] = x; + out[n++] = y; + out[n++] = w; + out[n++] = h; + } + } + int[] result = new int[n]; + System.arraycopy(out, 0, result, 0, n); + return result; + } +} diff --git a/CodenameOne/src/com/codename1/ui/Toolbar.java b/CodenameOne/src/com/codename1/ui/Toolbar.java index bd62b142c91..a8365581d8d 100644 --- a/CodenameOne/src/com/codename1/ui/Toolbar.java +++ b/CodenameOne/src/com/codename1/ui/Toolbar.java @@ -227,7 +227,7 @@ public class Toolbar extends Container { private Container permanentRightSideMenuContainer; /// The form of a toolbar the desktop {@code native} title-bar mode keeps detached, null otherwise private Form desktopHiddenHost; - /// Side menu commands added while detached, only bridged to the native menu bar + /// Original side menu commands bridged to the desktop native menu bar. private Vector desktopHiddenSideMenuCommands; private Command searchCommand; /// Component placed on the bottom (south) portion of the permanent/on-top @@ -1474,7 +1474,9 @@ private Form getHostForm() { } private boolean addDesktopHiddenSideMenuCommand(Command cmd) { - if (!isDesktopHidden()) { + boolean hidden = isDesktopHidden(); + Form host = getHostForm(); + if (!hidden && (host == null || !host.isDesktopNativeChrome())) { return false; } if (cmd != null) { @@ -1486,17 +1488,19 @@ private boolean addDesktopHiddenSideMenuCommand(Command cmd) { refreshDesktopHiddenNativeCommands(); } } - return true; + // Custom desktop chrome still draws a toolbar. Keep the original command + // for the native menu too: on-top side menus hold wrapped commands in a + // detached container, so harvesting the toolbar tree cannot find them. + return hidden; } /// `Form.initComponentImpl` publishes the commands when the form is shown, this covers later changes. /// Every mutation of the Toolbar's command lists calls it -- the left and right bars and the - /// overflow as well as the side menu -- because with the Toolbar hidden by the desktop chrome - /// the native menu is the only place those commands can be reached. A no-op unless this - /// Toolbar's hidden host form is the one on screen. + /// overflow as well as the side menu. Both native and custom desktop title bars publish + /// these commands. A no-op unless this Toolbar's host form is the one on screen. private void refreshDesktopHiddenNativeCommands() { - Form host = desktopHiddenHost; - if (host != null && host.isInitialized() && host.getParent() == null + Form host = getHostForm(); + if (host != null && host.isDesktopNativeChrome() && host.isInitialized() && host.getParent() == null && Display.getInstance().getCurrent() == host) { //NOPMD CompareObjectsWithEquals Display.impl.setNativeCommands(getAllNativeMenuCommands()); } @@ -2675,6 +2679,243 @@ private void addUniqueCommands(Vector all, Iterable src) { } } + private boolean applyingTitleInsets; + private int[] laidOutTitleInsets; + private boolean nativeTitleBarInsetsActive; + + private int[] titleInsets() { + Form form = getComponentForm(); + int[] insets = isVisible() && form != null && !(form instanceof Dialog) && form.isDesktopToolbarTitle() + ? Display.impl.getNativeWindowTitleBarInsets(this) : null; + // Querying metrics can enable native chrome before paint publishes any + // regions. Remember that activation until hide/removal clears it. + if (insets != null) { + nativeTitleBarInsetsActive = true; + } + return insets; + } + + // Native reserved space is additive and temporary: changing the platform metrics must + // not overwrite an application's theme, units, RTL padding or preferred-size settings. + private final class TitlePadding { + private final Style style = getStyle(); + private final float[] padding = new float[4]; + private final byte[] units; + private final boolean suppressed = style.isSuppressChangeEvents(); + + TitlePadding(int[] insets) { + int[] sides = {LEFT, RIGHT, TOP, BOTTOM}; + byte[] originalUnits = style.getPaddingUnit(); + // Style's unit array uses Component side indices, not padding's + // top/bottom/left/right argument order. Preserve null (pixels) too. + units = originalUnits == null ? null : originalUnits.clone(); + for (int i = 0; i < 4; i++) { + padding[i] = style.getPaddingFloatValue(false, sides[i]); + } + int left = style.getPaddingLeftNoRTL(); + int right = style.getPaddingRightNoRTL(); + int top = style.getPaddingTop(); + int bottom = style.getPaddingBottom(); + style.setSuppressChangeEvents(true); + style.setPaddingUnit(Style.UNIT_TYPE_PIXELS); + style.setPadding(TOP, top + insets[2], true); + style.setPadding(BOTTOM, bottom + insets[3], true); + style.setPadding(LEFT, left + insets[isRTL() ? 1 : 0], true); + style.setPadding(RIGHT, right + insets[isRTL() ? 0 : 1], true); + style.setSuppressChangeEvents(suppressed); + } + + void restore() { + int[] sides = {LEFT, RIGHT, TOP, BOTTOM}; + style.setSuppressChangeEvents(true); + for (int i = 0; i < 4; i++) { + style.setPadding(sides[i], padding[i], true); + } + style.setPaddingUnit(units); + style.setSuppressChangeEvents(suppressed); + } + } + + @Override + void doLayout() { + int[] insets = applyingTitleInsets ? null : titleInsets(); + if (insets == null) { + laidOutTitleInsets = null; + super.doLayout(); + return; + } + applyingTitleInsets = true; + TitlePadding padding = new TitlePadding(insets); + try { + super.doLayout(); + laidOutTitleInsets = insets; + } finally { + padding.restore(); + applyingTitleInsets = false; + } + } + + @Override + protected com.codename1.ui.geom.Dimension calcPreferredSize() { + int[] insets = applyingTitleInsets ? null : titleInsets(); + if (insets == null) { + return super.calcPreferredSize(); + } + applyingTitleInsets = true; + TitlePadding padding = new TitlePadding(insets); + try { + com.codename1.ui.geom.Dimension size = super.calcPreferredSize(); + return new com.codename1.ui.geom.Dimension(size.getWidth(), Math.max(size.getHeight(), insets[4])); + } finally { + padding.restore(); + applyingTitleInsets = false; + } + } + + private int[] nativeTitleBarRegions; + + /// Marks a custom title component's background as eligible for window dragging. + /// Plain Label and Container instances are eligible by default; subclasses and + /// other components keep their pointer input unless explicitly opted in here. + /// Focusable controls, pointer/long-press listeners and interactive descendants + /// remain excluded. Set false to keep even a plain label's background interactive. + /// Call on the EDT. Has no effect outside an integrated desktop title toolbar. + /// + /// @param component the title component to configure + /// @param dragRegion true for a passive background that can drag the window + public static void setTitleBarDragRegion(Component component, boolean dragRegion) { + component.putClientProperty("Toolbar.titleBarDragRegion", dragRegion ? Boolean.TRUE : Boolean.FALSE); + Component ancestor = component; + while (ancestor != null && !(ancestor instanceof Toolbar)) { + ancestor = ancestor.getParent(); + } + // Regions are published by Toolbar.paint(), not by a child's independent repaint. + if (ancestor != null) { + ancestor.repaint(); + } else { + component.repaint(); + } + } + + private boolean isPassiveTitleBarComponent(Component component) { + Object configured = component.getClientProperty("Toolbar.titleBarDragRegion"); + if (configured != null) { + return Boolean.TRUE.equals(configured); + } + // Without reflection there is no portable way to detect overridden pointer + // methods. Only known passive classes opt in implicitly; subclasses are safe. + return Label.class.equals(component.getClass()) || Container.class.equals(component.getClass()); + } + + // Native hit testing must see the actual laid-out controls, including disabled + // buttons. A disabled Save button is not an unexpected window-drag handle. + private void collectTitleBarControls(Container parent, Vector controls) { + for (Component child : parent) { + if (!child.isVisible()) { + continue; + } + if (!isPassiveTitleBarComponent(child) || child instanceof Button || child.isFocusable() || child.isGrabsPointerEvents() + || child.isDraggable() || child.isScrollable() || child.getLeadComponent() != null + || child.pointerPressedListeners != null && child.pointerPressedListeners.hasListeners() + || child.pointerReleasedListeners != null && child.pointerReleasedListeners.hasListeners() + || child.pointerDraggedListeners != null && child.pointerDraggedListeners.hasListeners() + || child.longPressListeners != null && child.longPressListeners.hasListeners() + || child.contextMenuListeners != null && child.contextMenuListeners.hasListeners() + || child.stylusListeners != null && child.stylusListeners.hasListeners() + || child.getContextMenuCommands() != null && child.getContextMenuCommands().length > 0) { + controls.addElement(child); + } else if (child instanceof Container) { + collectTitleBarControls((Container) child, controls); + } + } + } + + int[] titleBarRegions() { + Vector controls = new Vector(); + collectTitleBarControls(this, controls); + int[] regions = new int[(controls.size() + 1) * 4]; + regions[0] = getAbsoluteX(); + regions[1] = getAbsoluteY(); + regions[2] = getWidth(); + regions[3] = getHeight(); + for (int i = 0; i < controls.size(); i++) { + Component c = controls.elementAt(i); + int offset = (i + 1) * 4; + regions[offset] = c.getAbsoluteX(); + regions[offset + 1] = c.getAbsoluteY(); + regions[offset + 2] = c.getWidth(); + regions[offset + 3] = c.getHeight(); + } + return regions; + } + + boolean isTitleBarDragPoint(int x, int y) { + if (!isVisible()) { + return false; + } + int[] regions = titleBarRegions(); + for (int i = 0; i < regions.length; i += 4) { + boolean inside = x >= regions[i] && y >= regions[i + 1] + && x < regions[i] + regions[i + 2] && y < regions[i + 1] + regions[i + 3]; + if (i == 0 && !inside || i > 0 && inside) { + return false; + } + } + return true; + } + + private void clearNativeTitleBarRegions() { + if (nativeTitleBarInsetsActive || nativeTitleBarRegions != null) { + Display.impl.setNativeWindowTitleBarRegions(this, new int[0]); + nativeTitleBarRegions = null; + nativeTitleBarInsetsActive = false; + } + } + + @Override + public void setVisible(boolean visible) { + if (!visible) { + clearNativeTitleBarRegions(); + } + super.setVisible(visible); + } + + @Override + public void paint(Graphics g) { + int[] insets = titleInsets(); + boolean changedInsets = (insets == null) != (laidOutTitleInsets == null); + if (insets != null && laidOutTitleInsets != null) { + for (int i = 0; i < 5; i++) { + changedInsets |= insets[i] != laidOutTitleInsets[i]; + } + } + if (changedInsets) { + setShouldCalcPreferredSize(true); + revalidateLater(); + } + Form parent = getComponentForm(); + if (isVisible() && parent != null && !(parent instanceof Dialog) && parent.isDesktopToolbarTitle()) { + int[] regions = titleBarRegions(); + boolean changed = nativeTitleBarRegions == null || nativeTitleBarRegions.length != regions.length; + for (int i = 0; !changed && i < regions.length; i++) { + changed = regions[i] != nativeTitleBarRegions[i]; + } + if (changed) { + nativeTitleBarRegions = regions; + Display.impl.setNativeWindowTitleBarRegions(this, regions); + } + } else { + clearNativeTitleBarRegions(); + } + super.paint(g); + } + + @Override + protected void deinitialize() { + clearNativeTitleBarRegions(); + super.deinitialize(); + } + /// Wires the visible Toolbar to act as the draggable title bar of an undecorated desktop window /// (the {@code custom} desktop title-bar mode). The listener is registered at the form level (so /// it fires reliably for every pointer drag) and starts a window move only when the press falls @@ -2708,9 +2949,13 @@ private static final class ToolbarWindowDrag implements ActionListener { @Override public void actionPerformed(ActionEvent evt) { + if (!toolbar.isVisible()) { + dragging = false; + return; + } ActionEvent.Type t = evt.getEventType(); if (t == ActionEvent.Type.PointerPressed) { - dragging = toolbar.isWithinTitleBarBand(evt.getY()); + dragging = toolbar.isTitleBarDragPoint(evt.getX(), evt.getY()); if (dragging) { Display.impl.startNativeWindowDrag(evt.getX(), evt.getY()); } diff --git a/CodenameOne/src/com/codename1/ui/Window.java b/CodenameOne/src/com/codename1/ui/Window.java index a99754df1df..d3afd91e402 100644 --- a/CodenameOne/src/com/codename1/ui/Window.java +++ b/CodenameOne/src/com/codename1/ui/Window.java @@ -4401,7 +4401,11 @@ private void applyKeyInputScope() { Container scope = keyInputScopes == null ? null : keyInputScopes.get(keyInputScopes.size() - 1); this.keyInputScope = scope; - if (scope != null && (focused == null || !scope.contains(focused))) { + // A dialog installs its scope before show() creates the native peer. + // Starting an editor here would bind its input session to the main + // window, since this window has no native surface yet. show() initializes + // focus once the peer and component hierarchy are ready. + if (nativePeer != null && scope != null && (focused == null || !scope.contains(focused))) { focused = null; initFocused(); } diff --git a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityManager.java b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityManager.java index 0270b2ee6ee..3ada33db01a 100644 --- a/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityManager.java +++ b/CodenameOne/src/com/codename1/ui/accessibility/AccessibilityManager.java @@ -1322,7 +1322,12 @@ public boolean perform(Component source, Object argument) { if (!source.isEnabled()) { return false; } - source.keyReleased(Display.getInstance().getKeyCode(Display.GAME_FIRE)); + // Touch-only ports need not map a hardware GAME_FIRE key. Invoke + // the button's semantic action, including CheckBox/RadioButton + // state changes, without routing it through keyboard translation. + Button button = (Button) source; + button.pressed(); + button.released(); return true; } } @@ -1341,9 +1346,7 @@ public boolean perform(Component source, Object argument) { if (!list.isEnabled()) { return false; } - list.setSelectedIndex(index); - list.keyReleased(Display.getInstance().getKeyCode(Display.GAME_FIRE)); - return true; + return list.accessibilityActivateItem(index); } } @@ -1370,6 +1373,9 @@ public boolean perform(Component source, Object argument) { if (selected == list.getSelectedIndex()) { return false; } + if (list instanceof com.codename1.ui.ComboBox) { + return list.accessibilityActivateItem(selected); + } list.setSelectedIndex(selected); return true; } diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index 842653afcbc..0671e450fcc 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -46,7 +46,7 @@ /// This class provides the shared editing behavior. Feature layers (syntax highlighting, the gutter, /// styled rich text) subclass it and override `#paintContent(Graphics, int, int, int, int)` and the /// document/measurement hooks. -public class EditorView extends Component implements TextInputClient { +public class EditorView extends Component implements TextInputClient, com.codename1.ui.TextInputGeometry.Provider { private EditorHost host; private final boolean codeMode; private EditorDocument doc = new EditorDocument(); @@ -353,7 +353,14 @@ protected int contentWidth() { } private int contentHeight() { - return getHeight() - getStyle().getPaddingTop() - getStyle().getPaddingBottom(); + int visibleHeight = getHeight(); + TopLevelContainer top = getTopLevelContainer(); + if (top != null && top.getInvisibleAreaUnderVKB() > 0) { + int keyboardTop = top.asContainer().getAbsoluteY() + top.asContainer().getHeight() + - top.getInvisibleAreaUnderVKB(); + visibleHeight = Math.min(visibleHeight, Math.max(0, keyboardTop - getAbsoluteY())); + } + return visibleHeight - getStyle().getPaddingTop() - getStyle().getPaddingBottom(); } // ---- selection helpers ---- @@ -2063,6 +2070,75 @@ public TextInputState getEditingState() { return new TextInputState(doc.getText(), getSelectionStart(), getSelectionEnd(), composingStart, composingEnd); } + /// Measures all insertion positions with one bulk request per directional run. Subclasses + /// with styled runs or inline objects override this alongside measureColumnX(). + protected int[] measureLineCaretPositions(int line, String text, Font font) { + if (BidiUtil.isTrivialLtr(text, isRTL())) { + return font.getTextRunPositions(text, false); + } + int[] positions = new int[text.length() + 1]; + boolean[] assigned = new boolean[positions.length]; + for (int[] run : bidiRuns(text, font)) { + int[] runPositions = font.getTextRunPositions(text.substring(run[0], run[1]), (run[2] & 1) != 0); + for (int i = 0; i < runPositions.length; i++) { + int column = run[0] + i; + if (!assigned[column]) { + positions[column] = run[3] + runPositions[i]; + assigned[column] = true; + } + } + } + return positions; + } + + @Override + public com.codename1.ui.TextInputGeometry getGeometrySnapshot() { + Font font = getEditorFont(); + int first = lineAtContentY(scrollY); + int last = Math.min(doc.getLineCount() - 1, lineAtContentY(scrollY + Math.max(0, contentHeight()))); + java.util.List lines = new java.util.ArrayList(); + int size = 0; + int left = getAbsoluteX(); + int right = left + getWidth(); + for (int line = first; line <= last; line++) { + String text = doc.getLineText(line); + int[] positions = measureLineCaretPositions(line, text, font); + int x = left + getContentLeftInset() + lineContentOffsetX(line) - scrollX; + int y = getAbsoluteY() + getStyle().getPaddingTop() + lineTopContent(line) - scrollY; + int height = lineHeightAt(line); + int start = doc.getLineStart(line); + int[] points = new int[positions.length * 5]; + int count = 0; + for (int col = 0; col < positions.length; col++) { + int px = x + positions[col]; + // Keep visible positions and the endpoints of a character crossing either edge. + int previous = x + positions[Math.max(0, col - 1)]; + int next = x + positions[Math.min(positions.length - 1, col + 1)]; + if (Math.max(px, Math.max(previous, next)) < left + || Math.min(px, Math.min(previous, next)) > right) { + continue; + } + points[count++] = start + col; + points[count++] = px; + points[count++] = y; + points[count++] = Math.max(1, font.getHeight() / 16); + points[count++] = height; + } + int[] visible = new int[count]; + System.arraycopy(points, 0, visible, 0, count); + lines.add(visible); + size += count; + } + int[] points = new int[size]; + int at = 0; + for (int[] line : lines) { + System.arraycopy(line, 0, points, at, line.length); + at += line.length; + } + return new com.codename1.ui.TextInputGeometry(points, getSelectionStart(), getSelectionEnd(), + selectionRects(getSelectionStart(), getSelectionEnd(), first, last), getCaretRect()); + } + @Override public int[] getCaretRect() { return rectForOffset(caret); @@ -2102,6 +2178,10 @@ public String getTextRange(int start, int end) { @Override public int[] selectionRects(int start, int end) { + return selectionRects(start, end, 0, doc.getLineCount() - 1); + } + + private int[] selectionRects(int start, int end, int firstVisibleLine, int lastVisibleLine) { start = doc.clamp(start); end = doc.clamp(end); if (start > end) { @@ -2113,7 +2193,7 @@ public int[] selectionRects(int start, int end) { int firstLine = doc.lineOfOffset(start); int lastLine = doc.lineOfOffset(end); java.util.List rects = new java.util.ArrayList(); - for (int ln = firstLine; ln <= lastLine; ln++) { + for (int ln = Math.max(firstLine, firstVisibleLine); ln <= Math.min(lastLine, lastVisibleLine); ln++) { int ls = doc.getLineStart(ln); int le = doc.getLineEnd(ln); String text = doc.getLineText(ln); diff --git a/CodenameOne/src/com/codename1/ui/editor/RichView.java b/CodenameOne/src/com/codename1/ui/editor/RichView.java index 677aea25b9b..eb3e7a7f807 100644 --- a/CodenameOne/src/com/codename1/ui/editor/RichView.java +++ b/CodenameOne/src/com/codename1/ui/editor/RichView.java @@ -618,7 +618,7 @@ private int styledWidth(String text, int lineStart, int from, int to, RichBlocks } TextStyle st = inline.styleAt(lineStart + from); Font rf = fontFor(st.isBold() || isHeading(b.type), st.isItalic(), runPx(b.type, st.getFontSizeLevel(), st.getFontSizePx())); - return rf.stringWidth(text.substring(from, to)); + return rf.substringWidth(text, from, to - from); } private int atomWidth(String text, int lineStart, int[] atom, RichBlocks.BlockAttr b) { @@ -650,6 +650,33 @@ private int[][] richVisualAtoms(int line, String text, int lineStart) { return out; } + @Override + protected int[] measureLineCaretPositions(int line, String text, Font font) { + int start = getDocument().getLineStart(line); + RichBlocks.BlockAttr block = blocks.get(line); + int[] positions = new int[text.length() + 1]; + boolean[] assigned = new boolean[positions.length]; + for (int[] atom : richVisualAtoms(line, text, start)) { + int[] run; + if (atom[3] == 1) { + run = (atom[2] & 1) == 0 ? new int[]{0, atom[5]} : new int[]{atom[5], 0}; + } else { + TextStyle style = inline.styleAt(start + atom[0]); + Font runFont = fontFor(style.isBold() || isHeading(block.type), style.isItalic(), + runPx(block.type, style.getFontSizeLevel(), style.getFontSizePx())); + run = runFont.getTextRunPositions(text.substring(atom[0], atom[1]), (atom[2] & 1) != 0); + } + for (int i = 0; i < run.length; i++) { + int column = atom[0] + i; + if (!assigned[column]) { + positions[column] = atom[4] + run[i]; + assigned[column] = true; + } + } + } + return positions; + } + @Override protected int measureColumnX(int line, String text, int col, Font f) { int lineStart = getDocument().getLineStart(line); @@ -691,7 +718,7 @@ protected int measureColumnX(int line, String text, int col, Font f) { runEnd++; } Font rf = fontFor(st.isBold() || isHeading(b.type), st.isItalic(), runPx(b.type, st.getFontSizeLevel(), st.getFontSizePx())); - x += rf.stringWidth(text.substring(c, runEnd)); + x += rf.substringWidth(text, c, runEnd - c); c = runEnd; } return x; diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_chrome.h b/Ports/LinuxPort/nativeSources/cn1_linux_chrome.h new file mode 100644 index 00000000000..2bbab7a6e54 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_chrome.h @@ -0,0 +1,225 @@ +/* + * 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 Codename One 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. + */ + + +/* Included by cn1_linux_window.c. GTK owns the header, decorations and controls. + * The custom-title drawing area shows the Toolbar slice of the same back buffer + * as the body. No independent Java component tree or synthetic window controls. */ +static gboolean cn1TitleDragPoint(GtkWidget* widget, double eventX, double eventY) { + int x = cn1TitleEventX(widget, eventX), y = cn1TitleEventY(widget, eventY); + gboolean drag = cn1TitleRegionCount >= 4; + for (int i = 0; drag && i < cn1TitleRegionCount; i += 4) { + int* r = cn1TitleRegions + i; + gboolean inside = x >= r[0] && y >= r[1] && x - r[0] < r[2] && y - r[1] < r[3]; + if ((i == 0 && !inside) || (i > 0 && inside)) drag = FALSE; + } + return drag; +} + +// Decide ownership at touch-begin and retain it across boundaries until end/cancel. +// Returning FALSE lets GtkWindow's native gestures handle blank caption touches. +static GHashTable* cn1TitleJavaTouches; +static gboolean cn1TitleTouch(GtkWidget* widget, GdkEventTouch* event, gpointer data) { + if (event->type == GDK_TOUCH_BEGIN) { + if (cn1TitleDragPoint(widget, event->x, event->y)) return FALSE; + if (!cn1TitleJavaTouches) cn1TitleJavaTouches = g_hash_table_new(g_direct_hash, g_direct_equal); + g_hash_table_add(cn1TitleJavaTouches, event->sequence); + } + if (!cn1TitleJavaTouches || !g_hash_table_contains(cn1TitleJavaTouches, event->sequence)) return FALSE; + gboolean handled = cn1OnTouch(widget, event, data); + if (event->type == GDK_TOUCH_END || event->type == GDK_TOUCH_CANCEL) { + g_hash_table_remove(cn1TitleJavaTouches, event->sequence); + if (g_hash_table_size(cn1TitleJavaTouches) == 0) { + g_hash_table_destroy(cn1TitleJavaTouches); + cn1TitleJavaTouches = NULL; + } + } + return handled; +} + +static int cn1TitlePointerButtons; +static gboolean cn1TitleButton(GtkWidget* widget, GdkEventButton* event, gpointer data) { + int button = cn1LinuxButtonMask(event->button); + // GTK's implicit grab keeps releases on this canvas even if the pointer + // leaves the control. Finish the Java gesture before considering native drag. + if (event->type == GDK_BUTTON_RELEASE && (cn1TitlePointerButtons & button)) { + cn1TitlePointerButtons &= ~button; + return cn1OnButton(widget, event, data); + } + gboolean drag = cn1TitleDragPoint(widget, event->x, event->y); + // Propagation lets GtkWindow apply gtk-titlebar-{double,middle,right}-click + // actions and compositor move tracking, on both X11 and Wayland. + if (drag) return FALSE; + if (event->type == GDK_2BUTTON_PRESS || event->type == GDK_3BUTTON_PRESS) return TRUE; + if (event->type == GDK_BUTTON_PRESS) cn1TitlePointerButtons |= button; + return cn1OnButton(widget, event, data); +} +static void cn1TitleAllocated(GtkWidget* widget, GtkAllocation* allocation, gpointer data) { + (void)widget; (void)allocation; (void)data; + if (!cn1TitleHeader) return; + int oldHeight = cn1TitleHeight, oldX = cn1TitleX, oldY = cn1TitleY; + cn1TitleHeight = gtk_widget_get_allocated_height(cn1TitleHeader); + gtk_widget_translate_coordinates(cn1TitleCanvas, cn1TitleHeader, 0, 0, &cn1TitleX, &cn1TitleY); + if (oldHeight != cn1TitleHeight || oldX != cn1TitleX || oldY != cn1TitleY) { + GdkEventConfigure size = {0}; + size.width = gtk_widget_get_allocated_width(cn1DrawingArea); + size.height = gtk_widget_get_allocated_height(cn1DrawingArea); + cn1OnConfigure(cn1DrawingArea, &size, NULL); + // Width and total height can stay the same when the OS moves its buttons. + cn1LinuxPushEvent(CN1_EVENT_SIZE_CHANGED, cn1DisplayWidth, cn1DisplayHeight, 0); + } +} +static void cn1TitleEnable(gboolean enabled) { + if (!cn1Window || (!!cn1TitleHeader == !!enabled)) return; + cn1TitlePointerButtons = 0; + gboolean visible = gtk_widget_get_visible(cn1Window); + // GTK requires titlebar changes before mapping for some window managers. + if (visible) gtk_widget_hide(cn1Window); + if (gtk_widget_get_realized(cn1Window)) gtk_widget_unrealize(cn1Window); + if (enabled) { + cn1TitleHeader = gtk_header_bar_new(); + gtk_header_bar_set_show_close_button(GTK_HEADER_BAR(cn1TitleHeader), TRUE); + gtk_header_bar_set_has_subtitle(GTK_HEADER_BAR(cn1TitleHeader), FALSE); + // Do not override decoration-layout: GTK follows gtk-decoration-layout, + // including desktops that put controls on the left or omit minimize. + cn1TitleCanvas = gtk_drawing_area_new(); + gtk_widget_set_hexpand(cn1TitleCanvas, TRUE); + gtk_widget_set_size_request(cn1TitleCanvas, 1, 1); + gtk_widget_set_can_focus(cn1TitleCanvas, TRUE); + gtk_widget_set_events(cn1TitleCanvas, gtk_widget_get_events(cn1DrawingArea)); + GtkWidget* overlay = gtk_overlay_new(); + gtk_container_add(GTK_CONTAINER(overlay), cn1TitleCanvas); + cn1TitleFixed = gtk_fixed_new(); + gtk_overlay_add_overlay(GTK_OVERLAY(overlay), cn1TitleFixed); + // Pass through only the empty host. GDK still hit-tests non-pass-through + // child windows (GtkEntry, WebKit, etc.), so native peers keep pointer input. + // Disabling this would let the full-size host swallow blank caption gestures. + // https://docs.gtk.org/gdk3/method.Window.set_pass_through.html + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(overlay), cn1TitleFixed, TRUE); + cn1TitleAccessibilityFixed = gtk_fixed_new(); + gtk_widget_set_opacity(cn1TitleAccessibilityFixed, 0.01); + gtk_overlay_add_overlay(GTK_OVERLAY(overlay), cn1TitleAccessibilityFixed); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(overlay), cn1TitleAccessibilityFixed, TRUE); + gtk_header_bar_set_custom_title(GTK_HEADER_BAR(cn1TitleHeader), overlay); + gtk_widget_set_valign(overlay, GTK_ALIGN_FILL); + gtk_widget_set_vexpand(overlay, TRUE); + gtk_window_set_titlebar(GTK_WINDOW(cn1Window), cn1TitleHeader); + g_signal_connect(cn1TitleCanvas, "draw", G_CALLBACK(cn1OnDraw), NULL); + g_signal_connect(cn1TitleCanvas, "button-press-event", G_CALLBACK(cn1TitleButton), NULL); + g_signal_connect(cn1TitleCanvas, "button-release-event", G_CALLBACK(cn1TitleButton), NULL); + g_signal_connect(cn1TitleCanvas, "motion-notify-event", G_CALLBACK(cn1OnMotion), NULL); + g_signal_connect(cn1TitleCanvas, "leave-notify-event", G_CALLBACK(cn1OnLeave), NULL); + g_signal_connect(cn1TitleCanvas, "touch-event", G_CALLBACK(cn1TitleTouch), NULL); + g_signal_connect(cn1TitleCanvas, "scroll-event", G_CALLBACK(cn1OnScroll), NULL); + g_signal_connect(cn1TitleCanvas, "event", G_CALLBACK(cn1OnGenericEvent), NULL); + g_signal_connect(cn1TitleCanvas, "size-allocate", G_CALLBACK(cn1TitleAllocated), NULL); + gtk_widget_show_all(cn1TitleHeader); + int minimum, natural; + gtk_widget_get_preferred_height(cn1TitleHeader, &minimum, &natural); + cn1TitleHeight = natural; + } else { + if (cn1TitleJavaTouches) { + if (g_hash_table_contains(cn1TitleJavaTouches, cn1TouchSeq)) cn1TouchSeq = NULL; + g_hash_table_destroy(cn1TitleJavaTouches); + cn1TitleJavaTouches = NULL; + } + // Destroying a titlebar must not destroy native peers still owned by Java. + GList* children = gtk_container_get_children(GTK_CONTAINER(cn1TitleFixed)); + for (GList* child = children; child; child = child->next) { + GtkWidget* peer = GTK_WIDGET(child->data); + int x, y; + gtk_container_child_get(GTK_CONTAINER(cn1TitleFixed), peer, "x", &x, "y", &y, NULL); + g_object_ref(peer); + gtk_container_remove(GTK_CONTAINER(cn1TitleFixed), peer); + gtk_fixed_put(GTK_FIXED(cn1Fixed), peer, x + cn1TitleX, y + cn1TitleY); + g_object_unref(peer); + } + g_list_free(children); + // Accessibility entries keep widget pointers until the next tree update. + // Keep those widgets alive too when changing the native title mode. + children = gtk_container_get_children(GTK_CONTAINER(cn1TitleAccessibilityFixed)); + for (GList* child = children; child; child = child->next) { + GtkWidget* peer = GTK_WIDGET(child->data); + int x, y; + gtk_container_child_get(GTK_CONTAINER(cn1TitleAccessibilityFixed), peer, "x", &x, "y", &y, NULL); + g_object_ref(peer); + gtk_container_remove(GTK_CONTAINER(cn1TitleAccessibilityFixed), peer); + if (cn1AccessibilityFixed) + gtk_fixed_put(GTK_FIXED(cn1AccessibilityFixed), peer, x + cn1TitleX, y + cn1TitleY); + g_object_unref(peer); + } + g_list_free(children); + cn1TitleHeight = cn1TitleX = cn1TitleY = 0; + cn1TitleAccessibilityFixed = NULL; + cn1TitleHeader = cn1TitleCanvas = cn1TitleFixed = NULL; + gtk_window_set_titlebar(GTK_WINDOW(cn1Window), NULL); + } + if (visible) gtk_widget_show(cn1Window); +} +static void cn1TitleMetrics(void* arg) { + int* out = (int*)arg; + if (!cn1Window) return; + cn1TitleEnable(TRUE); + GtkAllocation header, content; + gtk_widget_get_allocation(cn1TitleHeader, &header); + gtk_widget_get_allocation(cn1TitleCanvas, &content); + int minimum, natural; + gtk_widget_get_preferred_height(cn1TitleHeader, &minimum, &natural); + if (header.width > 1 && content.width > 1) { + out[0] = MAX(0, cn1TitleX); + out[1] = MAX(0, header.width - cn1TitleX - content.width); + out[2] = MAX(0, cn1TitleY); + out[3] = MAX(0, header.height - cn1TitleY - content.height); + } + out[4] = natural; +} +typedef struct { int count; int* regions; } CN1TitleUpdate; +static gboolean cn1TitleUpdate(gpointer data) { + CN1TitleUpdate* update = (CN1TitleUpdate*)data; + free(cn1TitleRegions); + cn1TitleRegions = update->regions; + cn1TitleRegionCount = update->count; + cn1TitleEnable(update->count >= 4); + if (cn1TitleHeader) gtk_widget_set_size_request(cn1TitleHeader, -1, update->regions[3]); + free(update); + return G_SOURCE_REMOVE; +} +JAVA_VOID com_codename1_impl_linux_LinuxNative_mainWindowTitleBarInsets___int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT array) { + if (array == JAVA_NULL || ((JAVA_ARRAY)array)->length < 5) return; + int result[5] = {0}; + cn1LinuxRunOnMainAndWait(cn1TitleMetrics, result); + memcpy(CN1_ARRAY_DATA(array), result, sizeof(result)); +} +JAVA_VOID com_codename1_impl_linux_LinuxNative_mainWindowTitleBarRegions___int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT array) { + int count = array == JAVA_NULL ? 0 : ((JAVA_ARRAY)array)->length; + if (count % 4 != 0) return; + CN1TitleUpdate* update = (CN1TitleUpdate*)calloc(1, sizeof(CN1TitleUpdate)); + if (!update) return; + update->count = count; + update->regions = count ? (int*)malloc((size_t)count * sizeof(int)) : NULL; + if (count && !update->regions) { free(update); return; } + if (count) memcpy(update->regions, CN1_ARRAY_DATA(array), (size_t)count * sizeof(int)); + gdk_threads_add_idle(cn1TitleUpdate, update); +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c index 4a49a0371a2..6c1a7283e7e 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c @@ -290,6 +290,21 @@ static GtkWidget* cn1Window = 0; /* Set by initDisplay before publishing the GTK window. */ static pthread_t cn1GtkThread; static GtkWidget* cn1DrawingArea = 0; +static GtkWidget* cn1TitleHeader; +static GtkWidget* cn1TitleCanvas; +static GtkWidget* cn1TitleFixed; +static GtkWidget* cn1TitleAccessibilityFixed; +static int cn1TitleHeight; +static int cn1TitleX; +static int cn1TitleY; +static int* cn1TitleRegions; +static int cn1TitleRegionCount; +static int cn1TitleEventX(GtkWidget* widget, double x) { + return (int)x + (widget == cn1TitleCanvas ? cn1TitleX : 0); +} +static int cn1TitleEventY(GtkWidget* widget, double y) { + return (int)y + (widget == cn1TitleCanvas ? cn1TitleY : cn1TitleHeight); +} static GtkWidget* cn1Overlay = 0; /* GtkOverlay: drawing area + native widget layer */ static GtkWidget* cn1RootBox = 0; /* GtkBox: optional menu bar above the overlay */ static GtkWidget* cn1MenuBar = 0; /* the native menu bar, when commands published one */ @@ -344,6 +359,18 @@ static GtkWidget* cn1LinuxOverlayHost(int slot) { * must run on the GTK main thread (callers marshal via gdk_threads_add_idle). */ void cn1LinuxOverlayAdd(int slot, GtkWidget* w, int x, int y, int width, int height) { GtkWidget* host = cn1LinuxOverlayHost(slot); + if (slot == CN1_MAIN_WINDOW_SLOT) { + // The header overlay clips to its allocation. Spanning peers belong in + // the body overlay so their document portion remains visible. + if (cn1TitleHeader && y >= 0 && y < cn1TitleHeight + && height >= 0 && height <= cn1TitleHeight - y) { + host = cn1TitleFixed; + x -= cn1TitleX; + y -= cn1TitleY; + } else { + y -= cn1TitleHeight; + } + } if (host == 0 || w == 0) { return; } @@ -354,16 +381,34 @@ void cn1LinuxOverlayAdd(int slot, GtkWidget* w, int x, int y, int width, int hei void cn1LinuxOverlayMove(int slot, GtkWidget* w, int x, int y, int width, int height) { GtkWidget* host = cn1LinuxOverlayHost(slot); + if (slot == CN1_MAIN_WINDOW_SLOT) { + if (cn1TitleHeader && y >= 0 && y < cn1TitleHeight + && height >= 0 && height <= cn1TitleHeight - y) { + host = cn1TitleFixed; + x -= cn1TitleX; + y -= cn1TitleY; + } else { + y -= cn1TitleHeight; + } + } if (host == 0 || w == 0) { return; } gtk_widget_set_size_request(w, width, height); - gtk_fixed_move(GTK_FIXED(host), w, x, y); + if (gtk_widget_get_parent(w) != host) { + g_object_ref(w); + if (gtk_widget_get_parent(w)) gtk_container_remove(GTK_CONTAINER(gtk_widget_get_parent(w)), w); + gtk_fixed_put(GTK_FIXED(host), w, x, y); + g_object_unref(w); + } else { + gtk_fixed_move(GTK_FIXED(host), w, x, y); + } } void cn1LinuxOverlayRemove(int slot, GtkWidget* w) { - GtkWidget* host = cn1LinuxOverlayHost(slot); - if (host != 0 && w != 0 && gtk_widget_get_parent(w) == host) { + GtkWidget* host = w ? gtk_widget_get_parent(w) : NULL; + (void)slot; + if (host != 0 && GTK_IS_CONTAINER(host)) { gtk_container_remove(GTK_CONTAINER(host), w); } } @@ -473,7 +518,9 @@ static gboolean cn1OnDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { * blit. */ pthread_mutex_lock(&cn1BufferLock); if (cn1WindowG.surface) { - cairo_set_source_surface(cr, cn1WindowG.surface, 0, 0); + cairo_set_source_surface(cr, cn1WindowG.surface, + widget == cn1TitleCanvas ? -cn1TitleX : 0, + widget == cn1TitleCanvas ? -cn1TitleY : -cn1TitleHeight); cairo_paint(cr); } pthread_mutex_unlock(&cn1BufferLock); @@ -483,9 +530,9 @@ static gboolean cn1OnDraw(GtkWidget* widget, cairo_t* cr, gpointer data) { static gboolean cn1OnConfigure(GtkWidget* widget, GdkEventConfigure* e, gpointer data) { (void) widget; (void) data; - if (e->width != cn1DisplayWidth || e->height != cn1DisplayHeight) { + if (e->width != cn1DisplayWidth || e->height + cn1TitleHeight != cn1DisplayHeight) { cn1DisplayWidth = e->width; - cn1DisplayHeight = e->height; + cn1DisplayHeight = e->height + cn1TitleHeight; /* 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); @@ -556,7 +603,7 @@ static gboolean cn1OnButton(GtkWidget* widget, GdkEventButton* e, gpointer data) return TRUE; } cn1LinuxPushEvent(e->type == GDK_BUTTON_PRESS ? CN1_EVENT_POINTER_PRESSED : CN1_EVENT_POINTER_RELEASED, - (int) e->x, (int) e->y, cn1LinuxButtonMask(e->button) | cn1LinuxPointerSourceFlag((GdkEvent*) e)); + cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), cn1LinuxButtonMask(e->button) | cn1LinuxPointerSourceFlag((GdkEvent*) e)); return TRUE; } @@ -568,7 +615,7 @@ static gboolean cn1OnMotion(GtkWidget* widget, GdkEventMotion* e, gpointer data) } int mask = cn1LinuxStateMask(e->state); if (mask != 0) { - cn1LinuxPushEvent(CN1_EVENT_POINTER_DRAGGED, (int) e->x, (int) e->y, + cn1LinuxPushEvent(CN1_EVENT_POINTER_DRAGGED, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), mask | cn1LinuxPointerSourceFlag((GdkEvent*) e)); } else { /* No button held: this is hover, and it used to be dropped here. @@ -576,7 +623,7 @@ static gboolean cn1OnMotion(GtkWidget* widget, GdkEventMotion* e, gpointer data) * nothing else to fire it, so every hover rule in a desktop theme was * inert. Droppable rather than protected: a lost hover costs nothing * because hover is idempotent and the next motion re-establishes it. */ - cn1LinuxPushEvent(CN1_EVENT_POINTER_HOVER, (int) e->x, (int) e->y, + cn1LinuxPushEvent(CN1_EVENT_POINTER_HOVER, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), cn1LinuxPointerSourceFlag((GdkEvent*) e)); } return TRUE; @@ -613,13 +660,13 @@ static gboolean cn1OnTouch(GtkWidget* widget, GdkEventTouch* e, gpointer data) { case GDK_TOUCH_BEGIN: if (cn1TouchSeq == NULL) { cn1TouchSeq = e->sequence; - cn1LinuxPushEvent(CN1_EVENT_POINTER_PRESSED, (int) e->x, (int) e->y, + cn1LinuxPushEvent(CN1_EVENT_POINTER_PRESSED, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), CN1_PE_MASK_PRIMARY | CN1_PE_TOUCH_FLAG); } return TRUE; case GDK_TOUCH_UPDATE: if (e->sequence == cn1TouchSeq) { - cn1LinuxPushEvent(CN1_EVENT_POINTER_DRAGGED, (int) e->x, (int) e->y, + cn1LinuxPushEvent(CN1_EVENT_POINTER_DRAGGED, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), CN1_PE_MASK_PRIMARY | CN1_PE_TOUCH_FLAG); } return TRUE; @@ -627,7 +674,7 @@ static gboolean cn1OnTouch(GtkWidget* widget, GdkEventTouch* e, gpointer data) { case GDK_TOUCH_CANCEL: if (e->sequence == cn1TouchSeq) { cn1TouchSeq = NULL; - cn1LinuxPushEvent(CN1_EVENT_POINTER_RELEASED, (int) e->x, (int) e->y, + cn1LinuxPushEvent(CN1_EVENT_POINTER_RELEASED, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), CN1_PE_MASK_PRIMARY | CN1_PE_TOUCH_FLAG); } return TRUE; @@ -664,7 +711,7 @@ static gboolean cn1OnKey(GtkWidget* widget, GdkEventKey* e, gpointer data) { * is torn down GTK clears the toplevel focus to NULL, so CN1 keys resume. */ if (cn1Window != 0) { GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(cn1Window)); - if (focus != 0 && focus != cn1DrawingArea) { + if (focus != 0 && focus != cn1DrawingArea && focus != cn1TitleCanvas) { return FALSE; } } @@ -700,17 +747,17 @@ static gboolean cn1OnGenericEvent(GtkWidget* widget, GdkEvent* e, gpointer data) * deliver the whole gesture to one component and, more importantly, * end it: a touchpad emits no pointer events, so the two-pointer path * that normally calls pinchReleased() never runs here. */ - cn1LinuxPushEvent(CN1_EVENT_PINCH_BEGIN, (int) pe->x, (int) pe->y, 0); + cn1LinuxPushEvent(CN1_EVENT_PINCH_BEGIN, cn1TitleEventX(widget, pe->x), cn1TitleEventY(widget, pe->y), 0); } else if (pe->phase == GDK_TOUCHPAD_GESTURE_PHASE_END || pe->phase == GDK_TOUCHPAD_GESTURE_PHASE_CANCEL) { /* CANCEL as well as END: a cancelled gesture leaves the component * mid-pinch exactly as a dropped END would, and the Java side treats * both as "the fingers left the touchpad". */ cn1PinchLastScale = 1.0; - cn1LinuxPushEvent(CN1_EVENT_PINCH_END, (int) pe->x, (int) pe->y, 0); + cn1LinuxPushEvent(CN1_EVENT_PINCH_END, cn1TitleEventX(widget, pe->x), cn1TitleEventY(widget, pe->y), 0); } else if (pe->phase == GDK_TOUCHPAD_GESTURE_PHASE_UPDATE) { - int x = (int) pe->x; - int y = (int) pe->y; + int x = cn1TitleEventX(widget, pe->x); + int y = cn1TitleEventY(widget, pe->y); if (pe->scale > 0 && cn1PinchLastScale > 0) { double inc = pe->scale / cn1PinchLastScale; cn1PinchLastScale = pe->scale; @@ -756,13 +803,13 @@ static gboolean cn1OnScroll(GtkWidget* widget, GdkEventScroll* e, gpointer data) (void) data; /* One notch == 120 units (the WHEEL_DELTA the impl converts to pixels). */ if (e->direction == GDK_SCROLL_UP) { - cn1LinuxPushEvent(CN1_EVENT_MOUSE_WHEEL, (int) e->x, (int) e->y, 120); + cn1LinuxPushEvent(CN1_EVENT_MOUSE_WHEEL, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), 120); } else if (e->direction == GDK_SCROLL_DOWN) { - cn1LinuxPushEvent(CN1_EVENT_MOUSE_WHEEL, (int) e->x, (int) e->y, -120); + cn1LinuxPushEvent(CN1_EVENT_MOUSE_WHEEL, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), -120); } else if (e->direction == GDK_SCROLL_LEFT) { - cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, (int) e->x, (int) e->y, -120); + cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), -120); } else if (e->direction == GDK_SCROLL_RIGHT) { - cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, (int) e->x, (int) e->y, 120); + cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, 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 @@ -783,10 +830,10 @@ static gboolean cn1OnScroll(GtkWidget* widget, GdkEventScroll* e, gpointer data) 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); + cn1LinuxPushEvent(CN1_EVENT_MOUSE_WHEEL, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), -vertical * 120); } if (horizontal != 0) { - cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, (int) e->x, (int) e->y, horizontal * 120); + cn1LinuxPushEvent(CN1_EVENT_MOUSE_HWHEEL, cn1TitleEventX(widget, e->x), cn1TitleEventY(widget, e->y), horizontal * 120); } } return TRUE; @@ -1116,6 +1163,8 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in cn1WindowOpen = 1; } +#include "cn1_linux_chrome.h" + /* ------------------------------------------------------------- menu bar */ /* @@ -1413,7 +1462,9 @@ typedef struct { int x, y, w, h; } CN1Rect; static gboolean cn1QueueDrawIdle(gpointer data) { CN1Rect* r = (CN1Rect*) data; if (cn1DrawingArea != 0) { - gtk_widget_queue_draw_area(cn1DrawingArea, r->x, r->y, r->w, r->h); + gtk_widget_queue_draw_area(cn1DrawingArea, r->x, r->y - cn1TitleHeight, r->w, r->h); + if (cn1TitleCanvas && r->y < cn1TitleHeight) + gtk_widget_queue_draw(cn1TitleCanvas); } free(r); return FALSE; /* one-shot */ @@ -1533,6 +1584,11 @@ static AtkRole cn1A11yRole(const char* role) { static void cn1A11yBeginMain(void* ignored) { (void) ignored; cn1A11yFreeEntries(); + if (cn1TitleAccessibilityFixed) { + GList* titleChildren = gtk_container_get_children(GTK_CONTAINER(cn1TitleAccessibilityFixed)); + for (GList* child = titleChildren; child; child = child->next) gtk_widget_destroy(GTK_WIDGET(child->data)); + g_list_free(titleChildren); + } if (cn1AccessibilityFixed) { GList* children = gtk_container_get_children(GTK_CONTAINER(cn1AccessibilityFixed)); GList* item; @@ -1579,6 +1635,10 @@ static void cn1A11yNodeMain(void* pointer) { CN1A11yNodeCall* call = (CN1A11yNodeCall*) pointer; CN1A11yEntry* parent = cn1A11yFind(call->parentId); GtkWidget* host = parent ? parent->widget : cn1AccessibilityFixed; + gboolean titleRoot = cn1TitleAccessibilityFixed && call->y >= 0 + && call->y + call->height <= cn1TitleHeight + && (!parent || !gtk_widget_is_ancestor(parent->widget, cn1TitleAccessibilityFixed)); + if (titleRoot) host = cn1TitleAccessibilityFixed; GtkWidget* widget = cn1A11yWidget(call->role, call->label, call->value, call->flags); AtkObject* accessible = gtk_widget_get_accessible(widget); atk_object_set_role(accessible, cn1A11yRole(call->role)); @@ -1589,9 +1649,10 @@ static void cn1A11yNodeMain(void* pointer) { gtk_widget_set_size_request(widget, call->width > 0 ? call->width : 1, call->height > 0 ? call->height : 1); if (host && GTK_IS_FIXED(host)) { - int px = parent ? call->x - parent->x : call->x; - int py = parent ? call->y - parent->y : call->y; - gtk_fixed_put(GTK_FIXED(host), widget, px, py); + int px = titleRoot ? call->x - cn1TitleX : parent ? call->x - parent->x : call->x; + int py = titleRoot ? call->y - cn1TitleY : parent ? call->y - parent->y : call->y; + gtk_fixed_put(GTK_FIXED(host), widget, px, + host == cn1AccessibilityFixed ? py - cn1TitleHeight : py); } else if (host && GTK_IS_CONTAINER(host)) { gtk_container_add(GTK_CONTAINER(host), widget); } @@ -1646,7 +1707,12 @@ static void cn1A11yActionMain(void* pointer) { atk_object_set_description(accessible, "Custom accessibility action"); gtk_widget_set_size_request(custom, entry->widget ? gtk_widget_get_allocated_width(entry->widget) : 1, entry->widget ? gtk_widget_get_allocated_height(entry->widget) : 1); - gtk_fixed_put(GTK_FIXED(cn1AccessibilityFixed), custom, entry->x, entry->y); + gboolean inTitle = cn1TitleAccessibilityFixed && entry->widget + && gtk_widget_is_ancestor(entry->widget, cn1TitleAccessibilityFixed); + GtkWidget* actionHost = inTitle ? cn1TitleAccessibilityFixed : cn1AccessibilityFixed; + gtk_fixed_put(GTK_FIXED(actionHost), custom, + entry->x - (inTitle ? cn1TitleX : 0), + entry->y - (inTitle ? cn1TitleY : cn1TitleHeight)); gtk_widget_show(custom); g_signal_connect_data(custom, "clicked", G_CALLBACK(cn1A11yActionActivated), action, cn1A11yActionFree, 0); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index a73399a623e..04f40467d12 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -3308,6 +3308,25 @@ public boolean isAltKeyDown() { return (LinuxNative.currentModifiers() & 4) != 0; } + @Override + public int[] getNativeWindowTitleBarInsets(com.codename1.ui.Component toolbar) { + if (!(toolbar.getTopLevelContainer() instanceof com.codename1.ui.Form) + || toolbar.getComponentForm() instanceof com.codename1.ui.Dialog) { + return null; + } + int[] result = new int[5]; + LinuxNative.mainWindowTitleBarInsets(result); + return result; + } + + @Override + public void setNativeWindowTitleBarRegions(com.codename1.ui.Component toolbar, int[] regions) { + if (toolbar.getTopLevelContainer() instanceof com.codename1.ui.Form + && !(toolbar.getComponentForm() instanceof com.codename1.ui.Dialog)) { + LinuxNative.mainWindowTitleBarRegions(regions); + } + } + // ---- native menu bar ---------------------------------------------------- /// Commands published to the native menu bar, keyed by the id the bar echoes back. @@ -3357,7 +3376,7 @@ public boolean isNativeTitle() { /// after it was dismissed. @Override public void refreshNativeTitle() { - if (!isNativeTitle()) { + if (!isDesktop()) { return; } com.codename1.ui.Form f = getCurrentForm(); diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 8399b7cd1ee..361da314a11 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -229,6 +229,8 @@ public static native int desktopWindowCreate(int windowId, String title, int x, /// Window peers by slot; this is the one the form title goes to in desktop "native" /// title-bar mode, where the CN1 title area is suppressed. A no-op when headless. public static native void mainWindowSetTitle(String title); + public static native void mainWindowTitleBarInsets(int[] result); + public static native void mainWindowTitleBarRegions(int[] regions); public static native void desktopWindowSetTitle(int slot, String title); diff --git a/Ports/MacPort/nativeSources/CN1AppKitTextInput.m b/Ports/MacPort/nativeSources/CN1AppKitTextInput.m index 67fad7a14f4..f5a07d36a79 100644 --- a/Ports/MacPort/nativeSources/CN1AppKitTextInput.m +++ b/Ports/MacPort/nativeSources/CN1AppKitTextInput.m @@ -48,6 +48,8 @@ extern void com_codename1_impl_ios_IOSImplementation_tiFinishComposing___int( extern void com_codename1_impl_ios_IOSImplementation_tiEditorAction___int( CODENAME_ONE_THREAD_STATE, JAVA_INT action); +extern JAVA_VOID com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CODENAME_ONE_THREAD_STATE, JAVA_INT captureId, JAVA_BOOLEAN accepted); + /// Reports one replacement to the pure editor. No-op for the legacy path, whose /// edits travel as a whole-document stringEdit instead. void CN1MacTextInputNotifyReplace(NSRange range, NSString *text) { @@ -103,6 +105,9 @@ void CN1MacTextInputNotifyEditorAction(void) { @implementation CN1MacTextInputSession { int editSeq; + int pendingEditorCommand; + int deferredEditGeneration; + NSMutableArray *deferredEdits; /// Bumped by every start, so a teardown queued by the previous session can /// tell that the singleton has since been rebound to a different editor. int sessionGeneration; @@ -121,6 +126,51 @@ + (instancetype)sharedSession { return shared; } +// All queue access is on AppKit's main thread. A replaced/stopped session drops +// its delayed edits instead of replaying them into another document. +- (void)resetDeferredEditsIfNeeded { + if (!self.active || deferredEditGeneration != sessionGeneration) { + pendingEditorCommand = 0; + [deferredEdits removeAllObjects]; + deferredEditGeneration = sessionGeneration; + } +} + +- (BOOL)deferNativeEdit:(void (^)(void))edit { + [self resetDeferredEditsIfNeeded]; + if (pendingEditorCommand == 0) { + return NO; + } + if (deferredEdits == nil) { deferredEdits = [[NSMutableArray alloc] init]; } + void (^saved)(void) = [edit copy]; + [deferredEdits addObject:saved]; +#if !__has_feature(objc_arc) + [saved release]; +#endif + return YES; +} + +- (int)beginEditorCommand { + [self resetDeferredEditsIfNeeded]; + pendingEditorCommand = [self nextEditSeq]; + return pendingEditorCommand; +} + +- (void)completeEditorCommand:(int)sequence { + [self resetDeferredEditsIfNeeded]; + if (pendingEditorCommand != sequence) { return; } + pendingEditorCommand = 0; + while (deferredEdits.count > 0 && pendingEditorCommand == 0 && self.active) { + void (^edit)(void) = [deferredEdits[0] copy]; + [deferredEdits removeObjectAtIndex:0]; + edit(); +#if !__has_feature(objc_arc) + [edit release]; +#endif + [self resetDeferredEditsIfNeeded]; + } +} + - (instancetype)init { self = [super init]; if (self != nil) { @@ -174,6 +224,7 @@ - (void)startWithText:(NSString *)initialText sessionGeneration++; dispatch_async(dispatch_get_main_queue(), ^{ + [self resetDeferredEditsIfNeeded]; // The window the editing started in, not always the main one. Editing a // component in a secondary NSWindow used to make the MAIN host's view // first responder, which took key focus away from the window the user @@ -217,6 +268,7 @@ - (void)stop { // finish the composition of whichever editor now owns it. int teardownGeneration = sessionGeneration; dispatch_async(dispatch_get_main_queue(), ^{ + [self resetDeferredEditsIfNeeded]; BOOL superseded = sessionGeneration != teardownGeneration; if (!superseded) { [[NSTextInputContext currentInputContext] discardMarkedText]; @@ -240,6 +292,9 @@ - (BOOL)discardingSupersededComposition { } - (int)nextEditSeq { + if (self.pureEditor) { + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG 0, JAVA_FALSE); + } return ++editSeq; } @@ -247,6 +302,10 @@ - (int)currentEditSeq { return editSeq; } +- (int)currentSessionGeneration { + return sessionGeneration; +} + - (void)commitFinished:(BOOL)finished { if (self.pureEditor) { // The pure editor has already been told about the edit itself, operation @@ -272,7 +331,7 @@ JAVA_VOID com_codename1_impl_ios_IOSNative_setTextInputBounds___int_int_int_int( session.editorBounds = CGRectMake(x, y, w, h); } -JAVA_VOID com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_boolean_java_lang_String_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_INT constraint, JAVA_BOOLEAN autoCorrect, JAVA_BOOLEAN autoCapitalize, JAVA_BOOLEAN multiline, JAVA_OBJECT initialText, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT actionType) { +JAVA_VOID com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_boolean_java_lang_String_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_INT constraint, JAVA_BOOLEAN autoCorrect, JAVA_BOOLEAN autoCapitalize, JAVA_BOOLEAN multiline, JAVA_OBJECT initialText, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT actionType, JAVA_INT captureId) { // autoCorrect / autoCapitalize / constraint have no AppKit equivalent for a // custom drawn surface: correction and capitalization are a system wide // input source behaviour rather than a per field one. They are accepted and @@ -302,40 +361,61 @@ JAVA_VOID com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_ selStart:selStart selEnd:selEnd multiline:multiline != 0]; + dispatch_async(dispatch_get_main_queue(), ^{ + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_TRUE); + }); } JAVA_VOID com_codename1_impl_ios_IOSNative_stopTextInput__(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject) { [[CN1MacTextInputSession sharedSession] stop]; } -JAVA_VOID com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_OBJECT text, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT caretX, JAVA_INT caretY, JAVA_INT caretW, JAVA_INT caretH, JAVA_INT seq) { +JAVA_VOID com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT __cn1ThisObject, JAVA_OBJECT text, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT caretX, JAVA_INT caretY, JAVA_INT caretW, JAVA_INT caretH, JAVA_INT seq, JAVA_INT captureId) { CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; - // The caret geometry is taken from every push, stale or not: it is where the - // framework last drew the caret and an input method needs somewhere to put - // its candidate window. - session.caretRect = CGRectMake(caretX, caretY, caretW, caretH); - // The TEXT and the selection are not, when the echo is stale. Java echoes - // the sequence of the edit it applied, and a push for edit N that arrives - // after the user has already typed N+1 would otherwise overwrite the newer - // native text -- and the next keystroke would then be applied to a document - // the user has moved past, losing or misplacing what they typed. The UIKit - // path drops the same echo for the same reason. - if (seq != (JAVA_INT)[session currentEditSeq]) { - return; - } - if (session.markedRange.location != NSNotFound) { - // Mid composition the input method owns the text. Taking the - // framework's copy back here would drop the marked run and make the - // composition look like it did nothing. - return; - } - if (text != JAVA_NULL) { - session.text = toNSString(CN1_THREAD_GET_STATE_PASS_ARG text); - } - NSInteger len = (NSInteger)session.text.length; - NSInteger start = MIN(MAX((NSInteger)selStart, 0), len); - NSInteger end = MIN(MAX((NSInteger)selEnd, start), len); - session.selectedRange = NSMakeRange(start, end - start); + NSString *snapshot = text != JAVA_NULL + ? toNSString(CN1_THREAD_GET_STATE_PASS_ARG text) : nil; + int generation = [session currentSessionGeneration]; + // AppKit mutates the shadow on the main thread. Check the generation and + // apply the echo on that same queue: checking on the EDT left a window in + // which a keystroke could advance the shadow before this older push wrote + // it back, even though its sequence check had passed. + dispatch_async(dispatch_get_main_queue(), ^{ + if (!session.active || !session.pureEditor + || generation != [session currentSessionGeneration]) { + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_FALSE); + return; + } + // The caret geometry is taken from every push, stale or not: it is where the + // framework last drew the caret and an input method needs somewhere to put + // its candidate window. + session.caretRect = CGRectMake(caretX, caretY, caretW, caretH); + // The TEXT and the selection are not, when the echo is stale. Java echoes + // the sequence of the edit it applied, and a push for edit N that arrives + // after the user has already typed N+1 would otherwise overwrite the newer + // native text -- and the next keystroke would then be applied to a document + // the user has moved past, losing or misplacing what they typed. The UIKit + // path drops the same echo for the same reason. + if (seq != (JAVA_INT)[session currentEditSeq]) { + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_FALSE); + return; + } + if (session.markedRange.location != NSNotFound) { + // Mid composition the input method owns the text. Taking the + // framework's copy back here would drop the marked run and make the + // composition look like it did nothing. + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_FALSE); + return; + } + if (snapshot != nil) { + session.text = snapshot; + } + NSInteger len = (NSInteger)session.text.length; + NSInteger start = MIN(MAX((NSInteger)selStart, 0), len); + NSInteger end = MIN(MAX((NSInteger)selEnd, start), len); + session.selectedRange = NSMakeRange(start, end - start); + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_TRUE); + [session completeEditorCommand:(int)seq]; + }); } /// Pushes the framework's text into the live session without disturbing the diff --git a/Ports/MacPort/nativeSources/CN1AppKitWindows.m b/Ports/MacPort/nativeSources/CN1AppKitWindows.m index 6568d196381..734485c35c3 100644 --- a/Ports/MacPort/nativeSources/CN1AppKitWindows.m +++ b/Ports/MacPort/nativeSources/CN1AppKitWindows.m @@ -738,8 +738,15 @@ JAVA_VOID com_codename1_impl_mac_MacNative_macWindowDestroy___int(CODENAME_ONE_T other.pendingOwner = nil; } } + NSWindow *owner = rec.pendingOwner; [rec.window orderOut:nil]; [rec.window close]; + if ([NSApp keyWindow] == nil && owner.isVisible && owner.canBecomeKeyWindow) { + // The modal gate can be lifted before this close, while the dialog + // is still key. AppKit then has no remaining key window. Restore + // its owner without ordering or activating the application. + [owner makeKeyWindow]; + } [cn1WindowTable() replaceObjectAtIndex:slot withObject:[NSNull null]]; // A destroyed window is one fewer on screen. CN1MacWindowVisibilityChanged(); @@ -1306,6 +1313,13 @@ static void cn1SetWindowChromeEnabled(NSWindow *w, BOOL enabled) { // blocked window could take key focus back from the modal one and then throw // away every keystroke aimed at it. CN1MacWindowSetAcceptsKey(w, enabled); + if (enabled && w.isVisible && [NSApp keyWindow] == nil) { + // Closing a modal can leave no key window: AppKit tried to select the + // owner while the framework still had its input gate disabled. Once + // that gate is lifted, restore keyboard focus without raising an + // inactive application or stealing it from another window. + [w makeKeyWindow]; + } if (!enabled && w.isKeyWindow) { // Already holding it, so the gate alone is too late -- it only refuses // the next request. Hand the keyboard to the window that caused the diff --git a/Ports/MacPort/nativeSources/CN1MacAppDelegate.m b/Ports/MacPort/nativeSources/CN1MacAppDelegate.m index 745c5dd708a..221f0606502 100644 --- a/Ports/MacPort/nativeSources/CN1MacAppDelegate.m +++ b/Ports/MacPort/nativeSources/CN1MacAppDelegate.m @@ -873,6 +873,20 @@ - (void)applicationWillTerminate:(NSNotification *)notification { (void) notification; } +/// Reopening through Finder or the Dock is distinct from Cmd-Tab activation. +/// Restore the existing surface without constructing a new Form or document. +- (BOOL)applicationShouldHandleReopen:(NSApplication *)sender hasVisibleWindows:(BOOL)hasVisibleWindows { + NSWindow *window = [CN1MacHost sharedHost].builtWindow; + if (window != nil && (window.miniaturized || !hasVisibleWindows)) { + if (window.miniaturized) { + [window deminiaturize:nil]; + } + [window makeKeyAndOrderFront:nil]; + return NO; // Already handled; suppress AppKit's default untitled-document action. + } + return YES; +} + /// Quitting with the last window is the behaviour every other Codename One port /// has, and what Display.exitApplication and a window close both mean here. It /// is not the Mac convention for a document application -- those stay running diff --git a/Ports/MacPort/nativeSources/CN1MacChrome.m b/Ports/MacPort/nativeSources/CN1MacChrome.m index 63ad663b940..9895c9c261b 100644 --- a/Ports/MacPort/nativeSources/CN1MacChrome.m +++ b/Ports/MacPort/nativeSources/CN1MacChrome.m @@ -23,6 +23,8 @@ #import #import "CN1MacHost.h" #import "CN1MacMenu.h" +#import "CN1AppKitWindows.h" +#import "METALView.h" #include "cn1_globals.h" #include "com_codename1_impl_ios_IOSImplementation.h" @@ -280,6 +282,64 @@ void CN1MacHostSetWindowTitle(NSString *title) { }); } +// Copy before dispatch: a Java array must never escape into an asynchronous +// AppKit block, where the moving/collecting VM no longer protects its storage. +void com_codename1_impl_mac_MacNative_macSetTitleBarRegions___int_int_1ARRAY(CODENAME_ONE_THREAD_STATE, + JAVA_OBJECT __cn1ThisObject, JAVA_INT windowId, JAVA_OBJECT regionsObject) { + JAVA_ARRAY regions = (JAVA_ARRAY)regionsObject; + NSData *copy = regionsObject == JAVA_NULL ? [[NSData alloc] init] + : [[NSData alloc] initWithBytes:CN1_ARRAY_DATA(regions) length:regions->length * sizeof(JAVA_ARRAY_INT)]; + dispatch_async(dispatch_get_main_queue(), ^{ + NSView *view = windowId == -2 ? [CN1MacHost sharedHost].renderingView + : CN1MacPeerHostViewForWindowId(windowId); + if ([view isKindOfClass:[METALView class]]) { + ((METALView *)view).cn1TitleBarRegions = copy; + } + }); +#if !__has_feature(objc_arc) + [copy release]; +#endif +} + +// AppKit updates the snapshot. EDT layout can hold the render lock, so it +// must not dispatch_sync to AppKit merely to measure window controls. +extern void com_codename1_impl_mac_MacImplementation_macTitleBarInsetsChanged__(CODENAME_ONE_THREAD_STATE); +static int cn1TitleInsets[5]; +static void cn1MacUpdateTitleInsets(void) { + NSWindow *window = [CN1MacHost sharedHost].window; + NSView *view = [CN1MacHost sharedHost].renderingView; + if (!window || !view) return; + CGFloat scale = window.backingScaleFactor; + int metrics[5] = {0}; + NSWindowButton types[] = {NSWindowCloseButton, NSWindowMiniaturizeButton, NSWindowZoomButton}; + for (int i = 0; i < 3; i++) { + NSButton *button = [window standardWindowButton:types[i]]; + if (!button || button.hidden || !button.superview) continue; + NSRect rect = [view convertRect:button.frame fromView:button.superview]; + metrics[0] = MAX(metrics[0], (int)ceil((NSMaxX(rect) + 8) * scale)); + CGFloat bottom = view.isFlipped ? NSMaxY(rect) : NSHeight(view.bounds) - NSMinY(rect); + metrics[4] = MAX(metrics[4], (int)ceil((bottom + 4) * scale)); + } + BOOL changed; + @synchronized ([CN1MacHost class]) { + changed = memcmp(cn1TitleInsets, metrics, sizeof(metrics)) != 0; + memcpy(cn1TitleInsets, metrics, sizeof(metrics)); + } + // The query already returned the previous snapshot. Schedule a fresh EDT layout + // after publishing a change, outside the cache lock. Unchanged metrics stay quiet. + if (changed) { + com_codename1_impl_mac_MacImplementation_macTitleBarInsetsChanged__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); + } +} +void com_codename1_impl_mac_MacNative_macTitleBarInsets___int_1ARRAY(CODENAME_ONE_THREAD_STATE, + JAVA_OBJECT instance, JAVA_OBJECT array) { + if (array == JAVA_NULL || ((JAVA_ARRAY)array)->length < 5) return; + @synchronized ([CN1MacHost class]) { + memcpy(CN1_ARRAY_DATA(array), cn1TitleInsets, sizeof(cn1TitleInsets)); + } + dispatch_async(dispatch_get_main_queue(), ^{ cn1MacUpdateTitleInsets(); }); +} + void CN1MacHostSetWindowUndecorated(BOOL undecorated) { dispatch_async(dispatch_get_main_queue(), ^{ NSWindow *w = [CN1MacHost sharedHost].window; @@ -295,6 +355,7 @@ void CN1MacHostSetWindowUndecorated(BOOL undecorated) { w.titlebarAppearsTransparent = YES; w.titleVisibility = NSWindowTitleHidden; w.movableByWindowBackground = YES; + cn1MacUpdateTitleInsets(); } else { // Restored, unlike the Catalyst path, which only ever undecorates: // a form that set custom chrome and then navigated away left the @@ -303,6 +364,10 @@ void CN1MacHostSetWindowUndecorated(BOOL undecorated) { w.titlebarAppearsTransparent = NO; w.titleVisibility = NSWindowTitleVisible; w.movableByWindowBackground = NO; + NSView *view = [CN1MacHost sharedHost].renderingView; + if ([view isKindOfClass:[METALView class]]) { + ((METALView *)view).cn1TitleBarRegions = nil; + } } }); } diff --git a/Ports/MacPort/nativeSources/CN1MacTextInput.h b/Ports/MacPort/nativeSources/CN1MacTextInput.h index 3ed5c9fd1b5..246455b5e4a 100644 --- a/Ports/MacPort/nativeSources/CN1MacTextInput.h +++ b/Ports/MacPort/nativeSources/CN1MacTextInput.h @@ -117,6 +117,12 @@ void CN1MacTextInputNotifyEditorAction(void); /// text the user has already moved past. - (int)currentEditSeq; +/// Main-thread barrier for commands whose result is computed by the Java editor. +- (BOOL)deferNativeEdit:(void (^)(void))edit; +- (int)beginEditorCommand; +- (void)completeEditorCommand:(int)sequence; +- (void)resetDeferredEditsIfNeeded; + /// Caret rectangle in Codename One pixels, as last reported by the framework. @property (nonatomic) CGRect caretRect; diff --git a/Ports/MacPort/nativeSources/METALView.h b/Ports/MacPort/nativeSources/METALView.h index d4358a69889..ef44c02a275 100644 --- a/Ports/MacPort/nativeSources/METALView.h +++ b/Ports/MacPort/nativeSources/METALView.h @@ -117,6 +117,9 @@ /// than being delivered and then discarded further up. @property (nonatomic, assign) BOOL cn1InputEnabled; +/// Main-thread-only packed int32 rectangles: title band followed by controls. +@property (nonatomic, copy) NSData *cn1TitleBarRegions; + /// Keeps the peer input blocker above every peer subview. /// /// Called when input is toggled and whenever a peer is added: a peer added diff --git a/Ports/MacPort/nativeSources/METALView.m b/Ports/MacPort/nativeSources/METALView.m index c2f85f0ac7e..89bdd51cf8c 100644 --- a/Ports/MacPort/nativeSources/METALView.m +++ b/Ports/MacPort/nativeSources/METALView.m @@ -90,6 +90,12 @@ - (void)scrollWheel:(NSEvent *)event { } @end +@interface METALView () +- (BOOL)cn1DeferNativeEdit:(void (^)(void))edit; +- (void)cn1SendEditorKeyCommand:(int)command modifiers:(int)modifiers; +- (void)cn1SendEditorKeyCommand:(int)command; +@end + @implementation METALView { NSTrackingArea *cn1TrackingArea; NSMutableIndexSet *consumedKeys; @@ -213,6 +219,7 @@ @implementation METALView { @synthesize projectionMatrix; @synthesize cn1WindowId; @synthesize cn1InputEnabled; +@synthesize cn1TitleBarRegions; /// Custom setter so the peer blocker follows the flag wherever it is set from. - (void)setCn1InputEnabled:(BOOL)enabled { @@ -986,6 +993,7 @@ - (void)dealloc { self.renderPassDescriptor = nil; self.renderCommandEncoder = nil; self.peerComponentsLayer = nil; + self.cn1TitleBarRegions = nil; if (cn1TrackingArea != nil) { [self removeTrackingArea:cn1TrackingArea]; [cn1TrackingArea release]; @@ -1301,7 +1309,45 @@ static int cn1ButtonFromNumber(NSInteger buttonNumber) { } } +- (BOOL)cn1IsTitleBarEvent:(NSEvent *)event { + if (!self.cn1InputEnabled || self.cn1TitleBarRegions.length < 4 * sizeof(int32_t)) { + return NO; + } + CGPoint p = [self cn1PointFromEvent:event]; + const int32_t *regions = self.cn1TitleBarRegions.bytes; + NSUInteger count = self.cn1TitleBarRegions.length / (4 * sizeof(int32_t)); + for (NSUInteger i = 0; i < count; i++) { + const int32_t *r = regions + i * 4; + BOOL inside = p.x >= r[0] && p.y >= r[1] && p.x < r[0] + r[2] && p.y < r[1] + r[3]; + if ((i == 0 && !inside) || (i > 0 && inside)) { + return NO; + } + } + return YES; +} + - (void)mouseDown:(NSEvent *)event { + if ([self cn1IsTitleBarEvent:event]) { + if (event.clickCount == 2) { + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + NSString *action = [defaults stringForKey:@"AppleActionOnDoubleClick"]; + if ((action != nil && [action caseInsensitiveCompare:@"Minimize"] == NSOrderedSame) + || (action == nil && [defaults boolForKey:@"AppleMiniaturizeOnDoubleClick"])) { + [self.window performMiniaturize:nil]; + } else if (action == nil || [action caseInsensitiveCompare:@"Maximize"] == NSOrderedSame + || [action caseInsensitiveCompare:@"Zoom"] == NSOrderedSame + || [action caseInsensitiveCompare:@"Fill"] == NSOrderedSame) { + // These canvas windows use AppKit's full visible-frame standard + // size, so native zoom also provides Fill and restores on repeat. + [self.window performZoom:nil]; + } + } else { + // AppKit owns tracking, screen snapping and the final release. No + // Java press is sent, so dragging cannot activate a document control. + [self.window performWindowDragWithEvent:event]; + } + return; + } CN1MacPointerButton(CN1_PE_BUTTON_PRIMARY, cn1HeldButtonMask(), cn1ModifiersOf(event)); [self cn1Deliver:event to:pointerPressed type:CN1_POINTER_PRESSED]; } @@ -1312,6 +1358,7 @@ - (void)mouseDragged:(NSEvent *)event { } - (void)mouseUp:(NSEvent *)event { + if (!cn1PointerPressActive) { return; } CN1MacPointerButton(CN1_PE_BUTTON_PRIMARY, cn1HeldButtonMask(), cn1ModifiersOf(event)); [self cn1Deliver:event to:pointerReleased type:CN1_POINTER_RELEASED]; } @@ -1668,6 +1715,10 @@ - (void)keyDown:(NSEvent *)event { // rather than reading characters directly is what buys dead keys, CJK // input methods, dictation and the Emacs key bindings for free. [[self cn1ConsumedKeys] addIndex:event.keyCode]; + if ([self cn1DeferNativeEdit:^{ + cn1CurrentModifiers = cn1ModifiersOf(event); + [self interpretKeyEvents:@[event]]; + }]) { return; } [self interpretKeyEvents:@[event]]; return; } @@ -1773,6 +1824,7 @@ - (NSRange)cn1EffectiveRange:(NSRange)replacementRange { } - (void)insertText:(id)string replacementRange:(NSRange)replacementRange { + if ([self cn1DeferNativeEdit:^{ [self insertText:string replacementRange:replacementRange]; }]) { return; } CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; if (!cn1OwnsSession(self, session)) { return; @@ -1815,6 +1867,7 @@ - (void)insertText:(id)string replacementRange:(NSRange)replacementRange { - (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange { + if ([self cn1DeferNativeEdit:^{ [self setMarkedText:string selectedRange:selectedRange replacementRange:replacementRange]; }]) { return; } CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; if (!cn1OwnsSession(self, session)) { return; @@ -1883,6 +1936,7 @@ - (void)unmarkText { // the discard, so re-entering it here would only come straight back. return; } + if ([self cn1DeferNativeEdit:^{ [self unmarkText]; }]) { return; } CN1MacTextInputNotifyFinishComposing(); session.markedRange = NSMakeRange(NSNotFound, 0); [[NSTextInputContext currentInputContext] discardMarkedText]; @@ -2123,6 +2177,7 @@ - (void)cn1SetSelection:(NSRange)sel { } - (void)doCommandBySelector:(SEL)selector { + if ([self cn1DeferNativeEdit:^{ [self doCommandBySelector:selector]; }]) { return; } CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; if (!cn1OwnsSession(self, session)) { [super doCommandBySelector:selector]; @@ -2140,6 +2195,14 @@ - (void)doCommandBySelector:(SEL)selector { return; } if (selector == @selector(insertTab:) || selector == @selector(insertBacktab:)) { + BOOL forward = (selector == @selector(insertTab:)); + if (session.pureEditor && session.multiline) { + // TextInputClient owns indentation in a multiline editor, as on + // iOS. Do not end its session or move focus before delivering Tab. + // Single-line fields and the legacy text path still traverse below. + [self cn1SendEditorKeyCommand:18 modifiers:forward ? 0 : 1]; + return; + } // Tab moves focus rather than inserting, which is the AppKit convention. // // The two are told apart, and traversal is asked for explicitly. Ending @@ -2147,7 +2210,6 @@ - (void)doCommandBySelector:(SEL)selector { // pure editor reads that action as 0, which is "insert a newline", so Tab // put a line break into the field it was supposed to leave -- and // Shift-Tab, arriving here as the same case, did whatever Tab did. - BOOL forward = (selector == @selector(insertTab:)); [session commitFinished:YES]; extern JAVA_VOID com_codename1_impl_mac_MacImplementation_macTraverseTextEditing___boolean( CODENAME_ONE_THREAD_STATE, JAVA_BOOLEAN forward); @@ -2235,6 +2297,7 @@ extern JAVA_VOID com_codename1_impl_mac_MacImplementation_macTraverseTextEditing // fixed for the plain movement and left standing for the selecting // variant, which is the same key with Shift held. if (selector == @selector(moveToBeginningOfLineAndModifySelection:) + || selector == @selector(moveToLeftEndOfLineAndModifySelection:) || selector == @selector(moveToBeginningOfParagraphAndModifySelection:)) { NSUInteger anchor = [self cn1SelectionAnchor:sel]; NSUInteger to = [self cn1LineStart:cn1ActiveEdge(sel, anchor) inText:session.text]; @@ -2243,6 +2306,7 @@ extern JAVA_VOID com_codename1_impl_mac_MacImplementation_macTraverseTextEditing return; } if (selector == @selector(moveToEndOfLineAndModifySelection:) + || selector == @selector(moveToRightEndOfLineAndModifySelection:) || selector == @selector(moveToEndOfParagraphAndModifySelection:)) { NSUInteger anchor = [self cn1SelectionAnchor:sel]; NSUInteger to = [self cn1LineEnd:cn1ActiveEdge(sel, anchor) inText:session.text]; @@ -2267,12 +2331,14 @@ extern JAVA_VOID com_codename1_impl_mac_MacImplementation_macTraverseTextEditing // offset 0 and End jumped to the end of the whole field -- which is a // different key's behaviour, not a rough approximation of this one. if (selector == @selector(moveToBeginningOfLine:) + || selector == @selector(moveToLeftEndOfLine:) || selector == @selector(moveToBeginningOfParagraph:)) { [self cn1SetSelection:NSMakeRange([self cn1LineStart:sel.location inText:session.text], 0)]; return; } if (selector == @selector(moveToEndOfLine:) + || selector == @selector(moveToRightEndOfLine:) || selector == @selector(moveToEndOfParagraph:)) { [self cn1SetSelection:NSMakeRange([self cn1LineEnd:NSMaxRange(sel) inText:session.text], 0)]; @@ -2498,12 +2564,28 @@ - (BOOL)validateUserInterfaceItem:(id)item { /// /// Reported as a key COMMAND rather than an editor action: an action ends the /// session, and undo is something done inside one. -- (void)cn1SendEditorKeyCommand:(int)command { - extern JAVA_VOID com_codename1_impl_ios_IOSImplementation_tiKeyCommand___int_int( - CODENAME_ONE_THREAD_STATE, JAVA_INT cmd, JAVA_INT modifiers); +- (BOOL)cn1DeferNativeEdit:(void (^)(void))edit { + CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; + return session.pureEditor && cn1OwnsSession(self, session) && [session deferNativeEdit:edit]; +} + +- (void)cn1SendEditorKeyCommand:(int)command modifiers:(int)modifiers { + CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; + // The Java editor owns rich paste/undo results. Hold subsequent native edits + // until its sequenced state echo supplies the new text and selection. + if (session.markedRange.location != NSNotFound) { + session.markedRange = NSMakeRange(NSNotFound, 0); + CN1MacTextInputNotifyFinishComposing(); + } + extern JAVA_VOID com_codename1_impl_ios_IOSImplementation_tiKeyCommandWithSequence___int_int_int( + CODENAME_ONE_THREAD_STATE, JAVA_INT cmd, JAVA_INT modifiers, JAVA_INT sequence); struct ThreadLocalData *threadStateData = getThreadLocalData(); - com_codename1_impl_ios_IOSImplementation_tiKeyCommand___int_int( - threadStateData, (JAVA_INT)command, (JAVA_INT)0); + com_codename1_impl_ios_IOSImplementation_tiKeyCommandWithSequence___int_int_int( + threadStateData, (JAVA_INT)command, (JAVA_INT)modifiers, [session beginEditorCommand]); +} + +- (void)cn1SendEditorKeyCommand:(int)command { + [self cn1SendEditorKeyCommand:command modifiers:0]; } /// Guarded here as well as in validateUserInterfaceItem:, for the reason -copy: @@ -2517,6 +2599,7 @@ extern JAVA_VOID com_codename1_impl_ios_IOSImplementation_tiKeyCommand___int_int /// The condition is the same one validation answers with, deliberately: two /// spellings of "may this view undo" would eventually disagree. - (void)cn1PerformEditorUndoRedo:(int)command { + if ([self cn1DeferNativeEdit:^{ [self cn1PerformEditorUndoRedo:command]; }]) { return; } CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; if (!session.pureEditor || !cn1OwnsSession(self, session)) { return; @@ -2533,6 +2616,7 @@ - (void)redo:(id)sender { } - (void)copy:(id)sender { + if ([self cn1DeferNativeEdit:^{ [self copy:sender]; }]) { return; } // Checked here as well as in validation: a key equivalent reaches the // responder chain directly and never asks whether the menu item is // enabled, so validation alone leaves Command-C working. @@ -2551,6 +2635,12 @@ - (void)copy:(id)sender { if (sel.length == 0) { return; } + if ([CN1MacTextInputSession sharedSession].pureEditor) { + // The Java editor owns rich clipboard representations, not just the + // plain-text shadow used by AppKit for IME and selection queries. + [self cn1SendEditorKeyCommand:12]; // TextInputClient.KEY_COPY + return; + } NSPasteboard *pb = [NSPasteboard generalPasteboard]; [pb clearContents]; [pb writeObjects:@[[[CN1MacTextInputSession sharedSession].text @@ -2558,6 +2648,7 @@ - (void)copy:(id)sender { } - (void)cut:(id)sender { + if ([self cn1DeferNativeEdit:^{ [self cut:sender]; }]) { return; } if (cn1CutBlocked(self, [CN1MacTextInputSession sharedSession])) { return; } @@ -2565,11 +2656,17 @@ - (void)cut:(id)sender { if (sel.length == 0) { return; } + if ([CN1MacTextInputSession sharedSession].pureEditor + && cn1OwnsSession(self, [CN1MacTextInputSession sharedSession])) { + [self cn1SendEditorKeyCommand:13]; // TextInputClient.KEY_CUT + return; + } [self copy:sender]; [self delete:sender]; } - (void)delete:(id)sender { + if ([self cn1DeferNativeEdit:^{ [self delete:sender]; }]) { return; } NSRange sel = [self cn1SelectionForEditing]; if (sel.length == 0) { return; @@ -2590,6 +2687,7 @@ - (void)delete:(id)sender { } - (void)paste:(id)sender { + if ([self cn1DeferNativeEdit:^{ [self paste:sender]; }]) { return; } CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; if (cn1PasteBlocked(self, session)) { return; @@ -2597,6 +2695,10 @@ - (void)paste:(id)sender { if (!cn1OwnsSession(self, session)) { return; } + if (session.pureEditor) { + [self cn1SendEditorKeyCommand:14]; // TextInputClient.KEY_PASTE + return; + } NSString *pasted = [[NSPasteboard generalPasteboard] stringForType:NSPasteboardTypeString]; if (pasted == nil) { @@ -2608,6 +2710,7 @@ - (void)paste:(id)sender { } - (void)selectAll:(id)sender { + if ([self cn1DeferNativeEdit:^{ [self selectAll:sender]; }]) { return; } CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; // Ownership, like every other Edit action. Command-A is a key equivalent and // arrives here directly, so testing only "a session is active" let the key diff --git a/Ports/MacPort/src/com/codename1/impl/mac/MacImplementation.java b/Ports/MacPort/src/com/codename1/impl/mac/MacImplementation.java index 88d01735484..7bb02e917eb 100644 --- a/Ports/MacPort/src/com/codename1/impl/mac/MacImplementation.java +++ b/Ports/MacPort/src/com/codename1/impl/mac/MacImplementation.java @@ -218,6 +218,46 @@ private void nameEditingWindow(com.codename1.ui.Component cmp) { macNative.macTextInputSetOwnerWindow(ownerWindowId(cmp)); } + // Keep the native-only callback reachable to ParparVM's dead-code eliminator. + private static boolean titleInsetsCallbackLink; + + /// AppKit published changed title metrics after an asynchronous query. + public static void macTitleBarInsetsChanged() { + if (!com.codename1.ui.Display.isInitialized()) { + return; + } + com.codename1.ui.Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + com.codename1.ui.Form form = com.codename1.ui.Display.getInstance().getCurrent(); + if (form != null) { + form.revalidate(); + form.repaint(); + } + } + }); + } + + /// @inheritDoc + @Override + public int[] getNativeWindowTitleBarInsets(com.codename1.ui.Component toolbar) { + if (ownerWindowId(toolbar) != -2) { + return null; + } + if (titleInsetsCallbackLink) { + macTitleBarInsetsChanged(); + } + int[] result = new int[5]; + macNative.macTitleBarInsets(result); + return result; + } + + /// @inheritDoc + @Override + public void setNativeWindowTitleBarRegions(com.codename1.ui.Component toolbar, int[] regions) { + macNative.macSetTitleBarRegions(ownerWindowId(toolbar), regions); + } + /// Which surface the native side should treat as the owner of the next /// presentation or editing session. /// diff --git a/Ports/MacPort/src/com/codename1/impl/mac/MacNative.java b/Ports/MacPort/src/com/codename1/impl/mac/MacNative.java index 821037b4638..4a887b9de2a 100644 --- a/Ports/MacPort/src/com/codename1/impl/mac/MacNative.java +++ b/Ports/MacPort/src/com/codename1/impl/mac/MacNative.java @@ -60,6 +60,9 @@ native int macWindowCreate(int windowId, String title, int x, int y, int width, /// Cmd-Q or a window close does. See MacImplementation.exitApplication. native void requestTerminate(); + native void macTitleBarInsets(int[] result); + native void macSetTitleBarRegions(int windowId, int[] regions); + native void macWindowDestroy(int slot); native void macWindowShow(int slot, boolean visible); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows.h b/Ports/WindowsPort/nativeSources/cn1_windows.h index d372576130a..90216898f93 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows.h +++ b/Ports/WindowsPort/nativeSources/cn1_windows.h @@ -330,6 +330,10 @@ LRESULT cn1WinAccessibilityObject(HWND hwnd, WPARAM wParam, LPARAM lParam); * place before the native returns, and a form shown immediately afterwards cannot race it. * lParam carries the UTF-8 spec; see cn1WinMenuSetCommands. */ #define WM_CN1_MENU (WM_APP + 26) +#define WM_CN1_CHROME_METRICS (WM_APP + 27) +#define WM_CN1_CHROME_REGIONS (WM_APP + 28) +void cn1WinMenuSetIntegrated(BOOL integrated); +BOOL cn1WinChromeMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp, LRESULT* result); void cn1WinDesktopHandleMessage(WPARAM wParam, LPARAM lParam); HWND cn1WinDesktopHwnd(int slot); int cn1WinDesktopSlotForHwnd(HWND hwnd); diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_chrome.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_chrome.cpp new file mode 100644 index 00000000000..3e5db5c6bdf --- /dev/null +++ b/Ports/WindowsPort/nativeSources/cn1_windows_chrome.cpp @@ -0,0 +1,190 @@ +/* + * 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 Codename One 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. + */ + + +/* Integrated main-window title bar. The window keeps WS_OVERLAPPEDWINDOW: + * DWM owns the real caption buttons and DefWindowProc owns system commands, + * resize tracking, caption double-click and the window system menu. */ +#ifdef _WIN32 +#include "cn1_windows.h" +#include +#include +#include +#include + +extern "C" { +static BOOL cn1ChromeEnabled; +static int* cn1ChromeRegions; +static int cn1ChromeRegionCount; + +static UINT cn1ChromeDpi(HWND hwnd) { + typedef UINT (WINAPI *GetDpiFn)(HWND); + GetDpiFn fn = (GetDpiFn)GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForWindow"); + return fn ? fn(hwnd) : 96; +} +static int cn1ChromeMetric(HWND hwnd, int metric) { + typedef int (WINAPI *MetricFn)(int, UINT); + MetricFn fn = (MetricFn)GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetSystemMetricsForDpi"); + return fn ? fn(metric, cn1ChromeDpi(hwnd)) : GetSystemMetrics(metric); +} +static void cn1ChromeAppearance(HWND hwnd) { + DWORD light = 1, bytes = sizeof(light); + RegGetValueW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + L"AppsUseLightTheme", RRF_RT_REG_DWORD, NULL, &light, &bytes); + BOOL dark = light == 0; + // Windows 10 2004+; older desktop builds use attribute 19. + if (FAILED(DwmSetWindowAttribute(hwnd, 20, &dark, sizeof(dark)))) + DwmSetWindowAttribute(hwnd, 19, &dark, sizeof(dark)); +} +static void cn1ChromeEnable(HWND hwnd, BOOL enable) { + BOOL composed = FALSE; + enable = enable && SUCCEEDED(DwmIsCompositionEnabled(&composed)) && composed; + if (enable == cn1ChromeEnabled) return; + cn1ChromeEnabled = enable; + // Keep the native command/accelerator registry, but let the CN1 Toolbar + // expose menus in the integrated title area instead of overlapping it with + // Win32's non-client menu row. Native mode restores the ordinary HMENU. + cn1WinMenuSetIntegrated(enable); + cn1ChromeAppearance(hwnd); + MARGINS margins = {0, 0, enable ? cn1ChromeMetric(hwnd, SM_CYCAPTION) + cn1ChromeMetric(hwnd, SM_CYSIZEFRAME) : 0, 0}; + DwmExtendFrameIntoClientArea(hwnd, &margins); + SetWindowPos(hwnd, NULL, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); +} +static void cn1ChromeInsets(HWND hwnd, int* result) { + memset(result, 0, 5 * sizeof(int)); + if (!cn1ChromeEnabled) return; + int border = cn1ChromeMetric(hwnd, SM_CYSIZEFRAME) + cn1ChromeMetric(hwnd, SM_CXPADDEDBORDER); + RECT buttons = {0}; + RECT window; + POINT origin = {0, 0}; + GetWindowRect(hwnd, &window); + ClientToScreen(hwnd, &origin); + int reserve = 3 * cn1ChromeMetric(hwnd, SM_CXSIZE); + int height = cn1ChromeMetric(hwnd, SM_CYCAPTION) + border; + BOOL rtl = (GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_LAYOUTRTL) != 0; + if (SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_CAPTION_BUTTON_BOUNDS, &buttons, sizeof(buttons))) + && buttons.right > buttons.left && buttons.bottom > buttons.top) { + reserve = rtl ? window.left + buttons.right - origin.x + : window.right - (window.left + buttons.left); + height = window.top + buttons.bottom - origin.y; + } + result[rtl ? 0 : 1] = reserve + (int)(4 * cn1ChromeDpi(hwnd) / 96); + result[2] = IsZoomed(hwnd) ? 0 : border; + result[4] = height > result[2] ? height : result[2] + 1; +} +static BOOL cn1ChromeDragPoint(int x, int y) { + if (cn1ChromeRegionCount < 4) return FALSE; + for (int i = 0; i < cn1ChromeRegionCount; i += 4) { + int* r = cn1ChromeRegions + i; + BOOL inside = x >= r[0] && y >= r[1] && x - r[0] < r[2] && y - r[1] < r[3]; + if ((i == 0 && !inside) || (i > 0 && inside)) return FALSE; + } + return TRUE; +} + +/* All state, DWM queries and HWND changes stay on the window-owning thread. */ +BOOL cn1WinChromeMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp, LRESULT* result) { + if (msg == WM_CN1_CHROME_METRICS) { + cn1ChromeEnable(hwnd, TRUE); + cn1ChromeInsets(hwnd, (int*)lp); + *result = 0; + return TRUE; + } + if (msg == WM_CN1_CHROME_REGIONS) { + free(cn1ChromeRegions); + cn1ChromeRegions = (int*)lp; + cn1ChromeRegionCount = (int)wp; + cn1ChromeEnable(hwnd, wp >= 4); + *result = 0; + return TRUE; + } + if (msg == WM_NCDESTROY) { + free(cn1ChromeRegions); + cn1ChromeRegions = NULL; + cn1ChromeRegionCount = 0; + cn1ChromeEnabled = FALSE; + } + if (msg == WM_DWMCOMPOSITIONCHANGED) cn1ChromeEnable(hwnd, cn1ChromeRegionCount >= 4); + if (!cn1ChromeEnabled) return FALSE; + if (msg == WM_NCCALCSIZE && wp) { + NCCALCSIZE_PARAMS* size = (NCCALCSIZE_PARAMS*)lp; + LONG top = size->rgrc[0].top; + DefWindowProcW(hwnd, msg, wp, lp); // retain side/bottom resizing borders + int border = cn1ChromeMetric(hwnd, SM_CYSIZEFRAME) + cn1ChromeMetric(hwnd, SM_CXPADDEDBORDER); + size->rgrc[0].top = top + (IsZoomed(hwnd) ? border : 0); + *result = 0; + return TRUE; + } + // Native caption buttons must see non-client messages before app hit testing. + if (DwmDefWindowProc(hwnd, msg, wp, lp, result)) return TRUE; + if (msg == WM_NCHITTEST) { + LRESULT hit = DefWindowProcW(hwnd, msg, wp, lp); + if ((hit >= HTLEFT && hit <= HTBOTTOMRIGHT) || hit == HTCLOSE + || hit == HTMINBUTTON || hit == HTMAXBUTTON) { + *result = hit; + return TRUE; + } + POINT p = {GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}; + ScreenToClient(hwnd, &p); + int insets[5]; + cn1ChromeInsets(hwnd, insets); + if (!IsZoomed(hwnd) && p.y >= 0 && p.y < insets[2]) { + RECT client; + GetClientRect(hwnd, &client); + int corner = cn1ChromeMetric(hwnd, SM_CXSIZEFRAME) + cn1ChromeMetric(hwnd, SM_CXPADDEDBORDER); + *result = p.x < corner ? HTTOPLEFT : p.x >= client.right - corner ? HTTOPRIGHT : HTTOP; + } else { + *result = cn1ChromeDragPoint(p.x, p.y) ? HTCAPTION : HTCLIENT; + } + return TRUE; + } + if (msg == WM_SETTINGCHANGE || msg == WM_THEMECHANGED) cn1ChromeAppearance(hwnd); + // DWM margins are physical pixels and must follow the new monitor DPI too. + if (msg == WM_ACTIVATE || msg == WM_DWMCOMPOSITIONCHANGED || msg == WM_DPICHANGED) { + MARGINS margins = {0, 0, cn1ChromeMetric(hwnd, SM_CYCAPTION) + cn1ChromeMetric(hwnd, SM_CYSIZEFRAME), 0}; + DwmExtendFrameIntoClientArea(hwnd, &margins); + } + return FALSE; +} +JAVA_VOID com_codename1_impl_windows_WindowsNative_mainWindowTitleBarInsets___int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT array) { + if (array == JAVA_NULL || ((JAVA_ARRAY)array)->length < 5 || !cn1Win.hwnd) return; + int result[5] = {0}; + CN1_YIELD_THREAD; + SendMessageW(cn1Win.hwnd, WM_CN1_CHROME_METRICS, 0, (LPARAM)result); + CN1_RESUME_THREAD; + memcpy(CN1_ARRAY_DATA(array), result, sizeof(result)); +} +JAVA_VOID com_codename1_impl_windows_WindowsNative_mainWindowTitleBarRegions___int_1ARRAY( + CODENAME_ONE_THREAD_STATE, JAVA_OBJECT array) { + int count = array == JAVA_NULL ? 0 : ((JAVA_ARRAY)array)->length; + if (count % 4 != 0 || !cn1Win.hwnd) return; + int* copy = count ? (int*)malloc((size_t)count * sizeof(int)) : NULL; + if (count && !copy) return; + if (count) memcpy(copy, CN1_ARRAY_DATA(array), (size_t)count * sizeof(int)); + if (!PostMessageW(cn1Win.hwnd, WM_CN1_CHROME_REGIONS, count, (LPARAM)copy)) free(copy); +} +} +#endif diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_menu.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_menu.cpp index d725f05c6ed..2fd3254ca7b 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_menu.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_menu.cpp @@ -72,6 +72,13 @@ typedef struct { static CN1MenuItem menuItems[CN1_MENU_MAX_ITEMS]; static int menuItemCount = 0; static HMENU menuBar = NULL; +static BOOL integratedTitleBar = FALSE; + +void cn1WinMenuSetIntegrated(BOOL integrated) { + integratedTitleBar = integrated; + SetMenu(cn1Win.hwnd, integrated ? NULL : menuBar); + DrawMenuBar(cn1Win.hwnd); +} /* The standard top-level menus, in the order Windows applications put them. The hint * strings are Command.DESKTOP_MENU_* verbatim; anything else becomes a top-level menu @@ -263,7 +270,7 @@ static void rebuildMenu(const char* spec) { } } - SetMenu(cn1Win.hwnd, menuBar); + SetMenu(cn1Win.hwnd, integratedTitleBar ? NULL : menuBar); DrawMenuBar(cn1Win.hwnd); if (previous != NULL) { /* After SetMenu, never before: destroying the menu the window is still showing diff --git a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp index be3f7c83142..9ece259eac0 100644 --- a/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp +++ b/Ports/WindowsPort/nativeSources/cn1_windows_window.cpp @@ -684,7 +684,17 @@ int cn1WinHandleGesture(HWND hwnd, int windowId, LPARAM lParam) { /* ------------------------------------------------------------- window proc */ LRESULT CALLBACK cn1WinWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + LRESULT chromeResult; + if (cn1WinChromeMessage(hwnd, msg, wParam, lParam, &chromeResult)) return chromeResult; switch (msg) { + case WM_DPICHANGED: { + RECT* suggested = (RECT*)lParam; + SetWindowPos(hwnd, NULL, suggested->left, suggested->top, + suggested->right - suggested->left, suggested->bottom - suggested->top, + SWP_NOZORDER | SWP_NOACTIVATE); + cn1WinPushEvent(CN1_EVENT_THEME_SETTINGS_CHANGED, 0, 0, (int)LOWORD(wParam)); + return 0; + } case WM_SETTINGCHANGE: case WM_THEMECHANGED: case WM_SYSCOLORCHANGE: @@ -1180,6 +1190,13 @@ JAVA_INT com_codename1_impl_windows_WindowsNative_getDisplayWidth___R_int(CODENA * mm-based theme metrics correctly instead of using the mobile density * heuristic, which over-scales everything. */ JAVA_INT com_codename1_impl_windows_WindowsNative_screenDpi___R_int(CODENAME_ONE_THREAD_STATE) { + // LOGPIXELSX reports system DPI even for a per-monitor-aware window. + typedef UINT (WINAPI *GetDpiFn)(HWND); + GetDpiFn getDpi = (GetDpiFn)GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForWindow"); + if (getDpi != NULL) { + UINT current = getDpi(cn1Win.hwnd); + if (current > 0) return (JAVA_INT)current; + } int dpi = 96; HDC dc = GetDC(cn1Win.hwnd); if (dc != NULL) { diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java index baea4113f90..4c955bcac31 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsImplementation.java @@ -212,7 +212,7 @@ private static byte[] readFully(InputStream i) throws IOException { /** The port's bundled native (material) theme, embedded in the executable. */ private static final String NATIVE_THEME_RES = "windowsNativeTheme.res"; - private int screenDpi = 96; + private volatile int screenDpi = 96; /** * The live implementation instance, captured for {@link #runMainEventLoop()} @@ -810,6 +810,40 @@ void dispatchPointerHover(int windowId, int x, int y, int keyField) { windowPointerHover(windowId, x, y); } + // WM_DPICHANGED carries the new monitor DPI; ordinary appearance events carry zero. + // Publish it before the EDT rebuilds any theme metrics using convertToPixels(). + void dispatchThemeSettingsChanged(int dpi) { + if (dpi > 0 && dpi != screenDpi) { + screenDpi = dpi; + refreshForDpiChange(); + } + Display.getInstance().nativeThemeSettingsChanged(); + } + + void refreshForDpiChange() { + if (!Display.isInitialized()) { + return; + } + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + // A monitor change can leave all appearance settings equal. Rebuild + // physical-unit fonts and invalidate preferred sizes regardless. + com.codename1.ui.plaf.UIManager.getInstance().refreshTheme(); + com.codename1.ui.Form form = Display.getInstance().getCurrent(); + if (form != null) { + form.refreshTheme(true); + form.repaint(); + } + for (com.codename1.ui.Window window : Desktop.getInstance().getWindows()) { + window.refreshTheme(true); + window.revalidateLater(); + window.repaint(); + } + } + }); + } + private void drainInput() { while (WindowsNative.pollEvent(eventScratch)) { int type = eventScratch[0]; @@ -857,7 +891,7 @@ private void drainInput() { Desktop.getInstance().windowCloseRequested(windowId); break; case EVENT_THEME_SETTINGS_CHANGED: - Display.getInstance().nativeThemeSettingsChanged(); + dispatchThemeSettingsChanged(key); break; case EVENT_WINDOW_FOCUS: if (key != 0) { Display.getInstance().nativeThemeSettingsChanged(); } @@ -3311,6 +3345,25 @@ public boolean isAltKeyDown() { return (WindowsNative.currentModifiers() & 4) != 0; } + @Override + public int[] getNativeWindowTitleBarInsets(com.codename1.ui.Component toolbar) { + if (!(toolbar.getTopLevelContainer() instanceof com.codename1.ui.Form) + || toolbar.getComponentForm() instanceof com.codename1.ui.Dialog) { + return null; + } + int[] result = new int[5]; + WindowsNative.mainWindowTitleBarInsets(result); + return result; + } + + @Override + public void setNativeWindowTitleBarRegions(com.codename1.ui.Component toolbar, int[] regions) { + if (toolbar.getTopLevelContainer() instanceof com.codename1.ui.Form + && !(toolbar.getComponentForm() instanceof com.codename1.ui.Dialog)) { + WindowsNative.mainWindowTitleBarRegions(regions); + } + } + // ---- native menu bar ---------------------------------------------------- /// Commands published to the native menu bar, keyed by the id the bar echoes back. @@ -3361,7 +3414,7 @@ public boolean isNativeTitle() { /// after it was dismissed. @Override public void refreshNativeTitle() { - if (!isNativeTitle()) { + if (!isDesktop()) { return; } com.codename1.ui.Form f = getCurrentForm(); diff --git a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java index 3ad27c11040..1aa2ac03da9 100644 --- a/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java +++ b/Ports/WindowsPort/src/com/codename1/impl/windows/WindowsNative.java @@ -214,6 +214,8 @@ public static native int desktopWindowCreate(int windowId, String title, int x, /// Window peers by slot; this is the one the form title goes to in desktop "native" /// title-bar mode, where the CN1 title area is suppressed. A no-op when headless. public static native void mainWindowSetTitle(String title); + public static native void mainWindowTitleBarInsets(int[] result); + public static native void mainWindowTitleBarRegions(int[] regions); public static native void desktopWindowSetTitle(int slot, String title); diff --git a/Ports/iOSPort/nativeSources/CN1TextInputView.h b/Ports/iOSPort/nativeSources/CN1TextInputView.h index 88ccac36c50..55dd85b1592 100644 --- a/Ports/iOSPort/nativeSources/CN1TextInputView.h +++ b/Ports/iOSPort/nativeSources/CN1TextInputView.h @@ -51,9 +51,9 @@ /// Replaces the shadow mirror with the authoritative Java state (text + selection + caret rect in /// screen pixels) without echoing the change back to Java. `seq` is the generation of the last -/// native edit the Java side had applied when it pushed this state; a stale seq only refreshes -/// the caret geometry. -- (void)cn1SyncText:(NSString *)text selStart:(NSInteger)selStart selEnd:(NSInteger)selEnd +/// native edit the Java side had applied when it pushed this state. Returns YES only when +/// the shadow matches this capture, so its geometry can be published on the same main queue. +- (BOOL)cn1SyncText:(NSString *)text selStart:(NSInteger)selStart selEnd:(NSInteger)selEnd caretRect:(CGRect)caretPixels seq:(NSInteger)seq; /// Clears any leftover IME composition state; called when a session starts or stops so a stale marked diff --git a/Ports/iOSPort/nativeSources/CN1TextInputView.m b/Ports/iOSPort/nativeSources/CN1TextInputView.m index d46f475860a..5df8bebb591 100644 --- a/Ports/iOSPort/nativeSources/CN1TextInputView.m +++ b/Ports/iOSPort/nativeSources/CN1TextInputView.m @@ -38,6 +38,7 @@ extern NSString* toNSString(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT o); // Java callbacks (generated for the static methods on IOSImplementation) +extern void com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_INT captureId, JAVA_BOOLEAN accepted); extern void com_codename1_impl_ios_IOSImplementation_tiReplaceRange___int_int_java_lang_String_int(CN1_THREAD_STATE_MULTI_ARG JAVA_INT start, JAVA_INT end, JAVA_OBJECT text, JAVA_INT seq); extern void com_codename1_impl_ios_IOSImplementation_tiSetSelection___int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_INT start, JAVA_INT end, JAVA_INT seq); extern void com_codename1_impl_ios_IOSImplementation_tiCommit___java_lang_String_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT text, JAVA_INT seq); @@ -376,6 +377,7 @@ - (NSRange)clampRange:(NSRange)r { // Java echoes the seq of the last edit it APPLIED with each state push, and cn1SyncText drops // echoes whose seq is stale so a slow round trip can never regress the shadow under fast typing. - (int)cn1NextSeq { + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG 0, JAVA_FALSE); return ++_editSeq; } @@ -661,30 +663,34 @@ - (UITextRange *)characterRangeAtPoint:(CGPoint)point { #pragma mark sync from Java -- (void)cn1SyncText:(NSString *)text selStart:(NSInteger)selStart selEnd:(NSInteger)selEnd +- (BOOL)cn1SyncText:(NSString *)text selStart:(NSInteger)selStart selEnd:(NSInteger)selEnd caretRect:(CGRect)caretPixels seq:(NSInteger)seq { + // Reject stale echoes before changing either text or geometry. + if (seq != (NSInteger)_editSeq) { + return NO; + } + NSInteger len = text != nil ? (NSInteger)text.length : (NSInteger)_shadow.length; + NSInteger s = MAX((NSInteger)0, MIN(selStart, len)); + NSInteger e = MAX((NSInteger)0, MIN(selEnd, len)); + NSRange selection = NSMakeRange(MIN(s, e), (NSUInteger)(s > e ? s - e : e - s)); + if (_markedRange.location != NSNotFound) { + // IME owns the marked text. Its matching Java echo may still refresh layout. + if ((text != nil && ![_shadow isEqualToString:text]) || !NSEqualRanges(selection, _selectedRange)) { + return NO; + } + } else { + if (text != nil) { + [_shadow setString:text]; + } + _selectedRange = selection; + } CGFloat scale = [UIScreen mainScreen].scale; if (scale <= 0) { scale = 1; } _caretRectPoints = CGRectMake(caretPixels.origin.x / scale, caretPixels.origin.y / scale, MAX((CGFloat)2, caretPixels.size.width / scale), caretPixels.size.height / scale); - // A stale echo (the state push for edit N arriving after the user already typed edit N+1) - // must not regress the shadow text or caret; only the caret geometry above is refreshed. - if (seq != (NSInteger)_editSeq) { - return; - } - // Never disturb an active IME composition; only the caret geometry is refreshed above. - if (_markedRange.location != NSNotFound) { - return; - } - if (text != nil) { - [_shadow setString:text]; - } - NSInteger len = (NSInteger)_shadow.length; - NSInteger s = MAX((NSInteger)0, MIN(selStart, len)); - NSInteger e = MAX((NSInteger)0, MIN(selEnd, len)); - _selectedRange = NSMakeRange(MIN(s, e), (NSUInteger)(s > e ? s - e : e - s)); + return YES; } - (void)cn1ResetComposition { @@ -698,7 +704,7 @@ - (void)cn1ResetComposition { #pragma mark - native bridge (declared native in IOSNative.java) -void com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_boolean_java_lang_String_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT constraint, JAVA_BOOLEAN autoCorrect, JAVA_BOOLEAN autoCapitalize, JAVA_BOOLEAN multiline, JAVA_OBJECT initialText, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT actionType) { +void com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_boolean_java_lang_String_int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT constraint, JAVA_BOOLEAN autoCorrect, JAVA_BOOLEAN autoCapitalize, JAVA_BOOLEAN multiline, JAVA_OBJECT initialText, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT actionType, JAVA_INT captureId) { NSString* startText = initialText != NULL ? toNSString(CN1_THREAD_GET_STATE_PASS_ARG initialText) : @""; dispatch_async(dispatch_get_main_queue(), ^{ @autoreleasepool { @@ -777,6 +783,7 @@ void com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_boole cn1TextInputView.returnKeyType = rk; [cn1TextInputView cn1ResetComposition]; [cn1TextInputView cn1SyncText:(startText != nil ? startText : @"") selStart:selStart selEnd:selEnd caretRect:CGRectMake(0, 0, 2, 16) seq:0]; + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_TRUE); UIViewController* vc = [CodenameOne_GLViewController instance]; if (vc != nil) { cn1TextInputView.frame = vc.view.bounds; @@ -804,14 +811,14 @@ void com_codename1_impl_ios_IOSNative_setTextInputBounds___int_int_int_int(CN1_T }); } -void com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT text, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT caretX, JAVA_INT caretY, JAVA_INT caretW, JAVA_INT caretH, JAVA_INT seq) { +void com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT text, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT caretX, JAVA_INT caretY, JAVA_INT caretW, JAVA_INT caretH, JAVA_INT seq, JAVA_INT captureId) { NSString* nsText = text != NULL ? toNSString(CN1_THREAD_GET_STATE_PASS_ARG text) : nil; CGRect caret = CGRectMake(caretX, caretY, caretW, caretH); dispatch_async(dispatch_get_main_queue(), ^{ @autoreleasepool { - if (cn1TextInputView != nil) { - [cn1TextInputView cn1SyncText:nsText selStart:selStart selEnd:selEnd caretRect:caret seq:seq]; - } + BOOL accepted = cn1TextInputView != nil && + [cn1TextInputView cn1SyncText:nsText selStart:selStart selEnd:selEnd caretRect:caret seq:seq]; + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, accepted); } }); } @@ -830,10 +837,14 @@ void com_codename1_impl_ios_IOSNative_stopTextInput__(CN1_THREAD_STATE_MULTI_ARG #else +extern void com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_STATE_MULTI_ARG JAVA_INT captureId, JAVA_BOOLEAN accepted); + // watchOS has no UIKeyInput / UITextInput keyboard support; provide stubs so the symbols still link. -void com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_boolean_java_lang_String_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT constraint, JAVA_BOOLEAN autoCorrect, JAVA_BOOLEAN autoCapitalize, JAVA_BOOLEAN multiline, JAVA_OBJECT initialText, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT actionType) { +void com_codename1_impl_ios_IOSNative_startTextInput___int_boolean_boolean_boolean_java_lang_String_int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT constraint, JAVA_BOOLEAN autoCorrect, JAVA_BOOLEAN autoCapitalize, JAVA_BOOLEAN multiline, JAVA_OBJECT initialText, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT actionType, JAVA_INT captureId) { + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_FALSE); } -void com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT text, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT caretX, JAVA_INT caretY, JAVA_INT caretW, JAVA_INT caretH, JAVA_INT seq) { +void com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT text, JAVA_INT selStart, JAVA_INT selEnd, JAVA_INT caretX, JAVA_INT caretY, JAVA_INT caretW, JAVA_INT caretH, JAVA_INT seq, JAVA_INT captureId) { + com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(CN1_THREAD_GET_STATE_PASS_ARG captureId, JAVA_FALSE); } void com_codename1_impl_ios_IOSNative_setTextInputBounds___int_int_int_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_INT x, JAVA_INT y, JAVA_INT w, JAVA_INT h) { } diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 9ad3e02314f..a6b010be651 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -2480,6 +2480,13 @@ int Java_com_codename1_impl_ios_IOSImplementation_getDisplayWidthImpl() { void Java_com_codename1_impl_ios_IOSImplementation_flushBufferImpl (void* peer, int x, int y, int width, int height) { +#if TARGET_OS_OSX + // This entry point flushes the main PaintSurface. Secondary windows use + // macWindowFlush directly. Creating a window's cached Graphics can leave + // its view selected before the main surface's pending repaint is flushed; + // make the destination explicit so that repaint cannot cover the dialog. + [CN1MacHost sharedHost].activeRenderingView = nil; +#endif //CN1Log(@"Java_com_codename1_impl_ios_IOSImplementation_flushBufferImpl started"); [[CodenameOne_GLViewController instance] flushBuffer:(BRIDGE_CAST CN1Image *)peer x:x y:y width:width height:height]; //CN1Log(@"Java_com_codename1_impl_ios_IOSImplementation_flushBufferImpl finished"); diff --git a/Ports/iOSPort/nativeSources/IOSNative.m b/Ports/iOSPort/nativeSources/IOSNative.m index 4091342e0f5..51ae6fd1002 100644 --- a/Ports/iOSPort/nativeSources/IOSNative.m +++ b/Ports/iOSPort/nativeSources/IOSNative.m @@ -2764,6 +2764,49 @@ JAVA_INT com_codename1_impl_ios_IOSNative_stringWidthNative___long_java_lang_Str //XMLVM_END_WRAPPER } +// One Core Text layout serves every UTF-16 insertion position. Measuring every +// prefix separately makes long editor lines quadratic on each state update. +void com_codename1_impl_ios_IOSNative_textRunPositionsNative___long_java_lang_String_boolean_int_1ARRAY( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG peer, + JAVA_OBJECT textObject, JAVA_BOOLEAN rtl, JAVA_OBJECT positionsObject) { + POOL_BEGIN(); + NSString *text = toNSString(CN1_THREAD_STATE_PASS_ARG textObject); + CN1Font *font = (BRIDGE_CAST CN1Font *)(void *)peer; + if (font == nil) { font = [CN1Font systemFontOfSize:16.0]; } + NSDictionary *attributes = @{ + NSFontAttributeName: font, + (BRIDGE_CAST id)kCTWritingDirectionAttributeName: @[@((rtl ? kCTWritingDirectionRightToLeft + : kCTWritingDirectionLeftToRight) | kCTWritingDirectionOverride)] + }; + NSAttributedString *attributed = [[NSAttributedString alloc] initWithString:text attributes:attributes]; + CTLineRef line = CTLineCreateWithAttributedString((BRIDGE_CAST CFAttributedStringRef)attributed); + JAVA_ARRAY array = (JAVA_ARRAY)positionsObject; + JAVA_ARRAY_INT *positions = (JAVA_ARRAY_INT *)CN1_ARRAY_DATA(array); + NSUInteger count = MIN((NSUInteger)array->length, text.length + 1); + for (NSUInteger i = 0; i < count; i++) { positions[i] = -1; } + // CTLineGetOffsetForStringIndex scans glyphs for each query, so calling it + // once per character is still quadratic even when the CTLine is shared. + // Enumerate the shaped caret edges in one pass instead. + CTLineEnumerateCaretOffsets(line, ^(double offset, CFIndex index, bool leading, bool *stop) { + CFIndex insertion = leading ? index : index + 1; + if (insertion >= 0 && insertion < count && (leading || positions[insertion] == -1)) { + positions[insertion] = (JAVA_ARRAY_INT)offset; + } + }); + // Interior UTF-16 units of a surrogate pair or composed character share its + // logical leading edge. Core Text only enumerates legal cluster boundaries. + JAVA_ARRAY_INT previous = 0; + for (NSUInteger i = 0; i < count; i++) { + if (positions[i] == -1) { positions[i] = previous; } + previous = positions[i]; + } + CFRelease(line); +#if !__has_feature(objc_arc) + [attributed release]; +#endif + POOL_END(); +} + JAVA_INT com_codename1_impl_ios_IOSNative_charWidthNative___long_char(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_LONG n1, JAVA_CHAR n2) { //XMLVM_BEGIN_WRAPPER[com_codename1_impl_ios_IOSNative_charWidthNative___long_char] @@ -9026,7 +9069,12 @@ static void cn1AddFileChooserDocumentType(NSMutableArray *types, CFStringRef tag if (tag == nil || [tag length] == 0) { return; } - CFStringRef uti = UTTypeCreatePreferredIdentifierForTag(tagClass, (CFStringRef)tag, NULL); + // Unknown extensions produce dynamic UTIs. Filesystem URLs classify these + // as data, so creating an unconstrained dynamic UTI makes the picker reject + // the very extension requested by the caller (e.g. a saved .cn1doc file). + // The stable UTI string also works on older iOS versions, without referencing + // the kUTTypeData constant deprecated in iOS 15. + CFStringRef uti = UTTypeCreatePreferredIdentifierForTag(tagClass, (CFStringRef)tag, CFSTR("public.data")); if (uti != NULL) { #if __has_feature(objc_arc) NSString *type = CFBridgingRelease(uti); @@ -9065,7 +9113,14 @@ static void cn1AddFileChooserDocumentType(NSMutableArray *types, CFStringRef tag } #endif +void com_codename1_impl_ios_IOSNative_openFileChooserWithId___java_lang_String_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT accept, JAVA_INT requestId); + void com_codename1_impl_ios_IOSNative_openFileChooser___java_lang_String(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT accept) { + com_codename1_impl_ios_IOSNative_openFileChooserWithId___java_lang_String_int( + CN1_THREAD_STATE_PASS_ARG instanceObject, accept, 0); +} + +void com_codename1_impl_ios_IOSNative_openFileChooserWithId___java_lang_String_int(CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT instanceObject, JAVA_OBJECT accept, JAVA_INT requestId) { #if TARGET_OS_OSX NSString *nsAccept = accept == JAVA_NULL ? nil : toNSString(CN1_THREAD_STATE_PASS_ARG accept); NSArray *documentTypes = cn1FileChooserDocumentTypes(nsAccept); @@ -9083,40 +9138,26 @@ void com_codename1_impl_ios_IOSNative_openFileChooser___java_lang_String(CN1_THR if (documentTypes.count > 0) { panel.allowedFileTypes = documentTypes; } - NSInteger response = [panel runModal]; - NSURL *picked = response == NSModalResponseOK ? panel.URL : nil; - // Called from the AppKit main queue, not the EDT -- the same thread - // contract the UIKit twin of this callback has always had: - // -documentPicker:didPickDocumentsAtURLs: in - // CodenameOne_GLViewController.m calls this very method straight from a - // UIKit main thread delegate, and the EDT is a separate thread on both - // platforms. The share callback in CN1MacShare.m does the same. - // - // An application listener that dispatch_syncs to the main queue from - // here deadlocks on itself, and setURL() does exactly that through the - // shared browser native -- so these two call sites go through - // macFileChooserResult / macCapturePictureResult, which clear - // dropEvents immediately and hand the listener to the EDT. - // - // This used to argue the opposite: that marshalling macOS alone would - // put the port out of step with the iOS twin and the mac share - // callback. Consistency is worth something, but not a deadlock, and - // running an application listener on the EDT is the framework's - // contract rather than a local preference. The iOS twin still fires off - // the EDT and can still deadlock the same way; that is pre-existing and - // wants fixing where every caller benefits. - struct ThreadLocalData* threadStateData = getThreadLocalData(); - com_codename1_impl_ios_IOSImplementation_macFileChooserResult___java_lang_String( - threadStateData, - // path, not absoluteString. fileChooserResult prefixes "file:" and - // unfile() strips it again WITHOUT percent decoding, so an encoded - // URL reached FileSystemStorage.openInputStream() as a literal - // "My%20File.txt" and the documented workflow simply failed on any - // name with a space, a '#' or a non-ASCII character. A single path - // needs no encoding: unlike the multi-select gallery beside it, - // there is no separator here for a filename to collide with. - picked == nil ? JAVA_NULL - : fromNSString(threadStateData, [picked path])); + NSWindow *owner = [CN1MacKeyRenderingHostView() window]; + void (^completed)(NSModalResponse) = ^(NSModalResponse response) { + POOL_BEGIN(); + NSURL *picked = response == NSModalResponseOK ? panel.URL : nil; + [panel orderOut:nil]; + if (owner != nil && owner.visible) [owner makeKeyAndOrderFront:nil]; + // AppKit releases this sheet's owner; Java delivers the path on the EDT. + // FileSystemStorage expects an unescaped path, not a URL string. + struct ThreadLocalData *threadStateData = getThreadLocalData(); + com_codename1_impl_ios_IOSImplementation_macFileChooserResult___java_lang_String_int( + threadStateData, picked == nil ? JAVA_NULL : fromNSString(threadStateData, [picked path]), requestId); + POOL_END(); + }; + if (owner != nil) { + // A document-owned sheet avoids a nested application-modal event loop and + // returns keyboard/accessibility focus to the window that opened it. + [panel beginSheetModalForWindow:owner completionHandler:completed]; + } else { + [panel beginWithCompletionHandler:completed]; + } POOL_END(); #ifndef CN1_USE_ARC [documentTypes release]; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index f4aa49985b2..e4a2a2abdf4 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -799,10 +799,10 @@ public boolean isAsyncEditMode() { * @return The root pane of the form. If there is no layered pane, then this should just * return the content pane. Otherwise it may return the parent of the layered pane and content pane. */ - private static Container getRootPane(Form f) { + private static Container getRootPane(com.codename1.ui.TopLevelContainer f) { Container root = f.getContentPane(); Container parent = null; - while ((parent = root.getParent()) != null && parent != f) { + while ((parent = root.getParent()) != null && parent != f.asContainer()) { root = parent; } return root; @@ -912,10 +912,15 @@ static void showTextEditorAgain() { @Override public int getInvisibleAreaUnderVKB() { + // Secondary scenes reserve space in their own content pane. The main + // form must not shrink in response to another window's keyboard. + if (keyboardLayoutOwner() instanceof com.codename1.ui.Window) { + return 0; + } if (areaUnderVKBOverride >= 0) { return areaUnderVKBOverride; } - if(isAsyncEditMode()) { + if(isAsyncEditMode() || tiClient != null) { return nativeInstance.getVKBHeight(); } return 0; @@ -1045,6 +1050,36 @@ private static void updateNativeTextEditorFrame(boolean requestFocus) { } boolean keyboardShowing; + private volatile KeyboardLayout keyboardLayout; + private KeyboardLayout paddedKeyboardLayout; + + private static class KeyboardLayout { + final com.codename1.ui.TopLevelContainer owner; + float bottomPadding; + byte bottomUnit; + KeyboardLayout(com.codename1.ui.TopLevelContainer owner) { this.owner = owner; } + } + + private com.codename1.ui.TopLevelContainer keyboardLayoutOwner() { + Component client = tiClient instanceof Component ? (Component) tiClient : currentEditing; + com.codename1.ui.TopLevelContainer top = client == null ? null : client.getTopLevelContainer(); + return top != null ? top : Display.getInstance().getCurrent(); + } + + private boolean isKeyboardLayoutOwner(com.codename1.ui.TopLevelContainer owner) { + return owner == keyboardLayoutOwner() + && (!(owner instanceof Form) || owner == Display.getInstance().getCurrent()); + } + + private void restoreKeyboardPadding(KeyboardLayout layout) { + if (layout == null || paddedKeyboardLayout != layout) { + return; + } + Style style = getRootPane(layout.owner).getUnselectedStyle(); + style.setPaddingUnitBottom(layout.bottomUnit); + style.setPadding(Component.BOTTOM, layout.bottomPadding); + paddedKeyboardLayout = null; + } /** * Callback for native. Called when keyboard is shown. Used for async editing @@ -1052,18 +1087,30 @@ private static void updateNativeTextEditorFrame(boolean requestFocus) { */ static void keyboardWillBeShown(){ instance.keyboardShowing = true; - if(nativeInstance.isAsyncEditMode()) { + final KeyboardLayout layout = new KeyboardLayout(instance.keyboardLayoutOwner()); + instance.keyboardLayout = layout; + if(nativeInstance.isAsyncEditMode() || tiClient != null) { // revalidate the parent since the size of form is now larger due to the vkb - final Form current = Display.getInstance().getCurrent(); + final com.codename1.ui.TopLevelContainer current = layout.owner; //final Component currentEditingFinal = instance.currentEditing; if (current != null) { - if(current.isFormBottomPaddingEditingMode()) { + if (current instanceof com.codename1.ui.Window || ((Form) current).isFormBottomPaddingEditingMode()) { Display.getInstance().callSerially(new Runnable() { public void run() { - if (current != null) { - getRootPane(current).getUnselectedStyle().setPaddingUnit(new byte[] {Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS, Style.UNIT_TYPE_PIXELS}); - getRootPane(current).getUnselectedStyle().setPadding(Component.BOTTOM, nativeInstance.getVKBHeight()); - current.revalidate(); + if (instance.keyboardLayout == layout && instance.isKeyboardLayoutOwner(current)) { + instance.restoreKeyboardPadding(instance.paddedKeyboardLayout); + Style style = getRootPane(current).getUnselectedStyle(); + layout.bottomPadding = style.getPaddingFloatValue(false, Component.BOTTOM); + byte[] units = style.getPaddingUnit(); + layout.bottomUnit = units == null ? Style.UNIT_TYPE_PIXELS : units[Component.BOTTOM]; + style.setPaddingUnitBottom(Style.UNIT_TYPE_PIXELS); + style.setPadding(Component.BOTTOM, nativeInstance.getVKBHeight()); + instance.paddedKeyboardLayout = layout; + current.asContainer().revalidate(); + if (tiClient instanceof Component) { + instance.revealTextInputClient(tiClient); + instance.refreshTextInputGeometry(); + } Display.getInstance().callSerially(new Runnable() { public void run() { updateNativeTextEditorFrame(); @@ -1075,15 +1122,19 @@ public void run() { } else { Display.getInstance().callSerially(new Runnable() { public void run() { - if (current != null) { - if (instance.currentEditing != null) { + if (instance.keyboardLayout == layout && instance.isKeyboardLayoutOwner(current)) { + instance.restoreKeyboardPadding(instance.paddedKeyboardLayout); + if (tiClient instanceof Component) { + instance.revealTextInputClient(tiClient); + instance.refreshTextInputGeometry(); + } else if (instance.currentEditing != null) { instance.doNotHideTextEditorSemaphore++; try { instance.currentEditing.requestFocus(); } finally { instance.doNotHideTextEditorSemaphore--; } - current.revalidate(); + current.asContainer().revalidate(); Display.getInstance().callSerially(new Runnable() { public void run() { updateNativeTextEditorFrame(); @@ -1107,20 +1158,28 @@ public void run() { */ static void keyboardWillBeHidden(){ instance.keyboardShowing = false; + final KeyboardLayout layout = instance.keyboardLayout; + instance.keyboardLayout = null; Display.getInstance().callSerially(new Runnable(){ @Override public void run() { - Form current = Display.getInstance().getCurrent(); - if (current != null) { + if (layout == null || layout.owner == null) { + return; + } + instance.restoreKeyboardPadding(layout); + com.codename1.ui.TopLevelContainer current = layout.owner; + // Editing may already have stopped, so resolve layout against the + // saved owner, not the active client. A newer show still takes precedence. + if (instance.keyboardLayout == null) { instance.areaUnderVKBOverride = 0; try { - current.revalidate(); + current.asContainer().revalidate(); //Now that screen size is changed, the scroll positions may // be caught in a negative state, leaving a gap at the // top. //https://github.com/codenameone/CodenameOne/issues/2476 - Accessor.fixNegativeScrolls(current); + if (current instanceof Form) { Accessor.fixNegativeScrolls((Form) current); } } finally { instance.areaUnderVKBOverride = -1; } @@ -1756,6 +1815,63 @@ public void run() { /// text/selection queries are answered from this on the iOS main thread (the EDT is a /// separate ParparVM thread, so reading the live document there would race edits). private static volatile com.codename1.ui.TextInputState tiSnapshot; + private static volatile com.codename1.ui.TextInputGeometry tiGeometry; + // Retain each immutable capture until the matching native main-queue update consumes it. + // A newer EDT paint must not replace geometry still used by the native shadow document. + private static final java.util.Map tiPendingCaptures = + new java.util.HashMap(); + private static int tiCaptureId; + + private static class TextInputCapture { + private final com.codename1.ui.TextInputState state; + private final com.codename1.ui.TextInputGeometry geometry; + + TextInputCapture(com.codename1.ui.TextInputState state, com.codename1.ui.TextInputClient client) { + this.state = state; + geometry = com.codename1.ui.TextInputGeometry.capture(client); + } + } + + private static int queueTextInputCapture(com.codename1.ui.TextInputState state, + com.codename1.ui.TextInputClient client) { + TextInputCapture capture = new TextInputCapture(state, client); + synchronized (tiPendingCaptures) { + int id = ++tiCaptureId; + if (id == 0) { + id = ++tiCaptureId; + } + tiPendingCaptures.put(id, capture); + return id; + } + } + + /// Native main-queue acknowledgement: publish only with the matching shadow state. + /// Rejected echoes discard their capture; callbacks from closed sessions are ignored. + public static void tiPublishGeometry(int captureId, boolean accepted) { + synchronized (tiPendingCaptures) { + if (captureId == 0) { + // A native edit has advanced the shadow ahead of any Java echo. + tiSnapshot = null; + tiGeometry = null; + return; + } + TextInputCapture capture = tiPendingCaptures.remove(captureId); + if (capture != null) { + tiSnapshot = accepted ? capture.state : null; + tiGeometry = accepted ? capture.geometry : null; + } + } + } + + private static void clearTextInputCaptures() { + synchronized (tiPendingCaptures) { + tiPendingCaptures.clear(); + tiSnapshot = null; + tiGeometry = null; + } + } + + private int[] tiBounds; /// Generation of the last native-originated edit APPLIED to the Java document. Echoed back /// with every state push so the native shadow can drop echoes that predate a newer local /// edit (fast typing would otherwise regress the shadow text and caret). @@ -1770,7 +1886,8 @@ public boolean isTextInputSupported() { public Object startTextInput(com.codename1.ui.TextInputClient client, com.codename1.ui.TextInputConfig config) { tiClient = client; com.codename1.ui.TextInputState st = client.getEditingState(); - tiSnapshot = st; + clearTextInputCaptures(); + int captureId = queueTextInputCapture(st, client); tiNativeSeq = 0; int constraint = config != null ? config.getConstraint() : 0; boolean autoCorrect = config == null || config.isAutoCorrect(); @@ -1778,7 +1895,10 @@ public Object startTextInput(com.codename1.ui.TextInputClient client, com.codena boolean multiline = config == null || config.isMultiline(); nativeInstance.startTextInput(constraint, autoCorrect, autoCap, multiline, st.getText(), st.getSelectionStart(), st.getSelectionEnd(), - config != null ? config.getActionType() : com.codename1.ui.TextInputConfig.ACTION_DEFAULT); + config != null ? config.getActionType() : com.codename1.ui.TextInputConfig.ACTION_DEFAULT, captureId); + tiBounds = null; + refreshTextInputBounds(client); + tiKeepNativeCallbacksAlive(); return client; } @@ -1791,19 +1911,38 @@ public void updateTextInputState(Object handle, com.codename1.ui.TextInputState } com.codename1.ui.TextInputClient c = tiClient; int[] r = c != null ? c.getCaretRect() : new int[]{0, 0, 0, 0}; - tiSnapshot = state; + int captureId = queueTextInputCapture(state, c); + refreshTextInputBounds(c); nativeInstance.updateTextInputState(state.getText(), state.getSelectionStart(), state.getSelectionEnd(), - r[0], r[1], r[2], r[3], tiNativeSeq); - // Push the editor component's absolute bounds so the native input view can gate its touch region to - // the editor (letting iOS draw its selection loupe/handles there) while passing other touches through. - if (c instanceof com.codename1.ui.Component) { - com.codename1.ui.Component comp = (com.codename1.ui.Component) c; - nativeInstance.setTextInputBounds(comp.getAbsoluteX(), comp.getAbsoluteY(), - comp.getWidth(), comp.getHeight()); - } + r[0], r[1], r[2], r[3], tiNativeSeq, captureId); tiKeepNativeCallbacksAlive(); } + // Called on the EDT after layout/painting as well as text changes. UIKit only reads the + // volatile immutable result; it never synchronously waits for the EDT to measure fonts. + private void refreshTextInputGeometry() { + com.codename1.ui.TextInputClient client = tiClient; + if (client == null) { + return; + } + updateTextInputState(client, client.getEditingState()); + } + + private void refreshTextInputBounds(com.codename1.ui.TextInputClient client) { + if (client instanceof Component) { + Component component = (Component) client; + int x = component.getAbsoluteX() + component.getScrollX(); + int y = component.getAbsoluteY() + component.getScrollY(); + int width = component.getWidth(); + int height = component.getHeight(); + if (tiBounds == null || tiBounds[0] != x || tiBounds[1] != y + || tiBounds[2] != width || tiBounds[3] != height) { + tiBounds = new int[]{x, y, width, height}; + nativeInstance.setTextInputBounds(x, y, width, height); + } + } + } + // The ti* callbacks below are invoked ONLY from native code (CN1TextInputView.m). ParparVM's dead // code eliminator does not reliably retain such methods from the native-source scan, so it stubs some // of them to an empty body and the native call silently does nothing. Referencing them from this @@ -1817,11 +1956,13 @@ public void updateTextInputState(Object handle, com.codename1.ui.TextInputState private static void tiKeepNativeCallbacksAlive() { if (tiKeepAlive) { + tiPublishGeometry(0, false); tiCommit("", 0); tiSetComposing("", 0, 0); tiFinishComposing(0); tiDeleteBackward(0); tiKeyCommand(0, 0); + tiKeyCommandWithSequence(0, 0, 0); tiEditorAction(0); tiReplaceRange(0, 0, "", 0); tiSetSelection(0, 0, 0); @@ -1842,6 +1983,8 @@ public void stopTextInput(Object handle) { return; } tiClient = null; + clearTextInputCaptures(); + tiBounds = null; nativeInstance.stopTextInput(); } @@ -1915,6 +2058,26 @@ public void run() { }); } + /// macOS commands wait for this acknowledged state before replaying native edits. + public static void tiKeyCommandWithSequence(final int command, final int modifiers, final int seq) { + final com.codename1.ui.TextInputClient client = tiClient; + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (client == null || client != tiClient) { + return; + } + try { + client.onKeyCommand(command, modifiers); + } finally { + if (client == tiClient) { + tiNativeSeq = seq; + instance.updateTextInputState(client, client.getEditingState()); + } + } + } + }); + } + /// Callback from native: keyboard return / action key. public static void tiEditorAction(final int action) { final com.codename1.ui.TextInputClient c = tiClient; @@ -1931,7 +2094,7 @@ public void run() { // ---- UITextInput text/selection/geometry queries. These arrive synchronously on the iOS // MAIN thread while the EDT (a separate ParparVM thread) may be mid-edit, so text and // selection are answered from the volatile immutable snapshot pushed with each state - // update, and layout-dependent geometry marshals onto the EDT. ---- + // update, including immutable layout geometry captured on the EDT. ---- /// Total document length in UTF-16 characters. public static int tiTextLength() { @@ -1963,60 +2126,22 @@ public static int tiSelectionEnd() { return s != null ? s.getSelectionEnd() : 0; } - /// Runs a geometry query against the live editor on the EDT and returns its result. The main - /// thread never holds locks the EDT needs (all native UI work is dispatched asynchronously), - /// so the bounded wait cannot deadlock. - private static int[] tiGeometryQuery(final TiGeometryOp op) { - final com.codename1.ui.TextInputClient c = tiClient; - if (c == null) { - return new int[0]; - } - Display d = Display.getInstance(); - if (d.isEdt()) { - return op.run(c); - } - final int[][] out = new int[1][]; - d.callSeriallyAndWait(new Runnable() { - public void run() { - if (c == tiClient) { - out[0] = op.run(c); - } - } - }); - return out[0] != null ? out[0] : new int[0]; - } - - private interface TiGeometryOp { - int[] run(com.codename1.ui.TextInputClient c); - } - - /// Caret rectangle for an offset, absolute screen pixels `{x, y, w, h}`. + // UIKit geometry callbacks must not synchronously wait for the EDT. Rendering, font + // metrics and other EDT operations can synchronously dispatch to the main queue. + // Geometry is captured with each editing-state update and queried as immutable data. public static int[] tiRectForOffset(final int offset) { - int[] r = tiGeometryQuery(new TiGeometryOp() { - public int[] run(com.codename1.ui.TextInputClient c) { - return c.rectForOffset(offset); - } - }); - return r.length >= 4 ? r : new int[]{0, 0, 0, 0}; + com.codename1.ui.TextInputGeometry g = tiGeometry; + return g == null ? new int[]{0, 0, 0, 0} : g.rectForOffset(offset); } - /// Document offset nearest an absolute screen point (pixels). public static int tiOffsetAtPoint(final int x, final int y) { - int[] r = tiGeometryQuery(new TiGeometryOp() { - public int[] run(com.codename1.ui.TextInputClient c) { - return new int[]{c.offsetAtPoint(x, y)}; - } - }); - return r.length > 0 ? r[0] : 0; + com.codename1.ui.TextInputGeometry g = tiGeometry; + return g == null ? 0 : g.offsetAtPoint(x, y); } - /// Selection rectangles for `[start, end)`, flat array of absolute-pixel `{x, y, w, h, ...}`. public static int[] tiSelectionRects(final int start, final int end) { - return tiGeometryQuery(new TiGeometryOp() { - public int[] run(com.codename1.ui.TextInputClient c) { - return c.selectionRects(start, end); - } - }); + com.codename1.ui.TextInputGeometry g = tiGeometry; + return g == null ? new int[0] : g.selectionRects(start, end); } /// Callback from native: replace a range with text (range based edit). @@ -2041,8 +2166,12 @@ public static void tiSetSelection(final int start, final int end, final int seq) } Display.getInstance().callSerially(new Runnable() { public void run() { + if (c != tiClient) { + return; + } tiNativeSeq = seq; c.setSelectionRange(start, end); + instance.updateTextInputState(c, c.getEditingState()); } }); } @@ -2126,7 +2255,8 @@ private boolean isDirectToDrawable() { /// mode exists to make. @Override public void paintDirty() { - if (isDirectToDrawable() && hasPendingPaints()) { + boolean painted = hasPendingPaints(); + if (isDirectToDrawable() && painted) { Form f = Display.getInstance().getCurrent(); if (f != null) { // Painted HERE rather than enqueued. repaint(f) would append, @@ -2149,6 +2279,9 @@ public void paintDirty() { } } super.paintDirty(); + if (painted && tiClient != null) { + refreshTextInputGeometry(); + } } public void flushGraphics(int x, int y, int width, int height) { @@ -5153,6 +5286,13 @@ public int stringWidth(Object nativeFont, String str) { return stringWidthNative(fnt.peer, str); } + @Override + public int[] getTextRunPositions(Object nativeFont, String text, boolean rtl) { + int[] positions = new int[text.length() + 1]; + nativeInstance.textRunPositionsNative(f(nativeFont).peer, text, rtl, positions); + return positions; + } + class FontStringCache { String txt; long peer; @@ -5936,10 +6076,10 @@ private static int peerWindowId(Component cmp) { /// native's dispatch_sync back onto the main queue it was already occupying /// and deadlocked. /// - /// dropEvents is cleared HERE, immediately, and only the listener is - /// marshalled. That flag suppresses input while a modal picker is up, and - /// deferring it to the EDT would leave events dropped for a turn after the - /// picker closed. + /// AppKit scopes input suppression to each document sheet's owner. These + /// requests must not change the global dropEvents flag: another window can + /// remain interactive while a sheet is open, and unrelated capture requests + /// own their own suppression lifetime. Only the listener is marshalled. /// /// New methods rather than a change to the shared ones, so the iOS document /// picker and the Mac share callback keep the behaviour they have. Note @@ -5947,13 +6087,14 @@ private static int peerWindowId(Component cmp) { /// deadlock the same way -- that is pre-existing, framework-wide, and wants /// fixing inside fileChooserResult/capturePictureResult where every caller /// benefits. - public static void macFileChooserResult(final String r) { - dropEvents = false; - // Bound to the request NOW, on the thread the native answered on, - // rather than read later from the shared slot: by the time the EDT runs - // the delivery a second openFileChooser() may have replaced it, and - // this result would then fire the wrong listener and orphan its own. - final EventDispatcher target = macDequeue(macFileChooserQueue); + public static void macFileChooserResult(final String r, final int requestId) { + final EventDispatcher target; + synchronized (macFileChooserRequests) { + target = macFileChooserRequests.remove(requestId); + } + if (target == null) { + return; // Duplicate or unknown completion cannot consume another request. + } if (target != null && target == fileChooserCallback) { // Nothing else has claimed the slot, so keep it in step: the shared // path clears it after delivering, and leaving a fired dispatcher @@ -5971,7 +6112,7 @@ public void run() { fileChooserResult(r, target); } - /// @see #macFileChooserResult(String) + /// @see #macFileChooserResult(String, int) public static void macCapturePictureResult(final String r) { dropEvents = false; final MacCaptureRequest request = macDequeueCapture(); @@ -6047,13 +6188,13 @@ private static void capturePictureResult(String r, EventDispatcher captureCallba * Callback for the native document picker. */ public static void fileChooserResult(String r) { + dropEvents = false; fileChooserResult(r, fileChooserCallback); fileChooserCallback = null; } /// @see #capturePictureResult(String, EventDispatcher) private static void fileChooserResult(String r, EventDispatcher fileChooserCallback) { - dropEvents = false; if(fileChooserCallback != null) { if(r != null) { if(r.startsWith("file:")) { @@ -6110,30 +6251,13 @@ public static void captureMovieResult(String r) { private static EventDispatcher captureCallback; private static EventDispatcher fileChooserCallback; - /// The dispatchers of chooser requests the native has not answered yet, - /// oldest first. macOS only. + /// Outstanding macOS chooser dispatchers, keyed by request identity. /// - /// The shared callbacks above are ONE slot read at DELIVERY time, which is - /// only sound while a request cannot be outstanding when the next is made. - /// It can here: openFileChooser() returns as soon as it has queued the - /// panel, so two calls in a single EDT turn overwrite the slot before - /// either answer arrives -- the first panel's selection is then delivered - /// to the SECOND listener and clears the slot, and the second selection - /// finds it null and is dropped. Neither listener hears its own result and - /// one hears nothing at all. - /// - /// A queue rather than a single slot, because pairing by ORDER is exactly - /// right on this port: the panel runs through -[NSOpenPanel runModal], - /// which is application modal on the main queue, so a second panel cannot - /// start until the first returns and the answers come back in the order the - /// requests were made. - /// - /// iOS keeps the single slot. Its pickers are presented rather than run - /// modally and its result path is the shared one; changing it belongs with - /// a fix to fileChooserResult/capturePictureResult where every caller - /// benefits, not smuggled in here. - private static final java.util.ArrayList macFileChooserQueue = - new java.util.ArrayList(); + /// Document-owned sheets can finish in any order across windows. Each native + /// completion carries the identity assigned before its panel was presented. + private static final java.util.HashMap macFileChooserRequests = + new java.util.HashMap(); + private static int macFileChooserSequence; /// One outstanding capture request: who asked, and what SHAPE of answer it /// asked for. @@ -6177,30 +6301,6 @@ private static MacCaptureRequest macDequeueCapture() { } } - /// Remembers a chooser request's dispatcher so its own result can find it. - private static void macEnqueue(java.util.ArrayList queue, - EventDispatcher dispatcher) { - if (!isMacPlatformStatic() || dispatcher == null) { - return; - } - synchronized (queue) { - queue.add(dispatcher); - } - } - - /// The dispatcher for the oldest unanswered request, or null when there is - /// none -- a result the framework never asked for, which is dropped exactly - /// as the single-slot path dropped it. - private static EventDispatcher macDequeue( - java.util.ArrayList queue) { - synchronized (queue) { - if (queue.isEmpty()) { - return null; - } - return queue.remove(0); - } - } - /** * Captures a photo and notifies with the image data when available * @param response callback for the resulting image @@ -6629,11 +6729,29 @@ public void openGallery(ActionListener response, int type) { @Override public void openFileChooser(ActionListener response, String accept) { - fileChooserCallback = new EventDispatcher(); - fileChooserCallback.addListener(response); - macEnqueue(macFileChooserQueue, fileChooserCallback); - nativeInstance.openFileChooser(accept); - dropEvents = true; + EventDispatcher callback = new EventDispatcher(); + callback.addListener(response); + fileChooserCallback = callback; + if (isMacPlatform()) { + final int requestId; + synchronized (macFileChooserRequests) { + requestId = ++macFileChooserSequence; + macFileChooserRequests.put(requestId, callback); + } + try { + nativeInstance.openFileChooserWithId(accept, requestId); + } catch (RuntimeException failure) { + synchronized (macFileChooserRequests) { + macFileChooserRequests.remove(requestId); + } + if (fileChooserCallback == callback) { fileChooserCallback = null; } + throw failure; + } + if (tiKeepAlive) { macFileChooserResult(null, 0); } + } else { + dropEvents = true; + nativeInstance.openFileChooser(accept); + } } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 44e1946e02f..711e25ad15a 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -35,7 +35,7 @@ /** * Abstraction of the underlying native API's */ -public final class IOSNative { +public class IOSNative { native long beginBackgroundTask(); @@ -79,8 +79,8 @@ native void editStringAt(int x, int y, int w, int h, long peer, boolean singleLi int color, long imagePeer, int padTop, int padBottom, int padLeft, int padRight, String hint, int hintColor, boolean showToolbar, boolean blockCopyPaste, int alignment, int verticalAlignment, boolean returnExitsEditing); - native void startTextInput(int constraint, boolean autoCorrect, boolean autoCapitalize, boolean multiline, String initialText, int selStart, int selEnd, int actionType); - native void updateTextInputState(String text, int selStart, int selEnd, int caretX, int caretY, int caretW, int caretH, int seq); + native void startTextInput(int constraint, boolean autoCorrect, boolean autoCapitalize, boolean multiline, String initialText, int selStart, int selEnd, int actionType, int captureId); + native void updateTextInputState(String text, int selStart, int selEnd, int caretX, int caretY, int caretW, int caretH, int seq, int captureId); native void setTextInputBounds(int x, int y, int w, int h); native void stopTextInput(); native void resizeNativeTextView(int x, int y, int w, int h, int padTop, int padRight, int padBottom, int padLeft); @@ -140,6 +140,7 @@ native void editStringAt(int x, int y, int w, int h, long peer, boolean singleLi native void nativeDrawImageRoundedMutable(long peer, int alpha, int x, int y, int width, int height, int renderingHints, float cornerRadius); native void nativeTileImageGlobal(long peer, int alpha, int x, int y, int width, int height); native int stringWidthNative(long peer, String str); + native void textRunPositionsNative(long peer, String text, boolean rtl, int[] positions); native int charWidthNative(long peer, char ch); native int getFontHeightNative(long peer); native int fontAscentNative(long peer); @@ -696,6 +697,7 @@ native void prepareNativeDrag(String mimeTypes, int allowedActions, byte[] dragI native void captureCamera(boolean movie, int quality, int duration); native void openGallery(int type); native void openFileChooser(String accept); + native void openFileChooserWithId(String accept, int requestId); // Low-level camera API (com.codename1.camera). Backed by CN1Camera.m // which wraps AVCaptureSession. The IOSCameraImpl class on the Java side diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/DesktopTitleContent.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/DesktopTitleContent.java new file mode 100644 index 00000000000..c48480e279f --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/DesktopTitleContent.java @@ -0,0 +1,52 @@ +/* + * 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 Codename One 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; + +import com.codename1.ui.Button; +import com.codename1.ui.Command; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Label; +import com.codename1.ui.Toolbar; +import com.codename1.ui.layouts.BorderLayout; + +public final class DesktopTitleContent { + private DesktopTitleContent() { + } + + // tag::desktop-title-content[] + public static Form createEditor(Command save, Command search) { + Display.getInstance().setProperty("desktop.titleBar", "custom"); + Toolbar.setGlobalToolbar(true); + Form editor = new Form("Quarterly report", new BorderLayout()); + Container title = new Container(new BorderLayout()); + title.add(BorderLayout.WEST, new Button(save)); + title.add(BorderLayout.CENTER, new Label("Quarterly report")); + title.add(BorderLayout.EAST, new Button(search)); + editor.getToolbar().setTitleComponent(title); + return editor; + } + // end::desktop-title-content[] +} diff --git a/docs/developer-guide/Desktop-Integration.asciidoc b/docs/developer-guide/Desktop-Integration.asciidoc index 2d59c54bdb0..c2a50cbb736 100644 --- a/docs/developer-guide/Desktop-Integration.asciidoc +++ b/docs/developer-guide/Desktop-Integration.asciidoc @@ -46,7 +46,7 @@ The window chrome is selected by the `desktop.titleBar` setting, which takes one |=== |`native` |Hide the CN1 `Toolbar`. The `Form` title goes into the OS window title bar and the toolbar's commands are bridged to a native menu bar. This is the default for newly generated apps. -|`custom` |Undecorated window (no OS title bar) where the CN1 `Toolbar` itself acts as the window's +|`custom` |Application-drawn title content where the CN1 `Toolbar` acts as the window's title bar: it shows the `Form` title and side menu, dragging it moves the window, and the window is resized by dragging its edges. The commands are bridged to the native menu bar *and* remain in the toolbar's side menu. @@ -82,8 +82,21 @@ ever take effect on the desktop. ==== Enabling it on the native macOS build The local `mac-source` build runs through ParparVM, where the chrome is the application's own -`NSWindow` and `NSMenu`. There it's read as a runtime property and defaults to `toolbar`, so -existing apps are unaffected. Opt in from your app before the first `Form` is shown: +`NSWindow` and `NSMenu`. Set `codename1.arg.desktop.titleBar=custom` in +`codenameone_settings.properties` to draw title content in the toolbar, or use `native` for the +standard OS title bar. The generated launcher applies the build hint before calling the +application's `init()` method, so the first toolbar uses the requested mode. Without a hint the +installed theme chooses the mode: the bundled Aqua theme requests `native`. If the theme doesn't +specify a mode, the port supplies its fallback. + +In `custom` mode, macOS retains its native close, minimize and zoom controls and window resizing. +Dragging an empty part of the toolbar uses the OS window drag operation. Double-clicking it follows +the user's macOS title-bar preference. Buttons (including disabled buttons), editable titles, +scrollable regions and other interactive descendants are excluded from the drag area. The toolbar +publishes these regions to the native port after layout; applications don't need their own window +drag listeners. The native Windows and Linux ports use the same toolbar contract, as described below. + +The setting can also be supplied as a runtime property before constructing the first `Form`: [source,java] ----- @@ -94,9 +107,75 @@ NOTE: On a phone or tablet iOS build these properties are ignored. The legacy Ma also honors them, through `UIWindowScene` and `UIMenuBuilder` rather than AppKit; see <<_working_with_mac_catalyst>>. +==== Integrated title content on native desktops + +Use `desktop.titleBar=custom` when document actions, a search field, or other application controls +belong in the title area. Configure the toolbar with `setTitleComponent()` and the usual left/right +bar commands. The framework reserves space for the operating system's window controls; don't add +fixed padding for traffic lights or caption buttons. Theme padding is additional application spacing. + +[source,java] +----- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/DesktopTitleContent.java[tag=desktop-title-content,indent=0] +----- + +The example belongs in the normal application lifecycle, after Codename One has initialized the +display. Applications don't call `Display.init()`. The build hint can select the mode before +`init()` runs; a runtime property must be set before constructing the first form. + +[cols="1,3"] +|=== +|Native port |Window behavior in `custom` mode +|macOS |AppKit retains the real traffic-light buttons, resizable frame and native menu bar. +The toolbar occupies the full-size content area. Empty title space uses AppKit dragging and the +user's configured double-click action. +|Windows |The Desktop Window Manager retains the caption buttons. Native hit testing routes empty +title space to caption dragging and routes the edges to resizing. Windows handles maximize, +restore, minimize, close and the system menu. Application controls remain client input targets. +Caption metrics follow the window DPI; caption appearance follows the system light/dark setting. +|Linux |A `GtkHeaderBar` hosts the toolbar's rendered content beside real GTK window controls. +GTK chooses their arrangement from the desktop settings and handles title gestures and window +commands. The header and document share one Java component tree and rendering surface. +|=== + +On Windows, the integrated mode omits the separate Win32 menu row, which would overlap the custom +caption. Toolbar commands remain available through their application controls and side menu, and +native command accelerators remain registered. Use `native` mode for the traditional Win32 menu +bar. On Linux, the native GTK menu row remains below the integrated header. The macOS menu stays +in the system menu bar. If an application hides the toolbar's side menu, it must provide another +visible way to invoke those commands, such as its ribbon. + +Reserved space uses physical left/right edges, independent of a document's text direction. The +framework accounts for native controls while measuring and laying out the toolbar, and restores +the application's padding values and units afterward. Resize, DPI and native theme changes can +change this space. Avoid forcing the toolbar below its preferred height. + +Interactive children, including disabled buttons, are excluded from native dragging. Plain +`Label` and `Container` instances allow window gestures on their passive backgrounds. Pointer, +long-press, context-menu and stylus listeners keep their input. Subclasses and other component +types retain input by default, including those that override pointer methods. For a custom +component that only draws a title background, call `Toolbar.setTitleBarDragRegion(component, true)` +to allow dragging there; interactive children and listeners remain excluded. Passing `false` +also excludes a plain label or container. Hiding or removing the toolbar clears its drag regions; the Windows and Linux ports +restore their ordinary title area. When DWM composition is unavailable, Windows retains its +standard decorated frame instead of removing the user's window controls. + +This integration applies to the main application form in native desktop builds. Separate +`Window` and dialog peers retain their own native decorations. Phone/tablet UI and browser pages +use their existing toolbar behavior: this feature doesn't open browser popup windows or attempt +to control browser chrome. The Java SE desktop port retains its existing custom-title behavior. + +For port implementers, `getNativeWindowTitleBarInsets(Component)` supplies physical left, right, +top and bottom insets followed by the minimum title-band height, all in component pixels. +`setNativeWindowTitleBarRegions(Component, int[])` receives the full band followed by interactive +exclusion rectangles. An empty array clears the regions. These calls originate on the EDT; +ports must copy arrays before asynchronous native work and must not call Java UI code from a +synchronous native geometry query. The AppKit port reads a cached geometry snapshot so layout +can't deadlock against the native rendering thread. + === Native menu bar and command placement hints -When the `Toolbar` is hidden (`native` or `custom` mode), the commands you add through the +In `native` and `custom` title-bar modes, the commands you add through the `Toolbar` / `Form` command API are bridged to the platform's native menu bar - a Swing `JMenuBar` (which becomes the macOS screen menu) on the Java SE build, and an `NSMenu` on the native macOS build. Selecting a menu item runs the command's `actionPerformed` on the Codename One EDT, exactly as a diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index c2f59920196..e0b1f80ea3e 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -71,11 +71,23 @@ Treat that call the way you'd treat any other decision to expose a control surfa === Built-in user interface tools -Every server registers a small set of tools that read and drive the screen through the accessibility tree. An agent calls `ui_snapshot` to get the semantics tree of the current form as JSON, including the identifier, role, label, value, and state of each node, and the identifiers of the actions each node supports. +Every server registers a small set of tools that read and drive the screen through the accessibility tree. An agent calls `ui_snapshot` to get the semantics tree of the focused top-level container as JSON, including the identifier, role, label, value, and state of each node, and the identifiers of the actions each node supports. The agent drives the screen with `ui_perform_action`, which performs an action such as `activate` or `setText` on a node from the snapshot. The convenience tools `ui_activate` and `ui_set_text` wrap the common cases, and `ui_find` locates nodes by identifier, by label, or by screen coordinate. -Actions run on the Codename One EDT, so an agent never touches the live component tree directly. Each action returns whether it succeeded together with a fresh snapshot. +Call `ui_windows` to list the main form and desktop windows, with their `windowId`, `title`, +`visible`, `focused` and `blocked` fields. The snapshot, find and action tools accept an optional +`windowId`: omit it to use the focused top-level container, use `0` for the current main form, or +use a positive ID from `ui_windows` for a visible desktop window. This allows an agent to inspect +and operate a native Codename One dialog explicitly. Actions against a window blocked by a modal +dialog are rejected. Operating-system file pickers are outside the Codename One semantics tree. + +Actions run on the Codename One EDT, so an agent never touches the live component tree directly. +A completed action returns `success` and a fresh `snapshot`, with `pending:false`. If an action opens +a blocking modal dialog, its nested event loop can return a snapshot with `pending:true` and +`success:null` while the original handler is still running. This keeps the MCP connection available: +use subsequent calls to inspect and dismiss the dialog, then verify the resulting state. A pending +response isn't confirmation that the original action completed successfully. === Publishing application tools @@ -88,7 +100,7 @@ The server merges these tools with the built-in tools when a host lists the avai === Screenshots and the simulator -The server exposes a screenshot of the current form as an MCP image resource, so a vision capable model can see the screen alongside the semantic tree. The resource is enabled by default and can be disabled per server. +The server exposes a screenshot of the focused top-level container as an MCP image resource, so a vision capable model can see the screen alongside the semantic tree. The resource is enabled by default and can be disabled per server. The JavaSE simulator drives the same MCP menu described above, so an application can be exposed and driven from the running simulator during development. diff --git a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc index 45cbefcbf7e..9e3ff072f93 100644 --- a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc +++ b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc @@ -332,6 +332,28 @@ composing text, preserve UTF-16 offsets, map native navigation and clipboard act `TextInputClient.KEY_*`, update native selection state after programmatic edits, and anchor candidate UI with the supplied caret geometry. +Some native protocols, including iOS `UITextInput`, request geometry synchronously on the native +UI thread. That thread must not wait for the Codename One EDT: the EDT may already be waiting for +native drawing or font measurement. The iOS port captures an immutable `TextInputGeometry` on the +EDT when starting or updating input and after painting layout or scroll changes, then answers native +geometry queries from that snapshot. Native selection changes also refresh the snapshot after the +editor scrolls its caret into view. + +Custom editors can implement `TextInputGeometry.Provider` to collect visible caret positions in a +single layout pass. `getGeometrySnapshot()` runs on the EDT and returns a non-null immutable +snapshot. Caret records contain a UTF-16 document offset followed by absolute screen pixel +coordinates `{offset, x, y, width, height}`; selection rectangles use `{x, y, width, height}`. +Include visible insertion positions in document order, including line endpoints, and supply the +active caret as the fallback for offsets outside the snapshot. The default capture samples a +bounded range around the visible area and caret. Editors with expensive per-offset geometry should +provide their own snapshot to avoid repeating layout work for each character. + +The built-in `EditorView`, `CodeView` and `RichView` collect their visible geometry by line and +font run. `Font.getTextRunPositions(text, rtl)` lets the Apple ports shape each run once with Core +Text, including styled and directional runs, instead of measuring thousands of prefixes on every +input update. Other ports have a compatibility fallback. Editor subclasses that change character +measurement should override `measureLineCaretPositions()` alongside `measureColumnX()`. + A port with a better platform editor can instead supply a native peer and implement the existing semantic command/query channel. In both cases application code, change listeners, ready handling, and asynchronous queries remain the same. diff --git a/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java index 0a2772f5601..b95a76d0738 100644 --- a/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java +++ b/maven/build-engine/src/main/java/com/codename1/builders/MacOSNativeBuilder.java @@ -817,6 +817,7 @@ void writeStub(BuildRequest request, File stubSource, File classesDir, // makes the uploaded mapping unreachable -- the failure is a // stack trace nobody can retrace, months later. + hardeningRuntimeProperties(request) + + desktopTitleBarStubProperty(request) + svgRegistryInstall + " i.init(this);\n" + createStartInvocation(request, "i") diff --git a/maven/build-engine/src/test/java/com/codename1/builders/MacOSStubThemeTest.java b/maven/build-engine/src/test/java/com/codename1/builders/MacOSStubThemeTest.java index 11276d6d75f..886c7e114bd 100644 --- a/maven/build-engine/src/test/java/com/codename1/builders/MacOSStubThemeTest.java +++ b/maven/build-engine/src/test/java/com/codename1/builders/MacOSStubThemeTest.java @@ -53,6 +53,21 @@ */ class MacOSStubThemeTest { + @Test + void customTitleBarReachesTheApplicationBeforeInit(@TempDir Path tmp) throws Exception { + Map hints = new HashMap(); + hints.put("desktop.titleBar", "CUSTOM"); + String stub = generateStub(tmp, hints); + String property = "Display.getInstance().setProperty(\"desktop.titleBar\", \"custom\")"; + assertTrue(stub.contains(property)); + assertTrue(stub.indexOf(property) < stub.indexOf("i.init(this)")); + } + + @Test + void absentTitleBarHintKeepsTheThemeInControl(@TempDir Path tmp) throws Exception { + assertFalse(generateStub(tmp, new HashMap()).contains("desktop.titleBar")); + } + @Test void theGeneratedStubInstallsTheAquaThemeByDefault(@TempDir Path tmp) throws Exception { String stub = generateStub(tmp, new HashMap()); @@ -176,6 +191,9 @@ public String get(String key, String defaultValue) { request.setPackageName("com.example.app"); request.setMainClass("MyApp"); request.setVersion("1.0"); + for (Map.Entry hint : raw.entrySet()) { + request.putArgument(hint.getKey(), hint.getValue()); + } MacOSNativeBuilder builder = new MacOSNativeBuilder(); builder.setCodenameOneJar(new File("../core/target/classes")); diff --git a/maven/core-unittests/pom.xml b/maven/core-unittests/pom.xml index aadaa09d05b..fa0bb8cdf01 100644 --- a/maven/core-unittests/pom.xml +++ b/maven/core-unittests/pom.xml @@ -167,6 +167,13 @@ + + + com.codenameone + codenameone-ios + ${project.version} + test + com.codenameone cn1-ads-mock diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/ios/IOSTextInputGeometryTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/ios/IOSTextInputGeometryTest.java new file mode 100644 index 00000000000..7bab349b643 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/ios/IOSTextInputGeometryTest.java @@ -0,0 +1,411 @@ +/* + * 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.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.*; +import com.codename1.ui.editor.EditorView; +import com.codename1.ui.layouts.BorderLayout; +import static org.junit.jupiter.api.Assertions.*; + +class IOSTextInputGeometryTest extends UITestBase { + private static class NativeStub extends IOSNative { + int[] bounds; + int sequence; + String text; + boolean deferPublication; + final java.util.List captures = new java.util.ArrayList(); + void publish(int captureId) { + if (deferPublication) { + captures.add(captureId); + } else { + IOSImplementation.tiPublishGeometry(captureId, true); + } + } + @Override void startTextInput(int constraint, boolean correct, boolean caps, boolean multi, + String text, int start, int end, int action, int captureId) { publish(captureId); } + @Override void updateTextInputState(String text, int start, int end, int x, int y, int w, int h, int seq, int captureId) { sequence = seq; this.text = text; publish(captureId); } + @Override void setTextInputBounds(int x, int y, int w, int h) { bounds = new int[]{x, y, w, h}; } + @Override void stopTextInput() { } + @Override public boolean isAsyncEditMode() { return false; } + @Override public int getVKBHeight() { return 150; } + @Override boolean isDirectToDrawable() { return false; } + } + private static class Port extends IOSImplementation { + @Override public int getDisplayWidth() { return 800; } + @Override public int getDisplayHeight() { return 600; } + @Override public boolean hasPendingPaints() { return true; } + } + private static class Client extends EditorView { + int revealCount; + Client() { super(null, false); } + @Override public void scrollRectToVisible(int x, int y, int w, int h, Component space) { + revealCount++; + super.scrollRectToVisible(x, y, w, h, space); + } + } + + @FormTest + void geometryWaitsForItsMatchingNativeShadowAndDropsClosedSessions() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + NativeStub nativeStub = new NativeStub(); + Port port = new Port(); + Client client = new Client(); + try { + IOSImplementation.nativeInstance = nativeStub; + IOSImplementation.instance = port; + client.setWidth(400); + client.setHeight(100); + client.setText("old document"); + port.startTextInput(client, client.getConfig()); + int[] oldRect = IOSImplementation.tiRectForOffset(3); + nativeStub.deferPublication = true; + client.setText("new document with a different layout"); + client.setY(80); + port.updateTextInputState(client, client.getEditingState()); + int first = nativeStub.captures.get(nativeStub.captures.size() - 1); + int[] firstRect = client.rectForOffset(3); + assertArrayEquals(oldRect, IOSImplementation.tiRectForOffset(3), + "EDT capture must not replace geometry while the native shadow is still old"); + assertEquals("old document", IOSImplementation.tiTextRange(0, 100)); + client.setText("second update"); + client.setY(160); + port.paintDirty(); + int second = nativeStub.captures.get(nativeStub.captures.size() - 1); + IOSImplementation.tiPublishGeometry(first, true); + assertArrayEquals(firstRect, IOSImplementation.tiRectForOffset(3), + "each queued native update must publish its own capture, not the latest EDT paint"); + assertEquals("new document with a different layout", IOSImplementation.tiTextRange(0, 100)); + IOSImplementation.tiPublishGeometry(second, false); + assertEquals(0, IOSImplementation.tiSelectionRects(0, 3).length, + "a rejected native echo must not expose geometry for a different shadow"); + port.updateTextInputState(client, client.getEditingState()); + int stale = nativeStub.captures.get(nativeStub.captures.size() - 1); + port.stopTextInput(client); + nativeStub.deferPublication = false; + client.setText("replacement session"); + client.setY(240); + port.startTextInput(client, client.getConfig()); + int[] replacementRect = IOSImplementation.tiRectForOffset(3); + IOSImplementation.tiPublishGeometry(stale, true); + assertArrayEquals(replacementRect, IOSImplementation.tiRectForOffset(3)); + assertEquals("replacement session", IOSImplementation.tiTextRange(0, 100)); + } finally { + port.stopTextInput(client); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + + @FormTest + void paintRefreshesMovedResizedAndScrolledGeometryWithoutAnEdit() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + NativeStub nativeStub = new NativeStub(); + Port port = new Port(); + Client client = new Client(); + try { + IOSImplementation.nativeInstance = nativeStub; + IOSImplementation.instance = port; + Form form = new Form(new BorderLayout()); + Container ancestor = new Container(new BorderLayout()); + ancestor.add(BorderLayout.CENTER, client); + form.add(BorderLayout.CENTER, ancestor); + form.show(); + flushSerialCalls(); + client.setText("one\ntwo\nthree\nfour\nfive"); + port.startTextInput(client, client.getConfig()); + int[] before = IOSImplementation.tiRectForOffset(1); + ancestor.setY(ancestor.getY() + 45); + client.setWidth(client.getWidth() - 30); + client.setHeight(25); + client.setSelectionRange(18, 18); // scrolls internally without pushing text state + port.paintDirty(); + int[] after = IOSImplementation.tiRectForOffset(18); + assertFalse(java.util.Arrays.equals(before, after)); + assertArrayEquals(client.rectForOffset(18), after); + assertEquals(18, IOSImplementation.tiOffsetAtPoint(after[0], after[1] + 1)); + assertArrayEquals(new int[]{client.getAbsoluteX(), client.getAbsoluteY(), + client.getWidth(), client.getHeight()}, nativeStub.bounds); + port.stopTextInput(client); + assertArrayEquals(new int[]{0, 0, 0, 0}, IOSImplementation.tiRectForOffset(18)); + } finally { + port.stopTextInput(client); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + + @FormTest + void defaultKeyboardModeRevalidatesAndRevealsPureClientWithoutLegacyEditor() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + Port port = new Port(); + Client client = new Client(); + final int[] layouts = new int[1]; + try { + IOSImplementation.nativeInstance = new NativeStub(); + IOSImplementation.instance = port; + Form form = new Form(new BorderLayout()) { + @Override public void revalidate() { layouts[0]++; super.revalidate(); } + }; + form.setFormBottomPaddingEditingMode(false); + form.add(BorderLayout.CENTER, client); + form.show(); + flushSerialCalls(); + StringBuilder lines = new StringBuilder(); + for (int i = 0; i < 80; i++) lines.append("keyboard caret\n"); + client.setText(lines.toString()); + client.setSelectionRange(client.getTextLength(), client.getTextLength()); + port.startTextInput(client, client.getConfig()); + form.setOverrideInvisibleAreaUnderVKB(150); + int before = layouts[0]; + IOSImplementation.keyboardWillBeShown(); + flushSerialCalls(); + assertTrue(layouts[0] > before, "pure clients must revalidate without currentEditing"); + assertTrue(client.revealCount > 0, "the caret must be revealed in its ancestor viewport"); + int[] caret = client.getCaretRect(); + assertTrue(caret[1] + caret[3] <= form.getHeight() - 150, + "a full-height editor must scroll its own caret above the keyboard"); + assertArrayEquals(client.getCaretRect(), IOSImplementation.tiRectForOffset(client.getSelectionEnd())); + port.stopTextInput(client); + } finally { + port.stopTextInput(client); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + + @FormTest + void nativeSelectionRecapturesGeometryAndIgnoresReplacedSessions() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + Port port = new Port(); + Client client = new Client(); + Client replacement = new Client(); + try { + IOSImplementation.nativeInstance = new NativeStub(); + IOSImplementation.instance = port; + client.setText("one\ntwo\nthree\nfour"); + client.setWidth(200); + client.setHeight(25); + port.startTextInput(client, client.getConfig()); + IOSImplementation.tiSetSelection(17, 17, 1); + flushSerialCalls(); + assertEquals(17, IOSImplementation.tiSelectionEnd()); + assertArrayEquals(client.rectForOffset(17), IOSImplementation.tiRectForOffset(17)); + IOSImplementation.tiSetSelection(0, 0, 2); + replacement.setText("replacement"); + port.startTextInput(replacement, replacement.getConfig()); + flushSerialCalls(); + assertEquals(17, client.getSelectionEnd(), "queued old-session callbacks must not mutate it"); + } finally { + port.stopTextInput(replacement); + port.stopTextInput(client); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + @FormTest + void delayedKeyboardHideRestoresItsOwnerAndPreservesTheNextFormsPadding() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + Port port = new Port(); + Client a = new Client(); + Client b = new Client(); + try { + IOSImplementation.nativeInstance = new NativeStub(); + IOSImplementation.instance = port; + Form first = new Form(new BorderLayout()); + first.setFormBottomPaddingEditingMode(true); + first.add(BorderLayout.CENTER, a); + Form second = new Form(new BorderLayout()); + second.setFormBottomPaddingEditingMode(true); + second.add(BorderLayout.CENTER, b); + first.getContentPane().getStyle().setPaddingUnitBottom(com.codename1.ui.plaf.Style.UNIT_TYPE_DIPS); + first.getContentPane().getStyle().setPadding(Component.BOTTOM, 21); + second.getContentPane().getStyle().setPadding(Component.BOTTOM, 35); + first.show(); + flushSerialCalls(); + port.startTextInput(a, a.getConfig()); + IOSImplementation.keyboardWillBeShown(); + flushSerialCalls(); + assertEquals(150, first.getContentPane().getStyle().getPaddingBottom()); + IOSImplementation.keyboardWillBeHidden(); // queued against first + second.show(); + port.startTextInput(b, b.getConfig()); + IOSImplementation.keyboardWillBeShown(); + flushSerialCalls(); + assertEquals(21, first.getContentPane().getStyle().getPaddingFloatValue(false, Component.BOTTOM)); + assertEquals(com.codename1.ui.plaf.Style.UNIT_TYPE_DIPS, + first.getContentPane().getStyle().getPaddingUnit()[Component.BOTTOM]); + assertEquals(150, second.getContentPane().getStyle().getPaddingBottom()); + IOSImplementation.keyboardWillBeHidden(); + IOSImplementation.keyboardWillBeShown(); // same form, a newer notification + flushSerialCalls(); + assertEquals(150, second.getContentPane().getStyle().getPaddingBottom()); + IOSImplementation.keyboardWillBeHidden(); + flushSerialCalls(); + assertEquals(35, second.getContentPane().getStyle().getPaddingBottom()); + } finally { + port.stopTextInput(a); + port.stopTextInput(b); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + + @FormTest + void commandAcknowledgementFollowsTheFinalStateAndIgnoresReplacedSessions() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + final Port port = new Port(); + final NativeStub stub = new NativeStub(); + final int[] calls = new int[1]; + EditorView client = new EditorView(null, false) { + @Override public void onKeyCommand(int command, int modifiers) { + calls[0]++; + setText("intermediate"); + port.updateTextInputState(this, getEditingState()); + assertNotEquals(40, stub.sequence, "intermediate pushes cannot release the native barrier"); + setText("final rich paste"); + } + }; + Client replacement = new Client(); + try { + IOSImplementation.nativeInstance = stub; + IOSImplementation.instance = port; + port.startTextInput(client, client.getConfig()); + IOSImplementation.tiKeyCommandWithSequence(TextInputClient.KEY_PASTE, 0, 40); + flushSerialCalls(); + assertEquals(1, calls[0]); + assertEquals(40, stub.sequence); + assertEquals("final rich paste", stub.text); + IOSImplementation.tiKeyCommandWithSequence(TextInputClient.KEY_CUT, 0, 41); + port.startTextInput(replacement, replacement.getConfig()); + flushSerialCalls(); + assertEquals(1, calls[0]); + } finally { + port.stopTextInput(client); + port.stopTextInput(replacement); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + + @FormTest + void keyboardHideRelayoutsSavedWindowAfterInputStopsWithStyleEventsSuppressed() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + Port port = new Port(); + Client client = new Client(); + implementation.setMultiWindowSupported(true); + Window window = new Window("Stopped editor", new BorderLayout()); + try { + IOSImplementation.nativeInstance = new NativeStub(); + IOSImplementation.instance = port; + Form main = new Form("Main", new BorderLayout()); + main.show(); + window.add(BorderLayout.CENTER, client); + window.show(); + window.setWindowContentSize(400, 600); + flushSerialCalls(); + com.codename1.ui.plaf.Style style = window.getContentPane().getUnselectedStyle(); + style.setPaddingUnitBottom(com.codename1.ui.plaf.Style.UNIT_TYPE_DIPS); + style.setPadding(Component.BOTTOM, 3); + window.revalidate(); + flushSerialCalls(); + int heightBefore = client.getHeight(); + port.startTextInput(client, client.getConfig()); + IOSImplementation.keyboardWillBeShown(); + flushSerialCalls(); + assertTrue(client.getHeight() < heightBefore, "keyboard must shrink the viewport first"); + style.setSuppressChangeEvents(true); + IOSImplementation.keyboardWillBeHidden(); + port.stopTextInput(client); // ends the session before the queued hide runs + flushSerialCalls(); + assertEquals(3f, style.getPaddingFloatValue(false, Component.BOTTOM)); + assertEquals(com.codename1.ui.plaf.Style.UNIT_TYPE_DIPS, + style.getPaddingUnit()[Component.BOTTOM]); + assertEquals(heightBefore, client.getHeight(), + "the saved window must regain its viewport without a style-change event"); + } finally { + window.getContentPane().getUnselectedStyle().setSuppressChangeEvents(false); + port.stopTextInput(client); + window.dispose(); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + + @FormTest + void secondaryWindowKeyboardReservesItsOwnViewportAndLeavesMainFormAlone() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + Port port = new Port(); + Client client = new Client(); + implementation.setMultiWindowSupported(true); + Window window = new Window("Secondary editor", new BorderLayout()); + try { + IOSImplementation.nativeInstance = new NativeStub(); + IOSImplementation.instance = port; + Form main = new Form("Main", new BorderLayout()); + main.setFormBottomPaddingEditingMode(true); + main.getContentPane().getStyle().setPadding(Component.BOTTOM, 37); + main.show(); + window.add(BorderLayout.CENTER, client); + window.show(); + window.setWindowContentSize(400, 600); + flushSerialCalls(); + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 80; i++) text.append("window keyboard caret\n"); + client.setText(text.toString()); + client.setSelectionRange(client.getTextLength(), client.getTextLength()); + assertNull(client.getComponentForm()); + assertSame(window, client.getTopLevelContainer()); + int heightBefore = client.getHeight(); + int mainPadding = main.getContentPane().getStyle().getPaddingBottom(); + port.startTextInput(client, client.getConfig()); + IOSImplementation.keyboardWillBeShown(); + flushSerialCalls(); + assertEquals(mainPadding, main.getContentPane().getStyle().getPaddingBottom()); + assertEquals(0, port.getInvisibleAreaUnderVKB(), "another scene must not obscure the main form"); + assertTrue(client.getHeight() <= heightBefore - 140, "secondary content must shrink above its keyboard"); + assertTrue(client.revealCount > 0); + int[] caret = client.getCaretRect(); + assertTrue(caret[1] + caret[3] <= window.getAbsoluteY() + window.getHeight() - 150); + IOSImplementation.keyboardWillBeHidden(); + flushSerialCalls(); + assertEquals(heightBefore, client.getHeight()); + } finally { + port.stopTextInput(client); + window.dispose(); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + +} diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/ios/MacFileChooserRequestTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/ios/MacFileChooserRequestTest.java new file mode 100644 index 00000000000..4cc22a1eac3 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/ios/MacFileChooserRequestTest.java @@ -0,0 +1,123 @@ +/* + * 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.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Component; +import com.codename1.ui.Window; +import com.codename1.ui.layouts.BorderLayout; +import java.util.ArrayList; +import static org.junit.jupiter.api.Assertions.*; + +class MacFileChooserRequestTest extends UITestBase { + private static class KeyClient extends Component { + int presses; + @Override public boolean isFocusable() { return true; } + @Override public void keyPressed(int keyCode) { presses++; } + } + + @FormTest + void completedDocumentSheetDoesNotBlockInputWhileAnotherSheetRemains() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + IOSImplementation port = new IOSImplementation() { + @Override public String getPlatformName() { return "mac"; } + }; + NativeStub stub = new NativeStub(); + implementation.setMultiWindowSupported(true); + Window first = new Window("First", new BorderLayout()); + Window second = new Window("Second", new BorderLayout()); + KeyClient client = new KeyClient(); + try { + IOSImplementation.nativeInstance = stub; + IOSImplementation.instance = port; + first.add(BorderLayout.CENTER, client); + first.show(); + first.setFocused(client); + port.openFileChooser(e -> { }, ".cn1doc"); + second.show(); + port.openFileChooser(e -> { }, ".docx"); + IOSImplementation.macFileChooserResult(null, stub.requests.get(0)); + flushSerialCalls(); + IOSImplementation.windowKeyCallback(first.getWindowId(), 'q', true); + IOSImplementation.windowKeyCallback(first.getWindowId(), 'q', false); + flushSerialCalls(); + assertEquals(1, client.presses, "another document's sheet must not suppress this window's input"); + } finally { + for (Integer request : stub.requests) { IOSImplementation.macFileChooserResult(null, request); } + flushSerialCalls(); + second.dispose(); + first.dispose(); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } + + private static class NativeStub extends IOSNative { + final ArrayList requests = new ArrayList(); + boolean fail; + @Override void openFileChooserWithId(String accept, int requestId) { + if (fail) { throw new IllegalStateException("presentation failed"); } + requests.add(requestId); + } + } + + @FormTest + void chooserCompletionsFollowRequestIdentityRatherThanPresentationOrder() { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + IOSImplementation port = new IOSImplementation() { + @Override public String getPlatformName() { return "mac"; } + }; + NativeStub stub = new NativeStub(); + final ArrayList delivered = new ArrayList(); + try { + IOSImplementation.nativeInstance = stub; + IOSImplementation.instance = port; + port.openFileChooser(e -> delivered.add("first:" + e.getSource()), ".cn1doc"); + port.openFileChooser(e -> delivered.add("second:" + e.getSource()), ".docx"); + assertNotEquals(stub.requests.get(0), stub.requests.get(1)); + IOSImplementation.macFileChooserResult("/tmp/second.docx", stub.requests.get(1)); + IOSImplementation.macFileChooserResult(null, stub.requests.get(0)); + IOSImplementation.macFileChooserResult("/tmp/duplicate", stub.requests.get(1)); + flushSerialCalls(); + assertEquals(java.util.Arrays.asList("second:file:/tmp/second.docx", "first:null"), delivered); + stub.fail = true; + assertThrows(IllegalStateException.class, + () -> port.openFileChooser(e -> fail("failed request must not receive another result"), "*")); + stub.fail = false; + port.openFileChooser(e -> delivered.add("third:" + e.getSource()), "*"); + IOSImplementation.macFileChooserResult("/tmp/third", stub.requests.get(2)); + flushSerialCalls(); + assertEquals("third:file:/tmp/third", delivered.get(2)); + assertEquals(3, delivered.size()); + } finally { + for (Integer request : stub.requests) { IOSImplementation.macFileChooserResult(null, request); } + flushSerialCalls(); + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java index 7cc245b77f4..8c6c7a40df9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java @@ -28,8 +28,14 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; import com.codename1.ui.Button; +import com.codename1.ui.Dialog; +import com.codename1.ui.Desktop; +import com.codename1.testing.TestWindowManager; +import com.codename1.ui.Display; +import com.codename1.ui.DisplayTest; import com.codename1.ui.Form; import com.codename1.ui.TextField; +import com.codename1.ui.Window; import com.codename1.ui.events.ActionEvent; import com.codename1.ui.events.ActionListener; import java.util.List; @@ -42,6 +48,97 @@ /// semantics tree. Runs on the Codename One EDT via {@link FormTest}. class MCPServerTest extends UITestBase { + @FormTest + void modalActionReturnsWhileDialogIsOpenAndNextRequestCanCloseIt() throws Exception { + Form form = new Form("Main"); + final Dialog dialog = new Dialog("MCP dialog"); + Button close = new Button("Close test dialog"); + close.addActionListener(e -> dialog.dispose()); + dialog.add(close); + Button open = new Button("Open test dialog"); + open.addActionListener(e -> dialog.showPacked("Center", true)); + form.add(open); + form.show(); + DisplayTest.flushEdt(); + final MCPServer server = new MCPServer(); + long openId = firstNodeWithAction(callTool(server, "ui_snapshot", "{}"), "activate"); + final Throwable[] failure = new Throwable[1]; + try { + Display.getInstance().invokeAndBlock(() -> { + try { + Map result = parse(callTool(server, "ui_activate", + "{\"nodeId\":" + openId + "}")); + assertTrue(asBool(result.get("pending"))); + assertNull(result.get("success"), "pending is not a completed successful action"); + String snapshot = callTool(server, "ui_snapshot", "{}"); + assertTrue(snapshot.contains("Close test dialog")); + long closeId = firstNodeWithAction(snapshot, "activate"); + result = parse(callTool(server, "ui_activate", "{\"nodeId\":" + closeId + "}")); + assertTrue(asBool(result.get("success"))); + } catch (Throwable ex) { + failure[0] = ex; + } finally { + Display.getInstance().callSerially(() -> dialog.dispose()); + } + }); + if (failure[0] != null) throw new AssertionError("MCP modal workflow failed", failure[0]); + DisplayTest.flushEdt(); + assertSame(form, Display.getInstance().getCurrent()); + } finally { + dialog.dispose(); + } + } + + @FormTest + void snapshotAndActionsFollowNativeDialogWindow() throws Exception { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Form main = new Form("Main window"); + main.add(new Button("Main only button")); + main.show(); + DisplayTest.flushEdt(); + Dialog dialog = new Dialog("Native dialog"); + dialog.setNativeWindowMode(true); + TextField field = new TextField("dialog text"); + dialog.add(field); + dialog.showModeless(); + DisplayTest.flushEdt(); + Desktop.getInstance().windowFocusChanged(wm.getLastWindow().getWindowId(), true); + DisplayTest.flushEdt(); + try { + MCPServer server = new MCPServer(); + String snapshot = callTool(server, "ui_snapshot", "{}"); + assertTrue(snapshot.contains("dialog text")); + assertFalse(snapshot.contains("Main only button")); + long id = firstNodeWithAction(snapshot, "setText"); + Map result = parse(callTool(server, "ui_set_text", + "{\"nodeId\":" + id + ",\"text\":\"changed dialog\"}")); + assertTrue(asBool(result.get("success"))); + assertEquals("changed dialog", field.getText()); + + int windowId = wm.getLastWindow().getWindowId(); + Desktop.getInstance().windowFocusChanged(windowId, false); + DisplayTest.flushEdt(); + String windows = callTool(server, "ui_windows", "{}"); + assertTrue(windows.contains("Native dialog")); + snapshot = callTool(server, "ui_snapshot", "{\"windowId\":" + windowId + "}"); + assertTrue(snapshot.contains("changed dialog"), "explicit targeting works without focus"); + id = firstNodeWithAction(snapshot, "setText"); + result = parse(callTool(server, "ui_set_text", "{\"nodeId\":" + id + + ",\"windowId\":" + windowId + ",\"text\":\"explicit window\"}")); + assertTrue(asBool(result.get("success"))); + assertEquals("explicit window", field.getText()); + + long mainId = firstNodeWithAction(callTool(server, "ui_snapshot", "{\"windowId\":0}"), "activate"); + Desktop.getInstance().windowById(windowId).setModalityType(Window.MODALITY_APPLICATION); + result = parse(callTool(server, "ui_activate", "{\"nodeId\":" + mainId + ",\"windowId\":0}")); + assertFalse(asBool(result.get("success")), "MCP must honor the modal input block"); + assertTrue(result.get("error").toString().contains("blocked")); + } finally { + dialog.dispose(); + DisplayTest.flushEdt(); + } + } + @FormTest void initializeReportsProtocolCapabilitiesAndServerInfo() throws Exception { MCPServer server = new MCPServer(); 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 1127d4dc071..c3da503453b 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 @@ -211,6 +211,27 @@ public class TestCodenameOneImplementation extends CodenameOneImplementation { private boolean nativeTitle; private boolean desktop; private String desktopTitleBarMode = "toolbar"; + private int[] lastTitleBarRegions; + private int[] nativeTitleBarInsets; + + public void setNativeTitleBarInsets(int... values) { + nativeTitleBarInsets = values; + } + + @Override + public int[] getNativeWindowTitleBarInsets(Component toolbar) { + return nativeTitleBarInsets; + } + + @Override + public void setNativeWindowTitleBarRegions(Component toolbar, int[] regions) { + lastTitleBarRegions = regions; + } + + public int[] getLastTitleBarRegions() { + return lastTitleBarRegions; + } + private java.util.Vector lastNativeCommands; private int softkeyCount = 2; private boolean thirdSoftButton = false; @@ -1524,6 +1545,7 @@ public void repaint(com.codename1.ui.animations.Animation cmp) { } public void reset() { + hardwareGameKeysSupported = true; repaintLog = null; // Back to the default a test class starts from. Runs on every UITestBase // teardown, so a test that switches touch off and forgets -- or restores the @@ -1542,6 +1564,8 @@ public void reset() { shiftKeyDown = false; nativeCommandsSupported = true; lastNativeCommands = null; + lastTitleBarRegions = null; + nativeTitleBarInsets = null; clearFileSystem(); clearSockets(); clearConnections(); @@ -2668,12 +2692,18 @@ public int getBackKeyCode() { @Override public int getGameAction(int keyCode) { - return keyCode; + return hardwareGameKeysSupported ? keyCode : -1; + } + + private boolean hardwareGameKeysSupported = true; + + public void setHardwareGameKeysSupported(boolean supported) { + hardwareGameKeysSupported = supported; } @Override public int getKeyCode(int gameAction) { - return gameAction; + return hardwareGameKeysSupported ? gameAction : -1; } @Override diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/DesktopChromeTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/DesktopChromeTest.java index b02e725e2d0..763b8391329 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/DesktopChromeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/DesktopChromeTest.java @@ -25,10 +25,12 @@ import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.plaf.Style; import java.util.Vector; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -43,6 +45,289 @@ */ class DesktopChromeTest extends UITestBase { + @FormTest + void customChromePublishesOriginalOnTopSideMenuCommandsAndLaterChanges() { + boolean onTop = Toolbar.isOnTopSideMenu(); + try { + Toolbar.setOnTopSideMenu(true); + desktopMode("custom"); + Form f = new Form("Document"); + Command save = new Command("Save"); + save.setDesktopMenu("File"); + f.getToolbar().addCommandToSideMenu(save); + f.show(); + DisplayTest.flushEdt(); + assertTrue(implementation.getLastNativeCommands().contains(save)); + Command later = new Command("Export"); + f.getToolbar().addCommandToSideMenu(later); + assertTrue(implementation.getLastNativeCommands().contains(later)); + f.getToolbar().removeCommand(later); + assertFalse(implementation.getLastNativeCommands().contains(later)); + } finally { + Toolbar.setOnTopSideMenu(onTop); + } + } + + @FormTest + void customTitleBarExcludesButtonsAndPointerListenersFromWindowDrags() { + desktopMode("custom"); + Form f = new Form("Document"); + Toolbar toolbar = f.getToolbar(); + Button save = new Button("Save"); + save.setEnabled(false); + Label rename = new Label("Rename"); + rename.addPointerReleasedListener(e -> { }); + Label title = new Label("Document"); + Container content = BoxLayout.encloseX(save, rename, title); + toolbar.setTitleComponent(content); + f.show(); + DisplayTest.flushEdt(); + assertFalse(toolbar.isTitleBarDragPoint(save.getAbsoluteX() + 1, save.getAbsoluteY() + 1), + "even a disabled button must not initiate a window drag"); + assertFalse(toolbar.isTitleBarDragPoint(rename.getAbsoluteX() + 1, rename.getAbsoluteY() + 1), + "custom pointer controls keep their gestures"); + assertTrue(toolbar.isTitleBarDragPoint(title.getAbsoluteX() + 1, title.getAbsoluteY() + 1), + "an ordinary title label remains a native drag region"); + assertFalse(toolbar.isTitleBarDragPoint(toolbar.getAbsoluteX() + 1, + toolbar.getAbsoluteY() + toolbar.getHeight()), "the document below is not a title bar"); + } + + @FormTest + void customPointerHandlersAndLongPressListenersAreNotWindowDragRegions() { + desktopMode("custom"); + Form form = new Form("Custom controls"); + Label custom = new Label("Custom click") { + @Override public void pointerReleased(int x, int y) { } + }; + Label longPress = new Label("Long press"); + longPress.addLongPressListener(e -> { }); + Label ordinary = new Label("Plain title"); + form.getToolbar().setTitleComponent(BoxLayout.encloseX(custom, longPress, ordinary)); + form.show(); + DisplayTest.flushEdt(); + assertFalse(form.getToolbar().isTitleBarDragPoint(custom.getAbsoluteX() + 1, + custom.getAbsoluteY() + 1), "an overridden pointer handler must keep its press"); + assertFalse(form.getToolbar().isTitleBarDragPoint(longPress.getAbsoluteX() + 1, + longPress.getAbsoluteY() + 1), "a long-press listener must keep its press"); + assertTrue(form.getToolbar().isTitleBarDragPoint(ordinary.getAbsoluteX() + 1, + ordinary.getAbsoluteY() + 1)); + } + + @FormTest + void passiveCustomTitleBackgroundCanOptInWithoutTakingChildInput() { + desktopMode("custom"); + Form form = new Form("Passive custom title"); + Container custom = new Container(BoxLayout.x()) { }; + Label title = new Label("Title"); + Button button = new Button("Save"); + custom.add(title).add(button); + Toolbar.setTitleBarDragRegion(custom, true); + form.getToolbar().setTitleComponent(custom); + form.show(); + DisplayTest.flushEdt(); + assertTrue(form.getToolbar().isTitleBarDragPoint(title.getAbsoluteX() + 1, title.getAbsoluteY() + 1)); + assertFalse(form.getToolbar().isTitleBarDragPoint(button.getAbsoluteX() + 1, button.getAbsoluteY() + 1)); + Toolbar.setTitleBarDragRegion(title, false); + assertFalse(form.getToolbar().isTitleBarDragPoint(title.getAbsoluteX() + 1, title.getAbsoluteY() + 1)); + Toolbar.setTitleBarDragRegion(title, true); + title.addLongPressListener(e -> { }); + assertFalse(form.getToolbar().isTitleBarDragPoint(title.getAbsoluteX() + 1, title.getAbsoluteY() + 1)); + } + + @FormTest + void changingDragEligibilityRepaintsTheToolbarThatPublishesNativeRegions() { + desktopMode("custom"); + Form form = new Form("Repaint regions"); + Label title = new Label("Document title"); + Toolbar toolbar = form.getToolbar(); + toolbar.setTitleComponent(title); + form.show(); + DisplayTest.flushEdt(); + Graphics graphics = Image.createImage(800, 800).getGraphics(); + toolbar.paint(graphics); + int originalLength = implementation.getLastTitleBarRegions().length; + java.util.List repaints = new java.util.ArrayList(); + implementation.recordRepaints(repaints); + try { + Toolbar.setTitleBarDragRegion(title, false); + assertTrue(repaints.contains(toolbar), "a child-only repaint leaves the native hit map stale"); + toolbar.paint(graphics); + assertTrue(implementation.getLastTitleBarRegions().length > originalLength); + repaints.clear(); + Toolbar.setTitleBarDragRegion(title, true); + assertTrue(repaints.contains(toolbar)); + toolbar.paint(graphics); + assertEquals(originalLength, implementation.getLastTitleBarRegions().length); + } finally { + implementation.recordRepaints(null); + } + } + + @FormTest + void resetClearsNativeTitleInsets() { + implementation.setNativeTitleBarInsets(10, 20, 3, 4, 30); + implementation.reset(); + assertNull(implementation.getNativeWindowTitleBarInsets(null)); + } + + @FormTest + void customTitleRegionsFollowResizeAndClearWhenToolbarIsRemoved() { + desktopMode("custom"); + Form f = new Form("Document"); + f.show(); + DisplayTest.flushEdt(); + Toolbar toolbar = f.getToolbar(); + Graphics graphics = Image.createImage(800, 800).getGraphics(); + toolbar.paint(graphics); + int width = toolbar.getWidth(); + assertEquals(width, implementation.getLastTitleBarRegions()[2]); + toolbar.setWidth(width + 100); + toolbar.paint(graphics); + assertEquals(width + 100, implementation.getLastTitleBarRegions()[2]); + toolbar.deinitialize(); + assertEquals(0, implementation.getLastTitleBarRegions().length, + "a removed title bar must not leave invisible window drag handles"); + } + + @FormTest + void hidingToolbarImmediatelyClearsNativeAndFallbackDragRegions() { + desktopMode("custom"); + Form form = new Form("Document"); + form.show(); + DisplayTest.flushEdt(); + Toolbar toolbar = form.getToolbar(); + Graphics graphics = Image.createImage(800, 800).getGraphics(); + toolbar.paint(graphics); + assertTrue(implementation.getLastTitleBarRegions().length > 0); + toolbar.setVisible(false); + assertEquals(0, implementation.getLastTitleBarRegions().length, + "hiding must clear regions before any subsequent paint"); + assertFalse(toolbar.isTitleBarDragPoint(toolbar.getAbsoluteX() + 1, toolbar.getAbsoluteY() + 1)); + toolbar.paint(graphics); // a manual paint must not republish a hidden band + assertEquals(0, implementation.getLastTitleBarRegions().length); + toolbar.setVisible(true); + toolbar.paint(graphics); + assertTrue(implementation.getLastTitleBarRegions().length > 0); + } + + @FormTest + void hidingTitleToolbarBeforeFirstPaintClearsNativeChrome() { + desktopMode("custom"); + implementation.setNativeTitleBarInsets(70, 130, 6, 4, 50); + Form form = new Form("Not painted yet"); + form.show(); + Toolbar toolbar = form.getToolbar(); + toolbar.calcPreferredSize(); // native ports enable integrated chrome here + assertNull(implementation.getLastTitleBarRegions(), "no paint regions published yet"); + toolbar.setVisible(false); + assertNotNull(implementation.getLastTitleBarRegions(), "hide must send a native clear"); + assertEquals(0, implementation.getLastTitleBarRegions().length); + toolbar.setVisible(true); + toolbar.paint(Image.createImage(800, 800).getGraphics()); + assertTrue(implementation.getLastTitleBarRegions().length > 0, + "showing again must restore native drag regions"); + } + + @FormTest + void removingTitleToolbarBeforeFirstPaintClearsNativeChrome() { + desktopMode("custom"); + implementation.setNativeTitleBarInsets(70, 130, 6, 4, 50); + Form form = new Form("Removed before paint"); + form.show(); + Toolbar toolbar = form.getToolbar(); + toolbar.doLayout(); // layout also queries the native insets + assertNull(implementation.getLastTitleBarRegions(), "no paint regions published yet"); + toolbar.remove(); + assertNotNull(implementation.getLastTitleBarRegions(), "removal must send a native clear"); + assertEquals(0, implementation.getLastTitleBarRegions().length); + } + + @FormTest + void nativeControlsReservePhysicalSpaceWithoutChangingApplicationStyle() { + desktopMode("custom"); + implementation.setNativeTitleBarInsets(70, 130, 6, 4, 50); + Form form = new Form("Document"); + Toolbar toolbar = form.getToolbar(); + toolbar.getAllStyles().setPaddingUnit(com.codename1.ui.plaf.Style.UNIT_TYPE_PIXELS); + toolbar.getAllStyles().setPadding(2, 3, 5, 7); + toolbar.setTitleCentered(false); + Container content = new Container(new com.codename1.ui.layouts.BorderLayout()); + Button left = new Button("Save"); + Button right = new Button("Search"); + content.add(com.codename1.ui.layouts.BorderLayout.WEST, left); + content.add(com.codename1.ui.layouts.BorderLayout.EAST, right); + toolbar.setTitleComponent(content); + form.show(); + DisplayTest.flushEdt(); + assertTrue(toolbar.getPreferredH() >= 50); + assertTrue(content.getX() >= 75); + assertTrue(content.getX() + content.getWidth() <= toolbar.getWidth() - 137); + assertEquals(5, toolbar.getStyle().getPaddingLeftNoRTL()); + assertEquals(7, toolbar.getStyle().getPaddingRightNoRTL()); + assertEquals(2, toolbar.getStyle().getPaddingTop()); + assertEquals(3, toolbar.getStyle().getPaddingBottom()); + int x = content.getX(); + toolbar.layoutContainer(); + assertEquals(x, content.getX(), "repeated layouts must not accumulate native insets"); + implementation.setNativeTitleBarInsets(0, 90, 0, 0, 35); + toolbar.setShouldCalcPreferredSize(true); + toolbar.layoutContainer(); + assertTrue(content.getX() < x, "changed native geometry releases the old reserved space"); + assertTrue(content.getX() + content.getWidth() <= toolbar.getWidth() - 97); + toolbar.setRTL(true); + toolbar.layoutContainer(); + assertTrue(content.getX() + content.getWidth() <= toolbar.getWidth() - 90, + "OS controls stay on their physical side in an RTL document"); + } + + @FormTest + void nativeTitleInsetsPreserveMixedPaddingUnitsAcrossMeasurementsAndLayouts() { + desktopMode("custom"); + implementation.setNativeTitleBarInsets(70, 130, 6, 4, 50); + Form form = new Form("Mixed units"); + Toolbar toolbar = form.getToolbar(); + form.show(); + DisplayTest.flushEdt(); + Style style = toolbar.getStyle(); + byte[] units = {Style.UNIT_TYPE_DIPS, Style.UNIT_TYPE_PIXELS, + Style.UNIT_TYPE_SCREEN_PERCENTAGE, Style.UNIT_TYPE_REM}; + style.setPaddingUnit(units); + style.setPadding(2, 3, 5, 7); + for (boolean rtl : new boolean[]{false, true}) { + toolbar.setRTL(rtl); + for (int pass = 0; pass < 3; pass++) { + toolbar.calcPreferredSize(); + assertArrayEquals(units, style.getPaddingUnit(), "measurement must preserve per-side units"); + toolbar.doLayout(); + assertArrayEquals(units, style.getPaddingUnit(), "layout must preserve per-side units"); + assertEquals(2f, style.getPaddingFloatValue(false, Component.TOP)); + assertEquals(3f, style.getPaddingFloatValue(false, Component.BOTTOM)); + assertEquals(5f, style.getPaddingFloatValue(false, Component.LEFT)); + assertEquals(7f, style.getPaddingFloatValue(false, Component.RIGHT)); + } + } + style.setPaddingUnit((byte[]) null); + toolbar.calcPreferredSize(); + toolbar.doLayout(); + assertNull(style.getPaddingUnit(), "implicit pixel units must stay implicit"); + } + + @FormTest + void mobileAndLegacyToolbarsDoNotReserveDesktopControls() { + implementation.setNativeTitleBarInsets(200, 200, 50, 50, 300); + Toolbar.setGlobalToolbar(true); + implementation.setDesktop(false); + Form mobile = new Form("Mobile"); + mobile.show(); + DisplayTest.flushEdt(); + assertTrue(mobile.getToolbar().getPreferredH() < 300); + desktopMode("toolbar"); + Form legacy = new Form("Legacy"); + legacy.show(); + DisplayTest.flushEdt(); + assertTrue(legacy.getToolbar().getPreferredH() < 300); + } + private void desktopMode(String titleBarMode) { implementation.setDesktop(true); implementation.setDesktopTitleBarMode(titleBarMode); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java index c5e04ee1e3b..2ebd9df82b3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/DialogTest.java @@ -223,6 +223,33 @@ void timeoutDisposesDialogDuringAnimation() { assertTrue(dialog.isDisposed(), "Dialog should be disposed after timeout"); } + @FormTest + void modalWaiterDoesNotDisposeExpiredDialogOffEdt() throws Exception { + implementation.setBuiltinSoundsEnabled(false); + Dialog dialog = new Dialog("Timeout thread", new BorderLayout()); + dialog.showModeless(); + try { + dialog.setTimeout(0); + java.util.concurrent.atomic.AtomicBoolean observedDisposed = + new java.util.concurrent.atomic.AtomicBoolean(); + Thread waiter = new Thread(() -> observedDisposed.set(dialog.isDisposed())); + waiter.start(); + // Hold the EDT until the modal waiter's poll completes. Expiration must + // not remove an animation from this thread's list on the worker thread. + waiter.join(1000L); + assertFalse(waiter.isAlive(), "the disposal poll must not block on the EDT"); + assertFalse(observedDisposed.get(), "the waiter must only observe disposal"); + dialog.animate(); + Thread observer = new Thread(() -> observedDisposed.set(dialog.isDisposed())); + observer.start(); + observer.join(1000L); + assertFalse(observer.isAlive()); + assertTrue(observedDisposed.get(), "EDT disposal must be visible to the waiter"); + } finally { + dialog.dispose(); + } + } + @FormTest void staticConfigurationCarriesToNewDialogs() { boolean originalAutoAdjust = Dialog.isAutoAdjustDialogSize(); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java new file mode 100644 index 00000000000..ef7e5d49750 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/EditorGeometrySnapshotTest.java @@ -0,0 +1,193 @@ +/* + * 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.ui.editor.CodeView; +import com.codename1.ui.editor.EditorView; +import com.codename1.ui.layouts.BorderLayout; +import static org.junit.jupiter.api.Assertions.*; + +class EditorGeometrySnapshotTest extends UITestBase { + private static class CountingFont extends Font { + int bulkCalls; + int prefixCalls; + int measuredUnits; + @Override public int getHeight() { return 20; } + @Override public int charWidth(char c) { return 1; } + @Override public int stringWidth(String s) { return s.length(); } + @Override public int substringWidth(String s, int start, int length) { + prefixCalls++; + measuredUnits += length; + return length; + } + @Override public int[] getTextRunPositions(String text, boolean rtl) { + bulkCalls++; + measuredUnits += text.length(); + int[] positions = new int[text.length() + 1]; + for (int i = 0; i < positions.length; i++) positions[i] = rtl ? text.length() - i : i; + return positions; + } + void reset() { bulkCalls = prefixCalls = measuredUnits = 0; } + } + + @FormTest + void longEditorAndCodeLinesUseOneBulkMeasurementInsteadOfThousandsOfPrefixes() { + for (boolean code : new boolean[]{false, true}) { + final CountingFont font = new CountingFont(); + EditorView editor = code ? new CodeView(null) { + @Override protected Font getEditorFont() { return font; } + } : new EditorView(null, false) { + @Override protected Font getEditorFont() { return font; } + }; + Form form = new Form(new BorderLayout()); + form.add(BorderLayout.CENTER, editor); + form.show(); + flushSerialCalls(); + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 4096; i++) text.append('x'); + editor.setText(text.toString()); + editor.setWidth(5000); + editor.setSelectionRange(2048, 2048); + font.reset(); + TextInputGeometry geometry = TextInputGeometry.capture(editor); + assertEquals(1, font.bulkCalls); + assertTrue(font.prefixCalls <= 3, "only caret/selection endpoints may use scalar measurement"); + assertTrue(font.measuredUnits < 4096 * 4, "work must grow linearly in the line length"); + assertArrayEquals(editor.rectForOffset(2048), geometry.rectForOffset(2048)); + assertEquals(2048, geometry.offsetAtPoint(geometry.rectForOffset(2048)[0], + geometry.rectForOffset(2048)[1] + 5)); + } + } + + @FormTest + void bulkSnapshotsPreserveBidiAndStyledCaretGeometry() { + for (EditorView editor : new EditorView[]{new EditorView(null, false), + new CodeView(null), new com.codename1.ui.editor.RichView(null)}) { + Form form = new Form(new BorderLayout()); + form.add(BorderLayout.CENTER, editor); + form.show(); + flushSerialCalls(); + for (String text : new String[]{"abc def", "\u05d0\u05d1\u05d2", "abc \u05d0\u05d1 def"}) { + editor.setText(text); + if (editor instanceof com.codename1.ui.editor.RichView) { + editor.setSelectionRange(1, Math.min(3, text.length())); + ((com.codename1.ui.editor.RichView) editor).toggleBold(); + } + TextInputGeometry geometry = TextInputGeometry.capture(editor); + for (int i = 0; i <= text.length(); i++) { + assertArrayEquals(editor.rectForOffset(i), geometry.rectForOffset(i), + editor.getClass().getName() + " offset " + i); + } + } + } + } + @FormTest + void largeSelectionsOnlyMeasureVisibleLinesInEitherDirection() { + final int[] measured = new int[1]; + EditorView[] editors = {new EditorView(null, false) { + @Override protected int[] lineVisualSegments(int line, String text, int a, int b, Font font) { + measured[0]++; + return super.lineVisualSegments(line, text, a, b, font); + } + }, new com.codename1.ui.editor.RichView(null) { + @Override protected int[] lineVisualSegments(int line, String text, int a, int b, Font font) { + measured[0]++; + return super.lineVisualSegments(line, text, a, b, font); + } + }}; + StringBuilder text = new StringBuilder(); + for (int i = 0; i < 10000; i++) text.append("abc\n"); + for (EditorView editor : editors) { + editor.setWidth(300); + editor.setHeight(100); + editor.setText(text.toString()); + for (boolean reverse : new boolean[]{false, true}) { + int end = editor.getTextLength(); + editor.setSelectionRange(reverse ? end : 0, reverse ? 0 : end); + measured[0] = 0; + TextInputGeometry geometry = TextInputGeometry.capture(editor); + int[] rectangles = geometry.selectionRects(0, end); + assertTrue(measured[0] < 30, "capture must not walk 10,000 selected lines"); + assertTrue(rectangles.length > 0 && rectangles.length < 120); + for (int r = 0; r < rectangles.length; r += 4) { + assertTrue(rectangles[r + 1] + rectangles[r + 3] >= editor.getAbsoluteY()); + assertTrue(rectangles[r + 1] <= editor.getAbsoluteY() + editor.getHeight()); + } + } + } + } + + private static class ScrolledClient extends Container implements TextInputClient { + final java.util.List samples = new java.util.ArrayList(); + void scroll(int x, int y) { setScrollX(x); setScrollY(y); } + @Override protected com.codename1.ui.geom.Dimension calcScrollSize() { + return new com.codename1.ui.geom.Dimension(2000, 2000); + } + @Override public TextInputState getEditingState() { return new TextInputState("", 600, 600); } + @Override public int getTextLength() { return 2000; } + @Override public int offsetAtPoint(int x, int y) { samples.add(new int[]{x, y}); return y; } + @Override public int[] rectForOffset(int offset) { return new int[]{getAbsoluteX(), offset, 1, 1}; } + @Override public int[] getCaretRect() { return rectForOffset(600); } + @Override public int[] selectionRects(int a, int b) { return new int[0]; } + @Override public TextInputConfig getConfig() { return new TextInputConfig(); } + @Override public String getTextRange(int a, int b) { return ""; } + @Override public void commitText(String t) { } + @Override public void setComposingText(String t, int c) { } + @Override public void finishComposing() { } + @Override public void deleteSurroundingText(int a, int b) { } + @Override public void onKeyCommand(int c, int m) { } + @Override public void onEditorAction(int a) { } + @Override public void inputFocusGained() { } + @Override public void inputFocusLost() { } + @Override public void replaceRange(int a, int b, String t) { } + @Override public void setSelectionRange(int a, int b) { } + } + + @FormTest + void fallbackSamplesViewportAfterOwnAndAncestorScrolling() { + ScrolledClient client = new ScrolledClient(); + ScrolledClient ancestor = new ScrolledClient(); + ancestor.setWidth(1000); + ancestor.setHeight(1000); + ancestor.setScrollableX(true); + ancestor.setScrollableY(true); + ancestor.scroll(10, 15); + ancestor.setX(20); + ancestor.setY(30); + ancestor.add(client); + client.setX(40); + client.setY(50); + client.setWidth(100); + client.setHeight(80); + client.setScrollableX(true); + client.setScrollableY(true); + client.scroll(70, 400); + TextInputGeometry.capture(client); + assertArrayEquals(new int[]{50, 65}, client.samples.get(0)); + assertArrayEquals(new int[]{150, 145}, client.samples.get(3)); + } + +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/NativeWindowDialogTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/NativeWindowDialogTest.java index 02961764aa4..709fd1d42a4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/NativeWindowDialogTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/NativeWindowDialogTest.java @@ -43,6 +43,66 @@ /// The opt-in that backs a `Dialog` with a real operating system window. class NativeWindowDialogTest extends UITestBase { + @FormTest + void firstNativeDialogEditorKeepsItsInputSessionAfterShowing() { + implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + DisplayTest.flushEdt(); + final TextInputClient[] active = new TextInputClient[1]; + EditField field = new EditField() { + @Override public boolean isTextInputSupported() { return true; } + @Override public Object startTextInput(TextInputClient client, TextInputConfig config) { + assertNotNull(((Window) getTopLevelContainer()).getNativePeer(), + "input must start after its native owner exists"); + active[0] = client; + return client; + } + @Override public void stopTextInput(Object handle) { + if (active[0] == handle) active[0] = null; + } + }; + Dialog dialog = new Dialog("search"); + dialog.setLayout(new BorderLayout()); + dialog.add(BorderLayout.NORTH, field); + dialog.add(BorderLayout.SOUTH, new Button("Close")); + dialog.setNativeWindowMode(true); + dialog.showModeless(); + DisplayTest.flushEdt(); + try { + assertSame(field, field.getTopLevelContainer().getFocused()); + assertSame(field, active[0], "the initially focused editor must own a live input session"); + } finally { + dialog.dispose(); + DisplayTest.flushEdt(); + } + } + + @FormTest + void nativeDialogMeasuresAgainstItsOwnerRatherThanItsInitialWindow() { + TestWindowManager wm = implementation.setMultiWindowSupported(true); + Form main = new Form("main", new BorderLayout()); + main.show(); + DisplayTest.flushEdt(); + Dialog dialog = new Dialog("wide content"); + dialog.setLayout(new BorderLayout()); + Label body = new Label("wide cell editor"); + body.setPreferredW(600); + dialog.add(BorderLayout.CENTER, body); + dialog.setNativeWindowMode(true); + dialog.showModeless(); + DisplayTest.flushEdt(); + try { + assertSame(main, dialog.resolveHost(), + "moving the payload into its window must not make it its own sizing host"); + assertTrue(wm.getLastWindow().getWidth() >= 600, + "the initial peer width must not clip wider dialog content"); + } finally { + dialog.dispose(); + DisplayTest.flushEdt(); + } + } + private Dialog newDialog(String title) { Dialog d = new Dialog(title); d.setLayout(new BorderLayout()); diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/TextInputGeometryTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/TextInputGeometryTest.java new file mode 100644 index 00000000000..93fd97b6553 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/ui/TextInputGeometryTest.java @@ -0,0 +1,71 @@ +/* + * 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 org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class TextInputGeometryTest { + @Test void emptyCaretGeometryKeepsTheActiveEndOfBackwardSelections() { + int[] selection = {20, 30, 80, 12}; + TextInputGeometry geometry = new TextInputGeometry(new int[0], 10, 3, + selection, new int[]{20, 30, 1, 12}); + assertEquals(3, geometry.offsetAtPoint(100, 100)); + assertArrayEquals(selection, geometry.selectionRects(3, 10)); + assertArrayEquals(selection, geometry.selectionRects(10, 3)); + assertArrayEquals(new int[]{20, 30, 1, 12}, geometry.rectForOffset(99)); + } + + @Test void snapshotDoesNotRetainMutableArrays() { + int[] points = {0,10,20,2,12, 1,20,20,2,12, 2,30,20,2,12}; + int[] selection = {10,20,20,12}; + int[] fallback = {30,20,2,12}; + TextInputGeometry geometry = new TextInputGeometry(points, 0, 2, selection, fallback); + points[1] = 900; selection[0] = 900; fallback[0] = 900; + assertArrayEquals(new int[]{10,20,2,12}, geometry.rectForOffset(0)); + assertArrayEquals(new int[]{10,20,20,12}, geometry.selectionRects(2,0)); + assertArrayEquals(new int[]{30,20,2,12}, geometry.rectForOffset(99)); + int[] returned = geometry.rectForOffset(0); returned[0] = 400; + assertEquals(10, geometry.rectForOffset(0)[0]); + } + + @Test void hitTestingChoosesNearestLineThenCaretAndSupportsRtlPositions() { + TextInputGeometry geometry = new TextInputGeometry(new int[]{ + 0,10,20,2,12, 1,30,20,2,12, 2,20,20,2,12, + 3,10,40,2,12, 4,30,40,2,12}, 0,0,new int[0],new int[]{10,20,2,12}); + assertEquals(2, geometry.offsetAtPoint(21,24)); + assertEquals(4, geometry.offsetAtPoint(29,45)); + assertEquals(3, geometry.offsetAtPoint(-100,500)); + assertArrayEquals(new int[0], geometry.selectionRects(1,1)); + assertArrayEquals(new int[]{20,20,10,12}, geometry.selectionRects(1,2)); + } + @Test void adjacentLinesDoNotShareTheirBoundaryPixel() { + TextInputGeometry geometry = new TextInputGeometry(new int[]{ + 0,10,20,1,12, 1,100,32,1,12}, 0,0,new int[0],new int[]{10,20,1,12}); + assertEquals(0, geometry.offsetAtPoint(10,31)); + assertEquals(1, geometry.offsetAtPoint(10,32)); + assertEquals(1, geometry.offsetAtPoint(10,43)); + } + +} diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/accessibility/AccessibilitySemanticsTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/accessibility/AccessibilitySemanticsTest.java index 6f3fe9018cb..bf0db184b59 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/accessibility/AccessibilitySemanticsTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/accessibility/AccessibilitySemanticsTest.java @@ -43,6 +43,105 @@ class AccessibilitySemanticsTest extends UITestBase { + @FormTest + void touchOnlyPortsActivateButtonsAndToggleStateWithoutHardwareKeys() { + implementation.setHardwareGameKeysSupported(false); + try { + Form form = form(); + Button button = place(form, new Button("Save"), 0, 0, 100, 40); + CheckBox check = place(form, new CheckBox("Repeat header"), 0, 50, 150, 40); + final int[] clicks = {0}; + button.addActionListener(e -> clicks[0]++); + AccessibilityAction action = find(AccessibilityInspector.snapshot(form), button) + .getAction(AccessibilityAction.ACTIVATE); + assertTrue(action.perform(button, null)); + assertEquals(1, clicks[0]); + AccessibilityAction toggle = find(AccessibilityInspector.snapshot(form), check) + .getAction(AccessibilityAction.ACTIVATE); + assertTrue(toggle.perform(check, null)); + assertTrue(check.isSelected()); + assertTrue(toggle.perform(check, null)); + assertFalse(check.isSelected()); + button.setEnabled(false); + assertFalse(action.perform(button, null)); + assertEquals(1, clicks[0]); + } finally { + implementation.setHardwareGameKeysSupported(true); + } + } + + @FormTest + void touchOnlyListAndComboItemsSelectAndDispatchExactlyOneAction() { + implementation.setHardwareGameKeysSupported(false); + try { + Form form = form(); + for (com.codename1.ui.List list : new com.codename1.ui.List[] { + new com.codename1.ui.List("Home", "Insert", "Layout"), + new com.codename1.ui.ComboBox("Home", "Insert", "Layout")}) { + place(form, list, 0, 0, 200, 160); + final int[] changes = {0}; + list.addActionListener(e -> changes[0]++); + AccessibilityTreeSnapshot tree = AccessibilityInspector.snapshot(form); + AccessibilityNodeSnapshot item = tree.getNode(find(tree, list).getChildIds().get(1)); + assertTrue(item.getAction(AccessibilityAction.ACTIVATE).perform(list, null)); + assertEquals(1, list.getSelectedIndex()); + assertEquals(1, changes[0]); + if (list instanceof com.codename1.ui.ComboBox) { + AccessibilityAction adjust = find(AccessibilityInspector.snapshot(form), list) + .getAction(AccessibilityAction.SCROLL_FORWARD); + assertTrue(adjust.perform(list, null)); + assertEquals(2, list.getSelectedIndex()); + assertEquals(2, changes[0]); + } + int previousChanges = changes[0]; + list.setEnabled(false); + assertFalse(item.getAction(AccessibilityAction.ACTIVATE).perform(list, null)); + assertEquals(previousChanges, changes[0]); + list.remove(); + } + } finally { + implementation.setHardwareGameKeysSupported(true); + } + } + + @FormTest + void semanticListActivationIsIndependentOfTheLastPointerDrag() throws Exception { + java.lang.reflect.Field drag = Display.class.getDeclaredField("dragOccured"); + drag.setAccessible(true); + Display display = Display.getInstance(); + boolean old = drag.getBoolean(display); + try { + Form form = form(); + for (com.codename1.ui.List list : new com.codename1.ui.List[] { + new com.codename1.ui.List("Home", "Insert"), + new com.codename1.ui.ComboBox("Home", "Insert")}) { + place(form, list, 0, 0, 200, 160); + final int[] actions = {0}; + list.addActionListener(e -> actions[0]++); + drag.setBoolean(display, true); + assertTrue(display.hasDragOccured()); + AccessibilityTreeSnapshot tree = AccessibilityInspector.snapshot(form); + AccessibilityNodeSnapshot item = tree.getNode(find(tree, list).getChildIds().get(1)); + assertTrue(item.getAction(AccessibilityAction.ACTIVATE).perform(list, null)); + assertEquals(1, list.getSelectedIndex()); + assertEquals(1, actions[0]); + assertTrue(display.hasDragOccured(), "semantic actions must not reset pointer gesture state"); + list.remove(); + } + class PointerList extends com.codename1.ui.List { + PointerList() { super("One", "Two"); } + void pointerAction() { fireActionEvent(); } + } + PointerList pointer = new PointerList(); + final int[] actions = {0}; + pointer.addActionListener(e -> actions[0]++); + pointer.pointerAction(); + assertEquals(0, actions[0], "ordinary pointer activation still respects the drag guard"); + } finally { + drag.setBoolean(display, old); + } + } + @FormTest void associatedLabelChangesInvalidateTheCachedSnapshot() { Form form = form(); diff --git a/maven/windows/src/test/java/com/codename1/impl/windows/WindowsDpiTest.java b/maven/windows/src/test/java/com/codename1/impl/windows/WindowsDpiTest.java new file mode 100644 index 00000000000..f4bc4f31657 --- /dev/null +++ b/maven/windows/src/test/java/com/codename1/impl/windows/WindowsDpiTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2012, 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 Codename One 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; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class WindowsDpiTest { + @Test + void monitorChangesRefreshConversionsAndAppearanceEventsRetainDpi() throws Exception { + java.lang.reflect.Field singleton = WindowsImplementation.class.getDeclaredField("INSTANCE"); + singleton.setAccessible(true); + Object previous = singleton.get(null); + try { + final int[] refresh = new int[2]; + WindowsImplementation port = new WindowsImplementation() { + @Override + void refreshForDpiChange() { + refresh[0]++; + refresh[1] = convertToPixels(25, true); + } + }; + assertEquals(Math.round(25 * (96 / 25.4f)), port.convertToPixels(25, true)); + int[] dpis = {144, 192, 288, 96}; + int[] densities = {Display.DENSITY_MEDIUM, Display.DENSITY_HIGH, + Display.DENSITY_VERY_HIGH, Display.DENSITY_LOW}; + for (int i = 0; i < dpis.length; i++) { + port.dispatchThemeSettingsChanged(dpis[i]); + int pixels = Math.round(25 * (dpis[i] / 25.4f)); + assertEquals(pixels, port.convertToPixels(25, true)); + assertEquals(pixels, port.convertToPixels(25, false)); + assertEquals(i + 1, refresh[0]); + assertEquals(pixels, refresh[1]); + assertEquals(densities[i], port.getDeviceDensity()); + port.dispatchThemeSettingsChanged(dpis[i]); + port.dispatchThemeSettingsChanged(0); + port.dispatchThemeSettingsChanged(-1); + assertEquals(i + 1, refresh[0]); + assertEquals(pixels, port.convertToPixels(25, true)); + assertEquals(densities[i], port.getDeviceDensity()); + } + } finally { + singleton.set(null, previous); + } + } +} diff --git a/scripts/cn1playground/tools/verify-playground-browser.mjs b/scripts/cn1playground/tools/verify-playground-browser.mjs index 67253204b93..53495fcaa54 100644 --- a/scripts/cn1playground/tools/verify-playground-browser.mjs +++ b/scripts/cn1playground/tools/verify-playground-browser.mjs @@ -144,13 +144,20 @@ try { await setCode('class Greeter { String hi() { return "v1"; } }\nSystem.out.println("PG-RUN " + new Greeter().hi());\n' + 'Label l = new Label(new Greeter().hi());\nl\n'); r = await waitFor(l => l.startsWith('PG-RUN'), 30000); - check('typed code compiles and runs', r === 'PG-RUN v1', r); + // The snippet prints before PlaygroundRunner emits its terminal result. Consume + // that result before starting another edit, otherwise it can be mistaken for + // the following compilation's result (notably the host-class rejection below). + let completed = await waitFor(l => l.startsWith('[playground]'), 30000); + check('typed code compiles and runs', r === 'PG-RUN v1' && completed?.includes('preview updated'), + [r, completed].join(' | ')); log.length = 0; await setCode('class Greeter { String hi() { return "v2"; } }\nSystem.out.println("PG-RUN " + new Greeter().hi());\n' + 'Label l = new Label(new Greeter().hi());\nl\n'); r = await waitFor(l => l.startsWith('PG-RUN'), 30000); - check('a re-run redefines classes of the same name', r === 'PG-RUN v2', r); + completed = await waitFor(l => l.startsWith('[playground]'), 30000); + check('a re-run redefines classes of the same name', r === 'PG-RUN v2' && completed?.includes('preview updated'), + [r, completed].join(' | ')); // A class whose VM name is a host class's (java/lang/String is java_lang_String) must be // refused before it is evaluated, and leave the running VM intact for the next run. @@ -161,7 +168,9 @@ try { log.length = 0; await setCode('System.out.println("PG-RUN after " + "x".length());\nLabel l = new Label("ok");\nl\n'); r = await waitFor(l => l.startsWith('PG-RUN'), 30000); - check('the VM still works after the refused class', r === 'PG-RUN after 1', r); + completed = await waitFor(l => l.startsWith('[playground]'), 30000); + check('the VM still works after the refused class', r === 'PG-RUN after 1' && completed?.includes('preview updated'), + [r, completed].join(' | ')); log.length = 0; await setCode('Button b = new Button("Boom");\nb.addActionListener(e -> { String s = null; s.length(); });\n' diff --git a/scripts/desktop-titlebar-tests/README.md b/scripts/desktop-titlebar-tests/README.md new file mode 100644 index 00000000000..4112397c338 --- /dev/null +++ b/scripts/desktop-titlebar-tests/README.md @@ -0,0 +1,49 @@ +# Native desktop title-area checks + +These harnesses compile production native code and run **locally only**. Do not +add them to CI, dispatch native-test jobs, or use the banned `native-test` label. +They do not replace native application acceptance on each supported desktop. + +From the repository root: + +```sh +python3 scripts/desktop-titlebar-tests/check.py macos +python3 scripts/desktop-titlebar-tests/check.py linux +python3 scripts/desktop-titlebar-tests/check.py windows +``` + +Use `--output /path/to/a/new/unique-directory` to retain binaries and Linux +screenshots. Without it, the runner creates and removes a unique temporary +directory. No Maven repository is used by these harnesses. + +* **macOS:** requires Xcode command-line tools and AppKit. Checks actual native + window-button geometry, backing scale, copied metric snapshots, and hidden + controls, asynchronous change notifications (without repeat notifications for + unchanged geometry), and handled/unhandled Dock reopen decisions without launching a document editor. +* **Linux:** requires GCC, GTK 3 and Pango development headers, `pkg-config`, + `xvfb-run`, Openbox, and `xdotool`. Checks left/right system decoration layouts, + title/body coordinates, interactive exclusions, touch ownership across caption/control + boundaries and cancellation, native-peer click/focus/typing and reparenting, + double-click maximize, mode switching, and the real GTK close button. This is + X11 validation; acceptance under a Wayland compositor remains separate. +* **Windows:** requires Clang, an x86_64 MinGW sysroot/linker, and Wine. Compiles + the production chrome, window, and menu sources. Checks hit testing, system + commands, resizing regions, DWM margin refresh on DPI changes with event + fallthrough, and the native-frame fallback. Use `--build-only` + to copy the executable to a separate Windows/Wine environment. Wine without + DWM cannot verify caption rendering or Windows 11 Snap hover behavior; the + executable prints this limitation explicitly. Test these on Windows with + composition enabled before claiming native visual acceptance. + +The Linux and Windows harnesses include the production native implementation +and supply only the Java bridge/environment pieces that the harness doesn't +execute. The macOS harness extracts the production inset routines and supplies +a minimal host backed by a real `NSWindow`. + +The portable Toolbar layout regressions live in `DesktopChromeTest` in +`maven/core-unittests`. They cover additive physical insets, RTL, changes to +system geometry, restored application padding, and mobile/legacy behavior. + +`WindowsDpiTest` in `maven/windows` checks DPI conversion and density updates before +requesting layout refresh, including unchanged DPI and ordinary appearance events. +It does not replace a physical multi-monitor Windows check. diff --git a/scripts/desktop-titlebar-tests/check.py b/scripts/desktop-titlebar-tests/check.py new file mode 100644 index 00000000000..64c4ab030fa --- /dev/null +++ b/scripts/desktop-titlebar-tests/check.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""Local-only native title-area harnesses. No CI configuration or application host.""" +import argparse +import os +import pathlib +import shutil +import subprocess +import tempfile + +root = pathlib.Path(__file__).resolve().parents[2] +p = argparse.ArgumentParser(description=__doc__) +p.add_argument("platform", choices=["linux", "windows", "macos"]) +p.add_argument("--output", type=pathlib.Path, help="New directory for binaries/screenshots") +p.add_argument("--build-only", action="store_true", help="Build the Windows executable without Wine") +a = p.parse_args() +if os.environ.get("CI"): + p.error("Native title-area tests are local only") +if a.platform == "macos": + subprocess.run(["python3", str(root / "scripts/desktop-titlebar-tests/macos.py")], check=True) + raise SystemExit(0) +workspace = None +if a.output: + a.output.mkdir(parents=True, exist_ok=False) + out = a.output.resolve() +else: + workspace = tempfile.TemporaryDirectory(prefix="cn1-titlebar-test-") + out = pathlib.Path(workspace.name) +# These harnesses never translate/run Java. The runtime header only needs the +# generated index declaration, not an application-specific generated source tree. +(out / "cn1_class_method_index.h").write_text("extern int classNameLookup[];\n") +common = ["-ffunction-sections", "-fdata-sections", "-I" + str(root / "vm/ByteCodeTranslator/src"), "-I" + str(out)] +if a.platform == "linux": + flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "gtk+-3.0", "pangocairo"], text=True).split() + binary = out / "linux" + subprocess.run(["gcc", "-std=c11"] + common + ["-I" + str(root / "Ports/LinuxPort/nativeSources"), + str(root / "scripts/desktop-titlebar-tests/linux.c"), "-Wl,--gc-sections"] + flags + + ["-lpthread", "-lm", "-o", str(binary)], check=True) + env = dict(os.environ, G_DEBUG="fatal-warnings") + subprocess.run(["xvfb-run", "-a", "sh", "-c", + 'openbox >"$1/openbox.log" 2>&1 & wm=$!; trap "kill $wm 2>/dev/null || true" EXIT; ' + '"$1/linux" "$1/right.png" "$1/left.png"', "sh", str(out)], check=True, env=env) +else: + # Clang accepts the C11 atomics in the production ParparVM headers under C++. + # The MinGW linker supplies Windows import libraries; no headers are mocked. + linker = "x86_64-w64-mingw32-g++" + sysroot = pathlib.Path(subprocess.check_output([linker, "-print-sysroot"], text=True).strip()) + compiler = shutil.which("clang++") + if not compiler: + p.error("clang++ is required for the Windows cross-compile") + flags = [compiler, "--target=x86_64-w64-windows-gnu", "--sysroot=" + str(sysroot), + "-isystem", str(sysroot / "x86_64-w64-mingw32/include"), "-std=c++17"] + common + [ + "-I" + str(root / "Ports/WindowsPort/nativeSources")] + for source in ["cn1_windows_chrome.cpp", "cn1_windows_window.cpp", "cn1_windows_menu.cpp"]: + subprocess.run(flags + ["-fsyntax-only", str(root / "Ports/WindowsPort/nativeSources" / source)], check=True) + subprocess.run(flags + ["-c", str(root / "scripts/desktop-titlebar-tests/windows.cpp"), + "-o", str(out / "windows.o")], check=True) + subprocess.run([linker, str(out / "windows.o"), "-Wl,--gc-sections", "-ldwmapi", "-luser32", + "-ladvapi32", "-o", str(out / "windows.exe")], check=True) + if not a.build_only: + subprocess.run(["wine", str(out / "windows.exe")], check=True) +if a.output: + print("Artifacts:", out) +if workspace: + workspace.cleanup() diff --git a/scripts/desktop-titlebar-tests/linux.c b/scripts/desktop-titlebar-tests/linux.c new file mode 100644 index 00000000000..f203d4b883e --- /dev/null +++ b/scripts/desktop-titlebar-tests/linux.c @@ -0,0 +1,239 @@ +/* + * 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 Codename One 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. + */ + + +/* Local GTK test of the production window code; unused VM bridges are removed + * with --gc-sections. This is not a Java UI or a replacement application host. */ +#include "cn1_linux_window.c" +#include + +GtkWidget* cn1LinuxDesktopFixed(int slot) { (void)slot; return NULL; } +static void settle(void) { + for (int i = 0; i < 40; i++) { + while (gtk_events_pending()) gtk_main_iteration(); + g_usleep(5000); + } +} +static void snapshot(const char* filename) { + GdkWindow* window = gtk_widget_get_window(cn1Window); + GdkPixbuf* pixels = gdk_pixbuf_get_from_window(window, 0, 0, + gdk_window_get_width(window), gdk_window_get_height(window)); + assert(pixels); + assert(gdk_pixbuf_save(pixels, filename, "png", NULL, NULL)); + g_object_unref(pixels); +} +static GtkWidget* closeButton(GtkWidget* widget); +static void findCloseChild(GtkWidget* child, gpointer data) { + GtkWidget** result = (GtkWidget**)data; + if (!*result) *result = closeButton(child); +} +static GtkWidget* closeButton(GtkWidget* widget) { + if (GTK_IS_BUTTON(widget)) { + const char* name = atk_object_get_name(gtk_widget_get_accessible(widget)); + gboolean close = name && !strcmp(name, "Close"); + if (close) return widget; + } + if (GTK_IS_CONTAINER(widget)) { + GtkWidget* found = NULL; + gtk_container_forall(GTK_CONTAINER(widget), findCloseChild, &found); + return found; + } + return NULL; +} +static gboolean titleTouch(GdkEventType type, GdkEventSequence* sequence, double x) { + GdkEventTouch event = {0}; + event.type = type; + event.sequence = sequence; + event.x = x; + event.y = 10; + gboolean handled = FALSE; + g_signal_emit_by_name(cn1TitleCanvas, "touch-event", &event, &handled); + return handled; +} +static void touchOwnership(void) { + int nativeToken, javaToken; + GdkEventSequence* native = (GdkEventSequence*)&nativeToken; + GdkEventSequence* java = (GdkEventSequence*)&javaToken; + assert(!titleTouch(GDK_TOUCH_BEGIN, native, 200)); + assert(cn1TouchSeq == NULL); + assert(!titleTouch(GDK_TOUCH_UPDATE, native, 10)); // native drag crosses a control + assert(titleTouch(GDK_TOUCH_BEGIN, java, 10)); + assert(cn1TouchSeq == java); + assert(!titleTouch(GDK_TOUCH_END, native, 10)); // concurrent native touch stays native + assert(cn1TouchSeq == java); + assert(titleTouch(GDK_TOUCH_UPDATE, java, 200)); // control drag crosses blank caption + assert(titleTouch(GDK_TOUCH_END, java, 200)); + assert(cn1TouchSeq == NULL && cn1TitleJavaTouches == NULL); + assert(titleTouch(GDK_TOUCH_BEGIN, java, 10)); + assert(titleTouch(GDK_TOUCH_CANCEL, java, 200)); + assert(cn1TouchSeq == NULL && cn1TitleJavaTouches == NULL); + assert(!titleTouch(GDK_TOUCH_BEGIN, native, 200)); + assert(!titleTouch(GDK_TOUCH_CANCEL, native, 200)); +} +int main(int argc, char** argv) { + gtk_init(&argc, &argv); + cn1GtkThread = pthread_self(); + cn1Window = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(cn1Window), "CN1 integrated title test"); + g_signal_connect(cn1Window, "delete-event", G_CALLBACK(cn1OnDelete), NULL); + gtk_window_set_default_size(GTK_WINDOW(cn1Window), 720, 420); + cn1DrawingArea = gtk_drawing_area_new(); + gtk_widget_set_events(cn1DrawingArea, GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | + GDK_POINTER_MOTION_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_SCROLL_MASK | GDK_TOUCH_MASK); + cn1Overlay = gtk_overlay_new(); + gtk_container_add(GTK_CONTAINER(cn1Overlay), cn1DrawingArea); + cn1Fixed = gtk_fixed_new(); + gtk_overlay_add_overlay(GTK_OVERLAY(cn1Overlay), cn1Fixed); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(cn1Overlay), cn1Fixed, TRUE); + cn1AccessibilityFixed = gtk_fixed_new(); + gtk_overlay_add_overlay(GTK_OVERLAY(cn1Overlay), cn1AccessibilityFixed); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(cn1Overlay), cn1AccessibilityFixed, TRUE); + gtk_container_add(GTK_CONTAINER(cn1Window), cn1Overlay); + g_signal_connect(cn1DrawingArea, "draw", G_CALLBACK(cn1OnDraw), NULL); + g_signal_connect(cn1DrawingArea, "configure-event", G_CALLBACK(cn1OnConfigure), NULL); + g_object_set(gtk_settings_get_default(), "gtk-decoration-layout", ":minimize,maximize,close", NULL); + cn1TitleEnable(TRUE); + gtk_widget_show_all(cn1Window); + settle(); + int right[5] = {0}; + cn1TitleMetrics(right); + printf("right: %d %d %d %d %d canvas width %d\n", right[0], right[1], right[2], right[3], right[4], gtk_widget_get_allocated_width(cn1TitleCanvas)); + assert(right[1] > right[0]); + assert(gtk_widget_get_allocated_width(cn1TitleCanvas) > 400); + assert(gtk_widget_get_allocated_height(cn1TitleCanvas) > 20); + assert(right[2] + right[3] < 20); + assert(cn1DisplayHeight == gtk_widget_get_allocated_height(cn1DrawingArea) + cn1TitleHeight); + cn1ApplyPendingResize(); + cairo_set_source_rgb(cn1WindowG.cr, 0.95, 0.95, 0.95); + cairo_paint(cn1WindowG.cr); + cairo_set_source_rgb(cn1WindowG.cr, 0.1, 0.2, 0.4); + cairo_select_font_face(cn1WindowG.cr, "Sans", CAIRO_FONT_SLANT_NORMAL, CAIRO_FONT_WEIGHT_NORMAL); + cairo_set_font_size(cn1WindowG.cr, 15); + cairo_move_to(cn1WindowG.cr, right[0] + 12, right[2] + 22); + cairo_show_text(cn1WindowG.cr, "Save Undo Document title Search"); + cairo_move_to(cn1WindowG.cr, 20, cn1TitleHeight + 35); + cairo_show_text(cn1WindowG.cr, "Document starts below the integrated title bar"); + gtk_widget_queue_draw(cn1Window); + settle(); + if (argc > 1) snapshot(argv[1]); + cn1TitleRegions = malloc(8 * sizeof(int)); + int regions[] = {0, 0, cn1DisplayWidth, cn1TitleHeight, right[0], right[2], 80, 28}; + memcpy(cn1TitleRegions, regions, sizeof(regions)); + cn1TitleRegionCount = 8; + touchOwnership(); + GdkEventButton event = {0}; + event.type = GDK_BUTTON_PRESS; + event.button = 1; + event.x = 10; event.y = 10; + assert(cn1TitleButton(cn1TitleCanvas, &event, NULL)); // application control consumes + event.x = 200; + event.type = GDK_BUTTON_RELEASE; + assert(cn1TitleButton(cn1TitleCanvas, &event, NULL)); // release outside still completes control gesture + assert(!cn1TitlePointerButtons); + event.type = GDK_BUTTON_PRESS; + assert(!cn1TitleButton(cn1TitleCanvas, &event, NULL)); // GTK owns blank caption + assert(cn1TitleEventY(cn1DrawingArea, 12) == cn1TitleHeight + 12); + GtkWidget* edit = gtk_entry_new(); + cn1LinuxOverlayAdd(CN1_MAIN_WINDOW_SLOT, edit, right[0] + 5, right[2] + 5, 100, 22); + assert(gtk_widget_get_parent(edit) == cn1TitleFixed); + settle(); + // Use real pointer/key events: parentage alone cannot detect a pass-through host. + gtk_window_set_focus(GTK_WINDOW(cn1Window), NULL); + int editX, editY; + gtk_widget_translate_coordinates(edit, cn1TitleCanvas, 10, 10, &editX, &editY); + int canvasX, canvasY; + gdk_window_get_origin(gtk_widget_get_window(cn1TitleCanvas), &canvasX, &canvasY); + char input[200]; + snprintf(input, sizeof(input), "xdotool mousemove %d %d click 1", canvasX + editX, canvasY + editY); + assert(g_spawn_command_line_sync(input, NULL, NULL, NULL, NULL)); + settle(); + assert(gtk_widget_has_focus(edit)); + assert(g_spawn_command_line_sync("xdotool type native-title-input", NULL, NULL, NULL, NULL)); + settle(); + assert(!strcmp(gtk_entry_get_text(GTK_ENTRY(edit)), "native-title-input")); + // A blank portion of the same overlay must still reach the title canvas. + snprintf(input, sizeof(input), "xdotool mousemove %d %d click --repeat 2 --delay 80 1", + canvasX + 300, canvasY + 20); + assert(g_spawn_command_line_sync(input, NULL, NULL, NULL, NULL)); + settle(); + assert(gtk_window_is_maximized(GTK_WINDOW(cn1Window))); + gtk_window_unmaximize(GTK_WINDOW(cn1Window)); + settle(); + cn1LinuxOverlayMove(CN1_MAIN_WINDOW_SLOT, edit, 20, cn1TitleHeight + 30, 100, 22); + assert(gtk_widget_get_parent(edit) == cn1Fixed); + cn1LinuxOverlayRemove(CN1_MAIN_WINDOW_SLOT, edit); + // A peer spanning the title/body boundary must retain its document portion. + GtkWidget* spanning = gtk_drawing_area_new(); + cn1LinuxOverlayAdd(CN1_MAIN_WINDOW_SLOT, spanning, 0, 0, 300, cn1TitleHeight + 120); + assert(gtk_widget_get_parent(spanning) == cn1Fixed); + cn1LinuxOverlayMove(CN1_MAIN_WINDOW_SLOT, spanning, right[0], right[2], 100, 20); + assert(gtk_widget_get_parent(spanning) == cn1TitleFixed); + cn1LinuxOverlayMove(CN1_MAIN_WINDOW_SLOT, spanning, 0, cn1TitleHeight - 5, 300, 120); + assert(gtk_widget_get_parent(spanning) == cn1Fixed); + int spanningY; + gtk_container_child_get(GTK_CONTAINER(cn1Fixed), spanning, "y", &spanningY, NULL); + assert(spanningY == -5); + cn1LinuxOverlayRemove(CN1_MAIN_WINDOW_SLOT, spanning); + g_object_set(gtk_settings_get_default(), "gtk-decoration-layout", "close,minimize,maximize:", NULL); + settle(); + int left[5] = {0}; + cn1TitleMetrics(left); + printf("left: %d %d %d %d %d\n", left[0], left[1], left[2], left[3], left[4]); + assert(left[0] > left[1]); + if (argc > 2) snapshot(argv[2]); + int originX, originY; + gdk_window_get_origin(gtk_widget_get_window(cn1TitleCanvas), &originX, &originY); + char click[160]; + snprintf(click, sizeof(click), "xdotool mousemove %d %d click --repeat 2 --delay 80 1", + originX + 200, originY + 20); + assert(g_spawn_command_line_sync(click, NULL, NULL, NULL, NULL)); + settle(); + assert(gtk_window_is_maximized(GTK_WINDOW(cn1Window))); + gtk_window_unmaximize(GTK_WINDOW(cn1Window)); + settle(); + assert(!gtk_window_is_maximized(GTK_WINDOW(cn1Window))); + edit = gtk_entry_new(); + cn1LinuxOverlayAdd(CN1_MAIN_WINDOW_SLOT, edit, left[0] + 5, left[2] + 5, 100, 22); + GtkWidget* accessible = gtk_button_new_with_label("Title action"); + gtk_fixed_put(GTK_FIXED(cn1TitleAccessibilityFixed), accessible, 5, 5); + cn1TitleEnable(FALSE); + settle(); + assert(gtk_widget_get_parent(edit) == cn1Fixed); + assert(gtk_widget_get_parent(accessible) == cn1AccessibilityFixed); + cn1LinuxOverlayRemove(CN1_MAIN_WINDOW_SLOT, edit); + assert(!gtk_window_get_titlebar(GTK_WINDOW(cn1Window))); + assert(cn1TitleHeight == 0); + cn1TitleEnable(TRUE); + settle(); + assert(GTK_IS_HEADER_BAR(gtk_window_get_titlebar(GTK_WINDOW(cn1Window)))); + if (argc > 2) snapshot(argv[2]); + GtkWidget* close = closeButton(cn1TitleHeader); + assert(close); + cn1WindowOpen = 1; + gtk_button_clicked(GTK_BUTTON(close)); + settle(); + assert(!cn1WindowOpen); + gtk_widget_destroy(cn1Window); + puts("GTK_TITLEBAR_OK"); + return 0; +} diff --git a/scripts/desktop-titlebar-tests/macos.py b/scripts/desktop-titlebar-tests/macos.py new file mode 100644 index 00000000000..d199dbbac9f --- /dev/null +++ b/scripts/desktop-titlebar-tests/macos.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""Local AppKit check of production title-control geometry; no windows are shown.""" +import pathlib +import subprocess +import tempfile + +root = pathlib.Path(__file__).resolve().parents[2] +source = (root / "Ports/MacPort/nativeSources/CN1MacChrome.m").read_text() +start = source.index("static int cn1TitleInsets[5];") +end = source.index("void CN1MacHostSetWindowUndecorated", start) +delegate = (root / "Ports/MacPort/nativeSources/CN1MacAppDelegate.m").read_text() +reopen_start = delegate.index("- (BOOL)applicationShouldHandleReopen:") +reopen_end = delegate.index("/// Quitting with the last window", reopen_start) +harness = r''' +#import +#include +#define CODENAME_ONE_THREAD_STATE void* state +#define JAVA_OBJECT void* +#define JAVA_NULL NULL +#define CN1_THREAD_GET_STATE_PASS_SINGLE_ARG NULL +typedef struct { int length; int data[5]; } Array; +#define JAVA_ARRAY Array* +#define CN1_ARRAY_DATA(a) (((Array*)a)->data) +@interface CN1MacHost : NSObject +@property(nonatomic, strong) NSWindow* window; +@property(nonatomic, strong) NSView* renderingView; +- (NSWindow*)builtWindow; ++ (instancetype)sharedHost; +@end +@implementation CN1MacHost +- (NSWindow*)builtWindow { return self.window; } ++ (instancetype)sharedHost { static CN1MacHost* h; if (!h) h = [CN1MacHost new]; return h; } +@end +static int insetNotifications; +void com_codename1_impl_mac_MacImplementation_macTitleBarInsetsChanged__(void* state) { + assert([NSThread isMainThread]); + insetNotifications++; +} +PRODUCTION +@interface ReopenWindow : NSWindow +@property(nonatomic) BOOL testMiniaturized; +@property(nonatomic) int restored; +@property(nonatomic) int shown; +@end +@implementation ReopenWindow +- (BOOL)isMiniaturized { return self.testMiniaturized; } +- (void)deminiaturize:(id)sender { self.testMiniaturized = NO; self.restored++; } +- (void)makeKeyAndOrderFront:(id)sender { self.shown++; } +@end +@interface ReopenDelegate : NSObject +@end +@implementation ReopenDelegate +/*REOPEN*/ +@end +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + CN1MacHost* host = [CN1MacHost sharedHost]; + host.window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0,0,800,600) + styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | + NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable | + NSWindowStyleMaskFullSizeContentView backing:NSBackingStoreBuffered defer:NO]; + host.window.titlebarAppearsTransparent = YES; + host.window.titleVisibility = NSWindowTitleHidden; + host.renderingView = host.window.contentView; + cn1MacUpdateTitleInsets(); + assert(insetNotifications == 1); + CGFloat scale = host.window.backingScaleFactor; + assert(cn1TitleInsets[0] > 60 * scale && cn1TitleInsets[0] < 130 * scale); + assert(cn1TitleInsets[1] == 0); + assert(cn1TitleInsets[4] > 10 * scale && cn1TitleInsets[4] < 60 * scale); + Array result = {5, {0}}; + com_codename1_impl_mac_MacNative_macTitleBarInsets___int_1ARRAY(NULL,NULL,&result); + assert(result.data[0] == cn1TitleInsets[0]); + for (NSNumber* type in @[@(NSWindowCloseButton), @(NSWindowMiniaturizeButton), @(NSWindowZoomButton)]) + [host.window standardWindowButton:type.integerValue].hidden = YES; + // The query must return promptly with the old cache, then notify after + // the AppKit refresh changes it. No manual resize or repaint is needed. + com_codename1_impl_mac_MacNative_macTitleBarInsets___int_1ARRAY(NULL,NULL,&result); + assert(result.data[0] > 0 && insetNotifications == 1); + NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow:2]; + while (insetNotifications == 1 && deadline.timeIntervalSinceNow > 0) + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + assert(insetNotifications == 2); + assert(cn1TitleInsets[0] == 0 && cn1TitleInsets[4] == 0); + com_codename1_impl_mac_MacNative_macTitleBarInsets___int_1ARRAY(NULL,NULL,&result); + assert(result.data[0] == 0 && result.data[4] == 0); + [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; + assert(insetNotifications == 2); // unchanged metrics must not loop repaint + ReopenWindow* window = [[ReopenWindow alloc] initWithContentRect:NSMakeRect(0,0,100,100) + styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + host.window = window; + ReopenDelegate* delegate = [ReopenDelegate new]; + assert(![delegate applicationShouldHandleReopen:NSApp hasVisibleWindows:NO]); + assert(window.shown == 1); + assert([delegate applicationShouldHandleReopen:NSApp hasVisibleWindows:YES]); + assert(window.shown == 1); + window.testMiniaturized = YES; + assert(![delegate applicationShouldHandleReopen:NSApp hasVisibleWindows:YES]); + assert(window.shown == 2 && window.restored == 1); + host.window = nil; + assert([delegate applicationShouldHandleReopen:NSApp hasVisibleWindows:NO]); + puts("APPKIT_TITLEBAR_INSETS_AND_REOPEN_OK"); + } +} +'''.replace("PRODUCTION", source[start:end]).replace("/*REOPEN*/", delegate[reopen_start:reopen_end]) +with tempfile.TemporaryDirectory(prefix="cn1-titlebar-appkit-") as directory: + path = pathlib.Path(directory) + (path / "test.m").write_text(harness) + subprocess.run(["xcrun", "clang", "-fobjc-arc", "-fblocks", "-framework", "AppKit", + str(path / "test.m"), "-o", str(path / "test")], check=True) + subprocess.run([str(path / "test")], check=True) diff --git a/scripts/desktop-titlebar-tests/windows.cpp b/scripts/desktop-titlebar-tests/windows.cpp new file mode 100644 index 00000000000..b52fda6eaaf --- /dev/null +++ b/scripts/desktop-titlebar-tests/windows.cpp @@ -0,0 +1,143 @@ +/* + * 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 Codename One 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. + */ + + + +#include "cn1_windows.h" +#include +static int marginUpdates; +static MARGINS lastMargins; +static HRESULT WINAPI recordFrameMargins(HWND window, const MARGINS* margins) { + marginUpdates++; + lastMargins = *margins; + return DwmExtendFrameIntoClientArea(window, margins); +} +// Observe the real DWM call while retaining its native implementation. +#define DwmExtendFrameIntoClientArea recordFrameMargins +#include "cn1_windows_chrome.cpp" +#undef DwmExtendFrameIntoClientArea +#include +#include +extern "C" { +CN1WindowsContext cn1Win; +void cn1WinMenuSetIntegrated(BOOL integrated) { (void)integrated; } +struct ThreadLocalData* getThreadLocalData(void) { + static struct ThreadLocalData state; + return &state; +} +} +static LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + LRESULT result; + if (cn1WinChromeMessage(hwnd, msg, wp, lp, &result)) return result; + return DefWindowProcW(hwnd, msg, wp, lp); +} +static void settle(void) { + for (int i = 0; i < 30; i++) { + MSG msg; + while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + Sleep(5); + } +} +static LRESULT hit(HWND window, int x, int y) { + POINT point = {x, y}; + ClientToScreen(window, &point); + return SendMessageW(window, WM_NCHITTEST, 0, MAKELPARAM(point.x, point.y)); +} +int main(void) { + WNDCLASSW type = {}; + type.lpfnWndProc = windowProc; + type.hInstance = GetModuleHandleW(NULL); + type.lpszClassName = L"CN1TitleTest"; + assert(RegisterClassW(&type)); + HWND window = CreateWindowExW(0, type.lpszClassName, L"Integrated title test", + WS_OVERLAPPEDWINDOW, 80, 80, 720, 420, NULL, NULL, type.hInstance, NULL); + assert(window); + cn1Win.hwnd = window; + ShowWindow(window, SW_SHOW); + settle(); + int metrics[5] = {}; + SendMessageW(window, WM_CN1_CHROME_METRICS, 0, (LPARAM)metrics); + BOOL compositorAvailable = cn1ChromeEnabled; + if (compositorAvailable) { + assert(metrics[1] > 0 && metrics[4] > 0); + } else { + // Wine may expose no compositor. Verify the safe native-frame fallback, + // then exercise the production hit-test math separately. This is not + // Windows 11 caption-button/Snap visual acceptance. + assert(metrics[0] == 0 && metrics[1] == 0 && metrics[4] == 0); + puts("No DWM compositor: testing fallback and hit routing only"); + } + assert((GetWindowLongPtrW(window, GWL_STYLE) & WS_OVERLAPPEDWINDOW) == WS_OVERLAPPEDWINDOW); + int source[] = {0, 0, 700, 50, 20, 12, 80, 28}; + int* regions = (int*)malloc(sizeof(source)); + memcpy(regions, source, sizeof(source)); + SendMessageW(window, WM_CN1_CHROME_REGIONS, 8, (LPARAM)regions); + cn1ChromeEnabled = TRUE; + SetWindowPos(window, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); + int beforeDpi = marginUpdates; + RECT suggested; + GetWindowRect(window, &suggested); + LRESULT result = 0; + UINT dpi = cn1ChromeDpi(window); + // Processing the chrome message must refresh DWM and leave the event for + // the outer window proc to resize the HWND and publish the Java DPI event. + assert(!cn1WinChromeMessage(window, WM_DPICHANGED, MAKELONG(dpi, dpi), + (LPARAM)&suggested, &result)); + assert(marginUpdates == beforeDpi + 1); + assert(lastMargins.cyTopHeight == cn1ChromeMetric(window, SM_CYCAPTION) + + cn1ChromeMetric(window, SM_CYSIZEFRAME)); + assert(lastMargins.cxLeftWidth == 0 && lastMargins.cxRightWidth == 0 + && lastMargins.cyBottomHeight == 0); + assert(hit(window, 30, 25) == HTCLIENT); + assert(hit(window, 200, 25) == HTCAPTION); + assert(hit(window, 200, 100) == HTCLIENT); + assert(hit(window, 200, 1) == HTTOP); + assert(!cn1ChromeDragPoint(-1, 20)); + assert(!cn1ChromeDragPoint(700, 20)); + assert(!cn1ChromeDragPoint(200, 50)); + SendMessageW(window, WM_SYSCOMMAND, SC_MAXIMIZE, 0); + settle(); + assert(IsZoomed(window)); + SendMessageW(window, WM_SYSCOMMAND, SC_RESTORE, 0); + settle(); + assert(!IsZoomed(window)); + SendMessageW(window, WM_SYSCOMMAND, SC_MINIMIZE, 0); + settle(); + assert(IsIconic(window)); + SendMessageW(window, WM_SYSCOMMAND, SC_RESTORE, 0); + settle(); + assert(!IsIconic(window)); + SendMessageW(window, WM_CN1_CHROME_REGIONS, 0, 0); + assert(!cn1ChromeEnabled); + assert(!cn1ChromeRegions); + beforeDpi = marginUpdates; + assert(!cn1WinChromeMessage(window, WM_DPICHANGED, MAKELONG(dpi, dpi), + (LPARAM)&suggested, &result)); + assert(marginUpdates == beforeDpi); // native-frame mode needs no custom margin + SendMessageW(window, WM_CLOSE, 0, 0); + assert(!IsWindow(window)); + puts("WINDOWS_TITLEBAR_OK"); +} diff --git a/scripts/ios/text-input-state-tests/check.py b/scripts/ios/text-input-state-tests/check.py new file mode 100644 index 00000000000..afd2a2caf34 --- /dev/null +++ b/scripts/ios/text-input-state-tests/check.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0 +"""Local production text-state/geometry publication check (macOS + Xcode). + +Uses real Foundation strings and the main dispatch queue. Only UIKit's screen +scale and the Java publication callback are stubbed; no simulator is launched. +""" +import os +from pathlib import Path +import subprocess +import tempfile + +if os.environ.get("CI"): + raise SystemExit("Native checks are local only") +root = Path(__file__).resolve().parents[3] +source = (root / "Ports/iOSPort/nativeSources/CN1TextInputView.m").read_text() +start = source.index("- (BOOL)cn1SyncText:") +end = source.index("- (void)cn1ResetComposition", start) +bridge_start = source.index("void com_codename1_impl_ios_IOSNative_updateTextInputState_") +bridge_end = source.index("void com_codename1_impl_ios_IOSNative_stopTextInput__", bridge_start) +harness = r''' +#import +#import +#include +#define CN1_THREAD_STATE_MULTI_ARG +#define CN1_THREAD_GET_STATE_PASS_ARG +#define JAVA_OBJECT NSString* +#define JAVA_INT int +#define JAVA_BOOLEAN BOOL +@interface UIScreen : NSObject ++ (instancetype)mainScreen; +- (CGFloat)scale; +@end +@implementation UIScreen ++ (instancetype)mainScreen { static UIScreen* screen; if (!screen) screen = [UIScreen new]; return screen; } +- (CGFloat)scale { return 2; } +@end +@interface CN1TextInputView : NSObject { +@public + NSMutableString* _shadow; + NSInteger _editSeq; + NSRange _markedRange; + NSRange _selectedRange; + CGRect _caretRectPoints; +} +@end +@implementation CN1TextInputView +/*SYNC*/ +@end +static CN1TextInputView* cn1TextInputView; +static NSString* toNSString(NSString* text) { return text; } +static int published; +static BOOL accepted; +static NSString* expectedText; +static NSRange expectedSelection; +void com_codename1_impl_ios_IOSImplementation_tiPublishGeometry___int_boolean(int captureId, BOOL matches) { + assert([NSThread isMainThread]); + published = captureId; + accepted = matches; + if (matches) { + // This is when Java publishes the capture: the native shadow must already match. + assert([cn1TextInputView->_shadow isEqualToString:expectedText]); + assert(NSEqualRanges(cn1TextInputView->_selectedRange, expectedSelection)); + } +} +/*BRIDGE*/ +static void drainUntil(int captureId) { + NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow:2]; + while (published != captureId && [deadline timeIntervalSinceNow] > 0) + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + assert(published == captureId); +} +int main(void) { + @autoreleasepool { + cn1TextInputView = [CN1TextInputView new]; + cn1TextInputView->_shadow = [@"old" mutableCopy]; + cn1TextInputView->_selectedRange = NSMakeRange(1, 0); + cn1TextInputView->_markedRange = NSMakeRange(NSNotFound, 0); + cn1TextInputView->_editSeq = 4; + expectedText = @"new document"; + expectedSelection = NSMakeRange(2, 3); + com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int_int( + nil, expectedText, 2, 5, 10, 20, 4, 16, 4, 71); + assert(published == 0 && [cn1TextInputView->_shadow isEqualToString:@"old"]); + drainUntil(71); + assert(accepted); + assert(CGRectEqualToRect(cn1TextInputView->_caretRectPoints, CGRectMake(5, 10, 2, 8))); + // A newer native keystroke makes an older Java echo stale, geometry included. + cn1TextInputView->_editSeq = 5; + com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int_int( + nil, @"stale", 0, 0, 80, 90, 4, 16, 4, 72); + drainUntil(72); + assert(!accepted && [cn1TextInputView->_shadow isEqualToString:expectedText]); + assert(CGRectEqualToRect(cn1TextInputView->_caretRectPoints, CGRectMake(5, 10, 2, 8))); + cn1TextInputView->_markedRange = NSMakeRange(2, 3); + assert(![cn1TextInputView cn1SyncText:@"other" selStart:2 selEnd:5 caretRect:CGRectZero seq:5]); + // A matching IME echo may update geometry without replacing the marked text. + assert([cn1TextInputView cn1SyncText:expectedText selStart:2 selEnd:5 + caretRect:CGRectMake(20, 30, 4, 16) seq:5]); + assert(NSEqualRanges(cn1TextInputView->_markedRange, NSMakeRange(2, 3))); + assert(CGRectEqualToRect(cn1TextInputView->_caretRectPoints, CGRectMake(10, 15, 2, 8))); + puts("TEXT_INPUT_STATE_GEOMETRY_OK"); + } +} +'''.replace("/*SYNC*/", source[start:end]).replace("/*BRIDGE*/", source[bridge_start:bridge_end]) +with tempfile.TemporaryDirectory(prefix="cn1-text-input-state-") as directory: + path = Path(directory) + (path / "test.m").write_text(harness) + subprocess.run(["xcrun", "clang", "-fobjc-arc", "-fblocks", "-Wall", "-Werror", + "-framework", "Foundation", "-framework", "CoreGraphics", + str(path / "test.m"), "-o", str(path / "test")], check=True) + subprocess.run([str(path / "test")], check=True, timeout=10) diff --git a/scripts/macos/editor-command-tests/check.py b/scripts/macos/editor-command-tests/check.py new file mode 100644 index 00000000000..9a531958db7 --- /dev/null +++ b/scripts/macos/editor-command-tests/check.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Local macOS test of production editor command ordering; no CI integration.""" +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[3] +view = (ROOT / "Ports/MacPort/nativeSources/METALView.m").read_text() +view = view[view.index("@implementation METALView"):] +session = (ROOT / "Ports/MacPort/nativeSources/CN1AppKitTextInput.m").read_text() + + +def method(source, signature): + start = source.index(signature) + end = source.index("\n}", start) + 2 + return source[start:end] + "\n" + + +with tempfile.TemporaryDirectory(prefix="cn1-editor-command-") as directory: + tmp = Path(directory) + (tmp / "queue.h").write_text("".join(method(session, signature) for signature in [ + "- (void)resetDeferredEditsIfNeeded", "- (BOOL)deferNativeEdit:", + "- (int)beginEditorCommand", "- (void)completeEditorCommand:"])) + (tmp / "echo.h").write_text(method(session, + "JAVA_VOID com_codename1_impl_ios_IOSNative_updateTextInputState___")) + (tmp / "view.h").write_text("".join(method(view, signature) for signature in [ + "- (BOOL)cn1DeferNativeEdit:", "- (void)cn1SendEditorKeyCommand:(int)command modifiers:", + "- (void)cn1SendEditorKeyCommand:(int)command {", "- (NSInteger)cn1ReplaceRange:", + "- (NSRange)cn1EffectiveRange:", "- (void)insertText:", "- (void)cut:", "- (void)paste:"])) + executable = tmp / "contract" + subprocess.run(["xcrun", "clang", "-fno-objc-arc", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-framework", "AppKit", "-I", str(tmp), + str(Path(__file__).with_name("contract.m")), "-o", str(executable)], check=True, timeout=60) + subprocess.run([str(executable)], check=True, timeout=15) diff --git a/scripts/macos/editor-command-tests/contract.m b/scripts/macos/editor-command-tests/contract.m new file mode 100644 index 00000000000..12c1183d1ff --- /dev/null +++ b/scripts/macos/editor-command-tests/contract.m @@ -0,0 +1,168 @@ +/* + * 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 +#include + +#define JAVA_VOID void +#define JAVA_INT int +#define JAVA_OBJECT id +#define JAVA_NULL nil +#define CODENAME_ONE_THREAD_STATE void *thread +#define CN1_THREAD_GET_STATE_PASS_ARG NULL, +struct ThreadLocalData { int unused; }; +static struct ThreadLocalData *getThreadLocalData(void) { return NULL; } +static NSString *toNSString(void *thread, id text) { return text; } +static NSMutableArray *mainQueue; +static void enqueue(dispatch_block_t block) { + dispatch_block_t saved = [block copy]; + [mainQueue addObject:saved]; + [saved release]; +} +#define dispatch_async(queue, block) enqueue(block) + +@interface CN1MacTextInputSession : NSObject { + int editSeq, sessionGeneration, pendingEditorCommand, deferredEditGeneration; + NSMutableArray *deferredEdits; +} +@property BOOL active, pureEditor, multiline; +@property NSUInteger maxSize; +@property(copy) NSString *text; +@property NSRange selectedRange, markedRange; +@property CGRect caretRect; ++ (instancetype)sharedSession; +- (int)nextEditSeq; +- (int)currentEditSeq; +- (int)currentSessionGeneration; +- (void)reset:(NSString *)text; +- (void)commitFinished:(BOOL)finished; +- (BOOL)deferNativeEdit:(void (^)(void))edit; +- (int)beginEditorCommand; +- (void)completeEditorCommand:(int)sequence; +- (void)resetDeferredEditsIfNeeded; +@end +@implementation CN1MacTextInputSession ++ (instancetype)sharedSession { static id value; if (!value) value = [self new]; return value; } +- (int)nextEditSeq { return ++editSeq; } +- (int)currentEditSeq { return editSeq; } +- (int)currentSessionGeneration { return sessionGeneration; } +- (void)reset:(NSString *)text { + sessionGeneration++; + self.active = YES; self.pureEditor = YES; self.multiline = YES; + self.text = text; self.selectedRange = NSMakeRange(1, 2); + self.markedRange = NSMakeRange(NSNotFound, 0); + [self resetDeferredEditsIfNeeded]; +} +- (void)commitFinished:(BOOL)finished { } +#include "queue.h" +@end + +static int command, commandSequence, commandCount, replacements; +static NSRange replacedRange; +static BOOL cn1OwnsSession(id view, CN1MacTextInputSession *session) { return session.active; } +static BOOL cn1CutBlocked(id view, CN1MacTextInputSession *session) { return NO; } +static BOOL cn1PasteBlocked(id view, CN1MacTextInputSession *session) { return NO; } +static void CN1MacTextInputNotifyReplace(NSRange range, NSString *text) { + replacedRange = range; replacements++; + [[CN1MacTextInputSession sharedSession] nextEditSeq]; +} +static void CN1MacTextInputNotifyFinishComposing(void) { } +void com_codename1_impl_ios_IOSImplementation_tiKeyCommandWithSequence___int_int_int( + void *thread, int cmd, int modifiers, int sequence) { + command = cmd; commandSequence = sequence; commandCount++; +} +#include "echo.h" + +@interface TestView : NSObject +- (BOOL)cn1DeferNativeEdit:(void (^)(void))edit; +- (void)cn1SendEditorKeyCommand:(int)command modifiers:(int)modifiers; +- (void)cn1SendEditorKeyCommand:(int)command; +- (void)cn1ForgetAnchor; +- (NSRange)cn1SelectionForEditing; +- (void)copy:(id)sender; +- (void)delete:(id)sender; +- (void)cut:(id)sender; +- (void)paste:(id)sender; +- (void)insertText:(id)string replacementRange:(NSRange)range; +@end +@implementation TestView +- (void)cn1ForgetAnchor { } +- (NSRange)cn1SelectionForEditing { return [CN1MacTextInputSession sharedSession].selectedRange; } +- (void)copy:(id)sender { } +- (void)delete:(id)sender { } +#include "view.h" +@end + +static void drain(void) { + while (mainQueue.count) { + dispatch_block_t block = [mainQueue[0] copy]; + [mainQueue removeObjectAtIndex:0]; block(); [block release]; + } +} +static void echo(NSString *text, int caret, int sequence) { + com_codename1_impl_ios_IOSNative_updateTextInputState___java_lang_String_int_int_int_int_int_int_int( + NULL, nil, text, caret, caret, 0, 0, 1, 20, sequence); +} +int main(void) { + @autoreleasepool { + mainQueue = [NSMutableArray new]; + CN1MacTextInputSession *session = [CN1MacTextInputSession sharedSession]; + TestView *view = [TestView new]; + [session reset:@"abcd"]; + [view cut:nil]; + assert(command == 13); + int cutSequence = commandSequence; + [view insertText:@"X" replacementRange:NSMakeRange(NSNotFound, 0)]; + assert([session.text isEqualToString:@"abcd"] && replacements == 0); + echo(@"abcd", 3, cutSequence - 1); drain(); // stale echo cannot release typing + assert(replacements == 0); + echo(@"ad", 1, cutSequence); drain(); + assert([session.text isEqualToString:@"aXd"]); + assert(NSEqualRanges(replacedRange, NSMakeRange(1, 0))); + + [session reset:@"abcd"]; + [view paste:nil]; + int firstPaste = commandSequence; + int beforeCommands = commandCount; + [view paste:nil]; + [view insertText:@"X" replacementRange:NSMakeRange(NSNotFound, 0)]; + assert(commandCount == beforeCommands); + // Rich paste result intentionally differs from any plain-text clipboard guess. + echo(@"a[image]d", 8, firstPaste); drain(); + assert(command == 14 && commandCount == beforeCommands + 1); + assert([session.text isEqualToString:@"a[image]d"]); + echo(@"a[image][table]d", 15, commandSequence); drain(); + assert([session.text isEqualToString:@"a[image][table]Xd"]); + + [session reset:@"abcd"]; + [view cut:nil]; + [view insertText:@"lost" replacementRange:NSMakeRange(NSNotFound, 0)]; + echo(@"ad", 1, commandSequence); // queued for old session + [session reset:@"new document"]; + drain(); + assert([session.text isEqualToString:@"new document"]); + [view insertText:@"X" replacementRange:NSMakeRange(NSNotFound, 0)]; + assert([session.text isEqualToString:@"nX document"]); + puts("MAC_EDITOR_COMMAND_ORDER_OK"); + } + return 0; +} diff --git a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java index 4a82f9dd9ca..2b8fa2939e3 100644 --- a/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java +++ b/vm/ByteCodeTranslator/src/com/codename1/tools/translator/ByteCodeTranslator.java @@ -1365,7 +1365,7 @@ private static void writeCmakeProject(File projectRoot, File srcRoot, String app // saw it because the cross-compile leg sets WEBVIEW2_SDK_DIR and got the // library by accident, which is the same way the iOS port once lost // UniformTypeIdentifiers. - writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt winmm runtimeobject dbghelp advapi32)\n"); + writer.append(" target_link_libraries(${PROJECT_NAME} d2d1 dwrite dxgi windowscodecs winhttp ws2_32 user32 gdi32 ole32 oleaut32 uuid mf mfplat mfreadwrite mfuuid shell32 comdlg32 crypt32 bcrypt ncrypt winmm runtimeobject dbghelp advapi32 dwmapi)\n"); // BrowserComponent is backed by WebView2 (cn1_windows_browser.cpp), // gated on the SDK being present: when WEBVIEW2_SDK_DIR points at a // Microsoft.Web.WebView2 build/native folder we link the static diff --git a/vm/backend/build.sh b/vm/backend/build.sh index b9343205ac7..4e17e905f4f 100755 --- a/vm/backend/build.sh +++ b/vm/backend/build.sh @@ -60,8 +60,8 @@ TRANSLATOR_STAMP="$TRANSLATOR/com/codename1/tools/translator/ByteCodeTranslator. # Rebuild when a TRANSLATOR SOURCE is newer than what was compiled, for the same # reason the JavaAPI block below does it -- but here staleness does not merely # keep the old behaviour, it fails the build in a place that names nothing. The -# C runtime sources are copied out of src/ on every run a few lines down, so an -# old target/classes pairs a NEW cn1_globals.h with the emitter that preceded +# C runtime sources are staged from src/ on every run a few lines down, so an +# old translator pairs a NEW cn1_globals.h with the emitter that preceded # it. After a merge that renamed the POP2 helper, the generated C still called # the previous one and clang reported an undeclared function in a file this # script had just written; neither the translator nor target/classes appeared in @@ -72,13 +72,18 @@ if [ ! -f "$TRANSLATOR_STAMP" ] \ (cd "$REPO/vm" && mvn -q -B -pl ByteCodeTranslator -am package -DskipTests) fi -# the C runtime resources the translator emits from its classpath +# The C runtime resources the translator emits from its classpath. # cn1_sqlite3.c is synced too: it carries our build options for the bundled # engine, and a stale copy in target/classes silently builds the old ones. +# Stage privately and put this directory first on this build's classpath. +# Copying into shared target/classes truncates each destination before writing; +# a concurrent translator can read an empty header or a partial runtime source. +RUNTIME_RESOURCES="$WORK/runtime-resources" +mkdir -p "$RUNTIME_RESOURCES" for f in cn1_globals.h cn1_globals.m nativeMethods.m cn1_intrinsics.h cn1_sqlite3.c \ cn1_process.h cn1_process_vm.h \ cn1_virtual_thread.h cn1_virtual_thread.c cn1_virtual_thread_asm.S; do - cp "$REPO/vm/ByteCodeTranslator/src/$f" "$TRANSLATOR/$f" + cp "$REPO/vm/ByteCodeTranslator/src/$f" "$RUNTIME_RESOURCES/$f" done # Compiled once and shared by every build in this tree, so concurrent builds -- @@ -228,7 +233,7 @@ fi # CN1_BACKEND_CHECKED_CASTS=0 turns it off to measure what it costs. CAST_OPT="-Dcn1.checkedCasts=true" if [ "${CN1_BACKEND_CHECKED_CASTS:-1}" = "0" ]; then CAST_OPT=""; fi -"$J8/bin/java" $SQLITE_OPT $CAST_OPT $CN1_BACKEND_TRANSLATOR_OPTS -cp "$TRANSLATOR" \ +"$J8/bin/java" $SQLITE_OPT $CAST_OPT $CN1_BACKEND_TRANSLATOR_OPTS -cp "$RUNTIME_RESOURCES:$TRANSLATOR" \ com.codename1.tools.translator.ByteCodeTranslator \ clean "$JAVAAPI;$WORK/classes" "$WORK/out" "$MAIN" "$PKG" "$MAIN" 1.0 clean none \ > "$WORK/translate.log" 2>&1 || { echo "TRANSLATE FAILED"; tail -30 "$WORK/translate.log"; exit 1; } diff --git a/vm/selfhost/perf-baseline/evidence/pr-6019-windows-x64.md b/vm/selfhost/perf-baseline/evidence/pr-6019-windows-x64.md new file mode 100644 index 00000000000..717986702cb --- /dev/null +++ b/vm/selfhost/perf-baseline/evidence/pr-6019-windows-x64.md @@ -0,0 +1,26 @@ +# PR 6019: Windows x64 string-building memory calibration + +CPU: `AMD64 Family 26 Model 2 Stepping 1, AuthenticAMD`, four CPUs. Each report contains five interleaved rounds. These are existing hosted measurements; no native workflow was dispatched or rerun. + +| Run/job | Artifact | RAM ratio median | Report SHA-256 | +| --- | --- | ---: | --- | +| [38681883624](https://github.com/codenameone/CodenameOne/actions/runs/38681883624/job/116248034828) | `11948899513` / `perf/perf-results.json` | 0.266562214 | `e8550a38168eb9094cc26e7cdd486c9d0d8760c18697843bd6541008693ac0df` | +| [38689265151](https://github.com/codenameone/CodenameOne/actions/runs/38689265151/job/116271143659) | `11952492356` / `perf/perf-results.json` | 0.266736306 | `d43756195d9d207b8e9fe372b6bc29455fe79652b9a215b1de6ea13d1478e1bf` | +| [38688282220](https://github.com/codenameone/CodenameOne/actions/runs/38688282220/job/116277654729) | `11953418308` / `perf/perf-results.json` | 0.268257110 | `75389b42974b4597fd7529a56325784df3ecdd491d988a19f3264885aebda65e` | + +The pooled median is **0.267x**, consistent with the independently recorded [PR 5976 calibration](https://github.com/codenameone/CodenameOne/blob/0890827888cba6bb8710b7cf21224112f6221b82/vm/selfhost/perf-baseline/pr/5976.json). The previous 0.175x row came from a single run recorded in PR 6010. Across the three runs above, ParparVM peaks are 79.3–80.7 MB; JDK 25 peaks are 247.2–300.0 MB. The Word title/input changes do not modify the string benchmark, allocator, or collector. This recalibration records repeated measurements; it is not evidence of an intentional memory cost or proof of a particular cause for the older ratio. + +Only this CPU/benchmark memory baseline changes. The 35% memory tolerance, 0.05x absolute floor, 1.386x timing baseline and 20% timing tolerance remain unchanged. + +## Validation + +Replay using `perf-gate.py:verdict` and the resolved `perf_baseline.load()` rows: all 26 metrics in each of the three reports pass. Each string-building memory median fails with the old 0.175x baseline and passes with 0.267x. No benchmark was rerun. + +Reproduce the overlay with the three downloaded `perf/perf-results.json` reports: + +```sh +python3 vm/selfhost/calibrate-perf-baseline.py --pr 6019 --only stringBuilding --metric memory \ + --reason "Repeated same-CPU measurements; see this evidence file" \ + earlier.json reported-failure.json independent-control.json +python3 vm/selfhost/perf_baseline.py check --pr 6019 +``` diff --git a/vm/selfhost/perf-baseline/pr/6019.json b/vm/selfhost/perf-baseline/pr/6019.json new file mode 100644 index 00000000000..e77b05e8889 --- /dev/null +++ b/vm/selfhost/perf-baseline/pr/6019.json @@ -0,0 +1,27 @@ +{ + "pr": 6019, + "rebaseline": { + "windows-x64@amd64-family-26-model-2-authenticamd": { + "stringBuilding": { + "all": { + "from": { + "memory": 0.175, + "time": 1.386, + "tolerance": { + "memory": 0.35, + "time": 0.2 + } + }, + "memory": 0.267, + "reason": "Recalibrate Windows x64 AMD Family 26 Model 2 stringBuilding RAM from three existing five-round runs: 38681883624 at ba1172ca65 (0.266562x), 38689265151 at 8f3119a162 (0.266736x), and independent PR 5976 run 38688282220 at 0890827888 (0.268x). Median of run medians is 0.267x, matching the separately documented PR 5976 calibration. Native peaks are 79.3-80.7 MB across these runs; the JDK reference peaks are 247.2-300.0 MB. The 0.175x row originated from a single run recorded in PR 6010. This title/input PR does not change the string benchmark, allocator or collector. Preserve the 35% RAM tolerance and 1.386x timing baseline; source URLs, artifact hashes and gate replay are documented in evidence/pr-6019-windows-x64.md.", + "runs": 3, + "time": 1.386, + "tolerance": { + "memory": 0.35, + "time": 0.2 + } + } + } + } + } +} diff --git a/vm/tests/src/test/java/com/codename1/tools/translator/BackendRuntimeResourcesTest.java b/vm/tests/src/test/java/com/codename1/tools/translator/BackendRuntimeResourcesTest.java new file mode 100644 index 00000000000..d38507afdec --- /dev/null +++ b/vm/tests/src/test/java/com/codename1/tools/translator/BackendRuntimeResourcesTest.java @@ -0,0 +1,92 @@ +/* + * 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 Codename One in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.tools.translator; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.FileTime; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class BackendRuntimeResourcesTest { + @Test + void translationDoesNotRewriteSharedRuntimeResources() throws Exception { + BackendTestSupport.require(!CompilerHelper.isWindows(), "backend build.sh requires POSIX"); + Path jdk8 = BackendTestSupport.findJdk8(); + BackendTestSupport.require(jdk8 != null, "no JDK 8 available"); + Path backend = Paths.get("..", "backend").toAbsolutePath().normalize(); + Path translator = Paths.get("..", "ByteCodeTranslator", "target", "classes"); + String[] names = {"cn1_globals.m", "cn1_virtual_thread.h"}; + FileTime[] modified = new FileTime[names.length]; + byte[][] contents = new byte[names.length][]; + for (int i = 0; i < names.length; i++) { + Path resource = translator.resolve(names[i]); + modified[i] = Files.getLastModifiedTime(resource); + contents[i] = Files.readAllBytes(resource); + } + Path work = Files.createTempDirectory("cn1-backend-runtime-resources-"); + Path demo = Files.createDirectories(work.resolve("demo")); + Files.write(demo.resolve("RuntimeResourceProbe.java"), + ("package com.demo; public class RuntimeResourceProbe { " + + "public static void main(String[] args) { System.out.println(42); } }") + .getBytes(StandardCharsets.UTF_8)); + Path output = work.resolve("translated"); + Path log = work.resolve("build.log"); + ProcessBuilder build = new ProcessBuilder("bash", "build.sh", "RuntimeResourceProbe", + "com.demo", work.resolve("unused-binary").toString()); + build.directory(backend.toFile()); + build.environment().put("JDK_8_HOME", jdk8.toString()); + build.environment().put("JAVA_HOME", jdk8.toString()); + build.environment().put("CN1_BACKEND_DEMO", demo.toString()); + build.environment().put("CN1_BACKEND_STANDALONE_DEMO", "1"); + build.environment().put("CN1_BACKEND_SRC_OUT", output.toString()); + build.environment().put("CN1_BACKEND_HTTPS", "1"); + build.environment().put("CN1_BACKEND_SQLITE", "0"); + build.redirectErrorStream(true); + build.redirectOutput(log.toFile()); + Process process = build.start(); + try { + assertTrue(process.waitFor(5, TimeUnit.MINUTES), "backend translation timed out: " + log); + assertEquals(0, process.exitValue(), new String(Files.readAllBytes(log), StandardCharsets.UTF_8)); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + for (int i = 0; i < names.length; i++) { + Path resource = translator.resolve(names[i]); + assertArrayEquals(contents[i], Files.readAllBytes(resource), names[i]); + // Checking only bytes misses cp's truncate-and-rewrite window: other + // translator processes can observe an empty file during that window. + assertEquals(modified[i], Files.getLastModifiedTime(resource), + "backend builds must not rewrite the shared classpath resource " + names[i]); + } + assertArrayEquals(Files.readAllBytes(Paths.get("..", "ByteCodeTranslator", "src", "cn1_virtual_thread.h")), + Files.readAllBytes(output.resolve("cn1_virtual_thread.h")), + "the translated project must receive the current runtime header"); + } +}