class_name BubbleButton extends Button const ICON_FILL_RATIO: float = 0.76 const LABELED_ICON_FILL_RATIO: float = 0.58 const BubbleGradientBackgroundType = preload( "res://ui/components/bubble_menu/bubble_gradient_background.gd" ) const BubbleHoverDrawerType = preload( "res://ui/components/bubble_menu/bubble_hover_drawer.gd" ) @export var profile: BubbleMenuProfile @export_group("Authored Layout") @export var neutral_size: Vector2 = Vector2(120.0, 116.0) @export var desktop_anchor: Vector2 = Vector2.ZERO @export var compact_anchor: Vector2 = Vector2.ZERO @export var compact_minimum_size: Vector2 = Vector2.ZERO @export_group("Typography") @export var label_control_path: NodePath @export_range(1, 256, 1) var minimum_font_size: int = 14 @export_range(1, 256, 1) var maximum_font_size: int = 32 @export_group("Icon") @export_range(0.0, 1.0, 0.01) var icon_fill_ratio_override: float = 0.0 @export_group("Motion") @export_range(0.0, 32.0, 0.1) var horizontal_amplitude: float = 1.8 @export_range(0.0, 32.0, 0.1) var vertical_amplitude: float = 4.0 @export_range(0.1, 30.0, 0.1) var motion_period: float = 5.0 @export_range(0.0, TAU, 0.01) var motion_phase: float = 0.0 @export_range(0.0, 0.2, 0.001) var deformation_amplitude: float = 0.016 @export_range(0.1, 30.0, 0.1) var deformation_period: float = 6.0 @export var hover_wiggle_amplitude: Vector2 = Vector2.ZERO @export_range(0.1, 30.0, 0.1) var hover_wiggle_period: float = 2.0 @export_group("Hover Drawer") @export var hover_drawer_text: String = "" @export_enum("Right", "Left Vertical") var hover_drawer_mode: int = 0 @export_range(0.1, 12.0, 0.1) var hover_drawer_speed: float = 4.0 @export_group("Hit Area") @export var elliptical_hit_area: bool = true var neutral_position: Vector2 = Vector2.ZERO var presented_size: Vector2 = Vector2.ZERO var emphasis: float = 0.0 var _hovered: bool = false var _drawer_focused: bool = false var _drawer_reveal: float = 0.0 var _gradient_background: BubbleGradientBackground var _hover_drawer: BubbleHoverDrawer func _ready() -> void: mouse_entered.connect(_set_hovered.bind(true)) mouse_exited.connect(_set_hovered.bind(false)) focus_entered.connect(_set_drawer_focused.bind(true)) focus_exited.connect(_set_drawer_focused.bind(false)) resized.connect(_update_pivot) _update_pivot() apply_profile() _apply_icon_presentation(neutral_size) _update_hover_drawer() func _gui_input(event: InputEvent) -> void: if _adjustment_direction(self) == 0: return var direction: int = 0 if event.is_action_pressed(&"ui_left"): direction = -1 elif event.is_action_pressed(&"ui_right"): direction = 1 if direction == 0: return var adjustment_button: BaseButton = _find_adjustment_button(direction) if adjustment_button == null: return adjustment_button.pressed.emit() accept_event() func _find_adjustment_button(direction: int) -> BaseButton: var parent_node: Node = get_parent() if parent_node == null: return null for child: Node in parent_node.get_children(): var button := child as BaseButton if ( button != null and button.visible and not button.disabled and _adjustment_direction(button) == direction ): return button return null func _adjustment_direction(button: BaseButton) -> int: var descriptions: Array[String] = [ button.text.strip_edges().to_lower(), button.tooltip_text.strip_edges().to_lower(), button.accessibility_name.strip_edges().to_lower(), ] for description: String in descriptions: if description in ["-", "−", "minus", "decrease"]: return -1 if description in ["+", "plus", "increase"]: return 1 return 0 func apply_layout( center: Vector2, bubble_size: Vector2, font_size_ratio: float, ) -> void: position = center - bubble_size * 0.5 size = bubble_size neutral_position = position presented_size = bubble_size _update_pivot() _apply_icon_presentation(bubble_size) if _hover_drawer != null: _hover_drawer.refresh_layout() var label_control: Control = get_label_control() var font_size := clampi( roundi(minf(bubble_size.x, bubble_size.y) * font_size_ratio), minimum_font_size, maximum_font_size ) label_control.add_theme_font_size_override("font_size", font_size) func get_layout_size(layout_scale: float, compact: bool) -> Vector2: var bubble_size: Vector2 = neutral_size * layout_scale if compact: bubble_size.x = maxf(bubble_size.x, compact_minimum_size.x) bubble_size.y = maxf(bubble_size.y, compact_minimum_size.y) if _uses_icon_only_presentation(): var diameter: float = maxf(bubble_size.x, bubble_size.y) bubble_size = Vector2(diameter, diameter) return bubble_size func get_authored_anchor(compact: bool) -> Vector2: return compact_anchor if compact else desktop_anchor func get_label_control() -> Control: if not label_control_path.is_empty(): var custom_label := get_node_or_null(label_control_path) as Control if custom_label != null: return custom_label return self func _uses_icon_only_presentation() -> bool: return icon != null and text.is_empty() and label_control_path.is_empty() func _apply_icon_presentation(bubble_size: Vector2) -> void: if icon == null: return vertical_icon_alignment = VERTICAL_ALIGNMENT_CENTER expand_icon = true texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST var fill_ratio: float = ( ICON_FILL_RATIO if _uses_icon_only_presentation() else LABELED_ICON_FILL_RATIO ) if _uses_icon_only_presentation() and icon_fill_ratio_override > 0.0: fill_ratio = icon_fill_ratio_override add_theme_constant_override( "icon_max_width", maxi(1, roundi(minf(bubble_size.x, bubble_size.y) * fill_ratio)), ) if not _uses_icon_only_presentation(): return alignment = HORIZONTAL_ALIGNMENT_CENTER icon_alignment = HORIZONTAL_ALIGNMENT_CENTER func advance_emphasis(delta: float) -> void: var target: float = 1.0 if _hovered else 0.0 emphasis = move_toward( emphasis, target, profile.emphasis_speed * delta ) if _gradient_background != null: _gradient_background.queue_redraw() var drawer_target: float = 1.0 if (_hovered or _drawer_focused) else 0.0 _drawer_reveal = move_toward( _drawer_reveal, drawer_target, hover_drawer_speed * delta, ) if _hover_drawer != null: _hover_drawer.set_reveal(_drawer_reveal) func reset_hover_presentation() -> void: _hovered = false _drawer_focused = false emphasis = 0.0 _drawer_reveal = 0.0 if _gradient_background != null: _gradient_background.queue_redraw() if _hover_drawer != null: _hover_drawer.set_reveal(0.0) func calculate_target( elapsed: float, layout_scale: float, motion_scale: float, ) -> Vector2: var phase: float = ( elapsed / motion_period * TAU + motion_phase ) var idle_offset := Vector2( sin(phase * 0.73 + motion_phase) * horizontal_amplitude, sin(phase) * vertical_amplitude ) * layout_scale * motion_scale var hover_phase: float = elapsed / hover_wiggle_period * TAU var hover_offset := Vector2( sin(hover_phase), sin(hover_phase * 2.0 + motion_phase) * 0.5, ) * hover_wiggle_amplitude * emphasis * layout_scale * motion_scale idle_offset.y -= ( profile.hover_focus_lift * emphasis * layout_scale ) return neutral_position + idle_offset + hover_offset func calculate_visual_scale( elapsed: float, motion_scale: float, ) -> Vector2: var deformation_phase: float = ( elapsed / deformation_period * TAU + motion_phase * 1.37 ) var deformation_amount: float = ( sin(deformation_phase) * deformation_amplitude * motion_scale * lerpf(1.0, profile.emphasized_deformation_scale, emphasis) ) if _uses_icon_only_presentation(): deformation_amount = 0.0 var hover_scale: float = lerpf( 1.0, profile.hover_focus_scale, emphasis ) return Vector2( 1.0 + deformation_amount, 1.0 - deformation_amount ) * hover_scale func get_visual_radius(visual_scale: Vector2) -> float: var visual_size: Vector2 = presented_size * visual_scale return (visual_size.x + visual_size.y) * 0.25 func apply_presentation( target_position: Vector2, visual_scale: Vector2, position_weight: float, ) -> void: position = position.lerp(target_position, position_weight) scale = visual_scale func _has_point(point: Vector2) -> bool: if not elliptical_hit_area: return Rect2(Vector2.ZERO, size).has_point(point) var radius: Vector2 = size * 0.5 if radius.x <= 0.0 or radius.y <= 0.0: return false var normalized: Vector2 = (point - radius) / radius return normalized.length_squared() <= 1.0 func _set_hovered(value: bool) -> void: _hovered = value if _gradient_background != null: _gradient_background.queue_redraw() func _set_drawer_focused(value: bool) -> void: _drawer_focused = value func _update_pivot() -> void: pivot_offset = size * 0.5 func apply_profile() -> void: if profile == null: return _update_gradient_background() 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", 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_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() if label_control != self: label_control.add_theme_color_override("font_color", profile.text_color) func _update_gradient_background() -> void: if not profile.gradient_enabled: if _gradient_background != null: _gradient_background.queue_free() _gradient_background = null return if _gradient_background == null: _gradient_background = BubbleGradientBackgroundType.new() _gradient_background.name = "GradientBackground" add_child(_gradient_background) move_child(_gradient_background, 0) _gradient_background.configure(self, profile) func _update_hover_drawer() -> void: if hover_drawer_text.is_empty(): if _hover_drawer != null: _hover_drawer.queue_free() _hover_drawer = null return if _hover_drawer == null: _hover_drawer = BubbleHoverDrawerType.new() _hover_drawer.name = "HoverDrawer" add_child(_hover_drawer) move_child(_hover_drawer, 0) _hover_drawer.configure( self, hover_drawer_text, hover_drawer_mode as BubbleHoverDrawer.DrawerMode, )