fix: harden controller navigation and text entry

This commit is contained in:
Alexander Sellite 2026-08-16 16:40:10 -04:00
parent c4e49cdc52
commit 8ea877ecf4
28 changed files with 1320 additions and 96 deletions

View file

@ -770,6 +770,7 @@ func _initialize_application(dedicated: bool) -> void:
_interface_fonts,
)
_game_ui.setup_controller_mapping(_controller_mapping_manager)
_ui_pixelation.setup_controller_mapping(_controller_mapping_manager)
_game_ui.setup_keyboard_mouse_mapping(_keyboard_mouse_mapping_manager)
_data_root.conflict_detected.connect(_on_portable_conflict)
_data_root.status_changed.connect(_on_data_root_status)
@ -1224,6 +1225,10 @@ func _configure_popup_dialog(
func _input(event: InputEvent) -> void:
# Do not let controller aliases close or manipulate the world/menu behind
# the modal on-screen keyboard. The keyboard consumes the event itself.
if _game_ui.is_controller_text_entry_open():
return
if _handle_data_root_controller_input(event):
get_viewport().set_input_as_handled()
return

View file

@ -98,6 +98,7 @@ slow_walk={
interact={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":16,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":0,"button_index":3,"pressure":0.0,"pressed":false,"script":null)
]
}
fish_primary={

View file

@ -11,19 +11,23 @@ readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)"
readonly -a QUICK_TESTS=(
"scripts/validate_animalese_samples.gd"
"tests/android_readiness_validation.gd"
"tests/camera_drag_validation.gd"
"tests/controller_focus_presentation_validation.gd"
"tests/controller_focus_recovery_validation.gd"
"tests/controller_menu_accessibility_validation.gd"
"tests/controller_mapping_validation.gd"
"tests/controller_world_interaction_validation.gd"
"tests/controller_ui_navigation_validation.gd"
"tests/dedicated_server_config_validation.gd"
"tests/exported_decal_hotfix_validation.gd"
"tests/file_dialog_controller_navigation_validation.gd"
"tests/fish_catalog_content_validation.gd"
"tests/fish_quality_validation.gd"
"tests/fishing_shop_controller_validation.gd"
"tests/fishing_audio_validation.gd"
"tests/fishing_surface_validation.gd"
"tests/fur_pattern_validation.gd"
"tests/inventory_storage_validation.gd"
"tests/keyboard_mouse_mapping_validation.gd"
"tests/logbook_validation.gd"
"tests/network_player_animation_protocol_validation.gd"
@ -31,6 +35,7 @@ readonly -a QUICK_TESTS=(
"tests/player_experience_validation.gd"
"tests/shoreline_ambience_validation.gd"
"tests/surface_drawing_validation.gd"
"tests/tackle_order_validation.gd"
"tests/terrain_blender_material_validation.gd"
"tests/texture_sampling_validation.gd"
"tests/world_time_validation.gd"
@ -54,6 +59,7 @@ readonly -a HOST_TESTS=(
"tests/fish_hotbar_showcase_validation.gd"
"tests/fishing_authority_validation.gd"
"tests/gathering_showcase_validation.gd"
"tests/inventory_storage_persistence_validation.gd"
"tests/job_system_validation.gd"
"tests/surface_drawing_runtime_validation.gd"
)

View file

@ -80,7 +80,7 @@ const ROLE_LABELS: Dictionary = {
ROLE_A: "jump / menu accept",
ROLE_B: "menu back",
ROLE_X: "player menu",
ROLE_Y: "character call",
ROLE_Y: "interact / character call",
ROLE_LB: "focus chat or world",
ROLE_RB: "primary action",
ROLE_POINTER_MODIFIER: "virtual mouse modifier",
@ -138,6 +138,7 @@ const BUTTON_ACTION_ROLES: Dictionary = {
&"ui_accept": ROLE_A,
&"ui_cancel": ROLE_B,
&"open_backpack": ROLE_X,
&"interact": ROLE_Y,
&"character_call": ROLE_Y,
&"focus_gameplay": ROLE_LB,
&"fish_primary": ROLE_RB,

View file

@ -20,7 +20,7 @@ func _init() -> void:
assert(_has_joypad_button(&"jump", JOY_BUTTON_A))
assert(_has_joypad_button(&"ui_accept", JOY_BUTTON_A))
assert(_has_joypad_button(&"ui_cancel", JOY_BUTTON_B))
assert(not _has_joypad_button(&"interact", JOY_BUTTON_Y))
assert(_has_joypad_button(&"interact", JOY_BUTTON_Y))
assert(_has_joypad_button(&"character_call", JOY_BUTTON_Y))
assert(_has_joypad_button(&"fish_primary", JOY_BUTTON_RIGHT_SHOULDER))
assert(not _has_joypad_motion(

View file

@ -221,6 +221,16 @@ func _run() -> void:
assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
assert(chat_ui.is_open())
chat_ui.refocus_gameplay()
chat_ui.open_chat()
assert(chat_ui.is_open())
typed_chat_entry.text = "/not-a-real-command"
chat_ui.call("_send")
assert(not chat_ui.is_open())
assert(not typed_chat_entry.has_focus())
assert(not bool(chat_ui.get("_input_lock_applied")))
assert(chat_status.visible)
assert(chat_status.text == "Unknown command: /not-a-real-command")
chat_ui.call("_set_status", "")
var quick_menu := game_ui.get_node(
"%QuickRadialMenu"
) as QuickRadialMenu
@ -695,8 +705,13 @@ func _validate_pause_browser_transition(
var drag_motion := InputEventMouseMotion.new()
drag_motion.button_mask = MOUSE_BUTTON_MASK_RIGHT
drag_motion.relative = Vector2(24.0, -8.0)
drag_motion.screen_relative = drag_motion.relative
player.call("_input", drag_motion)
assert(not is_equal_approx(camera_yaw.rotation.y, yaw_before_drag))
await process_frame
assert(bool(player.get("_camera_dragging")))
if DisplayServer.get_name() != "headless":
assert(Input.mouse_mode == Input.MOUSE_MODE_CAPTURED)
var drag_release := InputEventMouseButton.new()
drag_release.button_index = MOUSE_BUTTON_RIGHT
drag_release.button_mask = 0

View file

@ -3,6 +3,9 @@ extends SceneTree
const FocusPresentationType = preload(
"res://ui/controller_focus_presentation.gd"
)
const FishBatchSelectionType = preload(
"res://ui/fish_batch_selection.gd"
)
var _failures: Array[String] = []
@ -12,6 +15,7 @@ func _initialize() -> void:
func _run() -> void:
_validate_controller_cooler_focus()
root.size = Vector2i(1280, 720)
var stage := Control.new()
stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@ -23,8 +27,10 @@ func _run() -> void:
standard_button.position = Vector2(80.0, 60.0)
standard_button.custom_minimum_size = Vector2(120.0, 60.0)
var normal_style := _button_style(Color("123f4e"))
var old_hover_style := _button_style(Color("58c6d4"))
var old_focus_style := _button_style(Color("238697"))
standard_button.add_theme_stylebox_override("normal", normal_style)
standard_button.add_theme_stylebox_override("hover", old_hover_style)
standard_button.add_theme_stylebox_override("focus", old_focus_style)
stage.add_child(standard_button)
var second_button := Button.new()
@ -82,6 +88,22 @@ func _run() -> void:
)
stage.scale = Vector2.ONE
await process_frame
var hover_motion := InputEventMouseMotion.new()
hover_motion.position = standard_button.get_global_rect().get_center()
hover_motion.global_position = hover_motion.position
root.push_input(hover_motion, true)
await process_frame
_expect(
root.gui_get_hovered_control() == standard_button,
"the pointer did not reach the hover-suppression test button",
)
presentation._input(controller_event)
standard_button.grab_focus()
await process_frame
_expect(
standard_button.get_theme_stylebox("hover") == normal_style,
"controller use leaves a stale mouse-hover highlight visible",
)
second_button.grab_focus()
await process_frame
@ -106,6 +128,10 @@ func _run() -> void:
standard_button.get_theme_stylebox("focus") == old_focus_style,
"native theme overrides were not restored after controller use",
)
_expect(
standard_button.get_theme_stylebox("hover") == old_hover_style,
"mouse hover styling was not restored after controller use",
)
presentation._input(controller_event)
var item_list := ItemList.new()
@ -284,6 +310,21 @@ func _run() -> void:
quit(1)
func _validate_controller_cooler_focus() -> void:
var selection := FishBatchSelectionType.new()
selection.set_visible_order([&"first", &"second"])
selection.select_only(&"first")
selection.focus_only(&"second")
_expect(
selection.get_focused_id() == &"second",
"cooler focus does not follow controller navigation",
)
_expect(
selection.get_selected_ids() == [&"first"],
"moving cooler focus unexpectedly changes the selected catch set",
)
func _button_style(color: Color) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = color

View file

@ -104,18 +104,20 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String:
return "right-stick click is not assigned to the sneak action"
if ControllerMappingManagerType.ROLE_LABELS.get(
ControllerMappingManagerType.ROLE_Y, ""
) != "character call":
return "Y is not presented as the character-call button"
) != "interact / character call":
return "Y does not present its contextual gameplay actions"
if ControllerMappingManagerType.BUTTON_ACTION_ROLES.get(
&"interact", &""
) != ControllerMappingManagerType.ROLE_Y:
return "Y is not assigned to the interact action"
if ControllerMappingManagerType.BUTTON_ACTION_ROLES.get(
&"character_call", &""
) != ControllerMappingManagerType.ROLE_Y:
return "Y is not assigned to the character-call action"
if ControllerMappingManagerType.BUTTON_ACTION_ROLES.has(&"interact"):
return "Y still exposes the obsolete controller interact binding"
if not _has_joy_button(&"character_call", JOY_BUTTON_Y):
return "character call does not default to Y"
if _has_joy_button(&"interact", JOY_BUTTON_Y):
return "Y still defaults to interact"
if not _has_joy_button(&"interact", JOY_BUTTON_Y):
return "interact does not default to Y"
if not _has_joy_button(&"sneak", JOY_BUTTON_RIGHT_STICK):
return "sneak does not default to right-stick click"
var trigger_button := InputEventJoypadButton.new()

View file

@ -43,6 +43,7 @@ func _run() -> void:
root.size = Vector2i(1280, 720)
await _validate_join_game_navigation()
await _validate_data_settings_navigation()
await _validate_settings_adjustment_navigation()
await _validate_mail_navigation()
await _validate_profile_confirmation_focus()
await _validate_profile_voice_navigation()
@ -196,6 +197,65 @@ func _validate_data_settings_navigation() -> void:
await process_frame
func _validate_settings_adjustment_navigation() -> void:
var panel := SettingsPanelScene.instantiate() as SettingsPanel
root.add_child(panel)
await process_frame
panel.show()
var sound_page := panel.get_node("%SoundPage") as SettingsBubblePage
sound_page.show_page(false)
for _frame: int in 2:
await process_frame
var environment := panel.get_node("%EnvironmentVolumeSlider") as HSlider
var sound_back := panel.get_node("%SoundBackButton") as Button
_assert_neighbor(
environment,
&"focus_neighbor_bottom",
sound_back,
)
_assert_neighbor(sound_back, &"focus_neighbor_top", environment)
sound_page.hide_page()
var controls_page := panel.get_node("%ControlsPage") as SettingsBubblePage
controls_page.show_page(false)
for _frame: int in 2:
await process_frame
var parent := panel.get_node("%MouseValue") as Button
var decrease := panel.get_node("%MouseDecrease") as Button
var increase := panel.get_node("%MouseIncrease") as Button
_expect(
decrease.focus_mode == Control.FOCUS_NONE
and increase.focus_mode == Control.FOCUS_NONE,
"Sensitivity adjustment bubbles are reachable before their parent.",
)
parent.grab_focus()
var accept := InputEventJoypadButton.new()
accept.button_index = JOY_BUTTON_A
accept.pressed = true
panel.call("_input", accept)
_expect(
decrease.focus_mode == Control.FOCUS_ALL
and increase.focus_mode == Control.FOCUS_ALL,
"Selecting a sensitivity parent did not enter its adjustment zone.",
)
_assert_neighbor(parent, &"focus_neighbor_left", decrease)
_assert_neighbor(parent, &"focus_neighbor_right", increase)
panel.handle_back()
for _frame: int in 2:
await process_frame
_expect(
decrease.focus_mode == Control.FOCUS_NONE
and increase.focus_mode == Control.FOCUS_NONE,
"Leaving an adjustment zone did not hide its child controls.",
)
_expect(
root.gui_get_focus_owner() == parent,
"Leaving an adjustment zone did not restore its parent focus.",
)
panel.queue_free()
await process_frame
func _validate_mail_navigation() -> void:
var page := MailPageType.new() as Control
root.add_child(page)
@ -364,6 +424,31 @@ func _validate_profile_confirmation_focus() -> void:
root.gui_get_focus_owner() in option_controls,
"Selecting an appearance feature did not focus its options.",
)
var rebuilt_option := root.gui_get_focus_owner() as BaseButton
if rebuilt_option != null:
rebuilt_option.pressed.emit()
for _frame: int in 3:
await process_frame
option_groups = page.call("_controller_option_groups")
option_controls.clear()
if not option_groups.is_empty():
for item: Variant in option_groups.front():
var option_control := item as Control
if option_control != null:
option_controls.append(option_control)
_expect(
root.gui_get_focus_owner() in option_controls,
"Rebuilding appearance options dropped controller focus.",
)
root.gui_release_focus()
var controller_down := InputEventJoypadButton.new()
controller_down.button_index = JOY_BUTTON_DPAD_DOWN
controller_down.pressed = true
page.call("handle_controller_input", controller_down)
_expect(
root.gui_get_focus_owner() in option_controls,
"Appearance options did not recover a lost controller focus owner.",
)
var cancel_options := InputEventAction.new()
cancel_options.action = &"ui_cancel"
cancel_options.pressed = true
@ -774,7 +859,7 @@ func _validate_inventory_tab_zone_transitions() -> void:
)
# Add three representative equipment entries so entering the content zone
# exercises the real three-column directional layout.
# exercises the real five-column directional layout.
var item_field := menu.get_node("%BagItemField") as Control
var bag_nodes: Dictionary = menu.get("_bag_item_nodes")
var owned_items: Array[OwnedItemType] = []
@ -944,6 +1029,32 @@ func _validate_inventory_tab_zone_transitions() -> void:
management_requests[0] == 1,
"Equipment-to-hotbar navigation did not open hotbar management.",
)
var up := InputEventAction.new()
up.action = &"ui_up"
up.pressed = true
_expect(
bool(menu.call("_handle_controller_ownership_input", up)),
"Up from hotbar management was not consumed.",
)
_expect(
menu.get("_controller_ownership")
== PlayerMenuType.ControllerOwnership.ITEM_LIST,
"Up from hotbar management did not return to Inventory contents.",
)
for _frame: int in 2:
await process_frame
_expect(
root.gui_get_focus_owner() == hotbar_source,
"Up from hotbar management did not restore the source item focus.",
)
_expect(
bool(menu.call("_handle_controller_ownership_input", down)),
"Inventory contents could not re-enter hotbar management.",
)
_expect(
management_requests[0] == 2,
"Re-entering the hotbar did not reopen hotbar management.",
)
_expect(
bool(menu.call("_handle_controller_ownership_input", accept)),
"Hotbar management did not consume controller A.",

View file

@ -0,0 +1,134 @@
extends SceneTree
const MainScene = preload("res://main/main.tscn")
var _failures: Array[String] = []
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
root.size = Vector2i(1280, 720)
var main := MainScene.instantiate()
root.add_child(main)
for _frame: int in 4:
await process_frame
if not bool(main.get("_application_initialized")):
main.call("_activate_selected_data_path", "", true)
for _frame: int in 8:
await process_frame
_expect(
bool(main.get("_application_initialized")),
"application initialization failed",
)
_expect(
bool(main.call("_prepare_private_host")),
"private host preparation failed",
)
var save_manager := main.get("_save_manager") as PlayerSaveManager
_expect(
save_manager != null and save_manager.initialize_new_game(),
"new-game initialization failed",
)
main.call("_enter_gameplay")
for _frame: int in 8:
await process_frame
var player := main.get("_player") as Player
var interaction := main.get("_shop_interaction") as FishingShopInteraction
var game_ui := main.get("_game_ui") as GameUI
var chat_service := main.get_node(
"%NetworkChatService"
) as NetworkChatService
_expect(player != null, "local player is unavailable")
_expect(interaction != null, "shop interaction is unavailable")
_expect(game_ui != null, "game UI is unavailable")
_expect(chat_service != null, "network chat service is unavailable")
if (
player != null
and interaction != null
and game_ui != null
and chat_service != null
):
player.global_position = interaction.global_position
for _frame: int in 6:
await physics_frame
_expect(
interaction.is_local_player_in_range(),
"local player did not enter the shop interaction range",
)
var character_calls: Array[String] = []
var capture_call := func(
_peer_id: int,
call_id: String,
_pitch_scale: float,
) -> void:
character_calls.append(call_id)
chat_service.character_call_received.connect(capture_call)
var press := InputEventJoypadButton.new()
press.device = 0
press.button_index = JOY_BUTTON_Y
press.pressed = true
_expect(
press.is_action_pressed("interact"),
"Y does not resolve to interact",
)
_expect(
press.is_action_pressed("character_call"),
"Y does not resolve to character call",
)
game_ui.call("_input", press)
main.call("_unhandled_input", press)
await process_frame
_expect(
game_ui.get_fishing_shop().visible,
"Y did not open the shop while the player was in range",
)
_expect(
character_calls.is_empty(),
"Y played a character call instead of opening the shop",
)
player.global_position = interaction.global_position + Vector3(20.0, 0.0, 0.0)
for _frame: int in 6:
await physics_frame
for _frame: int in 60:
if not game_ui.get_fishing_shop().visible:
break
await process_frame
_expect(
not interaction.is_local_player_in_range(),
"local player did not leave the shop interaction range",
)
_expect(
not game_ui.get_fishing_shop().visible,
"shop did not close after leaving its interaction range",
)
game_ui.call("_input", press)
await process_frame
_expect(
character_calls.size() == 1,
"Y did not play a character call away from an interaction",
)
chat_service.character_call_received.disconnect(capture_call)
var session := main.get_node("%NetworkSession") as NetworkSession
if session != null:
session.disconnect_session("Controller interaction validation complete.")
main.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
if _failures.is_empty():
print("Controller world interaction validation: PASS")
quit(0)
return
for failure: String in _failures:
push_error(failure)
quit(1)
func _expect(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)

View file

@ -0,0 +1 @@
uid://b6fg46sad1ma6

View file

@ -422,7 +422,6 @@ func _test_player_menu_sale(
) as SubViewport
assert(sell_action.visible and not sell_action.disabled)
assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP)
assert(sell_action.focus_mode == Control.FOCUS_ALL)
assert(ui_viewport != null)
await _activate_pointer_control(sell_action, ui_viewport)
await process_frame
@ -632,7 +631,8 @@ func _test_fishing_shop_sale_ui(
}))
var balance_before: int = player.wallet.get_balance()
assert(shop.open_shop())
await process_frame
for _frame: int in 2:
await process_frame
assert(shop_backdrop.visible)
assert(shop_backdrop.material is ShaderMaterial)
assert(shop.has_node("InputBlocker") and not shop.has_node("Dimmer"))

View file

@ -0,0 +1,99 @@
extends SceneTree
const FishingShopScene: PackedScene = preload(
"res://ui/fishing_shop.tscn"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
var shop := FishingShopScene.instantiate() as FishingShop
root.add_child(shop)
shop.show()
await process_frame
var tabs: Array = shop.get("_shop_tabs") as Array
assert(tabs.size() == FishingShop.ShopSection.size())
var supplies_list := shop.get_node("%SuppliesList") as VBoxContainer
var dummy_stock := Button.new()
dummy_stock.name = "ControllerTestStock"
dummy_stock.text = "test stock"
supplies_list.add_child(dummy_stock)
var activations: Array[int] = [0]
dummy_stock.pressed.connect(
func() -> void:
activations[0] += 1
)
var accept := InputEventJoypadButton.new()
accept.button_index = JOY_BUTTON_A
accept.pressed = true
var cancel := InputEventJoypadButton.new()
cancel.button_index = JOY_BUTTON_B
cancel.pressed = true
for section_index: int in range(FishingShop.ShopSection.ART_SUPPLIES + 1):
shop.set("_shop_section", section_index)
shop.call("_select_shop_section", section_index, false)
shop.call("_enter_shop_tabs_zone")
for _frame: int in 2:
await process_frame
var tab := tabs[section_index] as Button
assert(
root.gui_get_focus_owner() == tab,
"section %d tab focus mismatch: focused=%s tab=%s visible=%s mode=%d"
% [
section_index,
root.gui_get_focus_owner(),
tab,
tab.is_visible_in_tree(),
tab.focus_mode,
],
)
assert(
shop.get("_controller_zone")
== FishingShop.ControllerZone.TABS
)
shop.call("_input", accept)
for _frame: int in 2:
await process_frame
assert(int(shop.get("_shop_section")) == section_index)
assert(
shop.get("_controller_zone")
== FishingShop.ControllerZone.CONTENT
)
assert(not _array_contains_control(
tabs,
root.gui_get_focus_owner(),
))
if section_index == FishingShop.ShopSection.BAIT:
assert(root.gui_get_focus_owner() == dummy_stock)
shop.call("_input", accept)
assert(activations[0] == 1)
shop.call("_input", cancel)
for _frame: int in 2:
await process_frame
assert(shop.visible)
assert(
shop.get("_controller_zone")
== FishingShop.ControllerZone.TABS
)
assert(root.gui_get_focus_owner() == tab)
shop.hide()
shop.queue_free()
await process_frame
print("Fishing shop controller validation: PASS")
quit()
func _array_contains_control(values: Array, target: Control) -> bool:
for value: Variant in values:
var control := value as Control
if control == target:
return true
return false

View file

@ -0,0 +1 @@
uid://duwikmb08papi

View file

@ -82,6 +82,26 @@ func _validate_page() -> void:
page.get("_category") == LogbookCatalog.Category.FRESH_WATER
)
assert((page.get("_catalog_grid") as GridContainer).columns == 4)
var catalog_scroll := page.get("_catalog_scroll") as ScrollContainer
assert(catalog_scroll != null and catalog_scroll.follow_focus)
page.call(
"_set_controller_zone",
LogbookPage.ControllerZone.ENTRIES,
)
for _frame: int in 2:
await process_frame
var initial_entries: Array = (
page.get("_entry_buttons") as Dictionary
).values()
assert(initial_entries.size() > 4)
var final_entry := initial_entries.back() as Button
final_entry.grab_focus()
for _frame: int in 3:
await process_frame
assert(catalog_scroll.scroll_vertical > 0)
page.reset_controller_zone()
for _frame: int in 2:
await process_frame
var category_tabs: Array = page.get("_category_tabs") as Array
var category_tab_categories: Array = (
page.get("_category_tab_categories") as Array

View file

@ -1,11 +1,26 @@
extends SceneTree
const KeyboardType = preload("res://ui/on_screen_keyboard.gd")
const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
const SettingsManagerType = preload(
"res://settings/player_settings_manager.gd"
)
class ControllerInputProbe:
extends Node
var pressed_buttons: Array[int] = []
func _input(event: InputEvent) -> void:
var button := event as InputEventJoypadButton
if button != null and button.pressed:
pressed_buttons.append(button.button_index)
func _initialize() -> void:
call_deferred("_run")
@ -13,6 +28,7 @@ func _initialize() -> void:
func _run() -> void:
_validate_default_and_persistence()
await _validate_keyboard_entry()
await _validate_automapped_face_buttons()
print("On-screen keyboard validation: PASS")
quit()
@ -50,6 +66,8 @@ func _validate_keyboard_entry() -> void:
var host := Control.new()
root.add_child(host)
var edit := LineEdit.new()
edit.text = "seed"
edit.caret_column = 0
edit.focus_mode = Control.FOCUS_ALL
host.add_child(edit)
var keyboard := KeyboardType.new()
@ -70,25 +88,75 @@ func _validate_keyboard_entry() -> void:
edit.grab_focus()
keyboard.call("_input", activate_event)
assert(keyboard.is_open())
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")
)
var left_page_event := InputEventJoypadButton.new()
left_page_event.button_index = JOY_BUTTON_LEFT_SHOULDER
left_page_event.pressed = true
keyboard.call("_input", left_page_event)
assert(keyboard.is_open())
assert(keyboard.get("_page") == KeyboardType.Page.SYMBOLS)
var right_page_event := InputEventJoypadButton.new()
right_page_event.button_index = JOY_BUTTON_RIGHT_SHOULDER
right_page_event.pressed = true
keyboard.call("_input", right_page_event)
assert(keyboard.is_open())
assert(keyboard.get("_page") == KeyboardType.Page.LOWER)
for _frame: int in 2:
await process_frame
var focused_key := root.gui_get_focus_owner() as Button
assert(focused_key != null and focused_key.text == "q")
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")
var left_trigger := InputEventJoypadMotion.new()
left_trigger.axis = JOY_AXIS_TRIGGER_LEFT
left_trigger.axis_value = 1.0
keyboard.call("_input", left_trigger)
assert(keyboard.get("_buffer_caret") == edit.text.length() - 1)
var right_trigger := InputEventJoypadMotion.new()
right_trigger.axis = JOY_AXIS_TRIGGER_RIGHT
right_trigger.axis_value = 1.0
keyboard.call("_input", right_trigger)
assert(keyboard.get("_buffer_caret") == edit.text.length())
keyboard.call("_process", KeyboardType.CARET_BLINK_INTERVAL)
assert(
not str((keyboard.get("_preview") as Label).text).contains(
KeyboardType.CARET_GLYPH
)
)
keyboard.call("_type_character", "a")
keyboard.call("_type_space")
keyboard.call("_set_page", KeyboardType.Page.UPPER)
keyboard.call("_type_character", "B")
assert(edit.text == "a B")
assert(edit.text == "seedqa B")
keyboard.call("_move_caret", -1)
keyboard.call("_type_character", "C")
assert(edit.text == "a CB")
assert(edit.text == "seedqa CB")
var input_probe := ControllerInputProbe.new()
root.add_child(input_probe)
root.move_child(input_probe, 0)
var backspace_event := InputEventJoypadButton.new()
backspace_event.button_index = JOY_BUTTON_X
backspace_event.pressed = true
keyboard.call("_input", backspace_event)
assert(edit.text == "a B")
var defocus_event := InputEventJoypadButton.new()
defocus_event.button_index = JOY_BUTTON_LEFT_SHOULDER
defocus_event.pressed = true
keyboard.call("_input", defocus_event)
Input.parse_input_event(backspace_event)
await process_frame
assert(edit.text == "seedqa B")
assert(input_probe.pressed_buttons.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 keyboard.is_open())
assert(root.gui_get_focus_owner() == null)
assert(root.gui_get_focus_owner() == edit)
input_probe.queue_free()
edit.grab_focus()
keyboard.call("_input", activate_event)
assert(keyboard.is_open())
@ -98,5 +166,57 @@ func _validate_keyboard_entry() -> void:
)
keyboard.call("_submit")
assert(not keyboard.is_open())
assert(submitted == ["a B"])
assert(submitted == ["seedqa B"])
host.queue_free()
func _validate_automapped_face_buttons() -> void:
var manager := ControllerMappingManagerType.new()
root.add_child(manager)
await process_frame
var swapped_bindings: Dictionary = (
ControllerMappingManagerType.default_bindings()
)
swapped_bindings[str(ControllerMappingManagerType.ROLE_A)] = {
"kind": "button",
"button": int(JOY_BUTTON_B),
}
swapped_bindings[str(ControllerMappingManagerType.ROLE_B)] = {
"kind": "button",
"button": int(JOY_BUTTON_A),
}
manager._active_profile_key = "keyboard-mapping-test"
manager._profiles[manager._active_profile_key] = {
"controller_name": "keyboard mapping test",
"bindings": swapped_bindings,
}
var host := Control.new()
root.add_child(host)
var edit := LineEdit.new()
edit.focus_mode = Control.FOCUS_ALL
host.add_child(edit)
var keyboard := KeyboardType.new()
host.add_child(keyboard)
keyboard.setup_controller_mapping(manager)
keyboard.set_enabled(true)
await process_frame
var mapped_accept := InputEventJoypadButton.new()
mapped_accept.device = manager.get_active_device_id()
mapped_accept.button_index = JOY_BUTTON_B
mapped_accept.pressed = true
edit.grab_focus()
keyboard.call("_input", mapped_accept)
assert(keyboard.is_open())
for _frame: int in 2:
await process_frame
keyboard.call("_input", mapped_accept)
assert(keyboard.is_open())
assert(edit.text == "q")
var mapped_cancel := InputEventJoypadButton.new()
mapped_cancel.device = manager.get_active_device_id()
mapped_cancel.button_index = JOY_BUTTON_A
mapped_cancel.pressed = true
keyboard.call("_input", mapped_cancel)
assert(not keyboard.is_open())
host.queue_free()
manager.queue_free()

View file

@ -15,6 +15,7 @@ var _depth_scale: float = 1.0
var _batch_selected: bool = false
var _focused_catch: bool = false
var _hovered: bool = false
var _controller_focus_presentation_active: bool = false
var _quality_color := Color.WHITE
@ -96,6 +97,11 @@ func set_item_state(
_refresh_style()
func set_controller_focus_presentation_active(active: bool) -> void:
_controller_focus_presentation_active = active
_refresh_style()
func advance_presentation(delta: float, elapsed: float) -> void:
var response: float = 1.0 - exp(-9.0 * delta)
_neutral_position = _neutral_position.lerp(_target_position, response)
@ -107,7 +113,7 @@ func advance_presentation(delta: float, elapsed: float) -> void:
interaction_lift += 3.0
if _focused_catch:
interaction_lift += 1.5
elif _hovered:
elif _hovered and not _controller_focus_presentation_active:
interaction_lift += 0.8
_visual_root.position = Vector2(0.0, bob - interaction_lift)
_visual_root.rotation = tilt
@ -116,7 +122,7 @@ func advance_presentation(delta: float, elapsed: float) -> void:
emphasis += 0.085
if _focused_catch:
emphasis += 0.035
elif _hovered:
elif _hovered and not _controller_focus_presentation_active:
emphasis += 0.045
_visual_root.scale = Vector2.ONE * _depth_scale * emphasis
@ -150,7 +156,7 @@ func _refresh_style() -> void:
)
var circle_visible: bool = (
_batch_selected
or _hovered
or (_hovered and not _controller_focus_presentation_active)
or _focused_catch
)
var base_style: StyleBox = idle

View file

@ -24,6 +24,20 @@ const FOCUS_COLOR_REPLACEMENTS: Dictionary[StringName, StringName] = {
&"font_selected_color": &"font_color",
&"font_hovered_selected_color": &"font_hovered_color",
}
const HOVER_STYLE_REPLACEMENTS: Dictionary[StringName, StringName] = {
&"hover": &"normal",
&"hover_pressed": &"pressed",
&"hovered": &"",
&"hovered_selected": &"",
}
const HOVER_COLOR_REPLACEMENTS: Dictionary[StringName, StringName] = {
&"font_hover_color": &"font_color",
&"icon_hover_color": &"icon_normal_color",
&"font_hover_pressed_color": &"font_pressed_color",
&"icon_hover_pressed_color": &"icon_pressed_color",
&"font_hovered_color": &"font_color",
&"font_hovered_selected_color": &"font_color",
}
var _controller_active: bool = false
var _focused_control: Control
@ -32,6 +46,9 @@ var _popup_scroll_offset: float = 0.0
var _suppressed_control: Control
var _original_style_overrides: Dictionary[StringName, Dictionary] = {}
var _original_color_overrides: Dictionary[StringName, Dictionary] = {}
var _suppressed_hover_control: Control
var _original_hover_style_overrides: Dictionary[StringName, Dictionary] = {}
var _original_hover_color_overrides: Dictionary[StringName, Dictionary] = {}
var _original_popup_hover_style: Dictionary = {}
var _original_popup_hover_color: Dictionary = {}
var _focus_layer: CanvasLayer
@ -47,6 +64,7 @@ func _ready() -> void:
func _exit_tree() -> void:
_clear_focus_presentation()
_restore_native_hover_highlight()
func _input(event: InputEvent) -> void:
@ -69,6 +87,7 @@ func _input(event: InputEvent) -> void:
func _process(_delta: float) -> void:
_update_hover_suppression()
if _controller_active:
var focused_popup: PopupMenu = _active_focused_popup(get_viewport())
if focused_popup != null:
@ -111,6 +130,7 @@ func _set_controller_active(active: bool) -> void:
if _controller_active == active:
return
_controller_active = active
_update_hover_suppression()
if _controller_active:
var focused_popup: PopupMenu = _active_focused_popup(get_viewport())
if focused_popup != null:
@ -440,6 +460,113 @@ func _suppress_native_focus_highlight(control: Control) -> void:
)
func _update_hover_suppression() -> void:
if not _controller_active:
_restore_native_hover_highlight()
return
var hovered: Control = _hover_presentation_control(
_hovered_control_in_viewport(get_viewport())
)
if hovered == _suppressed_hover_control:
return
_restore_native_hover_highlight()
if hovered != null:
_suppress_native_hover_highlight(hovered)
func _hovered_control_in_viewport(viewport: Viewport) -> Control:
var embedded_windows: Array[Window] = viewport.get_embedded_subwindows()
for index: int in range(embedded_windows.size() - 1, -1, -1):
var window: Window = embedded_windows[index]
if not window.visible:
continue
var nested_hovered: Control = _hovered_control_in_viewport(window)
if nested_hovered != null:
return nested_hovered
return viewport.gui_get_hovered_control()
func _hover_presentation_control(control: Control) -> Control:
var candidate: Control = control
while candidate != null:
if (
candidate is BaseButton
or candidate is ItemList
or candidate is Tree
or candidate.focus_mode != Control.FOCUS_NONE
):
return candidate
candidate = candidate.get_parent() as Control
return null
func _suppress_native_hover_highlight(control: Control) -> void:
_suppressed_hover_control = control
_original_hover_style_overrides.clear()
_original_hover_color_overrides.clear()
if control.has_method(&"set_controller_focus_presentation_active"):
control.call(&"set_controller_focus_presentation_active", true)
for hover_name: StringName in HOVER_STYLE_REPLACEMENTS:
var replacement_name: StringName = HOVER_STYLE_REPLACEMENTS[hover_name]
_original_hover_style_overrides[hover_name] = {
"had_override": control.has_theme_stylebox_override(hover_name),
"value": control.get_theme_stylebox(hover_name),
}
var replacement: StyleBox = (
StyleBoxEmpty.new()
if replacement_name.is_empty()
else control.get_theme_stylebox(replacement_name)
)
control.add_theme_stylebox_override(hover_name, replacement)
for hover_name: StringName in HOVER_COLOR_REPLACEMENTS:
var replacement_name: StringName = HOVER_COLOR_REPLACEMENTS[hover_name]
if not control.has_theme_color(replacement_name):
continue
_original_hover_color_overrides[hover_name] = {
"had_override": control.has_theme_color_override(hover_name),
"value": control.get_theme_color(hover_name),
}
control.add_theme_color_override(
hover_name,
control.get_theme_color(replacement_name),
)
func _restore_native_hover_highlight() -> void:
if not is_instance_valid(_suppressed_hover_control):
_suppressed_hover_control = null
_original_hover_style_overrides.clear()
_original_hover_color_overrides.clear()
return
for hover_name: StringName in _original_hover_style_overrides:
var state: Dictionary = _original_hover_style_overrides[hover_name]
if bool(state.get("had_override", false)):
_suppressed_hover_control.add_theme_stylebox_override(
hover_name,
state.get("value") as StyleBox,
)
else:
_suppressed_hover_control.remove_theme_stylebox_override(hover_name)
for hover_name: StringName in _original_hover_color_overrides:
var state: Dictionary = _original_hover_color_overrides[hover_name]
if bool(state.get("had_override", false)):
_suppressed_hover_control.add_theme_color_override(
hover_name,
state.get("value") as Color,
)
else:
_suppressed_hover_control.remove_theme_color_override(hover_name)
if _suppressed_hover_control.has_method(
&"set_controller_focus_presentation_active"
):
_suppressed_hover_control.call(
&"set_controller_focus_presentation_active", false
)
_suppressed_hover_control = null
_original_hover_style_overrides.clear()
_original_hover_color_overrides.clear()
func _suppress_popup_focus_highlight(popup: PopupMenu) -> void:
_original_popup_hover_style = {
"had_override": popup.has_theme_stylebox_override(&"hover"),

View file

@ -67,6 +67,12 @@ func select_only(catch_id: StringName) -> void:
_anchor_id = catch_id
func focus_only(catch_id: StringName) -> void:
if catch_id.is_empty() or not _visible_ids.has(catch_id):
return
_focused_id = catch_id
func clear() -> void:
_selected_ids.clear()
_focused_id = StringName()

View file

@ -93,6 +93,11 @@ enum ShopSection {
SELL_FISH,
}
enum ControllerZone {
TABS,
CONTENT,
}
const SHOP_SECTION_LABELS: Array[String] = [
"Upgrades",
"Bait and Lures",
@ -175,6 +180,7 @@ var _cooler_modal_open: bool = false
var _shop_section: ShopSection = ShopSection.UPGRADES
var _shop_tabs: Array[OrganizerTab] = []
var _controller_mapping_manager: ControllerMappingManagerType
var _controller_zone: ControllerZone = ControllerZone.TABS
func _ready() -> void:
@ -204,6 +210,40 @@ func _input(event: InputEvent) -> void:
var button_event := event as InputEventJoypadButton
if button_event == null:
return
var uses_accept: bool = (
_controller_mapping_manager.event_matches_role(
event,
ControllerMappingManagerType.ROLE_A,
)
if _controller_mapping_manager != null
else button_event.button_index == JOY_BUTTON_A
)
var uses_cancel: bool = (
_controller_mapping_manager.event_matches_role(
event,
ControllerMappingManagerType.ROLE_B,
)
if _controller_mapping_manager != null
else button_event.button_index == JOY_BUTTON_B
)
if uses_cancel:
get_viewport().set_input_as_handled()
if not button_event.pressed:
return
if _controller_zone == ControllerZone.CONTENT:
_enter_shop_tabs_zone()
else:
close_shop()
return
if uses_accept:
get_viewport().set_input_as_handled()
if not button_event.pressed or _transaction_in_progress:
return
if _controller_zone == ControllerZone.TABS:
_enter_shop_content_zone()
else:
_activate_focused_shop_control()
return
var uses_left_bumper: bool = (
_controller_mapping_manager.event_uses_role(
event,
@ -226,12 +266,69 @@ func _input(event: InputEvent) -> void:
if not button_event.pressed or _transaction_in_progress:
return
var direction: int = -1 if uses_left_bumper else 1
var current_section: int = _focused_shop_tab_index()
if current_section < 0:
current_section = int(_shop_section)
var next_section: int = wrapi(
int(_shop_section) + direction,
current_section + direction,
0,
ShopSection.size(),
)
_select_shop_section(next_section, true)
if _controller_zone == ControllerZone.TABS:
_focus_shop_tab(next_section)
else:
_select_shop_section(next_section, true)
func _enter_shop_tabs_zone() -> void:
_controller_zone = ControllerZone.TABS
_apply_shop_controller_zone_focus_modes()
_configure_controller_focus()
_focus_shop_tab(int(_shop_section))
func _enter_shop_content_zone() -> void:
var section_index: int = _focused_shop_tab_index()
if section_index < 0:
section_index = int(_shop_section)
_controller_zone = ControllerZone.CONTENT
_select_shop_section(section_index, true)
func _activate_focused_shop_control() -> bool:
var focused := get_viewport().gui_get_focus_owner() as BaseButton
if (
focused == null
or focused.disabled
or _is_shop_tab(focused)
or not focused.is_visible_in_tree()
):
return false
focused.pressed.emit()
return true
func _focused_shop_tab_index() -> int:
var focused: Control = get_viewport().gui_get_focus_owner()
for tab_index: int in _shop_tabs.size():
if _shop_tabs[tab_index] == focused:
return tab_index
return -1
func _is_shop_tab(control: Control) -> bool:
for tab: OrganizerTab in _shop_tabs:
if tab == control:
return true
return false
func _focus_shop_tab(section_index: int) -> void:
if section_index < 0 or section_index >= _shop_tabs.size():
return
var tab: OrganizerTab = _shop_tabs[section_index]
if tab.focus_mode != Control.FOCUS_NONE and not tab.disabled:
tab.call_deferred("grab_focus")
func _request_shop_cooler() -> bool:
@ -249,10 +346,15 @@ func _focus_shop_section() -> void:
_set_feedback("")
_configure_controller_focus()
if _shop_section == ShopSection.UPGRADES:
_reel_purchase.grab_focus()
return
for upgrade_button: Button in [
_reel_purchase,
_barrier_purchase,
_cooler_purchase,
]:
if not upgrade_button.disabled:
upgrade_button.grab_focus()
return
if _shop_section == ShopSection.SELL_FISH:
_shop_tabs[int(ShopSection.SELL_FISH)].grab_focus()
return
for child: Node in _supplies_list.find_children(
"*", "Button", true, false
@ -261,9 +363,9 @@ func _focus_shop_section() -> void:
if stock_button != null and not stock_button.disabled:
stock_button.grab_focus()
return
var section_index: int = int(_shop_section)
if section_index >= 0 and section_index < _shop_tabs.size():
_shop_tabs[section_index].grab_focus()
var close_button := %CloseButton as Button
if close_button.focus_mode != Control.FOCUS_NONE:
close_button.grab_focus()
func _build_shop_tabs() -> void:
@ -293,6 +395,8 @@ func _build_shop_tabs() -> void:
func _select_shop_section(section_index: int, focus_content: bool) -> void:
if _closing:
return
if focus_content:
_controller_zone = ControllerZone.CONTENT
if section_index == int(_shop_section):
if section_index != ShopSection.SELL_FISH:
var showing_upgrades := section_index == ShopSection.UPGRADES
@ -325,11 +429,36 @@ func _select_shop_section(section_index: int, focus_content: bool) -> void:
func _configure_controller_focus() -> void:
if not visible or _cooler_page_active:
return
_apply_shop_controller_zone_focus_modes()
var candidates: Array[Control] = []
_collect_controller_focusables(self, candidates)
ControllerFocusNavigationType.configure_spatial_neighbors(candidates)
func _apply_shop_controller_zone_focus_modes() -> void:
var tabs_active: bool = _controller_zone == ControllerZone.TABS
for tab: OrganizerTab in _shop_tabs:
tab.focus_mode = (
Control.FOCUS_ALL if tabs_active else Control.FOCUS_NONE
)
var content_focus_mode := (
Control.FOCUS_NONE if tabs_active else Control.FOCUS_ALL
)
for content_root: Control in [_upgrades_content, _supplies_content]:
_set_descendant_button_focus_mode(content_root, content_focus_mode)
(%CloseButton as Button).focus_mode = content_focus_mode
func _set_descendant_button_focus_mode(
root_control: Control,
focus_mode: Control.FocusMode,
) -> void:
for child: Node in root_control.find_children("*", "BaseButton", true, false):
var button := child as BaseButton
if button != null:
button.focus_mode = focus_mode
func _collect_controller_focusables(
root: Node,
output: Array[Control],
@ -488,10 +617,11 @@ func open_shop() -> bool:
deactivate_shop_cooler_page()
show()
_shop_tab_bar.show()
_controller_zone = ControllerZone.TABS
_select_shop_section(ShopSection.UPGRADES, false)
_refresh_all()
call_deferred("_configure_controller_focus")
_reel_purchase.grab_focus()
_focus_shop_tab(int(ShopSection.UPGRADES))
menu_visibility_changed.emit(true)
return true
@ -751,6 +881,7 @@ func _refresh_supplies() -> void:
_supplies_list.remove_child(child)
child.queue_free()
if _shop_section in [ShopSection.UPGRADES, ShopSection.SELL_FISH]:
call_deferred("_configure_controller_focus")
return
_stock_title.text = SHOP_SECTION_LABELS[int(_shop_section)]
var stock_item_ids: Array[StringName] = (
@ -934,6 +1065,7 @@ func _refresh_supplies() -> void:
var canvas_grid := _add_stock_icon_grid()
for product_id: StringName in ArtShopStockType.GRID_PRODUCTS:
_add_art_upgrade_button(product_id, canvas_grid)
call_deferred("_configure_controller_focus")
func _item_belongs_in_current_section(item: ItemDataType) -> bool:

View file

@ -456,6 +456,10 @@ func setup(
func _input(event: InputEvent) -> void:
# The on-screen keyboard owns controller input while it is open. Its
# overlay is processed before the UI beneath it and consumes the event.
if is_controller_text_entry_open():
return
if _handle_virtual_mouse_input(event):
get_viewport().set_input_as_handled()
return
@ -476,6 +480,8 @@ func _input(event: InputEvent) -> void:
):
return
if event.is_action_pressed("character_call") and _can_use_character_call():
if _character_call_yields_to_world_interaction(event):
return
_network_chat_service.send_local_character_call(
_network_profile.call_id
)
@ -554,6 +560,19 @@ func _can_use_character_call() -> bool:
)
func _character_call_yields_to_world_interaction(
event: InputEvent,
) -> bool:
return (
event.is_action_pressed("interact")
and _shop_interaction != null
and _shop_interaction.is_local_player_in_range()
and _fishing_spot != null
and _fishing_spot.can_open_fishing_shop()
and not _fishing_shop.visible
)
func _on_character_call_received(
peer_id: int,
call_id: String,

View file

@ -358,6 +358,7 @@ func _build_interface() -> void:
_catalog_scroll.horizontal_scroll_mode = (
ScrollContainer.SCROLL_MODE_DISABLED
)
_catalog_scroll.follow_focus = true
_catalog_scroll.scroll_vertical_custom_step = CATALOG_ROW_STEP
catalog_scroll_margin.add_child(_catalog_scroll)
_catalog_grid = GridContainer.new()

View file

@ -4,6 +4,9 @@ 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"
)
@ -12,6 +15,8 @@ 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 = ""
signal text_submitted(value: String)
@ -22,10 +27,14 @@ enum Page {
}
var _enabled: bool = false
var _controller_mapping_manager: ControllerMappingManagerType
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: Label
var _page_buttons: Array[Button] = []
var _keys_host: VBoxContainer
@ -52,6 +61,21 @@ func _ready() -> void:
_build_interface()
hide()
set_process_input(true)
set_process(true)
func _process(delta: float) -> void:
if not visible:
return
_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:
@ -68,6 +92,12 @@ func is_open() -> bool:
return visible
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())
@ -88,23 +118,51 @@ func _input(event: InputEvent) -> void:
return
if visible:
var joy_motion := event as InputEventJoypadMotion
if joy_motion != null and _handle_trigger_shortcut(joy_motion):
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 and joy_button.pressed:
if joy_button.button_index == JOY_BUTTON_X:
_backspace()
get_viewport().set_input_as_handled()
return
if joy_button.button_index == JOY_BUTTON_LEFT_SHOULDER:
_close_keyboard(false)
get_viewport().set_input_as_handled()
return
if joy_button.button_index == JOY_BUTTON_RIGHT_SHOULDER:
_set_page(wrapi(int(_page) + 1, 0, Page.size()))
get_viewport().set_input_as_handled()
return
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()
@ -114,8 +172,15 @@ func _input(event: InputEvent) -> void:
joy_event == null
or not joy_event.pressed
or (
joy_event.button_index != JOY_BUTTON_A
and not event.is_action_pressed(&"ui_accept")
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
@ -123,6 +188,17 @@ func _input(event: InputEvent) -> void:
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,
@ -164,9 +240,12 @@ func _open_for(control: Control) -> void:
)
_target.set("virtual_keyboard_enabled", false)
_buffer = str(_target.get("text"))
_buffer_caret = _buffer.length()
_set_target_caret(_buffer_caret)
_page = Page.LOWER
_attach_to_target_window(control)
show()
_reset_caret_blink()
_refresh_preview()
_rebuild_keys()
@ -179,6 +258,8 @@ func _close_keyboard(restore_focus: bool) -> void:
_target_virtual_keyboard_enabled
)
hide()
_left_trigger_pressed = false
_right_trigger_pressed = false
get_viewport().gui_release_focus()
_restore_portable_host()
_target = null
@ -283,15 +364,13 @@ func _move_caret(direction: int) -> void:
if not is_instance_valid(_target):
_close_keyboard(false)
return
var caret: int = clampi(
_buffer_caret = clampi(
_get_caret_column() + direction,
0,
_buffer.length(),
)
if _target is LineEdit:
(_target as LineEdit).caret_column = caret
elif _target is TextEdit:
(_target as TextEdit).set_caret_column(caret)
_set_target_caret(_buffer_caret)
_reset_caret_blink()
_refresh_preview()
@ -320,28 +399,44 @@ func _handle_trigger_shortcut(event: InputEventJoypadMotion) -> bool:
func _get_caret_column() -> int:
if _target is LineEdit:
return clampi(
(_target as LineEdit).caret_column,
0,
_buffer.length()
)
return _buffer.length()
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.caret_column = caret
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
@ -349,7 +444,58 @@ 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())
_preview.text = displayed_text.insert(caret, "|")
var caret_glyph: String = CARET_GLYPH if _caret_visible else ""
_preview.text = displayed_text.insert(caret, caret_glyph)
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):
_focus_first_key()
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):
_focus_first_key()
focused = get_viewport().gui_get_focus_owner()
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 _set_page(page_index: int) -> void:

View file

@ -219,6 +219,8 @@ func consume_escape() -> bool:
func handle_controller_input(event: InputEvent) -> bool:
if not _profile_active or not _profile_interactive:
return false
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
_ensure_controller_zone_focus()
var cancel_pressed := _event_matches_controller_press(
event,
&"ui_cancel",
@ -395,23 +397,7 @@ func _apply_controller_zone_focus() -> void:
func _focus_controller_zone() -> void:
if not _profile_interactive:
return
var controls: Array[Control] = []
match _controller_zone:
ControllerZone.ACCOUNT:
controls = _account_controller_controls()
ControllerZone.CATEGORIES:
controls = _controls_under(_category_list)
ControllerZone.OPTIONS:
var groups: Array = _controller_option_groups()
if not groups.is_empty():
for item: Variant in groups[clampi(
_controller_option_depth, 0, groups.size() - 1
)]:
var option_control := item as Control
if option_control != null:
controls.append(option_control)
ControllerZone.COLOR_PICKER:
controls = _color_picker_controller_controls()
var controls: Array[Control] = _active_controller_zone_controls()
for control: Control in controls:
var button := control as BaseButton
if (
@ -429,6 +415,55 @@ func _focus_controller_zone() -> void:
return
func _ensure_controller_zone_focus() -> void:
var controls: Array[Control] = _active_controller_zone_controls()
if controls.is_empty():
if _controller_zone == ControllerZone.OPTIONS:
_controller_zone = ControllerZone.CATEGORIES
_controller_option_depth = 0
controls = _active_controller_zone_controls()
if controls.is_empty():
return
var focused: Control = get_viewport().gui_get_focus_owner()
if (
focused != null
and focused in controls
and focused.is_visible_in_tree()
and focused.focus_mode != Control.FOCUS_NONE
):
return
_apply_controller_zone_focus()
_focus_controller_zone()
func _active_controller_zone_controls() -> Array[Control]:
if (
_discard_confirmation != null
and _discard_confirmation.visible
):
return [_confirmation_confirm, _keep_editing_button]
match _controller_zone:
ControllerZone.ACCOUNT:
return _account_controller_controls()
ControllerZone.CATEGORIES:
return _controls_under(_category_list)
ControllerZone.OPTIONS:
var groups: Array = _controller_option_groups()
if groups.is_empty():
return []
var controls: Array[Control] = []
for item: Variant in groups[clampi(
_controller_option_depth, 0, groups.size() - 1
)]:
var option_control := item as Control
if option_control != null:
controls.append(option_control)
return controls
ControllerZone.COLOR_PICKER:
return _color_picker_controller_controls()
return []
func _account_controller_controls() -> Array[Control]:
var controls: Array[Control] = []
for control: Control in [
@ -451,21 +486,23 @@ func _controller_option_groups() -> Array:
if _option_list == null:
return groups
if _category_id != "fur_pattern":
groups.append(_controls_under(_option_list))
var option_controls: Array[Control] = _controls_under(_option_list)
if not option_controls.is_empty():
groups.append(option_controls)
return groups
var section_tabs := _option_list.find_child(
"FurSectionTabs", true, false
) as Node
groups.append(_controls_under(section_tabs))
_append_controller_group(groups, _controls_under(section_tabs))
if _active_fur_section == FUR_SECTION_PATTERNS:
groups.append(_controls_under(_option_list.find_child(
_append_controller_group(groups, _controls_under(_option_list.find_child(
"FurPatternPartTabs", true, false
)))
groups.append(_controls_under(_option_list.find_child(
_append_controller_group(groups, _controls_under(_option_list.find_child(
"FurPatternGrid", true, false
)))
else:
groups.append(_controls_under(_option_list.find_child(
_append_controller_group(groups, _controls_under(_option_list.find_child(
"FurColorChannelGrid", true, false
)))
var palette_controls: Array[Control] = _controls_under(
@ -476,10 +513,15 @@ func _controller_option_groups() -> Array:
) as Control
if custom_picker != null:
palette_controls.append(custom_picker)
groups.append(palette_controls)
_append_controller_group(groups, palette_controls)
return groups
func _append_controller_group(groups: Array, controls: Array[Control]) -> void:
if not controls.is_empty():
groups.append(controls)
func _controls_under(root: Node) -> Array[Control]:
var controls: Array[Control] = []
if root == null:

View file

@ -11,6 +11,7 @@ const ControllerFocusNavigationType = preload(
@export var focus_paths: Array[NodePath] = []
@export var initial_focus_path: NodePath
@export var back_focus_path: NodePath
@export var back_entry_path: NodePath
@export var maximum_layout_size: Vector2 = Vector2(720.0, 520.0)
@export var compact_maximum_layout_size: Vector2 = Vector2.ZERO
@export var compact_width_threshold: float = 680.0
@ -146,6 +147,23 @@ func focus_back() -> void:
back.grab_focus()
func set_controller_focus_scope(controls: Array[Control]) -> void:
for focus_control: Control in _focus_controls:
focus_control.focus_mode = Control.FOCUS_NONE
for control: Control in controls:
if control != null and control.is_visible_in_tree():
control.focus_mode = Control.FOCUS_ALL
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
func restore_controller_focus_scope() -> void:
if not visible or _is_transitioning:
return
for focus_control: Control in _focus_controls:
focus_control.focus_mode = Control.FOCUS_ALL
_configure_focus_navigation()
func get_page_id() -> StringName:
return page_id
@ -173,13 +191,13 @@ func _update_layout() -> void:
_cluster.position = _resting_cluster_position
_cluster.size = field_size
_cluster.apply_layout(field_size, compact)
ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls)
_configure_focus_navigation()
func _configure_focus_order() -> void:
for bubble: BubbleButton in _bubbles:
bubble.focus_mode = Control.FOCUS_NONE
ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls)
_configure_focus_navigation()
func _set_interactive(interactive: bool) -> void:
@ -203,16 +221,26 @@ func _set_interactive(interactive: bool) -> void:
if interactive:
call_deferred("_refresh_focus_navigation")
else:
ControllerFocusNavigationType.configure_spatial_neighbors(
_focus_controls
)
_configure_focus_navigation()
func _refresh_focus_navigation() -> void:
if visible and not _is_transitioning:
ControllerFocusNavigationType.configure_spatial_neighbors(
_focus_controls
)
_configure_focus_navigation()
func _configure_focus_navigation() -> void:
ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls)
if back_entry_path.is_empty() or back_focus_path.is_empty():
return
var entry := get_node_or_null(back_entry_path) as Control
var back := get_node_or_null(back_focus_path) as Control
if (
ControllerFocusNavigationType.is_focusable(entry)
and ControllerFocusNavigationType.is_focusable(back)
):
entry.focus_neighbor_bottom = entry.get_path_to(back)
back.focus_neighbor_top = back.get_path_to(entry)
func _finish_transition_out(generation: int, completed: Callable) -> void:

View file

@ -131,6 +131,7 @@ var _pending_identity_operation := ""
var _pending_identity_type := ""
var _pending_identity_path := ""
var _pending_import_data: Dictionary = {}
var _active_adjustment_group: Dictionary = {}
func _ready() -> void:
@ -293,6 +294,56 @@ func setup_data_and_identity(
_refresh_data_page()
func _input(event: InputEvent) -> void:
if not visible or is_input_mapping_capturing():
return
var mouse_button := event as InputEventMouseButton
if (
mouse_button != null
and mouse_button.pressed
and not _active_adjustment_group.is_empty()
):
_exit_adjustment_zone(false)
return
var button := event as InputEventJoypadButton
if button == null or not button.pressed:
return
if (
not _active_adjustment_group.is_empty()
and _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_B,
&"ui_cancel",
JOY_BUTTON_B,
)
):
_exit_adjustment_zone(true)
get_viewport().set_input_as_handled()
return
if not _event_matches_controller_role(
event,
ControllerMappingManagerType.ROLE_A,
&"ui_accept",
JOY_BUTTON_A,
):
return
if _active_adjustment_group.is_empty():
var group: Dictionary = _adjustment_group_for_control(
get_viewport().gui_get_focus_owner()
)
if group.is_empty():
return
_enter_adjustment_zone(group)
get_viewport().set_input_as_handled()
return
var focused := get_viewport().gui_get_focus_owner() as BaseButton
var decrease := _active_adjustment_group.get("decrease") as BaseButton
var increase := _active_adjustment_group.get("increase") as BaseButton
if focused == decrease or focused == increase:
focused.pressed.emit()
get_viewport().set_input_as_handled()
func open_panel(
settings_manager: SettingsManagerType,
presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL,
@ -370,6 +421,9 @@ func _finish_panel_close(applied_result: bool) -> void:
func handle_back() -> void:
if not _active_adjustment_group.is_empty():
_exit_adjustment_zone(true)
return
if (
_keyboard_mouse_mapping_panel != null
and _keyboard_mouse_mapping_panel.is_open()
@ -440,9 +494,105 @@ func _push_page(page_id: StringName) -> void:
or get_active_page_id() == page_id
):
return
_exit_adjustment_zone(false)
_start_page_transition(page_id, true)
func _adjustment_group_for_control(control: Control) -> Dictionary:
for group: Dictionary in _adjustment_groups():
if group.get("parent") as Control == control:
return group
return {}
func _adjustment_groups() -> Array[Dictionary]:
return [
{
"page": _controls_page,
"parent": _mouse_value,
"decrease": %MouseDecrease,
"increase": %MouseIncrease,
},
{
"page": _controls_page,
"parent": _controller_value,
"decrease": %ControllerDecrease,
"increase": %ControllerIncrease,
},
{
"page": _accessibility_page,
"parent": _auto_click_interval,
"decrease": %IntervalDecrease,
"increase": %IntervalIncrease,
},
]
func _enter_adjustment_zone(group: Dictionary) -> void:
var page := group.get("page") as SettingsBubblePage
var parent := group.get("parent") as Control
var decrease := group.get("decrease") as Control
var increase := group.get("increase") as Control
if page == null or parent == null or decrease == null or increase == null:
return
_active_adjustment_group = group
var controls: Array[Control] = [decrease, parent, increase]
page.set_controller_focus_scope(controls)
_set_adjustment_neighbors(decrease, parent, increase)
parent.grab_focus()
func _exit_adjustment_zone(restore_parent_focus: bool) -> void:
if _active_adjustment_group.is_empty():
return
var page := _active_adjustment_group.get("page") as SettingsBubblePage
var parent := _active_adjustment_group.get("parent") as Control
var decrease := _active_adjustment_group.get("decrease") as Control
var increase := _active_adjustment_group.get("increase") as Control
_active_adjustment_group.clear()
for control: Control in [decrease, increase]:
if control != null:
control.focus_mode = Control.FOCUS_NONE
if page != null:
page.restore_controller_focus_scope()
if restore_parent_focus and parent != null:
parent.call_deferred("grab_focus")
func _set_adjustment_neighbors(
decrease: Control,
parent: Control,
increase: Control,
) -> void:
decrease.focus_neighbor_left = decrease.get_path_to(decrease)
decrease.focus_neighbor_right = decrease.get_path_to(parent)
parent.focus_neighbor_left = parent.get_path_to(decrease)
parent.focus_neighbor_right = parent.get_path_to(increase)
increase.focus_neighbor_left = increase.get_path_to(parent)
increase.focus_neighbor_right = increase.get_path_to(increase)
for control: Control in [decrease, parent, increase]:
control.focus_neighbor_top = control.get_path_to(control)
control.focus_neighbor_bottom = control.get_path_to(control)
func _event_matches_controller_role(
event: InputEvent,
role: StringName,
action: 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
or event.is_action_pressed(action)
)
)
func _start_page_transition(page_id: StringName, push_page: bool) -> void:
var outgoing_page: SettingsBubblePage = _get_active_page()
var incoming_page: SettingsBubblePage = _pages.get(page_id)

View file

@ -425,6 +425,7 @@ bubble_paths = Array[NodePath]([NodePath("BubbleCluster/SoundBackButton")])
focus_paths = Array[NodePath]([NodePath("Paper/Content/VolumeGrid/MasterVolumeSlider"), NodePath("Paper/Content/VolumeGrid/MusicVolumeSlider"), NodePath("Paper/Content/VolumeGrid/EffectsVolumeSlider"), NodePath("Paper/Content/VolumeGrid/EnvironmentVolumeSlider"), NodePath("BubbleCluster/SoundBackButton")])
initial_focus_path = NodePath("Paper/Content/VolumeGrid/MasterVolumeSlider")
back_focus_path = NodePath("BubbleCluster/SoundBackButton")
back_entry_path = NodePath("Paper/Content/VolumeGrid/EnvironmentVolumeSlider")
compact_maximum_layout_size = Vector2(544, 400)
[node name="BubbleCluster" type="Control" parent="SoundPage"]
@ -601,7 +602,7 @@ grow_vertical = 2
script = ExtResource("3_page")
page_id = &"controls"
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseDecrease"), NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/MouseIncrease"), NodePath("BubbleCluster/ControllerDecrease"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/ControllerIncrease"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/MouseValue"), NodePath("BubbleCluster/ControllerValue"), NodePath("BubbleCluster/InvertYToggle"), NodePath("BubbleCluster/OnScreenKeyboardToggle"), NodePath("BubbleCluster/ControllerMapping"), NodePath("BubbleCluster/KeyboardMapping"), NodePath("BubbleCluster/ControlsBackButton")])
initial_focus_path = NodePath("BubbleCluster/MouseValue")
back_focus_path = NodePath("BubbleCluster/ControlsBackButton")
@ -758,7 +759,7 @@ grow_vertical = 2
script = ExtResource("3_page")
page_id = &"accessibility"
bubble_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/IntervalHelp"), NodePath("BubbleCluster/AccessibilityBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/IntervalDecrease"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/IntervalIncrease"), NodePath("BubbleCluster/AccessibilityBackButton")])
focus_paths = Array[NodePath]([NodePath("BubbleCluster/AutoClickToggle"), NodePath("BubbleCluster/AutoClickIntervalValue"), NodePath("BubbleCluster/AccessibilityBackButton")])
initial_focus_path = NodePath("BubbleCluster/AutoClickToggle")
back_focus_path = NodePath("BubbleCluster/AccessibilityBackButton")

View file

@ -11,6 +11,9 @@ const ControllerFocusPresentationType = preload(
const ControllerFocusRecoveryType = preload(
"res://ui/controller_focus_recovery.gd"
)
const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd")
signal effective_pixel_size_changed(
@ -60,6 +63,12 @@ func set_on_screen_keyboard_enabled(enabled: bool) -> void:
_on_screen_keyboard.set_enabled(enabled)
func setup_controller_mapping(
mapping_manager: ControllerMappingManagerType,
) -> void:
_on_screen_keyboard.setup_controller_mapping(mapping_manager)
func set_pixel_size(pixel_size: int) -> void:
_requested_pixel_size = clampi(
pixel_size,