feat: complete controller-first menu navigation

This commit is contained in:
Alexander Sellite 2026-08-08 14:23:28 -04:00
parent 26c9308fcf
commit 4ac7d5edb8
35 changed files with 2013 additions and 85 deletions

View file

@ -0,0 +1,75 @@
extends SceneTree
const FocusPresentationType = preload(
"res://ui/controller_focus_presentation.gd"
)
var _failures: Array[String] = []
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var stage := Control.new()
stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(stage)
var presentation := FocusPresentationType.new()
stage.add_child(presentation)
var standard_button := Button.new()
standard_button.text = "standard"
standard_button.custom_minimum_size = Vector2(120.0, 60.0)
stage.add_child(standard_button)
var authored_selector := Button.new()
authored_selector.text = "authored selector"
authored_selector.position = Vector2(140.0, 0.0)
authored_selector.custom_minimum_size = Vector2(160.0, 60.0)
authored_selector.set_meta(&"controller_focus_inversion_disabled", true)
stage.add_child(authored_selector)
await process_frame
var controller_event := InputEventJoypadButton.new()
controller_event.button_index = JOY_BUTTON_A
controller_event.pressed = true
presentation._input(controller_event)
standard_button.grab_focus()
await process_frame
_expect(
standard_button.material != null,
"ordinary controller focus receives inversion",
)
authored_selector.grab_focus()
await process_frame
_expect(
standard_button.material == null,
"inversion clears when focus moves",
)
_expect(
authored_selector.material == null,
"authored selector backgrounds opt out of inversion",
)
standard_button.grab_focus()
await process_frame
standard_button.focus_mode = Control.FOCUS_NONE
await process_frame
_expect(
standard_button.material == null,
"inversion clears when a focused control is defocused",
)
stage.queue_free()
if _failures.is_empty():
print("Controller focus presentation 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://bjay8ki3albk6

View file

@ -0,0 +1,88 @@
extends SceneTree
const ControllerFocusRecoveryType = preload(
"res://ui/controller_focus_recovery.gd"
)
var _failures: Array[String] = []
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var stage := Control.new()
stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(stage)
var recovery := ControllerFocusRecoveryType.new()
stage.add_child(recovery)
var option_list := VBoxContainer.new()
option_list.position = Vector2(100.0, 100.0)
stage.add_child(option_list)
var original := _make_button("cute", "cute (show variants)")
option_list.add_child(original)
await process_frame
var controller_event := InputEventJoypadButton.new()
controller_event.button_index = JOY_BUTTON_A
controller_event.pressed = true
recovery._input(controller_event)
original.grab_focus()
await process_frame
_expect(root.gui_get_focus_owner() == original, "original option receives focus")
option_list.remove_child(original)
original.queue_free()
var replacement := _make_button("cute", "cute (hide variants)")
option_list.add_child(replacement)
await process_frame
await process_frame
_expect(
root.gui_get_focus_owner() == replacement,
"focus follows a rebuilt semantic option",
)
var explicit_target := _make_button("explicit", "explicit")
option_list.add_child(explicit_target)
option_list.remove_child(replacement)
replacement.queue_free()
explicit_target.grab_focus()
await process_frame
_expect(
root.gui_get_focus_owner() == explicit_target,
"explicit focus changes take precedence over recovery",
)
var leave_world_ui_event := InputEventJoypadButton.new()
leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER
leave_world_ui_event.pressed = true
recovery._input(leave_world_ui_event)
root.gui_release_focus()
await process_frame
await process_frame
_expect(
root.gui_get_focus_owner() == null,
"LB intentionally leaving world UI is never recovered",
)
stage.queue_free()
if _failures.is_empty():
print("Controller focus recovery validation: PASS")
quit(0)
return
for failure: String in _failures:
push_error(failure)
quit(1)
func _make_button(text: String, tooltip: String) -> Button:
var button := Button.new()
button.text = text
button.tooltip_text = tooltip
button.custom_minimum_size = Vector2(120.0, 48.0)
return button
func _expect(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)

View file

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

View file

@ -0,0 +1,82 @@
extends SceneTree
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const UIReferencePresentationType = preload(
"res://ui/ui_reference_presentation.gd"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_validate_spatial_navigation()
_validate_controller_hierarchy_contract()
_validate_four_by_three_centering()
_validate_low_end_profile_contract()
print("Controller UI navigation validation: PASS")
quit()
func _validate_spatial_navigation() -> void:
var host := Control.new()
root.add_child(host)
var center := _make_button(host, "center", Vector2(200.0, 200.0))
var left := _make_button(host, "left", Vector2(50.0, 200.0))
var right := _make_button(host, "right", Vector2(350.0, 200.0))
var top := _make_button(host, "top", Vector2(200.0, 50.0))
var bottom := _make_button(host, "bottom", Vector2(200.0, 350.0))
var controls: Array[Control] = [center, left, right, top, bottom]
ControllerFocusNavigationType.configure_spatial_neighbors(controls)
assert(center.get_node(center.focus_neighbor_left) == left)
assert(center.get_node(center.focus_neighbor_right) == right)
assert(center.get_node(center.focus_neighbor_top) == top)
assert(center.get_node(center.focus_neighbor_bottom) == bottom)
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(source.contains("_reserve_main_navigation_for_page_switching"))
func _validate_four_by_three_centering() -> void:
var stage_position: Vector2 = (
UIReferencePresentationType.get_stage_position(Vector2(640.0, 480.0))
)
assert(stage_position.is_equal_approx(Vector2(0.0, 120.0)))
func _validate_low_end_profile_contract() -> void:
var launcher: String = FileAccess.get_file_as_string(
"res://scripts/portmaster/NETfishing.sh"
)
assert(launcher.contains("allwinner,h616"))
assert(launcher.contains("sun50iw9p1"))
assert(launcher.contains("NETFISHING_LOW_END=1"))
assert(launcher.contains("--max-fps 30"))
assert(launcher.contains("--audio-output-latency 40"))
func _make_button(
host: Control,
button_name: String,
button_position: Vector2,
) -> Button:
var button := Button.new()
button.name = button_name
button.position = button_position
button.size = Vector2(80.0, 80.0)
button.focus_mode = Control.FOCUS_ALL
host.add_child(button)
return button

View file

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

View file

@ -0,0 +1,82 @@
extends SceneTree
const KeyboardType = preload("res://ui/on_screen_keyboard.gd")
const SettingsManagerType = preload(
"res://settings/player_settings_manager.gd"
)
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_validate_default_and_persistence()
await _validate_keyboard_entry()
print("On-screen keyboard validation: PASS")
quit()
func _validate_default_and_persistence() -> void:
var defaults := PlayerSettings.new()
assert(not defaults.on_screen_keyboard_enabled)
var manager := SettingsManagerType.new()
root.add_child(manager)
assert(manager.load_settings())
var edited: PlayerSettings = manager.current_settings.copy()
edited.on_screen_keyboard_enabled = true
assert(manager.apply_settings(edited))
var reloaded := SettingsManagerType.new()
root.add_child(reloaded)
assert(reloaded.load_settings())
assert(reloaded.current_settings.on_screen_keyboard_enabled)
manager.queue_free()
reloaded.queue_free()
func _validate_keyboard_entry() -> void:
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)
await process_frame
keyboard.set_enabled(true)
edit.grab_focus()
var activate_event := InputEventJoypadButton.new()
activate_event.button_index = JOY_BUTTON_A
activate_event.pressed = true
keyboard.call("_input", activate_event)
assert(keyboard.is_open())
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")
keyboard.call("_move_caret", -1)
keyboard.call("_type_character", "C")
assert(edit.text == "a CB")
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)
assert(not keyboard.is_open())
assert(root.gui_get_focus_owner() == null)
edit.grab_focus()
keyboard.call("_input", activate_event)
assert(keyboard.is_open())
var submitted: Array[String] = []
edit.text_submitted.connect(func(value: String) -> void:
submitted.append(value)
)
keyboard.call("_submit")
assert(not keyboard.is_open())
assert(submitted == ["a B"])
host.queue_free()

View file

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

View file

@ -94,7 +94,10 @@ func _run() -> void:
assert(player_menu.size.is_equal_approx(
UIReferencePresentationType.REFERENCE_SIZE
))
assert(hotbar.position.is_equal_approx(Vector2.ZERO))
assert(hotbar.position.is_equal_approx(Vector2(
0.0,
canonical_stage.position.y,
)))
assert(hotbar.size.is_equal_approx(
UIReferencePresentationType.REFERENCE_SIZE
))