diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd index 025c3e0..ddb86a0 100644 --- a/tests/controller_focus_presentation_validation.gd +++ b/tests/controller_focus_presentation_validation.gd @@ -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) diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd index f0ebd7b..63982cd 100644 --- a/ui/controller_focus_presentation.gd +++ b/ui/controller_focus_presentation.gd @@ -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) diff --git a/ui/icons/pictograms/arrow_cursor.png b/ui/icons/pictograms/arrow_cursor.png index 0ea6e39..e33dbd7 100644 Binary files a/ui/icons/pictograms/arrow_cursor.png and b/ui/icons/pictograms/arrow_cursor.png differ