Skip to main content

fc_dioxus/core/
attrs.rs

1//! Typed attributes for upstream's dual-audience contract.
2
3use super::{
4    id::Id,
5    state::{Lifecycle, MessageRole},
6};
7use dioxus::prelude::*;
8
9/// A timestamp for `data-fc-ts`: epoch milliseconds, or a validated ISO-8601 UTC instant.
10/// Arbitrary strings cannot enter the DOM contract.
11///
12/// ```
13/// use fc_dioxus::prelude::Timestamp;
14/// assert!(Timestamp::iso8601("2026-10-09T12:00:00Z").is_ok());
15/// assert!(Timestamp::iso8601("2026-10-09T12:00:00.250Z").is_ok());
16/// assert!(Timestamp::iso8601("yesterday").is_err());
17/// assert!(Timestamp::iso8601("2026-13-09T12:00:00Z").is_err());
18/// ```
19#[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    /// Construct an epoch-millisecond timestamp.
28    #[must_use]
29    pub const fn from_epoch_millis(value: u64) -> Self {
30        Self(TimestampRepr::Millis(value))
31    }
32    /// Accept `YYYY-MM-DDTHH:MM:SS[.fraction]Z` with in-range fields.
33    ///
34    /// # Errors
35    /// Returns `InvalidTimestamp` for anything else, including offsets other than `Z`.
36    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/// The text is not an ISO-8601 UTC instant.
90#[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/// Only component kinds defined upstream can be emitted.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101#[non_exhaustive]
102pub enum Kind {
103    /// A chat surface.
104    Chat,
105    /// A message.
106    Message,
107    /// A tool call.
108    ToolCall,
109    /// A reasoning trace.
110    Reasoning,
111    /// An agent's live status row.
112    AgentStatus,
113    /// A request for the user's approval.
114    Approval,
115    /// A device authorization code.
116    DeviceAuth,
117    /// A dialog.
118    Dialog,
119    /// A metric.
120    Metric,
121    /// A toast notification.
122    Toast,
123    /// A file transfer.
124    Transfer,
125    /// A question put to the user.
126    UserPrompt,
127    /// A multiple-choice picker.
128    Multiselect,
129    /// The theme picker.
130    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/// Attributes headed for an rsx spread (`..list`), kept sorted by name on the way in.
154///
155/// Dioxus diffs a spread as a merge of two name-sorted lists, but passes a spread
156/// through as given. An unsorted list mis-diffs whenever its membership changes: an
157/// icon button that became enabled lost `aria-label` along with `disabled`, because
158/// the merge walked past it. rsx calls `into_boxed_slice` on every spread, so sorting
159/// there covers every list built from this type.
160#[derive(Debug, Clone, Default, PartialEq)]
161pub struct Spread(Vec<Attribute>);
162impl Spread {
163    /// The list as rsx consumes it: sorted by name, stably, so a later duplicate still
164    /// follows an earlier one.
165    #[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/// A [`Spread`] from attributes, as `vec!` makes a `Vec`.
211#[macro_export]
212macro_rules! spread {
213    ($($attribute:expr),* $(,)?) => {
214        $crate::core::attrs::Spread::from(::std::vec![$($attribute),*])
215    };
216}
217
218/// Machine-readable state. There are no free-form payload fields.
219#[derive(Debug, Clone, Default, PartialEq, Eq)]
220pub struct FcData {
221    /// Component kind.
222    pub kind: Option<Kind>,
223    /// A stable runtime identifier; never use credentials as identifiers.
224    pub id: Option<Id>,
225    /// Monotonic ordering integer.
226    pub seq: Option<u64>,
227    /// Message author role.
228    pub role: Option<MessageRole>,
229    /// Lifecycle state.
230    pub state: Option<Lifecycle>,
231    /// Epoch-millisecond timestamp.
232    pub ts: Option<Timestamp>,
233}
234impl FcData {
235    /// Render only present values; absence is not serialized as an empty string.
236    #[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
255/// A root's theme attributes by name, absent ones included so a mirror can clear them.
256pub(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
287// Consumer classes are additive; native semantics and state remain component-owned.
288pub(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
320/// Declare truncation and keep the full value reachable, as upstream's contract requires.
321pub(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
327/// `data-fc-truncated`, for the element that represents the whole record.
328pub(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
336/// The full value as a `title`, for the element showing the shortened text.
337pub(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
350/// Hold a disclosure-like region open or closed regardless of focus; absent leaves it to
351/// the CSS (`data-fc-open`).
352pub(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
358/// Mark an element that is animating out before removal (`data-fc-leaving`).
359pub(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
367/// A tooltip dismissed with Escape until the pointer or focus leaves (`data-fc-dismissed`).
368pub(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
376/// Which way a carousel control pages (`data-fc-carousel`).
377pub(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/// Markers upstream's multiselect markup uses to find its parts (`data-fc-*` flags).
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub(crate) enum MultiselectPart {
389    /// The live summary of what is chosen.
390    Summary,
391    /// The filter input.
392    Filter,
393    /// The "no matches" message.
394    Empty,
395    /// "Select visible".
396    SelectAll,
397    /// "Clear".
398    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
412/// Whether an option is chosen (`data-fc-selected`), and how many are (`data-fc-count`).
413pub(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
420/// A date picker's parts: the day a button stands for (`data-fc-date`), which way a month
421/// button pages (`data-fc-datepicker`), and the selection mirrored on the root
422/// (`data-fc-value`). Dates are ISO strings, never free text.
423pub(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
440/// A select's machine-readable parts: the value an invoker or option stands for
441/// (`data-fc-value`), the highlighted option (`data-fc-active`), and a placeholder shown
442/// in place of a value (`data-fc-placeholder`). Values are app identifiers, not user text.
443pub(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
461/// The context strip's resolved state on its root (`data-fc-provider`, `-model`,
462/// `-effort`), and whether a field applies (`data-fc-applicable`). Ids, not user text.
463pub(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    // Absent rather than empty: empty would claim effort is set to nothing.
469    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
483/// A composer's own lifecycle (`data-fc-state` of `idle`, `ready`, or `running`), and
484/// whether a file is being dragged over it (`data-fc-dragging`).
485pub(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}
495/// An item that failed (`data-fc-state="error"`), e.g. an attachment that could not be read.
496pub(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
504/// The language a code block holds (`data-fc-lang`), e.g. `rust`.
505pub(crate) fn lang_attribute(lang: &str) -> Attribute {
506    Attribute::new("data-fc-lang", lang.to_owned(), None, false)
507}
508
509/// A diff line's kind (`data-fc-diff` of `add` or `remove`); unchanged lines carry none.
510pub(crate) fn diff_attribute(kind: &'static str) -> Attribute {
511    Attribute::new("data-fc-diff", kind, None, false)
512}
513
514/// The git surface's machine-readable state. Paths and branch names are repository
515/// data the application already shows; nothing here is secret.
516#[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/// The data grid's machine-readable state.
556#[derive(Debug, Clone, Copy)]
557pub(crate) enum GridData {
558    /// The cell typing goes to.
559    Active,
560    /// Edited and not yet saved.
561    Dirty,
562    /// The true row count, which a virtualized grid renders only part of.
563    RowCount,
564    /// The true column count.
565    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
581/// `data-fc-state` in a component's own upstream vocabulary rather than the shared
582/// lifecycle: a terminal's `connected` and `disconnected`, a webview's `idle`,
583/// `loading`, `loaded`, and `error`, or an address bar's `insecure`.
584pub(crate) fn named_state_attribute(value: &'static str) -> Attribute {
585    Attribute::new("data-fc-state", value, None, false)
586}
587
588/// The theme picker's selections, mirrored so automation can read what is chosen without
589/// parsing labels.
590#[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
611/// A formatting control's command, read by upstream's editor binding.
612pub(crate) fn editor_mark_attribute(mark: &'static str) -> Attribute {
613    Attribute::new("data-fc-mark", mark, None, false)
614}
615/// A block control's command, read by upstream's editor binding.
616pub(crate) fn editor_block_attribute(block: &'static str) -> Attribute {
617    Attribute::new("data-fc-block", block, None, false)
618}