Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Views

A View is a message or modal layout written as markup with rsx!, and optionally a function of Props with #[view] whose buttons, selects and modal submits are handled inline. Markup builds the same types a hand-written response would, so everything here can also be done with Component constructors; markup is shorter, checks placement at compile time, and keeps a component next to the code that answers it.

use composure::{app, command, rsx, view, command::View};

/// A card that counts its likes; the count is carried in the button's `custom_id`
#[view]
pub fn Card(title: String, likes: u32) -> View {
    rsx! {
        <Container>
            "## " {title.as_str()}
            <Text>"-# " {likes} " likes"</Text>
            <Row>
                <Button primary on:click:like=move |_| Card { likes: likes + 1, title }>
                    "Like"
                </Button>
            </Row>
        </Container>
    }
}

/// Show a card
#[command]
fn card() -> Card {
    Card { title: "Hello".into(), likes: 0 }
}

#[app(handlers(card, Card))]
pub struct Bot;
fn main() {}

/card answers with the card. Clicking Like runs the closure, which returns new Props; the message the button is on is re-rendered with them. Nothing is stored anywhere: title and likes travel in the button’s custom_id.

Markup

rsx! takes JSX-like markup and evaluates to a View (a Components V2 message) or, when its only root is <Modal>, a ModalView. Both implement IntoResponse, so a Command can return them directly.

use composure::{command, rsx, command::View};

/// Show a status panel
#[command]
fn status() -> View {
    let healthy = 3;
    let total = 4;
    rsx! {
        <Container accent_color=0x57F287>
            "## Status"
            <Separator/>
            "Healthy: **" {healthy} "/" {total} "**"
        </Container>
    }
}
fn main() {}

Tags

PascalCase tags are either built-in (Discord’s components, listed in the tag reference) or the name of a #[view] function placed as a sub-View. Unknown lowercase tags are a compile error. Tags are self-closing (<Separator/>) or have children (<Row>..</Row>).

Placement follows Discord’s rules and is checked at compile time:

WhereWhat can go there
Top of a messagetext, <Text>, <Container>, <Section>, <Gallery>, <File>, <Separator>, <Row>
<Container>the same, except another <Container>
<Section>text and <Text> (1-3), plus one accessory: a <Button> or <Thumbnail>
<Row>up to 5 <Button>s, or one select
<Gallery><Item>s
select, <RadioGroup>, <CheckboxGroup><Option>s
<Modal> (the only root)text, <Text>, <Label>
<Label>exactly one input: <TextInput>, a select, <RadioGroup>, <CheckboxGroup>, <Checkbox>, <FileUpload>

A sub-View’s markup may also be a single <Button> or select meant for a row.

Discord’s limits are checked, so breaking one is an error that names it instead of Discord’s bare “This interaction failed”. What the markup shows is checked at compile time: more than 5 buttons in a <Row> (or a select with anything beside it), more than 3 texts in a <Section>, more than 5 children in a <Modal>, more than 25 <Option>s in a select (10 in a group) or 10 <Item>s in a <Gallery>. The rest (40 components in a message, 4000 characters of text, custom_ids past 100 characters, button labels past 80, levels filled by {..}) is checked in one walk when the View is answered with or sent through REST, and fails as a ViewError (a HandlerError in handlers). The limits are types::limits.

Text

String literals are text. Adjacent literals and {expressions} join into one TextDisplay, so these are the same:

use composure::rsx;
let name = "Ada";
let a =
rsx! { "Hello, **" {name} "**!" }
;
let b =
rsx! { <Text>"Hello, **" {name} "**!"</Text> }
;
assert_eq!(format!("{:?}", a.components()), format!("{:?}", b.components()));

Text is Discord markdown: # headings, -# subtext, **bold**, lists, links, mentions. A <Text> (or a component) ends the current run of text; use separate <Text> tags for separate text displays, for instance the 1-3 of a <Section>.

Text cannot go in a <Row>. Bare words without quotes are a compile error (put text in quotes).

Expressions

{expr} evaluates Rust inside markup. Where components can go, the value is anything the parent takes (each parent has a trait, like ContainerChild or RowChild):

ValueBecomes
&str, String, &String, char, integers, floats, Snowflake, format_args!(..)text, joined with adjacent text
Component, and the component structs the parent takes (ButtonComponent in a <Row>, …)that component
View, a #[view]’s Propsits components, in place
Option<T>nothing for None
Vec<T>, arrays, iterator adapters (map, filter, filter_map, flat_map, flatten, chain, take, skip, rev, once, vec::IntoIter)each item in order

So lists are a map, and conditionals are an Option:

use composure::{rsx, command::View};

fn shopping(items: &[&str], note: Option<&str>) -> View {
    rsx! {
        <Container>
            "## Shopping list"
            {items.iter().map(|item| rsx! { <Text>"- " {*item}</Text> })}
            {note.map(|note| rsx! { <Separator/> <Text>"-# " {note}</Text> })}
        </Container>
    }
}
fn main() { assert_eq!(shopping(&["eggs"], None).components().len(), 1); }

Other iterators (Peekable, Zip, …) need .collect::<Vec<_>>() first.

In text-only places (<Text>, a <Button>’s label, an <Option>’s label) the value is anything implementing IntoText: strings, numbers, Snowflake, and Options of them. Call .to_string() on other Display types.

Attributes

Attributes are written name=value; the value is a Rust expression (a literal, a variable, a call, a block {..}). A bare attribute is true: <Button disabled> is <Button disabled=true>. Each attribute may appear once.

As in Leptos’s view!, markup is a chain of builder calls: a tag is its type’s builder() (<Container> is Container::builder()), each attribute a method of the builder (ContainerBuilder::accent_color), each child a .child(..), then .build(). That means:

  • the builder’s methods are the attributes, each documented; your editor completes and documents them as you type
  • values are converted with IntoAttr: a &str works for a String or Option<String>, a T for an Option<T>, "👍" for an emoji
  • an attribute the tag does not have is a compile error from the compiler (no method named colourfound for structContainerBuilder``, with the closest name)
  • so is a child where Discord does not allow it (`&str` cannot go in a `<Row>`), pointing at the child

The builders also work by hand, outside markup.

Values that contain <..> (generics, closures with typed parameters) must be wrapped in braces, or the markup parser reads < as a tag: options={Vec::<SelectOption>::new()}.

Comments

<!-- comments --> are allowed anywhere and produce nothing.

Errors

rsx! reports every error in the markup at once, not just the first: a missing attribute, an unclosed or mismatched tag; and the compiler reports misplaced tags and unknown attributes. Each error points at the token that caused it, and repeats of the same error are dropped. The markup is still expanded, so the {..} blocks around a mistake keep type-checking. A #[view] with markup errors still gets its Props, builder and handlers, so code using the View doesn’t report errors of its own.

Your editor sees the builders: hovering <Container> (or </Container>) shows the type’s docs and go-to-definition opens it, attributes complete and show their docs like methods, and a tag being typed (<Con, even inside another tag) completes from the built-in tags and the sub-Views in scope.

View functions

#[view] turns a function returning View (or ModalView) into a component of its own. The parameters are its Props, which must be owned types:

use composure::{rsx, view, command::View};

/// A labeled progress bar
#[view]
pub fn Progress(label: String, done: u32, #[prop(default = 10)] total: u32, note: Option<String>) -> View {
    let filled = (done * 10 / total.max(1)).min(10) as usize;
    rsx! {
        <Text>
            "**" {label.as_str()} "** " {"▰".repeat(filled)} {"▱".repeat(10 - filled)}
            {note.map(|note| format!("\n-# {note}"))}
        </Text>
    }
}
fn main() {}

The macro keeps the function body and adds:

  • a Props struct with the same name and one public field per parameter: Progress { label, done, total, note }. Returning it from a Command (or a handler) sends the View; Progress { .. }.render() gives the View itself.
  • a builder, Progress::builder().label("Build").done(3).build(), which is what markup uses. Setters take anything IntoAttr converts, and leaving out a required Prop is a compile error.
  • IntoResponse, IntoView, Rerender for the Props, and the ViewHandler running its inline handlers.

Props:

PropIn markup
name: Trequired
name: Option<T>optional, None when left out; name=value sets Some(value)
#[prop(default = expr)] name: Toptional, expr when left out
children: Viewthe tag’s children (see below); not usable in handlers

Sub-Views

A View is placed in markup as a tag, with its Props as attributes:

use composure::{rsx, view, command::View};
#[view]
pub fn Progress(label: String, done: u32, #[prop(default = 10)] total: u32, note: Option<String>) -> View {
    rsx! { <Text>{label.as_str()}</Text> }
}
#[view]
pub fn Build(step: u32) -> View {
    rsx! {
        <Container>
            "## Build"
            <Progress label="Compile" done=step/>
            <Progress label="Test" done={step.saturating_sub(3)} total=7 note="flaky"/>
        </Container>
    }
}
fn main() {}

Tag names may be paths (<widgets::Progress done=1 label="x"/>). A sub-View with inline handlers must be listed in the same #[app] as the View placing it (or in any App of the tree); forgetting it is a compile error from #[app].

Children

A children: View Prop receives the markup between the tags, which lets a View wrap others:

use composure::{rsx, view, command::View};

/// A titled frame
#[view]
pub fn Panel(title: String, children: View) -> View {
    rsx! {
        <Container>
            "### " {title}
            <Separator/>
            {children}
        </Container>
    }
}

fn main() {
let view = rsx! {
    <Panel title="Inbox">
        <Text>"No new messages"</Text>
    </Panel>
};
assert_eq!(view.components().len(), 1);
}

Inline handlers

An on:<event>:<name>=closure attribute gives a component its own handler, inside the View that renders it. The macro turns each into an arm of the View’s handler, routed by the custom_id <prefix>:<name>:<state>:

  • <prefix> is the View’s name in snake_case (Card → card); change it with #[view(prefix = "c")] to save characters
  • <name> is the handler’s name, unique within the View
  • <state> holds the Props the closure uses, found by reading its body (format!("{name}") counts), encoded with custom_id

When the component is used, the Props are decoded and the closure runs with them in scope. Its return value answers the Interaction:

  • a View’s Props or a View (or a Result of one) re-renders the message the component is on (an Update)
  • anything else a Command can return (CommandOutput): a new message, Ephemeral(..), a ModalView to open a modal, ctx.defer_update(..), …
EventTagsClosure
on:click:name<Button>move |ctx| ..
on:select:nameselectsmove |ctx, values: T| ..
on:submit:name<Modal>move |ctx, field: T, ..| .. (Modal Views)

ctx is a ComponentCtx (a ModalCtx for submits); write _ when unused. Untyped, it works with any State; annotate it to require one, as Commands do: {move |ctx: ComponentCtx<impl Platform>| ..} (braced, because of the <..>). Closures may be async.

use composure::{rsx, view, command::{ComponentCtx, Ephemeral, Platform, View}};

#[view]
pub fn Vote(question: String, yes: u32, no: u32) -> View {
    rsx! {
        <Text>"**" {question.as_str()} "**\n" {yes} " yes, " {no} " no"</Text>
        <Row>
            <Button success on:click:yes=move |_| Vote { yes: yes + 1, question, no }>"Yes"</Button>
            <Button danger on:click:no=move |_| Vote { no: no + 1, question, yes }>"No"</Button>
            <Button on:click:who=move |ctx| {
                Ephemeral(format!("You are {}", ctx.user().map_or("nobody", |u| u.display_name())))
            }>"Who am I?"</Button>
        </Row>
    }
}
fn main() {}

Things to know:

  • The custom_id is limited to 100 characters, prefix and handler name included. Past that, answering with the View fails with a HandlerError (the error rides inside the View until then). Keep captured Props small: ids and counters, not text.
  • Captured Props must implement serde::Serialize and DeserializeOwned; integers, bool, String, Snowflake, Options, tuples, and #[derive(Serialize, Deserialize)] types do.
  • A children Prop cannot be captured: it is markup, not data.
  • A handler only sees Props. Using a local of the #[view] body (let total = ..) or a closure’s parameter around the markup ({items.iter().map(|item| rsx! { .. })}) is a compile error at its use (total is not a Prop), as is a local that shadows a Prop. Make it a Prop, or move the markup into a sub-View that takes it as one.
  • List every View with inline handlers in an #[app]; that is how its custom_ids are routed.

Selects

An on:select closure takes the selected values as its second parameter, typed with SelectValue: String, Snowflake, a resolved User, Role or PartialChannel, or a #[derive(Choice)] enum, each alone (one required selection), in an Option (none allowed) or a Vec (several; max then defaults to every option).

use composure::{Choice, rsx, view, command::View};

#[derive(Choice, Debug)]
pub enum Topping { Cheese, Olives, Basil }

#[view]
pub fn Pizza(picked: String) -> View {
    rsx! {
        <Text>"Toppings: " {picked.as_str()}</Text>
        <Row>
            <StringSelect placeholder="Pick toppings" options=Topping
                on:select:pick={move |_ctx, toppings: Vec<Topping>| Pizza {
                    picked: format!("{toppings:?}"),
                }}/>
        </Row>
    }
}
fn main() {}

Handlers defined elsewhere

on:click, on:select and on:submit without a name point at a #[component] or #[modal] handler instead, with the state to put in its custom_id: on:click=vote(poll, true) sets custom_id = vote::custom_id(poll, true), and on:click=refresh calls refresh::custom_id(). These work in any rsx!, not only in #[view]s. Or set custom_id=.. yourself and answer it however you like.

A #[view] returning ModalView with a <Modal> root is a form. With an inline on:submit, each input’s name matches one of the closure’s parameters, which receive the submitted values typed:

use composure::{Choice, app, command, rsx, view, command::{Ephemeral, ModalView}};

#[derive(Choice, Debug)]
pub enum Severity { Low, High }

#[view]
pub fn Report(page: String) -> ModalView {
    rsx! {
        <Modal title="Report a problem" on:submit:send={move |_ctx, what: String, severity: Severity, notify: bool| {
            Ephemeral(format!("{page}: {what} ({severity:?}, notify: {notify})"))
        }}>
            <Text>"Reporting **" {page.as_str()} "**"</Text>
            <Label text="What happened?">
                <TextInput name="what" paragraph max_len=500/>
            </Label>
            <Label text="Severity">
                <RadioGroup name="severity" options=Severity/>
            </Label>
            <Label text="Notify me">
                <Checkbox name="notify"/>
            </Label>
        </Modal>
    }
}

/// Report a problem with a page
#[command]
fn report(#[option("Which page")] page: String) -> Report {
    Report { page }
}

#[app(handlers(report, Report))]
pub struct Bot;
fn main() {}

Returning the Props opens the modal. The parameter types (anything implementing ModalValue) decide what each input requires, and mismatches are compile errors:

TypeRequiredInputs
String, a Choice enumyes<TextInput>, <RadioGroup>, <StringSelect>
Option<String>, Option<Choice>nothe same
boolno (checkboxes never are)<Checkbox>
Snowflake, User, Role, PartialChannelyesentity selects
Attachmentyes<FileUpload>
Option<..> of thosenothe same
Vec<..> of those, of String, of a Choiceno; several allowedselects, <CheckboxGroup>, <FileUpload>

Every input needs a parameter and every parameter an input. Inputs in a modal never take on: attributes of their own; the <Modal>’s submit reads them all.

A modal can also be submitted to a #[modal] handler (on:submit=rename(id)) or answered by custom_id=..; inputs then need no matching parameters, and their name is the custom_id the handler reads them by.

Message options

A View is the whole message, not just its components. These set the rest; they work on any View, rsx!’s output or a sub-View’s (whose options and files go to the View it is placed in):

Method
.ephemeral()Only the invoking user sees it. An update keeps the message’s ephemerality
.allowed_mentions(..)Who it may ping (AllowedMentions)
.suppress_notifications()No push or desktop notification
.file(..)Uploads a File with the message

<File>, <Thumbnail> and <Item> take a File as their src (or url): the View uploads it and points the component at attachment://<name>, so the names cannot drift apart.

use composure::{command, rsx, command::View, types::File};

/// Get your text back as a file
#[command]
fn export(#[option("What to put in the file", max_len = 2000)] text: &str) -> View {
    let file = File::new("export.txt", text.as_bytes()).content_type("text/plain");
    rsx! {
        <Text>"Here's your file:"</Text>
        <File src={file}/>
    }
    .ephemeral()
}
fn main() {
    let view = export("hi");
    assert_eq!(view.files()[0].name, "export.txt");
}

Ephemeral(view) still works, and a View with files is sent like any response with files (see Files).

Tag reference

Each tag builds the type named in its heading; its attributes are that type’s public fields plus the special ones listed. id (an i32, unique within the message) is accepted by every tag except <Modal>, <Item> and <Option>, and is omitted below.

Layout

<Container>

Container: a frame around other components, like an embed, with an optional accent bar. Top level only. Children: text, <Text>, <Section>, <Gallery>, <File>, <Separator>, <Row>, {..}.

AttributeType
accent_coloru32 (0xRRGGBB)Color of the bar on the left; none when unset
spoilerboolBlurs the content until clicked
<Container accent_color=0x5865F2 spoiler> .. </Container>

<Section>

Section: 1-3 text displays beside an accessory. Children: text and <Text>, plus exactly one <Button> or <Thumbnail>, which becomes the accessory wherever it is placed among the children.

AttributeType
accessoryimpl Into<Component>Special. The accessory as a value, instead of a child
<Section>
    <Text>"## Ada Lovelace"</Text>
    <Text>"Mathematician"</Text>
    <Thumbnail url=avatar_url/>
</Section>

<Text> (alias <TextDisplay>)

TextDisplay: markdown. Children: text and {..} only. Bare text outside a <Text> makes one too.

AttributeType
contentStringSpecial. The text, instead of children

<Separator>

Separator: vertical space, with a divider line by default. No children.

AttributeType
small / largeflagSpecial. Small (default) or large spacing
spacingSeparatorSpacingThe same, as a value
dividerboolfalse for space only, no line
<Separator large divider=false/>

<Row> (alias <ActionRow>)

ActionRow: up to 5 <Button>s, or one select. Children: those and {..}; no text.

Content

<Thumbnail>

Thumbnail: a small image, as a <Section>’s accessory only. No children.

AttributeType
src (or url)a URL, a File or an &AttachmentSpecial, required. An image URL (or attachment://<filename>), a File to upload with the message, or an attachment Discord has
descriptionStringAlt text, max 1024 characters
spoilerboolBlurs the image until clicked

MediaGallery: a grid of 1-10 images and videos. Children: <Item>s, and {..} of MediaGalleryItems.

<Item> (alias <MediaGalleryItem>)

MediaGalleryItem: one image or video in a <Gallery>. No children.

AttributeType
src (or url)a URL, a File or an &AttachmentSpecial, required. A URL (or attachment://<filename>), a File to upload with the message, or an attachment Discord has
descriptionStringAlt text, max 1024 characters
spoilerboolBlurs the media until clicked
<Gallery>
    <Item url="https://example.com/a.png" description="Before"/>
    <Item url="attachment://after.png" description="After" spoiler/>
    {extra.iter().map(|url| MediaGalleryItem::new(url.as_str()))}
</Gallery>

<File>

FileComponent: a file uploaded with the message, shown inline. No children. Give it the File and the View uploads it (Message options).

AttributeType
src (or url)a File, or impl Into<String>Special, required. The File to upload, or attachment://<filename> for one added with .file(..)
spoilerboolBlurs the file until clicked

Interactive

<Button>

ButtonComponent: in a <Row>, or as a <Section>’s accessory. Children: the label, as text and {..}.

AttributeType
primary / secondary / success / dangerflagSpecial. The style: blurple, grey (the default), green, red
styleButtonStyleSpecial. The style, as a value
on:click=handler(state..)Special. Answered by a #[component] handler (above)
on:click:name=closureSpecial. An inline handler; #[view] only
custom_idStringAnswered by whatever handles this custom_id; max 100 characters
urlStringA link button (style Link), opening the URL; no on:click or custom_id
sku_idSnowflakeA premium button (style Premium) offering the SKU; no label, emoji, on:click or custom_id
labelStringSpecial. The label, instead of children; max 80 characters
emoji&str or PartialEmojiShown before the label: "👍", or a custom emoji
disabledboolGreyed out, not clickable

A button needs exactly one of on:click, custom_id, url or sku_id.

<Row>
    <Button primary on:click:save=move |_| ..>"Save"</Button>
    <Button danger emoji="🗑️" on:click=delete(id)>"Delete"</Button>
    <Button url="https://discord.com/developers/docs">"Docs"</Button>
    <Button custom_id="help" disabled>"Help"</Button>
</Row>

<StringSelect> (alias <Select>)

StringSelect: a dropdown of up to 25 text options. In a <Row> in messages, or a <Label> in modals. Children: <Option>s, and {..} of SelectOptions.

AttributeType
on:select=handler(state..)Special. Answered by a #[component] handler
on:select:name=closureSpecial. An inline handler; #[view] only
custom_idStringAnswered by whatever handles this custom_id
namestring literalSpecial, modals only (required there). The input’s custom_id, matched to an on:submit parameter
optionsa #[derive(Choice)] enumSpecial. One option per variant, before any <Option> children
placeholderStringShown while nothing is selected; max 150 characters
min (or min_values)i32Fewest selections, 0-25 (default 1)
max (or max_values)i32Most selections, 1-25 (default 1; a Vec handler parameter makes it every option)
disabledboolNot selectable; messages only
requiredboolModals only; set from the submit parameter’s type

In messages, a select needs on:select or custom_id.

<Option>

SelectOption in a select, GroupOption in a <RadioGroup> or <CheckboxGroup>. Children: the label, as text and {..}.

AttributeType
valueimpl Into<String>Special, required. What is submitted when chosen; max 100 characters
labelStringSpecial. The label, instead of children; max 100 characters
descriptionStringA second line under the label; max 100 characters
emoji&str or PartialEmojiSelects only
defaultboolChosen initially
<StringSelect on:select:pick=move |_, size: Size| ..>
    <Option value="s" description="Fits most">"Small"</Option>
    <Option value="l" emoji="🐘" default>"Large"</Option>
</StringSelect>

<UserSelect>, <RoleSelect>, <MentionableSelect>, <ChannelSelect>

UserSelect, RoleSelect, MentionableSelect, ChannelSelect: pick users, roles, both, or channels, with Discord filling in the options. No children. The same attributes as <StringSelect> except options, plus:

AttributeType
default_valuesVec<SelectDefaultValue>Preselected entities: vec![SelectDefaultValue::user(id)]
channel_typesVec<ChannelType><ChannelSelect>: the kinds of channel offered
<Row>
    <ChannelSelect placeholder="Where to post?"
        channel_types={vec![ChannelType::GuildText]}
        on:select:pick=move |_, channel: PartialChannel| ..>
    </ChannelSelect>
</Row>

Modals

ModalCallbackData, as a ModalView. Must be the markup’s only root. Children: text, <Text> and <Label>s, at most 5 in total (Discord’s limit).

AttributeType
titleimpl Into<String>Required. Shown at the top; max 45 characters
on:submit=handler(state..)Submitted to a #[modal] handler
on:submit:name=closureAn inline handler; #[view] only
custom_idStringSubmitted to whatever handles this custom_id

A <Modal> needs exactly one of on:submit or custom_id, and takes no other attributes.

<Label>

Label: a title and optional description above one input. Children: exactly one <TextInput>, select, <RadioGroup>, <CheckboxGroup>, <Checkbox> or <FileUpload>.

AttributeType
text (or label)impl Into<String>Required. The title; max 45 characters
descriptionStringUnder the title; max 100 characters

Every input below takes name="..." (special, required, a string literal): its custom_id, matched to an on:submit parameter. None takes on: attributes.

<TextInput>

TextInput: free text. No children.

AttributeType
paragraphflagSpecial. Multi-line; single-line by default
styleTextInputStyleSpecial. The same, as a value
min_len (or min_length)i320-4000
max_len (or max_length)i321-4000
placeholderStringShown while empty; max 100 characters
valueStringPrefilled text; max 4000 characters
requiredboolSet from the submit parameter’s type

<RadioGroup>

RadioGroup: pick one of 2-10 options. Children: <Option>s, and {..} of GroupOptions.

AttributeType
optionsa #[derive(Choice)] enumSpecial. One option per variant
requiredboolSet from the submit parameter’s type

<CheckboxGroup>

CheckboxGroup: pick any of 1-10 options. Children: as <RadioGroup>.

AttributeType
optionsa #[derive(Choice)] enumSpecial. One option per variant
min (or min_values)u80-10
max (or max_values)u81-10
requiredboolSet from the submit parameter’s type

<Checkbox>

Checkbox: one yes/no, submitted as a bool. Never required. No children.

AttributeType
defaultboolChecked initially

<FileUpload>

FileUpload: attach files, submitted as Attachments. No children.

AttributeType
min (or min_values)u80-10
max (or max_values)u81-10 (a Vec parameter makes it 10)
file_typesVec<String>Up to 10 of "image", "video", "audio", or extensions like ".pdf"
requiredboolSet from the submit parameter’s type

Styling

Colors are yours: plain consts, set where a message is built. A wrapper View per tone keeps every message alike, and works the same whether a handler answers with it, Background Work edits with it, or it is sent through REST:

use composure::{command::View, rsx, view};

pub const SUCCESS: u32 = 0x57f287;

/// Something worked
#[view]
pub fn Success(title: String, children: View) -> View {
    rsx! {
        <Container accent_color=SUCCESS>
            <Text>"### " {title}</Text>
            {children}
        </Container>
    }
}

fn saved(channel: &str) -> View {
    rsx! { <Success title="Saved">"Announcements will appear in " {channel}</Success> }
}
fn main() { assert_eq!(saved("#news").into_components().unwrap().len(), 1); }

Handler errors are the one message Composure builds itself: by default, the ephemeral text of HandlerError::response. #[app(errors = ..)] takes an ErrorRenderer, a fn(&HandlerError) -> InteractionResponse, to answer them your way, both right away and when deferred work fails:

use composure::{
    app, command,
    command::{Ephemeral, HandlerError, IntoResponse},
    rsx,
    types::InteractionResponse,
};

const ERROR: u32 = 0xed4245;

/// Errors as an ephemeral red container
fn render_error(error: &HandlerError) -> InteractionResponse {
    let message = error.user_message().to_string();
    Ephemeral(rsx! { <Container accent_color=ERROR>{message}</Container> }).into_response()
}

/// Ping
#[command]
fn ping() -> &'static str {
    "Pong"
}

#[app(handlers(ping), errors = render_error)]
pub struct Bot;
fn main() {}

user_message() is what the user should read: a HandlerError::user’s message, a generic one otherwise. At most one App in a tree sets errors; the root uses it for the whole tree.

Without #[view]

rsx! works in any function; only inline handlers need #[view]. A plain function returning View is a good fit for layouts without state of their own, and for messages sent through REST. The message bodies are built from a &View with TryFrom: CreateMessage, CreateFollowup, EditMessage and ExecuteWebhook. Each sets the components, the Components V2 flag, the View’s other flags (ephemeral where the Endpoint takes it), its allowed mentions and its files, and fails before anything is sent when the View cannot be (a custom_id that could not be built, or past Discord’s limits):

use composure::{
    command::HandlerError,
    rest::{BotClient, CreateMessage, Transport},
    rsx,
    types::Snowflake,
};

async fn announce<T: Transport>(
    bot: &BotClient<T>,
    channel: Snowflake,
    text: &str,
) -> Result<(), HandlerError> {
    let view = rsx! { <Container>"📣 " {text}</Container> };
    bot.create_message(channel, &CreateMessage::try_from(&view)?).await?;
    Ok(())
}
fn main() {}

Built from the same tone Views as your replies, they look alike.