diff --git a/CodenameOne/src/com/codename1/ui/TextArea.java b/CodenameOne/src/com/codename1/ui/TextArea.java index cd4b01e22c3..82d1dd0567d 100644 --- a/CodenameOne/src/com/codename1/ui/TextArea.java +++ b/CodenameOne/src/com/codename1/ui/TextArea.java @@ -2311,6 +2311,25 @@ public ActionListener getDoneListener() { /// Sets a Done listener on the TextField - notice this listener will be called /// only on supported platforms that supports done action on the keyboard /// + /// The listener also affects which action the keyboard offers. On Android a + /// single line field with a listener shows Done even when another field + /// follows it, because Next would never fire the listener. To choose the + /// action yourself set the client property `"nextButton"` or `"doneButton"` + /// to any non-null value; that works with every input constraint, numeric + /// ones included: + /// + /// ```java + /// TextField quantity = new TextField(); + /// quantity.setConstraint(TextArea.NUMERIC); + /// quantity.putClientProperty("nextButton", Boolean.TRUE); + /// ``` + /// + /// A field that asks for Next moves to the following field without calling + /// this listener, and its keyboard usually has no Done to call it with. Set + /// the listener on a field that offers Done, typically the last one. + /// A field nothing follows offers Done even with `"nextButton"`, and + /// `"doneButton"` wins when both are set. + /// /// #### Parameters /// /// - `l`: the listener diff --git a/Ports/Android/src/com/codename1/impl/android/InPlaceEditView.java b/Ports/Android/src/com/codename1/impl/android/InPlaceEditView.java index 54764c0d772..747323789aa 100644 --- a/Ports/Android/src/com/codename1/impl/android/InPlaceEditView.java +++ b/Ports/Android/src/com/codename1/impl/android/InPlaceEditView.java @@ -1004,7 +1004,17 @@ public boolean onActionItemClicked(ActionMode mode, if(textArea.getClientProperty("goButton") != null) { mEditText.setImeOptions(ime | EditorInfo.IME_ACTION_GO); } else { - if(textArea.getDoneListener() != null){ + // "doneButton" and "nextButton" choose the action explicitly, ahead + // of the defaults below. Without them a Done listener forces Done even + // when a next field exists, because the listener only fires for Done: + // that default must not change, or every listener on a field that is + // not the last one stops firing. Done wins if both are set, and a Next + // with nowhere to go falls back to the defaults. + if (textArea.getClientProperty("doneButton") != null) { + mEditText.setImeOptions(ime | EditorInfo.IME_ACTION_DONE); + } else if (textArea.getClientProperty("nextButton") != null && nextDown != null) { + mEditText.setImeOptions(ime | EditorInfo.IME_ACTION_NEXT); + } else if(textArea.getDoneListener() != null){ mEditText.setImeOptions(ime | EditorInfo.IME_ACTION_DONE); } else if (nextDown != null) { mEditText.setImeOptions(ime | EditorInfo.IME_ACTION_NEXT); @@ -1219,7 +1229,6 @@ private synchronized void endEditing(int reason, boolean forceVKBOpen, boolean f //if (cursorTimer != null) { // cursorTimer.cancel(); //} - System.out.println("-------In endEditing"); if (!mIsEditing || mEditText == null) { return; } @@ -1235,9 +1244,6 @@ private synchronized void endEditing(int reason, boolean forceVKBOpen, boolean f boolean leaveKeyboardShowing = impl.isAsyncEditMode(); - System.out.println("Next flag: "+isNextActionFlagSet); - System.out.println("Next cmp: "+getNextComponent(mEditText.mTextArea)); - System.out.println("Reason : "+reason+" IME Action: "+REASON_IME_ACTION); if (reason == REASON_IME_ACTION && isNextActionFlagSet) { if (getNextComponent(mEditText.mTextArea) instanceof TextArea) { leaveKeyboardShowing = true; @@ -1252,15 +1258,15 @@ private synchronized void endEditing(int reason, boolean forceVKBOpen, boolean f if (forceVKBClose) { leaveKeyboardShowing = false; } - System.out.println("-----LEAVE KEYBOARD SHOWING: "+leaveKeyboardShowing); if (!leaveKeyboardShowing || actionCode == EditorInfo.IME_ACTION_DONE || actionCode == EditorInfo.IME_ACTION_SEARCH || actionCode == EditorInfo.IME_ACTION_SEND || actionCode == EditorInfo.IME_ACTION_GO) { - System.out.println("Hiding virtual keyboard"); showVirtualKeyboard(false); } int imo = mEditText.getImeOptions() & 0xf; // Get rid of flags + // Next is deliberately absent: it moves on, it does not finish, so a field + // whose action is Next never fires its Done listener from the keyboard. if (reason == REASON_IME_ACTION && ((TextArea) mEditText.mTextArea).getDoneListener() != null - && (actionCode == EditorInfo.IME_ACTION_DONE)|| actionCode == EditorInfo.IME_ACTION_SEARCH || actionCode == EditorInfo.IME_ACTION_SEND || actionCode == EditorInfo.IME_ACTION_GO) { + && (actionCode == EditorInfo.IME_ACTION_DONE || actionCode == EditorInfo.IME_ACTION_SEARCH || actionCode == EditorInfo.IME_ACTION_SEND || actionCode == EditorInfo.IME_ACTION_GO)) { ((TextArea) mEditText.mTextArea).fireDoneEvent(keyEvent); } diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 775041e59f1..48c61cf7326 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -987,6 +987,39 @@ void cn1_setStyleDoneButton(CN1_THREAD_STATE_MULTI_ARG UIBarButtonItem* btn) { #endif + +#if !TARGET_OS_OSX && !TARGET_OS_WATCH +// A keyboard with no Return key: the toolbar is the only place its Next or Done +// can live. +static BOOL cn1IsPadKeyboard(UIKeyboardType type) { + return type == UIKeyboardTypeDecimalPad + || type == UIKeyboardTypePhonePad + || type == UIKeyboardTypeNumberPad; +} + +static BOOL cn1ExplicitKeyboardAction(void) { +#ifndef NEW_CODENAME_ONE_VM + return com_codename1_impl_ios_TextEditUtil_isExplicitKeyboardAction__(); +#else + return com_codename1_impl_ios_TextEditUtil_isExplicitKeyboardAction___R_boolean(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); +#endif +} + +// Whether such a keyboard gets the toolbar on this device. An iPad's docked +// number pad is a full keyboard with a Return key, so it is left alone; its +// floating keyboard is not, and nothing here can tell the two apart, so a field +// that chose its action with "nextButton"/"doneButton" gets the toolbar on an +// iPad whichever keyboard is up -- beside a Return key when it is the docked one. +static BOOL cn1PadKeyboardNeedsToolbar(UIKeyboardType type) { + return cn1IsPadKeyboard(type) && (!isIPad() || cn1ExplicitKeyboardAction()); +} + +// The developer's choice (showToolbar), minus landscape -- except where the +// toolbar is the keyboard's only action key. Unused on tvOS, which has no toolbar. +__attribute__((unused)) static BOOL cn1EditToolbarAllowed(BOOL showToolbar, BOOL landscape, UIKeyboardType type) { + return showToolbar && (!landscape || cn1PadKeyboardNeedsToolbar(type)); +} +#endif void Java_com_codename1_impl_ios_IOSImplementation_editStringAtImpl (CN1_THREAD_STATE_MULTI_ARG int x, int y, int w, int h, void* font, int isSingleLine, int rows, int maxSize, int constraint, const char* str, int len, BOOL forceSlideUp, @@ -1000,10 +1033,12 @@ void cn1_setStyleDoneButton(CN1_THREAD_STATE_MULTI_ARG UIBarButtonItem* btn) { #if TARGET_OS_OSX #else #if !TARGET_OS_WATCH - // don't show toolbar in iOS 8 in landscape since there is just no room for that... - if(isIOS8() && displayHeight < displayWidth) { - showToolbar = NO; - } + // In landscape there is little room for the toolbar, so it is left out -- but + // only for a keyboard that has a Return key. This used to be unconditional, and + // isIOS8() is true of every device now, so a number pad in landscape had neither + // a Return key nor a toolbar: no Next, no Done, no way to leave the field from + // the keyboard. See cn1EditToolbarAllowed. + __attribute__((unused)) const BOOL cn1LandscapeEdit = isIOS8() && displayHeight < displayWidth; //CN1Log(@"Java_com_codename1_impl_ios_IOSImplementation_editStringAtImpl"); currentlyEditingMaxLength = maxSize; // Honored by the UITextView shouldChangeTextInRange: delegate (METALView) to @@ -1191,13 +1226,11 @@ void cn1_setStyleDoneButton(CN1_THREAD_STATE_MULTI_ARG UIBarButtonItem* btn) { name:UITextFieldTextDidChangeNotification object:utf]; - if ((utf.keyboardType == UIKeyboardTypeDecimalPad - || utf.keyboardType == UIKeyboardTypePhonePad - || utf.keyboardType == UIKeyboardTypeNumberPad - || (utf.returnKeyType == UIReturnKeyNext && isVKBAlwaysOpen())) && !isIPad()) { + if (cn1PadKeyboardNeedsToolbar(utf.keyboardType) + || (utf.returnKeyType == UIReturnKeyNext && isVKBAlwaysOpen() && !isIPad())) { //add navigation toolbar to the top of the keyboard #if !TARGET_OS_TV - if(showToolbar) { + if(cn1EditToolbarAllowed(showToolbar, cn1LandscapeEdit, utf.keyboardType)) { #ifndef CN1_USE_ARC UIToolbar *toolbar = [[[UIToolbar alloc] init] autorelease]; #else @@ -1396,7 +1429,7 @@ void cn1_setStyleDoneButton(CN1_THREAD_STATE_MULTI_ARG UIBarButtonItem* btn) { } #if !TARGET_OS_TV - if(showToolbar) { + if(cn1EditToolbarAllowed(showToolbar, cn1LandscapeEdit, utv.keyboardType)) { //add navigation toolbar to the top of the keyboard #ifndef CN1_USE_ARC UIToolbar *toolbar = [[[UIToolbar alloc] init] autorelease]; diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index fe84325c293..ab9fdb9a609 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -1852,10 +1852,13 @@ - (BOOL)presentFramebuffer */ -(void) keyboardDoneClicked { if(editingComponent != nil) { + // -3, not the -2 that Return and Next send: IOSImplementation.editingUpdate + // treats the two alike except for a field that asked for Next explicitly, + // where only this button fires the Done listener. if([editingComponent isKindOfClass:[UITextView class]]) { - stringEdit(YES, -2, ((UITextView*)editingComponent).text); + stringEdit(YES, -3, ((UITextView*)editingComponent).text); } else { - stringEdit(YES, -2, ((UITextField*)editingComponent).text); + stringEdit(YES, -3, ((UITextField*)editingComponent).text); } if(isVKBAlwaysOpen()) { com_codename1_impl_ios_IOSImplementation_foldKeyboard__(CN1_THREAD_GET_STATE_PASS_SINGLE_ARG); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 64dba461022..232064e7c95 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1633,11 +1633,18 @@ public void run() { // field, or in a window-only application left a stale destination in place. // getNextComponent is Form-only but is defined as exactly this call, and // getTabIterator is on TopLevelContainer. + // "doneButton" and "nextButton" choose the keyboard action explicitly. Done + // is expressed as "there is no next field": the native side derives the Return + // key, the toolbar button and whether Return advances from that one answer, so + // no second path can disagree with it. Next is what a field with a successor + // gets anyway; setting it only changes whether advancing fires the Done + // listener (see editingUpdate) and whether an iPad gets the toolbar. if (parentTop != null) { - TextEditUtil.setNextEditComponent(parentTop.getTabIterator(cmp).getNext()); + TextEditUtil.setNextEditComponent(TextEditUtil.nextFor(cmp, parentTop.getTabIterator(cmp).getNext())); } else if (current != null) { - TextEditUtil.setNextEditComponent(current.getNextComponent(cmp)); + TextEditUtil.setNextEditComponent(TextEditUtil.nextFor(cmp, current.getNextComponent(cmp))); } + TextEditUtil.setExplicitKeyboardAction(TextEditUtil.choosesKeyboardAction(cmp)); Display.getInstance().callSerially(new Runnable() { @Override public void run() { @@ -1763,11 +1770,16 @@ public static void editingUpdate(final String s, final int cursorPositon, final public void run() { if(instance.currentEditing != null) { if(finished) { - editNext = cursorPositon == -2; + // -2 is Return or the toolbar's Next, -3 the toolbar's Done. They + // were one code, and still behave as one, except for a field that + // asked for Next explicitly: there Next moves on without finishing, + // so only Done fires the listener -- which is what Android does. + editNext = TextEditUtil.endedOnKeyboardAction(cursorPositon); + boolean firesDone = TextEditUtil.firesDoneEvent(cursorPositon, instance.currentEditing); synchronized(EDITING_LOCK) { instance.currentEditing.setText(s); Display.getInstance().onEditingComplete(instance.currentEditing, s); - if(editNext && instance.currentEditing != null && instance.currentEditing instanceof TextArea) { + if(firesDone && instance.currentEditing instanceof TextArea) { ((TextArea)instance.currentEditing).fireDoneEvent(); } Component cmp = instance.currentEditing; diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/TextEditUtil.java b/Ports/iOSPort/src/com/codename1/impl/ios/TextEditUtil.java index c52e0135704..33d9b413bd4 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/TextEditUtil.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/TextEditUtil.java @@ -56,6 +56,96 @@ public static void setNextEditComponent(Component cmp) { nextEditedComponent = cmp; } + /** + * The cursor position native code reports when editing ended on the Return + * key or the toolbar's Next button. + */ + static final int ENDED_BY_RETURN_OR_NEXT = -2; + + /** + * The cursor position native code reports when editing ended on the + * toolbar's Done button (METALView keyboardDoneClicked). + */ + static final int ENDED_BY_DONE = -3; + + /** + * Whether native code reported that the edit ended on a keyboard action, as + * opposed to a touch elsewhere or a programmatic stop. Both codes move to the + * next component where there is one, as the single code they used to be did. + */ + static boolean endedOnKeyboardAction(int endCode) { + return endCode == ENDED_BY_RETURN_OR_NEXT || endCode == ENDED_BY_DONE; + } + + /** + * Whether the component chose its keyboard action with the "nextButton" or + * "doneButton" client property. + */ + static boolean choosesKeyboardAction(Component cmp) { + return cmp.getClientProperty("doneButton") != null || cmp.getClientProperty("nextButton") != null; + } + + /** + * The component the keyboard moves to from cmp. "doneButton" answers null + * whatever follows: the native side derives the Return key, the toolbar + * button and whether Return advances from "is there a next component", so + * saying there is none is the one place Done has to be decided. + * + * @param cmp the component about to be edited + * @param following the component after it in the tab order, or null + */ + static Component nextFor(Component cmp, Component following) { + return cmp.getClientProperty("doneButton") != null ? null : following; + } + + /** + * Whether ending the edit of a component fires its Done listener. Every + * keyboard ending does, as it always has, with one exception: a field that + * asked for Next explicitly and has somewhere to go moves on without + * finishing, which is what Android does with its Next action. The toolbar's + * Done button still fires, but such a field has one only beside Next on a + * number, decimal or phone pad in async edit mode: a listener that must run + * belongs on a field that offers Done. + * + * @param endCode the cursor position native code reported + * @param edited the component whose edit ended, possibly null + */ + static boolean firesDoneEvent(int endCode, Component edited) { + if (edited == null) { + return false; + } + if (endCode == ENDED_BY_DONE) { + return true; + } + if (endCode != ENDED_BY_RETURN_OR_NEXT) { + return false; + } + return edited.getClientProperty("nextButton") == null || isLastEditComponent(); + } + + private static boolean explicitKeyboardAction; + + /** + * Records whether the component about to be edited chose its keyboard action + * with the "nextButton" or "doneButton" client property. + * + * @param explicit true if it did + */ + public static void setExplicitKeyboardAction(boolean explicit) { + explicitKeyboardAction = explicit; + } + + /** + * Callback for native code. An explicit action is the developer saying the + * field must offer that action, so the toolbar carrying it is shown on an + * iPad too, where it is otherwise left out. + * + * @return true if the edited component chose its keyboard action explicitly + */ + public static boolean isExplicitKeyboardAction() { + return explicitKeyboardAction; + } + /** * @return if the currently edited textarea is the last component on the * form diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/KeyboardActionSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/KeyboardActionSnippets.java new file mode 100644 index 00000000000..89a22c3b979 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/KeyboardActionSnippets.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets; + +import com.codename1.ui.Dialog; +import com.codename1.ui.TextArea; +import com.codename1.ui.TextField; + +/// Snippets for choosing the virtual keyboard's Next or Done action, included +/// by the developer guide's virtual keyboard section. +public class KeyboardActionSnippets { + void explicitActions() { + // tag::keyboard-action-next-done[] + TextField quantity = new TextField("", "Quantity", 6, TextArea.NUMERIC); + TextField price = new TextField("", "Price", 8, TextArea.DECIMAL); + + // Numeric, and offering Next even if the field has a Done listener + quantity.putClientProperty("nextButton", Boolean.TRUE); + + // Offers Done even if another field follows it, and calls the listener + price.putClientProperty("doneButton", Boolean.TRUE); + price.setDoneListener(e -> Dialog.show("Saved", price.getText(), "OK", null)); + // end::keyboard-action-next-done[] + } +} diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index b8ac8190f67..9e0a3d23ced 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -495,6 +495,39 @@ This will hide the toolbar for that given field. NOTE: You can customize the color of the #Done# button in the toolbar by setting the `ios.doneButtonColor` display property. For example: To change the color to red, you could do `Display.getInstance().setProperty("ios.doneButtonColor", String.valueOf(0xff0000))`. +===== Choosing next or done + +The action the keyboard offers is separate from the input constraint, so a `NUMERIC` or `DECIMAL` field can offer #Next# or #Done# like any other. Without any setting, a single line field picks its action in this order: + +. The `searchField`, `sendButton` and `goButton` client properties described above (Android). +. #Done#, if the field has a Done listener (Android). #Next# never calls a Done listener there, so a field with a listener offers #Done# even when another field follows it. +. #Next#, if another field follows this one in the tab order. +. #Done#. + +iOS skips the first two steps: it offers #Next# when another field follows and #Done# otherwise, and calls the Done listener for both. + +To make the choice yourself, set the `nextButton` or `doneButton` client property to any non-null value: + +[source,java] +---- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/KeyboardActionSnippets.java[tag=keyboard-action-next-done,indent=0] +---- + +Both properties work on Android and iOS, and take effect ahead of the Done listener and the following field. On Android `searchField`, `sendButton` and `goButton` still come first. + +- `nextButton` offers #Next# and moves to the following editable field. It doesn't call the Done listener: #Next# moves on, #Done# finishes. On a field nothing follows there is no #Next# to offer, so the last field of a form still offers #Done#. +- `doneButton` offers #Done# and ends editing, even when another field follows. It wins if both properties are set. + +WARNING: A field with `nextButton` usually has no #Done# on its keyboard, so a Done listener set on it isn't called when the user moves on. Set the listener on a field that offers #Done#, typically the last one. + +On Android the properties apply to single line fields, and to multi-line ones that have a Done listener. On iOS a multi-line field keeps its #Return# key for new lines, so there the properties only decide what the toolbar button says and does. + +On iOS the number, decimal and phone keyboards have no #Return# key, so their #Next# or #Done# is a button in a toolbar above the keyboard: + +- On an iPhone that toolbar is shown in portrait and landscape. For keyboards that do have a #Return# key the toolbar is left out in landscape, where there is little room for it. +- On an iPad the docked number keyboard is a full keyboard with its own #Return# key, so a field gets no toolbar there. The floating keyboard has no such key, and the two can't be told apart, so a field with `nextButton` or `doneButton` gets the toolbar on an iPad with either keyboard. +- `iosHideToolbar` removes the toolbar wherever it would appear. On a number, decimal or phone keyboard that leaves no #Next# or #Done# at all. + ==== Clearable text field iOS has a convention where an X can be placed after the text field to clear it. Some Android apps have it but there is no native support for that as of this writing. diff --git a/maven/core-unittests/src/test/java/com/codename1/impl/ios/TextEditUtilKeyboardActionTest.java b/maven/core-unittests/src/test/java/com/codename1/impl/ios/TextEditUtilKeyboardActionTest.java new file mode 100644 index 00000000000..a38a017d599 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/impl/ios/TextEditUtilKeyboardActionTest.java @@ -0,0 +1,245 @@ +/* + * 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.ui.TextField; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The "nextButton" and "doneButton" client properties on iOS: which component +/// the keyboard moves to, and whether ending an edit fires the Done listener. +/// The defaults must stay exactly what they were before the properties existed. +class TextEditUtilKeyboardActionTest extends com.codename1.junit.UITestBase { + private static final int RETURN_OR_NEXT = -2; + private static final int DONE = -3; + + @AfterEach + void clearNext() { + TextEditUtil.setNextEditComponent(null); + TextEditUtil.setExplicitKeyboardAction(false); + } + + @Test + void endCodesAreTheOnesNativeCodeSends() throws Exception { + assertEquals(RETURN_OR_NEXT, TextEditUtil.ENDED_BY_RETURN_OR_NEXT); + assertEquals(DONE, TextEditUtil.ENDED_BY_DONE); + + // The native side sends literals, so hold them to the constants here: + // the Done button alone sends ENDED_BY_DONE, Next and Return the other. + File source = new File("../../Ports/iOSPort/nativeSources/METALView.m"); + assertTrue(source.isFile(), "METALView.m not found from " + new File(".").getAbsolutePath()); + String text = new String(Files.readAllBytes(source.toPath()), StandardCharsets.UTF_8); + String done = method(text, "-(void) keyboardDoneClicked {"); + String next = method(text, "-(void) keyboardNextClicked {"); + String ret = method(text, "- (BOOL)textFieldShouldReturn:(UITextField *)theTextField {"); + assertTrue(done.contains("stringEdit(YES, " + DONE + ",")); + assertFalse(done.contains("stringEdit(YES, " + RETURN_OR_NEXT + ",")); + assertTrue(next.contains("stringEdit(YES, " + RETURN_OR_NEXT + ",")); + assertFalse(next.contains("stringEdit(YES, " + DONE + ",")); + assertTrue(ret.contains("stringEdit(YES, " + RETURN_OR_NEXT + ",")); + assertFalse(ret.contains("stringEdit(YES, " + DONE + ",")); + } + + /// The text of an Objective-C method: from its signature to the next line + /// that closes a top-level body. + private static String method(String text, String signature) { + int start = text.indexOf(signature); + assertTrue(start >= 0, "no method " + signature); + int end = text.indexOf("\n}", start); + assertTrue(end > start, "unterminated method " + signature); + return text.substring(start, end); + } + + private static class NativeStub extends IOSNative { + @Override public boolean isAsyncEditMode() { return false; } + @Override void setNativeEditingComponentVisible(boolean b) { } + } + + private static class Port extends IOSImplementation { + @Override public int getDisplayWidth() { return 800; } + @Override public int getDisplayHeight() { return 600; } + } + + /// Ends an edit of field the way native code does and answers how many + /// times its Done listener ran. Drives IOSImplementation.editingUpdate + /// itself, so the wiring is held and not only the helpers it calls. + private int endEdit(Port port, TextField field, TextField following, int endCode) throws Exception { + final int[] fired = new int[1]; + field.setDoneListener(e -> fired[0]++); + TextEditUtil.setNextEditComponent(TextEditUtil.nextFor(field, following)); + Field editing = IOSImplementation.class.getDeclaredField("currentEditing"); + editing.setAccessible(true); + editing.set(port, field); + IOSImplementation.editingUpdate("42", endCode, true); + flushSerialCalls(); + assertNull(editing.get(port), "the edit must have ended"); + assertEquals("42", field.getText()); + return fired[0]; + } + + private static boolean editNext() throws Exception { + Field editNext = IOSImplementation.class.getDeclaredField("editNext"); + editNext.setAccessible(true); + return editNext.getBoolean(null); + } + + @FormTest + void editingUpdateFiresDoneForEveryEndingExceptAnExplicitNext() throws Exception { + IOSNative oldNative = IOSImplementation.nativeInstance; + IOSImplementation oldInstance = IOSImplementation.instance; + Port port = new Port(); + try { + IOSImplementation.nativeInstance = new NativeStub(); + IOSImplementation.instance = port; + TextField following = new TextField(); + + // no property: both codes fire and both advance, as the one code did + assertEquals(1, endEdit(port, new TextField(), following, RETURN_OR_NEXT)); + assertTrue(editNext()); + assertEquals(1, endEdit(port, new TextField(), following, DONE)); + assertTrue(editNext()); + assertEquals(0, endEdit(port, new TextField(), following, -1)); + assertFalse(editNext()); + + // nextButton: advancing does not finish, the Done button does + TextField next = new TextField(); + next.putClientProperty("nextButton", Boolean.TRUE); + assertEquals(0, endEdit(port, next, following, RETURN_OR_NEXT)); + assertTrue(editNext()); + assertEquals(1, endEdit(port, next, following, DONE)); + assertEquals(1, endEdit(port, next, null, RETURN_OR_NEXT)); + + // doneButton: fires, and there is nowhere to advance to + TextField done = new TextField(); + done.putClientProperty("doneButton", Boolean.TRUE); + assertEquals(1, endEdit(port, done, following, RETURN_OR_NEXT)); + assertTrue(TextEditUtil.isLastEditComponent()); + } finally { + IOSImplementation.nativeInstance = oldNative; + IOSImplementation.instance = oldInstance; + Field editNext = IOSImplementation.class.getDeclaredField("editNext"); + editNext.setAccessible(true); + editNext.setBoolean(null, false); + } + } + + @Test + void bothEndCodesAreKeyboardActionsAndNothingElseIs() { + assertTrue(TextEditUtil.endedOnKeyboardAction(RETURN_OR_NEXT)); + assertTrue(TextEditUtil.endedOnKeyboardAction(DONE)); + assertFalse(TextEditUtil.endedOnKeyboardAction(-1)); + assertFalse(TextEditUtil.endedOnKeyboardAction(0)); + assertFalse(TextEditUtil.endedOnKeyboardAction(7)); + } + + @Test + void theExplicitFlagIsWhatNativeCodeReadsBack() { + assertFalse(TextEditUtil.isExplicitKeyboardAction()); + TextField field = new TextField(); + field.putClientProperty("nextButton", Boolean.TRUE); + TextEditUtil.setExplicitKeyboardAction(TextEditUtil.choosesKeyboardAction(field)); + assertTrue(TextEditUtil.isExplicitKeyboardAction()); + TextEditUtil.setExplicitKeyboardAction(TextEditUtil.choosesKeyboardAction(new TextField())); + assertFalse(TextEditUtil.isExplicitKeyboardAction()); + } + + @Test + void aPlainFieldKeepsItsSuccessorAndFiresOnEveryEnding() { + TextField field = new TextField(); + TextField following = new TextField(); + assertSame(following, TextEditUtil.nextFor(field, following)); + assertFalse(TextEditUtil.choosesKeyboardAction(field)); + + TextEditUtil.setNextEditComponent(following); + assertTrue(TextEditUtil.firesDoneEvent(RETURN_OR_NEXT, field)); + assertTrue(TextEditUtil.firesDoneEvent(DONE, field)); + TextEditUtil.setNextEditComponent(null); + assertTrue(TextEditUtil.firesDoneEvent(RETURN_OR_NEXT, field)); + } + + @Test + void doneButtonHasNoSuccessorAndFires() { + TextField field = new TextField(); + field.putClientProperty("doneButton", Boolean.TRUE); + assertNull(TextEditUtil.nextFor(field, new TextField())); + assertTrue(TextEditUtil.choosesKeyboardAction(field)); + + TextEditUtil.setNextEditComponent(TextEditUtil.nextFor(field, new TextField())); + assertTrue(TextEditUtil.isLastEditComponent()); + assertTrue(TextEditUtil.firesDoneEvent(RETURN_OR_NEXT, field)); + assertTrue(TextEditUtil.firesDoneEvent(DONE, field)); + } + + @Test + void nextButtonMovesOnWithoutFiringButItsDoneButtonStillFires() { + TextField field = new TextField(); + TextField following = new TextField(); + field.putClientProperty("nextButton", Boolean.TRUE); + assertSame(following, TextEditUtil.nextFor(field, following)); + assertTrue(TextEditUtil.choosesKeyboardAction(field)); + + TextEditUtil.setNextEditComponent(following); + assertFalse(TextEditUtil.firesDoneEvent(RETURN_OR_NEXT, field)); + assertTrue(TextEditUtil.firesDoneEvent(DONE, field)); + } + + @Test + void nextButtonWithNothingFollowingIsAPlainLastField() { + TextField field = new TextField(); + field.putClientProperty("nextButton", Boolean.TRUE); + TextEditUtil.setNextEditComponent(TextEditUtil.nextFor(field, null)); + assertTrue(TextEditUtil.isLastEditComponent()); + assertTrue(TextEditUtil.firesDoneEvent(RETURN_OR_NEXT, field)); + } + + @Test + void doneButtonWinsOverNextButton() { + TextField field = new TextField(); + field.putClientProperty("nextButton", Boolean.TRUE); + field.putClientProperty("doneButton", Boolean.TRUE); + TextEditUtil.setNextEditComponent(TextEditUtil.nextFor(field, new TextField())); + assertTrue(TextEditUtil.isLastEditComponent()); + assertTrue(TextEditUtil.firesDoneEvent(RETURN_OR_NEXT, field)); + } + + @Test + void anEditThatDidNotEndOnTheKeyboardNeverFires() { + TextField field = new TextField(); + assertFalse(TextEditUtil.firesDoneEvent(-1, field)); + assertFalse(TextEditUtil.firesDoneEvent(0, field)); + assertFalse(TextEditUtil.firesDoneEvent(RETURN_OR_NEXT, null)); + } +}