Skip to content

Commit 2fe5327

Browse files
committed
Implement paginated text select component
1 parent e401b33 commit 2fe5327

File tree

5 files changed

+359
-16
lines changed

5 files changed

+359
-16
lines changed
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Any, Callable, Literal, Self, override
5+
6+
from discord import Interaction, SelectOption
7+
from discord.abc import MISSING
8+
from discord.ui import ActionRow, LayoutView, Select, View
9+
from discord.ui.item import ContainedItemCallbackType
10+
from discord.ui.select import SelectCallbackDecorator
11+
12+
from ghutils.utils.types import AsyncCallable
13+
14+
logger = logging.getLogger(__name__)
15+
16+
# randomly generated UUIDs
17+
PREVIOUS_PAGE_VALUE = "e4215656-23ba-4a3d-8386-795778944b4b"
18+
NEXT_PAGE_VALUE = "1e1f5d4c-e908-42cf-8d6c-8b66aadf0998"
19+
20+
MAX_PAGE_LENGTH = 23
21+
22+
23+
type PageGetter[V: View | LayoutView] = AsyncCallable[
24+
[V, Interaction, PaginatedSelect[V], int],
25+
list[SelectOption],
26+
]
27+
28+
29+
class PaginatedSelect[V: View | LayoutView](Select[V]):
30+
_inner_callback: ContainedItemCallbackType[V | ActionRow[Any], Self] | None
31+
_page_getter: PageGetter[V] | None
32+
_page_cache: dict[int, list[SelectOption]]
33+
34+
_page: int
35+
_selected_page: int | None
36+
_selected_index: int | None
37+
38+
def __init__(
39+
self,
40+
*,
41+
custom_id: str = MISSING,
42+
placeholder: None = None,
43+
min_values: Literal[0, 1] = 1,
44+
max_values: Literal[0, 1] = 1,
45+
options: list[SelectOption] = MISSING,
46+
disabled: bool = False,
47+
required: bool = True,
48+
row: int | None = None,
49+
id: int | None = None,
50+
inner_callback: ContainedItemCallbackType[V | ActionRow[Any], Self]
51+
| None = None,
52+
page_getter: PageGetter[V] | None = None,
53+
) -> None:
54+
super().__init__(
55+
custom_id=custom_id,
56+
placeholder=placeholder,
57+
min_values=min_values,
58+
max_values=max_values,
59+
options=options,
60+
disabled=disabled,
61+
required=required,
62+
row=row,
63+
id=id,
64+
)
65+
66+
# TODO: implement?
67+
if self.max_values > 1:
68+
raise NotImplementedError("PaginatedSelect does not support multi-select")
69+
70+
self._inner_callback = inner_callback
71+
self._page_getter = page_getter
72+
self._page_cache = {}
73+
self._page = 1
74+
self._selected_page = None
75+
self._selected_index = None
76+
77+
if options is not MISSING:
78+
# reuse the logic in the decorator
79+
self.options = options
80+
81+
@property
82+
def page_getter(self):
83+
"""A decorator to set the page getter function.
84+
85+
The page getter receives a 1-indexed page number to fetch, and should return up
86+
to 23 select options. If a page has less than 23 options, it is assumed to be
87+
the final page.
88+
"""
89+
return self._decorate_page_getter
90+
91+
@page_getter.setter
92+
def page_getter(self, page_getter: PageGetter[V] | None):
93+
self._page_getter = page_getter
94+
95+
def _decorate_page_getter(self) -> Callable[[PageGetter[V]], PageGetter[V]]:
96+
def decorator(page_getter: PageGetter[V]) -> PageGetter[V]:
97+
self._page_getter = page_getter
98+
return page_getter
99+
100+
return decorator
101+
102+
@property
103+
@override
104+
def options(self) -> list[SelectOption]:
105+
# https://stackoverflow.com/a/59313599
106+
assert Select.options.fget is not None
107+
return Select.options.fget(self)
108+
109+
@options.setter
110+
@override
111+
def options(self, value: list[SelectOption]):
112+
if len(value) > MAX_PAGE_LENGTH:
113+
raise ValueError(
114+
f"Pages must not contain more than {MAX_PAGE_LENGTH} options (got {len(value)})"
115+
)
116+
117+
assert Select.options.fset is not None
118+
Select.options.fset(self, value)
119+
120+
self._page_cache[self._page] = self.options.copy()
121+
122+
for i, option in enumerate(self.options):
123+
if option.default:
124+
self._selected_page = self._page
125+
self._selected_index = i
126+
break
127+
128+
# sanity check: don't add page selection options if they're already added
129+
# (this hopefully shouldn't happen)
130+
if self.options and (
131+
self.options[0].value == PREVIOUS_PAGE_VALUE
132+
or self.options[-1].value == NEXT_PAGE_VALUE
133+
or len(self.options) > MAX_PAGE_LENGTH
134+
):
135+
return
136+
137+
# NOTE: we need to check this *before* mutating self.options
138+
if (
139+
# only allow going to the next page if the current page is full
140+
len(self.options) == MAX_PAGE_LENGTH
141+
# and either the next page has values or we haven't fetched it yet
142+
and self._page_cache.get(self._page + 1, True)
143+
):
144+
self.options.append(
145+
SelectOption(
146+
emoji="➡️",
147+
label=f"Page {self._page + 1}",
148+
value=NEXT_PAGE_VALUE,
149+
)
150+
)
151+
152+
if self._page > 1:
153+
self.options.insert(
154+
0,
155+
SelectOption(
156+
emoji="⬅️",
157+
label=f"Page {self._page - 1}",
158+
value=PREVIOUS_PAGE_VALUE,
159+
),
160+
)
161+
162+
@property
163+
def selected_option(self) -> SelectOption | None:
164+
if self._selected_page is None or self._selected_index is None:
165+
return None
166+
167+
if self._selected_page == self._page:
168+
index = self._selected_index
169+
if self._page > 1:
170+
# skip the back option
171+
index += 1
172+
return self.options[index]
173+
174+
return self._page_cache[self._selected_page][self._selected_index]
175+
176+
@selected_option.setter
177+
def selected_option(self, option: None):
178+
self._selected_page = None
179+
self._selected_index = None
180+
181+
@override
182+
async def callback(self, interaction: Interaction):
183+
"""The callback associated with this UI item.
184+
185+
This can be overridden by subclasses, but subclasses should prefer to override
186+
`inner_callback` in most cases.
187+
"""
188+
if selected := set(self.values):
189+
if PREVIOUS_PAGE_VALUE in selected:
190+
# go to previous page
191+
await self._switch_to_page(interaction, self._page - 1)
192+
193+
elif NEXT_PAGE_VALUE in selected:
194+
# go to next page
195+
await self._switch_to_page(interaction, self._page + 1)
196+
197+
else:
198+
# normal selection
199+
if self.selected_option:
200+
self.selected_option.default = False
201+
202+
self._selected_page = self._page
203+
204+
for i, option in enumerate(self.options):
205+
if option.value in selected:
206+
self._selected_index = i - 1 if self._page > 1 else i
207+
option.default = True
208+
break
209+
210+
self._clear_remote_page_selection()
211+
212+
await self.inner_callback(interaction)
213+
214+
else:
215+
# deselected
216+
if self.selected_option:
217+
self.selected_option.default = False
218+
self.selected_option = None
219+
self._clear_remote_page_selection()
220+
221+
await self.inner_callback(interaction)
222+
223+
async def inner_callback(self, interaction: Interaction):
224+
"""The callback associated with this UI item. Not called when switching pages.
225+
226+
This can be overridden by subclasses. The default implementation calls the
227+
function decorated by `paginated_select`, if any.
228+
"""
229+
if self._inner_callback:
230+
assert self.view is not None
231+
await self._inner_callback(self.view, interaction, self)
232+
233+
async def _switch_to_page(self, interaction: Interaction, page: int):
234+
if page < 1:
235+
page = 1
236+
237+
if (options := self._page_cache.get(page)) is None:
238+
assert self._page_getter
239+
assert self.view
240+
options = await self._page_getter(self.view, interaction, self, page)
241+
if len(options) > MAX_PAGE_LENGTH:
242+
raise ValueError(
243+
f"Pages must not contain more than {MAX_PAGE_LENGTH} options (got {len(options)})"
244+
)
245+
self._page_cache[page] = options
246+
247+
# if an option on the current page is selected, mark it as default
248+
# we use a loop to ensure *only* the selected option is marked
249+
for i, option in enumerate(options):
250+
option.default = self._selected_page == page and self._selected_index == i
251+
252+
# note: the options setter checks option.default
253+
self._page = page
254+
self.options = options
255+
256+
# if an option on a different page is selected, set the placeholder
257+
# and update the description of the option pointing at that page
258+
if self.selected_option and self._selected_page != page:
259+
self.placeholder = self.selected_option.label
260+
261+
assert self._selected_page is not None
262+
self.options[
263+
0 if self._selected_page < page else -1
264+
].description = f"(selected on page {self._selected_page})"
265+
else:
266+
self.placeholder = None
267+
268+
if interaction.response.is_done():
269+
await interaction.edit_original_response(view=self.view)
270+
else:
271+
await interaction.response.edit_message(view=self.view)
272+
273+
def _clear_remote_page_selection(self):
274+
self.placeholder = None
275+
276+
if self.options[0].value == PREVIOUS_PAGE_VALUE:
277+
self.options[0].description = None
278+
279+
if self.options[-1].value == NEXT_PAGE_VALUE:
280+
self.options[-1].description = None
281+
282+
283+
def paginated_select[
284+
S: View | LayoutView | ActionRow[Any],
285+
SelectT: PaginatedSelect[Any],
286+
](
287+
*,
288+
options: list[SelectOption] = MISSING,
289+
custom_id: str = MISSING,
290+
min_values: Literal[0, 1] = 1,
291+
max_values: Literal[0, 1] = 1,
292+
disabled: bool = False,
293+
row: int | None = None,
294+
id: int | None = None,
295+
) -> SelectCallbackDecorator[S, SelectT]:
296+
def decorator(inner_callback: ContainedItemCallbackType[Any, Any]) -> SelectT:
297+
select = PaginatedSelect[Any](
298+
options=options,
299+
custom_id=custom_id,
300+
min_values=min_values,
301+
max_values=max_values,
302+
disabled=disabled,
303+
row=row,
304+
id=id,
305+
inner_callback=inner_callback,
306+
)
307+
return select # pyright: ignore[reportReturnType]
308+
309+
return decorator

bot/src/ghutils/ui/components/visibility.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
from discord import Embed, Interaction
66
from discord.app_commands import Command
7-
from discord.ui import ActionRow, Button, Container, DynamicItem, Item, LayoutView, View
7+
from discord.ui import Button, DynamicItem, Item, LayoutView, View
88
from discord.utils import MISSING
99

1010
from ghutils.core.bot import GHUtilsBot
1111
from ghutils.core.types import CustomEmoji
1212
from ghutils.utils.discord.commands import AnyInteractionCommand
13+
from ghutils.utils.discord.components import AnyComponentParent
1314

1415
type MessageVisibility = Literal["public", "private"]
1516

@@ -165,7 +166,7 @@ async def respond_with_visibility(
165166

166167
@overload
167168
def add_visibility_buttons(
168-
parent: View | LayoutView | ActionRow[Any] | Container[Any],
169+
parent: AnyComponentParent,
169170
interaction: Interaction,
170171
visibility: Literal["public"],
171172
*,
@@ -176,7 +177,7 @@ def add_visibility_buttons(
176177

177178
@overload
178179
def add_visibility_buttons(
179-
parent: View | LayoutView | ActionRow[Any] | Container[Any],
180+
parent: AnyComponentParent,
180181
interaction: Interaction,
181182
visibility: Literal["private"],
182183
*,
@@ -186,7 +187,7 @@ def add_visibility_buttons(
186187

187188
@overload
188189
def add_visibility_buttons(
189-
parent: View | LayoutView | ActionRow[Any] | Container[Any],
190+
parent: AnyComponentParent,
190191
interaction: Interaction,
191192
visibility: MessageVisibility,
192193
*,
@@ -197,7 +198,7 @@ def add_visibility_buttons(
197198

198199

199200
def add_visibility_buttons(
200-
parent: View | LayoutView | ActionRow[Any] | Container[Any],
201+
parent: AnyComponentParent,
201202
interaction: Interaction,
202203
visibility: MessageVisibility,
203204
*,

0 commit comments

Comments
 (0)