feat: complete controller-first menu navigation
This commit is contained in:
parent
26c9308fcf
commit
4ac7d5edb8
35 changed files with 2013 additions and 85 deletions
75
tests/controller_focus_presentation_validation.gd
Normal file
75
tests/controller_focus_presentation_validation.gd
Normal 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)
|
||||
1
tests/controller_focus_presentation_validation.gd.uid
Normal file
1
tests/controller_focus_presentation_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bjay8ki3albk6
|
||||
88
tests/controller_focus_recovery_validation.gd
Normal file
88
tests/controller_focus_recovery_validation.gd
Normal 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)
|
||||
1
tests/controller_focus_recovery_validation.gd.uid
Normal file
1
tests/controller_focus_recovery_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cqk75fxykqrhs
|
||||
82
tests/controller_ui_navigation_validation.gd
Normal file
82
tests/controller_ui_navigation_validation.gd
Normal 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
|
||||
1
tests/controller_ui_navigation_validation.gd.uid
Normal file
1
tests/controller_ui_navigation_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cbv0ia42rqu41
|
||||
82
tests/on_screen_keyboard_validation.gd
Normal file
82
tests/on_screen_keyboard_validation.gd
Normal 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()
|
||||
1
tests/on_screen_keyboard_validation.gd.uid
Normal file
1
tests/on_screen_keyboard_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bgmt6joqdk37q
|
||||
|
|
@ -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
|
||||
))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue