diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 020e8be7b10..84a6b1e92a4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -35,6 +35,9 @@ on: - 'scripts/check-native-warnings.py' - 'scripts/check-native-warnings.sh' - 'scripts/native-warnings/**' + # Same reason: the C++ linkage gate runs from this workflow and nowhere else, so + # ignoring scripts/** would let a change that weakens it merge unexercised. + - 'scripts/check-native-cpp-linkage.py' # The build hint gates are run from this workflow and nowhere else, and one # of them holds an empty baseline. Ignoring the whole directory meant a # change that breaks a gate, or that adds a line to the baseline, could @@ -94,6 +97,9 @@ on: - 'scripts/check-native-warnings.py' - 'scripts/check-native-warnings.sh' - 'scripts/native-warnings/**' + # Same reason: the C++ linkage gate runs from this workflow and nowhere else, so + # ignoring scripts/** would let a change that weakens it merge unexercised. + - 'scripts/check-native-cpp-linkage.py' # The build hint gates are run from this workflow and nowhere else, and one # of them holds an empty baseline. Ignoring the whole directory meant a # change that breaks a gate, or that adds a line to the baseline, could @@ -566,6 +572,15 @@ jobs: mvn -B -q -f maven/pom.xml -pl windows,linux -am -DskipTests \ -Dcn1.binaries="${CN1_BINARIES}" compile scripts/check-native-signatures.sh --require-all + # Linkage, which the signature gate above cannot see: it verifies that a + # native's NAME matches its Java method, and a mangled C++ symbol has the + # right name too. Needs no compiler and no build output, so it sits on its + # own step rather than behind the compile above. + - name: Check native C++ linkage + if: ${{ matrix.java-version == 8 }} + run: | + python3 scripts/check-native-cpp-linkage.py --self-test + python3 scripts/check-native-cpp-linkage.py # The native warning gate itself runs on the macOS/Windows/Linux legs that # actually compile C; what runs HERE is its parser, against a hand-authored # fixture, plus a structural check on the checked-in baselines. Both need no diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index f88e828f8ea..479d67ef522 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -317,7 +317,14 @@ jobs: uses: actions/upload-artifact@v7 with: name: android-instrumentation-logs - path: artifacts/connectedAndroidTest*.log + # The comparison also writes a PNG for every screenshot that did not match a + # stored golden, including one that has no golden at all. Uploading only the log + # left those on the runner, so there was no way to seed an Android golden from + # CI -- a new suite test could be added, captured and reported as missing here + # forever, and the only route to a golden was an emulator on somebody's desk. + path: | + artifacts/connectedAndroidTest*.log + artifacts/*.png if-no-files-found: warn retention-days: 14 compression-level: 6 @@ -326,7 +333,10 @@ jobs: uses: actions/upload-artifact@v7 with: name: android-instrumentation-logs-${{ matrix.id }} - path: artifacts/connectedAndroidTest*.log + # Same reason as the default leg above. + path: | + artifacts/connectedAndroidTest*.log + artifacts/*.png if-no-files-found: warn retention-days: 14 compression-level: 6 diff --git a/CLAUDE.md b/CLAUDE.md index 0d1fb40312f..87fd1b4b181 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -482,6 +482,33 @@ calls -- is a warning: it is dead code, not a broken build. Note the offline gat reads `target/classes`, so **a stale port build reports natives that no longer exist**; rebuild the module before believing a finding. +#### The right name is not enough in C++ + +A native defined in a `.cpp` or `.mm` file gets its name **mangled** unless it is +declared `extern "C"`. The file compiles, the symbol it exports is not the one the +generated code calls, and the link fails on the device in a file nobody touched -- +naming a symbol that is visibly right there in the source. + +`NativeSignatureVerifier` cannot see it. It checks that a native's *name* matches +its Java method, and a mangled function has the right name too; linkage is not part +of a name. #5845 shipped exactly this in `cn1_windows_window.cpp` and only a real +Windows build caught it. + +`scripts/check-native-cpp-linkage.py` closes that, over every tracked `.cpp`, `.cc`, +`.cxx` and `.mm`. It needs no compiler and no build output. Like the control-character +gate it has **no baseline and no exclusions**: a native without C linkage is never +intentional, and the fix is always one line. The script is re-included in `pr.yml`'s +`paths` (both triggers) so a change that weakens the gate cannot merge unexercised. + +```bash +scripts/check-native-cpp-linkage.py # every tracked C++ translation unit +scripts/check-native-cpp-linkage.py PATH ... # just these +``` + +Note what it deliberately does not report: a port-internal C++ helper, whose mangling +is correct, and a prototype, which needs no linkage of its own -- only a *definition* +whose symbol the generated code will call. + #### A framework an app gets by accident is a framework it can lose The iOS port referenced the `UTType` class while `ByteCodeTranslator` linked diff --git a/CodenameOne/src/com/codename1/components/FloatingActionButton.java b/CodenameOne/src/com/codename1/components/FloatingActionButton.java index c4c6a432cb5..8b3cf71e1d0 100644 --- a/CodenameOne/src/com/codename1/components/FloatingActionButton.java +++ b/CodenameOne/src/com/codename1/components/FloatingActionButton.java @@ -461,6 +461,11 @@ public void released(int x, int y) { } final Container con = createPopupContent(subMenu); Dialog d = new Dialog(); + // Framework chrome, never an operating system window: this popup is POSITIONED by the + // framework, and native window mode documents those margins as ignored, so in a window + // it comes out centred and loses the placement that is its whole point. See + // TooltipManager for the full note. + d.setNativeWindowMode(false); d.setDialogUIID("Container"); d.getContentPane().setUIID("Container"); d.setLayout(new BorderLayout()); diff --git a/CodenameOne/src/com/codename1/components/GroupBox.java b/CodenameOne/src/com/codename1/components/GroupBox.java new file mode 100644 index 00000000000..d69016285b4 --- /dev/null +++ b/CodenameOne/src/com/codename1/components/GroupBox.java @@ -0,0 +1,175 @@ +/* + * 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.components; + +import com.codename1.ui.Container; +import com.codename1.ui.Label; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.Layout; + +/// A titled frame around a group of related controls. +/// +/// The desktop equivalent of a section header, and the one piece of grouping chrome all +/// three desktop toolkits agree on: `NSBox` with a title, `GtkFrame` with a label widget, +/// and WinUI's headered content. On a phone the same grouping is expressed by a gap and a +/// heading, which is why Codename One never had this -- and why a desktop form built out of +/// plain containers reads as one undifferentiated column of controls. +/// +/// Two UIIDs: `GroupBox` styles the frame (its border is the box) and `GroupBoxTitle` +/// styles the caption. A theme that wants the caption to sit *in* the top edge rather than +/// above it does that with a negative top margin on `GroupBoxTitle`; nothing here hard-codes +/// a position, because the three platforms disagree about it. +/// +/// ```java +/// GroupBox appearance = new GroupBox("Appearance"); +/// appearance.add(new CheckBox("Use the system accent colour")) +/// .add(new CheckBox("Reduce transparency")); +/// ``` +/// +/// The content is an ordinary `Container`, so `add`, `remove` and the layout all behave as +/// they would anywhere else -- `#getContentPane()` is there for the rare caller that wants +/// the inner container itself. +public class GroupBox extends Container { + private final Label title = new Label("", "GroupBoxTitle"); + private final Container content; + + /// An untitled group whose content stacks vertically. + public GroupBox() { + this("", com.codename1.ui.layouts.BoxLayout.y()); + } + + /// A titled group whose content stacks vertically. + /// + /// #### Parameters + /// + /// - `titleText`: the caption + public GroupBox(String titleText) { + this(titleText, com.codename1.ui.layouts.BoxLayout.y()); + } + + /// A titled group with a layout of its own. + /// + /// #### Parameters + /// + /// - `titleText`: the caption + /// + /// - `contentLayout`: the layout for the grouped controls + public GroupBox(String titleText, Layout contentLayout) { + super(new BorderLayout()); + setUIID("GroupBox"); + content = new Container(contentLayout); + content.setUIID("Container"); + title.setText(titleText); + // An empty caption must not reserve a strip: an untitled group is a plain box, and a + // blank label with the title style's padding would leave a gap nothing explains. + title.setHidden(titleText == null || titleText.length() == 0); + // super, explicitly: the overrides below route an ordinary add into the content pane, + // which is what every caller means -- and would put the caption and the content pane + // itself inside the content pane if these two went through them. + super.addComponent(BorderLayout.NORTH, title); + super.addComponent(BorderLayout.CENTER, content); + } + + /// The caption. + /// + /// #### Returns + /// + /// the title text, never null + public String getTitle() { + return title.getText(); + } + + /// Sets the caption. Setting it empty removes the caption strip entirely. + /// + /// #### Parameters + /// + /// - `titleText`: the caption + public void setTitle(String titleText) { + title.setText(titleText == null ? "" : titleText); + title.setHidden(title.getText().length() == 0); + } + + /// The caption component, for a caller that needs to style or replace its icon. + /// + /// #### Returns + /// + /// the title label + public Label getTitleComponent() { + return title; + } + + /// The container the grouped controls live in. + /// + /// #### Returns + /// + /// the content container + public Container getContentPane() { + return content; + } + + /// @inheritDoc + /// + /// Routed into the content pane, so an ordinary add means "add to the group" rather than + /// "add beside the caption". `Container#add(Component)` is final and delegates here, so + /// overriding this covers the chaining form too. + @Override + public void addComponent(com.codename1.ui.Component cmp) { + if (content == null) { + // During the super constructor, before the content pane exists. + super.addComponent(cmp); + return; + } + content.addComponent(cmp); + } + + /// @inheritDoc + /// + /// The constraint belongs to the CONTENT layout, not to the BorderLayout that positions + /// the caption. That layout is an implementation detail, and a caller passing + /// `BorderLayout.SOUTH` means "below the other controls in this group", never "outside the + /// box, under the frame". + @Override + public void addComponent(Object constraints, com.codename1.ui.Component cmp) { + if (content == null) { + super.addComponent(constraints, cmp); + return; + } + content.addComponent(constraints, cmp); + } + + /// @inheritDoc + @Override + public void removeComponent(com.codename1.ui.Component cmp) { + if (cmp == title || cmp == content) { //NOPMD CompareObjectsWithEquals + super.removeComponent(cmp); + return; + } + content.removeComponent(cmp); + } + + /// @inheritDoc + @Override + public void removeAll() { + content.removeAll(); + } +} diff --git a/CodenameOne/src/com/codename1/components/Separator.java b/CodenameOne/src/com/codename1/components/Separator.java new file mode 100644 index 00000000000..fb3549ca97a --- /dev/null +++ b/CodenameOne/src/com/codename1/components/Separator.java @@ -0,0 +1,162 @@ +/* + * 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.components; + +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.Graphics; +import com.codename1.ui.geom.Dimension; +import com.codename1.ui.plaf.Style; + +/// A rule that divides one group of controls from the next. +/// +/// Every desktop toolkit draws one -- `NSBox` in separator mode, WinUI's +/// `MenuFlyoutSeparator` and navigation separators, `GtkSeparator` -- and Codename One had +/// no component for it, so applications drew their own out of a `Label` with a bottom +/// border, or a `Container` with a fixed height and a background colour. Those look +/// approximately right on one platform and wrong everywhere else, because the thing that +/// differs between platforms is exactly the part they hard-code: the thickness, the +/// colour, and how much air sits either side of it. +/// +/// All three come from the `Separator` UIID. The rule itself is the style's **border** +/// where there is one and its background colour otherwise, the air either side is the +/// style's margin, and the thickness is the `separatorThicknessMM` theme constant. A +/// theme that says nothing gets a one-pixel line in the foreground colour. +/// +/// ```java +/// Container settings = new Container(BoxLayout.y()); +/// settings.add(new Label("Appearance")) +/// .add(new Separator()) +/// .add(new Label("Privacy")); +/// ``` +/// +/// A separator is not focusable and is skipped by keyboard traversal: it is decoration, +/// and stopping on it with Tab would be a bug on every platform. +public class Separator extends Component { + + /// A rule that runs left to right, dividing the rows of a vertical stack. + public static final int HORIZONTAL = 0; + + /// A rule that runs top to bottom, dividing the columns of a horizontal row. + public static final int VERTICAL = 1; + + private int orientation = HORIZONTAL; + + /// A horizontal separator. + public Separator() { + this(HORIZONTAL); + } + + /// A separator running in the given direction. + /// + /// #### Parameters + /// + /// - `orientation`: `#HORIZONTAL` or `#VERTICAL` + public Separator(int orientation) { + this.orientation = orientation; + setUIID("Separator"); + setFocusable(false); + } + + /// The direction this rule runs in. + /// + /// #### Returns + /// + /// `#HORIZONTAL` or `#VERTICAL` + public int getOrientation() { + return orientation; + } + + /// Sets the direction this rule runs in. + /// + /// #### Parameters + /// + /// - `orientation`: `#HORIZONTAL` or `#VERTICAL` + public void setOrientation(int orientation) { + if (this.orientation != orientation) { + this.orientation = orientation; + setShouldCalcPreferredSize(true); + } + } + + /// The rule's thickness in pixels, from the `separatorThicknessMM` theme constant. + /// + /// Never zero. A theme is free to ask for a hairline, and a hairline on a high density + /// screen rounds to zero millimetres worth of pixels -- which would make the separator + /// invisible rather than thin, and invisible is the one thing it must not be. + /// + /// #### Returns + /// + /// the thickness in pixels, at least 1 + protected int getThickness() { + // A string, like every other *MM constant here (see Slider's track and thumb): the + // theme format has no float accessor, and parsing a malformed one has to fall back + // rather than throw out of a paint. + String mm = getUIManager().getThemeConstant("separatorThicknessMM", null); + if (mm == null || mm.trim().length() == 0) { + return 1; + } + try { + return Math.max(1, Display.getInstance().convertToPixels(Float.parseFloat(mm.trim()))); + } catch (NumberFormatException notANumber) { + return 1; + } + } + + @Override + protected Dimension calcPreferredSize() { + Style s = getStyle(); + int thickness = getThickness(); + if (orientation == VERTICAL) { + return new Dimension(thickness + s.getHorizontalPadding(), s.getVerticalPadding()); + } + return new Dimension(s.getHorizontalPadding(), thickness + s.getVerticalPadding()); + } + + @Override + public void paint(Graphics g) { + Style s = getStyle(); + if (s.getBorder() != null) { + // A themed border already draws the rule, including the per-side colours a + // platform that wants a two-tone bevel needs. Painting over it would double it. + return; + } + int thickness = getThickness(); + g.setColor(s.getFgColor()); + int alpha = g.concatenateAlpha(s.getFgAlpha()); + if (orientation == VERTICAL) { + g.fillRect(getX() + s.getPaddingLeft(isRTL()) + (getWidth() - s.getHorizontalPadding() + - thickness) / 2, + getY() + s.getPaddingTop(), + thickness, + getHeight() - s.getVerticalPadding()); + } else { + g.fillRect(getX() + s.getPaddingLeft(isRTL()), + getY() + s.getPaddingTop() + (getHeight() - s.getVerticalPadding() + - thickness) / 2, + getWidth() - s.getHorizontalPadding(), + thickness); + } + g.setAlpha(alpha); + } +} diff --git a/CodenameOne/src/com/codename1/components/Stepper.java b/CodenameOne/src/com/codename1/components/Stepper.java new file mode 100644 index 00000000000..5b583885fdd --- /dev/null +++ b/CodenameOne/src/com/codename1/components/Stepper.java @@ -0,0 +1,317 @@ +/* + * 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.components; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.ui.util.EventDispatcher; + +/// A numeric field with increment and decrement controls: `NSStepper`, WinUI's `NumberBox` +/// with its spin buttons, `GtkSpinButton`. +/// +/// Codename One had no equivalent. The nearest thing is `Slider`, which is a different +/// control for a different job -- a slider is for a value whose exact number does not +/// matter, and a stepper is for one where it does. An application that needed a bounded +/// number on the desktop had to build the composite by hand, and every hand-built one got +/// the same two things wrong: it let the field hold text that is not a number, and it let +/// the buttons walk past the bounds. +/// +/// ```java +/// Stepper copies = new Stepper(1, 1, 99); +/// copies.addActionListener(e -> print(copies.getValue())); +/// ``` +/// +/// Three UIIDs: `Stepper` for the composite, `StepperField` for the text field and +/// `StepperButton` for the two buttons. The buttons carry `-` and `+` by default; a theme +/// or an application that wants arrows sets icons on `#getDecrementButton()` and +/// `#getIncrementButton()`. +/// +/// The value is an int. A stepper over a fractional quantity is a real control on some +/// platforms, and it is deliberately not this one: doing it properly means a format, a +/// locale and a parse policy, and guessing those is worse than not offering them. +public class Stepper extends Container { + private final TextField field = new TextField(); + private final Button decrement = new Button("-", "StepperButton"); + private final Button increment = new Button("+", "StepperButton"); + private final EventDispatcher listeners = new EventDispatcher(); + + private int value; + private int minValue; + private int maxValue; + private int step = 1; + + /// A stepper over 0..100 starting at 0. + public Stepper() { + this(0, 0, 100); + } + + /// A stepper over the given range. + /// + /// #### Parameters + /// + /// - `value`: the initial value, clamped into the range + /// + /// - `minValue`: the lowest value the control will produce + /// + /// - `maxValue`: the highest value the control will produce + public Stepper(int value, int minValue, int maxValue) { + super(new BorderLayout()); + setUIID("Stepper"); + if (maxValue < minValue) { + throw new IllegalArgumentException("maxValue " + maxValue + + " is below minValue " + minValue); + } + this.minValue = minValue; + this.maxValue = maxValue; + this.value = clamp(value); + + field.setUIID("StepperField"); + field.setConstraint(TextField.NUMERIC); + field.setText(String.valueOf(this.value)); + field.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + commitTypedText(); + } + }); + field.addDataChangedListener(new com.codename1.ui.events.DataChangedListener() { + @Override + public void dataChanged(int type, int index) { + commitTypedText(); + } + }); + + decrement.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + setValue(Stepper.this.value - step); + } + }); + increment.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + setValue(Stepper.this.value + step); + } + }); + + Container buttons = new Container(BoxLayout.x()); + buttons.setUIID("Container"); + buttons.add(decrement).add(increment); + add(BorderLayout.CENTER, field); + add(BorderLayout.EAST, buttons); + updateButtonState(); + } + + /// The current value. + /// + /// #### Returns + /// + /// the value, always within `#getMinValue()`..`#getMaxValue()` + public int getValue() { + return value; + } + + /// Sets the value, clamped into the range. Fires the action listeners only when the + /// value actually moved, so a caller that sets the same value twice does not report a + /// change that did not happen. + /// + /// #### Parameters + /// + /// - `newValue`: the requested value + public void setValue(int newValue) { + int clamped = clamp(newValue); + if (clamped == value) { + // Still refresh the text: the user may have typed something out of range, in + // which case the field and the value disagree and the field is the wrong one. + syncField(); + updateButtonState(); + return; + } + value = clamped; + syncField(); + updateButtonState(); + listeners.fireActionEvent(new ActionEvent(this)); + } + + /// The lowest value this control will produce. + /// + /// #### Returns + /// + /// the minimum + public int getMinValue() { + return minValue; + } + + /// The highest value this control will produce. + /// + /// #### Returns + /// + /// the maximum + public int getMaxValue() { + return maxValue; + } + + /// Sets the range. The current value is clamped into the new range, which fires the + /// listeners when that moves it. + /// + /// #### Parameters + /// + /// - `minValue`: the lowest value + /// + /// - `maxValue`: the highest value + public void setRange(int minValue, int maxValue) { + if (maxValue < minValue) { + throw new IllegalArgumentException("maxValue " + maxValue + + " is below minValue " + minValue); + } + this.minValue = minValue; + this.maxValue = maxValue; + setValue(value); + } + + /// How far one press of a button moves the value. + /// + /// #### Returns + /// + /// the step, at least 1 + public int getStep() { + return step; + } + + /// Sets how far one press of a button moves the value. + /// + /// #### Parameters + /// + /// - `step`: the step, which must be positive + public void setStep(int step) { + if (step < 1) { + throw new IllegalArgumentException("step must be positive: " + step); + } + this.step = step; + } + + /// The decrement button, for a caller that wants to give it an icon. + /// + /// #### Returns + /// + /// the decrement button + public Button getDecrementButton() { + return decrement; + } + + /// The increment button, for a caller that wants to give it an icon. + /// + /// #### Returns + /// + /// the increment button + public Button getIncrementButton() { + return increment; + } + + /// The editable field, for a caller that wants to make it read-only. + /// + /// #### Returns + /// + /// the text field + public TextField getField() { + return field; + } + + /// Notified when the value changes, however it changed. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void addActionListener(ActionListener l) { + listeners.addListener(l); + } + + /// Stops notifying a listener. + /// + /// #### Parameters + /// + /// - `l`: the listener + public void removeActionListener(ActionListener l) { + listeners.removeListener(l); + } + + /// Takes whatever the user typed and turns it into a value. + /// + /// An empty field is left alone rather than treated as zero: a user clearing the field + /// to retype it would otherwise watch it fill itself in under the caret. Anything that + /// is not a number is ignored the same way -- the field is corrected when the value is + /// next set, which is what leaving the field does. + private void commitTypedText() { + String text = field.getText(); + if (text == null || text.length() == 0) { + return; + } + int typed; + try { + typed = Integer.parseInt(text.trim()); + } catch (NumberFormatException err) { + return; + } + if (typed == value) { + return; + } + int clamped = clamp(typed); + value = clamped; + updateButtonState(); + if (clamped != typed) { + // Out of range. Correct the field now rather than at focus loss, because the + // number under the caret is not one this control can produce. + syncField(); + } + listeners.fireActionEvent(new ActionEvent(this)); + } + + private void syncField() { + String text = String.valueOf(value); + if (!text.equals(field.getText())) { + field.setText(text); + } + } + + /// Disables the button that cannot move: a desktop stepper at its bound greys out the + /// half that would step past it rather than accepting a press that does nothing. + private void updateButtonState() { + decrement.setEnabled(value > minValue); + increment.setEnabled(value < maxValue); + } + + private int clamp(int v) { + if (v < minValue) { + return minValue; + } + if (v > maxValue) { + return maxValue; + } + return v; + } +} diff --git a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java index 412ed92cf35..c495d85f811 100644 --- a/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java +++ b/CodenameOne/src/com/codename1/impl/CodenameOneImplementation.java @@ -4992,6 +4992,31 @@ public void showNativeScreen(Object nativeFullScreenPeer) { public void setNativeCommands(Vector commands) { } + /// Whether this platform actually has a native menu system for + /// `#setNativeCommands(Vector)` to put commands on. + /// + /// False here, and the default matters: `setNativeCommands` is a no-op on a platform + /// that has no menu bar, and `MenuBar.updateCommands` calls it and RETURNS -- it draws + /// no soft buttons, because on a platform with a real menu bar drawing them too would + /// duplicate every command. So on a platform without one, asking for + /// `Display#COMMAND_BEHAVIOR_NATIVE` did not fall back to anything: the commands went to + /// a method that discards them and were never drawn at all. Silently, since nothing in + /// that path can tell "handled natively" from "dropped". + /// + /// That was latent until a theme asked for it. The desktop native themes declare + /// `commandBehavior: Native`, which is right for the platforms they model and which two + /// of the ports that will install them -- Windows and Linux -- cannot honour yet. + /// + /// A port overrides this to true when it really puts the commands somewhere the user can + /// reach them. Everything else keeps whatever Codename One draws itself. + /// + /// #### Returns + /// + /// true if this platform has a native menu bar + public boolean isNativeCommandsSupported() { + return false; + } + /// Returns the desktop title-bar mode for this platform: one of {@code "native"} (OS title /// bar + native menu bar), {@code "custom"} (undecorated window where the CN1 Toolbar acts as /// the title bar) or {@code "toolbar"} (legacy in-app CN1 Toolbar). Returns {@code "toolbar"} @@ -5006,6 +5031,24 @@ public String getDesktopTitleBarMode() { return "toolbar"; } + /// The desktop title-bar mode this platform was explicitly asked for, or null when nobody + /// asked. Distinct from {@link #getDesktopTitleBarMode()}, which is documented to answer a + /// usable mode and therefore cannot express "unset" - it answers {@code "toolbar"} both for + /// a port with no opinion and for a project that deliberately chose the legacy look. + /// + /// That distinction is the whole point: it is what lets a native theme carry a + /// {@code desktopTitleBarMode} constant (Windows and macOS keep a system title bar and want + /// {@code native}; GNOME's HeaderBar IS the title bar and wants {@code custom}) without + /// overriding a project that set the build hint by hand. A build hint answers here; a theme + /// constant only gets consulted when this returns null. + /// + /// #### Returns + /// + /// the explicitly configured mode, or null when nothing configured one + public String getConfiguredDesktopTitleBarMode() { + return null; + } + /// Minimizes the native desktop window when the application draws its own (custom mode) /// window chrome on an undecorated window. No-op on platforms without a native window. public void minimizeNativeWindow() { @@ -10782,6 +10825,23 @@ public void setCommandBehavior(int commandBehavior) { commandBehavior = Display.COMMAND_BEHAVIOR_SOFTKEY; } } + if (commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE && !isNativeCommandsSupported()) { + // Normalised here for the same reason BUTTON_BAR is normalised above: this is + // where a behaviour the platform cannot honour gets turned into one it can, and + // doing it here fixes every reader at once rather than each in turn. + // + // NATIVE is the one behaviour whose unsupported case is silent. MenuBar. + // updateCommands hands the commands to setNativeCommands and returns without + // drawing soft buttons -- correct where there is a real menu bar, since drawing + // them too would duplicate every command, and on a platform without one it means + // the commands go to a method that discards them and are never drawn at all. + // Nothing downstream can tell that from "the platform handled it". + // + // Latent until a theme asked for it, which the desktop native themes now do: + // they declare commandBehavior: Native, which is right for the platforms they + // model and which the Windows and Linux ports cannot honour yet. + commandBehavior = Display.COMMAND_BEHAVIOR_DEFAULT; + } this.commandBehavior = commandBehavior; notifyCommandBehavior(commandBehavior); } diff --git a/CodenameOne/src/com/codename1/ui/Component.java b/CodenameOne/src/com/codename1/ui/Component.java index e07c16af9b9..a2393c11491 100644 --- a/CodenameOne/src/com/codename1/ui/Component.java +++ b/CodenameOne/src/com/codename1/ui/Component.java @@ -7202,11 +7202,86 @@ boolean fireContextMenu(int x, int y) { return true; } } + // A listener that did not consume the event has not handled it, so the component's + // own commands still get to answer. Resolved in the same walk rather than in a + // second one, so the nearest ancestor with EITHER wins -- a row inside a table that + // has its own commands must not be overruled by the table's listener declining. + if (c.contextMenuCommands != null && c.contextMenuCommands.length > 0) { + ContextMenu.show(c, x, y, c.contextMenuCommands); + return true; + } c = c.getParent(); } return false; } + /// Which component's commands a context-menu request at this point would open, or null + /// when it would open nothing. + /// + /// The same walk `#fireContextMenu(int, int)` performs, minus the showing. Split out + /// because the showing is a modal popup that parks the caller until the user dismisses + /// it: the routing is the part worth asserting, and asserting it through the showing + /// means a test with no user to dismiss the menu simply hangs. + /// + /// #### Returns + /// + /// the component whose commands would open, or null + Component resolveContextMenuOwner() { + Component c = this; + while (c != null) { + if (c.contextMenuCommands != null && c.contextMenuCommands.length > 0) { + return c; + } + c = c.getParent(); + } + return null; + } + + /// The commands a right click on this component offers. + /// + /// #### Returns + /// + /// the commands, or null when this component has no menu of its own + public Command[] getContextMenuCommands() { + if (contextMenuCommands == null) { + return null; + } + Command[] copy = new Command[contextMenuCommands.length]; + System.arraycopy(contextMenuCommands, 0, copy, 0, contextMenuCommands.length); + return copy; + } + + /// Gives this component a right-click menu. + /// + /// The menu opens by itself on a secondary mouse button, a stylus barrel button or a + /// long press, so nothing else is needed: + /// + /// ```java + /// label.setContextMenuCommands(cut, copy, paste); + /// ``` + /// + /// The commands are fixed. When they depend on what was clicked -- which row, which + /// cell -- register a `#addContextMenuListener(ActionListener)` instead and call + /// `ContextMenu#show(Component, int, int, Command...)` from it. + /// + /// Passing null or an empty array removes the menu. It does not leave an empty one + /// behind: a menu with no items is a rectangle the user has to dismiss to learn it was + /// empty. + /// + /// #### Parameters + /// + /// - `commands`: the menu items in order, or null for none + public void setContextMenuCommands(Command... commands) { + if (commands == null || commands.length == 0) { + contextMenuCommands = null; + return; + } + contextMenuCommands = new Command[commands.length]; + System.arraycopy(commands, 0, contextMenuCommands, 0, commands.length); + } + + private Command[] contextMenuCommands; + /// Dispatches a mouse wheel event to the registered listeners walking up the component /// hierarchy until a listener consumes the event. Returns true if a listener consumed it, /// in which case the default scrolling behavior should be skipped. diff --git a/CodenameOne/src/com/codename1/ui/ContextMenu.java b/CodenameOne/src/com/codename1/ui/ContextMenu.java new file mode 100644 index 00000000000..7cd0a45a07d --- /dev/null +++ b/CodenameOne/src/com/codename1/ui/ContextMenu.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by 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.ui; + +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; + +/// The right-click menu. +/// +/// Codename One has carried the *event* for a long time -- +/// `Component#addContextMenuListener(ActionListener)` fires on a secondary mouse button, a +/// stylus barrel button or a long press -- and has never had anything that turns it into a +/// menu. Every application that wanted one built its own popup, which is why none of them +/// looked like the platform. +/// +/// Two ways in. Give a component its commands and the menu appears by itself: +/// +/// ```java +/// label.setContextMenuCommands(cut, copy, paste); +/// ``` +/// +/// Or open it from a listener, when the commands depend on what was clicked: +/// +/// ```java +/// table.addContextMenuListener(e -> { +/// ContextMenu.show(table, e.getX(), e.getY(), commandsFor(rowAt(e.getY()))); +/// e.consume(); +/// }); +/// ``` +/// +/// The menu is an anchored popup, which means it is drawn inside the application's surface +/// rather than in a window of its own -- deliberately, and for the same reasons +/// `Dialog#showPopupDialog(Rectangle)` gives: the rectangle it points at is in its host's +/// coordinate space, a separate window would never receive the click meant to dismiss it, +/// and it would steal focus from its opener every time it appeared. It is styled through +/// `PopupContentPane` and `Command`, which the desktop native themes define. +/// +/// A null or empty command array opens nothing. That is not a special case to work around: +/// a menu with no items is a rectangle the user has to dismiss to learn it was empty. +public final class ContextMenu { + + private ContextMenu() { + } + + /// Opens the menu at a point, in the coordinate space of the anchor's top level. + /// + /// #### Parameters + /// + /// - `anchor`: the component the menu belongs to; its top level hosts the popup + /// + /// - `x`: the pointer x coordinate + /// + /// - `y`: the pointer y coordinate + /// + /// - `commands`: the menu items, in order + /// + /// #### Returns + /// + /// the command the user chose, or null if the menu was dismissed or never opened + public static Command show(Component anchor, int x, int y, Command... commands) { + if (anchor == null || commands == null || commands.length == 0) { + return null; + } + Command[] chosen = new Command[1]; + Dialog menu = build(commands, chosen); + TopLevelContainer host = anchor.getTopLevelContainer(); + if (host != null) { + menu.setTopLevelHost(host); + } + // A one-pixel rectangle at the pointer: the popup points at where the user clicked, + // not at the middle of whatever they clicked on. Anchoring to the component would put + // a menu for a full-width row in the centre of the screen. + menu.showPopupDialog(new Rectangle(x, y, 1, 1)); + return chosen[0]; + } + + /// Opens the menu over a component rather than at a point, for a caller that has no + /// pointer position -- a keyboard menu key, or a disclosure button that opens the same + /// menu a right click would. + /// + /// #### Parameters + /// + /// - `anchor`: the component to point at + /// + /// - `commands`: the menu items, in order + /// + /// #### Returns + /// + /// the command the user chose, or null + public static Command show(Component anchor, Command... commands) { + if (anchor == null || commands == null || commands.length == 0) { + return null; + } + Command[] chosen = new Command[1]; + build(commands, chosen).showPopupDialog(anchor); + return chosen[0]; + } + + /// Builds the popup: one left-aligned button per command, stacked, in a dialog with no + /// title and no chrome of its own. + /// + /// The buttons carry the command's NAME rather than the command itself. A + /// `Button(Command)` fires the command from inside its own action event, which would run + /// it while the menu is still showing -- so a command that opens a form or another dialog + /// would put it underneath a menu still holding the popup layer, and the menu would + /// outlive the screen it belonged to. Here the menu is disposed first and the command + /// dispatched after, which is the order `Dialog` uses for its own command buttons. + /// + /// #### Parameters + /// + /// - `commands`: the menu items + /// + /// - `chosen`: a one-element box the pressed command is written into + /// + /// #### Returns + /// + /// the popup, not yet shown + private static Dialog build(Command[] commands, final Command[] chosen) { + final Dialog menu = new Dialog(); + menu.setDisposeWhenPointerOutOfBounds(true); + menu.setLayout(new BorderLayout()); + menu.setAutoDispose(true); + // A context menu never becomes an operating system window even where the theme asks + // for one by default -- see the class note. setNativeWindowMode is per instance and + // outranks both the static default and the theme constant. + menu.setNativeWindowMode(false); + + Container items = new Container(BoxLayout.y()); + items.setUIID("CommandList"); + items.setScrollableY(true); + // `final` on the loop variable, not merely convention: the core compiles at Java 5 + // source level, where a foreach variable is NOT implicitly final and so cannot be + // captured by the listener below without it. + for (final Command cmd : commands) { + if (cmd == null) { + continue; + } + Button b = new Button(cmd.getCommandName(), cmd.getIcon()); + b.setUIID("Command"); + b.setEnabled(cmd.isEnabled()); + b.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + chosen[0] = cmd; + menu.dispose(); + cmd.actionPerformed(new ActionEvent(cmd, ActionEvent.Type.Command)); + } + }); + items.add(b); + } + menu.add(BorderLayout.CENTER, items); + return menu; + } +} diff --git a/CodenameOne/src/com/codename1/ui/Dialog.java b/CodenameOne/src/com/codename1/ui/Dialog.java index 86fbf13f67e..3e775f79a71 100644 --- a/CodenameOne/src/com/codename1/ui/Dialog.java +++ b/CodenameOne/src/com/codename1/ui/Dialog.java @@ -2705,6 +2705,16 @@ void nativeCloseRequested(ActionEvent evt) { // Vetoed so the dialog owns the teardown. Letting the window dispose itself // first would leave the dialog believing it was still showing. evt.consume(); + cancel(); + } + + /// Every way a user says "not this one" -- the window's close control, the platform back + /// gesture, and Escape on a desktop keyboard -- means the same thing, so they resolve it in + /// one place: the back command when there is one, and disposal when there isn't. + /// + /// Written three times before this existed, which is how the three drifted apart: add a + /// fourth entry point and the next one is written a fourth time. + void cancel() { Command back = getBackCommand(); if (back != null) { dispatchCommand(back, new ActionEvent(back, ActionEvent.Type.Command)); @@ -2713,6 +2723,17 @@ void nativeCloseRequested(ActionEvent evt) { dispose(); } + /// @inheritDoc + /// + /// Escape closes a dialog, which is what its window's close control already means. An + /// anchored popup is included: it is the one surface where Escape is the ONLY way out that + /// does not also click something underneath. + @Override + boolean escapePressed() { + cancel(); + return true; + } + /// Takes this dialog back out of its window. Idempotent, and marshalled onto the /// event dispatch thread. /// @@ -3164,12 +3185,7 @@ void hostBackPressed(ActionEvent evt) { return; } evt.consume(); - Command back = getBackCommand(); - if (back != null) { - dispatchCommand(back, new ActionEvent(back, ActionEvent.Type.Command)); - return; - } - dispose(); + cancel(); } /// Whether this is the last dialog added to its host's shared layer, which is the diff --git a/CodenameOne/src/com/codename1/ui/Form.java b/CodenameOne/src/com/codename1/ui/Form.java index fce503b772f..8a6d9eee373 100644 --- a/CodenameOne/src/com/codename1/ui/Form.java +++ b/CodenameOne/src/com/codename1/ui/Form.java @@ -899,9 +899,29 @@ public Container getTitleArea() { } /// Returns the configured desktop title-bar mode ({@code native}, {@code custom} or - /// {@code toolbar}). Sourced from the implementation (desktop ports report the real mode; - /// everything else returns {@code toolbar}). + /// {@code toolbar}). + /// + /// Three sources in order. The build hint wins: a project that spelled out + /// {@code desktop.titleBar} means it, and a theme must not talk it out of that. Then the + /// installed theme's {@code desktopTitleBarMode} constant, which is how the desktop native + /// themes express the convention of the platform they model -- Windows and macOS keep a + /// system title bar and ask for {@code native}, GNOME's HeaderBar is the title bar and asks + /// for {@code custom}. Then whatever the port reports, which is {@code toolbar} everywhere + /// that is not a desktop. + /// + /// The theme step is gated on {@link Display#isDesktop()} so a mobile port that somehow + /// loaded a desktop theme still renders its ordinary chrome. String getDesktopTitleBarMode() { + String configured = Display.impl.getConfiguredDesktopTitleBarMode(); + if (configured != null && configured.length() > 0) { + return configured; + } + if (Display.getInstance().isDesktop()) { + String themed = getUIManager().getThemeConstant("desktopTitleBarMode", null); + if (themed != null && themed.length() > 0) { + return themed; + } + } return Display.impl.getDesktopTitleBarMode(); } @@ -919,8 +939,17 @@ boolean isDesktopNativeChrome() { /// Indicates the {@code native} desktop title-bar mode, where the CN1 Toolbar is hidden entirely: /// the form title goes into the real OS window title bar and the commands are bridged to a native /// menu bar. Inert (false) on mobile. + /// + /// Conditional on the platform actually HAVING a native menu bar. Hiding the Toolbar takes + /// away the side menu, which is the only place the commands are drawn, so doing it on a + /// port whose `setNativeCommands` discards them removes every command from the + /// application. The title still goes to the OS title bar on such a port -- that part + /// works everywhere -- and the Toolbar stays, which is the legacy look rather than a + /// broken one. boolean isDesktopHideToolbar() { - return Display.getInstance().isDesktop() && "native".equals(getDesktopTitleBarMode()); + return Display.getInstance().isDesktop() + && "native".equals(getDesktopTitleBarMode()) + && Display.impl.isNativeCommandsSupported(); } /// Indicates the {@code custom} desktop title-bar mode, where the CN1 Toolbar stays visible and @@ -3842,10 +3871,45 @@ static TabIterator buildTabIterator(Container root, Component start) { return new TabIterator(out, start); } + /// The traversal order a desktop keyboard walks with Tab. + /// + /// Deliberately NOT `#buildTabIterator(Container, Component)`, whose filter is opt-in: + /// `Component#getPreferredTabIndex()` defaults to -1 and `TextArea` is the only class in the + /// framework that ever calls `setPreferredTabIndex(0)`, so that iterator holds text areas and + /// nothing else. That is right for what built it -- "next field while editing", which is what + /// `TextEditUtil` and `Picker` use it for -- and wrong for Tab, which would then walk between + /// a form's text fields and skip every button, checkbox and slider between them. + /// + /// A desktop keyboard should reach whatever the pointer reaches, so the filter here is + /// focusability itself. An explicit `preferredTabIndex` still wins: those components sort to + /// the front in the order they were numbered, which is the whole point of setting one. + /// Everything else keeps document order, because `Collections#sort` is stable and + /// `ComponentSelector` walks the tree in the order the form reads. + /// + /// #### Parameters + /// + /// - `root`: the top level to walk + /// + /// - `start`: the component to start from + /// + /// #### Returns + /// + /// the desktop traversal iterator + static TabIterator buildDesktopTabIterator(Container root, Component start) { + root.updateTabIndices(0); + java.util.List out = new ArrayList(); + out.addAll(ComponentSelector.select("*", root).filter(new DesktopTabIteratorFilter())); + Collections.sort(out, new DesktopTabIteratorComparator()); + return new TabIterator(out, start); + } + /// {@inheritDoc} @Override public void keyPressed(int keyCode) { int game = Display.getInstance().getGameAction(keyCode); + if (desktopKeyPressed(keyCode)) { + return; + } if (menuBar.handlesKeycode(keyCode) && !focusedHandlesInput(keyCode)) { menuBar.keyPressed(keyCode); return; @@ -3941,6 +4005,97 @@ private boolean focusedHandlesInput(int keyCode) { && focused.getComponentForm() == this; //NOPMD CompareObjectsWithEquals } + /// Horizontal tab. Ports deliver Tab as its character code, which is what + /// `JavaSEPort.C.getCode` returns for a key event whose `getKeyChar()` is defined -- Tab's + /// is, so it arrives here as 9 rather than as an AWT virtual key. + private static final int KEY_TAB = 9; + + /// Escape, likewise delivered as its character code. + private static final int KEY_ESCAPE = 27; + + /// The two keyboard conventions every desktop toolkit has and Codename One never had. + /// + /// **Tab / Shift-Tab moves focus.** The traversal order itself is not new -- `TabIterator`, + /// `getNextComponent` and `preferredTabIndex` have been here for years -- but nothing was + /// ever wired to the key, so the only consumers were "next field while editing" paths in + /// the ports. On a desktop a form that cannot be operated from the keyboard is not a + /// desktop form. + /// + /// **Escape means cancel.** On a `Dialog` it does what the window's own close control does, + /// which is already written as "the back command, or dispose when there isn't one". On a + /// plain form it fires the back command and does nothing at all when there is none -- Escape + /// must never be able to exit an application. + /// + /// Enter needs nothing here: ports already map it to `GAME_KEY_CODE_FIRE` and + /// `keyReleased` already fires `getDefaultCommand()` on `GAME_FIRE`. + /// + /// The whole method is gated on `Display#isDesktop()`, so no mobile key dispatch and no + /// mobile screenshot baseline moves. Returning true means the key was consumed. + /// + /// #### Parameters + /// + /// - `keyCode`: the code being dispatched + /// + /// #### Returns + /// + /// true when this form handled the key and dispatch should stop + private boolean desktopKeyPressed(int keyCode) { + if (!Display.getInstance().isDesktop()) { + return false; + } + if (keyCode == KEY_TAB) { + return moveFocusByTab(Display.getInstance().isShiftKeyDown()); + } + if (keyCode == KEY_ESCAPE) { + return escapePressed(); + } + return false; + } + + /// Moves focus one step along the tab order, wrapping at either end so the keyboard can + /// never strand itself. Answers false when there is nothing else focusable, leaving the key + /// to ordinary dispatch. + /// + /// #### Parameters + /// + /// - `backwards`: true for Shift-Tab + boolean moveFocusByTab(boolean backwards) { + Component from = focused; + if (from == null || from.getComponentForm() != this) { //NOPMD CompareObjectsWithEquals + initFocused(); + from = focused; + } + TabIterator order = buildDesktopTabIterator(this, from); + Component next = backwards ? order.getPrevious() : order.getNext(); + if (next == null) { + // Ran off the end. Wrap, so the keyboard can never strand itself at the last + // control of a form with no way back except the pointer. + java.util.List all = order.getComponents(); + if (all.isEmpty()) { + return false; + } + next = backwards ? all.get(all.size() - 1) : all.get(0); + } + if (next == from) { //NOPMD CompareObjectsWithEquals + return false; + } + setFocused(next); + next.scrollRectToVisible(0, 0, next.getWidth(), next.getHeight(), next); + return true; + } + + /// Escape on an ordinary form: the back command when there is one, nothing otherwise. + /// `Dialog` overrides this to close itself. + boolean escapePressed() { + Command back = getBackCommand(); + if (back == null) { + return false; + } + back.actionPerformed(new ActionEvent(back, ActionEvent.Type.Command)); + actionCommandImpl(back); + return true; + } + /// Space, the lowest code that stands for a character a text component can receive. private static final int FIRST_PRINTABLE_KEY_CODE = 32; @@ -5594,6 +5749,18 @@ public Component getCurrent() { return current; } + /// The components in traversal order, unmodifiable. + /// + /// Exposed so a caller that ran off either end can wrap round to the other one without + /// rebuilding the order it just walked. + /// + /// #### Returns + /// + /// the traversal order + public java.util.List getComponents() { + return Collections.unmodifiableList(components); + } + /// Sets the current component in the iterator. This reposition the iterator /// to the given component. /// @@ -5797,4 +5964,35 @@ public boolean filter(Component c) { return c.getTabIndex() >= 0 && c.isVisible() && c.isFocusable() && c.isEnabled() && !c.isHidden(true); } } + + /// Everything a pointer could reach, which is what a desktop keyboard must reach too. + /// Note the absence of the `getTabIndex() >= 0` test the mobile filter opens with: that test + /// is what makes the ordinary iterator opt-in, and opting in is exactly what nothing except + /// `TextArea` does. + private static class DesktopTabIteratorFilter implements Filter { + @Override + public boolean filter(Component c) { + return c.isVisible() && c.isFocusable() && c.isEnabled() && !c.isHidden(true); + } + } + + /// Orders the desktop traversal: an explicitly numbered component first, in its number's + /// order, and everything else in document order behind it. + /// + /// Zero counts as unnumbered, not as first. `setPreferredTabIndex(0)` is how + /// `Component#setTraversable(boolean)` says "join the order", never "be the first" -- + /// reading it as a position would put every `TextArea` ahead of the label above it. + private static class DesktopTabIteratorComparator implements Comparator { + @Override + public int compare(Component o1, Component o2) { + int i1 = positionOf(o1); + int i2 = positionOf(o2); + return i1 < i2 ? -1 : i2 < i1 ? 1 : 0; + } + + private int positionOf(Component c) { + int idx = c.getPreferredTabIndex(); + return idx > 0 ? idx : Integer.MAX_VALUE; + } + } } diff --git a/CodenameOne/src/com/codename1/ui/MenuBar.java b/CodenameOne/src/com/codename1/ui/MenuBar.java index b7f0c7ad26b..aea23be510b 100644 --- a/CodenameOne/src/com/codename1/ui/MenuBar.java +++ b/CodenameOne/src/com/codename1/ui/MenuBar.java @@ -489,6 +489,10 @@ public void setSelectCommand(Command selectCommand) { private void updateCommands() { int commandBehavior = getCommandBehavior(); if (commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE) { + // Reachable only where the platform really has a menu bar: + // CodenameOneImplementation.setCommandBehavior normalises NATIVE away on a + // platform that does not, because returning here without one drops every + // command silently. Display.getInstance().getImplementation().setNativeCommands(commands); return; } diff --git a/CodenameOne/src/com/codename1/ui/Toolbar.java b/CodenameOne/src/com/codename1/ui/Toolbar.java index 4cb1985bcd2..1a8e3434690 100644 --- a/CodenameOne/src/com/codename1/ui/Toolbar.java +++ b/CodenameOne/src/com/codename1/ui/Toolbar.java @@ -1785,6 +1785,11 @@ public void actionPerformed(ActionEvent evt) { if (isLeft) { sidemenuDialog = new InteractionDialog(new BorderLayout()); + // Framework chrome, never an operating system window: this popup is POSITIONED by the + // framework, and native window mode documents those margins as ignored, so in a window + // it comes out centred and loses the placement that is its whole point. See + // TooltipManager for the full note. + sidemenuDialog.setNativeWindowMode(false); sidemenuDialog.setFormMode(true); sidemenuDialog.setUIID("Container"); @@ -1833,6 +1838,11 @@ public void actionPerformed(ActionEvent evt) { } } else { rightSidemenuDialog = new InteractionDialog(new BorderLayout()); + // Framework chrome, never an operating system window: this popup is POSITIONED by the + // framework, and native window mode documents those margins as ignored, so in a window + // it comes out centred and loses the placement that is its whole point. See + // TooltipManager for the full note. + rightSidemenuDialog.setNativeWindowMode(false); rightSidemenuDialog.setFormMode(true); rightSidemenuDialog.setUIID("Container"); diff --git a/CodenameOne/src/com/codename1/ui/TooltipManager.java b/CodenameOne/src/com/codename1/ui/TooltipManager.java index 79ee4e337e5..6862b91723f 100644 --- a/CodenameOne/src/com/codename1/ui/TooltipManager.java +++ b/CodenameOne/src/com/codename1/ui/TooltipManager.java @@ -141,6 +141,13 @@ protected void showTooltip(final String tip, final Component cmp) { return; } currentTooltip = new InteractionDialog(new BorderLayout()); + // Framework chrome, never an operating system window. This popup is POSITIONED by + // the framework -- it is placed relative to the surface it belongs to -- and native + // window mode documents those margins as ignored, so in a window it would come out + // centred and lose the placement that is its whole point. Dialog.usesNativeWindow + // already exempts menus and anchored popups for the same reason; AbstractDialog is a + // closed interface (see the comment at its end), so each of these says so itself. + currentTooltip.setNativeWindowMode(false); // showPopupDialog infers the host from the anchor anyway, but saying it is // free and records which surface this tooltip belongs to. currentTooltip.setTopLevelHost(f); diff --git a/CodenameOne/src/com/codename1/ui/Window.java b/CodenameOne/src/com/codename1/ui/Window.java index 10f3ae55655..8a1bc628d27 100644 --- a/CodenameOne/src/com/codename1/ui/Window.java +++ b/CodenameOne/src/com/codename1/ui/Window.java @@ -1011,6 +1011,78 @@ public Form.TabIterator getTabIterator(Component start) { return Form.buildTabIterator(this, start); } + /// Horizontal tab, as a port delivers it. Same value `Form` uses; see its note. + private static final int KEY_TAB = 9; + + /// Escape, likewise. + private static final int KEY_ESCAPE = 27; + + /// Tab traversal and Escape inside a window, the same two conventions `Form` gained. + /// + /// A window needs its own copy rather than inheriting one because it is not a `Form` -- it + /// extends `Container` -- and because it owns a keyboard scope that a form does not. Both + /// keys are therefore resolved after `focusWithinKeyScope()`, so an overlay that has claimed + /// the keyboard keeps it. + /// + /// Escape closes the window when its close operation allows, which is what the platform's own + /// close control does. A window whose close operation is DO_NOTHING_ON_CLOSE ignores it, for + /// the same reason it ignores the close button. + /// + /// #### Parameters + /// + /// - `keyCode`: the code being dispatched + /// + /// #### Returns + /// + /// true when the window handled the key and dispatch should stop + private boolean desktopKeyPressed(int keyCode) { + if (!Display.getInstance().isDesktop()) { + return false; + } + if (keyCode == KEY_TAB) { + return moveFocusByTab(Display.getInstance().isShiftKeyDown()); + } + if (keyCode == KEY_ESCAPE) { + return escapePressed(); + } + return false; + } + + /// Moves focus one step along this window's desktop traversal order, wrapping at either end. + /// + /// #### Parameters + /// + /// - `backwards`: true for Shift-Tab + boolean moveFocusByTab(boolean backwards) { + Component from = getFocused(); + Form.TabIterator order = Form.buildDesktopTabIterator(this, from); + Component next = backwards ? order.getPrevious() : order.getNext(); + if (next == null) { + java.util.List all = order.getComponents(); + if (all.isEmpty()) { + return false; + } + next = backwards ? all.get(all.size() - 1) : all.get(0); + } + if (next == from) { //NOPMD CompareObjectsWithEquals + return false; + } + setFocused(next); + next.scrollRectToVisible(0, 0, next.getWidth(), next.getHeight(), next); + return true; + } + + /// Escape asks the window to close, which is what its own close control asks. Honours the + /// close operation, so a window that refuses the close button refuses this too. + boolean escapePressed() { + if (getCloseOperation() == DO_NOTHING_ON_CLOSE) { + return false; + } + closeRequested(); + return true; + } + + /// {@inheritDoc} @Override public void scrollComponentToVisible(Component c) { @@ -4504,6 +4576,9 @@ public void keyPressed(int keyCode) { if (!focusWithinKeyScope()) { return; } + if (desktopKeyPressed(keyCode)) { + return; + } int game = Display.getInstance().getGameAction(keyCode); if (focused != null) { // Everything below is asked of the component that took the press, not of diff --git a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java index 5e65f57ab27..c4502148d95 100644 --- a/CodenameOne/src/com/codename1/ui/plaf/UIManager.java +++ b/CodenameOne/src/com/codename1/ui/plaf/UIManager.java @@ -1281,6 +1281,46 @@ private void resetThemeProps(Hashtable installedTheme) { themeProps.put("ScrollThumb.bgColor", foreground); } + // The interactive desktop scrollbar draws through its own four UIIDs + // (LookAndFeel.initScroll picks them when interactiveScrollBool is on) so that turning + // it on never restyles the mobile bar. Nothing seeded them, which meant a theme that + // enabled the constant without also defining all four got a track and a thumb built + // from the blank default style: an invisible scrollbar, drawn, reserving a gutter, with + // nothing reporting a problem. The seeds below are the mobile ones plus the two things + // the desktop bar needs and the mobile one does not -- a gutter wide enough to grab + // (the track UIID's horizontal padding is what reserves it) and thumb hover/pressed + // states, so the highlight exists even before a theme styles it. + // + // Guarded like every other seed here, so a theme that defines these suppresses them + // rather than fighting them. All three desktop native themes do. + if (installedTheme == null || !installedTheme.containsKey("DesktopScroll.derive")) { + themeProps.put("DesktopScroll.margin", "0,0,0,0"); + int gutter = Math.max(2, Display.getInstance().convertToPixels(3, true) / 2); + themeProps.put("DesktopScroll.padding", "0," + gutter + ",0," + gutter); + themeProps.put("DesktopScroll.transparency", "0"); + } + if (installedTheme == null || !installedTheme.containsKey("DesktopScrollThumb.derive")) { + themeProps.put("DesktopScrollThumb.padding", "0,0,0,0"); + themeProps.put("DesktopScrollThumb.margin", "0,0,0,0"); + themeProps.put("DesktopScrollThumb.bgColor", foreground); + themeProps.put("DesktopScrollThumb.sel#derive", "DesktopScrollThumb"); + themeProps.put("DesktopScrollThumb.press#derive", "DesktopScrollThumb"); + } + if (installedTheme == null || !installedTheme.containsKey("DesktopHorizontalScroll.derive")) { + themeProps.put("DesktopHorizontalScroll.margin", "0,0,0,0"); + int gutter = Math.max(2, Display.getInstance().convertToPixels(3, true) / 2); + themeProps.put("DesktopHorizontalScroll.padding", gutter + ",0," + gutter + ",0"); + themeProps.put("DesktopHorizontalScroll.transparency", "0"); + } + if (installedTheme == null + || !installedTheme.containsKey("DesktopHorizontalScrollThumb.derive")) { + themeProps.put("DesktopHorizontalScrollThumb.padding", "0,0,0,0"); + themeProps.put("DesktopHorizontalScrollThumb.margin", "0,0,0,0"); + themeProps.put("DesktopHorizontalScrollThumb.bgColor", foreground); + themeProps.put("DesktopHorizontalScrollThumb.sel#derive", "DesktopHorizontalScrollThumb"); + themeProps.put("DesktopHorizontalScrollThumb.press#derive", "DesktopHorizontalScrollThumb"); + } + if (installedTheme == null || !installedTheme.containsKey("SliderFull.derive")) { themeProps.put("SliderFull.bgColor", foreground); } diff --git a/CodenameOne/src/com/codename1/ui/spinner/Picker.java b/CodenameOne/src/com/codename1/ui/spinner/Picker.java index 2e1935a1f61..23da1ecf560 100644 --- a/CodenameOne/src/com/codename1/ui/spinner/Picker.java +++ b/CodenameOne/src/com/codename1/ui/spinner/Picker.java @@ -350,6 +350,11 @@ && isLightweightModeSupportedForType(type)) { setEnabled(true); } else { Dialog pickerDlg = new Dialog(); + // Framework chrome, never an operating system window: this popup is POSITIONED by the + // framework, and native window mode documents those margins as ignored, so in a window + // it comes out centred and loses the placement that is its whole point. See + // TooltipManager for the full note. + pickerDlg.setNativeWindowMode(false); pickerDlg.setDisposeWhenPointerOutOfBounds(true); pickerDlg.setLayout(new BorderLayout()); Calendar cld = Calendar.getInstance(); @@ -768,6 +773,15 @@ protected void deinitialize() { }; + // Framework chrome, never an operating system window. This one is placed by + // hand -- setX/setY/setWidth/setHeight below, then show(top, bottom, left, + // right) -- and native window mode documents exactly those margins as + // ignored, so in a window the popup comes out centred and every placement + // variant collapses onto the same picture. Measured on the Windows port + // before this line existed: LightweightPickerButtons captured two of its + // four placements, the two it did capture were byte-identical, and the + // suite then timed out waiting for the other two. + dlg.setNativeWindowMode(false); dlg.setOwner(Picker.this); //dlg.setFormMode(!isTablet); ComponentSelector.select("DialogTitle", dlg).getParent().setPadding(0).setMargin(0).setBorder(Border.createEmpty()); diff --git a/CodenameOne/src/com/codename1/ui/validation/Validator.java b/CodenameOne/src/com/codename1/ui/validation/Validator.java index fbaaebd63ab..e8f9ad79cee 100644 --- a/CodenameOne/src/com/codename1/ui/validation/Validator.java +++ b/CodenameOne/src/com/codename1/ui/validation/Validator.java @@ -102,7 +102,19 @@ public class Validator { private static boolean validateOnEveryKey = false; private final HashMap constraintList = new HashMap(); private final ArrayList submitButtons = new ArrayList(); - private InteractionDialog message = new InteractionDialog(); + // Framework chrome, never an operating system window: this bubble is placed against the + // field it belongs to, and native window mode documents those margins as ignored. The + // field initialiser needs it as much as the reassignment below does -- it is the instance + // that is used until a constraint supplies a message of its own, and missing it is what + // FrameworkChromeNeverWindowsTest caught by counting constructions against opt-outs. + private InteractionDialog message = newChromePopup(); + + /// An InteractionDialog that will not become a window whatever the theme default is. + private static InteractionDialog newChromePopup() { + InteractionDialog d = new InteractionDialog(); + d.setNativeWindowMode(false); + return d; + } /// Error message UIID defaults to DialogBody. Allows customizing the look of the message private String errorMessageUIID = "DialogBody"; /// Indicates the mode in which validation failures are expressed @@ -569,6 +581,11 @@ public void focusGained(Component cmp) { String err = getErrorMessage(cmp); if (err != null && err.length() > 0) { message = new InteractionDialog(err); + // Framework chrome, never an operating system window: this popup is POSITIONED by the + // framework, and native window mode documents those margins as ignored, so in a window + // it comes out centred and loses the placement that is its whole point. See + // TooltipManager for the full note. + message.setNativeWindowMode(false); // The emblem path below shows by rectangle, which has // no anchor component to resolve a host from. message.setTopLevelHost(p); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index a60f1c19002..671bc754ebe 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -1149,7 +1149,15 @@ public static void setShowEDTViolationStacks(boolean aShowEDTViolationStacks) { private static Resources nativeThemeRes; // Desktop window-chrome configuration. Defaults preserve the legacy behavior (CN1 Toolbar, // no interactive scrollbars); the generated desktop Stub opts a new app in. - private static String desktopTitleBarMode = "toolbar"; + // + // null means "nobody asked", NOT "toolbar". The two have to be distinguishable because a + // desktop native theme carries its own desktopTitleBarMode constant, and that constant may + // only speak when the project did not. Every reader below coalesces null to "toolbar" for + // its own answer, so nothing outside this class sees the sentinel -- except + // getConfiguredDesktopTitleBarMode, which exists to report it, and + // injectDesktopThemeConstants, which must not inject a mode nobody chose or it would + // overwrite the theme's own constant with this default. + private static String desktopTitleBarMode; private static boolean desktopInteractiveScrollbars = false; // Caches the last command-name signature pushed to the native menu bar to avoid rebuilding // (and flickering the macOS screen menu) when an unchanged form is re-shown. @@ -2729,6 +2737,17 @@ public void run() { } } + /// @inheritDoc + /// + /// True on the desktop, where setNativeCommands below builds a real Swing JMenuBar + /// (which becomes the macOS screen menu). False elsewhere, including the phone-skinned + /// simulator, where there is no menu bar to put anything on and the commands belong in + /// whatever Codename One draws. + @Override + public boolean isNativeCommandsSupported() { + return isDesktop(); + } + @Override public void setNativeCommands(Vector commands) { if (!isDesktopNativeChromeMode()) { @@ -3062,7 +3081,7 @@ public static void setDesktopTitleBarMode(String mode) { /// @return the configured desktop title-bar mode (defaults to {@code "toolbar"}). public static String getDesktopTitleBarModeSetting() { - return desktopTitleBarMode; + return desktopTitleBarMode == null ? "toolbar" : desktopTitleBarMode; } /// The desktop title-bar mode core consults to decide whether to suppress the CN1 Toolbar. @@ -3090,9 +3109,26 @@ public static boolean isDesktopInteractiveScrollbars() { /// Resolves the effective desktop title-bar mode, honoring the /// {@code codename1.arg.desktop.titleBar} system property fallback. private String resolveDesktopTitleBarMode() { + String mode = configuredDesktopTitleBarMode(); + return mode == null ? "toolbar" : mode; + } + + /// The mode the project actually asked for, or null when it asked for nothing. Kept + /// separate from {@link #resolveDesktopTitleBarMode()} so the "unset" case survives all the + /// way to Form, where a desktop native theme's own constant gets to answer instead. + private static String configuredDesktopTitleBarMode() { return System.getProperty("codename1.arg.desktop.titleBar", desktopTitleBarMode); } + /// @inheritDoc + @Override + public String getConfiguredDesktopTitleBarMode() { + if (!isDesktop()) { + return null; + } + return configuredDesktopTitleBarMode(); + } + /// @return true when running on the desktop with a title-bar mode that hides the CN1 /// Toolbar in favor of native chrome (native or custom). boolean isDesktopNativeChromeMode() { @@ -3166,8 +3202,11 @@ private void injectDesktopThemeConstants(Hashtable h) { if (h == null || !isDesktop()) { return; } - String mode = System.getProperty("codename1.arg.desktop.titleBar", desktopTitleBarMode); + String mode = configuredDesktopTitleBarMode(); if (mode != null && mode.length() > 0) { + // Only when the project asked. Injecting the default here would write + // "toolbar" over a desktop native theme's own constant and put the in-app + // Toolbar back on every screen the theme meant to hand to the window. h.put("@desktopTitleBarMode", mode); } boolean interactive = desktopInteractiveScrollbars diff --git a/Ports/LinuxPort/nativeSources/cn1_linux.h b/Ports/LinuxPort/nativeSources/cn1_linux.h index fcb88a5d1f5..dad60492a33 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux.h @@ -95,7 +95,12 @@ typedef enum { * under the cursor and the theme's hover rules are dead entries in the .res. * Deliberately the same number the Windows port uses, so the two desktop wire * protocols do not drift apart. */ - CN1_EVENT_POINTER_HOVER = 22 + CN1_EVENT_POINTER_HOVER = 22, + /* A native menu bar item was chosen. keyCode carries the Codename One command id the + * Java side handed out in setNativeCommands. Queued like every other input so the + * command runs on the EDT rather than on the GTK thread. Same number as the Windows + * port's, so the two desktop wire protocols do not drift apart. */ + CN1_EVENT_MENU_COMMAND = 23 } CN1EventType; /* Fixed-point scale for the gesture keyCode field (see CN1_EVENT_PINCH). */ diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c index a81ac209b50..23f86db08ec 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c @@ -274,6 +274,12 @@ int cn1LinuxPopEvent(int* out) { static GtkWidget* cn1Window = 0; static GtkWidget* cn1DrawingArea = 0; 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 */ +/* Created ONCE and reused. A fresh group per rebuild would leak one per published form and + * leave the window holding every group it had ever been given -- the menu items go away + * with the bar, but the groups themselves do not. */ +static GtkAccelGroup* cn1MenuAccels = 0; static GtkWidget* cn1Fixed = 0; /* GtkFixed overlay hosting positioned native peers */ static GtkWidget* cn1AccessibilityFixed = 0; /* transparent GTK/ATK semantic hierarchy */ static CN1Graphics cn1WindowG; /* the on-screen / headless back buffer */ @@ -1014,7 +1020,14 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in gtk_widget_set_opacity(cn1AccessibilityFixed, 0.01); 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); + /* A vertical box between the window and the overlay, so a menu bar has somewhere to go. + * It is created unconditionally and stays EMPTY until commands arrive: an application + * that publishes none packs nothing above the overlay, and a GtkBox with one child + * that expands is laid out exactly as the overlay was when it was the window's direct + * child. That is what keeps every existing screenshot byte-identical. */ + cn1RootBox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_box_pack_start(GTK_BOX(cn1RootBox), cn1Overlay, TRUE, TRUE, 0); + gtk_container_add(GTK_CONTAINER(cn1Window), cn1RootBox); g_signal_connect(cn1DrawingArea, "draw", G_CALLBACK(cn1OnDraw), 0); g_signal_connect(cn1DrawingArea, "configure-event", G_CALLBACK(cn1OnConfigure), 0); @@ -1035,6 +1048,224 @@ JAVA_VOID com_codename1_impl_linux_LinuxNative_initDisplay___java_lang_String_in cn1WindowOpen = 1; } +/* ------------------------------------------------------------- menu bar */ + +/* + * The native menu bar. + * + * Commands arrive as one encoded row each, the format + * IOSImplementation.setNativeCommands writes for the macOS menu and + * WindowsImplementation writes for the Win32 one: + * + * "\t