fc_dioxus/components/
composer.rs1use 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");
15const NAME_FITS: usize = 32;
17fc_modifier! { enum Frame for BEM { Open => None, Contained => "contained" } }
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Attachment {
22 pub name: String,
24 pub size: u64,
26 pub error: Option<String>,
28}
29
30#[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#[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#[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 #[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 #[props(default)]
88 onfiles: Option<EventHandler<Vec<dioxus::html::FileData>>>,
89 #[props(default)]
91 tools: Option<Element>,
92 #[props(default)] placeholder: Option<String>,
93 #[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 readonly: running,
164 value: "{text}",
165 oninput: move |event| state.set(event.value()),
166 onkeydown: move |event| {
167 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 span { class: BEM.element("attachment-name"), title: "{attachment.name}",
184 ..truncated_attribute(&Clipped::chars(attachment.name.clone(), NAME_FITS)),
185 "{attachment.name}"
186 }
187 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}