Skip to content

Commit 2f4573e

Browse files
committed
Add ContextMenu pagination extensions to core pack
1 parent 2861521 commit 2f4573e

1 file changed

Lines changed: 301 additions & 0 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
using Elements.Core;
2+
using FrooxEngine;
3+
using System.Diagnostics.CodeAnalysis;
4+
using System.Runtime.CompilerServices;
5+
6+
#pragma warning disable CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do)
7+
8+
namespace MonkeyLoader.Resonite.UI
9+
{
10+
/// <summary>
11+
/// Contains extension methods to add pagination to and check for pagination on <see cref="ContextMenu"/>s.
12+
/// </summary>
13+
public static class ContextMenuPaginationExtensions
14+
{
15+
private static readonly ConditionalWeakTable<ContextMenu, PaginationInfo> _paginationInfosByContextMenu = [];
16+
17+
/// <inheritdoc cref="AddPagination(ContextMenu, int, int, out ContextMenuItem, out ContextMenuItem)"/>
18+
public static void AddPagination(this ContextMenu contextMenu, int maxItems, int currentPage = 0)
19+
=> contextMenu.AddPagination(maxItems, currentPage, out _, out _);
20+
21+
/// <summary>
22+
/// Adds pagination with the given configuration to this context menu.<br/>
23+
/// If there already is pagination, it will be replaced.
24+
/// </summary>
25+
/// <remarks>
26+
/// The maximum number of non-pagination items to show per page does not include the paging buttons.<br/>
27+
/// This means that up to <c><paramref name="maxItems"/> + 2</c> non-pagination items will be displayed without pagination.
28+
/// </remarks>
29+
/// <param name="back">The newly created context menu item to move to the previous page.</param>
30+
/// <param name="forward">The newly created context menu item to move to the next page.</param>
31+
/// <returns/>
32+
/// <exception cref="ArgumentNullException">When this <paramref name="contextMenu"/> is <see langword="null"/>.</exception>
33+
/// <exception cref="ArgumentOutOfRangeException">When <paramref name="maxItems"/> is not greater than zero.</exception>
34+
/// <exception cref="InvalidOperationException">When adding the pagination failed for other reasons.</exception>
35+
/// <inheritdoc cref="TryAddPagination(ContextMenu, int, int, out ContextMenuItem?, out ContextMenuItem?)"/>
36+
public static void AddPagination(this ContextMenu contextMenu, int maxItems, int currentPage,
37+
out ContextMenuItem back, out ContextMenuItem forward)
38+
{
39+
ArgumentNullException.ThrowIfNull(contextMenu);
40+
ArgumentOutOfRangeException.ThrowIfLessThan(maxItems, 0);
41+
42+
if (!contextMenu.TryAddPagination(ref maxItems, ref currentPage, true, out back!, out forward!))
43+
throw new InvalidOperationException("Failed to add pagination!");
44+
}
45+
46+
/// <summary>
47+
/// Tries to add pagination with the given configuration to this context menu.<br/>
48+
/// If there already is pagination, nothing happens.
49+
/// </summary>
50+
/// <inheritdoc cref="TryAddPagination(ContextMenu, int, int, out ContextMenuItem?, out ContextMenuItem?)"/>
51+
public static bool TryAddPagination(this ContextMenu contextMenu, int maxItems, int currentPage = 0)
52+
=> contextMenu.TryAddPagination(ref maxItems, ref currentPage, false, out _, out _);
53+
54+
/// <summary>
55+
/// Tries to add pagination with the given configuration to this context menu.<br/>
56+
/// If there already is pagination, the <see langword="out"/> parameters will
57+
/// be set to its <see cref="ContextMenuItem">menu items</see>.
58+
/// </summary>
59+
/// <param name="maxItems">The maximum number of non-pagination items to show per page. Must be greater than zero.</param>
60+
/// <param name="currentPage">The page that should be shown from the start. Can be any integer value, even negative.</param>
61+
/// <inheritdoc cref="TryAddPagination(ContextMenu, ref int, ref int, bool, out ContextMenuItem?, out ContextMenuItem?)"/>
62+
public static bool TryAddPagination(this ContextMenu contextMenu, int maxItems, int currentPage,
63+
[NotNullWhen(true)] out ContextMenuItem? back, [NotNullWhen(true)] out ContextMenuItem? forward)
64+
=> contextMenu.TryAddPagination(ref maxItems, ref currentPage, false, out back, out forward);
65+
66+
/// <summary>
67+
/// Tries to add pagination with the given configuration to this context menu.<br/>
68+
/// If <paramref name="forceNew"/> is not <see langword="true"/> and there already is pagination,
69+
/// the <see langword="ref"/> and <see langword="out"/> parameters will be set to its
70+
/// configuration and <see cref="ContextMenuItem">menu items</see>.
71+
/// </summary>
72+
/// <remarks><para>
73+
/// This method will not throw exceptions, even if the <paramref name="contextMenu"/> is <see langword="null"/>,
74+
/// or <paramref name="maxItems"/> is set to an invalid number.<br/>
75+
/// Any <see langword="ref"/> and <see langword="out"/> parameters only have
76+
/// valid or updated values if the return value is <see langword="true"/>.
77+
/// </para><para>
78+
/// The maximum number of non-pagination items to show per page does not include the paging buttons.<br/>
79+
/// This means that up to <c><paramref name="maxItems"/> + 2</c> non-pagination items will be displayed without pagination.
80+
/// </para></remarks>
81+
/// <param name="contextMenu">The context menu to add pagination to.</param>
82+
/// <param name="maxItems">
83+
/// The maximum number of non-pagination items to show per page. Must be greater than zero.<br/>
84+
/// Will be set to the already configured value if pagination was
85+
/// already set up and <paramref name="forceNew"/> is <see langword="false"/>.
86+
/// </param>
87+
/// <param name="currentPage">
88+
/// The page that should be shown from the start. Can be any integer value, even negative.<br/>
89+
/// Will be set to the clamped value after set up or the current value if pagination was
90+
/// already set up and <paramref name="forceNew"/> is <see langword="false"/>.<br/>
91+
/// This can be <c>-1</c> if there's only up to <c><paramref name="maxItems"/> + 2</c> non-pagination items, so pagination isn't necessary.
92+
/// </param>
93+
/// <param name="forceNew">Whether to re-add pagination even if it is already present.</param>
94+
/// <param name="back">
95+
/// The newly created or already present context menu item to move to the
96+
/// previous page if the return value is <see langword="true"/>; otherwise, <see langword="null"/>.
97+
/// </param>
98+
/// <param name="forward">
99+
/// The newly created or already present context menu item to move to the
100+
/// next page if the return value is <see langword="true"/>; otherwise, <see langword="null"/>.
101+
/// </param>
102+
/// <returns><see langword="true"/> if this <see cref="ContextMenu"/> is now paginated; otherwise, <see langword="false"/>.</returns>
103+
public static bool TryAddPagination(this ContextMenu contextMenu, ref int maxItems, ref int currentPage, bool forceNew,
104+
[NotNullWhen(true)] out ContextMenuItem? back, [NotNullWhen(true)] out ContextMenuItem? forward)
105+
{
106+
if (contextMenu.HasPagination(out var paginationInfo))
107+
{
108+
if (!forceNew)
109+
{
110+
maxItems = paginationInfo.MaxItems;
111+
currentPage = paginationInfo.CurrentPage;
112+
113+
back = paginationInfo.Back;
114+
forward = paginationInfo.Forward;
115+
116+
return true;
117+
}
118+
119+
paginationInfo.Destroy();
120+
}
121+
122+
back = null;
123+
forward = null;
124+
125+
if (contextMenu is null || maxItems <= 0)
126+
return false;
127+
128+
if (contextMenu.LocalUser != contextMenu.Owner.Target || contextMenu._ui is null)
129+
return false;
130+
131+
paginationInfo = new(contextMenu, maxItems, currentPage);
132+
133+
back = paginationInfo.Back;
134+
forward = paginationInfo.Forward;
135+
currentPage = paginationInfo.CurrentPage;
136+
137+
return true;
138+
}
139+
140+
/// <summary>
141+
/// Checks if this context menu already has pagination.
142+
/// </summary>
143+
/// <inheritdoc cref="HasPagination(ContextMenu, out int, out int, out ContextMenuItem?, out ContextMenuItem?)"/>
144+
public static bool HasPagination(this ContextMenu contextMenu)
145+
=> contextMenu.HasPagination(out _);
146+
147+
/// <summary>
148+
/// Checks if this context menu already has pagination and returns its configuration if so.
149+
/// </summary>
150+
/// <inheritdoc cref="HasPagination(ContextMenu, out int, out int, out ContextMenuItem?, out ContextMenuItem?)"/>
151+
public static bool HasPagination(this ContextMenu contextMenu, out int maxItems, out int currentPage)
152+
=> contextMenu.HasPagination(out maxItems, out currentPage, out _, out _);
153+
154+
/// <summary>
155+
/// Checks if this context menu already has pagination and returns its
156+
/// configuration and <see cref="ContextMenuItem">menu items</see> if so.
157+
/// </summary>
158+
/// <param name="contextMenu">The context menu to check for pagination.</param>
159+
/// <param name="maxItems">The maximum number of non-pagination items shown per page if there is pagination; otherwise, <see cref="int.MinValue"/>.</param>
160+
/// <param name="currentPage">
161+
/// The currently shown page if there is pagination; otherwise, <see cref="int.MinValue"/>.<br/>
162+
/// This can be <c>-1</c> if there's only up to <c><paramref name="maxItems"/> + 2</c> non-pagination items, so pagination isn't necessary.
163+
/// </param>
164+
/// <param name="back">The context menu item to move to the previous page if there is pagination; otherwise, <see langword="null"/>.</param>
165+
/// <param name="forward">The context menu item to move to the next page if there is pagination; otherwise, <see langword="null"/>.</param>
166+
/// <returns><see langword="true"/> if there is pagination on this context menu; otherwise, <see langword="false"/>.</returns>
167+
public static bool HasPagination(this ContextMenu contextMenu, out int maxItems, out int currentPage,
168+
[NotNullWhen(true)] out ContextMenuItem? back, [NotNullWhen(true)] out ContextMenuItem? forward)
169+
{
170+
if (!contextMenu.HasPagination(out var paginationInfo))
171+
{
172+
maxItems = int.MinValue;
173+
currentPage = int.MinValue;
174+
back = null;
175+
forward = null;
176+
177+
return false;
178+
}
179+
180+
maxItems = paginationInfo.MaxItems;
181+
currentPage = paginationInfo.CurrentPage;
182+
back = paginationInfo.Back;
183+
forward = paginationInfo.Forward;
184+
185+
return true;
186+
}
187+
188+
private static bool HasPagination(this ContextMenu contextMenu, [NotNullWhen(true)] out PaginationInfo? paginationInfo)
189+
{
190+
if (!_paginationInfosByContextMenu.TryGetValue(contextMenu, out paginationInfo))
191+
return false;
192+
193+
if (paginationInfo.IsInvalid)
194+
paginationInfo = null;
195+
196+
return paginationInfo is not null;
197+
}
198+
199+
private sealed class PaginationInfo
200+
{
201+
public ContextMenuItem Back { get; }
202+
public ContextMenu ContextMenu { get; }
203+
public int CurrentPage { get; private set; }
204+
public ContextMenuItem Forward { get; }
205+
public int MaxItems { get; }
206+
207+
public bool IsInvalid => ContextMenu.FilterWorldElement() is null
208+
|| Back.FilterWorldElement() is null || Forward.FilterWorldElement() is null;
209+
210+
public PaginationInfo(ContextMenu contextMenu, int maxItems, int currentPage)
211+
{
212+
ContextMenu = contextMenu;
213+
MaxItems = maxItems;
214+
CurrentPage = currentPage;
215+
216+
Back = contextMenu.AddItem("Back", OfficialAssets.Common.Icons.Left_Chevron, RadiantUI_Constants.Neutrals.LIGHT);
217+
Back.Slot.OrderOffset = long.MinValue;
218+
Back.Button.LocalPressed += PreviousPage;
219+
Back.Destroyed += Destroy;
220+
221+
Forward = contextMenu.AddItem("Forward", OfficialAssets.Common.Icons.Right_Chevron, RadiantUI_Constants.Neutrals.LIGHT);
222+
Forward.Slot.OrderOffset = long.MaxValue;
223+
Forward.Button.LocalPressed += NextPage;
224+
225+
var itemsRoot = ContextMenu._itemsRoot.Target;
226+
itemsRoot.ChildAdded += SlotChildrenChanged;
227+
itemsRoot.ChildRemoved += SlotChildrenChanged;
228+
itemsRoot.ChildrenOrderInvalidated += SlotChildrenChanged;
229+
230+
UpdatePagination();
231+
232+
_paginationInfosByContextMenu.AddOrUpdate(contextMenu, this);
233+
}
234+
235+
public void Destroy(IDestroyable? destroyable = null)
236+
{
237+
var itemsRoot = ContextMenu._itemsRoot.Target;
238+
239+
itemsRoot.ChildAdded -= SlotChildrenChanged;
240+
itemsRoot.ChildRemoved -= SlotChildrenChanged;
241+
itemsRoot.ChildrenOrderInvalidated -= SlotChildrenChanged;
242+
243+
_paginationInfosByContextMenu.Remove(ContextMenu);
244+
}
245+
246+
private void NextPage(IButton button, ButtonEventData args)
247+
{
248+
++CurrentPage;
249+
UpdatePagination();
250+
}
251+
252+
private void PreviousPage(IButton button, ButtonEventData args)
253+
{
254+
--CurrentPage;
255+
UpdatePagination();
256+
}
257+
258+
private void SlotChildrenChanged(Slot slot, Slot child)
259+
=> UpdatePagination();
260+
261+
private void SlotChildrenChanged(Slot slot)
262+
=> UpdatePagination();
263+
264+
private void UpdatePagination()
265+
{
266+
if (IsInvalid)
267+
return;
268+
269+
// Do not count the paging buttons
270+
var itemsRoot = ContextMenu._itemsRoot.Target;
271+
var items = itemsRoot.ChildrenCount - 2;
272+
273+
if (items <= MaxItems)
274+
{
275+
CurrentPage = -1;
276+
Back.Slot.ActiveSelf = false;
277+
Forward.Slot.ActiveSelf = false;
278+
return;
279+
}
280+
281+
// Shift max page down by one item to prevent empty page at the end
282+
var maxPage = (items - 1) / MaxItems;
283+
CurrentPage = MathX.Max(0, MathX.Min(maxPage, CurrentPage));
284+
285+
Back.Slot.ActiveSelf = true;
286+
Back.Button.Enabled = CurrentPage > 0;
287+
288+
Forward.Slot.ActiveSelf = true;
289+
Forward.Button.Enabled = CurrentPage < maxPage;
290+
291+
var pageStart = CurrentPage * MaxItems;
292+
var pageEnd = (CurrentPage + 1) * MaxItems;
293+
294+
for (var i = 0; i < items; ++i)
295+
itemsRoot[i + 1].ActiveSelf = i >= pageStart && i < pageEnd;
296+
}
297+
}
298+
}
299+
}
300+
301+
#pragma warning restore CS1573 // Parameter has no matching param tag in the XML comment (but other parameters do)

0 commit comments

Comments
 (0)