# Made by Xavier Alvarez. A part of the "FreeControl" Godot addon.
@tool
class_name Carousel extends Container
## A container for Carousel Display of [Control] nodes.



## Changes the behavior of how draging scrolls the carousel items. Also see [member snap_carousel_transtion_type], [member snap_carousel_ease_type], and [member paging_requirement].
enum SNAP_BEHAVIOR {
    NONE = 0b00, ## No behavior.
    SNAP = 0b01, ## Once drag is released, the carousel will snap to the nearest item.x
    PAGING = 0b10 ## Carousel items will not scroll when dragged, unless [member paging_requirement] threshold is met. [member hard_stop] will be assumed as [code]true[/code] for this.
}
## Internel enum used to differentiate what animation is currently playing
enum ANIMATION_TYPE {
    NONE = 0b00, ## No behavior.
    MANUAL = 0b01, ## Currently animating via request by [method go_to_index].
    SNAP = 0b10 ## Currently animating via an auto-item snapping request.
}


## This signal is emited when a snap reaches it's destination.
signal snap_end
## This signal is emited when a snap begins.
signal snap_begin
## This signal is emited when a drag finishes. This does not include the slowdown caused when [member hard_stop] is [code]false[/code].
signal drag_end
## This signal is emited when a drag begins.
signal drag_begin
## This signal is emited when the slowdown, caused when [member hard_stop] is [code]false[/code], finished naturally.
signal slowdown_end
## This signal is emited when the slowdown, caused when [member hard_stop] is [code]false[/code], is interrupted by another drag or other feature.
signal slowdown_interupted



@export_group("Carousel Options")
## The index of the item this carousel will start at.
@export var starting_index : int = 0:
    set(val):
        if starting_index != val:
            starting_index = val
            go_to_index(-val, false)
## The size of each item in the carousel.
@export var item_size : Vector2 = Vector2(200, 200):
    set(val):
        if item_size != val:
            var current_index := get_carousel_index()
            item_size = val
            _settup_children()
            go_to_index(current_index, false)
## The space between each item in the carousel.
@export var item_seperation : int = 0:
    set(val):
        if item_seperation != val:
            item_seperation = val
            _kill_animation()
            _adjust_children()
## The orientation the carousel items will be displayed in.
@export_range(0, 360, 0.001, "or_less", "or_greater") var carousel_angle : float = 0.0:
    set(val):
        if carousel_angle != val:
            var current_index := get_carousel_index()

            carousel_angle = val
            _angle_vec = Vector2.RIGHT.rotated(deg_to_rad(carousel_angle))

            _kill_animation()
            _adjust_children()
            go_to_index(current_index, false)

@export_group("Loop Options")
## Allows looping from the last item to the first and vice versa.
@export var allow_loop : bool = true
## If [code]true[/code], the carousel will display it's items as if looping. Otherwise, the items will not loop.
## [br][br]
## also see [member enforce_border] and [member border_limit].
@export var display_loop : bool = true:
    set(val):
        if val != display_loop:
            display_loop = val
            _adjust_children()
            notify_property_list_changed()
## The number of items, surrounding the current item of the current index, that will be visible.
## If [code]-1[/code], all items will be visible.
@export var display_range : int = -1:
    set(val):
        val = max(-1, val)
        if val != display_range:
            display_range = val
            _adjust_children()

@export_group("Snap")
## Assigns the behavior of how draging scrolls the carousel items. Also see [member snap_carousel_transtion_type], [member snap_carousel_ease_type], and [member paging_requirement].
@export var snap_behavior : SNAP_BEHAVIOR = SNAP_BEHAVIOR.SNAP:
    set(val):
        if val != snap_behavior:
            snap_behavior = val
            _end_drag_slowdown()
            _create_animation(get_carousel_index(), ANIMATION_TYPE.SNAP)
            notify_property_list_changed()
## If [member snap_behavior] is [SNAP_BEHAVIOR.PAGING], this is the draging threshold needed to page to the next carousel item.
@export var paging_requirement : int = 200:
    set(val):
        val = max(1, val)
        if val != paging_requirement:
            paging_requirement = val
            _adjust_children()

@export_group("Animation Options")
@export_subgroup("Manual")
## The duration of the animation any call to [method go_to_index] will cause, if the animation option is requested.
@export_range(0.001, 2.0, 0.001, "or_greater") var manual_carousel_duration : float = 0.4
## The [enum Tween.TransitionType] of the animation any call to [method go_to_index] will cause, if the animation option is requested.
@export var manual_carousel_transtion_type : Tween.TransitionType
## The [enum Tween.EaseType] of the animation any call to [method go_to_index] will cause, if the animation option is requested.
@export var manual_carousel_ease_type : Tween.EaseType

@export_subgroup("Snap")
## The duration of the animation when snapping to an item.
@export_range(0.001, 2.0, 0.001, "or_greater") var snap_carousel_duration : float = 0.2
## The [enum Tween.TransitionType] of the animation when snapping to an item.
@export var snap_carousel_transtion_type : Tween.TransitionType
## The [enum Tween.EaseType] of the animation when snapping to an item.
@export var snap_carousel_ease_type : Tween.EaseType

@export_group("Drag")
## If [code]true[/code], the user is allowed to drag via their mouse or touch.
@export var can_drag : bool = true:
    set(val):
        if val != can_drag:
            can_drag = val
            notify_property_list_changed()
            if !val:
                _drag_scroll_value = 0
                if _is_dragging:
                    _adjust_children()
## If [code]true[/code], the user is allowed to drag outisde the drawer's bounding box.
## [br][br]
## Also see [member can_drag].
@export var drag_outside : bool = false:
    set(val):
        if val != drag_outside:
            drag_outside = val
@export_subgroup("Limits")
## The max amount a user can drag in either direction. If [code]0[/code], then the user can drag any amount they wish.
@export var drag_limit : int = 0:
    set(val):
        val = max(0, val)
        if val != drag_limit: drag_limit = val
## When dragging, the user will not be able to move past the last or first item, besides for [member border_limit] number of extra pixels.
## [br][br]
## This value is assumed [code]false[/code] is [member display_loop] is [code]true[/code].
@export var enforce_border : bool = false:
    set(val):
        if val != enforce_border:
            enforce_border = val
            _adjust_children()
            notify_property_list_changed()
## The amount of extra pixels a user can drag past the last and before the first item in the carousel.
## [br][br]
## This property does nothing if enforce_border is [code]false[/code].
@export var border_limit : int = 0:
    set(val):
        if val != border_limit:
            border_limit = val
            _adjust_children()

@export_subgroup("Slowdown")
## If [code]true[/code] the carousel will immediately stop when not being dragged. Otherwise, drag speed will be gradually decreased.
## [br][br]
## This property is assumed [code]true[/code] if [member snap_behavior] is set to [SNAP_BEHAVIOR.PAGING]. Also see [member slowdown_drag], [member slowdown_friction], and [member slowdown_cutoff].
@export var hard_stop : bool = true:
    set(val):
        if val != hard_stop:
            hard_stop = val
            _end_drag_slowdown()
            notify_property_list_changed()
## The percentage multiplier the drag velocity will experience each frame.
## [br][br]
## This property does nothing if [member hard_stop] is [code]true[/code].
@export_range(0.0, 1.0, 0.001) var slowdown_drag : float = 0.9
## The constant decrease the drag velocity will experience each frame.
## [br][br]
## This property does nothing if [member hard_stop] is [code]true[/code].
@export_range(0.0, 5.0, 0.001, "or_greater", "hide_slider") var slowdown_friction : float = 0.1
## The cutoff amount. If drag velocity magnitude drops below this amount, the slowdown has finished.
## [br][br]
## This property does nothing if [member hard_stop] is [code]true[/code].
@export_range(0.01, 10.0, 0.001, "or_greater", "hide_slider") var slowdown_cutoff : float = 0.01


var _scroll_value : int
var _drag_scroll_value : int
var _drag_velocity : float

var _item_count : int = 0
var _item_infos : Array

var _scroll_tween : Tween
var _is_dragging : bool = false

var _last_animation : ANIMATION_TYPE = ANIMATION_TYPE.NONE

var _angle_vec : Vector2
var _mouse_checking : bool




# Public Functions

## Gets the index of the current carousel item.[br]
## If [param with_drag] is [code]true[/code] the current drag will also be considered.[br]
## If [param with_clamp] is [code]true[/code] the index will be looped if [member allow_loop] is true or clamped to a vaild index within the carousel.
func get_carousel_index(with_drag : bool = false, with_clamp : bool = true) -> int:
    if _item_count == 0: return -1

    var scroll : int = _scroll_value
    if with_drag: scroll += _drag_scroll_value

    var calculated := floori((float(scroll) / float(_get_relevant_axis())) + 0.5)
    if with_clamp:
        if allow_loop:
            calculated = posmod(calculated, _item_count)
        else:
            calculated = clampi(calculated, 0, _item_count - 1)

    return calculated
## Moves to an item of the given index within the carousel. If an invalid index is given, it will be posmod into a vaild index.
func go_to_index(idx : int, animation : bool = true) -> void:
    if _item_count == 0: return

    if allow_loop:
        idx = (((idx % _item_count) - _item_count) % _item_count)
    else:
        idx = clamp(idx, 0, _item_count - 1)

    if animation:
        _create_animation(idx, ANIMATION_TYPE.MANUAL)
    else:
        _kill_animation()
        _scroll_value = -_get_relevant_axis() * idx
        _adjust_children()
## Moves to the previous item in the carousel, if there is one.
func prev(animation : bool = true) -> void:
    go_to_index(get_carousel_index() - 1, animation)
## Moves to the next item in the carousel, if there is one.
func next(animation : bool = true) -> void:
    go_to_index(get_carousel_index() + 1, animation)
## Enacts a manual drag on the carousel. This can be used even if [member can_drag] is [code]false[/code].
## Note that [param from] and [param dir] are considered in local coordinates.
## [br][br]
## Is not affected by [member hard_stop], [member drag_outside], and [member drag_limit].
func flick(from : Vector2, dir : Vector2) -> void:
    drag_begin.emit()
    _kill_animation()
    _end_drag_slowdown()

    _handle_drag_angle(dir - from)

    _on_drag_release()
## Returns if the carousel is currening scrolling via na animation
func is_animating() -> bool:
    return _scroll_tween.is_running()
## Returns if the carousel is currening being dragged by player input.
func being_dragged() -> bool:
    return _is_dragging
## Returns the current scroll value.
func get_scroll(with_drag : bool = false) -> int:
    if with_drag:
        return _scroll_value + _drag_scroll_value
    return _scroll_value
## Returns the current number of items in the carousel
func get_item_count() -> int:
    return _item_count


# Virtual Functions

## A virtual function that is is called whenever the scroll changes.
func _on_progress(scroll : int) -> void: pass
## A virtual function that is is called whenever the scroll changes, for each visible item in the carousel
func _on_item_progress(item : Control, local_scroll : int, scroll : int, local_index : int, index : int) -> void: pass



func _handle_drag_angle(local_pos : Vector2) -> void:
    var projected_scalar : float = -local_pos.dot(_angle_vec) / _angle_vec.length_squared()
    _drag_velocity = projected_scalar
    _drag_scroll_value += projected_scalar

    if drag_limit != 0:
        _drag_scroll_value = clampi(_drag_scroll_value, -drag_limit, drag_limit)

    if snap_behavior == SNAP_BEHAVIOR.PAGING:
        if paging_requirement < _drag_scroll_value:
            _drag_scroll_value = 0
            var desired := get_carousel_index() + 1
            if allow_loop || desired < _item_count:
                _create_animation(desired, ANIMATION_TYPE.SNAP)
        elif -paging_requirement > _drag_scroll_value:
            _drag_scroll_value = 0
            var desired := get_carousel_index() - 1
            if allow_loop || desired >= 0:
                _create_animation(desired, ANIMATION_TYPE.SNAP)
    else:
        _adjust_children()


# Private Functions

func _get_child_rect(child : Control) -> Rect2:
    var child_pos : Vector2 = (size - item_size) * 0.5
    var child_size : Vector2

    child_size = child.get_combined_minimum_size()
    match child.size_flags_horizontal:
        SIZE_FILL: child_size.x = item_size.x
        SIZE_SHRINK_BEGIN: pass
        SIZE_SHRINK_CENTER: child_pos.x += (item_size.x - child_size.x) * 0.5
        SIZE_SHRINK_END: child_pos.x += (item_size.x - child_size.x)
    match child.size_flags_vertical:
        SIZE_FILL: child_size.y = item_size.y
        SIZE_SHRINK_BEGIN: pass
        SIZE_SHRINK_CENTER: child_pos.y += (item_size.y - child_size.y) * 0.5
        SIZE_SHRINK_END: child_pos.y += (item_size.y - child_size.y)

    child.size = child_size
    child.position = child_pos

    return Rect2(child_pos, child_size)
func _get_control_children() -> Array[Control]:
    var ret : Array[Control]
    ret.assign(get_children().filter(func(child : Node): return child is Control && child.visible))
    return ret
func _get_relevant_axis() -> int:
    var abs_angle_vec = _angle_vec.abs()

    if abs_angle_vec.y >= abs_angle_vec.x:
        return (item_size.x / abs_angle_vec.y) + item_seperation
    return (item_size.y / abs_angle_vec.x) + item_seperation
func _get_adjusted_scroll() -> int:
    var scroll := _scroll_value
    if snap_behavior != SNAP_BEHAVIOR.PAGING:
        scroll += _drag_scroll_value
    if enforce_border:
        scroll = clampi(scroll, -border_limit, _get_relevant_axis() * (_item_count - 1) + border_limit)
    elif display_loop:
        scroll = posmod(scroll, _get_relevant_axis() * _item_count)
    return scroll


func _create_animation(idx : int, animation_type : ANIMATION_TYPE) -> void:
    _kill_animation()
    if _item_count == 0: return
    _scroll_tween = create_tween()

    var axis := _get_relevant_axis()
    var max_scroll := axis * _item_count
    if max_scroll == 0: max_scroll = 1

    var desired_scroll := posmod(axis * idx, max_scroll)

    if allow_loop && display_loop:
        _scroll_value = posmod(_scroll_value, max_scroll)
        if abs(_scroll_value - desired_scroll) > (max_scroll >> 1):
            var left_distance := posmod(_scroll_value - desired_scroll, max_scroll)
            var right_distance := posmod(desired_scroll - _scroll_value, max_scroll)

            if left_distance < right_distance:
                desired_scroll -= max_scroll
            else:
                desired_scroll += max_scroll

    _last_animation = animation_type
    match animation_type:
        ANIMATION_TYPE.MANUAL:
            _scroll_tween.set_ease(manual_carousel_ease_type)
            _scroll_tween.set_trans(manual_carousel_transtion_type)
            _scroll_tween.tween_method(
                _animation_method,
                _scroll_value,
                desired_scroll,
                manual_carousel_duration)
        ANIMATION_TYPE.SNAP:
            snap_begin.emit()
            _scroll_tween.set_ease(snap_carousel_ease_type)
            _scroll_tween.set_trans(snap_carousel_transtion_type)
            _scroll_tween.tween_method(
                _animation_method,
                _scroll_value,
                desired_scroll,
                snap_carousel_duration)
            _scroll_tween.tween_callback(_kill_animation)
    _scroll_tween.play()
func _animation_method(scroll : int) -> void:
    _scroll_value = scroll
    _adjust_children()
func _kill_animation() -> void:
    if _last_animation == ANIMATION_TYPE.SNAP:
        snap_end.emit()
    _last_animation = ANIMATION_TYPE.NONE

    if _scroll_tween && _scroll_tween.is_running():
        _scroll_tween.kill()


func _sort_children() -> void:
    _settup_children()
    _adjust_children()
func _settup_children() -> void:
    var children : Array[Control] = _get_control_children()
    _item_count = children.size()

    _item_infos.resize(_item_count)
    for i : int in range(0, _item_count):
        var item_info := ItemInfo.new()
        item_info.node = children[i]
        item_info.rect = _get_child_rect(children[i])
        _item_infos[i] = item_info
func _adjust_children() -> void:
    if _item_count == 0: return

    var range : Array
    var max_local_offset : int
    var children : Array[Control] = _get_control_children()
    var axis := _get_relevant_axis()
    var scroll := _get_adjusted_scroll()

    var index : int
    var local_scroll : int
    var adjustment : int

    if axis == 0:
        index = 0
        local_scroll = 0
        adjustment = 0
    else:
        index = int(scroll / axis)
        local_scroll = fposmod(scroll, axis)
        adjustment = int(scroll < 0)

    index += adjustment & int(local_scroll == 0)

    _on_progress(scroll)

    if display_loop:
        if display_range == -1:
            max_local_offset = (_item_count >> 1)
            range = range(0, _item_count)
            for i : int in range:
                _item_infos[i].loaded = false
        else:
            max_local_offset = (display_range >> 1)
            range = range(0, (display_range << 1) + 1)
            for i : int in range(0, _item_count):
                var item_info : ItemInfo = _item_infos[i]
                item_info.loaded = false
                item_info.node.visible = false

        for item : int in range:
            var local_offset := (item >> 1) * (((item & 1) << 1) - 1) + (item & 1)
            var local_index := posmod(index + local_offset, _item_count)
            var item_info : ItemInfo = _item_infos[local_index]
            if item_info.loaded: break

            local_offset += adjustment
            var rect : Rect2 = item_info.rect

            rect.position += _angle_vec * (local_offset * axis - local_scroll)

            fit_child_in_rect(item_info.node, rect)
            item_info.loaded = true
            item_info.node.visible = true
            item_info.node.z_index = max_local_offset - abs(local_offset)
            _on_item_progress(item_info.node, local_scroll, scroll, item, local_index)
    else:
        if display_range == -1:
            max_local_offset = (_item_count >> 1) + (_item_count & 1) + 1
            range = range(0, _item_count)
        else:
            max_local_offset = display_range
            range = range(max(0, index - display_range), min(_item_count, index + display_range + 1))
            for info : ItemInfo in _item_infos:
                info.node.visible = false

        for item : int in range:
            var local_index := item - index
            var item_info : ItemInfo = _item_infos[item]

            local_index += adjustment
            var rect : Rect2 = item_info.rect

            rect.position += _angle_vec * (local_index * axis - local_scroll)

            fit_child_in_rect(item_info.node, rect)
            item_info.node.visible = true
            item_info.node.z_index = max_local_offset - abs(local_index)
            _on_item_progress(item_info.node, local_scroll, scroll, item, local_index)

func _start_drag_slowdown() -> void:
    if is_inside_tree() && !get_tree().process_frame.is_connected(_handle_drag_slowdown):
        get_tree().process_frame.connect(_handle_drag_slowdown)
func _end_drag_slowdown() -> void:
    if abs(_drag_velocity) < slowdown_cutoff:
        slowdown_interupted.emit()
        _drag_velocity = 0
        if snap_behavior == SNAP_BEHAVIOR.SNAP:
            _create_animation(get_carousel_index(), ANIMATION_TYPE.SNAP)
    if is_inside_tree() && get_tree().process_frame.is_connected(_handle_drag_slowdown):
        get_tree().process_frame.disconnect(_handle_drag_slowdown)
func _handle_drag_slowdown() -> void:
    if abs(_drag_velocity) < slowdown_cutoff:
        slowdown_end.emit()
        _end_drag_slowdown()
        return

    if _drag_velocity > 0:
        _drag_velocity = max(0, _drag_velocity - slowdown_friction)
    else:
        _drag_velocity = min(0, _drag_velocity + slowdown_friction)
    _drag_velocity *= slowdown_drag
    _scroll_value += _drag_velocity
    _adjust_children()



func _init() -> void:
    sort_children.connect(_sort_children)
    tree_exiting.connect(_end_drag_slowdown)
    mouse_exited.connect(_mouse_check)

    _angle_vec = Vector2.RIGHT.rotated(deg_to_rad(carousel_angle))
func _ready() -> void:
    _settup_children()
    if _item_count > 0:
        starting_index = posmod(starting_index, _item_count)
        go_to_index(-starting_index, false)

func _validate_property(property: Dictionary) -> void:
    if property.name == "enforce_border":
        if display_loop:
            property.usage |= PROPERTY_USAGE_READ_ONLY
    elif property.name == "border_limit":
        if display_loop || !enforce_border:
            property.usage |= PROPERTY_USAGE_READ_ONLY
    elif property.name == "paging_requirement":
        if snap_behavior != SNAP_BEHAVIOR.PAGING:
            property.usage |= PROPERTY_USAGE_READ_ONLY
    elif property.name == "hard_stop":
        if snap_behavior == SNAP_BEHAVIOR.PAGING:
            property.usage |= PROPERTY_USAGE_READ_ONLY
    elif property.name in ["slowdown_drag", "slowdown_friction", "slowdown_cutoff"]:
        if hard_stop || snap_behavior == SNAP_BEHAVIOR.PAGING:
            property.usage |= PROPERTY_USAGE_READ_ONLY
    elif property.name in ["drag_outside"]:
        if !can_drag:
            property.usage |= PROPERTY_USAGE_READ_ONLY

func _gui_input(event: InputEvent) -> void:
    if event is not InputEventMouse:
        return

    var has_point := get_viewport_rect().has_point(event.position)

    if (
        (event is InputEventScreenDrag || event is InputEventMouseMotion) &&
        (drag_outside || has_point)
    ):
        if event.pressure == 0:
            if _is_dragging: _on_drag_release()
            return

        if !_is_dragging && has_point:
            drag_begin.emit()
            _mouse_checking = true
            _end_drag_slowdown()
            _kill_animation()
        _is_dragging = true

        _handle_drag_angle(event.relative)
    elif (event is InputEventScreenTouch || event is InputEventMouseButton):
        if !event.pressed: _on_drag_release()
func _on_drag_release() -> void:
    _mouse_checking = false
    _is_dragging = false
    drag_end.emit()

    if snap_behavior != SNAP_BEHAVIOR.PAGING:
        _scroll_value = _get_adjusted_scroll()
        _drag_scroll_value = 0
        if snap_behavior == SNAP_BEHAVIOR.NONE:
            if !hard_stop: _start_drag_slowdown()
        elif snap_behavior == SNAP_BEHAVIOR.SNAP:
            if hard_stop: _create_animation(get_carousel_index(), ANIMATION_TYPE.SNAP)
            else: _start_drag_slowdown()

func _mouse_check() -> void:
    if _mouse_checking:
        _on_drag_release()


func _get_allowed_size_flags_horizontal() -> PackedInt32Array:
    return [SIZE_FILL, SIZE_SHRINK_BEGIN, SIZE_SHRINK_CENTER, SIZE_SHRINK_END]
func _get_allowed_size_flags_vertical() -> PackedInt32Array:
    return [SIZE_FILL, SIZE_SHRINK_BEGIN, SIZE_SHRINK_CENTER, SIZE_SHRINK_END]


# Used to hold data about a carousel item
class ItemInfo:
    var node : Control
    var rect : Rect2
    var loaded : bool

# Made by Xavier Alvarez. A part of the "FreeControl" Godot addon.
