981 lines
29 KiB
GDScript
981 lines
29 KiB
GDScript
class_name OnScreenKeyboard
|
|
extends Control
|
|
|
|
const ControllerFocusNavigationType = preload(
|
|
"res://ui/controller_focus_navigation.gd"
|
|
)
|
|
const ControllerMappingManagerType = preload(
|
|
"res://settings/controller_mapping_manager.gd"
|
|
)
|
|
const CHECK_ICON: Texture2D = preload(
|
|
"res://ui/icons/pictograms/check_mark_dark.png"
|
|
)
|
|
const CHARACTER_KEY_SIZE: Vector2 = Vector2(96.0, 72.0)
|
|
const CHARACTER_FONT_SIZE: int = 34
|
|
const CANONICAL_WINDOW_SIZE: Vector2 = Vector2(1280.0, 720.0)
|
|
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)
|
|
|
|
enum Page {
|
|
LOWER,
|
|
UPPER,
|
|
SYMBOLS,
|
|
}
|
|
|
|
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
|
|
var _buffer: String = ""
|
|
var _buffer_caret: int = 0
|
|
var _caret_blink_elapsed: float = 0.0
|
|
var _caret_visible: bool = true
|
|
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
|
|
var _space_button: Button
|
|
var _check_button: Button
|
|
var _left_trigger_pressed: bool = false
|
|
var _right_trigger_pressed: bool = false
|
|
var _portable_host_parent: Node
|
|
var _portable_host_index: int = -1
|
|
var _portable_target_window: Window
|
|
var _portable_target_window_size: Vector2i
|
|
var _portable_target_window_position: Vector2i
|
|
|
|
|
|
func _ready() -> void:
|
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
z_index = 1000
|
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
|
_build_interface()
|
|
hide()
|
|
set_process_input(true)
|
|
set_process(true)
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if not visible:
|
|
return
|
|
_recover_key_focus()
|
|
_caret_blink_elapsed += delta
|
|
if _caret_blink_elapsed < CARET_BLINK_INTERVAL:
|
|
return
|
|
_caret_blink_elapsed = fmod(
|
|
_caret_blink_elapsed,
|
|
CARET_BLINK_INTERVAL,
|
|
)
|
|
_caret_visible = not _caret_visible
|
|
_refresh_preview()
|
|
|
|
|
|
func set_enabled(enabled: bool) -> void:
|
|
_enabled = enabled
|
|
if not _is_available_for_controller() and visible:
|
|
_close_keyboard(false)
|
|
|
|
|
|
func is_enabled() -> bool:
|
|
return _is_available_for_controller()
|
|
|
|
|
|
func is_open() -> bool:
|
|
# 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(
|
|
mapping_manager: ControllerMappingManagerType,
|
|
) -> void:
|
|
_controller_mapping_manager = mapping_manager
|
|
|
|
|
|
func request_for_focused_control() -> bool:
|
|
return request_for_control(get_viewport().gui_get_focus_owner())
|
|
|
|
|
|
func request_for_control(control: Control = null) -> bool:
|
|
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:
|
|
var joy_motion := event as InputEventJoypadMotion
|
|
if joy_motion != null:
|
|
if not _handle_trigger_shortcut(joy_motion):
|
|
_move_key_focus(_controller_direction(event))
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
var joy_button := event as InputEventJoypadButton
|
|
if joy_button != null:
|
|
if joy_button.pressed:
|
|
# Resolve the automapper's logical roles before raw button numbers.
|
|
# Handheld mappings do not necessarily report their labeled A/B/X
|
|
# buttons using Godot's matching physical button constants.
|
|
if _event_matches_role(
|
|
event,
|
|
ControllerMappingManagerType.ROLE_X,
|
|
JOY_BUTTON_X,
|
|
):
|
|
_backspace()
|
|
elif _event_matches_role(
|
|
event,
|
|
ControllerMappingManagerType.ROLE_LB,
|
|
JOY_BUTTON_LEFT_SHOULDER,
|
|
):
|
|
_set_page(wrapi(int(_page) - 1, 0, Page.size()))
|
|
elif _event_matches_role(
|
|
event,
|
|
ControllerMappingManagerType.ROLE_RB,
|
|
JOY_BUTTON_RIGHT_SHOULDER,
|
|
):
|
|
_set_page(wrapi(int(_page) + 1, 0, Page.size()))
|
|
elif _event_matches_role(
|
|
event,
|
|
ControllerMappingManagerType.ROLE_A,
|
|
JOY_BUTTON_A,
|
|
):
|
|
_activate_focused_key()
|
|
elif _event_matches_role(
|
|
event,
|
|
ControllerMappingManagerType.ROLE_B,
|
|
JOY_BUTTON_B,
|
|
):
|
|
_close_keyboard(true)
|
|
else:
|
|
_move_key_focus(_controller_direction(event))
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
if event.is_action_pressed(&"ui_cancel"):
|
|
_close_keyboard(true)
|
|
get_viewport().set_input_as_handled()
|
|
return
|
|
var joy_event := event as InputEventJoypadButton
|
|
if (
|
|
joy_event == null
|
|
or not joy_event.pressed
|
|
or (
|
|
not _event_matches_role(
|
|
event,
|
|
ControllerMappingManagerType.ROLE_A,
|
|
JOY_BUTTON_A,
|
|
)
|
|
and (
|
|
_controller_mapping_manager != null
|
|
or not event.is_action_pressed(&"ui_accept")
|
|
)
|
|
)
|
|
):
|
|
return
|
|
if request_for_focused_control():
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func _event_matches_role(
|
|
event: InputEvent,
|
|
role: StringName,
|
|
fallback_button: JoyButton,
|
|
) -> bool:
|
|
if _controller_mapping_manager != null:
|
|
return _controller_mapping_manager.event_matches_role(event, role)
|
|
var button := event as InputEventJoypadButton
|
|
return button != null and button.button_index == fallback_button
|
|
|
|
|
|
func _is_available_for_controller() -> bool:
|
|
return should_enable_for_controller(
|
|
_enabled,
|
|
DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD),
|
|
OS.get_name(),
|
|
)
|
|
|
|
|
|
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,
|
|
platform_name: String = "",
|
|
) -> bool:
|
|
# Desktop display backends can advertise native virtual-keyboard support
|
|
# without presenting one for controller focus. Only Android delegates to
|
|
# that platform keyboard by default.
|
|
var resolved_platform: String = (
|
|
OS.get_name() if platform_name.is_empty() else platform_name
|
|
)
|
|
return (
|
|
preference_enabled
|
|
or resolved_platform != "Android"
|
|
or not native_virtual_keyboard_available
|
|
)
|
|
|
|
|
|
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)
|
|
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 target: Control = _native_session_target()
|
|
var joy_button := event as InputEventJoypadButton
|
|
if joy_button != null and joy_button.pressed:
|
|
if (
|
|
target != null
|
|
and _event_matches_role(
|
|
event,
|
|
ControllerMappingManagerType.ROLE_B,
|
|
JOY_BUTTON_B,
|
|
)
|
|
):
|
|
_close_native_keyboard()
|
|
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:
|
|
return
|
|
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
|
|
_begin_native_session(control)
|
|
if not control.has_focus():
|
|
control.grab_focus()
|
|
_show_native_keyboard_for(control)
|
|
|
|
|
|
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:
|
|
if DisplayServer.has_feature(DisplayServer.FEATURE_VIRTUAL_KEYBOARD):
|
|
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
|
|
or not is_instance_valid(control)
|
|
or not control.is_inside_tree()
|
|
or not control.is_visible_in_tree()
|
|
):
|
|
return false
|
|
if control is LineEdit:
|
|
return (control as LineEdit).editable
|
|
if control is TextEdit:
|
|
return (control as TextEdit).editable
|
|
return false
|
|
|
|
|
|
func _open_for(control: Control) -> void:
|
|
_target = control
|
|
_target_virtual_keyboard_enabled = bool(
|
|
_target.get("virtual_keyboard_enabled")
|
|
)
|
|
_target.set("virtual_keyboard_enabled", false)
|
|
_buffer = str(_target.get("text"))
|
|
_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()
|
|
_refresh_preview()
|
|
_rebuild_keys()
|
|
|
|
|
|
func _close_keyboard(restore_focus: bool) -> void:
|
|
var prior_target: Control = _target
|
|
if is_instance_valid(prior_target):
|
|
prior_target.set(
|
|
"virtual_keyboard_enabled",
|
|
_target_virtual_keyboard_enabled
|
|
)
|
|
hide()
|
|
_left_trigger_pressed = false
|
|
_right_trigger_pressed = false
|
|
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()
|
|
|
|
|
|
func _attach_to_target_window(control: Control) -> void:
|
|
var target_window: Window = control.get_window()
|
|
if target_window == null or target_window == get_window():
|
|
return
|
|
_portable_host_parent = get_parent()
|
|
_portable_host_index = get_index()
|
|
_portable_target_window = target_window
|
|
_portable_target_window_size = target_window.size
|
|
_portable_target_window_position = target_window.position
|
|
var parent_window := target_window.get_parent() as Window
|
|
if parent_window != null:
|
|
target_window.size = parent_window.size
|
|
target_window.position = parent_window.position
|
|
reparent(target_window, false)
|
|
anchor_left = 0.0
|
|
anchor_top = 0.0
|
|
anchor_right = 0.0
|
|
anchor_bottom = 0.0
|
|
size = CANONICAL_WINDOW_SIZE
|
|
var available: Vector2 = Vector2(target_window.size)
|
|
var fit_scale: float = minf(
|
|
available.x / CANONICAL_WINDOW_SIZE.x,
|
|
available.y / CANONICAL_WINDOW_SIZE.y,
|
|
)
|
|
scale = Vector2.ONE * maxf(fit_scale, 0.01)
|
|
position = (available - CANONICAL_WINDOW_SIZE * fit_scale) * 0.5
|
|
|
|
|
|
func _restore_portable_host() -> void:
|
|
if _portable_host_parent == null:
|
|
return
|
|
var original_parent: Node = _portable_host_parent
|
|
var original_index: int = _portable_host_index
|
|
var target_window: Window = _portable_target_window
|
|
_portable_host_parent = null
|
|
_portable_host_index = -1
|
|
_portable_target_window = null
|
|
reparent(original_parent, false)
|
|
if original_index >= 0:
|
|
original_parent.move_child(
|
|
self,
|
|
mini(original_index, original_parent.get_child_count() - 1),
|
|
)
|
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
scale = Vector2.ONE
|
|
if target_window != null and is_instance_valid(target_window):
|
|
target_window.size = _portable_target_window_size
|
|
target_window.position = _portable_target_window_position
|
|
|
|
|
|
func _submit() -> void:
|
|
var submitted_target: Control = _target
|
|
var submitted_text: String = _buffer
|
|
_close_keyboard(false)
|
|
if submitted_target is LineEdit:
|
|
(submitted_target as LineEdit).text_submitted.emit(submitted_text)
|
|
elif submitted_target is TextEdit:
|
|
(submitted_target as TextEdit).text_changed.emit()
|
|
text_submitted.emit(submitted_text)
|
|
|
|
|
|
func _type_character(character: String) -> void:
|
|
if not is_instance_valid(_target):
|
|
_close_keyboard(false)
|
|
return
|
|
var caret: int = _get_caret_column()
|
|
var next_text: String = (
|
|
_buffer.substr(0, caret)
|
|
+ character
|
|
+ _buffer.substr(caret)
|
|
)
|
|
if _target is LineEdit:
|
|
var line_edit := _target as LineEdit
|
|
if line_edit.max_length > 0 and next_text.length() > line_edit.max_length:
|
|
return
|
|
_buffer = next_text
|
|
_set_target_text(caret + character.length())
|
|
|
|
|
|
func _type_space() -> void:
|
|
_type_character(" ")
|
|
|
|
|
|
func _backspace() -> void:
|
|
if not is_instance_valid(_target) or _buffer.is_empty():
|
|
return
|
|
var caret: int = _get_caret_column()
|
|
if caret <= 0:
|
|
return
|
|
_buffer = _buffer.erase(caret - 1, 1)
|
|
_set_target_text(caret - 1)
|
|
|
|
|
|
func _move_caret(direction: int) -> void:
|
|
if not is_instance_valid(_target):
|
|
_close_keyboard(false)
|
|
return
|
|
_buffer_caret = clampi(
|
|
_get_caret_column() + direction,
|
|
0,
|
|
_buffer.length(),
|
|
)
|
|
_set_target_caret(_buffer_caret)
|
|
_reset_caret_blink()
|
|
_refresh_preview()
|
|
|
|
|
|
func _handle_trigger_shortcut(event: InputEventJoypadMotion) -> bool:
|
|
if event.axis == JOY_AXIS_TRIGGER_LEFT:
|
|
if event.axis_value <= TRIGGER_RELEASE_THRESHOLD:
|
|
_left_trigger_pressed = false
|
|
elif (
|
|
event.axis_value >= TRIGGER_PRESS_THRESHOLD
|
|
and not _left_trigger_pressed
|
|
):
|
|
_left_trigger_pressed = true
|
|
_move_caret(-1)
|
|
return true
|
|
elif event.axis == JOY_AXIS_TRIGGER_RIGHT:
|
|
if event.axis_value <= TRIGGER_RELEASE_THRESHOLD:
|
|
_right_trigger_pressed = false
|
|
elif (
|
|
event.axis_value >= TRIGGER_PRESS_THRESHOLD
|
|
and not _right_trigger_pressed
|
|
):
|
|
_right_trigger_pressed = true
|
|
_move_caret(1)
|
|
return true
|
|
return false
|
|
|
|
|
|
func _get_caret_column() -> int:
|
|
return clampi(_buffer_caret, 0, _buffer.length())
|
|
|
|
|
|
func _set_target_text(caret: int) -> void:
|
|
_buffer_caret = clampi(caret, 0, _buffer.length())
|
|
if _target is LineEdit:
|
|
var line_edit := _target as LineEdit
|
|
line_edit.text = _buffer
|
|
line_edit.text_changed.emit(_buffer)
|
|
elif _target is TextEdit:
|
|
var text_edit := _target as TextEdit
|
|
text_edit.text = _buffer
|
|
text_edit.text_changed.emit()
|
|
_set_target_caret(_buffer_caret)
|
|
_reset_caret_blink()
|
|
_refresh_preview()
|
|
|
|
|
|
func _set_target_caret(caret: int) -> void:
|
|
if _target is LineEdit:
|
|
(_target as LineEdit).caret_column = caret
|
|
elif _target is TextEdit:
|
|
var text_edit := _target as TextEdit
|
|
var text_before_caret: String = _buffer.substr(0, caret)
|
|
var caret_line: int = text_before_caret.count("\n")
|
|
var last_newline: int = text_before_caret.rfind("\n")
|
|
var caret_column: int = (
|
|
caret if last_newline < 0 else caret - last_newline - 1
|
|
)
|
|
text_edit.set_caret_line(caret_line)
|
|
text_edit.set_caret_column(caret_column)
|
|
|
|
|
|
func _reset_caret_blink() -> void:
|
|
_caret_blink_elapsed = 0.0
|
|
_caret_visible = true
|
|
|
|
|
|
func _refresh_preview() -> void:
|
|
if _preview == null:
|
|
return
|
|
var displayed_text: String = _buffer
|
|
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_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:
|
|
if event.is_action_pressed(&"ui_up"):
|
|
return Vector2.UP
|
|
if event.is_action_pressed(&"ui_down"):
|
|
return Vector2.DOWN
|
|
if event.is_action_pressed(&"ui_left"):
|
|
return Vector2.LEFT
|
|
if event.is_action_pressed(&"ui_right"):
|
|
return Vector2.RIGHT
|
|
return Vector2.ZERO
|
|
|
|
|
|
func _move_key_focus(direction: Vector2) -> void:
|
|
if direction == Vector2.ZERO:
|
|
return
|
|
var focused: Control = get_viewport().gui_get_focus_owner()
|
|
if focused == null or not is_ancestor_of(focused):
|
|
focused = _recover_key_focus()
|
|
if focused == null:
|
|
return
|
|
var neighbor_path := NodePath()
|
|
if direction == Vector2.UP:
|
|
neighbor_path = focused.focus_neighbor_top
|
|
elif direction == Vector2.DOWN:
|
|
neighbor_path = focused.focus_neighbor_bottom
|
|
elif direction == Vector2.LEFT:
|
|
neighbor_path = focused.focus_neighbor_left
|
|
elif direction == Vector2.RIGHT:
|
|
neighbor_path = focused.focus_neighbor_right
|
|
if neighbor_path.is_empty():
|
|
return
|
|
var neighbor := focused.get_node_or_null(neighbor_path) as Control
|
|
if ControllerFocusNavigationType.is_focusable(neighbor):
|
|
neighbor.grab_focus()
|
|
|
|
|
|
func _activate_focused_key() -> void:
|
|
var focused: Control = get_viewport().gui_get_focus_owner()
|
|
if focused == null or not is_ancestor_of(focused):
|
|
focused = _recover_key_focus()
|
|
var button := focused as BaseButton
|
|
if button != null and not button.disabled:
|
|
button.pressed.emit()
|
|
|
|
|
|
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()
|
|
|
|
|
|
func _rebuild_keys() -> void:
|
|
for child: Node in _keys_host.get_children():
|
|
_keys_host.remove_child(child)
|
|
child.queue_free()
|
|
_key_buttons.clear()
|
|
var rows: Array = _rows_for_page()
|
|
for row_value: Variant in rows:
|
|
var row := HBoxContainer.new()
|
|
row.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
row.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
row.add_theme_constant_override("separation", 10)
|
|
_keys_host.add_child(row)
|
|
for character_value: Variant in row_value as Array:
|
|
var character: String = str(character_value)
|
|
var key_button: Button = _make_button(character, CHARACTER_KEY_SIZE)
|
|
key_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
|
key_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
key_button.add_theme_font_size_override(
|
|
"font_size", CHARACTER_FONT_SIZE
|
|
)
|
|
key_button.pressed.connect(_type_character.bind(character))
|
|
row.add_child(key_button)
|
|
_key_buttons.append(key_button)
|
|
for index: int in _page_buttons.size():
|
|
_page_buttons[index].button_pressed = index == int(_page)
|
|
call_deferred("_configure_key_focus")
|
|
|
|
|
|
func _configure_key_focus() -> void:
|
|
var focus_controls: Array[Control] = []
|
|
for button: Button in _key_buttons:
|
|
focus_controls.append(button)
|
|
for button: Button in [
|
|
_caret_left_button,
|
|
_caret_right_button,
|
|
_backspace_button,
|
|
_space_button,
|
|
_check_button,
|
|
]:
|
|
focus_controls.append(button)
|
|
ControllerFocusNavigationType.configure_spatial_neighbors(focus_controls)
|
|
if not _key_buttons.is_empty():
|
|
_key_buttons[0].grab_focus()
|
|
|
|
|
|
func _rows_for_page() -> Array:
|
|
match _page:
|
|
Page.UPPER:
|
|
return [
|
|
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
|
|
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
|
|
["Z", "X", "C", "V", "B", "N", "M"],
|
|
]
|
|
Page.SYMBOLS:
|
|
return [
|
|
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
|
|
["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"],
|
|
["-", "_", "=", "+", "[", "]", "{", "}"],
|
|
[".", ",", "?", "/", ":", ";", "'", "\""]
|
|
]
|
|
_:
|
|
return [
|
|
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
|
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
|
|
["z", "x", "c", "v", "b", "n", "m"],
|
|
]
|
|
|
|
|
|
func _build_interface() -> void:
|
|
var backdrop := ColorRect.new()
|
|
backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
backdrop.color = Color(0.012, 0.075, 0.105, 0.82)
|
|
backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
add_child(backdrop)
|
|
var screen_margin := MarginContainer.new()
|
|
screen_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
for side: StringName in [
|
|
&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"
|
|
]:
|
|
screen_margin.add_theme_constant_override(side, 14)
|
|
add_child(screen_margin)
|
|
var panel := PanelContainer.new()
|
|
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
panel.add_theme_stylebox_override(
|
|
"panel",
|
|
_make_style(Color(0.075, 0.27, 0.34, 0.98), 18, 4)
|
|
)
|
|
screen_margin.add_child(panel)
|
|
var margin := MarginContainer.new()
|
|
for side: StringName in [&"margin_left", &"margin_top", &"margin_right", &"margin_bottom"]:
|
|
margin.add_theme_constant_override(side, 18)
|
|
panel.add_child(margin)
|
|
var layout := VBoxContainer.new()
|
|
layout.add_theme_constant_override("separation", 10)
|
|
margin.add_child(layout)
|
|
var preview_panel := PanelContainer.new()
|
|
preview_panel.custom_minimum_size = Vector2(0.0, 86.0)
|
|
preview_panel.add_theme_stylebox_override(
|
|
"panel",
|
|
_make_style(Color(0.82, 0.94, 0.95, 1.0), 10, 2)
|
|
)
|
|
layout.add_child(preview_panel)
|
|
_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.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
|
|
page_row.add_theme_constant_override("separation", 12)
|
|
layout.add_child(page_row)
|
|
for page_name: String in ["lower", "upper", "symbols"]:
|
|
var page_button: Button = _make_button(page_name, Vector2(150.0, 44.0))
|
|
page_button.toggle_mode = true
|
|
page_button.focus_mode = Control.FOCUS_NONE
|
|
page_button.pressed.connect(_set_page.bind(_page_buttons.size()))
|
|
page_row.add_child(page_button)
|
|
_page_buttons.append(page_button)
|
|
_keys_host = VBoxContainer.new()
|
|
_keys_host.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
_keys_host.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
_keys_host.add_theme_constant_override("separation", 12)
|
|
layout.add_child(_keys_host)
|
|
var utility_row := HBoxContainer.new()
|
|
utility_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
utility_row.add_theme_constant_override("separation", 12)
|
|
layout.add_child(utility_row)
|
|
_caret_left_button = _make_button("LT <", Vector2(118.0, 64.0))
|
|
_caret_left_button.tooltip_text = "move text cursor left"
|
|
_caret_left_button.pressed.connect(_move_caret.bind(-1))
|
|
utility_row.add_child(_caret_left_button)
|
|
_caret_right_button = _make_button("> RT", Vector2(118.0, 64.0))
|
|
_caret_right_button.tooltip_text = "move text cursor right"
|
|
_caret_right_button.pressed.connect(_move_caret.bind(1))
|
|
utility_row.add_child(_caret_right_button)
|
|
_backspace_button = _make_button("backspace X", Vector2(180.0, 64.0))
|
|
_backspace_button.pressed.connect(_backspace)
|
|
utility_row.add_child(_backspace_button)
|
|
_space_button = _make_button("space", Vector2(350.0, 64.0))
|
|
_space_button.size_flags_stretch_ratio = 4.0
|
|
_space_button.pressed.connect(_type_space)
|
|
utility_row.add_child(_space_button)
|
|
_check_button = _make_button("", Vector2(92.0, 64.0))
|
|
_check_button.icon = CHECK_ICON
|
|
_check_button.expand_icon = true
|
|
_check_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
_check_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
_check_button.vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
_check_button.tooltip_text = "submit"
|
|
_check_button.accessibility_name = "submit text"
|
|
_check_button.add_theme_constant_override("icon_max_width", 42)
|
|
_check_button.pressed.connect(_submit)
|
|
utility_row.add_child(_check_button)
|
|
_rebuild_keys()
|
|
|
|
|
|
func _make_button(label: String, minimum_size: Vector2) -> Button:
|
|
var button := Button.new()
|
|
button.text = label
|
|
button.custom_minimum_size = minimum_size
|
|
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
button.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
button.focus_mode = Control.FOCUS_ALL
|
|
button.add_theme_font_size_override("font_size", 22)
|
|
button.add_theme_color_override("font_color", Color(0.025, 0.12, 0.17, 1.0))
|
|
button.add_theme_color_override("font_hover_color", Color(0.025, 0.12, 0.17, 1.0))
|
|
button.add_theme_color_override("font_focus_color", Color(0.025, 0.12, 0.17, 1.0))
|
|
button.add_theme_stylebox_override(
|
|
"normal",
|
|
_make_style(Color(0.72, 0.88, 0.91, 1.0), 10, 2)
|
|
)
|
|
button.add_theme_stylebox_override(
|
|
"hover",
|
|
_make_style(Color(0.87, 0.96, 0.96, 1.0), 10, 3)
|
|
)
|
|
button.add_theme_stylebox_override("focus", button.get_theme_stylebox("hover"))
|
|
button.add_theme_stylebox_override(
|
|
"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
|
|
|
|
|
|
func _make_style(color: Color, radius: int, border_width: int) -> StyleBoxFlat:
|
|
var style := StyleBoxFlat.new()
|
|
style.bg_color = color
|
|
style.border_color = Color(0.025, 0.12, 0.17, 1.0)
|
|
style.set_border_width_all(border_width)
|
|
style.set_corner_radius_all(radius)
|
|
return style
|