diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd index cbfb369..1c324a2 100644 --- a/tests/on_screen_keyboard_validation.gd +++ b/tests/on_screen_keyboard_validation.gd @@ -23,12 +23,22 @@ class ControllerInputProbe: class NativeKeyboardProbe: extends OnScreenKeyboard + var shown_controls: Array[Control] = [] + var hide_count: int = 0 func _uses_native_virtual_keyboard() -> bool: return true + func _show_native_keyboard_for(control: Control) -> void: + shown_controls.append(control) + + + func _hide_native_keyboard() -> void: + hide_count += 1 + + func _initialize() -> void: call_deferred("_run") @@ -294,41 +304,54 @@ func _validate_native_keyboard() -> void: await process_frame edit.grab_focus() assert(keyboard.is_open()) + var focus_exit_count: int = 0 + edit.focus_exited.connect(func() -> void: + focus_exit_count += 1 + ) + assert(keyboard.request_for_control(edit)) + assert(edit.has_focus()) + assert(focus_exit_count == 0) + assert(not edit.virtual_keyboard_enabled) + assert(not edit.virtual_keyboard_show_on_focus) + assert(keyboard.shown_controls == [edit]) var character_event := InputEventKey.new() character_event.keycode = KEY_A character_event.unicode = 97 character_event.pressed = true - keyboard.call("_input", character_event) + Input.parse_input_event(character_event) await process_frame assert(edit.has_focus()) + assert(edit.text == "a") outside_button.grab_focus() await process_frame assert(outside_button.has_focus()) - edit.grab_focus() + assert(keyboard.is_open()) var backspace_event := InputEventKey.new() backspace_event.keycode = KEY_BACKSPACE backspace_event.pressed = true - keyboard.call("_input", backspace_event) + Input.parse_input_event(backspace_event) await process_frame assert(edit.has_focus()) - outside_button.grab_focus() - await process_frame - assert(outside_button.has_focus()) - edit.grab_focus() + assert(edit.text.is_empty()) var close_event := InputEventJoypadButton.new() close_event.button_index = JOY_BUTTON_B close_event.pressed = true keyboard.call("_input", close_event) assert(not edit.has_focus()) assert(not keyboard.is_open()) + assert(edit.virtual_keyboard_enabled) + assert(edit.virtual_keyboard_show_on_focus) + assert(keyboard.hide_count == 1) assert(keyboard.request_for_control(edit)) await process_frame assert(edit.has_focus()) assert(keyboard.is_open()) + assert(keyboard.shown_controls == [edit, edit]) var escape_event := InputEventKey.new() escape_event.keycode = KEY_ESCAPE escape_event.pressed = true keyboard.call("_input", escape_event) assert(not edit.has_focus()) assert(not keyboard.is_open()) + assert(keyboard.hide_count == 2) host.queue_free() diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd index e961f08..a6ed070 100644 --- a/ui/on_screen_keyboard.gd +++ b/ui/on_screen_keyboard.gd @@ -29,6 +29,9 @@ enum Page { var _enabled: bool = false var _controller_mapping_manager: ControllerMappingManagerType +var _native_target: Control +var _native_target_virtual_keyboard_enabled: bool = true +var _native_target_virtual_keyboard_show_on_focus: bool = true var _page: Page = Page.LOWER var _target: Control var _target_virtual_keyboard_enabled: bool = true @@ -260,6 +263,8 @@ static func should_enable_for_controller( func _native_text_entry_is_active() -> bool: if not _uses_native_virtual_keyboard(): return false + if _native_session_target() != null: + return true var focused: Control = get_viewport().gui_get_focus_owner() return ( _can_edit(focused) @@ -269,21 +274,18 @@ func _native_text_entry_is_active() -> bool: func _handle_native_keyboard_input(event: InputEvent) -> void: var focused: Control = get_viewport().gui_get_focus_owner() - var native_entry_active: bool = ( - _can_edit(focused) - and bool(focused.get("virtual_keyboard_enabled")) - ) + var target: Control = _native_session_target() var joy_button := event as InputEventJoypadButton if joy_button != null and joy_button.pressed: if ( - native_entry_active + target != null and _event_matches_role( event, ControllerMappingManagerType.ROLE_B, JOY_BUTTON_B, ) ): - _close_native_keyboard(focused) + _close_native_keyboard() get_viewport().set_input_as_handled() return if ( @@ -298,31 +300,168 @@ func _handle_native_keyboard_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return var key_event := event as InputEventKey - if key_event == null or not key_event.pressed or not native_entry_active: + if key_event == null or not key_event.pressed: return - if key_event.keycode == KEY_ESCAPE: - _close_native_keyboard(focused) + if ( + target != null + and focused != target + and _can_edit(focused) + and bool(focused.get("virtual_keyboard_enabled")) + ): + _begin_native_session(focused) + target = focused + # Touch-focused fields initially use Godot's automatic Android keyboard. + # Adopt that keyboard before the first edit is delivered so a transient + # Control focus change cannot make LineEdit/TextEdit hide it. + if ( + target == null + and _can_edit(focused) + and bool(focused.get("virtual_keyboard_enabled")) + ): + _begin_native_session(focused) + target = focused + if target == null: + return + if ( + key_event.keycode == KEY_ESCAPE + or event.is_action_pressed(&"ui_cancel") + ): + _close_native_keyboard() get_viewport().set_input_as_handled() return if key_event.keycode in [KEY_ENTER, KEY_KP_ENTER]: + _close_native_keyboard.call_deferred() return + # Android's hidden EditText delivers edits as key events. Keep their Godot + # destination stable even if a menu refresh briefly moved GUI focus. + if not target.has_focus(): + target.grab_focus() + + +func _exit_tree() -> void: + _end_native_session(true) func _open_native_keyboard_for(control: Control) -> void: if not _can_edit(control): return - control.set("virtual_keyboard_enabled", true) - if control.has_focus(): - control.release_focus() - control.call_deferred("grab_focus") + _begin_native_session(control) + if not control.has_focus(): + control.grab_focus() + _show_native_keyboard_for(control) -func _close_native_keyboard(control: Control) -> void: - if is_instance_valid(control) and control.has_focus(): - control.release_focus() +func _begin_native_session(control: Control) -> void: + if _native_target == control: + return + _end_native_session(false) + _native_target = control + _native_target_virtual_keyboard_enabled = bool( + control.get("virtual_keyboard_enabled") + ) + _native_target_virtual_keyboard_show_on_focus = bool( + control.get("virtual_keyboard_show_on_focus") + ) + # The session owns keyboard visibility. Leaving this enabled would make a + # Control focus exit call virtual_keyboard_hide() behind our back. + control.set("virtual_keyboard_enabled", false) + control.set("virtual_keyboard_show_on_focus", false) + + +func _close_native_keyboard() -> void: + var target: Control = _native_session_target() + _end_native_session(true) + if target != null and target.has_focus(): + target.release_focus() + + +func _end_native_session(hide_keyboard: bool) -> void: + var target: Control = _native_target + _native_target = null + if target != null and is_instance_valid(target): + target.set( + "virtual_keyboard_enabled", + _native_target_virtual_keyboard_enabled, + ) + target.set( + "virtual_keyboard_show_on_focus", + _native_target_virtual_keyboard_show_on_focus, + ) + if hide_keyboard: + _hide_native_keyboard() + + +func _native_session_target() -> Control: + if _native_target == null: + return null + if _can_edit(_native_target): + return _native_target + _end_native_session(true) + return null + + +func _show_native_keyboard_for(control: Control) -> void: + var keyboard_type: DisplayServer.VirtualKeyboardType = ( + DisplayServer.KEYBOARD_TYPE_DEFAULT + ) + var max_length: int = -1 + var caret_start: int = -1 + var caret_end: int = -1 + if control is LineEdit: + var line_edit := control as LineEdit + keyboard_type = _line_edit_keyboard_type(line_edit) + max_length = line_edit.max_length + caret_start = line_edit.caret_column + if line_edit.has_selection(): + caret_start = line_edit.get_selection_from_column() + caret_end = line_edit.get_selection_to_column() + elif control is TextEdit: + var text_edit := control as TextEdit + keyboard_type = DisplayServer.KEYBOARD_TYPE_MULTILINE + caret_start = _text_edit_caret_offset(text_edit) + DisplayServer.virtual_keyboard_show( + str(control.get("text")), + control.get_global_rect(), + keyboard_type, + max_length, + caret_start, + caret_end, + ) + + +func _hide_native_keyboard() -> void: DisplayServer.virtual_keyboard_hide() +static func _line_edit_keyboard_type( + line_edit: LineEdit, +) -> DisplayServer.VirtualKeyboardType: + match line_edit.virtual_keyboard_type: + LineEdit.KEYBOARD_TYPE_MULTILINE: + return DisplayServer.KEYBOARD_TYPE_MULTILINE + LineEdit.KEYBOARD_TYPE_NUMBER: + return DisplayServer.KEYBOARD_TYPE_NUMBER + LineEdit.KEYBOARD_TYPE_NUMBER_DECIMAL: + return DisplayServer.KEYBOARD_TYPE_NUMBER_DECIMAL + LineEdit.KEYBOARD_TYPE_PHONE: + return DisplayServer.KEYBOARD_TYPE_PHONE + LineEdit.KEYBOARD_TYPE_EMAIL_ADDRESS: + return DisplayServer.KEYBOARD_TYPE_EMAIL_ADDRESS + LineEdit.KEYBOARD_TYPE_PASSWORD: + return DisplayServer.KEYBOARD_TYPE_PASSWORD + LineEdit.KEYBOARD_TYPE_URL: + return DisplayServer.KEYBOARD_TYPE_URL + return DisplayServer.KEYBOARD_TYPE_DEFAULT + + +static func _text_edit_caret_offset(text_edit: TextEdit) -> int: + var caret_line: int = text_edit.get_caret_line() + var offset: int = 0 + for line_index: int in caret_line: + offset += text_edit.get_line(line_index).length() + 1 + return offset + text_edit.get_caret_column() + + func _can_edit(control: Control) -> bool: if ( control == null