canto/Assets/Scripts/UI/Elements/Recycler.cs
2025-08-16 16:17:16 -04:00

276 lines
6.2 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using KitsuneCafe.UI.MVVM;
using Unity.Properties;
using UnityEngine;
using UnityEngine.UIElements;
namespace KitsuneCafe.UI
{
public class RecyclerViewModel
{
public record RotateEvent(Direction Direction, int FirstIndex);
public enum Direction
{
None = 0,
Clockwise = -1,
CounterClockwise = 1
}
private int displayCount;
public int DisplayCount
{
get => displayCount;
set
{
if (displayCount != value)
{
displayCount = value;
CreateIndices();
}
}
}
private IList itemSource;
public IList ItemSource
{
get => itemSource;
set
{
if (itemSource != value)
{
itemSource = value;
CreateIndices();
}
}
}
public int Count => Math.Min(DisplayCount, ItemSource.Count);
private List<int> indices;
public IReadOnlyList<int> Indices => indices;
public readonly ICommand<Direction> RotateCommand;
public event EventHandler<RotateEvent> Rotated = delegate { };
public RecyclerViewModel()
{
indices = new();
RotateCommand = new RelayCommand<Direction>(CanRotate, Rotate);
}
private void CreateIndices()
{
if (itemSource == null || displayCount == 0) { return; }
indices = Enumerable.Range(0, Math.Min(DisplayCount, ItemSource.Count)).ToList();
}
private bool CanRotate(Direction direction)
{
return direction == Direction.Clockwise || direction == Direction.CounterClockwise;
}
public void Rotate(Direction direction)
{
if (direction == Direction.Clockwise)
{
indices.RemoveAt(0);
var next = indices.Last() + 1;
next %= ItemSource.Count;
indices.Add(next);
Notify(direction);
}
else if (direction == Direction.CounterClockwise)
{
indices.RemoveAt(ItemSource.Count - 1);
var count = ItemSource.Count;
var previous = indices[0] - 1;
previous += count % count;
indices.Insert(0, previous);
Notify(direction);
}
}
private void Notify(Direction direction)
{
Rotated?.Invoke(this, new RotateEvent(direction, indices.First()));
}
}
public class SelectEvent : EventBase<SelectEvent>
{
public int ElementIndex { get; set; }
public int DataIndex { get; set; }
}
[UxmlElement]
public partial class RecyclerView : VisualElement
{
public const string BaseClass = "kitsunecafe__recycler-view";
[UxmlAttribute, CreateProperty]
public int DisplayCount
{
get => viewModel.DisplayCount;
set
{
viewModel.DisplayCount = value;
CreateItems();
}
}
[CreateProperty]
public IList ItemSource
{
get => viewModel.ItemSource;
set
{
if (ItemSource != value)
{
viewModel.ItemSource = value;
CreateItems();
Rebind();
}
}
}
private IBinder binder;
[CreateProperty]
public IBinder Binder
{
get => binder;
set
{
if (binder != value)
{
binder = value;
CreateItems();
Rebind();
}
}
}
private readonly RecyclerViewModel viewModel = new();
private readonly DragManipulator drag;
public RecyclerView()
{
AddToClassList(BaseClass);
drag = new DragManipulator(this);
drag.Dragged += OnDrag;
this.AddManipulator(drag);
RegisterCallback<NavigationMoveEvent>(OnNavigationMove);
viewModel.Rotated += OnRotated;
}
private void CreateItems()
{
Clear();
if (DisplayCount == 0 || binder == null) { return; }
for (int i = 0; i < DisplayCount; i++)
{
Add(binder.CreateItem());
}
FocusFirst();
}
private void Rebind()
{
if (viewModel == null || ItemSource == null || DisplayCount == 0 || binder == null) { return; }
for (int i = 0; i < DisplayCount; i++)
{
TryBindItem(this[i], i);
}
}
private bool HasItem(int index)
{
return 0 <= index && index < ItemSource.Count;
}
private void OnRotated(object sender, RecyclerViewModel.RotateEvent e)
{
VisualElement recycled;
switch (e.Direction)
{
case RecyclerViewModel.Direction.Clockwise:
recycled = this[0];
RemoveAt(0);
Insert(DisplayCount - 1, recycled);
TryBindItem(recycled, DisplayCount - 1);
NotifySelection();
break;
case RecyclerViewModel.Direction.CounterClockwise:
recycled = this[DisplayCount - 1];
RemoveAt(DisplayCount - 1);
Insert(0, recycled);
TryBindItem(recycled, 0);
NotifySelection();
break;
}
FocusFirst();
}
private void NotifySelection()
{
using SelectEvent evt = SelectEvent.GetPooled();
evt.target = this[0];
evt.ElementIndex = 0;
evt.DataIndex = viewModel.Indices[0];
SendEvent(evt);
}
private void FocusFirst()
{
if (childCount > 0)
{
this[0].Focus();
}
}
private void TryBindItem(VisualElement element, int index)
{
if (HasItem(index))
{
var idx = viewModel.Indices[index];
if (0 <= idx && idx < ItemSource.Count)
{
binder.BindItem(element, idx);
}
}
else
{
binder.UnbindItem(element);
}
}
private void OnDrag(Vector2 vector)
{
throw new NotImplementedException();
}
private void OnNavigationMove(NavigationMoveEvent evt)
{
var direction = evt.direction switch
{
NavigationMoveEvent.Direction.Left => RecyclerViewModel.Direction.CounterClockwise,
NavigationMoveEvent.Direction.Right => RecyclerViewModel.Direction.Clockwise,
NavigationMoveEvent.Direction.Next => RecyclerViewModel.Direction.Clockwise,
NavigationMoveEvent.Direction.Previous => RecyclerViewModel.Direction.CounterClockwise,
_ => RecyclerViewModel.Direction.None
};
viewModel.RotateCommand.TryExecute(direction);
}
}
}