Skip to content

Commit d747608

Browse files
authored
feat: implement cv2 layout parsing (#50)
* refactor!: make get/set manager work on class level * refactor!: update component (de)registration logic * refactor: make component invocation work with changes * feat(docs): add changelog entries * chore: bump dev dependencies * chore: fix pyright issues * feat: make parse_message_components cv2-compatible * docs(examples): add cv2 example (wip) * feat: implement v2 component parsing into manager * feat!: implement component layout updating * fix(ci): make pre-commit actually use config??? * docs(example): clean up example * refactor: break out disnake types into separate file * feat(docs): add changelog entries * chore(docs): oopsie * fix: use new layout methods in row example * fix: make count chars actually store all possible count chars * feat: implement cv2 id fields on rich components * fish(mael): i'm
1 parent 459396e commit d747608

File tree

11 files changed

+286
-129
lines changed

11 files changed

+286
-129
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ repos:
2929
rev: v0.9.6
3030
hooks:
3131
- id: ruff
32-
args: [--fix, --exit-non-zero-on-fix]
32+
args: [--fix, --exit-non-zero-on-fix, --config, pyproject.toml]
3333
name: Running ruff in all files.
3434
- id: ruff-format
35+
args: [--config, pyproject.toml]
3536

3637
- repo: https://github.com/ariebovenberg/slotscheck
3738
rev: v0.19.1

changelog/50.breaking.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Modify :meth:`ComponentManager.parse_message_components` to take components directly and return a sequence of disnake UI components to support parsing v2 components.

changelog/50.breaking.1.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Remove ``ComponentManager.finalise_components`` in favour of :meth:`ComponentManager.update_layout`.

changelog/50.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add :meth:`ComponentManager.update_layout` to update a disnake UI component layout in-place with rich components (v1 and v2 compatible).

examples/row.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import typing
55

66
import disnake
7-
import disnake_compass
87
from disnake.ext import commands
98

9+
import disnake_compass
10+
1011
DEFAULT_OPTION = disnake.SelectOption(
1112
label="Please enable some options.",
1213
value="placeholder",
@@ -32,9 +33,7 @@ def parse_options(self) -> typing.Sequence[disnake.SelectOption]:
3233

3334
return [disnake.SelectOption(label=option) for option in self.options]
3435

35-
def update_select(
36-
self, components: typing.Sequence[disnake_compass.api.RichComponent]
37-
):
36+
def update_select(self, components: typing.Sequence[disnake_compass.api.RichComponent]):
3837
select: DynamicSelectMenu | None = None
3938
options: list[disnake.SelectOption] = []
4039

@@ -57,7 +56,7 @@ async def callback(self, interaction: disnake.MessageInteraction[disnake.Client]
5756
# Get all components on the message for easier re-sending.
5857
# Both of these lists will automagically contain self so that any
5958
# changes immediately reflect without extra effort.
60-
rows, components = await manager.parse_message_components(interaction.message)
59+
layout, components = await manager.parse_message_components(interaction.message.components)
6160

6261
# Toggle style for the clicked button.
6362
self.style = (
@@ -71,8 +70,8 @@ async def callback(self, interaction: disnake.MessageInteraction[disnake.Client]
7170
self.update_select(components)
7271

7372
# Re-send and update all components.
74-
finalised = await manager.finalise_components(rows)
75-
await interaction.response.edit_message(components=finalised)
73+
await manager.update_layout(layout, components)
74+
await interaction.response.edit_message(components=layout)
7675

7776

7877
@manager.register()
@@ -93,9 +92,7 @@ def set_options(self, options: typing.List[disnake.SelectOption]):
9392
self.max_values = 1
9493
self.disabled = True
9594

96-
async def callback(
97-
self, interaction: disnake.MessageInteraction[disnake.Client]
98-
) -> None:
95+
async def callback(self, interaction: disnake.MessageInteraction[disnake.Client]) -> None:
9996
selection = (
10097
"\n".join(f"- {value}" for value in interaction.values)
10198
if interaction.values
@@ -109,16 +106,22 @@ async def callback(
109106
async def test_components(
110107
interaction: disnake.CommandInteraction[disnake.Client],
111108
) -> None:
112-
layout = await manager.finalise_components(
109+
# TODO: Maybe a utility method to turn a sequence of rich components into a
110+
# sequence of UI components?
111+
layout = [
113112
[
114-
[
115-
OptionsToggleButton(label="numbers", options=["1", "2", "3", "4", "5"]),
116-
OptionsToggleButton(label="letters", options=["a", "b", "c", "d", "e"]),
117-
OptionsToggleButton(label="symbols", options=["*", "&", "#", "+", "-"]),
118-
],
119-
[DynamicSelectMenu()],
113+
await OptionsToggleButton(
114+
label="numbers", options=["1", "2", "3", "4", "5"]
115+
).as_ui_component(),
116+
await OptionsToggleButton(
117+
label="letters", options=["a", "b", "c", "d", "e"]
118+
).as_ui_component(),
119+
await OptionsToggleButton(
120+
label="symbols", options=["*", "&", "#", "+", "-"]
121+
).as_ui_component(),
120122
],
121-
)
123+
[await DynamicSelectMenu().as_ui_component()],
124+
]
122125

123126
await interaction.response.send_message(components=layout)
124127

examples/v2.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""An example showcasing a simple cv2-style paginator powered by disnake-compass."""
2+
3+
import os
4+
5+
import disnake
6+
import disnake_compass
7+
from disnake.ext import commands
8+
9+
bot = commands.InteractionBot()
10+
11+
manager = disnake_compass.get_manager()
12+
manager.add_to_client(bot)
13+
14+
15+
@manager.register()
16+
class PageAdvanceButton(disnake_compass.RichButton):
17+
increment: int
18+
19+
async def callback(self, inter: disnake.MessageInteraction[disnake.Client]) -> None:
20+
_, components = await manager.parse_message_components(inter.message.components)
21+
22+
# Find and increment the page tracker...
23+
for component in components:
24+
if isinstance(component, PageTrackerButton):
25+
component.increment(self.increment)
26+
break
27+
else:
28+
# Unreachable, a page tracker should always be sent alongside
29+
# any page advancing buttons.
30+
raise RuntimeError
31+
32+
new_page = await create_paginator(component.page, component.pages)
33+
await inter.response.edit_message(components=new_page)
34+
35+
36+
@manager.register()
37+
class PageTrackerButton(disnake_compass.RichButton):
38+
disabled: bool = True
39+
40+
page: int
41+
pages: int
42+
43+
def __attrs_post_init__(self) -> None:
44+
self.update_label()
45+
46+
def increment(self, inc: int):
47+
self.page = (self.page + inc) % self.pages
48+
self.update_label()
49+
50+
def update_label(self):
51+
self.label = f"{self.page + 1} / {self.pages}"
52+
53+
async def callback(self, _: disnake.MessageInteraction[disnake.Client]) -> None:
54+
raise NotImplementedError # This button is always disabled.
55+
56+
57+
def create_paginator_content(page_number: int):
58+
if page_number == 0:
59+
return disnake.ui.Section(
60+
disnake.ui.TextDisplay(
61+
"Welcome aboard, greenhorn!\nYou are able-bodied and ready for the voyage, I trust?"
62+
),
63+
accessory=disnake.ui.Thumbnail(
64+
"https://limbuscompany.wiki.gg/images/The_Pequod_Captain_Ishmael_Idle_Sprite.png"
65+
),
66+
)
67+
68+
if page_number == 1:
69+
return disnake.ui.Section(
70+
disnake.ui.TextDisplay("Of course. Has my compass ever led you astray?"),
71+
accessory=disnake.ui.Thumbnail(
72+
"https://limbuscompany.wiki.gg/images/The_Pequod_Captain_Ishmael_Evade_Sprite.png"
73+
),
74+
)
75+
76+
if page_number == 2:
77+
return disnake.ui.Section(
78+
disnake.ui.TextDisplay("I'm Fishmael"),
79+
accessory=disnake.ui.Thumbnail(
80+
"https://media.tenor.com/mINH1nt-0zgAAAAd/fishmael-limbus-company.gif"
81+
),
82+
)
83+
84+
msg = f"Unknown page number: {page_number}"
85+
raise ValueError(msg)
86+
87+
88+
async def create_paginator_controls(page_number: int, pages: int):
89+
return disnake.ui.ActionRow(
90+
await PageAdvanceButton(label="<", increment=-1).as_ui_component(),
91+
await PageTrackerButton(page=page_number, pages=pages).as_ui_component(),
92+
await PageAdvanceButton(label=">", increment=1).as_ui_component(),
93+
)
94+
95+
96+
async def create_paginator(page_number: int, pages: int):
97+
assert page_number < pages
98+
99+
return disnake.ui.Container(
100+
disnake.ui.TextDisplay("## Compass"),
101+
disnake.ui.Separator(),
102+
create_paginator_content(page_number),
103+
disnake.ui.Separator(),
104+
await create_paginator_controls(page_number, pages),
105+
)
106+
107+
108+
@bot.slash_command()
109+
async def test_v2(inter: disnake.CommandInteraction[disnake.Client]):
110+
await inter.response.send_message(components=[await create_paginator(0, 3)])
111+
112+
113+
bot.run(os.getenv("EXAMPLE_TOKEN"))

src/disnake_compass/api/component.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ class RichButton(RichComponent, typing.Protocol):
115115
A disabled button is greyed out on discord, and cannot be pressed.
116116
Disabled buttons can therefore not cause any interactions, either.
117117
"""
118+
id: int
118119

119120
async def as_ui_component( # noqa: D102
120121
self, manager: ComponentManager | None = None, /
@@ -151,6 +152,7 @@ class RichSelect(RichComponent, typing.Protocol):
151152
A disabled select is greyed out on discord, and cannot be used.
152153
Disabled selects can therefore not cause any interactions, either.
153154
"""
155+
id: int
154156

155157
async def as_ui_component( # noqa: D102
156158
self, manager: ComponentManager | None = None, /
@@ -241,6 +243,17 @@ def get_identifier(self, custom_id: str, /) -> tuple[str, typing.Sequence[str]]:
241243
"""
242244
...
243245

246+
def lookup_identifier(self, component_type: type[RichComponent], /) -> str:
247+
"""Look up the identifier of an already registered component type.
248+
249+
Parameters
250+
----------
251+
component_type
252+
The component type for which to look up the registered identifier.
253+
254+
"""
255+
...
256+
244257
async def make_custom_id(self, component: RichComponent, /) -> str:
245258
"""Make a custom id from the provided component.
246259
@@ -278,7 +291,7 @@ def register_component(
278291
The component class to register.
279292
identifier
280293
The identifier with which to register this component class.
281-
294+
282295
Returns
283296
-------
284297
:class:`type`\[:data:`.ComponentT`]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Re-exports of some disnake internal types related to components."""
2+
3+
import typing
4+
5+
import disnake
6+
7+
# NOTE: Keep up to date with disnake types...
8+
9+
ActionRowMessageComponent = disnake.ui.Button[typing.Any] | disnake.ui.Select[typing.Any]
10+
MessageTopLevelComponentV2 = (
11+
disnake.ui.Section
12+
| disnake.ui.TextDisplay
13+
| disnake.ui.MediaGallery
14+
| disnake.ui.File
15+
| disnake.ui.Separator
16+
| disnake.ui.Container
17+
)
18+
ModalTopLevelComponent_ = disnake.ui.TextDisplay | disnake.ui.Label
19+
ActionRowChildT = typing.TypeVar("ActionRowChildT", bound=disnake.ui.WrappedComponent)
20+
NonActionRowChildT = typing.TypeVar(
21+
"NonActionRowChildT",
22+
bound=MessageTopLevelComponentV2 | ModalTopLevelComponent_,
23+
)
24+
AnyUIComponentInput = ActionRowChildT | disnake.ui.ActionRow[ActionRowChildT] | NonActionRowChildT
25+
ComponentInput = (
26+
AnyUIComponentInput[ActionRowChildT, NonActionRowChildT]
27+
| typing.Sequence[
28+
AnyUIComponentInput[ActionRowChildT, NonActionRowChildT] | typing.Sequence[ActionRowChildT]
29+
]
30+
)
31+
MessageComponents = ComponentInput[ActionRowMessageComponent, MessageTopLevelComponentV2]

src/disnake_compass/impl/component/button.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class RichButton(
5252
style: disnake.ButtonStyle = fields.internal(default=disnake.ButtonStyle.secondary)
5353
emoji: _AnyEmoji | None = fields.internal(default=None)
5454
disabled: bool = fields.internal(default=False)
55+
id: int = fields.internal(default=0)
5556

5657
async def as_ui_component( # noqa: D102
5758
self, manager: component_api.ComponentManager | None = None, /
@@ -64,4 +65,5 @@ async def as_ui_component( # noqa: D102
6465
disabled=self.disabled,
6566
emoji=self.emoji,
6667
custom_id=await self.make_custom_id(manager),
68+
id=self.id,
6769
)

src/disnake_compass/impl/component/select.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class BaseSelect(
3838
min_values: int = fields.internal(default=1)
3939
max_values: int = fields.internal(default=1)
4040
disabled: bool = fields.internal(default=False)
41+
id: int = fields.internal(default=0)
4142

4243

4344
class RichStringSelect(BaseSelect, typing.Protocol):
@@ -81,6 +82,7 @@ async def as_ui_component( # noqa: D102
8182
disabled=self.disabled,
8283
options=self.options,
8384
custom_id=await self.make_custom_id(manager),
85+
id=self.id,
8486
)
8587

8688

@@ -116,6 +118,7 @@ async def as_ui_component( # noqa: D102
116118
max_values=self.max_values,
117119
disabled=self.disabled,
118120
custom_id=await self.make_custom_id(manager),
121+
id=self.id,
119122
)
120123

121124

@@ -151,6 +154,7 @@ async def as_ui_component( # noqa: D102
151154
max_values=self.max_values,
152155
disabled=self.disabled,
153156
custom_id=await self.make_custom_id(manager),
157+
id=self.id,
154158
)
155159

156160

@@ -186,6 +190,7 @@ async def as_ui_component( # noqa: D102
186190
max_values=self.max_values,
187191
disabled=self.disabled,
188192
custom_id=await self.make_custom_id(manager),
193+
id=self.id,
189194
)
190195

191196

@@ -230,4 +235,5 @@ async def as_ui_component( # noqa: D102
230235
max_values=self.max_values,
231236
disabled=self.disabled,
232237
custom_id=await self.make_custom_id(manager),
238+
id=self.id,
233239
)

0 commit comments

Comments
 (0)