Skip to main content

fc_dioxus/components/
composer.rs

1//! The chat composer, from composer.css. Behaviour ports upstream's `composer.ts`.
2use crate::{
3    core::{
4        assets,
5        attrs::{
6            component_attributes, composer_state_attribute, dragging_attribute,
7            error_state_attribute, truncated_attribute,
8        },
9    },
10    prelude::*,
11    theme::use_stylesheets,
12};
13const BEM: Bem = Bem::new("fc-composer");
14const BTN: Bem = Bem::new("fc-btn");
15/// Roughly how many characters of a file name a chip shows before its ellipsis.
16const NAME_FITS: usize = 32;
17fc_modifier! { enum Frame for BEM { Open => None, Contained => "contained" } }
18
19/// One attached file.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Attachment {
22    /// Display name, also its remove button's name.
23    pub name: String,
24    /// Bytes.
25    pub size: u64,
26    /// Why it could not be read. A failed file is shown, never silently dropped.
27    pub error: Option<String>,
28}
29
30/// "1 attachment · 2.0 KB · Shift+Enter for a new line": the composer's status line.
31#[must_use]
32pub fn describe(text: &str, attachments: &[Attachment], running: bool) -> String {
33    let mut parts = Vec::new();
34    if !attachments.is_empty() {
35        let bytes: u64 = attachments.iter().map(|a| a.size).sum();
36        let n = attachments.len();
37        parts.push(format!("{n} attachment{}", if n == 1 { "" } else { "s" }));
38        if bytes > 0 {
39            parts.push(format_size(bytes));
40        }
41    }
42    if running {
43        parts.push("generating".to_owned());
44    }
45    if text.trim().is_empty() && attachments.is_empty() {
46        parts.push("Shift+Enter for a new line".to_owned());
47    }
48    parts.join(" · ")
49}
50
51/// Bytes as people read them: "512 B", "2.0 KB", "1.5 MB".
52#[must_use]
53pub fn format_size(bytes: u64) -> String {
54    const KIB: f64 = 1024.0;
55    #[allow(clippy::cast_precision_loss)]
56    let value = bytes as f64;
57    if value < KIB {
58        format!("{bytes} B")
59    } else if value < KIB * KIB {
60        format!("{:.1} KB", value / KIB)
61    } else {
62        format!("{:.1} MB", value / KIB / KIB)
63    }
64}
65
66/// Text entry, attachments, and a send control that becomes Stop while a run is in
67/// flight.
68///
69/// A `<textarea>`, not contenteditable, so input methods, undo, and Shift+Enter work
70/// natively. Enter sends unless an input method is composing. The send button changes
71/// meaning, never identity, so focus does not jump. Stop does not clear the draft, and
72/// the draft clears only after `onsubmit` accepts it.
73#[component]
74pub fn Composer(
75    label: AccessibleName,
76    #[props(default)] value: Option<ReadSignal<String>>,
77    #[props(default)] default_value: String,
78    #[props(default)] oninput: Option<EventHandler<String>>,
79    onsubmit: EventHandler<(String, Vec<Attachment>)>,
80    /// A run is in flight: the field is read-only and Send becomes Stop.
81    #[props(default)]
82    running: bool,
83    #[props(default)] onstop: Option<EventHandler<()>>,
84    #[props(default)] attachments: Vec<Attachment>,
85    #[props(default)] onremove: Option<EventHandler<usize>>,
86    /// Files dropped on the composer.
87    #[props(default)]
88    onfiles: Option<EventHandler<Vec<dioxus::html::FileData>>>,
89    /// Controls beside the send button: attach, model, tools.
90    #[props(default)]
91    tools: Option<Element>,
92    #[props(default)] placeholder: Option<String>,
93    /// Draw the composer as its own bordered surface.
94    #[props(default)]
95    contained: bool,
96    #[props(default)] class: Option<String>,
97    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
98) -> Element {
99    let state = use_controllable(value, || default_value, oninput);
100    let mut dragging = use_signal(|| false);
101    let text = state.get();
102    let empty = text.trim().is_empty() && attachments.is_empty();
103    let phase = if running {
104        "running"
105    } else if empty {
106        "idle"
107    } else {
108        "ready"
109    };
110    let mut attributes = component_attributes(
111        attributes,
112        ClassList::new(BEM.block())
113            .with_modifier(
114                BEM,
115                if contained {
116                    Frame::Contained
117                } else {
118                    Frame::Open
119                },
120            )
121            .extend(class.as_deref()),
122        &[],
123    );
124    attributes.push(composer_state_attribute(phase));
125    attributes.extend(dragging_attribute(dragging()));
126    let submit = {
127        let attachments = attachments.clone();
128        move || {
129            let text = state.get();
130            if running || (text.trim().is_empty() && attachments.is_empty()) {
131                return;
132            }
133            onsubmit.call((text.trim().to_owned(), attachments.clone()));
134            state.set(String::new());
135        }
136    };
137    let submit_key = submit.clone();
138    let submit_click = submit;
139    let send_class = ClassList::new(BTN.block())
140        .with(BTN.modifier_raw("primary"))
141        .with(BEM.element("send"))
142        .to_string();
143    let status = describe(&text, &attachments, running);
144    rsx! {
145        if use_stylesheets() {
146            document::Stylesheet { href: assets::COMPONENTS_BUTTON_CSS }
147            document::Stylesheet { href: assets::COMPONENTS_COMPOSER_CSS }
148        }
149        div {
150            ondragover: move |event| { event.prevent_default(); dragging.set(true); },
151            ondragleave: move |_| dragging.set(false),
152            ondrop: move |event| {
153                event.prevent_default();
154                dragging.set(false);
155                if let Some(onfiles) = onfiles {
156                    let files = event.data_transfer().files();
157                    if !files.is_empty() { onfiles.call(files); }
158                }
159            },
160            ..attributes,
161            textarea { class: BEM.element("input"), rows: "2", aria_label: label.to_string(), placeholder,
162                // Read-only, not disabled: a disabled field leaves the tab order.
163                readonly: running,
164                value: "{text}",
165                oninput: move |event| state.set(event.value()),
166                onkeydown: move |event| {
167                    // Enter during input-method composition picks a candidate; sending
168                    // there would commit half a character.
169                    if event.key() != Key::Enter || event.is_composing() || event.modifiers().shift() {
170                        return;
171                    }
172                    event.prevent_default();
173                    submit_key();
174                },
175            }
176            if !attachments.is_empty() {
177                ul { class: BEM.element("attachments"),
178                    for (index, attachment) in attachments.iter().enumerate() {
179                        li { key: "{index}-{attachment.name}", class: BEM.element("attachment"),
180                            ..error_state_attribute(attachment.error.is_some()),
181                            // The name is ellipsized by the stylesheet; its full value stays in
182                            // `title`, and a name long enough to be cut is declared truncated.
183                            span { class: BEM.element("attachment-name"), title: "{attachment.name}",
184                                ..truncated_attribute(&Clipped::chars(attachment.name.clone(), NAME_FITS)),
185                                "{attachment.name}"
186                            }
187                            // Each remove button names its file, so four of them are not all "Remove".
188                            button { r#type: "button", class: BEM.element("attachment-remove"), aria_label: "Remove {attachment.name}",
189                                onclick: move |_| if let Some(onremove) = onremove { onremove.call(index); }, "×" }
190                        }
191                    }
192                }
193            }
194            div { class: BEM.element("row"),
195                div { class: BEM.element("tools"), {tools} }
196                button { r#type: "button", class: send_class, disabled: !running && empty,
197                    aria_label: if running { "Stop generating" } else { "Send message" },
198                    onclick: move |_| if running { if let Some(onstop) = onstop { onstop.call(()); } } else { submit_click(); },
199                    ..crate::spread![composer_state_attribute(phase)],
200                    if running { "Stop" } else { "Send" }
201                }
202            }
203            p { class: BEM.element("status"), role: "status", "{status}" }
204        }
205    }
206}