use crate::{
core::{
assets,
attrs::{
component_attributes, composer_state_attribute, dragging_attribute,
error_state_attribute, truncated_attribute,
},
},
prelude::*,
theme::use_stylesheets,
};
const BEM: Bem = Bem::new("fc-composer");
const BTN: Bem = Bem::new("fc-btn");
const NAME_FITS: usize = 32;
fc_modifier! { enum Frame for BEM { Open => None, Contained => "contained" } }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attachment {
pub name: String,
pub size: u64,
pub error: Option<String>,
}
#[must_use]
pub fn describe(text: &str, attachments: &[Attachment], running: bool) -> String {
let mut parts = Vec::new();
if !attachments.is_empty() {
let bytes: u64 = attachments.iter().map(|a| a.size).sum();
let n = attachments.len();
parts.push(format!("{n} attachment{}", if n == 1 { "" } else { "s" }));
if bytes > 0 {
parts.push(format_size(bytes));
}
}
if running {
parts.push("generating".to_owned());
}
if text.trim().is_empty() && attachments.is_empty() {
parts.push("Shift+Enter for a new line".to_owned());
}
parts.join(" · ")
}
#[must_use]
pub fn format_size(bytes: u64) -> String {
const KIB: f64 = 1024.0;
#[allow(clippy::cast_precision_loss)]
let value = bytes as f64;
if value < KIB {
format!("{bytes} B")
} else if value < KIB * KIB {
format!("{:.1} KB", value / KIB)
} else {
format!("{:.1} MB", value / KIB / KIB)
}
}
#[component]
pub fn Composer(
label: AccessibleName,
#[props(default)] value: Option<ReadSignal<String>>,
#[props(default)] default_value: String,
#[props(default)] oninput: Option<EventHandler<String>>,
onsubmit: EventHandler<(String, Vec<Attachment>)>,
#[props(default)]
running: bool,
#[props(default)] onstop: Option<EventHandler<()>>,
#[props(default)] attachments: Vec<Attachment>,
#[props(default)] onremove: Option<EventHandler<usize>>,
#[props(default)]
onfiles: Option<EventHandler<Vec<dioxus::html::FileData>>>,
#[props(default)]
tools: Option<Element>,
#[props(default)] placeholder: Option<String>,
#[props(default)]
contained: bool,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let state = use_controllable(value, || default_value, oninput);
let mut dragging = use_signal(|| false);
let text = state.get();
let empty = text.trim().is_empty() && attachments.is_empty();
let phase = if running {
"running"
} else if empty {
"idle"
} else {
"ready"
};
let mut attributes = component_attributes(
attributes,
ClassList::new(BEM.block())
.with_modifier(
BEM,
if contained {
Frame::Contained
} else {
Frame::Open
},
)
.extend(class.as_deref()),
&[],
);
attributes.push(composer_state_attribute(phase));
attributes.extend(dragging_attribute(dragging()));
let submit = {
let attachments = attachments.clone();
move || {
let text = state.get();
if running || (text.trim().is_empty() && attachments.is_empty()) {
return;
}
onsubmit.call((text.trim().to_owned(), attachments.clone()));
state.set(String::new());
}
};
let submit_key = submit.clone();
let submit_click = submit;
let send_class = ClassList::new(BTN.block())
.with(BTN.modifier_raw("primary"))
.with(BEM.element("send"))
.to_string();
let status = describe(&text, &attachments, running);
rsx! {
if use_stylesheets() {
document::Stylesheet { href: assets::COMPONENTS_BUTTON_CSS }
document::Stylesheet { href: assets::COMPONENTS_COMPOSER_CSS }
}
div {
ondragover: move |event| { event.prevent_default(); dragging.set(true); },
ondragleave: move |_| dragging.set(false),
ondrop: move |event| {
event.prevent_default();
dragging.set(false);
if let Some(onfiles) = onfiles {
let files = event.data_transfer().files();
if !files.is_empty() { onfiles.call(files); }
}
},
..attributes,
textarea { class: BEM.element("input"), rows: "2", aria_label: label.to_string(), placeholder,
readonly: running,
value: "{text}",
oninput: move |event| state.set(event.value()),
onkeydown: move |event| {
if event.key() != Key::Enter || event.is_composing() || event.modifiers().shift() {
return;
}
event.prevent_default();
submit_key();
},
}
if !attachments.is_empty() {
ul { class: BEM.element("attachments"),
for (index, attachment) in attachments.iter().enumerate() {
li { key: "{index}-{attachment.name}", class: BEM.element("attachment"),
..error_state_attribute(attachment.error.is_some()),
span { class: BEM.element("attachment-name"), title: "{attachment.name}",
..truncated_attribute(&Clipped::chars(attachment.name.clone(), NAME_FITS)),
"{attachment.name}"
}
button { r#type: "button", class: BEM.element("attachment-remove"), aria_label: "Remove {attachment.name}",
onclick: move |_| if let Some(onremove) = onremove { onremove.call(index); }, "×" }
}
}
}
}
div { class: BEM.element("row"),
div { class: BEM.element("tools"), {tools} }
button { r#type: "button", class: send_class, disabled: !running && empty,
aria_label: if running { "Stop generating" } else { "Send message" },
onclick: move |_| if running { if let Some(onstop) = onstop { onstop.call(()); } } else { submit_click(); },
..crate::spread![composer_state_attribute(phase)],
if running { "Stop" } else { "Send" }
}
}
p { class: BEM.element("status"), role: "status", "{status}" }
}
}
}