Stabilize custom and Android text entry
This commit is contained in:
parent
1aa77180a4
commit
5df8b2e7cc
2 changed files with 288 additions and 30 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue