1use super::{
4 id::Id,
5 state::{Lifecycle, MessageRole},
6};
7use dioxus::prelude::*;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct Timestamp(TimestampRepr);
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22enum TimestampRepr {
23 Millis(u64),
24 Iso(String),
25}
26impl Timestamp {
27 #[must_use]
29 pub const fn from_epoch_millis(value: u64) -> Self {
30 Self(TimestampRepr::Millis(value))
31 }
32 pub fn iso8601(value: &str) -> Result<Self, InvalidTimestamp> {
37 let bytes = value.as_bytes();
38 let digits = |range: std::ops::Range<usize>| -> Option<u32> {
39 let part = value.get(range)?;
40 if part.bytes().all(|b| b.is_ascii_digit()) {
41 part.parse().ok()
42 } else {
43 None
44 }
45 };
46 let separators = [(4, b'-'), (7, b'-'), (10, b'T'), (13, b':'), (16, b':')];
47 let shape = bytes.len() >= 20
48 && bytes.last() == Some(&b'Z')
49 && separators
50 .iter()
51 .all(|(index, byte)| bytes.get(*index) == Some(byte));
52 let fraction = value.get(19..value.len().saturating_sub(1)).unwrap_or("");
53 let fraction_ok = fraction.is_empty()
54 || (fraction.len() > 1
55 && fraction.starts_with('.')
56 && fraction.bytes().skip(1).all(|b| b.is_ascii_digit()));
57 let fields = (
58 digits(0..4),
59 digits(5..7),
60 digits(8..10),
61 digits(11..13),
62 digits(14..16),
63 digits(17..19),
64 );
65 match fields {
66 (Some(_), Some(month), Some(day), Some(hour), Some(minute), Some(second))
67 if shape
68 && fraction_ok
69 && (1..=12).contains(&month)
70 && (1..=31).contains(&day)
71 && hour < 24
72 && minute < 60
73 && second < 61 =>
74 {
75 Ok(Self(TimestampRepr::Iso(value.to_owned())))
76 }
77 _ => Err(InvalidTimestamp),
78 }
79 }
80}
81impl std::fmt::Display for Timestamp {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match &self.0 {
84 TimestampRepr::Millis(value) => write!(f, "{value}"),
85 TimestampRepr::Iso(value) => f.write_str(value),
86 }
87 }
88}
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct InvalidTimestamp;
92impl std::fmt::Display for InvalidTimestamp {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 f.write_str("expected an ISO-8601 UTC timestamp such as 2026-10-09T12:00:00Z")
95 }
96}
97impl std::error::Error for InvalidTimestamp {}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101#[non_exhaustive]
102pub enum Kind {
103 Chat,
105 Message,
107 ToolCall,
109 Reasoning,
111 AgentStatus,
113 Approval,
115 DeviceAuth,
117 Dialog,
119 Metric,
121 Toast,
123 Transfer,
125 UserPrompt,
127 Multiselect,
129 ThemePicker,
131}
132impl Kind {
133 const fn as_str(self) -> &'static str {
134 match self {
135 Self::Chat => "chat",
136 Self::Message => "message",
137 Self::ToolCall => "tool-call",
138 Self::Reasoning => "reasoning",
139 Self::AgentStatus => "agent-status",
140 Self::Approval => "approval",
141 Self::DeviceAuth => "device-auth",
142 Self::Dialog => "dialog",
143 Self::Metric => "metric",
144 Self::Toast => "toast",
145 Self::Transfer => "transfer",
146 Self::UserPrompt => "user-prompt",
147 Self::Multiselect => "multiselect",
148 Self::ThemePicker => "theme-picker",
149 }
150 }
151}
152
153#[derive(Debug, Clone, Default, PartialEq)]
161pub struct Spread(Vec<Attribute>);
162impl Spread {
163 #[must_use]
166 pub fn into_boxed_slice(mut self) -> Box<[Attribute]> {
167 self.0.sort_by(|a, b| a.name.cmp(b.name));
168 self.0.into_boxed_slice()
169 }
170}
171impl std::ops::Deref for Spread {
172 type Target = Vec<Attribute>;
173 fn deref(&self) -> &Self::Target {
174 &self.0
175 }
176}
177impl std::ops::DerefMut for Spread {
178 fn deref_mut(&mut self) -> &mut Self::Target {
179 &mut self.0
180 }
181}
182impl From<Vec<Attribute>> for Spread {
183 fn from(list: Vec<Attribute>) -> Self {
184 Self(list)
185 }
186}
187impl From<Spread> for Vec<Attribute> {
188 fn from(list: Spread) -> Self {
189 list.0
190 }
191}
192impl FromIterator<Attribute> for Spread {
193 fn from_iter<I: IntoIterator<Item = Attribute>>(iter: I) -> Self {
194 Self(iter.into_iter().collect())
195 }
196}
197impl Extend<Attribute> for Spread {
198 fn extend<I: IntoIterator<Item = Attribute>>(&mut self, iter: I) {
199 self.0.extend(iter);
200 }
201}
202impl IntoIterator for Spread {
203 type Item = Attribute;
204 type IntoIter = std::vec::IntoIter<Attribute>;
205 fn into_iter(self) -> Self::IntoIter {
206 self.0.into_iter()
207 }
208}
209
210#[macro_export]
212macro_rules! spread {
213 ($($attribute:expr),* $(,)?) => {
214 $crate::core::attrs::Spread::from(::std::vec![$($attribute),*])
215 };
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct FcData {
221 pub kind: Option<Kind>,
223 pub id: Option<Id>,
225 pub seq: Option<u64>,
227 pub role: Option<MessageRole>,
229 pub state: Option<Lifecycle>,
231 pub ts: Option<Timestamp>,
233}
234impl FcData {
235 #[must_use]
237 pub fn attributes(&self) -> Spread {
238 let mut attrs = Spread::default();
239 for (name, value) in [
240 ("data-fc-kind", self.kind.map(|v| v.as_str().to_owned())),
241 ("data-fc-id", self.id.as_ref().map(ToString::to_string)),
242 ("data-fc-seq", self.seq.map(|v| v.to_string())),
243 ("data-fc-role", self.role.map(|v| v.as_str().to_owned())),
244 ("data-fc-state", self.state.map(|v| v.as_str().to_owned())),
245 ("data-fc-ts", self.ts.as_ref().map(ToString::to_string)),
246 ] {
247 if let Some(value) = value {
248 attrs.push(Attribute::new(name, value, None, false));
249 }
250 }
251 attrs
252 }
253}
254
255pub(crate) type ThemePairs = [(&'static str, Option<&'static str>); 4];
257
258pub(crate) fn theme_pairs(
259 theme: Option<&'static str>,
260 brand: Option<&'static str>,
261 design: Option<&'static str>,
262 palette: Option<&'static str>,
263) -> ThemePairs {
264 [
265 ("data-fc-theme", theme),
266 ("data-fc-brand", brand),
267 ("data-fc-design", design),
268 ("data-fc-palette", palette),
269 ]
270}
271
272pub(crate) fn theme_attributes(pairs: &ThemePairs) -> Spread {
273 let mut attrs = crate::spread![Attribute::new(
274 "data-fc-version",
275 super::assets::CSS_VERSION,
276 None,
277 false,
278 )];
279 for (name, value) in pairs {
280 if let Some(value) = value {
281 attrs.push(Attribute::new(name, *value, None, false));
282 }
283 }
284 attrs
285}
286
287pub(crate) fn component_attributes(
289 attributes: Vec<Attribute>,
290 mut classes: super::class::ClassList,
291 protected: &[&str],
292) -> Spread {
293 for attr in &attributes {
294 if attr.name == "class" {
295 if let dioxus::core::AttributeValue::Text(value) = &attr.value {
296 classes = classes.with(value);
297 }
298 }
299 }
300 let mut attributes: Spread = attributes
301 .into_iter()
302 .filter(|attr| attr.name != "class" && !protected.contains(&attr.name))
303 .collect();
304 attributes.push(Attribute::new("class", classes.to_string(), None, false));
305 attributes
306}
307
308pub(crate) fn availability_attributes(availability: &super::state::Availability) -> Spread {
309 use super::state::Availability;
310 match availability {
311 Availability::Enabled => Spread::default(),
312 Availability::Disabled => crate::spread![Attribute::new("disabled", true, None, false)],
313 Availability::Unavailable { reason } => crate::spread![
314 Attribute::new("aria-disabled", "true", None, false),
315 Attribute::new("aria-describedby", reason.to_string(), None, false),
316 ],
317 }
318}
319
320pub(crate) fn clipped_attributes(text: &super::text::Clipped) -> Spread {
322 let mut attributes = truncated_attribute(text);
323 attributes.extend(full_text_title(text));
324 attributes
325}
326
327pub(crate) fn truncated_attribute(text: &super::text::Clipped) -> Spread {
329 if text.is_truncated() {
330 crate::spread![Attribute::new("data-fc-truncated", "true", None, false)]
331 } else {
332 Spread::default()
333 }
334}
335
336pub(crate) fn full_text_title(text: &super::text::Clipped) -> Spread {
338 if text.is_truncated() {
339 crate::spread![Attribute::new(
340 "title",
341 text.full_text().to_owned(),
342 None,
343 false,
344 )]
345 } else {
346 Spread::default()
347 }
348}
349
350pub(crate) fn open_attribute(open: Option<bool>) -> Spread {
353 open.map(|open| Attribute::new("data-fc-open", open.to_string(), None, false))
354 .into_iter()
355 .collect()
356}
357
358pub(crate) fn leaving_attribute(leaving: bool) -> Spread {
360 if leaving {
361 crate::spread![Attribute::new("data-fc-leaving", "true", None, false)]
362 } else {
363 Spread::default()
364 }
365}
366
367pub(crate) fn dismissed_attribute(dismissed: bool) -> Spread {
369 if dismissed {
370 crate::spread![Attribute::new("data-fc-dismissed", "true", None, false)]
371 } else {
372 Spread::default()
373 }
374}
375
376pub(crate) fn carousel_control_attribute(next: bool) -> Attribute {
378 Attribute::new(
379 "data-fc-carousel",
380 if next { "next" } else { "prev" },
381 None,
382 false,
383 )
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub(crate) enum MultiselectPart {
389 Summary,
391 Filter,
393 Empty,
395 SelectAll,
397 Clear,
399}
400pub(crate) fn multiselect_part(part: MultiselectPart) -> Attribute {
401 match part {
402 MultiselectPart::Summary => Attribute::new("data-fc-selection-summary", "", None, false),
403 MultiselectPart::Filter => Attribute::new("data-fc-filter", "", None, false),
404 MultiselectPart::Empty => Attribute::new("data-fc-empty", "", None, false),
405 MultiselectPart::SelectAll => {
406 Attribute::new("data-fc-selection-action", "all", None, false)
407 }
408 MultiselectPart::Clear => Attribute::new("data-fc-selection-action", "clear", None, false),
409 }
410}
411
412pub(crate) fn selected_attribute(selected: bool) -> Attribute {
414 Attribute::new("data-fc-selected", selected.to_string(), None, false)
415}
416pub(crate) fn count_attribute(count: usize) -> Attribute {
417 Attribute::new("data-fc-count", count.to_string(), None, false)
418}
419
420pub(crate) fn date_attribute(iso: &str) -> Attribute {
424 Attribute::new("data-fc-date", iso.to_owned(), None, false)
425}
426pub(crate) fn datepicker_nav_attribute(next: bool) -> Attribute {
427 Attribute::new(
428 "data-fc-datepicker",
429 if next { "next" } else { "prev" },
430 None,
431 false,
432 )
433}
434pub(crate) fn date_value_attribute(iso: Option<String>) -> Spread {
435 iso.map(|iso| Attribute::new("data-fc-value", iso, None, false))
436 .into_iter()
437 .collect()
438}
439
440pub(crate) fn value_attribute(value: &str) -> Attribute {
444 Attribute::new("data-fc-value", value.to_owned(), None, false)
445}
446pub(crate) fn active_attribute(active: bool) -> Spread {
447 if active {
448 crate::spread![Attribute::new("data-fc-active", "true", None, false)]
449 } else {
450 Spread::default()
451 }
452}
453pub(crate) fn placeholder_attribute(placeholder: bool) -> Spread {
454 if placeholder {
455 crate::spread![Attribute::new("data-fc-placeholder", "true", None, false)]
456 } else {
457 Spread::default()
458 }
459}
460
461pub(crate) fn context_attributes(provider: &str, model: &str, effort: Option<&str>) -> Spread {
464 let mut attrs = crate::spread![
465 Attribute::new("data-fc-provider", provider.to_owned(), None, false),
466 Attribute::new("data-fc-model", model.to_owned(), None, false),
467 ];
468 if let Some(effort) = effort {
470 attrs.push(Attribute::new(
471 "data-fc-effort",
472 effort.to_owned(),
473 None,
474 false,
475 ));
476 }
477 attrs
478}
479pub(crate) fn applicable_attribute(applicable: bool) -> Attribute {
480 Attribute::new("data-fc-applicable", applicable.to_string(), None, false)
481}
482
483pub(crate) fn composer_state_attribute(state: &'static str) -> Attribute {
486 Attribute::new("data-fc-state", state, None, false)
487}
488pub(crate) fn dragging_attribute(dragging: bool) -> Spread {
489 if dragging {
490 crate::spread![Attribute::new("data-fc-dragging", "true", None, false)]
491 } else {
492 Spread::default()
493 }
494}
495pub(crate) fn error_state_attribute(error: bool) -> Spread {
497 if error {
498 crate::spread![Attribute::new("data-fc-state", "error", None, false)]
499 } else {
500 Spread::default()
501 }
502}
503
504pub(crate) fn lang_attribute(lang: &str) -> Attribute {
506 Attribute::new("data-fc-lang", lang.to_owned(), None, false)
507}
508
509pub(crate) fn diff_attribute(kind: &'static str) -> Attribute {
511 Attribute::new("data-fc-diff", kind, None, false)
512}
513
514#[derive(Debug, Clone, Copy)]
517pub(crate) enum GitData {
518 Branch,
519 BranchState,
520 Staged,
521 Unstaged,
522 Conflicted,
523 Untracked,
524 Empty,
525 Path,
526 Selected,
527 Stage,
528 State,
529 Glyph,
530 StagePath,
531}
532impl GitData {
533 const fn name(self) -> &'static str {
534 match self {
535 Self::Branch => "data-fc-branch",
536 Self::BranchState => "data-fc-branch-state",
537 Self::Staged => "data-fc-staged",
538 Self::Unstaged => "data-fc-unstaged",
539 Self::Conflicted => "data-fc-conflicted",
540 Self::Untracked => "data-fc-untracked",
541 Self::Empty => "data-fc-empty",
542 Self::Path => "data-fc-path",
543 Self::Selected => "data-fc-selected",
544 Self::Stage => "data-fc-stage",
545 Self::State => "data-fc-state",
546 Self::Glyph => "data-fc-glyph",
547 Self::StagePath => "data-fc-stage-path",
548 }
549 }
550 pub(crate) fn attribute(self, value: &(impl ToString + ?Sized)) -> Attribute {
551 Attribute::new(self.name(), value.to_string(), None, false)
552 }
553}
554
555#[derive(Debug, Clone, Copy)]
557pub(crate) enum GridData {
558 Active,
560 Dirty,
562 RowCount,
564 ColCount,
566}
567impl GridData {
568 const fn name(self) -> &'static str {
569 match self {
570 Self::Active => "data-fc-active",
571 Self::Dirty => "data-fc-dirty",
572 Self::RowCount => "data-fc-rowcount",
573 Self::ColCount => "data-fc-colcount",
574 }
575 }
576 pub(crate) fn attribute(self, value: &(impl ToString + ?Sized)) -> Attribute {
577 Attribute::new(self.name(), value.to_string(), None, false)
578 }
579}
580
581pub(crate) fn named_state_attribute(value: &'static str) -> Attribute {
585 Attribute::new("data-fc-state", value, None, false)
586}
587
588#[derive(Debug, Clone, Copy)]
591pub(crate) enum ThemePickerData {
592 Design,
593 Palette,
594 Scheme,
595 Contrast,
596}
597impl ThemePickerData {
598 const fn name(self) -> &'static str {
599 match self {
600 Self::Design => "data-fc-design-selection",
601 Self::Palette => "data-fc-palette-selection",
602 Self::Scheme => "data-fc-scheme-selection",
603 Self::Contrast => "data-fc-contrast-selection",
604 }
605 }
606 pub(crate) fn attribute(self, value: &'static str) -> Attribute {
607 Attribute::new(self.name(), value, None, false)
608 }
609}
610
611pub(crate) fn editor_mark_attribute(mark: &'static str) -> Attribute {
613 Attribute::new("data-fc-mark", mark, None, false)
614}
615pub(crate) fn editor_block_attribute(block: &'static str) -> Attribute {
617 Attribute::new("data-fc-block", block, None, false)
618}