feat: overhaul controller menu accessibility

This commit is contained in:
Alexander Sellite 2026-08-15 19:20:42 -04:00
parent a7837c3711
commit 4f9c85989b
46 changed files with 4091 additions and 396 deletions

View file

@ -69,6 +69,34 @@ func _run() -> void:
"inversion clears when a focused control is defocused",
)
var scroll := ScrollContainer.new()
scroll.position = Vector2(0.0, 80.0)
scroll.size = Vector2(180.0, 90.0)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
stage.add_child(scroll)
var list := VBoxContainer.new()
list.custom_minimum_size.x = 160.0
scroll.add_child(list)
var last_scroll_button: Button
for index: int in 8:
var scroll_button := Button.new()
scroll_button.text = "scroll option %d" % index
scroll_button.custom_minimum_size = Vector2(160.0, 40.0)
list.add_child(scroll_button)
last_scroll_button = scroll_button
await process_frame
last_scroll_button.grab_focus()
await process_frame
await process_frame
_expect(
scroll.scroll_vertical > 0,
"controller focus scrolls an off-screen selection into view",
)
_expect(
root.gui_get_focus_owner() == last_scroll_button,
"scroll following preserves the selected controller control",
)
stage.queue_free()
if _failures.is_empty():
print("Controller focus presentation validation: PASS")

View file

@ -94,6 +94,16 @@ func _validate_manager(manager: ControllerMappingManagerType) -> String:
ControllerMappingManagerType.ROLE_ORDER.size(),
str(defaults.keys()),
]
if ControllerMappingManagerType.ROLE_LABELS.get(
ControllerMappingManagerType.ROLE_RIGHT_STICK_CLICK, ""
) != "sneak":
return "right-stick click is not presented as sneak"
if ControllerMappingManagerType.BUTTON_ACTION_ROLES.get(
&"sneak", &""
) != ControllerMappingManagerType.ROLE_RIGHT_STICK_CLICK:
return "right-stick click is not assigned to the sneak action"
if not _has_joy_button(&"sneak", JOY_BUTTON_RIGHT_STICK):
return "sneak does not default to right-stick click"
var trigger_button := InputEventJoypadButton.new()
trigger_button.device = manager.get_active_device_id()
trigger_button.button_index = JOY_BUTTON_MISC1
@ -226,10 +236,41 @@ func _validate_portmaster_launcher() -> String:
func _validate_auto_map(manager: ControllerMappingManagerType) -> String:
if manager.get_role_prompt(ControllerMappingManagerType.ROLE_LT) != (
"press or squeeze LT"
):
return "auto-map does not identify the LT control"
if manager.get_role_prompt(ControllerMappingManagerType.ROLE_RT) != (
"press or squeeze RT"
):
return "auto-map does not identify the RT control"
var panel := ControllerMappingPanelType.new()
root.add_child(panel)
panel.setup(manager)
panel.open_panel()
var mapping_scrolls: Array[Node] = panel.find_children(
"", "ScrollContainer", true, false
)
if mapping_scrolls.is_empty():
return "controller mapper does not contain its mapping list"
if not (mapping_scrolls[0] as ScrollContainer).follow_focus:
return "controller mapper does not scroll to follow controller focus"
var first_binding := panel._binding_buttons.get(
ControllerMappingManagerType.ROLE_ORDER.front()
) as Button
var last_binding := panel._binding_buttons.get(
ControllerMappingManagerType.ROLE_ORDER.back()
) as Button
if first_binding.get_node(first_binding.focus_neighbor_top) != first_binding:
return "controller mapper loses focus above its first row"
if last_binding.get_node(last_binding.focus_neighbor_bottom) != (
panel._close_button
):
return "controller mapper cannot reach Done from its last row"
if panel._close_button.get_node(
panel._close_button.focus_neighbor_top
) != last_binding:
return "controller mapper cannot return from Done to its last row"
panel._begin_auto_map()
for role: StringName in ControllerMappingManagerType.ROLE_ORDER:
panel._process(ControllerMappingPanelType.CAPTURE_NEUTRAL_SECONDS)
@ -391,3 +432,11 @@ func _keyboard_event_count(action: StringName) -> int:
if event is InputEventKey or event is InputEventMouseButton:
count += 1
return count
func _has_joy_button(action: StringName, button_index: JoyButton) -> bool:
for event: InputEvent in InputMap.action_get_events(action):
var button := event as InputEventJoypadButton
if button != null and button.button_index == button_index:
return true
return false

View file

@ -0,0 +1,457 @@
extends SceneTree
const JoinGamePageScene = preload(
"res://ui/network/join_game_page.tscn"
)
const SettingsPanelScene = preload("res://ui/settings_panel.tscn")
const BubbleConfirmationScene = preload(
"res://ui/components/bubble_menu/bubble_confirmation_page.tscn"
)
const TitleConfirmationScene = preload(
"res://ui/title_confirmation_bubble_page.tscn"
)
const MailPageType = preload("res://ui/mail_page.gd")
const ProfilePageType = preload("res://ui/profile_page.gd")
const DialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
)
var _failures: Array[String] = []
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
root.size = Vector2i(1280, 720)
await _validate_join_game_navigation()
await _validate_data_settings_navigation()
await _validate_mail_navigation()
await _validate_profile_confirmation_focus()
await _validate_confirmation_dialog_navigation()
await _validate_bubble_confirmation_navigation()
_validate_mapping_capture_contract()
if _failures.is_empty():
print("Controller menu accessibility validation: PASS")
quit(0)
return
for failure: String in _failures:
push_error(failure)
quit(1)
func _validate_join_game_navigation() -> void:
var page := JoinGamePageScene.instantiate() as Control
root.add_child(page)
await process_frame
page.show()
var discover := page.get_node("%DiscoverButton") as Button
var direct := page.get_node("%DirectButton") as Button
var saved := page.get_node("%SavedButton") as Button
var recent := page.get_node("%RecentButton") as Button
var address := page.get_node("%Address") as LineEdit
var name_edit := page.get_node("%NameEdit") as LineEdit
var server_list := page.get_node("%ServerList") as ItemList
var refresh := page.get_node("%RefreshButton") as Button
var join := page.get_node("%JoinButton") as Button
var save := page.get_node("%SaveButton") as Button
var edit := page.get_node("%EditButton") as Button
var favorite := page.get_node("%FavoriteButton") as Button
var delete := page.get_node("%DeleteButton") as Button
var cancel := page.get_node("%CancelButton") as Button
var back := page.get_node("%BackButton") as Button
var modes: Array[Control] = [discover, direct, saved, recent]
address.hide()
name_edit.hide()
server_list.show()
_set_button_state(refresh, true)
_set_button_state(join, true)
_set_button_state(save, false)
_set_button_state(edit, false)
_set_button_state(favorite, false)
_set_button_state(delete, false)
_set_button_state(cancel, false)
_set_button_state(back, true)
page.set("_mode", 0)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(discover, &"focus_neighbor_bottom", server_list)
_assert_neighbor(server_list, &"focus_neighbor_top", discover)
_assert_neighbor(server_list, &"focus_neighbor_bottom", refresh)
_assert_neighbor(refresh, &"focus_neighbor_right", join)
_assert_neighbor(back, &"focus_neighbor_left", join)
var discover_controls: Array[Control] = modes.duplicate()
discover_controls.append_array([server_list, refresh, join, back])
_assert_directionally_reachable(discover, discover_controls)
address.show()
address.editable = true
server_list.hide()
_set_button_state(refresh, false)
_set_button_state(join, true)
_set_button_state(save, true)
page.set("_mode", 1)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(direct, &"focus_neighbor_bottom", address)
_assert_neighbor(address, &"focus_neighbor_top", direct)
_assert_neighbor(address, &"focus_neighbor_bottom", join)
var direct_controls: Array[Control] = modes.duplicate()
direct_controls.append_array([address, join, save, back])
_assert_directionally_reachable(direct, direct_controls)
name_edit.show()
name_edit.editable = true
page.set("_name_entry_active", true)
page.call("_configure_controller_navigation")
await process_frame
_assert_neighbor(address, &"focus_neighbor_bottom", name_edit)
_assert_neighbor(name_edit, &"focus_neighbor_top", address)
page.call("request_back")
_expect(
not bool(page.get("_name_entry_active")),
"Join-game Back should leave the server-name edit substate.",
)
_expect(
page.visible,
"Join-game Back should not close the browser from an edit substate.",
)
await process_frame
page.queue_free()
await process_frame
func _validate_data_settings_navigation() -> void:
var panel := SettingsPanelScene.instantiate() as Control
root.add_child(panel)
await process_frame
panel.show()
var data_page := panel.get_node("%DataPage") as SettingsBubblePage
data_page.show_page(false)
for _frame: int in 2:
await process_frame
var controls: Array[Control] = [
panel.get_node("%OpenDataFolder") as Control,
panel.get_node("%ChangeDataFolder") as Control,
panel.get_node("%CopyPlayerFingerprint") as Control,
panel.get_node("%ExportPlayerIdentity") as Control,
panel.get_node("%ImportPlayerIdentity") as Control,
panel.get_node("%ExportHostIdentity") as Control,
panel.get_node("%ImportHostIdentity") as Control,
panel.get_node("%DataBackButton") as Control,
]
for control: Control in controls:
_expect(
control.focus_mode == Control.FOCUS_ALL,
"Data & Identity control %s is not controller-focusable."
% control.name,
)
_assert_directionally_reachable(controls.front(), controls)
panel.queue_free()
await process_frame
func _validate_mail_navigation() -> void:
var page := MailPageType.new() as Control
root.add_child(page)
await process_frame
page.call("activate")
page.call("set_interactive", true)
var inbox_list := page.get("_inbox_list") as VBoxContainer
var entry := Button.new()
entry.text = "test letter"
entry.custom_minimum_size = Vector2(1008.0, 54.0)
inbox_list.add_child(entry)
await process_frame
page.call("_refresh_controller_navigation")
var archive_view := page.get("_archive_view_button") as Button
var send_mail := page.get("_send_mail_button") as Button
_assert_neighbor(
archive_view, &"focus_neighbor_right", send_mail
)
_assert_neighbor(archive_view, &"focus_neighbor_bottom", entry)
_assert_neighbor(send_mail, &"focus_neighbor_bottom", entry)
_assert_neighbor(entry, &"focus_neighbor_top", archive_view)
_assert_directionally_reachable(
archive_view, [archive_view, send_mail, entry]
)
var inbox := page.get("_inbox") as Control
var compose := page.get("_compose") as Control
var letter := page.get("_letter") as Control
inbox.hide()
compose.hide()
letter.show()
var accept := page.get("_accept") as Button
var decline := page.get("_decline") as Button
var close := page.get("_letter_close") as Button
var archive := page.get("_archive") as Button
var delete := page.get("_delete") as Button
accept.show()
decline.show()
delete.disabled = false
page.call("_refresh_controller_navigation")
_assert_neighbor(accept, &"focus_neighbor_bottom", decline)
_assert_neighbor(decline, &"focus_neighbor_bottom", delete)
_assert_neighbor(delete, &"focus_neighbor_left", archive)
_assert_neighbor(archive, &"focus_neighbor_left", close)
_assert_neighbor(close, &"focus_neighbor_top", close)
_assert_directionally_reachable(
accept, [accept, decline, close, archive, delete]
)
page.queue_free()
await process_frame
func _validate_profile_confirmation_focus() -> void:
var page := ProfilePageType.new() as Control
root.add_child(page)
await process_frame
page.call("activate")
page.call("set_interactive", true)
page.call("reset_controller_zone")
var suggestions := page.get("_suggestions") as HBoxContainer
var suggestion := Button.new()
suggestion.text = "alternate name"
suggestions.add_child(suggestion)
page.call("_apply_controller_zone_focus")
var account_controls: Array[Control] = []
for item: Variant in page.call("_account_controller_controls"):
account_controls.append(item as Control)
var preview := page.get("_preview") as Control
var reset_view := page.get("_reset_view_button") as Button
_expect(
suggestion in account_controls,
"Profile name-conflict choices are outside the account zone.",
)
_expect(
suggestion.focus_mode == Control.FOCUS_ALL,
"Profile name-conflict choices are not controller-focusable.",
)
_expect(
preview.focus_mode == Control.FOCUS_NONE,
"The profile preview must not take controller focus.",
)
_expect(
reset_view.focus_mode == Control.FOCUS_NONE,
"The profile reset-view button must not take controller focus.",
)
page.call("_show_confirmation", "defaults")
var confirmation := page.get("_discard_confirmation") as Control
var confirm := page.get("_confirmation_confirm") as Button
var keep_editing := page.get("_keep_editing_button") as Button
_expect(confirmation.visible, "The profile confirmation did not open.")
_expect(
confirm.focus_mode == Control.FOCUS_ALL,
"The profile confirmation action is not controller-focusable.",
)
_expect(
keep_editing.focus_mode == Control.FOCUS_ALL,
"The profile confirmation cancel action is not controller-focusable.",
)
for control: Control in account_controls:
_expect(
control.focus_mode == Control.FOCUS_NONE,
"Profile confirmation leaked focus to %s." % control.name,
)
var cancel_event := InputEventAction.new()
cancel_event.action = &"ui_cancel"
cancel_event.pressed = true
_expect(
bool(page.call("handle_controller_input", cancel_event)),
"Profile confirmation did not consume controller Back.",
)
_expect(
not confirmation.visible,
"Controller Back did not close the profile confirmation.",
)
for control: Control in account_controls:
_expect(
control.focus_mode == Control.FOCUS_ALL,
"Profile account focus was not restored after confirmation.",
)
page.queue_free()
await process_frame
func _validate_mapping_capture_contract() -> void:
var title_source: String = FileAccess.get_file_as_string(
"res://ui/title_screen.gd"
)
var game_ui_source: String = FileAccess.get_file_as_string(
"res://ui/game_ui.gd"
)
var chat_source: String = FileAccess.get_file_as_string(
"res://ui/chat_ui.gd"
)
var main_source: String = FileAccess.get_file_as_string(
"res://main/main.gd"
)
_expect(
title_source.contains(
"_settings_panel.is_input_mapping_capturing()"
),
"Title input does not respect every active binding capture.",
)
_expect(
game_ui_source.contains("or is_input_mapping_capturing()"),
"Controller menu scrolling does not respect every binding capture.",
)
_expect(
chat_source.contains("func _configure_controller_focus()"),
"Chat does not author controller routes for its exterior controls.",
)
_expect(
chat_source.contains(
"Control.FOCUS_ALL if _opened else Control.FOCUS_NONE"
),
"Passive chat controls can steal world controller focus.",
)
_expect(
main_source.contains("func _configure_popup_dialog("),
"Runtime popup dialogs do not receive authored controller routes.",
)
func _validate_confirmation_dialog_navigation() -> void:
var dialog := ConfirmationDialog.new()
dialog.ok_button_text = "continue"
var fields := VBoxContainer.new()
var first := LineEdit.new()
first.placeholder_text = "passphrase"
first.custom_minimum_size = Vector2(420.0, 42.0)
fields.add_child(first)
var second := LineEdit.new()
second.placeholder_text = "confirm passphrase"
second.custom_minimum_size = Vector2(420.0, 42.0)
fields.add_child(second)
dialog.add_child(fields)
root.add_child(dialog)
dialog.popup_centered(Vector2i(560, 300))
for _frame: int in 2:
await process_frame
DialogControllerNavigationType.configure_scope(dialog, first)
await process_frame
var controls: Array[Control] = (
DialogControllerNavigationType.interactive_controls(dialog)
)
_expect(
controls.has(first) and controls.has(second),
"Confirmation-dialog text fields are not controller reachable.",
)
_expect(
controls.has(dialog.get_ok_button())
and controls.has(dialog.get_cancel_button()),
"Confirmation-dialog actions are not controller reachable.",
)
_assert_directionally_reachable(first, controls)
_expect(
dialog.gui_get_focus_owner() == first,
"Confirmation dialog did not focus its requested entry control.",
)
dialog.hide()
await process_frame
root.gui_release_focus()
dialog.popup_centered(Vector2i(560, 300))
await process_frame
DialogControllerNavigationType.configure_scope(dialog, first)
await process_frame
_expect(
dialog.gui_get_focus_owner() == first,
"A reopened dialog did not restore controller focus.",
)
dialog.queue_free()
await process_frame
func _validate_bubble_confirmation_navigation() -> void:
for scene: PackedScene in [
BubbleConfirmationScene,
TitleConfirmationScene,
]:
var page := scene.instantiate() as Control
root.add_child(page)
await process_frame
page.show()
page.call("_set_interactive", true)
var confirm := page.get_node("BubbleCluster/ConfirmButton") as Button
var cancel := page.get_node("BubbleCluster/CancelButton") as Button
_assert_neighbor(confirm, &"focus_neighbor_left", cancel)
_assert_neighbor(confirm, &"focus_neighbor_right", cancel)
_assert_neighbor(confirm, &"focus_neighbor_top", confirm)
_assert_neighbor(confirm, &"focus_neighbor_bottom", confirm)
_assert_neighbor(cancel, &"focus_neighbor_left", confirm)
_assert_neighbor(cancel, &"focus_neighbor_right", confirm)
_assert_neighbor(cancel, &"focus_neighbor_top", cancel)
_assert_neighbor(cancel, &"focus_neighbor_bottom", cancel)
page.queue_free()
await process_frame
func _set_button_state(button: Button, shown: bool) -> void:
button.visible = shown
button.disabled = false
func _assert_neighbor(
origin: Control,
property: StringName,
expected: Control,
) -> void:
var path: NodePath = origin.get(property)
_expect(
not path.is_empty(),
"%s has no %s neighbor." % [origin.name, property],
)
if path.is_empty():
return
var actual := origin.get_node_or_null(path) as Control
_expect(
actual == expected,
"%s points %s to %s instead of %s."
% [
origin.name,
property,
actual.name if actual != null else "nothing",
expected.name,
],
)
func _assert_directionally_reachable(
start: Control,
controls: Array[Control],
) -> void:
var expected: Dictionary[int, bool] = {}
for control: Control in controls:
expected[control.get_instance_id()] = true
var visited: Dictionary[int, bool] = {start.get_instance_id(): true}
var pending: Array[Control] = [start]
while not pending.is_empty():
var current: Control = pending.pop_front()
for path: NodePath in [
current.focus_neighbor_left,
current.focus_neighbor_right,
current.focus_neighbor_top,
current.focus_neighbor_bottom,
]:
var neighbor := current.get_node_or_null(path) as Control
if (
neighbor == null
or not expected.has(neighbor.get_instance_id())
or visited.has(neighbor.get_instance_id())
):
continue
visited[neighbor.get_instance_id()] = true
pending.append(neighbor)
_expect(
visited.size() == expected.size(),
"Only %d of %d controls are directionally reachable from %s."
% [visited.size(), expected.size(), start.name],
)
func _expect(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)

View file

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

View file

@ -14,7 +14,12 @@ func _initialize() -> void:
func _run() -> void:
_validate_spatial_navigation()
_validate_strict_directional_navigation()
_validate_disabled_control_exclusion()
_validate_traversal_cycle()
_validate_controller_hierarchy_contract()
_validate_player_page_zone_contracts()
_validate_shop_navigation_contract()
_validate_four_by_three_centering()
_validate_low_end_profile_contract()
print("Controller UI navigation validation: PASS")
@ -38,16 +43,118 @@ func _validate_spatial_navigation() -> void:
host.queue_free()
func _validate_strict_directional_navigation() -> void:
var host := Control.new()
root.add_child(host)
var origin := _make_button(host, "origin", Vector2(200.0, 200.0))
var mostly_below := _make_button(
host, "mostly_below", Vector2(250.0, 400.0)
)
var controls: Array[Control] = [origin, mostly_below]
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
assert(origin.get_node(origin.focus_neighbor_right) == origin)
assert(origin.get_node(origin.focus_neighbor_top) == origin)
assert(origin.get_node(origin.focus_neighbor_bottom) == mostly_below)
host.queue_free()
func _validate_disabled_control_exclusion() -> void:
var host := Control.new()
root.add_child(host)
var left := _make_button(host, "left", Vector2(50.0, 100.0))
var disabled := _make_button(host, "disabled", Vector2(200.0, 100.0))
var right := _make_button(host, "right", Vector2(350.0, 100.0))
disabled.disabled = true
disabled.focus_next = disabled.get_path_to(right)
ControllerFocusNavigationType.configure_spatial_neighbors(
[left, disabled, right]
)
assert(left.get_node(left.focus_neighbor_right) == right)
assert(right.get_node(right.focus_neighbor_left) == left)
assert(disabled.focus_next.is_empty())
host.queue_free()
func _validate_traversal_cycle() -> void:
var host := Control.new()
root.add_child(host)
var first := _make_button(host, "first", Vector2(50.0, 50.0))
var second := _make_button(host, "second", Vector2(200.0, 50.0))
var third := _make_button(host, "third", Vector2(50.0, 150.0))
var controls: Array[Control] = [third, first, second]
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
assert(first.get_node(first.focus_next) == second)
assert(second.get_node(second.focus_next) == third)
assert(third.get_node(third.focus_next) == first)
assert(first.get_node(first.focus_previous) == third)
host.queue_free()
func _validate_controller_hierarchy_contract() -> void:
var source: String = FileAccess.get_file_as_string(
"res://ui/player_menu.gd"
)
assert(source.contains("ROLE_POINTER_MODIFIER"))
assert(source.contains("ROLE_CAMERA_ZOOM"))
assert(source.contains("_handle_controller_secondary_switch"))
assert(source.contains("ROLE_LB"))
assert(source.contains("ROLE_RB"))
assert(not source.contains("_handle_controller_secondary_switch"))
assert(source.contains("ControllerOwnership.INVENTORY_TABS"))
assert(source.contains("ControllerOwnership.SORT_FILTER"))
assert(source.contains("_reset_controller_zone_for_section"))
assert(source.contains("preserve_tab_zone"))
assert(source.contains("active_tab.call_deferred(\"grab_focus\")"))
assert(source.contains("CONTROLLER_PICKUP_HOLD_SECONDS"))
assert(source.contains("_reserve_main_navigation_for_page_switching"))
assert(source.contains("configure_spatial_neighbors(candidates)"))
func _validate_player_page_zone_contracts() -> void:
for path: String in [
"res://ui/logbook_page.gd",
"res://ui/the_net_page.gd",
"res://ui/mail_page.gd",
"res://ui/profile_page.gd",
"res://ui/players_page.gd",
]:
var source: String = FileAccess.get_file_as_string(path)
assert(source.contains("func reset_controller_zone()"))
assert(source.contains("func handle_controller_input(event: InputEvent)"))
var profile_source: String = FileAccess.get_file_as_string(
"res://ui/profile_page.gd"
)
assert(profile_source.contains("ControllerZone.COLOR_PICKER"))
assert(profile_source.contains("_adjust_controller_color_gamut"))
assert(profile_source.contains("_customize_button.text = \"customize\""))
assert(profile_source.contains("_enter_controller_customization"))
assert(not profile_source.contains(
"if event.is_action_pressed(\"ui_down\"):\n"
+ "\t\t\t_controller_zone = ControllerZone.CATEGORIES"
))
assert(profile_source.contains(
"_reset_view_button.focus_mode = Control.FOCUS_NONE"
))
var preview_source: String = FileAccess.get_file_as_string(
"res://ui/profile_preview.gd"
)
assert(preview_source.contains("focus_mode = Control.FOCUS_NONE"))
assert(not preview_source.contains("grab_focus()"))
var mail_source: String = FileAccess.get_file_as_string(
"res://ui/mail_page.gd"
)
assert(mail_source.contains("_configure_compose_controller_navigation"))
assert(mail_source.contains("_set_compose_neighbors"))
var net_source: String = FileAccess.get_file_as_string(
"res://ui/the_net_page.gd"
)
assert(net_source.contains("ROLE_RIGHT_STICK_Y"))
func _validate_shop_navigation_contract() -> void:
var source: String = FileAccess.get_file_as_string(
"res://ui/fishing_shop.gd"
)
assert(source.contains("ROLE_LB"))
assert(source.contains("ROLE_RB"))
assert(source.contains("_configure_controller_focus"))
func _validate_four_by_three_centering() -> void:

View file

@ -0,0 +1,183 @@
extends SceneTree
const FileDialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
)
const InterfaceFontControllerType = preload(
"res://ui/interface_font_controller.gd"
)
const OnScreenKeyboardType = preload("res://ui/on_screen_keyboard.gd")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
var dialog := FileDialog.new()
dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
dialog.access = FileDialog.ACCESS_FILESYSTEM
dialog.use_native_dialog = false
root.add_child(dialog)
dialog.current_dir = "/tmp"
dialog.popup_centered(Vector2i(900, 540))
for _frame: int in 3:
await process_frame
FileDialogControllerNavigationType.configure(dialog)
await process_frame
var root_scope: Window = (
FileDialogControllerNavigationType.active_scope(dialog)
)
assert(root_scope == dialog)
var root_controls: Array[Control] = (
FileDialogControllerNavigationType.interactive_controls(root_scope)
)
var directory_list: ItemList
var create_folder_button: Button
var path_edit: LineEdit
var has_menu_button: bool = false
var has_cancel: bool = false
var has_select: bool = false
for control: Control in root_controls:
if (
control is ItemList
and control.accessibility_name == "Directories & Files:"
):
directory_list = control as ItemList
if control is LineEdit and control.accessibility_name == "Path:":
path_edit = control as LineEdit
var button := control as Button
if button != null:
create_folder_button = (
button
if button.tooltip_text == "Create a new folder."
else create_folder_button
)
has_cancel = has_cancel or button.text == "Cancel"
has_select = has_select or button.text == "Select Current Folder"
has_menu_button = has_menu_button or control is MenuButton
assert(directory_list != null)
assert(path_edit != null)
assert(create_folder_button != null)
assert(has_menu_button)
assert(has_cancel)
assert(has_select)
assert(dialog.gui_get_focus_owner() == directory_list)
_assert_directionally_reachable(directory_list, root_controls)
create_folder_button.pressed.emit()
for _frame: int in 3:
await process_frame
FileDialogControllerNavigationType.configure(dialog)
await process_frame
var folder_scope: Window = (
FileDialogControllerNavigationType.active_scope(dialog)
)
assert(folder_scope != null and folder_scope != dialog)
var folder_controls: Array[Control] = (
FileDialogControllerNavigationType.interactive_controls(folder_scope)
)
var folder_name_edit: LineEdit
var folder_cancel: Button
var folder_ok: Button
for control: Control in folder_controls:
if control is LineEdit:
folder_name_edit = control as LineEdit
var button := control as Button
if button != null and button.text == "Cancel":
folder_cancel = button
if button != null and button.text == "OK":
folder_ok = button
assert(folder_name_edit != null)
assert(folder_cancel != null)
assert(folder_ok != null)
assert(folder_scope.gui_get_focus_owner() == folder_name_edit)
_assert_directionally_reachable(folder_name_edit, folder_controls)
var keyboard := OnScreenKeyboardType.new()
root.add_child(keyboard)
keyboard.set_enabled(true)
folder_name_edit.grab_focus()
assert(keyboard.request_for_control(folder_name_edit))
assert(keyboard.is_open())
assert(keyboard.get_parent() == folder_scope)
keyboard.call("_close_keyboard", true)
var main_source: String = FileAccess.get_file_as_string(
"res://main/main.gd"
)
assert(main_source.contains("picker_scope != _data_folder_dialog"))
assert(main_source.contains("picker_scope.hide()"))
assert(main_source.contains("is_controller_text_entry_open()"))
dialog.queue_free()
keyboard.queue_free()
await process_frame
await _validate_compact_dialog()
print("File dialog controller navigation validation: PASS")
quit()
func _validate_compact_dialog() -> void:
root.size = Vector2i(640, 480)
var font_controller := InterfaceFontControllerType.new()
root.add_child(font_controller)
await process_frame
var dialog := FileDialog.new()
dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR
dialog.access = FileDialog.ACCESS_FILESYSTEM
dialog.use_native_dialog = false
root.add_child(dialog)
dialog.current_dir = "/tmp"
font_controller.popup_file_dialog(dialog)
for _frame: int in 4:
await process_frame
assert(dialog.size.x <= 616 and dialog.size.y <= 456)
var scope: Window = FileDialogControllerNavigationType.active_scope(dialog)
var controls: Array[Control] = (
FileDialogControllerNavigationType.interactive_controls(scope)
)
var directory_list: ItemList
for control: Control in controls:
if (
control is ItemList
and control.accessibility_name == "Directories & Files:"
):
directory_list = control as ItemList
break
assert(directory_list != null)
_assert_directionally_reachable(directory_list, controls)
dialog.queue_free()
font_controller.queue_free()
await process_frame
func _assert_directionally_reachable(
start: Control,
controls: Array[Control],
) -> void:
var expected: Dictionary[int, bool] = {}
for control: Control in controls:
expected[control.get_instance_id()] = true
var visited: Dictionary[int, bool] = {start.get_instance_id(): true}
var pending: Array[Control] = [start]
while not pending.is_empty():
var current: Control = pending.pop_front()
for path: NodePath in [
current.focus_neighbor_left,
current.focus_neighbor_right,
current.focus_neighbor_top,
current.focus_neighbor_bottom,
]:
var neighbor := current.get_node_or_null(path) as Control
if (
neighbor == null
or not expected.has(neighbor.get_instance_id())
or visited.has(neighbor.get_instance_id())
):
continue
visited[neighbor.get_instance_id()] = true
pending.append(neighbor)
assert(visited.size() == expected.size())

View file

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

View file

@ -0,0 +1,160 @@
extends SceneTree
const KeyboardMouseMappingManagerType = preload(
"res://settings/keyboard_mouse_mapping_manager.gd"
)
const KeyboardMouseMappingPanelType = preload(
"res://ui/keyboard_mouse_mapping_panel.gd"
)
const SettingsPanelScene = preload("res://ui/settings_panel.tscn")
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var manager := KeyboardMouseMappingManagerType.new()
root.add_child(manager)
await process_frame
var defaults: Dictionary = manager.get_active_bindings()
assert(
defaults.size()
== KeyboardMouseMappingManagerType.ROLE_ORDER.size()
)
assert(
str(defaults[
str(KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION)
].get("kind", "")) == "mouse_button"
)
assert(
str(defaults[
str(KeyboardMouseMappingManagerType.ROLE_CHAT)
].get("kind", "")) == "key"
)
var joypad_events_before: int = _event_count(&"jump", true)
var rebind_key := InputEventKey.new()
rebind_key.physical_keycode = KEY_R
rebind_key.pressed = true
var jump_binding: Dictionary = manager.binding_from_event(rebind_key)
assert(manager.validate_binding(jump_binding))
assert(manager.set_binding(
KeyboardMouseMappingManagerType.ROLE_JUMP,
jump_binding,
))
assert(manager.has_custom_mapping())
assert(FileAccess.file_exists(
KeyboardMouseMappingManagerType.MAPPING_PATH
))
assert(_has_physical_key(&"jump", KEY_R))
assert(_event_count(&"jump", true) == joypad_events_before)
var mouse_event := InputEventMouseButton.new()
mouse_event.button_index = MOUSE_BUTTON_XBUTTON1
mouse_event.pressed = true
var mouse_binding: Dictionary = manager.binding_from_event(mouse_event)
assert(manager.set_binding(
KeyboardMouseMappingManagerType.ROLE_PRIMARY_ACTION,
mouse_binding,
))
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_XBUTTON1))
assert(not _has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT))
var joypad_event := InputEventJoypadButton.new()
joypad_event.button_index = JOY_BUTTON_A
joypad_event.pressed = true
assert(manager.binding_from_event(joypad_event).is_empty())
var panel := KeyboardMouseMappingPanelType.new()
root.add_child(panel)
panel.setup(manager)
panel.open_panel()
await process_frame
assert(_has_label_text(panel, "keyboard binds"))
var scrolls: Array[Node] = panel.find_children(
"", "ScrollContainer", true, false
)
assert(not scrolls.is_empty())
assert((scrolls.front() as ScrollContainer).follow_focus)
var first_binding := panel._binding_buttons.get(
KeyboardMouseMappingManagerType.ROLE_ORDER.front()
) as Button
var last_binding := panel._binding_buttons.get(
KeyboardMouseMappingManagerType.ROLE_ORDER.back()
) as Button
assert(first_binding.get_node(
first_binding.focus_neighbor_top
) == first_binding)
assert(last_binding.get_node(
last_binding.focus_neighbor_bottom
) == panel._close_button)
assert(panel._close_button.get_node(
panel._close_button.focus_neighbor_top
) == last_binding)
panel._begin_capture(KeyboardMouseMappingManagerType.ROLE_INTERACT)
var interact_key := InputEventKey.new()
interact_key.physical_keycode = KEY_F
interact_key.pressed = true
panel._input(interact_key)
assert(_has_physical_key(&"interact", KEY_F))
assert(not panel.is_capturing())
var settings_panel := SettingsPanelScene.instantiate() as SettingsPanel
root.add_child(settings_panel)
await process_frame
var controller_bubble := settings_panel.get_node(
"%ControllerMapping"
) as Button
var keyboard_bubble := settings_panel.get_node("%KeyboardMapping") as Button
assert(controller_bubble.text == "controller\nbinds")
assert(keyboard_bubble.text == "keyboard\nbinds")
assert(manager.reset_mapping())
assert(not manager.has_custom_mapping())
assert(not FileAccess.file_exists(
KeyboardMouseMappingManagerType.MAPPING_PATH
))
assert(_has_physical_key(&"jump", KEY_SPACE))
assert(_has_mouse_button(&"fish_primary", MOUSE_BUTTON_LEFT))
assert(_has_physical_key(&"open_chat", KEY_T))
assert(_event_count(&"jump", true) == joypad_events_before)
settings_panel.queue_free()
panel.queue_free()
manager.queue_free()
print("Keyboard and mouse mapping validation: PASS")
quit(0)
func _event_count(action: StringName, joypad: bool) -> int:
var result: int = 0
for event: InputEvent in InputMap.action_get_events(action):
if joypad == (
event is InputEventJoypadButton or event is InputEventJoypadMotion
):
result += 1
return result
func _has_physical_key(action: StringName, key: Key) -> bool:
for event: InputEvent in InputMap.action_get_events(action):
var key_event := event as InputEventKey
if key_event != null and key_event.physical_keycode == key:
return true
return false
func _has_mouse_button(action: StringName, button: MouseButton) -> bool:
for event: InputEvent in InputMap.action_get_events(action):
var mouse_event := event as InputEventMouseButton
if mouse_event != null and mouse_event.button_index == button:
return true
return false
func _has_label_text(parent: Node, expected: String) -> bool:
for node: Node in parent.find_children("", "Label", true, false):
if (node as Label).text == expected:
return true
return false

View file

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

View file

@ -249,6 +249,36 @@ func _validate_page() -> void:
assert(_detail_text(page).contains(LogbookCatalog.facts_for(bluegill)))
_validate_detail_field_fonts(page)
_validate_handwritten_numeric_scale(page)
var detail_buttons: Array = page.get("_detail_buttons") as Array
assert(detail_buttons.size() == 4)
var portrait_detail := detail_buttons[0] as Button
var facts_detail := detail_buttons[1] as Button
var quality_detail := detail_buttons[2] as Button
var stats_detail := detail_buttons[3] as Button
assert(
portrait_detail.get_node(portrait_detail.focus_neighbor_right)
== facts_detail
)
assert(
portrait_detail.get_node(portrait_detail.focus_neighbor_bottom)
== quality_detail
)
assert(
facts_detail.get_node(facts_detail.focus_neighbor_left)
== portrait_detail
)
assert(
facts_detail.get_node(facts_detail.focus_neighbor_bottom)
== quality_detail
)
assert(
quality_detail.get_node(quality_detail.focus_neighbor_bottom)
== stats_detail
)
assert(
stats_detail.get_node(stats_detail.focus_neighbor_top)
== quality_detail
)
var portrait_button := page.get("_detail_portrait_button") as Button
assert(portrait_button != null)
portrait_button.pressed.emit()
@ -266,6 +296,29 @@ func _validate_page() -> void:
(page.get("_portrait_overlay_backdrop") as Button).pressed.emit()
await process_frame
assert(not portrait_overlay.visible)
facts_detail.pressed.emit()
await process_frame
var overlay_text := page.get("_portrait_overlay_text") as Label
assert(overlay_text.visible)
assert(
overlay_text.get_theme_font_size("font_size")
== LogbookPage.DETAIL_OVERLAY_TEXT_FONT_SIZE
)
assert(
overlay_text.horizontal_alignment
== HORIZONTAL_ALIGNMENT_LEFT
)
assert(
portrait_overlay.size.x
- overlay_text.size.x
<= float(
(LogbookPage.DETAIL_OVERLAY_EDGE_MARGIN
+ LogbookPage.DETAIL_OVERLAY_CONTENT_MARGIN) * 2
+ 8
)
)
(page.get("_portrait_overlay_backdrop") as Button).pressed.emit()
await process_frame
inventory.remove_catch_by_id(fish_catch.catch_id)
await process_frame