class_name SettingsBubblePage extends Control const ControllerFocusNavigationType = preload( "res://ui/controller_focus_navigation.gd" ) const QUICK_ENTER_DURATION: float = 0.14 const QUICK_EXIT_DURATION: float = 0.10 const QUICK_ENTER_SCALE: float = 0.97 @export var page_id: StringName @export var cluster_path: NodePath = ^"BubbleCluster" @export var bubble_paths: Array[NodePath] = [] @export var focus_paths: Array[NodePath] = [] @export var initial_focus_path: NodePath @export var back_focus_path: NodePath @export var back_entry_path: NodePath @export var maximum_layout_size: Vector2 = Vector2(720.0, 520.0) var _cluster: BubbleCluster var _bubbles: Array[BubbleButton] = [] var _focus_controls: Array[Control] = [] var _transition: Tween var _transition_generation: int = 0 var _resting_cluster_position: Vector2 = Vector2.ZERO var _is_transitioning: bool = false func _ready() -> void: _cluster = get_node(cluster_path) as BubbleCluster for path: NodePath in bubble_paths: var bubble := get_node(path) as BubbleButton if bubble != null: _bubbles.append(bubble) for path: NodePath in focus_paths: var focus_control := get_node(path) as Control if focus_control != null: _focus_controls.append(focus_control) _cluster.configure(_bubbles) _cluster.motion_scale = 0.0 _configure_focus_order() resized.connect(_update_layout) call_deferred("_update_layout") set_process(false) func show_page(should_focus: bool = true) -> void: _transition_generation += 1 _cancel_transition() show() _is_transitioning = false _cluster.position = _resting_cluster_position _reset_cluster_presentation() set_process(true) _set_interactive(true) var paper := get_node_or_null("Paper") as Control if paper != null: UtilityPageStyle.animate_in(paper) if should_focus: focus_initial() func transition_out( completed: Callable, _duration_override: float = -1.0, ) -> void: _transition_generation += 1 _cancel_transition() _is_transitioning = true _set_interactive(false) var paper := get_node_or_null("Paper") as Control if paper != null: UtilityPageStyle.animate_out(paper, Callable()) set_process(true) var generation: int = _transition_generation _cluster.position = _resting_cluster_position _cluster.pivot_offset = _cluster.size * 0.5 _transition = create_tween().set_parallel(true) _transition.tween_property( _cluster, "scale", Vector2.ONE * QUICK_ENTER_SCALE, QUICK_EXIT_DURATION ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) _transition.tween_property( _cluster, "modulate:a", 0.0, QUICK_EXIT_DURATION ) _transition.finished.connect( _finish_transition_out.bind(generation, completed), CONNECT_ONE_SHOT ) func transition_in( should_focus: bool, completed: Callable, _duration_override: float = -1.0, ) -> void: _transition_generation += 1 _cancel_transition() show() _is_transitioning = true _set_interactive(false) set_process(true) _cluster.position = _resting_cluster_position _cluster.pivot_offset = _cluster.size * 0.5 _cluster.scale = Vector2.ONE * QUICK_ENTER_SCALE _cluster.modulate.a = 0.0 var paper := get_node_or_null("Paper") as Control if paper != null: UtilityPageStyle.animate_in(paper) var generation: int = _transition_generation _transition = create_tween().set_parallel(true) _transition.tween_property( _cluster, "scale", Vector2.ONE, QUICK_ENTER_DURATION ).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT) _transition.tween_property( _cluster, "modulate:a", 1.0, QUICK_ENTER_DURATION ) _transition.finished.connect( _finish_transition_in.bind(generation, should_focus, completed), CONNECT_ONE_SHOT ) func hide_page() -> void: _transition_generation += 1 _cancel_transition() _is_transitioning = false _set_interactive(false) set_process(false) hide() if _cluster != null: _cluster.position = _resting_cluster_position _reset_cluster_presentation() func focus_initial() -> void: var initial := get_node_or_null(initial_focus_path) as Control if initial != null and initial.visible and initial.focus_mode != Control.FOCUS_NONE: initial.grab_focus() func focus_back() -> void: var back := get_node_or_null(back_focus_path) as Control if back != null and back.visible and back.focus_mode != Control.FOCUS_NONE: back.grab_focus() func set_controller_focus_scope(controls: Array[Control]) -> void: for focus_control: Control in _focus_controls: focus_control.focus_mode = Control.FOCUS_NONE for control: Control in controls: if control != null and control.is_visible_in_tree(): control.focus_mode = Control.FOCUS_ALL ControllerFocusNavigationType.configure_spatial_neighbors(controls) func restore_controller_focus_scope() -> void: if not visible or _is_transitioning: return for focus_control: Control in _focus_controls: focus_control.focus_mode = Control.FOCUS_ALL _configure_focus_navigation() func get_page_id() -> StringName: return page_id func _process(delta: float) -> void: if visible: _cluster.advance_motion(delta) func _update_layout() -> void: if not is_node_ready() or _cluster == null: return var field_size := Vector2( minf(size.x, maximum_layout_size.x), minf(size.y, maximum_layout_size.y) ) field_size.x = maxf(1.0, field_size.x) field_size.y = maxf(1.0, field_size.y) _resting_cluster_position = (size - field_size) * 0.5 if not _is_transitioning: _cluster.position = _resting_cluster_position _cluster.size = field_size _cluster.pivot_offset = _cluster.size * 0.5 _cluster.apply_layout(field_size, false) _configure_focus_navigation() func _configure_focus_order() -> void: for bubble: BubbleButton in _bubbles: bubble.focus_mode = Control.FOCUS_NONE _configure_focus_navigation() func _set_interactive(interactive: bool) -> void: mouse_filter = ( Control.MOUSE_FILTER_PASS if interactive else Control.MOUSE_FILTER_IGNORE ) for focus_control: Control in _focus_controls: focus_control.focus_mode = ( Control.FOCUS_ALL if interactive else Control.FOCUS_NONE ) focus_control.mouse_filter = ( Control.MOUSE_FILTER_STOP if interactive else Control.MOUSE_FILTER_IGNORE ) # Most settings pages are configured while their parent panel is hidden. # Rebuild the graph after a page becomes visible so hidden-at-ready controls # do not retain the empty neighbors produced by the initial layout pass. if interactive: call_deferred("_refresh_focus_navigation") else: _configure_focus_navigation() func _refresh_focus_navigation() -> void: if visible and not _is_transitioning: _configure_focus_navigation() func _configure_focus_navigation() -> void: ControllerFocusNavigationType.configure_spatial_neighbors(_focus_controls) if back_entry_path.is_empty() or back_focus_path.is_empty(): return var entry := get_node_or_null(back_entry_path) as Control var back := get_node_or_null(back_focus_path) as Control if ( ControllerFocusNavigationType.is_focusable(entry) and ControllerFocusNavigationType.is_focusable(back) ): entry.focus_neighbor_bottom = entry.get_path_to(back) back.focus_neighbor_top = back.get_path_to(entry) func _finish_transition_out(generation: int, completed: Callable) -> void: if generation != _transition_generation or not visible: return _transition = null _is_transitioning = false hide() _cluster.position = _resting_cluster_position _reset_cluster_presentation() if completed.is_valid(): completed.call() func _finish_transition_in( generation: int, should_focus: bool, completed: Callable, ) -> void: if generation != _transition_generation or not visible: return _transition = null _is_transitioning = false _cluster.position = _resting_cluster_position _reset_cluster_presentation() set_process(true) _set_interactive(true) if should_focus: focus_initial() completed.call() func _cancel_transition() -> void: if _transition != null: _transition.kill() _transition = null func _reset_cluster_presentation() -> void: if _cluster == null: return _cluster.scale = Vector2.ONE _cluster.modulate.a = 1.0