diff --git a/tests/controller_focus_presentation_validation.gd b/tests/controller_focus_presentation_validation.gd index e349a1e..d0791cc 100644 --- a/tests/controller_focus_presentation_validation.gd +++ b/tests/controller_focus_presentation_validation.gd @@ -12,6 +12,7 @@ func _initialize() -> void: func _run() -> void: + root.size = Vector2i(1280, 720) var stage := Control.new() stage.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) root.add_child(stage) @@ -19,14 +20,20 @@ func _run() -> void: stage.add_child(presentation) var standard_button := Button.new() standard_button.text = "standard" + standard_button.position = Vector2(80.0, 60.0) standard_button.custom_minimum_size = Vector2(120.0, 60.0) + var normal_style := _button_style(Color("123f4e")) + var old_focus_style := _button_style(Color("238697")) + standard_button.add_theme_stylebox_override("normal", normal_style) + standard_button.add_theme_stylebox_override("focus", old_focus_style) 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) + var second_button := Button.new() + second_button.text = "second" + second_button.position = Vector2(260.0, 60.0) + second_button.custom_minimum_size = Vector2(120.0, 60.0) + # Legacy opt-outs must not prevent the new universal cursor from appearing. + second_button.set_meta(&"controller_focus_inversion_disabled", true) + stage.add_child(second_button) await process_frame var controller_event := InputEventJoypadButton.new() @@ -35,42 +42,198 @@ func _run() -> void: presentation._input(controller_event) standard_button.grab_focus() await process_frame + var focus_arrow := presentation.get("_focus_arrow") as TextureRect + _expect(focus_arrow != null, "controller focus arrow was not created") + _expect(focus_arrow.visible, "controller focus arrow is not visible") _expect( - standard_button.material != null, - "ordinary controller focus receives inversion", + focus_arrow.texture == FocusPresentationType.FOCUS_ARROW_TEXTURE, + "controller focus arrow uses the wrong artwork", ) + _expect( + is_equal_approx( + focus_arrow.rotation_degrees, + FocusPresentationType.FOCUS_ARROW_ROTATION_DEGREES, + ), + "controller focus arrow does not point toward five o'clock", + ) + _expect( + standard_button.material == null, + "controller focus still applies material inversion", + ) + _expect( + standard_button.get_theme_stylebox("focus") == normal_style, + "native focus highlighting was not neutralized", + ) + _expect( + _arrow_marks_top_left(focus_arrow, standard_button.get_global_rect()), + "controller focus arrow is not in the control's top-left corner", + ) + stage.scale = Vector2.ONE * 0.5 + await process_frame + _expect( + focus_arrow.size.is_equal_approx( + FocusPresentationType.FOCUS_ARROW_SIZE * 0.5 + ), + "controller focus arrow does not follow canonical UI stage scale", + ) + _expect( + _arrow_marks_top_left(focus_arrow, standard_button.get_global_rect()), + "scaled controller focus arrow left its control's top-left corner", + ) + stage.scale = Vector2.ONE + await process_frame + + second_button.grab_focus() + await process_frame + _expect( + focus_arrow.visible, + "legacy inversion metadata incorrectly hides the focus arrow", + ) + _expect( + _arrow_marks_top_left(focus_arrow, second_button.get_global_rect()), + "controller focus arrow did not follow the focused control", + ) + var pointer_motion := InputEventMouseMotion.new() pointer_motion.position = Vector2(4.0, 4.0) presentation._input(pointer_motion) await process_frame _expect( - standard_button.material == null, - "mouse motion clears controller focus presentation", + not focus_arrow.visible, + "mouse motion does not clear controller focus presentation", + ) + _expect( + standard_button.get_theme_stylebox("focus") == old_focus_style, + "native theme overrides were not restored after controller use", ) presentation._input(controller_event) - authored_selector.grab_focus() + var item_list := ItemList.new() + item_list.position = Vector2(80.0, 160.0) + item_list.size = Vector2(240.0, 150.0) + item_list.max_columns = 1 + for index: int in 4: + item_list.add_item("list item %d" % index) + stage.add_child(item_list) + await process_frame + item_list.select(0) + item_list.grab_focus() + await process_frame + var first_item_arrow_y: float = focus_arrow.position.y + item_list.select(2) await process_frame _expect( - standard_button.material == null, - "inversion clears when focus moves", + focus_arrow.position.y > first_item_arrow_y, + "focus arrow does not follow the selected ItemList row", ) _expect( - authored_selector.material == null, - "authored selector backgrounds opt out of inversion", + item_list.get_theme_stylebox("selected") is StyleBoxEmpty, + "ItemList still draws a selected-row cursor highlight", + ) + _expect( + item_list.get_theme_color("font_selected_color") + == item_list.get_theme_color("font_color"), + "ItemList still recolors the selected-row cursor text", ) - standard_button.grab_focus() + var tree := Tree.new() + tree.position = Vector2(700.0, 160.0) + tree.size = Vector2(240.0, 150.0) + tree.hide_root = true + var tree_root: TreeItem = tree.create_item() + var first_tree_item: TreeItem = tree.create_item(tree_root) + first_tree_item.set_text(0, "first folder") + var second_tree_item: TreeItem = tree.create_item(tree_root) + second_tree_item.set_text(0, "second folder") + stage.add_child(tree) await process_frame - standard_button.focus_mode = Control.FOCUS_NONE + first_tree_item.select(0) + tree.grab_focus() + await process_frame + var first_tree_arrow_y: float = focus_arrow.position.y + second_tree_item.select(0) await process_frame _expect( - standard_button.material == null, - "inversion clears when a focused control is defocused", + focus_arrow.position.y > first_tree_arrow_y, + "focus arrow does not follow the selected Tree row", ) + var popup := PopupMenu.new() + for index: int in 5: + popup.add_item("popup item %d" % index) + root.add_child(popup) + popup.popup(Rect2i(500, 300, 0, 0)) + popup.set_focused_item(0) + for _frame: int in 2: + await process_frame + _expect( + presentation.get("_focused_popup") == popup, + "focus arrow did not enter an open PopupMenu", + ) + _expect( + presentation.get("_focus_layer").custom_viewport == popup, + "focus arrow is not rendered inside the PopupMenu viewport", + ) + _expect( + popup.get_theme_stylebox("hover") is StyleBoxEmpty, + "PopupMenu still draws its native focus highlight", + ) + _expect( + _arrow_marks_top_left( + focus_arrow, + presentation.call("_popup_focus_target_rect", popup) as Rect2, + ), + "focus arrow does not mark the focused PopupMenu item", + ) + var first_popup_arrow_y: float = focus_arrow.position.y + popup.set_focused_item(2) + await process_frame + _expect( + focus_arrow.position.y > first_popup_arrow_y, + "focus arrow does not follow PopupMenu item navigation", + ) + popup.hide() + popup.queue_free() + await process_frame + item_list.grab_focus() + await process_frame + + var dialog := FileDialog.new() + dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR + dialog.access = FileDialog.ACCESS_FILESYSTEM + dialog.size = Vector2i(720, 480) + root.add_child(dialog) + dialog.popup_centered() + for _frame: int in 2: + await process_frame + var dialog_cancel := dialog.get_cancel_button() + dialog_cancel.grab_focus() + for _frame: int in 2: + await process_frame + _expect( + focus_arrow.visible, + "focus arrow is hidden for an embedded dialog control", + ) + _expect( + presentation.get("_focus_layer").custom_viewport + == dialog_cancel.get_viewport(), + "focus arrow is not rendered inside the dialog viewport", + ) + _expect( + _arrow_marks_top_left( + focus_arrow, + presentation.call("_focus_target_rect", dialog_cancel) as Rect2, + ), + "focus arrow does not follow an embedded dialog control", + ) + dialog.hide() + dialog.queue_free() + await process_frame + item_list.grab_focus() + await process_frame + var scroll := ScrollContainer.new() - scroll.position = Vector2(0.0, 80.0) + scroll.position = Vector2(420.0, 160.0) scroll.size = Vector2(180.0, 90.0) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED stage.add_child(scroll) @@ -86,15 +249,29 @@ func _run() -> void: last_scroll_button = scroll_button await process_frame last_scroll_button.grab_focus() - await process_frame - await process_frame + for _frame: int in 2: + await process_frame _expect( scroll.scroll_vertical > 0, - "controller focus scrolls an off-screen selection into view", + "controller focus does not reveal an off-screen selection", ) _expect( root.gui_get_focus_owner() == last_scroll_button, - "scroll following preserves the selected controller control", + "scroll following does not preserve the focused control", + ) + _expect( + _arrow_marks_top_left( + focus_arrow, + last_scroll_button.get_global_rect(), + ), + "focus arrow does not follow a scrolled control", + ) + + last_scroll_button.focus_mode = Control.FOCUS_NONE + await process_frame + _expect( + not focus_arrow.visible, + "focus arrow remains after its control becomes unfocusable", ) stage.queue_free() @@ -107,6 +284,30 @@ func _run() -> void: quit(1) +func _button_style(color: Color) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = color + style.set_corner_radius_all(8) + return style + + +func _arrow_marks_top_left(arrow: Control, target: Rect2) -> bool: + var arrow_pivot: Vector2 = arrow.position + arrow.size * 0.5 + var arrow_scale: Vector2 = arrow.size / FocusPresentationType.FOCUS_ARROW_SIZE + 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 expected_tip: Vector2 = ( + target.position + + FocusPresentationType.FOCUS_ARROW_TARGET_OVERLAP * arrow_scale + ) + return actual_tip.distance_to(expected_tip) <= 0.5 + + func _expect(condition: bool, message: String) -> void: if not condition: _failures.append(message) diff --git a/ui/components/bubble_menu/bag_item_sprite.gd b/ui/components/bubble_menu/bag_item_sprite.gd index d89f388..5ea70d9 100644 --- a/ui/components/bubble_menu/bag_item_sprite.gd +++ b/ui/components/bubble_menu/bag_item_sprite.gd @@ -20,8 +20,6 @@ var _dragging: bool = false func _ready() -> void: mouse_entered.connect(_set_hovered.bind(true)) mouse_exited.connect(_set_hovered.bind(false)) - focus_entered.connect(_refresh_style) - focus_exited.connect(_refresh_style) resized.connect(_update_visual_pivot) _update_visual_pivot() _apply_quantity_style() @@ -60,7 +58,7 @@ func advance_presentation(elapsed: float, motion_enabled: bool) -> void: ) * 0.014 _visual_root.position = Vector2(0.0, bob) _visual_root.rotation = tilt - var emphasis: float = 1.04 if _hovered or has_focus() else 1.0 + var emphasis: float = 1.04 if _hovered else 1.0 _visual_root.scale = Vector2.ONE * _depth_scale * emphasis @@ -103,9 +101,7 @@ func _refresh_style() -> void: ) if _selected: style.bg_color = UtilityPageStyle.OCEAN_SELECTED - if has_focus(): - style.bg_color = UtilityPageStyle.OCEAN_BUTTON_HOVER - elif _hovered: + if _hovered: style.bg_color = Color(UtilityPageStyle.OCEAN_PANEL_MID, 0.88) style.set_border_width_all(0) style.set_corner_radius_all(64) diff --git a/ui/components/bubble_menu/bubble_button.gd b/ui/components/bubble_menu/bubble_button.gd index f01d50b..3dc3730 100644 --- a/ui/components/bubble_menu/bubble_button.gd +++ b/ui/components/bubble_menu/bubble_button.gd @@ -28,14 +28,11 @@ var neutral_position: Vector2 = Vector2.ZERO var presented_size: Vector2 = Vector2.ZERO var emphasis: float = 0.0 var _hovered: bool = false -var _focused: bool = false func _ready() -> void: mouse_entered.connect(_set_hovered.bind(true)) mouse_exited.connect(_set_hovered.bind(false)) - focus_entered.connect(_set_focused.bind(true)) - focus_exited.connect(_set_focused.bind(false)) resized.connect(_update_pivot) _update_pivot() apply_profile() @@ -151,7 +148,7 @@ func _apply_icon_presentation(bubble_size: Vector2) -> void: func advance_emphasis(delta: float) -> void: - var target: float = 1.0 if _hovered or _focused else 0.0 + var target: float = 1.0 if _hovered else 0.0 emphasis = move_toward( emphasis, target, @@ -233,10 +230,6 @@ func _set_hovered(value: bool) -> void: _hovered = value -func _set_focused(value: bool) -> void: - _focused = value - - func _update_pivot() -> void: pivot_offset = size * 0.5 @@ -247,12 +240,12 @@ func apply_profile() -> void: add_theme_stylebox_override("normal", profile.make_normal_style()) var hover_style: StyleBoxFlat = profile.make_hover_style() add_theme_stylebox_override("hover", hover_style) - add_theme_stylebox_override("focus", hover_style) + add_theme_stylebox_override("focus", profile.make_normal_style()) add_theme_stylebox_override("pressed", profile.make_pressed_style()) add_theme_stylebox_override("disabled", profile.make_disabled_style()) add_theme_color_override("font_color", profile.text_color) add_theme_color_override("font_hover_color", profile.text_hover_color) - add_theme_color_override("font_focus_color", profile.text_hover_color) + add_theme_color_override("font_focus_color", profile.text_color) add_theme_color_override("font_pressed_color", profile.text_pressed_color) add_theme_color_override("font_disabled_color", profile.text_disabled_color) var label_control: Control = get_label_control() diff --git a/ui/components/bubble_menu/cooler_fish_sprite.gd b/ui/components/bubble_menu/cooler_fish_sprite.gd index be47c17..32b5532 100644 --- a/ui/components/bubble_menu/cooler_fish_sprite.gd +++ b/ui/components/bubble_menu/cooler_fish_sprite.gd @@ -19,10 +19,7 @@ var _quality_color := Color.WHITE func _ready() -> void: - set_meta(&"controller_focus_inversion_disabled", true) resized.connect(_update_visual_pivot) - focus_entered.connect(_refresh_style) - focus_exited.connect(_refresh_style) mouse_entered.connect(func() -> void: _hovered = true _refresh_style() @@ -108,7 +105,7 @@ func advance_presentation(delta: float, elapsed: float) -> void: var interaction_lift: float = 0.0 if _batch_selected: interaction_lift += 3.0 - if _focused_catch or has_focus(): + if _focused_catch: interaction_lift += 1.5 elif _hovered: interaction_lift += 0.8 @@ -117,7 +114,7 @@ func advance_presentation(delta: float, elapsed: float) -> void: var emphasis: float = 1.0 if _batch_selected: emphasis += 0.085 - if _focused_catch or has_focus(): + if _focused_catch: emphasis += 0.035 elif _hovered: emphasis += 0.045 @@ -154,7 +151,7 @@ func _refresh_style() -> void: var circle_visible: bool = ( _batch_selected or _hovered - or has_focus() + or _focused_catch ) var base_style: StyleBox = idle if circle_visible: @@ -168,7 +165,7 @@ func _refresh_style() -> void: base_style, ) add_theme_stylebox_override("hover", selected if _batch_selected else hover) - add_theme_stylebox_override("focus", selected) + add_theme_stylebox_override("focus", base_style) add_theme_stylebox_override("pressed", selected) diff --git a/ui/components/bubble_menu/notepad_ink_action.gd b/ui/components/bubble_menu/notepad_ink_action.gd index 85ec1b2..27bf221 100644 --- a/ui/components/bubble_menu/notepad_ink_action.gd +++ b/ui/components/bubble_menu/notepad_ink_action.gd @@ -16,15 +16,12 @@ extends Button @onready var _ink_outline: NotepadInkOutline = %InkOutline var _hovered: bool = false -var _focused: bool = false var _pressed: bool = false func _ready() -> void: mouse_entered.connect(_set_hovered.bind(true)) mouse_exited.connect(_set_hovered.bind(false)) - focus_entered.connect(_set_focused.bind(true)) - focus_exited.connect(_set_focused.bind(false)) button_down.connect(_set_pressed.bind(true)) button_up.connect(_set_pressed.bind(false)) gui_input.connect(_on_gui_input) @@ -42,11 +39,6 @@ func _set_hovered(value: bool) -> void: _refresh_ink() -func _set_focused(value: bool) -> void: - _focused = value - _refresh_ink() - - func _set_pressed(value: bool) -> void: _pressed = value _refresh_ink() @@ -75,7 +67,7 @@ func _refresh_ink() -> void: if not disabled: if persistent_mark: strength = 0.78 - if _hovered or _focused: + if _hovered: strength = maxf(strength, 0.92) if _pressed: strength = 1.0 @@ -99,6 +91,6 @@ func _apply_text_color() -> void: var color: Color = disabled_ink_color if disabled else ink_color add_theme_color_override("font_color", color) add_theme_color_override("font_hover_color", color.darkened(0.08)) - add_theme_color_override("font_focus_color", color.darkened(0.08)) + add_theme_color_override("font_focus_color", color) add_theme_color_override("font_pressed_color", color.darkened(0.16)) add_theme_color_override("font_disabled_color", disabled_ink_color) diff --git a/ui/components/bubble_menu/notepad_ink_choice.gd b/ui/components/bubble_menu/notepad_ink_choice.gd index 80aefbd..7a091db 100644 --- a/ui/components/bubble_menu/notepad_ink_choice.gd +++ b/ui/components/bubble_menu/notepad_ink_choice.gd @@ -34,7 +34,6 @@ var _items: Array[String] = [] var _item_ids: Array[int] = [] var _selected_index: int = 0 var _hovered: bool = false -var _focused: bool = false var _opened_with_mouse: bool = false @@ -43,8 +42,6 @@ func _ready() -> void: _apply_outline_geometry() mouse_entered.connect(_set_hovered.bind(true)) mouse_exited.connect(_set_hovered.bind(false)) - focus_entered.connect(_set_focused.bind(true)) - focus_exited.connect(_set_focused.bind(false)) gui_input.connect(_on_display_gui_input) for index: int in _choice_buttons.size(): var button: Button = _choice_buttons[index] @@ -202,15 +199,10 @@ func _set_hovered(value: bool) -> void: _refresh_ink() -func _set_focused(value: bool) -> void: - _focused = value - _refresh_ink() - - func _refresh_ink() -> void: if is_node_ready(): _ink_outline.set_mark_strength( - 0.88 if (_hovered or _focused) and not disabled else 0.0 + 0.88 if _hovered and not disabled else 0.0 ) @@ -246,9 +238,9 @@ func _apply_paper_style() -> void: for state: StringName in [ &"hover", &"pressed", - &"focus", ]: button.add_theme_stylebox_override(state, hover) + button.add_theme_stylebox_override("focus", empty) button.add_theme_color_override("font_color", ink_color) button.add_theme_color_override( "font_hover_color", @@ -256,7 +248,7 @@ func _apply_paper_style() -> void: ) button.add_theme_color_override( "font_focus_color", - ink_color.darkened(0.08), + ink_color, ) button.add_theme_color_override( "font_pressed_color", diff --git a/ui/components/organizer_tab.gd b/ui/components/organizer_tab.gd index 076d022..bb2f777 100644 --- a/ui/components/organizer_tab.gd +++ b/ui/components/organizer_tab.gd @@ -29,7 +29,6 @@ var _selected: bool = false func _ready() -> void: toggle_mode = true - set_meta(&"controller_focus_inversion_disabled", true) _selected = button_pressed add_theme_font_override("font", UtilityPageStyle.TuffyFont) add_theme_font_size_override("font_size", FONT_SIZE) @@ -43,8 +42,6 @@ func _ready() -> void: add_theme_color_override(color_name, Color.TRANSPARENT) mouse_entered.connect(_set_hovered.bind(true)) mouse_exited.connect(_set_hovered.bind(false)) - focus_entered.connect(refresh_state) - focus_exited.connect(refresh_state) toggled.connect(_on_toggled) _apply_empty_styles() call_deferred("_initialize_motion") @@ -113,7 +110,7 @@ func _set_hovered(hovered: bool) -> void: func _target_y() -> float: if button_pressed: return SELECTED_SETTLE_Y - if _hovered or has_focus(): + if _hovered: return HOVER_SETTLE_Y return INACTIVE_SETTLE_Y @@ -139,8 +136,6 @@ func _draw() -> void: ) if disabled: base_color = base_color.lerp(Color("8a978f"), 0.62) - elif has_focus(): - base_color = base_color.lightened(0.12) elif _hovered: base_color = base_color.lightened(0.08) var width: float = size.x diff --git a/ui/controller_focus_presentation.gd b/ui/controller_focus_presentation.gd index fab4ce6..5399893 100644 --- a/ui/controller_focus_presentation.gd +++ b/ui/controller_focus_presentation.gd @@ -2,34 +2,51 @@ class_name ControllerFocusPresentation extends Node const CONTROLLER_MOTION_THRESHOLD: float = 0.35 -const INVERSION_DISABLED_META: StringName = &"controller_focus_inversion_disabled" +const FOCUS_ARROW_TEXTURE: Texture2D = preload( + "res://ui/icons/pictograms/arrow_light_up_full.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_CANVAS_LAYER: int = 120 + +const FOCUS_STYLE_REPLACEMENTS: Dictionary[StringName, StringName] = { + &"focus": &"normal", + &"selected": &"", + &"selected_focus": &"", + &"cursor": &"", + &"cursor_unfocused": &"", +} +const FOCUS_COLOR_REPLACEMENTS: Dictionary[StringName, StringName] = { + &"font_focus_color": &"font_color", + &"icon_focus_color": &"icon_normal_color", + &"font_selected_color": &"font_color", + &"font_hovered_selected_color": &"font_hovered_color", +} var _controller_active: bool = false -var _focused_item: CanvasItem -var _original_material: Material -var _inversion_material: ShaderMaterial +var _focused_control: Control +var _focused_popup: PopupMenu +var _popup_scroll_offset: float = 0.0 +var _suppressed_control: Control +var _original_style_overrides: Dictionary[StringName, Dictionary] = {} +var _original_color_overrides: Dictionary[StringName, Dictionary] = {} +var _original_popup_hover_style: Dictionary = {} +var _original_popup_hover_color: Dictionary = {} +var _focus_layer: CanvasLayer +var _focus_arrow: TextureRect func _ready() -> void: - var inversion_shader := Shader.new() - inversion_shader.code = """ -shader_type canvas_item; -render_mode unshaded; - -void fragment() { - vec4 source = texture(TEXTURE, UV) * COLOR; - COLOR = vec4(vec3(1.0) - source.rgb, source.a); -} -""" - _inversion_material = ShaderMaterial.new() - _inversion_material.shader = inversion_shader + _build_focus_arrow() get_viewport().gui_focus_changed.connect(_on_focus_changed) set_process_input(true) set_process(true) func _exit_tree() -> void: - _restore_focused_item() + _clear_focus_presentation() func _input(event: InputEvent) -> void: @@ -52,31 +69,65 @@ func _input(event: InputEvent) -> void: func _process(_delta: float) -> void: - if not is_instance_valid(_focused_item): - _focused_item = null - _original_material = null - return - var focus_owner: Control = get_viewport().gui_get_focus_owner() + if _controller_active: + var focused_popup: PopupMenu = _active_focused_popup(get_viewport()) + if focused_popup != null: + if focused_popup != _focused_popup or _focused_control != null: + _apply_to_popup(focused_popup) + else: + _update_arrow_geometry() + return + var focus_owner: Control = _active_focus_owner() if ( - focus_owner != _focused_item - or not focus_owner.is_visible_in_tree() - or focus_owner.focus_mode == Control.FOCUS_NONE - or bool(focus_owner.get_meta(INVERSION_DISABLED_META, false)) + focus_owner != _focused_control + or not _focus_is_presentable(focus_owner) ): _apply_to_focus(focus_owner) + elif _controller_active: + _update_arrow_geometry() + + +func _build_focus_arrow() -> void: + _focus_layer = CanvasLayer.new() + _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_layer.add_child(_focus_arrow) func _set_controller_active(active: bool) -> void: if _controller_active == active: return _controller_active = active - var focus_owner: Control = get_viewport().gui_get_focus_owner() + if _controller_active: + var focused_popup: PopupMenu = _active_focused_popup(get_viewport()) + if focused_popup != null: + _apply_to_popup(focused_popup) + return + var focus_owner: Control = _active_focus_owner() _apply_to_focus(focus_owner) if _controller_active: _queue_focus_visibility_update(focus_owner) func _on_focus_changed(control: Control) -> void: + if _controller_active: + var focused_popup: PopupMenu = _active_focused_popup(get_viewport()) + if focused_popup != null: + _apply_to_popup(focused_popup) + return _apply_to_focus(control) if _controller_active: _queue_focus_visibility_update(control) @@ -100,22 +151,370 @@ func _ensure_focus_visible(control: Control) -> void: func _apply_to_focus(control: Control) -> void: - _restore_focused_item() + _clear_focus_presentation() + if not _controller_active or not _focus_is_presentable(control): + return + _focused_control = control + _focus_layer.custom_viewport = control.get_viewport() + _suppress_native_focus_highlight(control) + _focus_arrow.visible = true + _update_arrow_geometry() + + +func _apply_to_popup(popup: PopupMenu) -> void: + _clear_focus_presentation() if ( not _controller_active - or control == null - or not control.is_visible_in_tree() - or control.focus_mode == Control.FOCUS_NONE - or bool(control.get_meta(INVERSION_DISABLED_META, false)) + or popup == null + or not is_instance_valid(popup) + or not popup.visible + or popup.get_focused_item() < 0 ): return - _focused_item = control - _original_material = _focused_item.material - _focused_item.material = _inversion_material + _focused_popup = popup + _popup_scroll_offset = 0.0 + _focus_layer.custom_viewport = popup + _suppress_popup_focus_highlight(popup) + _focus_arrow.visible = true + _update_arrow_geometry() -func _restore_focused_item() -> void: - if is_instance_valid(_focused_item): - _focused_item.material = _original_material - _focused_item = null - _original_material = null +func _focus_is_presentable(control: Control) -> bool: + return ( + control != null + and is_instance_valid(control) + and control.is_visible_in_tree() + and control.focus_mode != Control.FOCUS_NONE + ) + + +func _update_arrow_geometry() -> void: + if ( + _focus_arrow == null + or not _controller_active + or ( + _focused_popup == null + and not _focus_is_presentable(_focused_control) + ) + or ( + _focused_popup != null + and ( + not is_instance_valid(_focused_popup) + or not _focused_popup.visible + or _focused_popup.get_focused_item() < 0 + ) + ) + ): + if _focus_arrow != null: + _focus_arrow.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 + var desired_tip: Vector2 = ( + focus_rect.position + + FOCUS_ARROW_TARGET_OVERLAP * 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 = ( + desired_tip - arrow_size * 0.5 - tip_from_pivot + ) + _focus_arrow.visible = true + + +func _presentation_canvas_scale() -> Vector2: + var host := get_parent() as Control + if host == null: + return Vector2.ONE + var host_scale: Vector2 = ( + host.get_global_transform_with_canvas().get_scale().abs() + ) + return Vector2( + maxf(host_scale.x, 0.001), + maxf(host_scale.y, 0.001), + ) + + +func _focus_target_rect(control: Control) -> Rect2: + var item_list := control as ItemList + if item_list != null: + var selected_items: PackedInt32Array = item_list.get_selected_items() + if not selected_items.is_empty(): + return _rect_in_presentation_viewport( + item_list, + item_list.get_item_rect(selected_items[0]), + ) + var tree := control as Tree + if tree != null: + var selected_item: TreeItem = tree.get_selected() + if selected_item != null: + return _rect_in_presentation_viewport( + tree, + tree.get_item_area_rect( + selected_item, + tree.get_selected_column(), + ), + ) + return _rect_in_presentation_viewport( + control, + Rect2(Vector2.ZERO, control.size), + ) + + +func _rect_in_presentation_viewport( + control: Control, + local_rect: Rect2, +) -> Rect2: + var canvas_transform: Transform2D = control.get_global_transform_with_canvas() + var corners: Array[Vector2] = [ + canvas_transform * local_rect.position, + canvas_transform * Vector2(local_rect.end.x, local_rect.position.y), + canvas_transform * local_rect.end, + canvas_transform * Vector2(local_rect.position.x, local_rect.end.y), + ] + var result := Rect2(corners.front(), Vector2.ZERO) + for corner: Vector2 in corners: + result = result.expand(corner) + return _offset_rect_to_presentation_viewport( + result, + control.get_viewport(), + ) + + +func _popup_focus_target_rect(popup: PopupMenu) -> Rect2: + var focused_index: int = popup.get_focused_item() + var panel_style: StyleBox = popup.get_theme_stylebox(&"panel") + var panel_left: float = panel_style.get_content_margin(SIDE_LEFT) + var panel_top: float = panel_style.get_content_margin(SIDE_TOP) + var panel_right: float = panel_style.get_content_margin(SIDE_RIGHT) + var panel_bottom: float = panel_style.get_content_margin(SIDE_BOTTOM) + var item_heights: Array[float] = [] + var focused_top: float = 0.0 + for index: int in popup.get_item_count(): + var item_height: float = _popup_item_height(popup, index) + item_heights.append(item_height) + if index < focused_index: + focused_top += item_height + var focused_height: float = item_heights[focused_index] + var visible_height: float = maxf( + 1.0, + float(popup.size.y) - panel_top - panel_bottom, + ) + if focused_top < _popup_scroll_offset: + _popup_scroll_offset = focused_top + elif focused_top + focused_height > _popup_scroll_offset + visible_height: + _popup_scroll_offset = ( + focused_top + focused_height - visible_height + ) + var local_rect := Rect2( + Vector2( + panel_left, + panel_top + focused_top - _popup_scroll_offset, + ), + Vector2( + maxf(1.0, float(popup.size.x) - panel_left - panel_right), + focused_height, + ), + ) + if _focus_layer.custom_viewport == popup: + return local_rect + local_rect.position += Vector2(popup.position) + var parent_node: Node = popup.get_parent() + if parent_node == null: + return local_rect + return _offset_rect_to_presentation_viewport( + local_rect, + parent_node.get_viewport(), + ) + + +func _popup_item_height(popup: PopupMenu, index: int) -> float: + var vertical_separation: float = float( + popup.get_theme_constant(&"v_separation") + ) + if popup.is_item_separator(index) and popup.get_item_text(index).is_empty(): + return maxf( + 1.0, + popup.get_theme_stylebox(&"separator").get_minimum_size().y, + ) + vertical_separation + var font: Font = popup.get_theme_font(&"font") + var font_height: float = font.get_height( + popup.get_theme_font_size(&"font_size") + ) + var icon: Texture2D = popup.get_item_icon(index) + var icon_height: float = icon.get_height() if icon != null else 0.0 + return maxf(font_height, icon_height) + vertical_separation + + +func _offset_rect_to_presentation_viewport( + rect: Rect2, + starting_viewport: Viewport, +) -> Rect2: + var result: Rect2 = rect + var current_viewport: Viewport = starting_viewport + var presentation_viewport: Viewport = ( + _focus_layer.custom_viewport + if _focus_layer != null + else get_viewport() + ) + while current_viewport != presentation_viewport: + var embedded_window := current_viewport as Window + if embedded_window == null or not embedded_window.is_embedded(): + break + result.position += Vector2(embedded_window.position) + var parent_node: Node = embedded_window.get_parent() + if parent_node == null: + break + current_viewport = parent_node.get_viewport() + return result + + +func _active_focus_owner() -> Control: + return _focus_owner_in_viewport(get_viewport()) + + +func _active_focused_popup(viewport: Viewport) -> PopupMenu: + var embedded_windows: Array[Window] = viewport.get_embedded_subwindows() + for index: int in range(embedded_windows.size() - 1, -1, -1): + var window: Window = embedded_windows[index] + if not window.visible: + continue + var nested_popup: PopupMenu = _active_focused_popup(window) + if nested_popup != null: + return nested_popup + var popup := window as PopupMenu + if popup != null and popup.get_focused_item() >= 0: + return popup + return null + + +func _focus_owner_in_viewport(viewport: Viewport) -> Control: + var embedded_windows: Array[Window] = viewport.get_embedded_subwindows() + for index: int in range(embedded_windows.size() - 1, -1, -1): + var window: Window = embedded_windows[index] + if not window.visible: + continue + var nested_owner: Control = _focus_owner_in_viewport(window) + if nested_owner != null: + return nested_owner + return viewport.gui_get_focus_owner() + + +func _suppress_native_focus_highlight(control: Control) -> void: + _suppressed_control = control + _original_style_overrides.clear() + _original_color_overrides.clear() + for focus_name: StringName in FOCUS_STYLE_REPLACEMENTS: + var replacement_name: StringName = FOCUS_STYLE_REPLACEMENTS[focus_name] + _original_style_overrides[focus_name] = { + "had_override": control.has_theme_stylebox_override(focus_name), + "value": control.get_theme_stylebox(focus_name), + } + var replacement: StyleBox = ( + StyleBoxEmpty.new() + if replacement_name.is_empty() + else control.get_theme_stylebox(replacement_name) + if control.has_theme_stylebox_override(replacement_name) + else StyleBoxEmpty.new() + ) + control.add_theme_stylebox_override(focus_name, replacement) + for focus_name: StringName in FOCUS_COLOR_REPLACEMENTS: + var replacement_name: StringName = FOCUS_COLOR_REPLACEMENTS[focus_name] + if not control.has_theme_color(replacement_name): + continue + _original_color_overrides[focus_name] = { + "had_override": control.has_theme_color_override(focus_name), + "value": control.get_theme_color(focus_name), + } + control.add_theme_color_override( + focus_name, + control.get_theme_color(replacement_name), + ) + + +func _suppress_popup_focus_highlight(popup: PopupMenu) -> void: + _original_popup_hover_style = { + "had_override": popup.has_theme_stylebox_override(&"hover"), + "value": popup.get_theme_stylebox(&"hover"), + } + _original_popup_hover_color = { + "had_override": popup.has_theme_color_override(&"font_hover_color"), + "value": popup.get_theme_color(&"font_hover_color"), + } + popup.add_theme_stylebox_override(&"hover", StyleBoxEmpty.new()) + popup.add_theme_color_override( + &"font_hover_color", + popup.get_theme_color(&"font_color"), + ) + + +func _restore_native_focus_highlight() -> void: + if not is_instance_valid(_suppressed_control): + _suppressed_control = null + _original_style_overrides.clear() + _original_color_overrides.clear() + return + for focus_name: StringName in _original_style_overrides: + var state: Dictionary = _original_style_overrides[focus_name] + if bool(state.get("had_override", false)): + _suppressed_control.add_theme_stylebox_override( + focus_name, + state.get("value") as StyleBox, + ) + else: + _suppressed_control.remove_theme_stylebox_override(focus_name) + for focus_name: StringName in _original_color_overrides: + var state: Dictionary = _original_color_overrides[focus_name] + if bool(state.get("had_override", false)): + _suppressed_control.add_theme_color_override( + focus_name, + state.get("value") as Color, + ) + else: + _suppressed_control.remove_theme_color_override(focus_name) + _suppressed_control = null + _original_style_overrides.clear() + _original_color_overrides.clear() + + +func _restore_popup_focus_highlight() -> void: + if not is_instance_valid(_focused_popup): + _original_popup_hover_style.clear() + _original_popup_hover_color.clear() + return + if bool(_original_popup_hover_style.get("had_override", false)): + _focused_popup.add_theme_stylebox_override( + &"hover", + _original_popup_hover_style.get("value") as StyleBox, + ) + else: + _focused_popup.remove_theme_stylebox_override(&"hover") + if bool(_original_popup_hover_color.get("had_override", false)): + _focused_popup.add_theme_color_override( + &"font_hover_color", + _original_popup_hover_color.get("value") as Color, + ) + else: + _focused_popup.remove_theme_color_override(&"font_hover_color") + _original_popup_hover_style.clear() + _original_popup_hover_color.clear() + + +func _clear_focus_presentation() -> void: + _restore_native_focus_highlight() + _restore_popup_focus_highlight() + _focused_control = null + _focused_popup = null + _popup_scroll_offset = 0.0 + if _focus_layer != null: + _focus_layer.custom_viewport = get_viewport() + if _focus_arrow != null: + _focus_arrow.visible = false diff --git a/ui/icons/pictograms/arrow_light_up_full.png b/ui/icons/pictograms/arrow_light_up_full.png new file mode 100644 index 0000000..094b8df Binary files /dev/null and b/ui/icons/pictograms/arrow_light_up_full.png differ diff --git a/ui/icons/pictograms/arrow_light_up_full.png.import b/ui/icons/pictograms/arrow_light_up_full.png.import new file mode 100644 index 0000000..c514da8 --- /dev/null +++ b/ui/icons/pictograms/arrow_light_up_full.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://y07l341wrgkq" +path="res://.godot/imported/arrow_light_up_full.png-5033fcbf6f553c3cbfd8b0dd9d5e8b11.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://ui/icons/pictograms/arrow_light_up_full.png" +dest_files=["res://.godot/imported/arrow_light_up_full.png-5033fcbf6f553c3cbfd8b0dd9d5e8b11.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/ui/logbook_page.gd b/ui/logbook_page.gd index ab183a8..6f05a41 100644 --- a/ui/logbook_page.gd +++ b/ui/logbook_page.gd @@ -569,7 +569,6 @@ func _refresh_catalog() -> void: func _make_entry(fish: FishDataType, discovered: bool) -> Button: var entry := Button.new() - entry.set_meta(&"controller_focus_inversion_disabled", true) entry.custom_minimum_size = CATALOG_ENTRY_SIZE entry.size_flags_horizontal = Control.SIZE_SHRINK_CENTER entry.size_flags_vertical = Control.SIZE_SHRINK_CENTER