diff --git a/tests/on_screen_keyboard_validation.gd b/tests/on_screen_keyboard_validation.gd index 822fb37..5454a65 100644 --- a/tests/on_screen_keyboard_validation.gd +++ b/tests/on_screen_keyboard_validation.gd @@ -21,6 +21,14 @@ class ControllerInputProbe: pressed_buttons.append(button.button_index) +class NativeKeyboardProbe: + extends OnScreenKeyboard + + + func _uses_native_virtual_keyboard() -> bool: + return true + + func _initialize() -> void: call_deferred("_run") @@ -29,6 +37,7 @@ func _run() -> void: _validate_default_and_persistence() await _validate_keyboard_entry() await _validate_automapped_face_buttons() + await _validate_native_keyboard() print("On-screen keyboard validation: PASS") quit() @@ -48,6 +57,21 @@ func _validate_default_and_persistence() -> void: assert( KeyboardType.should_enable_for_controller(true, true, "Android") ) + assert( + KeyboardType.should_use_native_virtual_keyboard( + false, true, "Android" + ) + ) + assert( + not KeyboardType.should_use_native_virtual_keyboard( + true, true, "Android" + ) + ) + assert( + not KeyboardType.should_use_native_virtual_keyboard( + false, true, "Linux" + ) + ) var manager := SettingsManagerType.new() root.add_child(manager) assert(manager.load_settings()) @@ -91,7 +115,9 @@ func _validate_keyboard_entry() -> void: assert(edit.caret_column == edit.text.length()) assert(keyboard.get("_buffer_caret") == edit.text.length()) assert( - str((keyboard.get("_preview") as Label).text).begins_with("seed") + str( + (keyboard.get("_preview") as RichTextLabel).get_parsed_text() + ).begins_with("seed") ) var left_page_event := InputEventJoypadButton.new() left_page_event.button_index = JOY_BUTTON_LEFT_SHOULDER @@ -109,13 +135,21 @@ func _validate_keyboard_entry() -> void: await process_frame var focused_key := root.gui_get_focus_owner() as Button assert(focused_key != null and focused_key.text == "q") + var second_key := keyboard.get("_key_buttons")[1] as Button + second_key.grab_focus() + var outside_button := Button.new() + host.add_child(outside_button) + outside_button.grab_focus() + assert(root.gui_get_focus_owner() == outside_button) + keyboard.call("_process", 0.0) + assert(root.gui_get_focus_owner() == second_key) var conflicting_cancel_binding := InputEventJoypadButton.new() conflicting_cancel_binding.button_index = JOY_BUTTON_A InputMap.action_add_event(&"ui_cancel", conflicting_cancel_binding) keyboard.call("_input", activate_event) InputMap.action_erase_event(&"ui_cancel", conflicting_cancel_binding) assert(keyboard.is_open()) - assert(edit.text == "seedq") + assert(edit.text == "seedw") var left_trigger := InputEventJoypadMotion.new() left_trigger.axis = JOY_AXIS_TRIGGER_LEFT left_trigger.axis_value = 1.0 @@ -126,20 +160,23 @@ func _validate_keyboard_entry() -> void: right_trigger.axis_value = 1.0 keyboard.call("_input", right_trigger) assert(keyboard.get("_buffer_caret") == edit.text.length()) + var preview := keyboard.get("_preview") as RichTextLabel + var preview_height_before_blink: float = preview.get_content_height() + var preview_text_before_blink: String = preview.get_parsed_text() keyboard.call("_process", KeyboardType.CARET_BLINK_INTERVAL) assert( - not str((keyboard.get("_preview") as Label).text).contains( - KeyboardType.CARET_GLYPH - ) + not bool(keyboard.get("_caret_visible")) ) + assert(preview.get_parsed_text() == preview_text_before_blink) + assert(is_equal_approx(preview.get_content_height(), preview_height_before_blink)) keyboard.call("_type_character", "a") keyboard.call("_type_space") keyboard.call("_set_page", KeyboardType.Page.UPPER) keyboard.call("_type_character", "B") - assert(edit.text == "seedqa B") + assert(edit.text == "seedwa B") keyboard.call("_move_caret", -1) keyboard.call("_type_character", "C") - assert(edit.text == "seedqa CB") + assert(edit.text == "seedwa CB") var input_probe := ControllerInputProbe.new() root.add_child(input_probe) root.move_child(input_probe, 0) @@ -148,7 +185,7 @@ func _validate_keyboard_entry() -> void: backspace_event.pressed = true Input.parse_input_event(backspace_event) await process_frame - assert(edit.text == "seedqa B") + assert(edit.text == "seedwa B") assert(input_probe.pressed_buttons.is_empty()) var close_event := InputEventJoypadButton.new() close_event.button_index = JOY_BUTTON_B @@ -166,7 +203,28 @@ func _validate_keyboard_entry() -> void: ) keyboard.call("_submit") assert(not keyboard.is_open()) - assert(submitted == ["seedqa B"]) + assert(submitted == ["seedwa B"]) + var letter_body := TextEdit.new() + letter_body.text = "letter" + letter_body.focus_mode = Control.FOCUS_ALL + host.add_child(letter_body) + letter_body.text_changed.connect(func() -> void: + outside_button.call_deferred("grab_focus") + ) + letter_body.grab_focus() + keyboard.call("_input", activate_event) + assert(keyboard.is_open()) + for _frame: int in 2: + await process_frame + var letter_key := keyboard.get("_key_buttons")[2] as Button + letter_key.grab_focus() + keyboard.call("_input", activate_event) + for _frame: int in 2: + await process_frame + assert(letter_body.text == "lettere") + assert(root.gui_get_focus_owner() == letter_key) + keyboard.call("_input", close_event) + assert(not keyboard.is_open()) host.queue_free() @@ -220,3 +278,51 @@ func _validate_automapped_face_buttons() -> void: assert(not keyboard.is_open()) host.queue_free() manager.queue_free() + + +func _validate_native_keyboard() -> void: + var host := Control.new() + root.add_child(host) + var edit := LineEdit.new() + edit.focus_mode = Control.FOCUS_ALL + edit.virtual_keyboard_enabled = true + host.add_child(edit) + var outside_button := Button.new() + host.add_child(outside_button) + var keyboard := NativeKeyboardProbe.new() + host.add_child(keyboard) + await process_frame + edit.grab_focus() + assert(keyboard.is_open()) + var character_event := InputEventKey.new() + character_event.keycode = KEY_A + character_event.unicode = 97 + character_event.pressed = true + keyboard.call("_input", character_event) + outside_button.grab_focus() + await process_frame + assert(edit.has_focus()) + var backspace_event := InputEventKey.new() + backspace_event.keycode = KEY_BACKSPACE + backspace_event.pressed = true + keyboard.call("_input", backspace_event) + outside_button.grab_focus() + await process_frame + assert(edit.has_focus()) + 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(keyboard.request_for_control(edit)) + await process_frame + assert(edit.has_focus()) + assert(keyboard.is_open()) + 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()) + host.queue_free() diff --git a/ui/on_screen_keyboard.gd b/ui/on_screen_keyboard.gd index e1b870c..80f936b 100644 --- a/ui/on_screen_keyboard.gd +++ b/ui/on_screen_keyboard.gd @@ -17,6 +17,7 @@ const TRIGGER_PRESS_THRESHOLD: float = 0.55 const TRIGGER_RELEASE_THRESHOLD: float = 0.25 const CARET_BLINK_INTERVAL: float = 0.5 const CARET_GLYPH: String = "▌" +const PREVIEW_TEXT_COLOR: Color = Color(0.025, 0.12, 0.17, 1.0) signal text_submitted(value: String) @@ -35,10 +36,11 @@ var _buffer: String = "" var _buffer_caret: int = 0 var _caret_blink_elapsed: float = 0.0 var _caret_visible: bool = true -var _preview: Label +var _preview: RichTextLabel var _page_buttons: Array[Button] = [] var _keys_host: VBoxContainer var _key_buttons: Array[Button] = [] +var _last_focused_key: WeakRef var _caret_left_button: Button var _caret_right_button: Button var _backspace_button: Button @@ -67,6 +69,7 @@ func _ready() -> void: func _process(delta: float) -> void: if not visible: return + _recover_key_focus() _caret_blink_elapsed += delta if _caret_blink_elapsed < CARET_BLINK_INTERVAL: return @@ -89,7 +92,9 @@ func is_enabled() -> bool: func is_open() -> bool: - return visible + # Native Android text entry must block the same underlying menu and gameplay + # shortcuts as the custom controller keyboard. + return visible or _native_text_entry_is_active() func setup_controller_mapping( @@ -103,17 +108,21 @@ func request_for_focused_control() -> bool: func request_for_control(control: Control = null) -> bool: - if ( - not _is_available_for_controller() - or visible - or not _can_edit(control) - ): + if visible or not _can_edit(control): + return false + if _uses_native_virtual_keyboard(): + _open_native_keyboard_for(control) + return true + if not _is_available_for_controller(): return false _open_for(control) return true func _input(event: InputEvent) -> void: + if _uses_native_virtual_keyboard(): + _handle_native_keyboard_input(event) + return if not _is_available_for_controller(): return if visible: @@ -207,6 +216,29 @@ func _is_available_for_controller() -> bool: ) +func _uses_native_virtual_keyboard() -> bool: + return should_use_native_virtual_keyboard( + _enabled, + DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD), + OS.get_name(), + ) + + +static func should_use_native_virtual_keyboard( + preference_enabled: bool, + native_virtual_keyboard_available: bool, + platform_name: String = "", +) -> bool: + var resolved_platform: String = ( + OS.get_name() if platform_name.is_empty() else platform_name + ) + return ( + not preference_enabled + and native_virtual_keyboard_available + and resolved_platform == "Android" + ) + + static func should_enable_for_controller( preference_enabled: bool, native_virtual_keyboard_available: bool, @@ -225,6 +257,89 @@ static func should_enable_for_controller( ) +func _native_text_entry_is_active() -> bool: + if not _uses_native_virtual_keyboard(): + return false + var focused: Control = get_viewport().gui_get_focus_owner() + return ( + _can_edit(focused) + and bool(focused.get("virtual_keyboard_enabled")) + ) + + +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 joy_button := event as InputEventJoypadButton + if joy_button != null and joy_button.pressed: + if ( + native_entry_active + and _event_matches_role( + event, + ControllerMappingManagerType.ROLE_B, + JOY_BUTTON_B, + ) + ): + _close_native_keyboard(focused) + get_viewport().set_input_as_handled() + return + if ( + _event_matches_role( + event, + ControllerMappingManagerType.ROLE_A, + JOY_BUTTON_A, + ) + and _can_edit(focused) + ): + _open_native_keyboard_for(focused) + 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: + return + if key_event.keycode == KEY_ESCAPE: + _close_native_keyboard(focused) + get_viewport().set_input_as_handled() + return + if key_event.keycode in [KEY_ENTER, KEY_KP_ENTER]: + return + # Android delivers IME edits after this input phase. Reassert the same field + # afterward in case a page refresh tried to move focus while its text changed. + _preserve_native_text_focus.call_deferred(focused) + + +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") + + +func _close_native_keyboard(control: Control) -> void: + if is_instance_valid(control) and control.has_focus(): + control.release_focus() + DisplayServer.virtual_keyboard_hide() + + +func _preserve_native_text_focus(control: Control) -> void: + if ( + not _uses_native_virtual_keyboard() + or not is_instance_valid(control) + or not _can_edit(control) + or not control.is_inside_tree() + or not control.is_visible_in_tree() + or not bool(control.get("virtual_keyboard_enabled")) + ): + return + if not control.has_focus(): + control.grab_focus() + + func _can_edit(control: Control) -> bool: if control is LineEdit: return (control as LineEdit).editable @@ -243,6 +358,7 @@ func _open_for(control: Control) -> void: _buffer_caret = _buffer.length() _set_target_caret(_buffer_caret) _page = Page.LOWER + _last_focused_key = null _attach_to_target_window(control) show() _reset_caret_blink() @@ -263,6 +379,7 @@ func _close_keyboard(restore_focus: bool) -> void: get_viewport().gui_release_focus() _restore_portable_host() _target = null + _last_focused_key = null if restore_focus and is_instance_valid(prior_target): prior_target.grab_focus() @@ -444,8 +561,15 @@ func _refresh_preview() -> void: if _target is LineEdit and (_target as LineEdit).secret: displayed_text = "*".repeat(_buffer.length()) var caret: int = clampi(_get_caret_column(), 0, displayed_text.length()) - var caret_glyph: String = CARET_GLYPH if _caret_visible else "" - _preview.text = displayed_text.insert(caret, caret_glyph) + var caret_color: Color = PREVIEW_TEXT_COLOR + if not _caret_visible: + caret_color.a = 0.0 + _preview.clear() + _preview.add_text(displayed_text.substr(0, caret)) + _preview.push_color(caret_color) + _preview.add_text(CARET_GLYPH) + _preview.pop() + _preview.add_text(displayed_text.substr(caret)) func _controller_direction(event: InputEvent) -> Vector2: @@ -465,8 +589,9 @@ func _move_key_focus(direction: Vector2) -> void: return var focused: Control = get_viewport().gui_get_focus_owner() if focused == null or not is_ancestor_of(focused): - _focus_first_key() - return + focused = _recover_key_focus() + if focused == null: + return var neighbor_path := NodePath() if direction == Vector2.UP: neighbor_path = focused.focus_neighbor_top @@ -486,20 +611,42 @@ func _move_key_focus(direction: Vector2) -> void: func _activate_focused_key() -> void: var focused: Control = get_viewport().gui_get_focus_owner() if focused == null or not is_ancestor_of(focused): - _focus_first_key() - focused = get_viewport().gui_get_focus_owner() + focused = _recover_key_focus() var button := focused as BaseButton if button != null and not button.disabled: button.pressed.emit() -func _focus_first_key() -> void: - if not _key_buttons.is_empty(): - _key_buttons[0].grab_focus() +func _remember_key_focus(control: Control) -> void: + if visible and control != null and is_ancestor_of(control): + _last_focused_key = weakref(control) + + +func _recover_key_focus() -> Control: + var focused: Control = get_viewport().gui_get_focus_owner() + if focused != null and is_ancestor_of(focused): + return focused + var remembered: Control = null + if _last_focused_key != null: + remembered = _last_focused_key.get_ref() as Control + if ( + remembered == null + or not is_instance_valid(remembered) + or not remembered.is_inside_tree() + or not remembered.is_visible_in_tree() + or remembered.focus_mode == Control.FOCUS_NONE + ): + remembered = ( + _key_buttons.front() if not _key_buttons.is_empty() else null + ) + if remembered != null: + remembered.grab_focus() + return remembered func _set_page(page_index: int) -> void: _page = page_index as Page + _last_focused_key = null _rebuild_keys() @@ -606,13 +753,17 @@ func _build_interface() -> void: _make_style(Color(0.82, 0.94, 0.95, 1.0), 10, 2) ) layout.add_child(preview_panel) - _preview = Label.new() - _preview.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0)) - _preview.add_theme_font_size_override("font_size", 30) - _preview.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + _preview = RichTextLabel.new() + _preview.bbcode_enabled = true + _preview.fit_content = false + _preview.scroll_active = false + _preview.autowrap_mode = TextServer.AUTOWRAP_OFF + _preview.add_theme_color_override("default_color", PREVIEW_TEXT_COLOR) + _preview.add_theme_font_size_override("normal_font_size", 30) _preview.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - _preview.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _preview.clip_contents = true _preview.add_theme_constant_override("outline_size", 0) + _preview.mouse_filter = Control.MOUSE_FILTER_IGNORE preview_panel.add_child(_preview) var page_row := HBoxContainer.new() page_row.alignment = BoxContainer.ALIGNMENT_CENTER @@ -687,6 +838,7 @@ func _make_button(label: String, minimum_size: Vector2) -> Button: "pressed", _make_style(Color(0.44, 0.72, 0.77, 1.0), 10, 3) ) + button.focus_entered.connect(_remember_key_focus.bind(button)) return button