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, object CurrentItem); public enum Direction { None = 0, Clockwise = -1, CounterClockwise = 1 } private int displayCount; public int DisplayCount { get => displayCount; set { if (displayCount != value) { displayCount = value; } } } private IList itemSource; public IList ItemSource { get => itemSource; set { if (itemSource != value) { itemSource = value; } } } public int Count => Math.Max(displayCount, itemSource.Count); private int currentIndex = 0; public int SelectedIndex => currentIndex; public readonly ICommand RotateCommand; public event EventHandler Rotated = delegate { }; public RecyclerViewModel() { RotateCommand = new RelayCommand(CanRotate, Rotate); } private bool CanRotate(Direction direction) { return direction == Direction.Clockwise || direction == Direction.CounterClockwise; } public static int WrapIndex(int index, int count) { return (index + count) % count; } public int WrapIndex(int index) { return WrapIndex(index, Count); } public int GetOffset(int index) { return WrapIndex(currentIndex + index); } public static T GetItem(IList xs, int index) { return xs[WrapIndex(index, xs.Count)]; } public static object GetItem(IList xs, int index) { return xs[WrapIndex(index, xs.Count)]; } public object GetItem(int index) { if (0 > index || index >= itemSource.Count) { return null; } return GetItem(itemSource, index); } public static IEnumerable GetSlice(IList xs, int first, int count) { var diff = count - xs.Count; var xs2 = new List(xs); xs2.AddRange(Enumerable.Repeat(default, diff)); var len = first + count; for (int i = first; i < len; i++) { yield return GetItem(xs2, i); } } public static IEnumerable GetSlice(IList xs, int first, int count) { var diff = count - xs.Count; var xs2 = xs.Cast().ToList(); xs2.AddRange(Enumerable.Repeat(default, diff)); var len = first + count; for (int i = first; i < len; i++) { yield return GetItem(xs2, i); } } public IEnumerable GetSlice(int first, int count) { return GetSlice(itemSource, first, count); } public void Rotate(Direction direction) { var dir = direction switch { Direction.Clockwise => 1, Direction.CounterClockwise => -1, _ => 0 }; if (dir != 0) { currentIndex = WrapIndex(currentIndex + dir); Notify(direction); } } private void Notify(Direction direction) { Rotated?.Invoke(this, new RotateEvent( direction, currentIndex, GetItem(currentIndex) )); } } public class SelectEvent : EventBase { public int SelectedIndex; public object SelectedItem; protected override void Init() { base.Init(); bubbles = true; tricklesDown = true; } } [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 VisualTreeAsset template; [UxmlAttribute, CreateProperty] public VisualTreeAsset Template { get => template; set { if (template != value) { template = 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(OnNavigationMove); viewModel.Rotated += OnRotated; } private void CreateItems() { Clear(); if (DisplayCount == 0 || template == null) { return; } for (int i = 0; i < DisplayCount; i++) { var ve = template.CloneTree(); ve.focusable = true; Add(ve); } FocusFirst(); } public void Rebind() { if (viewModel == null || ItemSource == null || DisplayCount == 0) { 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) { var first = 0; var last = DisplayCount - 1; var (from, to) = e.Direction switch { RecyclerViewModel.Direction.Clockwise => (first, last), RecyclerViewModel.Direction.CounterClockwise => (last, first), RecyclerViewModel.Direction.None => (-1, -1) }; if (from == -1 && to == -1) { return; } var element = this[from]; RemoveAt(from); Insert(to, element); element.dataSource = viewModel.GetItem(viewModel.GetOffset(to)); FocusFirst(); NotifySelection(); } private void NotifySelection() { using SelectEvent evt = SelectEvent.GetPooled(); evt.target = this[0]; evt.SelectedIndex = viewModel.SelectedIndex; evt.SelectedItem = viewModel.GetItem(evt.SelectedIndex); SendEvent(evt); } private void FocusFirst() { if (childCount > 0) { this[0].Focus(); } } private void TryBindItem(VisualElement element, int index) { if (HasItem(index)) { var idx = viewModel.WrapIndex(index); if (0 <= idx && idx < ItemSource.Count) { element.dataSource = ItemSource[idx]; } } else { element.dataSource = null; } } 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); } } }