diff --git a/build.gradle b/build.gradle index 650ccae9..53d7c41d 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ subprojects { javafx { version = "20.0.2" - modules 'javafx.graphics' + modules 'javafx.graphics', 'javafx.controls' } } diff --git a/gradle/include/android/grandroid_ext.h b/gradle/include/android/grandroid_ext.h index 6b4570c3..4fa956c5 100644 --- a/gradle/include/android/grandroid_ext.h +++ b/gradle/include/android/grandroid_ext.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -58,9 +58,11 @@ jobject substrateGetActivity(); #ifdef SUBSTRATE void __attribute__((weak)) attach_setActivityResult(jint requestCode, jint resultCode, jobject intent) {} void __attribute__((weak)) attach_setLifecycleEvent(const char *event) {} +void __attribute__((weak)) attach_setComposingText(const char *id, const char *text) {} #else void attach_setActivityResult(jint requestCode, jint resultCode, jobject intent); void attach_setLifecycleEvent(const char *event); +void attach_setComposingText(const char *id, const char *text); #endif #define ATTACH_GRAAL() \ diff --git a/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/KeyboardService.java b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/KeyboardService.java index 45d1f1e1..53e3e80d 100644 --- a/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/KeyboardService.java +++ b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/KeyboardService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -29,6 +29,7 @@ import com.gluonhq.attach.util.Services; import javafx.beans.property.ReadOnlyFloatProperty; +import javafx.beans.property.ReadOnlyStringProperty; import javafx.scene.Node; import javafx.scene.Parent; @@ -85,4 +86,32 @@ static Optional create() { * @return A ReadOnlyFloatProperty with the height of the soft keyboard */ ReadOnlyFloatProperty visibleHeightProperty(); + + /** + * Assigns a keyboard type to a specific node (typically a {@link javafx.scene.control.TextInputControl}). + * When the node gains gets activated, the keyboard type is applied automatically. + * When the keyboard hides, the keyboard type reverts to {@link KeyboardType#ASCII}. + * + *

If nodes are registered, they default to {@link KeyboardType#ASCII}.

+ * + * @param node the node (typically a text input control) to configure + * @param type the {@link KeyboardType} to use when this node is active + * @since 4.0.25 + */ + void setKeyboardTypeForNode(Node node, KeyboardType type); + + /** + * Returns a read-only property that reflects the current composing text for the given node + * (typically a {@link javafx.scene.control.TextInputControl}), as reported by the native IME. + * + *

Note that the JavaFX text input control default {@code textProperty()} will still + * catch all the internals of the text composition when predictive text is enabled (that could show + * partial text being removed and added back again while the user is typing)

+ * + * @param node the node whose text to observe + * @return a ReadOnlyStringProperty with the composed text for the given node + * @since 4.0.25 + */ + ReadOnlyStringProperty textPropertyForNode(Node node); + } diff --git a/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/KeyboardType.java b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/KeyboardType.java new file mode 100644 index 00000000..a214f3f8 --- /dev/null +++ b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/KeyboardType.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026, Gluon + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL GLUON BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.gluonhq.attach.keyboard; + +/** + * Defines the type of keyboard to display. + * + *

On iOS, these map directly to {@code UIKeyboardType} values. + * On Android, they map to the corresponding {@code InputType} flags.

+ * + * @since 4.0.25 + */ +public enum KeyboardType { + + /** + * The default keyboard, supporting general text input. + */ + DEFAULT(0), + + /** + * A keyboard that displays standard ASCII characters. + */ + ASCII(1), + + /** + * A keyboard optimized for number and punctuation entry. + */ + NUMBERS_AND_PUNCTUATION(2), + + /** + * A keyboard optimized for URL entry (with {@code .}, {@code /}, + * and {@code .com} keys). + */ + URL(3), + + /** + * A numeric keypad designed for PIN entry (locale digits 0-9). + */ + NUMBER_PAD(4), + + /** + * A keypad designed for entering telephone numbers + * (digits, {@code *}, and {@code #}). + */ + PHONE_PAD(5), + + /** + * A keyboard optimized for entering a person's name or phone number. + */ + NAME_PHONE_PAD(6), + + /** + * A keyboard optimized for entering email addresses (with {@code @} + * and {@code .} keys). + */ + EMAIL(7), + + /** + * A numeric keypad with a decimal point. + */ + DECIMAL_PAD(8), + + /** + * A keyboard optimized for Twitter text entry + * (with {@code @} and {@code #} keys). + */ + TWITTER(9), + + /** + * A keyboard optimized for web search terms and URL entry. + */ + WEB_SEARCH(10), + + /** + * A numeric keypad that outputs only ASCII digits + */ + ASCII_NUMBER_PAD(11); + + private final int value; + + KeyboardType(int value) { + this.value = value; + } + + /** + * Returns the native integer value corresponding to this keyboard type. + * + * @return the native keyboard type value + */ + public int getValue() { + return value; + } +} + diff --git a/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/AndroidKeyboardService.java b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/AndroidKeyboardService.java index 03cab836..98571a92 100644 --- a/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/AndroidKeyboardService.java +++ b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/AndroidKeyboardService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2022, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,25 +27,12 @@ */ package com.gluonhq.attach.keyboard.impl; -import com.gluonhq.attach.keyboard.KeyboardService; -import com.gluonhq.attach.util.Util; -import javafx.animation.Interpolator; -import javafx.animation.TranslateTransition; import javafx.application.Platform; import javafx.beans.property.ReadOnlyFloatProperty; -import javafx.beans.property.ReadOnlyFloatWrapper; import javafx.scene.Node; import javafx.scene.Parent; -import javafx.util.Duration; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class AndroidKeyboardService implements KeyboardService { - - private static final Logger LOG = Logger.getLogger(AndroidKeyboardService.class.getName()); - private static final ReadOnlyFloatWrapper VISIBLE_HEIGHT = new ReadOnlyFloatWrapper(); - private static final boolean debug = Util.DEBUG; +public class AndroidKeyboardService extends BaseKeyboardService { static { System.loadLibrary("keyboard"); @@ -69,36 +56,33 @@ public ReadOnlyFloatProperty visibleHeightProperty() { return VISIBLE_HEIGHT.getReadOnlyProperty(); } - private static void adjustPosition(Node node, Parent parent, double kh) { - if (node == null || node.getScene() == null || node.getScene().getWindow() == null) { - return; - } - double tTot = node.getScene().getHeight(); - double ty = node.getLocalToSceneTransform().getTy() + node.getBoundsInParent().getHeight() + 2; - double y = 1; - Parent root = parent == null ? node.getScene().getRoot() : parent; - if (ty > tTot - kh) { - y = tTot - ty - kh; - } else if (kh == 0 && root.getTranslateY() != 0) { - y = 0; - } - if (y <= 0) { - if (debug) { - LOG.log(Level.INFO, String.format("Moving %s %.2f pixels", root, y)); - } - final TranslateTransition transition = new TranslateTransition(Duration.millis(50), root); - transition.setFromY(root.getTranslateY()); - transition.setToY(y); - transition.setInterpolator(Interpolator.EASE_OUT); - transition.playFromStart(); - } + @Override + protected void applyKeyboardType(int nativeValue) { + nativeSetKeyboardType(nativeValue); } - // callback + @Override + protected void applyActiveNodeId(String id) { + nativeSetActiveNodeId(id); + } + + // native + private static native void nativeSetKeyboardType(int keyboardTypeValue); + private static native void nativeSetActiveNodeId(String id); + + // callbacks private static void notifyVisibleHeight(float height) { if (VISIBLE_HEIGHT.get() != height) { Platform.runLater(() -> VISIBLE_HEIGHT.set(height)); } } + /** + * Called from keyboard.c when the native layer receives composing text + * tagged with a node id. + */ + private static void notifyComposingText(String id, String text) { + updateTextForId(id, text); + } + } \ No newline at end of file diff --git a/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/BaseKeyboardService.java b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/BaseKeyboardService.java new file mode 100644 index 00000000..0b4b493b --- /dev/null +++ b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/BaseKeyboardService.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2026, Gluon + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL GLUON BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.gluonhq.attach.keyboard.impl; + +import com.gluonhq.attach.keyboard.KeyboardService; +import com.gluonhq.attach.keyboard.KeyboardType; +import com.gluonhq.attach.util.Util; +import javafx.animation.Interpolator; +import javafx.animation.TranslateTransition; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyFloatWrapper; +import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.input.MouseEvent; +import javafx.util.Duration; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.WeakHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Base class that provides common functionality for iOS and Android implementations. + */ +public abstract class BaseKeyboardService implements KeyboardService { + + private static final Logger LOG = Logger.getLogger(BaseKeyboardService.class.getName()); + protected static final ReadOnlyFloatWrapper VISIBLE_HEIGHT = new ReadOnlyFloatWrapper(); + protected static final boolean debug = Util.DEBUG; + + /** Map of nodes and keyboard types. */ + private final Map nodeKeyboardTypes = new WeakHashMap<>(); + + /** Map of nodes and text properties. */ + private static final Map nodeTextProperties = new WeakHashMap<>(); + + /** Map of ids and nodes. */ + private static final Map idToNode = new HashMap<>(); + + BaseKeyboardService() { + VISIBLE_HEIGHT.addListener((obs, ov, nv) -> { + if (nv != null && nv.doubleValue() <= 0) { + if (debug) { + LOG.info("Keyboard hidden, reset default type"); + } + applyActiveNodeId(""); // reset active node + applyKeyboardType(KeyboardType.ASCII.getValue()); + } + }); + } + + @Override + public void setKeyboardTypeForNode(Node node, KeyboardType type) { + Objects.requireNonNull(node, "node must not be null"); + Objects.requireNonNull(type, "type must not be null"); + nodeKeyboardTypes.put(node, type); + installEventFilter(node); + } + + @Override + public ReadOnlyStringProperty textPropertyForNode(Node node) { + Objects.requireNonNull(node, "node must not be null"); + installEventFilter(node); + return nodeTextProperties.computeIfAbsent(node, n -> { + idToNode.put(syntheticId(n), n); + return new ReadOnlyStringWrapper(""); + }).getReadOnlyProperty(); + } + + private void installEventFilter(Node node) { + node.addEventFilter(MouseEvent.MOUSE_CLICKED, e -> { + KeyboardType type = nodeKeyboardTypes.getOrDefault(node, KeyboardType.ASCII); + if (debug) { + LOG.info(String.format("Active keyboard type: %s for id %s", type, syntheticId(node))); + } + applyActiveNodeId(syntheticId(node)); + applyKeyboardType(type.getValue()); + }); + } + + /** + * Uses the node's own {@link Node#getId() id} if set, otherwise falls back to an + * id based on its identity hash code. + */ + protected static String syntheticId(Node node) { + String id = node.getId(); + return id != null ? id : "attach-kb-" + System.identityHashCode(node); + } + + /** + * Called from the native callback to update the text property for the + * node identified by {@code id}. + */ + protected static void updateTextForId(String id, String text) { + Node node = idToNode.get(id); + if (node == null) { + return; + } + ReadOnlyStringWrapper wrapper = nodeTextProperties.get(node); + if (wrapper != null && !Objects.equals(wrapper.get(), text)) { + Platform.runLater(() -> wrapper.set(text)); + } + } + + protected static void adjustPosition(Node node, Parent parent, double kh) { + if (node == null || node.getScene() == null || node.getScene().getWindow() == null) { + return; + } + double tTot = node.getScene().getHeight(); + double ty = node.getLocalToSceneTransform().getTy() + node.getBoundsInParent().getHeight() + 2; + double y = 1; + Parent root = parent == null ? node.getScene().getRoot() : parent; + if (ty > tTot - kh) { + y = tTot - ty - kh; + } else if (kh == 0 && root.getTranslateY() != 0) { + y = 0; + } + if (y <= 0) { + if (debug) { + LOG.log(Level.INFO, String.format("Moving %s %.2f pixels", root, y)); + } + final TranslateTransition transition = new TranslateTransition(Duration.millis(50), root); + transition.setFromY(root.getTranslateY()); + transition.setToY(y); + transition.setInterpolator(Interpolator.EASE_OUT); + transition.playFromStart(); + } + } + + /** + * Apply the keyboard type on the native side. + * @param nativeValue the integer value from {@link KeyboardType#getValue()} + */ + protected abstract void applyKeyboardType(int nativeValue); + + /** + * Pass to the native layer the id of the currently active node. + * @param id the string id of the active node + */ + protected abstract void applyActiveNodeId(String id); +} + diff --git a/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/IOSKeyboardService.java b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/IOSKeyboardService.java index f005f78b..a5302c03 100644 --- a/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/IOSKeyboardService.java +++ b/modules/keyboard/src/main/java/com/gluonhq/attach/keyboard/impl/IOSKeyboardService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2022, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,27 +27,16 @@ */ package com.gluonhq.attach.keyboard.impl; -import com.gluonhq.attach.keyboard.KeyboardService; import com.gluonhq.attach.lifecycle.LifecycleEvent; import com.gluonhq.attach.lifecycle.LifecycleService; -import com.gluonhq.attach.util.Util; -import javafx.animation.Interpolator; -import javafx.animation.TranslateTransition; import javafx.application.Platform; import javafx.beans.property.ReadOnlyFloatProperty; -import javafx.beans.property.ReadOnlyFloatWrapper; +import javafx.beans.property.ReadOnlyStringProperty; import javafx.scene.Node; import javafx.scene.Parent; -import javafx.util.Duration; +import javafx.scene.control.TextInputControl; -import java.util.logging.Level; -import java.util.logging.Logger; - -public class IOSKeyboardService implements KeyboardService { - - private static final Logger LOG = Logger.getLogger(IOSKeyboardService.class.getName()); - private static final ReadOnlyFloatWrapper VISIBLE_HEIGHT = new ReadOnlyFloatWrapper(); - private static boolean debug; +public class IOSKeyboardService extends BaseKeyboardService { static { if (Platform.isFxApplicationThread()) { @@ -58,8 +47,6 @@ public class IOSKeyboardService implements KeyboardService { } public IOSKeyboardService() { - debug = Util.DEBUG; - LifecycleService.create().ifPresent(l -> { l.addListener(LifecycleEvent.PAUSE, IOSKeyboardService::stopObserver); l.addListener(LifecycleEvent.RESUME, IOSKeyboardService::startObserver); @@ -82,34 +69,28 @@ public ReadOnlyFloatProperty visibleHeightProperty() { return VISIBLE_HEIGHT.getReadOnlyProperty(); } - private static void adjustPosition(Node node, Parent parent, double kh) { - if (node == null || node.getScene() == null || node.getScene().getWindow() == null) { - return; - } - double tTot = node.getScene().getHeight(); - double ty = node.getLocalToSceneTransform().getTy() + node.getBoundsInParent().getHeight() + 2; - double y = 1; - Parent root = parent == null ? node.getScene().getRoot() : parent; - if (ty > tTot - kh) { - y = tTot - ty - kh; - } else if (kh == 0 && root.getTranslateY() != 0) { - y = 0; - } - if (y <= 0) { - if (debug) { - LOG.log(Level.INFO, String.format("Moving %s %.2f pixels", root, y)); - } - final TranslateTransition transition = new TranslateTransition(Duration.millis(50), root); - transition.setFromY(root.getTranslateY()); - transition.setToY(y); - transition.setInterpolator(Interpolator.EASE_OUT); - transition.playFromStart(); + @Override + protected void applyKeyboardType(int nativeValue) { + nativeSetKeyboardType(nativeValue); + } + + @Override + public ReadOnlyStringProperty textPropertyForNode(Node node) { + if (node instanceof TextInputControl) { + return ((TextInputControl) node).textProperty(); } + return super.textPropertyForNode(node); + } + + @Override + protected void applyActiveNodeId(String id) { + // no-op: iOS does not track active node, so no need to inform native layer } // native private static native void startObserver(); private static native void stopObserver(); + private static native void nativeSetKeyboardType(int type); // callback private static void notifyVisibleHeight(float height) { diff --git a/modules/keyboard/src/main/java/module-info.java b/modules/keyboard/src/main/java/module-info.java index 1f98ce35..a4ddbc55 100644 --- a/modules/keyboard/src/main/java/module-info.java +++ b/modules/keyboard/src/main/java/module-info.java @@ -28,6 +28,7 @@ module com.gluonhq.attach.keyboard { requires javafx.graphics; + requires javafx.controls; requires com.gluonhq.attach.util; requires com.gluonhq.attach.lifecycle; diff --git a/modules/keyboard/src/main/native/android/c/keyboard.c b/modules/keyboard/src/main/native/android/c/keyboard.c index 1636be8c..e95270d6 100644 --- a/modules/keyboard/src/main/native/android/c/keyboard.c +++ b/modules/keyboard/src/main/native/android/c/keyboard.c @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, 2022, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -29,7 +29,11 @@ static jclass jKeyboardServiceClass; static jclass jAttachKeyboardClass; +static jclass jActivityClass; static jmethodID jAttach_notifyHeightMethod; +static jmethodID jAttach_notifyComposingTextMethod; +static jmethodID jActivity_setKeyboardTypeMethod; +static jmethodID jActivity_setActiveNodeIdMethod; void initKeyboard(); static jfloat density; @@ -49,6 +53,7 @@ JNI_OnLoad_keyboard(JavaVM *vm, void *reserved) ATTACH_LOG_FINE("Initializing native Keyboard from OnLoad"); jAttachKeyboardClass = (*env)->NewGlobalRef(env, (*env)->FindClass(env, "com/gluonhq/attach/keyboard/impl/AndroidKeyboardService")); jAttach_notifyHeightMethod = (*env)->GetStaticMethodID(env, jAttachKeyboardClass, "notifyVisibleHeight", "(F)V"); + jAttach_notifyComposingTextMethod = (*env)->GetStaticMethodID(env, jAttachKeyboardClass, "notifyComposingText", "(Ljava/lang/String;Ljava/lang/String;)V"); initKeyboard(); ATTACH_LOG_FINE("Initializing native Keyboard done"); return JNI_VERSION_1_8; @@ -68,13 +73,15 @@ void initKeyboard() KeyboardInited = 1; ATTACH_LOG_FINE("Init AndroidKeyboardService"); - jclass activityClass = substrateGetActivityClass(); + jActivityClass = substrateGetActivityClass(); jobject jActivity = substrateGetActivity(); jKeyboardServiceClass = GET_REGISTER_DALVIK_CLASS(jKeyboardServiceClass, "com/gluonhq/helloandroid/KeyboardService"); ATTACH_DALVIK(); jmethodID jKeyboardServiceInitMethod = (*dalvikEnv)->GetMethodID(dalvikEnv, jKeyboardServiceClass, "", "(Landroid/app/Activity;)V"); jobject keyboardservice = (*dalvikEnv)->NewObject(dalvikEnv, jKeyboardServiceClass, jKeyboardServiceInitMethod, jActivity); + jActivity_setKeyboardTypeMethod = (*dalvikEnv)->GetStaticMethodID(dalvikEnv, jActivityClass, "setKeyboardType", "(I)V"); + jActivity_setActiveNodeIdMethod = (*dalvikEnv)->GetStaticMethodID(dalvikEnv, jActivityClass, "setActiveNodeId", "(Ljava/lang/String;)V"); density = android_getDensity(dalvikEnv); DETACH_DALVIK(); @@ -84,6 +91,51 @@ void initKeyboard() ATTACH_LOG_FINE("Dalvik KeyboardService init was called"); } +// from Java to Android + +JNIEXPORT void JNICALL Java_com_gluonhq_attach_keyboard_impl_AndroidKeyboardService_nativeSetKeyboardType(JNIEnv *env, jclass cls, jint keyboardTypeValue) +{ + ATTACH_LOG_FINE("nativeSetKeyboardType: keyboardTypeValue = %d", keyboardTypeValue); + ATTACH_DALVIK(); + (*dalvikEnv)->CallStaticVoidMethod(dalvikEnv, jActivityClass, jActivity_setKeyboardTypeMethod, keyboardTypeValue); + DETACH_DALVIK(); + ATTACH_LOG_FINE("nativeSetKeyboardType done"); +} + +JNIEXPORT void JNICALL Java_com_gluonhq_attach_keyboard_impl_AndroidKeyboardService_nativeSetActiveNodeId(JNIEnv *env, jclass cls, jstring id) +{ + const char *idChars = (*env)->GetStringUTFChars(env, id, NULL); + ATTACH_LOG_FINE("nativeSetActiveNodeId: id = %s", idChars); + ATTACH_DALVIK(); + jstring dalvikId = (*dalvikEnv)->NewStringUTF(dalvikEnv, idChars); + (*dalvikEnv)->CallStaticVoidMethod(dalvikEnv, jActivityClass, jActivity_setActiveNodeIdMethod, dalvikId); + (*dalvikEnv)->DeleteLocalRef(dalvikEnv, dalvikId); + DETACH_DALVIK(); + (*env)->ReleaseStringUTFChars(env, id, idChars); + ATTACH_LOG_FINE("nativeSetActiveNodeId done"); +} + +////////////////////////////////// +// native (Substrate) to Java // +////////////////////////////////// + +void attach_setComposingText(const char *id, const char *text) +{ + ATTACH_LOG_FINE("attach_setComposingText: forwarding to Graal: id=%s, text=%s", id, text); + ATTACH_GRAAL(); + jstring graalId = (*graalEnv)->NewStringUTF(graalEnv, id); + jstring graalText = (*graalEnv)->NewStringUTF(graalEnv, text); + (*graalEnv)->CallStaticVoidMethod(graalEnv, jAttachKeyboardClass, jAttach_notifyComposingTextMethod, graalId, graalText); + (*graalEnv)->DeleteLocalRef(graalEnv, graalText); + (*graalEnv)->DeleteLocalRef(graalEnv, graalId); + DETACH_GRAAL(); + ATTACH_LOG_FINE("attach_setComposingText done"); +} + +/////////////////////////// +// From Dalvik to native // +/////////////////////////// + JNIEXPORT void JNICALL Java_com_gluonhq_helloandroid_KeyboardService_nativeDispatchKeyboardHeight(JNIEnv *env, jobject activity, jfloat jheight) { ATTACH_LOG_FINE("Dispatching keyboard height from native Dalvik layer: %.3f", jheight / density); diff --git a/modules/keyboard/src/main/native/ios/Keyboard.h b/modules/keyboard/src/main/native/ios/Keyboard.h index 2aaecef1..86d6ea71 100644 --- a/modules/keyboard/src/main/native/ios/Keyboard.h +++ b/modules/keyboard/src/main/native/ios/Keyboard.h @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020, Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -27,6 +27,7 @@ */ #import +#import #include "jni.h" #include "AttachMacros.h" @@ -36,3 +37,4 @@ @end void sendVisibleHeight(); +void setGlassKeyboardType(int type); diff --git a/modules/keyboard/src/main/native/ios/Keyboard.m b/modules/keyboard/src/main/native/ios/Keyboard.m index b1d7f080..bc6e46b0 100644 --- a/modules/keyboard/src/main/native/ios/Keyboard.m +++ b/modules/keyboard/src/main/native/ios/Keyboard.m @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 Gluon + * Copyright (c) 2020, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -53,6 +53,77 @@ // Keyboard Keyboard *_keyboard; CGFloat currentKeyboardHeight = 0.0f; +static UIKeyboardType currentKeyboardType = UIKeyboardTypeASCIICapable; +static BOOL keyboardTypeSwizzled = NO; +static BOOL isReloading = NO; + +// Swizzled keyboardType implementation that returns our custom type +static UIKeyboardType swizzled_keyboardType(id self, SEL _cmd) { + return currentKeyboardType; +} + +static void ensureSwizzled() { + Class glassWindowClass = objc_getClass("GlassWindow"); + if (!glassWindowClass) { + AttachLog(@"GlassWindow class not found, cannot override UITextInputTraits"); + return; + } + + if (!keyboardTypeSwizzled) { + class_replaceMethod(glassWindowClass, + @selector(keyboardType), + (IMP)swizzled_keyboardType, + "I@:"); + keyboardTypeSwizzled = YES; + AttachLog(@"Successfully swizzled keyboardType on GlassWindow"); + } +} + +// Force the keyboard to reload with the new type by resigning and +// re-becoming first responder on the current first responder. +static UIView *findFirstResponder(UIView *view) { + if ([view isFirstResponder]) { + return view; + } + for (UIView *subview in view.subviews) { + UIView *found = findFirstResponder(subview); + if (found) { + return found; + } + } + return nil; +} + +static void reloadKeyboard() { + UIWindow *keyWindow = nil; + for (UIWindow *window in [UIApplication sharedApplication].windows) { + if (window.isKeyWindow) { + keyWindow = window; + break; + } + } + if (!keyWindow) { + return; + } + UIView *firstResponder = findFirstResponder(keyWindow); + if (firstResponder) { + AttachLog(@"Reloading keyboard by cycling first responder"); + isReloading = YES; + [firstResponder resignFirstResponder]; + // Small delay to let UIKit finish dismissing before re-showing + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + isReloading = NO; + [firstResponder becomeFirstResponder]; + }); + } +} + +void setGlassKeyboardType(int type) { + currentKeyboardType = (UIKeyboardType)type; + ensureSwizzled(); + AttachLog(@"Keyboard type set to %d", type); +} JNIEXPORT void JNICALL Java_com_gluonhq_attach_keyboard_impl_IOSKeyboardService_startObserver (JNIEnv *env, jclass jClass) @@ -78,11 +149,23 @@ return; } +JNIEXPORT void JNICALL Java_com_gluonhq_attach_keyboard_impl_IOSKeyboardService_nativeSetKeyboardType +(JNIEnv *env, jclass jClass, jint type) +{ + if (keyboardTypeSwizzled && (UIKeyboardType)type == currentKeyboardType) { + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + setGlassKeyboardType((int)type); + reloadKeyboard(); + }); +} + void sendVisibleHeight() { (*env)->CallStaticVoidMethod(env, jAttachKeyboardClass, jAttachKeyboardMethod_notifyVisibleHeight, currentKeyboardHeight); } -@implementation Keyboard +@implementation Keyboard - (void) startObserver { @@ -107,6 +190,10 @@ - (void)keyboardWillShow:(NSNotification*)notification { } - (void)keyboardWillHide:(NSNotification*)notification { + if (isReloading) { + [self logMessage:@"Keyboard will hide (suppressed – reload in progress)"]; + return; + } currentKeyboardHeight = 0.0f; [self logMessage:@"Keyboard will hide"]; sendVisibleHeight(); diff --git a/modules/keyboard/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json b/modules/keyboard/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json index 0d3037fe..fd604614 100644 --- a/modules/keyboard/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json +++ b/modules/keyboard/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json @@ -2,7 +2,8 @@ { "name" : "com.gluonhq.attach.keyboard.impl.AndroidKeyboardService", "methods":[ - {"name":"notifyVisibleHeight","parameterTypes":["float"]} - ] + {"name":"notifyVisibleHeight","parameterTypes":["float"]}, + {"name":"notifyComposingText","parameterTypes":["java.lang.String","java.lang.String"]} + ] } ] \ No newline at end of file