feat: replace the controller focus cursor

This commit is contained in:
Alexander Sellite 2026-08-17 21:43:58 -04:00
parent b4474da0ea
commit 91e997e20f
3 changed files with 143 additions and 30 deletions

View file

@ -49,12 +49,26 @@ func _run() -> void:
standard_button.grab_focus()
await process_frame
var focus_arrow := presentation.get("_focus_arrow") as TextureRect
var focus_shadow := presentation.get("_focus_arrow_shadow") as TextureRect
_expect(focus_arrow != null, "controller focus arrow was not created")
_expect(focus_shadow != null, "controller focus shadow was not created")
_expect(focus_arrow.visible, "controller focus arrow is not visible")
_expect(focus_shadow.visible, "controller focus shadow is not visible")
_expect(
focus_arrow.texture == FocusPresentationType.FOCUS_ARROW_TEXTURE,
"controller focus arrow uses the wrong artwork",
)
_expect(
focus_shadow.texture == focus_arrow.texture,
"controller focus shadow does not follow the cursor silhouette",
)
_expect(
focus_shadow.position.is_equal_approx(
focus_arrow.position
+ FocusPresentationType.FOCUS_ARROW_SHADOW_OFFSET
),
"controller focus shadow uses the wrong offset",
)
_expect(
is_equal_approx(
focus_arrow.rotation_degrees,
@ -115,6 +129,18 @@ func _run() -> void:
_arrow_marks_top_left(focus_arrow, second_button.get_global_rect()),
"controller focus arrow did not follow the focused control",
)
var bubble_button := BubbleButton.new()
bubble_button.text = "bubble"
bubble_button.position = Vector2(440.0, 60.0)
bubble_button.size = Vector2(120.0, 120.0)
stage.add_child(bubble_button)
await process_frame
bubble_button.grab_focus()
await process_frame
_expect(
_arrow_overlaps_bubble_edge(focus_arrow, bubble_button),
"controller focus arrow does not meet the visible bubble edge",
)
var pointer_motion := InputEventMouseMotion.new()
pointer_motion.position = Vector2(4.0, 4.0)
@ -124,6 +150,10 @@ func _run() -> void:
not focus_arrow.visible,
"mouse motion does not clear controller focus presentation",
)
_expect(
not focus_shadow.visible,
"mouse motion leaves the controller focus shadow visible",
)
_expect(
standard_button.get_theme_stylebox("focus") == old_focus_style,
"native theme overrides were not restored after controller use",
@ -349,6 +379,27 @@ func _arrow_marks_top_left(arrow: Control, target: Rect2) -> bool:
return actual_tip.distance_to(expected_tip) <= 0.5
func _arrow_overlaps_bubble_edge(
arrow: Control,
bubble: BubbleButton,
) -> bool:
var arrow_pivot: Vector2 = arrow.position + arrow.size * 0.5
var tip_from_pivot: Vector2 = (
(FocusPresentationType.FOCUS_ARROW_TIP_UV - Vector2.ONE * 0.5)
* arrow.size
).rotated(deg_to_rad(
FocusPresentationType.FOCUS_ARROW_ROTATION_DEGREES
))
var actual_tip: Vector2 = arrow_pivot + tip_from_pivot
var local_tip: Vector2 = (
bubble.get_global_transform_with_canvas().affine_inverse()
* actual_tip
)
var radius: Vector2 = bubble.size * 0.5
var normalized: Vector2 = (local_tip - radius) / radius
return normalized.length() >= 0.75 and normalized.length() <= 1.0
func _expect(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)

View file

@ -5,10 +5,13 @@ const CONTROLLER_MOTION_THRESHOLD: float = 0.35
const FOCUS_ARROW_TEXTURE: Texture2D = preload(
"res://ui/icons/pictograms/arrow_cursor.png"
)
const FOCUS_ARROW_SIZE: Vector2 = Vector2(32.0, 32.0)
const FOCUS_ARROW_ROTATION_DEGREES: float = 150.0
const FOCUS_ARROW_TIP_UV: Vector2 = Vector2(0.5, 0.0)
const FOCUS_ARROW_TARGET_OVERLAP: Vector2 = Vector2(10.0, 10.0)
const FOCUS_ARROW_SIZE: Vector2 = Vector2(64.0, 64.0)
const FOCUS_ARROW_ROTATION_DEGREES: float = 60.0
const FOCUS_ARROW_TIP_UV: Vector2 = Vector2(0.96875, 0.4453125)
const FOCUS_ARROW_TARGET_OVERLAP: Vector2 = Vector2(16.0, 16.0)
const FOCUS_ARROW_BUBBLE_OVERLAP: float = 10.0
const FOCUS_ARROW_SHADOW_OFFSET: Vector2 = Vector2(-6.0, -3.0)
const FOCUS_ARROW_SHADOW_COLOR: Color = Color(0.0, 0.035, 0.055, 0.56)
const FOCUS_ARROW_CANVAS_LAYER: int = 120
const FOCUS_STYLE_REPLACEMENTS: Dictionary[StringName, StringName] = {
@ -52,6 +55,7 @@ var _original_hover_color_overrides: Dictionary[StringName, Dictionary] = {}
var _original_popup_hover_style: Dictionary = {}
var _original_popup_hover_color: Dictionary = {}
var _focus_layer: CanvasLayer
var _focus_arrow_shadow: TextureRect
var _focus_arrow: TextureRect
@ -111,21 +115,38 @@ func _build_focus_arrow() -> void:
_focus_layer.name = "ControllerFocusArrowLayer"
_focus_layer.layer = FOCUS_ARROW_CANVAS_LAYER
add_child(_focus_layer)
_focus_arrow = TextureRect.new()
_focus_arrow.name = "ControllerFocusArrow"
_focus_arrow.texture = FOCUS_ARROW_TEXTURE
_focus_arrow.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_focus_arrow.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_focus_arrow.size = FOCUS_ARROW_SIZE
_focus_arrow.pivot_offset = FOCUS_ARROW_SIZE * 0.5
_focus_arrow.rotation_degrees = FOCUS_ARROW_ROTATION_DEGREES
_focus_arrow.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
_focus_arrow.mouse_filter = Control.MOUSE_FILTER_IGNORE
_focus_arrow.focus_mode = Control.FOCUS_NONE
_focus_arrow.visible = false
_focus_arrow_shadow = _make_focus_cursor_texture(
"ControllerFocusArrowShadow"
)
_focus_arrow_shadow.self_modulate = FOCUS_ARROW_SHADOW_COLOR
_focus_layer.add_child(_focus_arrow_shadow)
_focus_arrow = _make_focus_cursor_texture("ControllerFocusArrow")
_focus_layer.add_child(_focus_arrow)
func _make_focus_cursor_texture(node_name: String) -> TextureRect:
var texture_rect := TextureRect.new()
texture_rect.name = node_name
texture_rect.texture = FOCUS_ARROW_TEXTURE
texture_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
texture_rect.size = FOCUS_ARROW_SIZE
texture_rect.pivot_offset = FOCUS_ARROW_SIZE * 0.5
texture_rect.rotation_degrees = FOCUS_ARROW_ROTATION_DEGREES
texture_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
texture_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
texture_rect.focus_mode = Control.FOCUS_NONE
texture_rect.visible = false
return texture_rect
func _set_focus_cursor_visible(is_visible: bool) -> void:
if _focus_arrow_shadow != null:
_focus_arrow_shadow.visible = is_visible
if _focus_arrow != null:
_focus_arrow.visible = is_visible
func _set_controller_active(active: bool) -> void:
if _controller_active == active:
return
@ -177,7 +198,7 @@ func _apply_to_focus(control: Control) -> void:
_focused_control = control
_focus_layer.custom_viewport = control.get_viewport()
_suppress_native_focus_highlight(control)
_focus_arrow.visible = true
_set_focus_cursor_visible(true)
_update_arrow_geometry()
@ -195,7 +216,7 @@ func _apply_to_popup(popup: PopupMenu) -> void:
_popup_scroll_offset = 0.0
_focus_layer.custom_viewport = popup
_suppress_popup_focus_highlight(popup)
_focus_arrow.visible = true
_set_focus_cursor_visible(true)
_update_arrow_geometry()
@ -225,29 +246,71 @@ func _update_arrow_geometry() -> void:
)
)
):
if _focus_arrow != null:
_focus_arrow.visible = false
_set_focus_cursor_visible(false)
return
var focus_rect: Rect2 = (
_popup_focus_target_rect(_focused_popup)
if _focused_popup != null
else _focus_target_rect(_focused_control)
)
var presentation_scale: Vector2 = _presentation_canvas_scale()
var arrow_size: Vector2 = FOCUS_ARROW_SIZE * presentation_scale
_focus_arrow.size = arrow_size
_focus_arrow.pivot_offset = arrow_size * 0.5
_focus_arrow_shadow.size = arrow_size
_focus_arrow_shadow.pivot_offset = arrow_size * 0.5
var desired_tip: Vector2 = (
focus_rect.position
_popup_focus_target_rect(_focused_popup).position
+ FOCUS_ARROW_TARGET_OVERLAP * presentation_scale
if _focused_popup != null
else _focus_target_position(
_focused_control,
presentation_scale,
)
)
var tip_from_pivot: Vector2 = (
(FOCUS_ARROW_TIP_UV - Vector2.ONE * 0.5) * arrow_size
).rotated(deg_to_rad(FOCUS_ARROW_ROTATION_DEGREES))
_focus_arrow.position = (
var arrow_position: Vector2 = (
desired_tip - arrow_size * 0.5 - tip_from_pivot
)
_focus_arrow.visible = true
_focus_arrow.position = arrow_position
_focus_arrow_shadow.position = (
arrow_position
+ FOCUS_ARROW_SHADOW_OFFSET * presentation_scale
)
_set_focus_cursor_visible(true)
func _focus_target_position(
control: Control,
presentation_scale: Vector2,
) -> Vector2:
if control is BubbleButton or control is CoolerFishSprite:
return _bubble_focus_target_position(control)
return (
_focus_target_rect(control).position
+ FOCUS_ARROW_TARGET_OVERLAP * presentation_scale
)
func _bubble_focus_target_position(control: Control) -> Vector2:
var direction := Vector2.RIGHT.rotated(
deg_to_rad(FOCUS_ARROW_ROTATION_DEGREES)
)
var radius: Vector2 = control.size * 0.5
var safe_radius := Vector2(
maxf(radius.x, 0.001),
maxf(radius.y, 0.001),
)
var boundary_distance: float = 1.0 / sqrt(
pow(direction.x / safe_radius.x, 2.0)
+ pow(direction.y / safe_radius.y, 2.0)
)
var local_tip: Vector2 = (
radius
- direction * boundary_distance
+ direction * FOCUS_ARROW_BUBBLE_OVERLAP
)
return _rect_in_presentation_viewport(
control,
Rect2(local_tip, Vector2.ZERO),
).position
func _presentation_canvas_scale() -> Vector2:
@ -643,5 +706,4 @@ func _clear_focus_presentation() -> void:
_popup_scroll_offset = 0.0
if _focus_layer != null:
_focus_layer.custom_viewport = get_viewport()
if _focus_arrow != null:
_focus_arrow.visible = false
_set_focus_cursor_visible(false)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Before After
Before After