use super::{
id::Id,
state::{Lifecycle, MessageRole},
};
use dioxus::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Timestamp(TimestampRepr);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum TimestampRepr {
Millis(u64),
Iso(String),
}
impl Timestamp {
#[must_use]
pub const fn from_epoch_millis(value: u64) -> Self {
Self(TimestampRepr::Millis(value))
}
pub fn iso8601(value: &str) -> Result<Self, InvalidTimestamp> {
let bytes = value.as_bytes();
let digits = |range: std::ops::Range<usize>| -> Option<u32> {
let part = value.get(range)?;
if part.bytes().all(|b| b.is_ascii_digit()) {
part.parse().ok()
} else {
None
}
};
let separators = [(4, b'-'), (7, b'-'), (10, b'T'), (13, b':'), (16, b':')];
let shape = bytes.len() >= 20
&& bytes.last() == Some(&b'Z')
&& separators
.iter()
.all(|(index, byte)| bytes.get(*index) == Some(byte));
let fraction = value.get(19..value.len().saturating_sub(1)).unwrap_or("");
let fraction_ok = fraction.is_empty()
|| (fraction.len() > 1
&& fraction.starts_with('.')
&& fraction.bytes().skip(1).all(|b| b.is_ascii_digit()));
let fields = (
digits(0..4),
digits(5..7),
digits(8..10),
digits(11..13),
digits(14..16),
digits(17..19),
);
match fields {
(Some(_), Some(month), Some(day), Some(hour), Some(minute), Some(second))
if shape
&& fraction_ok
&& (1..=12).contains(&month)
&& (1..=31).contains(&day)
&& hour < 24
&& minute < 60
&& second < 61 =>
{
Ok(Self(TimestampRepr::Iso(value.to_owned())))
}
_ => Err(InvalidTimestamp),
}
}
}
impl std::fmt::Display for Timestamp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.0 {
TimestampRepr::Millis(value) => write!(f, "{value}"),
TimestampRepr::Iso(value) => f.write_str(value),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InvalidTimestamp;
impl std::fmt::Display for InvalidTimestamp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("expected an ISO-8601 UTC timestamp such as 2026-10-09T12:00:00Z")
}
}
impl std::error::Error for InvalidTimestamp {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Kind {
Chat,
Message,
ToolCall,
Reasoning,
AgentStatus,
Approval,
DeviceAuth,
Dialog,
Metric,
Toast,
Transfer,
UserPrompt,
Multiselect,
ThemePicker,
}
impl Kind {
const fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Message => "message",
Self::ToolCall => "tool-call",
Self::Reasoning => "reasoning",
Self::AgentStatus => "agent-status",
Self::Approval => "approval",
Self::DeviceAuth => "device-auth",
Self::Dialog => "dialog",
Self::Metric => "metric",
Self::Toast => "toast",
Self::Transfer => "transfer",
Self::UserPrompt => "user-prompt",
Self::Multiselect => "multiselect",
Self::ThemePicker => "theme-picker",
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Spread(Vec<Attribute>);
impl Spread {
#[must_use]
pub fn into_boxed_slice(mut self) -> Box<[Attribute]> {
self.0.sort_by(|a, b| a.name.cmp(b.name));
self.0.into_boxed_slice()
}
}
impl std::ops::Deref for Spread {
type Target = Vec<Attribute>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for Spread {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl From<Vec<Attribute>> for Spread {
fn from(list: Vec<Attribute>) -> Self {
Self(list)
}
}
impl From<Spread> for Vec<Attribute> {
fn from(list: Spread) -> Self {
list.0
}
}
impl FromIterator<Attribute> for Spread {
fn from_iter<I: IntoIterator<Item = Attribute>>(iter: I) -> Self {
Self(iter.into_iter().collect())
}
}
impl Extend<Attribute> for Spread {
fn extend<I: IntoIterator<Item = Attribute>>(&mut self, iter: I) {
self.0.extend(iter);
}
}
impl IntoIterator for Spread {
type Item = Attribute;
type IntoIter = std::vec::IntoIter<Attribute>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[macro_export]
macro_rules! spread {
($($attribute:expr),* $(,)?) => {
$crate::core::attrs::Spread::from(::std::vec![$($attribute),*])
};
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FcData {
pub kind: Option<Kind>,
pub id: Option<Id>,
pub seq: Option<u64>,
pub role: Option<MessageRole>,
pub state: Option<Lifecycle>,
pub ts: Option<Timestamp>,
}
impl FcData {
#[must_use]
pub fn attributes(&self) -> Spread {
let mut attrs = Spread::default();
for (name, value) in [
("data-fc-kind", self.kind.map(|v| v.as_str().to_owned())),
("data-fc-id", self.id.as_ref().map(ToString::to_string)),
("data-fc-seq", self.seq.map(|v| v.to_string())),
("data-fc-role", self.role.map(|v| v.as_str().to_owned())),
("data-fc-state", self.state.map(|v| v.as_str().to_owned())),
("data-fc-ts", self.ts.as_ref().map(ToString::to_string)),
] {
if let Some(value) = value {
attrs.push(Attribute::new(name, value, None, false));
}
}
attrs
}
}
pub(crate) type ThemePairs = [(&'static str, Option<&'static str>); 4];
pub(crate) fn theme_pairs(
theme: Option<&'static str>,
brand: Option<&'static str>,
design: Option<&'static str>,
palette: Option<&'static str>,
) -> ThemePairs {
[
("data-fc-theme", theme),
("data-fc-brand", brand),
("data-fc-design", design),
("data-fc-palette", palette),
]
}
pub(crate) fn theme_attributes(pairs: &ThemePairs) -> Spread {
let mut attrs = crate::spread![Attribute::new(
"data-fc-version",
super::assets::CSS_VERSION,
None,
false,
)];
for (name, value) in pairs {
if let Some(value) = value {
attrs.push(Attribute::new(name, *value, None, false));
}
}
attrs
}
pub(crate) fn component_attributes(
attributes: Vec<Attribute>,
mut classes: super::class::ClassList,
protected: &[&str],
) -> Spread {
for attr in &attributes {
if attr.name == "class" {
if let dioxus::core::AttributeValue::Text(value) = &attr.value {
classes = classes.with(value);
}
}
}
let mut attributes: Spread = attributes
.into_iter()
.filter(|attr| attr.name != "class" && !protected.contains(&attr.name))
.collect();
attributes.push(Attribute::new("class", classes.to_string(), None, false));
attributes
}
pub(crate) fn availability_attributes(availability: &super::state::Availability) -> Spread {
use super::state::Availability;
match availability {
Availability::Enabled => Spread::default(),
Availability::Disabled => crate::spread![Attribute::new("disabled", true, None, false)],
Availability::Unavailable { reason } => crate::spread![
Attribute::new("aria-disabled", "true", None, false),
Attribute::new("aria-describedby", reason.to_string(), None, false),
],
}
}
pub(crate) fn clipped_attributes(text: &super::text::Clipped) -> Spread {
let mut attributes = truncated_attribute(text);
attributes.extend(full_text_title(text));
attributes
}
pub(crate) fn truncated_attribute(text: &super::text::Clipped) -> Spread {
if text.is_truncated() {
crate::spread![Attribute::new("data-fc-truncated", "true", None, false)]
} else {
Spread::default()
}
}
pub(crate) fn full_text_title(text: &super::text::Clipped) -> Spread {
if text.is_truncated() {
crate::spread![Attribute::new(
"title",
text.full_text().to_owned(),
None,
false,
)]
} else {
Spread::default()
}
}
pub(crate) fn open_attribute(open: Option<bool>) -> Spread {
open.map(|open| Attribute::new("data-fc-open", open.to_string(), None, false))
.into_iter()
.collect()
}
pub(crate) fn leaving_attribute(leaving: bool) -> Spread {
if leaving {
crate::spread![Attribute::new("data-fc-leaving", "true", None, false)]
} else {
Spread::default()
}
}
pub(crate) fn dismissed_attribute(dismissed: bool) -> Spread {
if dismissed {
crate::spread![Attribute::new("data-fc-dismissed", "true", None, false)]
} else {
Spread::default()
}
}
pub(crate) fn carousel_control_attribute(next: bool) -> Attribute {
Attribute::new(
"data-fc-carousel",
if next { "next" } else { "prev" },
None,
false,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MultiselectPart {
Summary,
Filter,
Empty,
SelectAll,
Clear,
}
pub(crate) fn multiselect_part(part: MultiselectPart) -> Attribute {
match part {
MultiselectPart::Summary => Attribute::new("data-fc-selection-summary", "", None, false),
MultiselectPart::Filter => Attribute::new("data-fc-filter", "", None, false),
MultiselectPart::Empty => Attribute::new("data-fc-empty", "", None, false),
MultiselectPart::SelectAll => {
Attribute::new("data-fc-selection-action", "all", None, false)
}
MultiselectPart::Clear => Attribute::new("data-fc-selection-action", "clear", None, false),
}
}
pub(crate) fn selected_attribute(selected: bool) -> Attribute {
Attribute::new("data-fc-selected", selected.to_string(), None, false)
}
pub(crate) fn count_attribute(count: usize) -> Attribute {
Attribute::new("data-fc-count", count.to_string(), None, false)
}
pub(crate) fn date_attribute(iso: &str) -> Attribute {
Attribute::new("data-fc-date", iso.to_owned(), None, false)
}
pub(crate) fn datepicker_nav_attribute(next: bool) -> Attribute {
Attribute::new(
"data-fc-datepicker",
if next { "next" } else { "prev" },
None,
false,
)
}
pub(crate) fn date_value_attribute(iso: Option<String>) -> Spread {
iso.map(|iso| Attribute::new("data-fc-value", iso, None, false))
.into_iter()
.collect()
}
pub(crate) fn value_attribute(value: &str) -> Attribute {
Attribute::new("data-fc-value", value.to_owned(), None, false)
}
pub(crate) fn active_attribute(active: bool) -> Spread {
if active {
crate::spread![Attribute::new("data-fc-active", "true", None, false)]
} else {
Spread::default()
}
}
pub(crate) fn placeholder_attribute(placeholder: bool) -> Spread {
if placeholder {
crate::spread![Attribute::new("data-fc-placeholder", "true", None, false)]
} else {
Spread::default()
}
}
pub(crate) fn context_attributes(provider: &str, model: &str, effort: Option<&str>) -> Spread {
let mut attrs = crate::spread![
Attribute::new("data-fc-provider", provider.to_owned(), None, false),
Attribute::new("data-fc-model", model.to_owned(), None, false),
];
if let Some(effort) = effort {
attrs.push(Attribute::new(
"data-fc-effort",
effort.to_owned(),
None,
false,
));
}
attrs
}
pub(crate) fn applicable_attribute(applicable: bool) -> Attribute {
Attribute::new("data-fc-applicable", applicable.to_string(), None, false)
}
pub(crate) fn composer_state_attribute(state: &'static str) -> Attribute {
Attribute::new("data-fc-state", state, None, false)
}
pub(crate) fn dragging_attribute(dragging: bool) -> Spread {
if dragging {
crate::spread![Attribute::new("data-fc-dragging", "true", None, false)]
} else {
Spread::default()
}
}
pub(crate) fn error_state_attribute(error: bool) -> Spread {
if error {
crate::spread![Attribute::new("data-fc-state", "error", None, false)]
} else {
Spread::default()
}
}
pub(crate) fn lang_attribute(lang: &str) -> Attribute {
Attribute::new("data-fc-lang", lang.to_owned(), None, false)
}
pub(crate) fn diff_attribute(kind: &'static str) -> Attribute {
Attribute::new("data-fc-diff", kind, None, false)
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum GitData {
Branch,
BranchState,
Staged,
Unstaged,
Conflicted,
Untracked,
Empty,
Path,
Selected,
Stage,
State,
Glyph,
StagePath,
}
impl GitData {
const fn name(self) -> &'static str {
match self {
Self::Branch => "data-fc-branch",
Self::BranchState => "data-fc-branch-state",
Self::Staged => "data-fc-staged",
Self::Unstaged => "data-fc-unstaged",
Self::Conflicted => "data-fc-conflicted",
Self::Untracked => "data-fc-untracked",
Self::Empty => "data-fc-empty",
Self::Path => "data-fc-path",
Self::Selected => "data-fc-selected",
Self::Stage => "data-fc-stage",
Self::State => "data-fc-state",
Self::Glyph => "data-fc-glyph",
Self::StagePath => "data-fc-stage-path",
}
}
pub(crate) fn attribute(self, value: &(impl ToString + ?Sized)) -> Attribute {
Attribute::new(self.name(), value.to_string(), None, false)
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum GridData {
Active,
Dirty,
RowCount,
ColCount,
}
impl GridData {
const fn name(self) -> &'static str {
match self {
Self::Active => "data-fc-active",
Self::Dirty => "data-fc-dirty",
Self::RowCount => "data-fc-rowcount",
Self::ColCount => "data-fc-colcount",
}
}
pub(crate) fn attribute(self, value: &(impl ToString + ?Sized)) -> Attribute {
Attribute::new(self.name(), value.to_string(), None, false)
}
}
pub(crate) fn named_state_attribute(value: &'static str) -> Attribute {
Attribute::new("data-fc-state", value, None, false)
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ThemePickerData {
Design,
Palette,
Scheme,
Contrast,
}
impl ThemePickerData {
const fn name(self) -> &'static str {
match self {
Self::Design => "data-fc-design-selection",
Self::Palette => "data-fc-palette-selection",
Self::Scheme => "data-fc-scheme-selection",
Self::Contrast => "data-fc-contrast-selection",
}
}
pub(crate) fn attribute(self, value: &'static str) -> Attribute {
Attribute::new(self.name(), value, None, false)
}
}
pub(crate) fn editor_mark_attribute(mark: &'static str) -> Attribute {
Attribute::new("data-fc-mark", mark, None, false)
}
pub(crate) fn editor_block_attribute(block: &'static str) -> Attribute {
Attribute::new("data-fc-block", block, None, false)
}