API: Views¶
All view classes share a common mixin (_StatefulMixin) that provides state management, navigation, instance limiting, undo/redo, and lifecycle handling. The mixin is combined with either discord.ui.View (V1) or discord.ui.LayoutView (V2).
Shared Constructor Parameters¶
These parameters apply to all view classes:
context=None, # commands.Context -- extracts user/guild/interaction
interaction=None, # discord.Interaction -- alternative to context
timeout=180, # Seconds before timeout (None = no timeout)
persistence_key=None, # Stable identity for persistent data
theme=None, # Per-view Theme override
Pass either context or interaction -- both extract the user, guild, and interaction for send(). Use context from prefix/hybrid commands, interaction from app commands or component callbacks.
Shared Methods¶
These methods are available on all view classes (V1 and V2):
send(...)¶
Sends the view as a message. V1 accepts content, embed, embeds, file, files, ephemeral. V2 accepts file, files, ephemeral (V2 sends the view as its own content, so no content/embed params). The file / files pair mirrors discord.py's Messageable.send signature and pairs with the V2 media builders (gallery, image_section, file_attachment) for attachment:// references. See Local file attachments.
Return value: the sent discord.Message on success, or None when the view was blocked before reaching Discord. Two conditions produce None:
- Instance limit rejection --
instance_policy = "reject"and the user has hitinstance_limit. Theon_instance_limithook fires and handles the response automatically. - Participant registration failure --
auto_register_participants = Trueand a user inallowed_usersalready occupies an instance of this view type. Rollback removes all side effects (registry, state tree, participants).
In both cases, the view is fully cleaned up -- no message was sent, no state remains. See send() and Rollback for usage patterns.
dispatch(action_type, payload=None)¶
Dispatches an action through the store with source=self.id. Subscriber failures are caught and logged internally -- dispatch() does not raise from subscriber errors.
refresh(**kwargs)¶
Edits the view's message with view=self plus any extra kwargs forwarded to message.edit(). Does NOT rebuild components -- call your rebuild method (e.g. build_ui()) first. Handles discord.NotFound silently if the message has been deleted. V2 callers pass no args; V1 callers pass embed= or content=.
respond(interaction, content=None, *, ephemeral=False, **kwargs)¶
Sends an interaction response, falling back to interaction.followup.send() when the response slot is already consumed. Under serialize_interactions, queued interactions may be auto-deferred before their callback runs; direct calls to interaction.response.send_message() raise InteractionResponded in that case. This method checks interaction.response.is_done() and routes transparently.
# Always works, no manual is_done() check needed
await self.respond(interaction, "Not your turn!", ephemeral=True)
# Works with embeds, views, files -- any send_message kwarg
await self.respond(interaction, embed=my_embed, ephemeral=True)
Use self.respond() instead of interaction.response.send_message() in any CascadeUI callback that needs to send feedback to the user.
open_modal(interaction, modal, *, fallback_message=None)¶
Opens a modal dialog, with a graceful fallback if the response slot is already consumed. send_modal() must be the first response to an interaction -- it cannot follow a defer(). Under serialize_interactions, a queued interaction may be auto-deferred before the callback runs. This method checks is_done() and sends an ephemeral fallback instead of raising InteractionResponded.
Returns True if the modal was sent, False if the fallback fired.
await self.open_modal(interaction, modal)
# Custom fallback text
await self.open_modal(interaction, modal, fallback_message="Try again.")
Use self.open_modal() instead of interaction.response.send_modal() in any CascadeUI callback that needs to open a modal.
attach_child(child_view)¶
Registers a child view for automatic cleanup. When the parent exits or times out, all attached children that haven't finished are exited with delete_message=True. Enforces three invariants: self-attachment raises ValueError, circular chains raise ValueError, and re-parenting detaches from the old parent cleanly. The parent= kwarg on the child's constructor automates this -- send() calls attach_child on success. See Child Attachment.
on_message_delete() (async, override)¶
Called when the view's Discord message is deleted externally (admin delete, bulk purge, channel delete). Default calls exit(delete_message=False). Override for custom behavior (logging, re-sending). If overriding without calling exit(), the view remains as a ghost in the state store.
on_replaced() (async, override)¶
Called on the old view when instance_policy = "replace" is about to evict it. Fires before exit() while the view is fully intact (message, participants, channel). Default sends replaced_message to the channel when set and the view has participants. Override for custom notification (DMs, embeds, mentions). Errors are logged but never block the new view's send().
check_instance_available(*, user_id=None, guild_id=None, session_origin=None, state_store=None) (classmethod)¶
Sync pre-check that returns True if a new instance slot is available, False if the limit would be exceeded. Counts both owners and participants. Avoids constructing the view when __init__ is expensive. Returns True when no instance_limit is set or when scope can't be determined (missing user_id/guild_id).
auto_refresh_ephemeral (class attribute)¶
Engages the 15-minute ephemeral refresh handoff. Default None derives from timeout: ephemeral views with timeout > 900 (or timeout=None) engage the handoff; shorter timeouts decline it. Set True to pin on, False to pin off.
Customization knobs (all class attributes):
| Attribute | Default | Purpose |
|---|---|---|
refresh_warning_seconds |
90 |
How early to swap before the 900s wall |
refresh_button_label |
"Continue Session" |
Button label text |
refresh_button_emoji |
"🔄" |
Button emoji (must be a valid Discord button emoji) |
refresh_button_style |
ButtonStyle.primary |
Button style |
Emoji must be a valid Discord button emoji
Discord rejects Unicode symbols (like ↻ U+21BB from the Arrows block) as invalid button emoji even though they render as glyphs in some fonts. Valid values are Unicode emoji code points (typically U+1F000+) or custom Discord emoji. If the library sees Discord return error 50035 for the emoji at arming time, the library retries once without the emoji and logs a warning -- the handoff still works, but the button loses its icon.
See Auto-Refresh for Long-Lived Ephemerals in the guide for the full rationale, advanced customization, and ghost-panel behavior. See Ephemeral Editability Expires After 15 Minutes for the platform constraint.
replace(view_or_class, interaction=None, **kwargs)¶
Replaces the current view with another view. One-way (no stack history saved). view_or_class accepts either a view class (constructed internally with **kwargs) or a pre-constructed view instance (used directly; **kwargs must be empty).
push(view_or_class, interaction, *, rebuild=None, **kwargs)¶
Pushes the current view onto the navigation stack and navigates to the next view. view_or_class accepts either a view class (constructed internally with **kwargs; constructor kwargs auto-captured so pop() can reconstruct faithfully) or a pre-constructed view instance (used directly; **kwargs must be empty). The instance form pairs with async classmethod constructors like PaginatedLayoutView.from_data and from_cursor, where the view is built before the navigation call.
Passing extra kwargs alongside an instance raises TypeError -- the instance is already initialized.
The Discord message edit fires on every push regardless of whether rebuild is supplied. rebuild is an optional pre-edit hook for views that need post-construction setup: V2 views with empty trees can run v.build_ui(), V1 views can return a dict of edit kwargs (e.g., rebuild=lambda v: {"embed": v.build_embed()}). Views built by async classmethods like from_data come fully populated and need no rebuild. Sync or async callables both work.
pop(interaction, *, rebuild=None)¶
Pops the top entry from the navigation stack, reconstructs that view with its original kwargs, and returns it. Returns None if the stack is empty. Non-reconstructible kwargs (context, interaction, etc.) are re-supplied by the framework.
batch()¶
Returns an async context manager for batched dispatch. Convenience for self.state_store.batch().
undo()¶
Undoes the last state change for this view (requires enable_undo = True and UndoMiddleware).
redo()¶
Redoes the last undone state change.
dispatch_scoped(data)¶
Updates scoped state (requires state_scope to be set on the view class).
Named Scoped-State Accessors¶
Four convenience methods for reading scoped state without raw dict-chain traversal. Each defaults to the view's own user_id/guild_id and accepts explicit overrides for hub views reading other users'/guilds' slices:
user_scoped_state(user_id=None) -> dict-- reads the"user"scope sliceguild_scoped_state(guild_id=None) -> dict-- reads the"guild"scope sliceuser_guild_scoped_state(user_id=None, guild_id=None) -> dict-- reads the"user_guild"composite scope sliceglobal_scoped_state() -> dict-- reads the"global"scope slice (single shared slot)
All return {} when identifiers are missing, matching scoped_state semantics.
Session Data¶
shared_data(property, dict) -- reads the current session'sshared_datadict. Returns{}if the session does not exist or has no data. Shared across all views in the same push/pop chain.update_session(**data)-- merges key-value pairs into the session'sshared_datadict. DispatchesSESSION_UPDATED.
# Read
lang = self.shared_data.get("lang", "en")
# Write
await self.update_session(lang="fr", difficulty="hard")
set_class_attribute(name, value)¶
Overrides a class-level policy attribute (participant_limit, instance_limit, instance_policy, etc.) with a per-invocation value while running the same __init_subclass__ validator pipeline. Resolves the grammar tension where views need to parameterize a policy from a slash-command argument without bypassing validation.
make_exit_button(label="Exit", style=ButtonStyle.secondary, emoji="❌", delete_message=False, custom_id=None)¶
Returns a pre-configured StatefulButton without adding it to the view. Use in V2 views that need to place exit buttons inside specific ActionRow or Container subtrees rather than at the top level. add_exit_button() continues to work for top-level placement.
add_exit_button(label="Exit", style=ButtonStyle.secondary, row=None, emoji="❌", delete_message=False, custom_id=None)¶
Adds an exit button that calls self.exit(). In V2 views, the button is wrapped in an ActionRow. Set delete_message=True to delete the message instead of disabling components. Pass custom_id for persistent views.
await exit(delete_message=None)¶
Cleans up the view: cancels tasks, unsubscribes, disables components. When delete_message is None (the default), behavior is resolved from the exit_policy class attribute ("disable" freezes, "delete" deletes). Pass True or False explicitly to override the policy at any call site. V2 views freeze components in place (since edit(view=None) would empty the message); V1 views strip the view entirely.
get_theme()¶
Returns the view's theme (per-view override or global default).
await seed_initial_state(state) (override)¶
Initializes per-view state slots before the first subscriber notification. Called once during send(), inside the registration batch, after the view is registered but before participant claiming and before the batch's BATCH_COMPLETE fires. Override to dispatch actions or write to state["application"] so subscribers see the seeded state from frame one instead of an empty slot followed by a separate seeding dispatch.
async def seed_initial_state(self, state):
if "leaderboard" not in state["application"]:
await self.dispatch("LEADERBOARD_SEED", {"entries": []})
The hook receives the live store state dict. Dispatches issued from inside join the surrounding batch, so seed work collapses into the view's VIEW_CREATED notification cycle. Default is a no-op.
on_state_changed(state) (override)¶
Called when a matching state change occurs. The default implementation looks up build_ui() on the subclass and, if present, calls it followed by refresh(). Both sync and async build_ui() are supported.
If build_ui() returns a dict, the dict is splatted as keyword arguments into refresh(). This is the V1 idiom for re-rendering an embed:
V2 views return None (the default) and mutate the component tree directly inside build_ui(). Override on_state_changed() itself only when you need behavior beyond rebuild + refresh.
Concurrent calls are coalesced automatically - if a second state change arrives while the first is being processed, the update re-runs once with the latest state after completing. See Concurrent Updates.
state_selector(state) (override)¶
Returns a slice of state. If the return value hasn't changed, on_state_changed won't fire.
await register_participant(user_id, *, interaction=None) -> bool¶
Registers a non-owner user in the instance index so that instance_limit and participant_limit apply to them. Returns True on success (including the owner short-circuit), False on rejection. Never raises.
user_id accepts either an int or any object with an int .id attribute (discord.Member, discord.User, discord.Object) -- coercion happens silently at the entry point.
Two rejection paths fire automatically:
- Per-user instance collision (the joiner already holds an instance of this view type): the library calls
self.on_instance_limit(error)with the joiner's interaction temporarily swapped in, so the rejection ephemeral targets the joiner -- not the view owner. - View capacity overflow (the view is at
participant_limit): the library callsself.on_participant_limit(user_id, interaction=interaction).
Pass the interaction keyword when the registration is driven by a button or select callback so the rejection hooks can respond ephemerally on the right interaction. Skips silently when user_id matches the view owner.
unregister_participant(user_id)¶
Removes a participant from the session index. Use when a participant leaves a multi-user view (e.g., a player disconnects mid-game).
interaction_check(interaction) (override)¶
Called before every component callback. Returns True to allow, False to block. By default, checks allowed_users first (if set), then falls back to owner_only.
Shared Properties¶
id(str): UUID instance identifierpersistence_key(str | None): Stable data identity keymessage(Message | None): The sent message, if anystate_store(StateStore): The singleton storesession_id(str | None): Session identity for this view. Auto-derived at__init__as<module.QualName>:user_<id>:<8hex>unlesssession_continuity = Trueis set on the class (which drops the:<8hex>suffix) or an explicitsession_id=kwarg is passed.scoped_state(dict): The scoped state for this view's user/guild (empty dict if no state_scope)shared_data(dict): The current session'sshared_datadict (empty dict if no session or no data)
Shared Class Attributes¶
Validated at subclass-definition time
Class attributes whose values are bounded -- string enums (instance_policy, instance_scope, state_scope, replace_policy, exit_policy), positive integers (instance_limit, participant_limit, undo_limit), positive floats (auto_defer_delay), and booleans (owner_only, auto_defer, auto_register_participants, etc.) -- are validated by _StatefulMixin.__init_subclass__ when a subclass is defined. A typo like instance_policy = "rejct" raises ValueError at module import with a message naming the class, the attribute, the bad value, and the valid options. Validation runs once per subclass at class-definition time and inspects only cls.__dict__, so per-subclass cost is O(overrides-on-this-subclass) -- inherited defaults pay zero cost. There is no per-instantiation overhead.
subscribed_actions(set[str] | None): Action types to listen for. Default is an empty set (no notifications). Set the actions your view needs to react to. Set toNoneto receive all actions (not recommended). Every matching dispatch fires the view'son_state_changed(), so subscribe only to actions the view reads.state_scope(str | None):"user","guild","user_guild","global", orNone. Determines state scoping.enable_undo(bool): Enable undo/redo for this view (default:False).undo_limit(int): Max undo stack depth (default:20).auto_back_button(bool): Automatically add a back button when pushed (default:False).instance_limit(int | None): Maximum active instances within the instance scope.None(default) means unlimited.instance_scope(str): How instances are grouped for limit counting. One of"user","guild","user_guild"(default), or"global".instance_policy(str): What to do when the limit is exceeded."replace"(default) exits the oldest instances."reject"blockssend()--on_instance_limitfires andsend()returnsNone.owner_only(bool): Only the creating user can interact with the view (default:True). Set toFalsefor shared views.unauthorized_message(str): Ephemeral message sent to non-owners (default:"You cannot interact with this.").error_message(str): Description used in the defaulton_errorred embed (default:"An unexpected error occurred while processing your interaction.").reopen_failure_message(str): Ephemeral message sent when the ephemeral refresh button fails to reconstruct the view (default:"Could not refresh this view. Please reopen from the original command."). Used by the defaulton_reopen_failurehook. Only relevant for ephemeral views where the auto-refresh handoff is engaged (eitherauto_refresh_ephemeral = Trueor derived from a timeout greater than900).allowed_users(frozenset[int]): When non-empty, only these user IDs can interact. Overridesowner_onlycompletely. Empty (default) defers toowner_only. Stored as afrozensetand exposed via a property pair: assignment coerces bothintand snowflake-shaped objects (Member,User,Object) at the setter, soview.allowed_users = {member, 12345}works. Direct mutation is unsupported -- to add a user after construction, useawait view.register_participant(user_id)(which writes to_participants, notallowed_users) or rebind the attribute:view.allowed_users = view.allowed_users | {new_id}.participant_limit(int | None): Maximum total view occupants (owner + participants).None(default) means unlimited. Owner counts toward the cap, soparticipant_limit = 8admits one host plus seven joiners. Enforced insideregister_participant.participant_limit_message(str): Ephemeral message sent whenregister_participantrejects a joiner due to view-capacity overflow (default:"This session is full."). Used by the defaulton_participant_limithook.auto_register_participants(bool): WhenTrue,send()iteratesallowed_usersand callsregister_participantfor each non-owner before the Discord send. All-or-nothing rollback: any rejection unregisters every previously-claimed slot ANDstate_store.unregister_view(self.id), thensend()returnsNone. A rejection therefore leaves zero side effects -- no message, no registry entry, no half-claimed participants. Default:False.protect_attached(bool): WhenTrue(default), views with active participants or attached children from other users are excluded from replacement candidates during instance enforcement. If no replaceable views remain, falls back to reject behavior (on_instance_limitfires). Same-user attachments do not trigger protection. Has no effect on views without attachments or wheninstance_policy = "reject". Set toFalsefor views where silent replacement is expected (e.g. spectator panels).replaced_message(str | None): Static message sent to the channel when this view is replaced and has active participants.None(default) means silent replacement. Used by the defaulton_replacedhook.replace_policy(str): Whatinstance_policy="replace"does to the old view's message."delete"(default) removes it;"disable"freezes its components in place. Only governs the instance-replace transition.exit_policy(str): What bareexit()calls do when nodelete_messageargument is supplied."disable"(default) freezes the components in place;"delete"removes the message. Always overridden by an explicitdelete_message=argument or by anexit()method override. Independent ofreplace_policy.auto_defer(bool): Enable the auto-defer safety net (default:True).auto_defer_delay(float): Seconds before auto-deferring (default:2.5).serialize_interactions(bool): Serialize rapid button clicks with anasyncio.Lock(default:True). Set toFalsefor views that handle parallel callbacks.session_continuity(bool): Governssession_idauto-derivation polarity. DefaultFalsegives every invocation a per-instance UUID suffix, so repeat opens of the same view class are independent sessions with their own nav stack, undo timeline, andshared_data. Set toTrueon views that want repeat-open state coalescing (undo history surviving close-and-reopen,shared_datacontinuity across gestures); the opt-in collapses derivation back to the class-coalesced shape. Push/pop chains stay on one session regardless because_navigate_toforwardssession_idexplicitly.
V2 Views¶
StatefulLayoutView¶
Base class for V2 views. Extends discord.ui.LayoutView.
V2 views ARE the message content -- send() takes no content or embed params. Build the component tree in __init__ or an async builder, then call send().
V2-Specific Class Attributes¶
validate_placement(bool): Run the V2 placement validator before every Discord round-trip. WhenTrue(default), the assembled component tree is walked at three seams -- the initialsend(), every state-drivenrefresh()after the render-hash short-circuit, and the in-place edits frompush()/pop()navigation -- and any composition Discord rejects with HTTP 400 raisesValueErrorwith a path string identifying the violation node and a suggested fix. Type rejections cover Container nesting, Section nesting, Section accessory not in{Button, Thumbnail}, standaloneButton/Select/Thumbnailat LayoutView or Container level, Modal-only types (Label,RadioGroup,CheckboxGroup,Checkbox,FileUpload) anywhere in the tree, and ActionRow children outside the Button/Select union. Size rejections cover empty Containers, empty Sections, empty ActionRows, and MediaGallery items outside the 1-10 range. Set toFalseonly when the validator's matrix lags a discord.py or Discord update; opting out otherwise signals an actual placement bug, prefer fixing the tree. See V2 Placement Rules for the full matrix and the builders-as-guardrails framing.
V2-Specific Methods¶
clear_row(row)¶
No-op on V2 views. V2 uses a tree structure rather than rows.
TabLayoutView¶
Tab-based navigation using button switching.
Each tab builder is an async function that returns a list of V2 components. The first tab is displayed on send.
Class Attributes¶
| Attribute | Default | Purpose |
|---|---|---|
active_tab_style |
ButtonStyle.primary |
Style for the currently active tab button |
inactive_tab_style |
ButtonStyle.secondary |
Style for inactive tab buttons |
tab_overflow_policy |
"fill" |
Row-distribution strategy when tabs exceed the five-per-row ActionRow cap. Presets: "fill", "balance", "pin_first", "pin_last". Or tuple[int, ...] for explicit per-row widths. |
Methods¶
await _refresh_tabs()¶
Re-runs the current tab's builder and edits the message. Use from Refresh button callbacks.
on_tab_switched(self, index) (override)¶
Called after a tab switch completes. Override to inject analytics, async setup, or validation logic without reimplementing the tab-switch closure.
WizardLayoutView¶
Multi-step wizard with back/next navigation and per-step validation.
WizardLayoutView(
context=None,
steps=[
{"name": str, "builder": async_fn, "validator": async_fn},
...
],
**kwargs,
)
builder(self)-- async, returns a list of V2 components for the stepvalidator(self, interaction)-- async, returnsTrueto proceed orFalseto block
on_finish(self, interaction) (override)¶
Called when the final step passes validation. Default implementation calls self.exit(). Override to customize the finish behavior (e.g. build a summary card, save data, navigate).
Navigation Button Customization¶
Back, Next, and Finish buttons are added automatically. Back is disabled on the first step. Next is replaced with Finish on the last step. All three support class-attribute customization:
| Attribute | Default | Purpose |
|---|---|---|
back_button_label |
"Back" |
Back button label |
back_button_emoji |
None |
Back button emoji |
back_button_style |
ButtonStyle.secondary |
Back button style |
next_button_label |
"Next" |
Next button label |
next_button_emoji |
None |
Next button emoji |
next_button_style |
ButtonStyle.primary |
Next button style |
finish_button_label |
"Finish" |
Finish button label |
finish_button_emoji |
None |
Finish button emoji |
finish_button_style |
ButtonStyle.success |
Finish button style |
step_indicator_label |
None |
Callable (current, total) -> str. Default: "Step {current}/{total}" |
FormLayoutView¶
V2 form with native text, select, and boolean fields.
FormLayoutView(
context=None,
title="Form",
fields=[
{"id": str, "type": "text"|"select"|"boolean", "label": str,
"validators": [...], "placeholder": str, "default": Any, "required": bool},
...
],
**kwargs,
)
Displays form state as a V2 component tree (Container + TextDisplay). text fields are grouped behind a single "Edit Text Fields" button that opens a Modal populated with one TextInput per declared text field (Discord caps this at 5 text fields per form; construction raises ValueError above the limit). Submitted values flow back into form.values and the view rebuilds. select and boolean fields render inline as interactive components.
Validators declared in the field dict attach directly to the generated TextInput and run server-side after submission.
Text-Edit Button Customization¶
Three class attributes mirror the refresh_button_* grammar:
| Attribute | Default | Purpose |
|---|---|---|
text_edit_button_label |
None |
None → smart default: "Edit {label}" for one text field, "Edit Text Fields" for multiple. |
text_edit_button_emoji |
"\u270f\ufe0f" (✏️) |
Emoji on the grouped button. Set None to disable. |
text_edit_button_style |
ButtonStyle.secondary |
Button style. |
FormView (V1) exposes the same three attributes and 5-field ceiling.
PaginatedLayoutView¶
V2 paginated view with component-tree pages.
Each page is a list of V2 components. Navigation buttons (Previous, Next, First, Last, Go-to-page) work identically to V1's PaginatedView.
Class Methods¶
await PaginatedLayoutView.from_data(items, per_page, formatter, **kwargs)¶
Creates a paginated view by chunking items and applying formatter to each chunk. The formatter should return a list of V2 components.
Instance Methods¶
await refresh_data(items)¶
Re-paginates with new data using the original per_page and formatter.
_build_extra_items() (override)¶
Hook for adding components after the navigation row.
on_page_changed(self, page) (override)¶
Called after a page change completes. Override to react to page changes without reimplementing the navigation wiring.
Navigation Button Customization¶
All five navigation buttons (first, previous, indicator, next, last) support label/emoji/style class-attribute overrides. See PaginatedView below for the shared attribute names.
LeaderboardLayoutView / PersistentLeaderboardLayoutView¶
V2-only paginated ranked-display pattern. Subclass of PaginatedLayoutView. Renders a sorted list of (user_id, stats_dict) entries across one or more pages. Each page is a card with ranked entry lines; the summary header appears on page 1 only by default.
class ServerLeaderboard(LeaderboardLayoutView):
leaderboard_top_n = 25
leaderboard_per_page = 10
def format_stats(self, user_id, stats):
return f"{stats['wins']}W / {stats['games']}G"
view = ServerLeaderboard(context=ctx, entries=entries, title="Server Rankings")
await view.send()
Class Attributes¶
| Attribute | Default | Purpose |
|---|---|---|
leaderboard_top_n |
10 |
Total entries to consider from the data source. |
leaderboard_per_page |
5 |
Entries per page. None collapses into a single page equal to top_n. |
title |
"Leaderboard" |
H2 on the rankings card. Constructor title= kwarg overrides. |
subtitle |
"Rankings" |
H3 above the ranked rows. Set to None or "" to skip. |
leaderboard_empty_message |
"No entries recorded yet." |
Static text when no entries exist. |
entry_layout |
"lines" |
"lines" packs entries into a single TextDisplay; "sections" renders each entry as a Section with optional avatar Thumbnail. Section mode caps leaderboard_per_page at 5. |
podium_emojis |
{1: "🥇", 2: "🥈", 3: "🥉"} |
Rank-keyed glyphs for format_rank. Override the dict to change the podium treatment without overriding format_rank. |
entry_separator |
" -- " |
Separator between name and stat columns inside format_entry (lines mode). |
card_color |
None |
Optional discord.Color for the rankings card accent. None falls through to the active theme. |
show_title_divider |
True |
Toggle the divider rendered below the title. |
Override Hooks¶
| Hook | Purpose |
|---|---|
get_entries() |
Data source. Default returns the constructor entries= kwarg. |
format_rank(rank) |
Rank column. Default reads podium_emojis for ranks 1-3, falls back to f"**{rank}.**". |
format_name(user_id, stats) |
Name column. Default mentions the user (<@user_id>) or returns stats['display_name'] when present. |
format_stats(user_id, stats) |
Inline stat column. Default f"{wins}W / {games}G". |
format_accessory(user_id, stats) |
Optional right-side accessory. Default None. |
format_entry(rank, user_id, stats) |
Composes the four column hooks. Override only when row layout itself needs to change. |
format_primary / format_secondary |
Section-mode two-line body. |
get_avatar_url(user_id, stats) (async) |
Section-mode Thumbnail URL. |
build_summary(entries) |
Returns dict[str, str] (rendered inline page 1), a Container (standalone card on every page), or None (no summary). |
on_leaderboard_empty() |
Returns the V2 component list shown when entries is empty. |
on_state_changed(state) (async, override) |
Calls rebuild_pages() then the paginated refresh; live-data subclasses subscribe to data actions and override get_entries(). |
Persistent Variant¶
PersistentLeaderboardLayoutView composes _PersistentMixin with LeaderboardLayoutView for admin-posted permanent panels. Defaults: owner_only = False, exit_policy = "disable", timeout = None. Requires persistence_key= at construction. on_restore calls rebuild_pages() to refresh from live data after a bot restart.
See docs/guide/patterns.md for cardinality model, customization tiers, and section-mode rendering details.
MenuLayoutView¶
V2 category-based navigation hub with push/pop drill-down.
MenuLayoutView(
context=None,
categories=[
{"label": str, "view": ViewClass, "emoji": str,
"description": str, "style": ButtonStyle, "rebuild": callable},
...
],
**kwargs,
)
Each category generates an action_section() item that pushes to the specified view class when clicked. The description, emoji, style, and rebuild keys are optional.
Class Attributes¶
| Attribute | Default | Purpose |
|---|---|---|
menu_style |
ButtonStyle.primary |
Default button style for all category items |
auto_exit_button |
True |
Whether to add an exit button at the bottom |
Override Hooks¶
_build_header() (override)¶
Returns V2 components (list or single) for the area above category items. Default returns [].
_build_footer() (override)¶
Returns V2 components (list or single) for the area below category items. Default returns [].
_build_category_item(category, index) (override)¶
Controls how a single category is rendered. Default creates an action_section().
on_category_selected(category, index, interaction) (override)¶
Called before pushing to the selected category's view. Default is a no-op. Override for analytics, guards, or pre-push setup.
Properties¶
categories(list[dict]): The category list this menu was constructed with.
RolesLayoutView / PersistentRolesLayoutView¶
V2-only role self-assign panel pattern. Each category renders as a Container with toggle buttons; cardinality (at-most-one / at-least-one) is enforced inside the pattern without per-role callback boilerplate. Role buttons are DynamicPersistentButton subclasses declared once at module import -- clicks route by custom_id template match regardless of view lifecycle.
class MyRoles(PersistentRolesLayoutView):
categories = [
RoleCategory(
name="Colors",
roles={"Red": 111, "Blue": 222, "Green": 333},
exclusive=True,
color=discord.Color.red(),
),
]
title = "Server Roles"
view = MyRoles(context=ctx, persistence_key=f"roles:{ctx.guild.id}")
await view.send()
RoleCategory (typed schema)¶
RoleCategory(
name: str,
roles: dict[str, int], # label -> role_id
exclusive: bool = False, # at most one active
required: bool = False, # at least one active
color: Optional[discord.Color] = None, # container accent
button_style: Optional[ButtonStyle] = None, # default secondary
icon: Optional[str] = None, # title prefix emoji
description: Optional[str] = None, # text between heading and buttons
)
Class Attributes¶
| Attribute | Default | Purpose |
|---|---|---|
title |
"Server Roles" |
H2 above all categories. None suppresses. |
subtitle |
None |
Optional H3 below title. Set to a string to render. |
hint_normal |
None |
Hint for free-multi-select categories. |
hint_exclusive |
"◉" |
Hint for exclusive-only categories (U+25C9 fisheye, text-size filled circle). |
hint_required |
"*" |
Hint for required-only categories. |
hint_exclusive_required |
"◉ *" |
Hint for exclusive+required categories. Both indicators render at text-size for visual consistency. |
assigned_message |
"Gave you **{role}**." |
Response after role added (no swap). |
removed_message |
"Removed **{role}**." |
Response after role removed. |
required_message |
"You must keep at least one **{category}** role." |
Response when required-last removal rejected. |
swap_message |
"Switched to **{role}** (removed {removed})." |
Response after exclusive swap. |
role_error_message |
"Could not update roles: {error}" |
Response on role mutation failure. |
categories |
[] |
List of RoleCategory. Declared on the subclass. |
Override Hooks¶
Hooks on RolesLayoutView are @classmethod (not instance methods). The dispatch path routes through DynamicPersistentButton which has no view instance at click time; hook classmethods read class attributes and respond to the interaction directly. super() works normally.
| Hook | Purpose |
|---|---|
format_category_title(category) |
Heading line for the category. Default: f"### {category.name}" plus optional category.icon prefix. |
format_category_hint(category) |
Hint rendered below the heading. Default routes to hint_* attribute. Return None to skip. |
format_button_label(role_name, role_id, category) |
Button label. Default: role_name. |
format_button_emoji(role_name, role_id, category) |
Button emoji. Default: None. |
format_button_style(role_name, role_id, category) |
Button style. Default: category.button_style or ButtonStyle.secondary. |
build_category_card(category) |
Render one category as a Container. Default composes the smaller format_* hooks. |
on_role_assigned(interaction, member, role, category) |
Response after role added without swap. |
on_role_removed(interaction, member, role, category) |
Response after role removed. |
on_role_swap(interaction, member, role_added, roles_removed, category) |
Response after exclusive-mode swap. |
on_role_required_block(interaction, member, role, category) |
Response when required-category last-role removal rejected. |
on_role_error(interaction, error) |
Response on role mutation failure. |
Persistent Variant¶
PersistentRolesLayoutView composes _PersistentMixin with RolesLayoutView. Defaults: owner_only = False, exit_policy = "disable", timeout = None. Requires persistence_key= at construction. Role buttons survive restart independent of view re-attachment because each is a globally-registered DynamicPersistentButton subclass. The default on_restore re-renders the message from the current categories on every restart so source-code edits propagate to the displayed message; unchanged panels pay zero API cost via the render-hash short-circuit in refresh().
See docs/guide/patterns.md for detailed cardinality behavior, customization tiers, and examples.
PersistentLayoutView¶
V2 persistent view that survives bot restarts.
Same requirements and behavior as PersistentView -- persistence_key required, all interactive components need explicit custom_id, timeout forced to None, owner_only defaults to False. Auto-registers subclasses via __init_subclass__ into the same registry as PersistentView.
Methods¶
on_restore(bot) (override)¶
Called after the view is restored on bot restart.
V1 Views (Classic)¶
StatefulView¶
Base class for V1 views. Extends discord.ui.View.
V1-Specific Methods¶
send(content=None, *, embed=None, embeds=None, ephemeral=False)¶
Sends the view with optional content and embeds.
clear_row(row: int)¶
Removes all components on the given row number. Useful for dynamically rebuilding a specific section.
PersistentView¶
V1 persistent view that survives bot restarts.
timeoutis forced toNoneowner_onlydefaults toFalsepersistence_keymust be provided (raisesValueError)- All components must have explicit
custom_idvalues - Cannot be sent as ephemeral (
send(ephemeral=True)raisesValueError) - Duplicate
persistence_keyregistration exits the previous view instance
Methods¶
on_restore(bot) (override)¶
Called after the view is restored on bot restart.
V1 Patterns¶
MenuView¶
MenuView(
context=None,
categories=[
{"label": str, "view": ViewClass, "emoji": str,
"style": ButtonStyle, "rebuild": callable},
...
],
**kwargs,
)
V1 equivalent of MenuLayoutView. Each category generates a StatefulButton. Override build_embed() for the hub card. Override _build_extra_items() to add controls alongside category buttons. Override _build_category_button(category, index) to customize individual buttons.
Supports the same menu_style, auto_exit_button, and on_category_selected as MenuLayoutView.
TabView¶
Supports the same tab_overflow_policy, active_tab_style, inactive_tab_style, on_tab_switched, and _build_tab_rows as TabLayoutView. V1 applies the per-row split by assigning button.row; V2 wraps each row in an ActionRow.
WizardView¶
WizardView(
context=None,
steps=[{"name": str, "builder": async_fn, "validator": async_fn}, ...],
**kwargs,
)
Override async def on_finish(self, interaction) to customize finish behavior. Supports the same navigation button customization attributes as WizardLayoutView.
FormView¶
FormView(
context=None,
title="Form",
fields=[{"id": str, "type": "text"|"select"|"boolean", "label": str, "validators": [...], ...}, ...],
**kwargs,
)
PaginatedView¶
Pages can be Embed objects, strings, or dicts with "embed" and/or "content" keys.
Class Attributes:
jump_threshold(int): Minimum page count at which first/last and go-to-page buttons appear (default:5). A view with five or more pages surfaces the jump controls.
Class Methods:
await PaginatedView.from_data(items, per_page, formatter, **kwargs)-- Chunks items and applies formatter (returns embed/str/dict). Storesper_pageandformatterforrefresh_data().
Instance Methods:
await refresh_data(items)-- Re-paginates with new data. RaisesRuntimeErrorif not created viafrom_data()._build_extra_items()(override) -- Hook for adding components below navigation buttons (rows 1-4).
PersistenceMiddleware(manager=None, *, backend=None, registry=None, application=None, bot=None, migrators=None)¶
Write-through middleware that owns the full persistence pipeline. Install via setup_middleware once in setup_hook, after loading cogs.
- Without
bot: data-only persistence - With
bot: also re-attaches PersistentView and PersistentLayoutView instances, and installs the message-deletion cleanup listener backend: aPersistenceBackendinstance (e.g.SQLiteBackend,InMemoryBackend) used as the shorthand for any namespace not configured explicitlyregistry,application: per-namespace configs (RegistryPersistence,ApplicationPersistence) that override the shorthand. Scoped state rides under the application namespace -- opt a scoped slot in viapersistent_slots = ("scoped",)on the view class.
from cascadeui import setup_middleware
from cascadeui.state.middleware import PersistenceMiddleware
from cascadeui.persistence import SQLiteBackend
await setup_middleware(
PersistenceMiddleware(backend=SQLiteBackend("cascadeui.db"), bot=self),
)
The reattach summary ({"restored": [...], "skipped": [...], "failed": [...], "removed": [...]}) is available via await store.persistence_manager.reattach_persistent_views().
See docs/api/persistence.md for the full API reference.
InstanceLimitError¶
Exception raised when an instance limit is reached.
Attributes¶
view_type(str): The class name of the view that hit the limitlimit(int): The instance limit value that was exceededblocked_user_id(int | None): The user ID that was blocked. Set when raised byregister_participant(),Nonewhen raised bysend().
When it is raised¶
- Reject policy: Always raised when a new view would exceed
instance_limitwithinstance_policy = "reject". - PersistentView protection: Raised when a non-persistent view attempts to replace a
PersistentViewunder the replace policy. - Participant registration: Not raised --
register_participant()returnsboolinstead. Per-user instance collisions fireon_instance_limitand returnFalse.
Utility Decorators¶
Optional decorators for wrapping callbacks in error boundaries, retry logic, or safe execution. All are exported from the package root.
@with_error_boundary(name=None)¶
Wraps an async callable so exceptions are logged with context instead of raised. Returns None when the wrapped callable raises. Use on background or fire-and-forget paths where a raised exception would otherwise be swallowed by the asyncio event loop.
from cascadeui import with_error_boundary
@with_error_boundary("sync_scores")
async def sync_scores(user_id):
...
@with_retry(config=None)¶
Retries an async callable on failure with exponential backoff. Accepts an optional RetryConfig(max_attempts, base_delay, max_delay, exceptions); defaults to three attempts with a 1-second base delay.
from cascadeui import with_retry
from cascadeui.utils.errors import RetryConfig
@with_retry(RetryConfig(max_attempts=5, base_delay=2.0))
async def fetch_profile(user_id):
...
safe_execute(coro, default=None, name=None)¶
One-shot wrapper that awaits a coroutine and returns default on exception (with a logged traceback). Pair with the decorators when the call site is not the right place to attach an error boundary.
Task Manager¶
get_task_manager()¶
Returns the process-wide TaskManager singleton. The manager tracks background tasks per owner and cancels them cleanly on view exit or bot shutdown. Views own task creation and cancellation implicitly through _StatefulMixin -- direct use is only needed for standalone background work outside a view's lifecycle.
from cascadeui import get_task_manager
tm = get_task_manager()
tm.create_task("my_worker", poll_loop())
# Later:
await tm.cancel_tasks("my_worker")
See cascadeui/utils/tasks.py for the full TaskManager API.