facett-card/src/lib.rs
//! **facett-card** — a YouTrack / Jira-style **record card**: a titled record
//! with a **typed field grid** ([`CardField`]) and **nestable sub-cards**
//! ([`Card::children`], each in a collapsible header, recursively — a parent
//! issue expands into its sub-tasks). A [`CardView`] is the [`Facet`] driver: it
//! renders the card read-only (theme-aware chips / bars / hyperlinks per field
//! kind), toggles into an **edit form** that *reuses* [`facett_form`] rather than
//! re-implementing inputs, and serializes the whole card (incl. children) as
//! pretty JSON for `copy()`.
//!
//! ## Why edit-mode reuses facett-form
//! A card in edit mode is just a *bound form* over the card's own fields. We make
//! the card itself the [`FormModel`]: [`Card::fields`] lays each [`CardField`]
//! into the [`FormUi`] field-builder with the right widget for its [`FieldKind`]
//! (text / number / select / date / …), and [`Card::from_clip`]/[`Card::to_clip`]
//! round-trip the card as JSON. The hard parts (labels, per-field outlines, the
//! Save/Reset bar, dirty-tracking, clipboard) are facett-form's — we don't
//! re-solve them. Sub-cards stay read-mode; expanding one and toggling *its* edit
//! is the same recursion.
use egui::Ui;
use facett_core::clip::{ClipKind, ClipPayload, CopySource, PasteTarget};
use facett_core::{Facet, FacetCaps, Semantics, theme};
use facett_form::{FormModel, FormUi, FormView, ValidationReport};
use serde::{Deserialize, Serialize};
/// A field's typed value. Kept deliberately small + serde-friendly so a whole
/// [`Card`] (incl. children) round-trips through JSON for copy/paste + edit.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum FieldValue {
Text(String),
Number(f64),
/// A single state/enum token (e.g. `"open"`), plus the choosable set so edit
/// mode can render a closed `ComboBox`.
Enum { state: String, options: Vec<String> },
/// A user/assignee — a display name (avatar is an initial).
User(String),
/// A `YYYY-MM-DD` date string (masked text in edit mode).
Date(String),
/// A list of tag tokens, rendered as chips.
Tags(Vec<String>),
/// A hyperlink: a label + a URL.
Link { label: String, url: String },
/// A 0.0–1.0 progress fraction, rendered as a bar.
Progress(f32),
}
impl FieldValue {
/// The kind this value renders as (drives the read view + edit widget).
pub fn kind(&self) -> FieldKind {
match self {
FieldValue::Text(_) => FieldKind::Text,
FieldValue::Number(_) => FieldKind::Number,
FieldValue::Enum { .. } => FieldKind::Enum,
FieldValue::User(_) => FieldKind::User,
FieldValue::Date(_) => FieldKind::Date,
FieldValue::Tags(_) => FieldKind::Tags,
FieldValue::Link { .. } => FieldKind::Link,
FieldValue::Progress(_) => FieldKind::Progress,
}
}
/// A compact display string (for `state_json` and the avatar initial).
pub fn display(&self) -> String {
match self {
FieldValue::Text(s) | FieldValue::User(s) | FieldValue::Date(s) => s.clone(),
FieldValue::Number(n) => trim_float(*n),
FieldValue::Enum { state, .. } => state.clone(),
FieldValue::Tags(t) => t.join(", "),
FieldValue::Link { label, .. } => label.clone(),
FieldValue::Progress(p) => format!("{:.0}%", (p.clamp(0.0, 1.0)) * 100.0),
}
}
}
/// The render/edit kind of a field — what widget the read view paints and what
/// input the edit form swaps in. Mirrors the variants of [`FieldValue`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldKind {
Text,
Number,
Enum,
User,
Date,
Tags,
Link,
Progress,
}
impl FieldKind {
pub fn as_str(self) -> &'static str {
match self {
FieldKind::Text => "text",
FieldKind::Number => "number",
FieldKind::Enum => "enum",
FieldKind::User => "user",
FieldKind::Date => "date",
FieldKind::Tags => "tags",
FieldKind::Link => "link",
FieldKind::Progress => "progress",
}
}
}
/// One row of a card's field grid: a `label` + a typed `value`. `kind` is derived
/// from the value (kept as a field so it survives serde + can be asserted).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CardField {
pub label: String,
pub value: FieldValue,
pub kind: FieldKind,
}
impl CardField {
/// Build a field; the `kind` is inferred from the value.
pub fn new(label: impl Into<String>, value: FieldValue) -> Self {
let kind = value.kind();
Self { label: label.into(), value, kind }
}
/// A stable, slug-ish id for the form/validation report (label, lowercased).
fn id(&self) -> String {
self.label.to_ascii_lowercase().replace([' ', '/', '(', ')'], "_")
}
/// Render this field's *value* read-only into `ui`, theme-aware per kind.
fn show_read(&self, ui: &mut Ui) {
let th = theme(ui);
match &self.value {
FieldValue::Text(s) => {
ui.label(egui::RichText::new(s).color(th.text));
}
FieldValue::Number(n) => {
ui.label(egui::RichText::new(trim_float(*n)).color(th.text).monospace());
}
FieldValue::Enum { state, options } => {
// The enum chip carries how many states it can take as its value,
// so the AccessKit node announces both the current state (label)
// and the size of its option set (numeric_value).
chip(ui, state, th.accent, th.glow, Some(options.len() as f64))
}
FieldValue::User(name) => {
ui.horizontal(|ui| {
avatar(ui, name, th.accent);
ui.label(egui::RichText::new(name).color(th.text));
});
}
FieldValue::Date(d) => {
ui.label(egui::RichText::new(d).color(th.text_dim).monospace());
}
FieldValue::Tags(tags) => {
ui.horizontal_wrapped(|ui| {
for t in tags {
chip(ui, t, th.point, th.point, None);
}
});
}
FieldValue::Link { label, url } => {
ui.hyperlink_to(egui::RichText::new(label).color(th.accent), url);
}
FieldValue::Progress(p) => {
let frac = p.clamp(0.0, 1.0);
ui.add(egui::ProgressBar::new(frac).desired_width(140.0).text(format!("{:.0}%", frac * 100.0)));
}
}
}
/// Lay this field into an edit form (reusing facett-form's field-builders).
fn build_edit(&mut self, f: &mut FormUi<'_>) {
let id = self.id();
match &mut self.value {
FieldValue::Text(s) => f.text(&id, &self.label, s),
FieldValue::Number(n) => {
// Edit as f64 via a scratch (DragValue<i64/f64>); facett-form has f64.
f.number_f64(&id, &self.label, n, None);
}
FieldValue::Enum { state, options } => {
let opts: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
f.select(&id, &self.label, state, &opts);
}
FieldValue::User(name) => f.text(&id, &self.label, name),
FieldValue::Date(d) => f.date(&id, &self.label, d),
FieldValue::Tags(tags) => {
// Edit the tag list as a comma-joined string, split back on commit.
let mut joined = tags.join(", ");
f.text(&id, &self.label, &mut joined);
*tags = joined.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
}
FieldValue::Link { label, url } => {
f.text(&format!("{id}_label"), &format!("{} (label)", self.label), label);
f.text(&format!("{id}_url"), &format!("{} (url)", self.label), url);
}
FieldValue::Progress(p) => {
// Edit progress as a 0–100 integer percent.
let mut pct = (p.clamp(0.0, 1.0) * 100.0).round() as i64;
f.number_i64(&id, &format!("{} (%)", self.label), &mut pct, Some(0..=100));
*p = (pct as f32 / 100.0).clamp(0.0, 1.0);
}
}
}
/// This field as JSON for `state_json` (label/kind/display value).
fn to_state_json(&self) -> serde_json::Value {
serde_json::json!({
"label": self.label,
"kind": self.kind.as_str(),
"value": self.value.display(),
})
}
}
/// A titled record: an optional `id`/`title`, a `fields` grid, and nestable
/// `children` sub-cards (each rendered in a collapsible header, recursively).
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Card {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default)]
pub fields: Vec<CardField>,
#[serde(default)]
pub children: Vec<Card>,
}
impl Card {
/// A card with a title (no id, no fields, no children yet).
pub fn new(title: impl Into<String>) -> Self {
Self { title: Some(title.into()), ..Default::default() }
}
/// Builder: set the id.
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
/// Builder: push a field.
pub fn field(mut self, field: CardField) -> Self {
self.fields.push(field);
self
}
/// Builder: push a sub-card.
pub fn child(mut self, child: Card) -> Self {
self.children.push(child);
self
}
/// Total descendant count (children + their children, recursively).
pub fn descendant_count(&self) -> usize {
self.children.iter().map(|c| 1 + c.descendant_count()).sum()
}
/// The card's title for display (falls back to id, then `"(untitled)"`).
fn display_title(&self) -> String {
self.title
.clone()
.or_else(|| self.id.clone())
.unwrap_or_else(|| "(untitled)".to_string())
}
/// Render the field grid read-only (label column + themed value).
fn show_fields_read(&self, ui: &mut Ui, salt: &str) {
if self.fields.is_empty() {
return;
}
let th = theme(ui);
egui::Grid::new(format!("facett_card_grid_{salt}"))
.num_columns(2)
.spacing([16.0, 6.0])
.striped(true)
.show(ui, |ui| {
for field in &self.fields {
ui.label(egui::RichText::new(&field.label).color(th.text_dim).strong());
field.show_read(ui);
ui.end_row();
}
});
}
/// Render this card read-only: title, fields, then each child in a
/// `CollapsingHeader` (recursively). `salt` keeps egui ids unique per nesting.
fn show_read(&self, ui: &mut Ui, salt: &str) {
let th = theme(ui);
if self.title.is_some() || self.id.is_some() {
ui.horizontal(|ui| {
ui.heading(egui::RichText::new(self.display_title()).color(th.text));
if let Some(id) = &self.id {
ui.label(egui::RichText::new(format!("#{id}")).color(th.text_dim).monospace());
}
});
}
self.show_fields_read(ui, salt);
for (i, child) in self.children.iter().enumerate() {
let child_salt = format!("{salt}_{i}");
egui::CollapsingHeader::new(egui::RichText::new(child.display_title()).color(th.accent))
.id_salt(format!("facett_card_child_{child_salt}"))
.default_open(false)
.show(ui, |ui| child.show_read(ui, &child_salt));
}
}
}
// `Card` is the FormModel for its own edit mode — a card-edit is a bound form.
impl FormModel for Card {
fn fields(&mut self, f: &mut FormUi<'_>) {
f.section(&self.display_title(), |f| {
for field in &mut self.fields {
field.build_edit(f);
}
});
}
fn validate(&self) -> ValidationReport {
// The card has no hard field rules of its own; consumers can subclass by
// wrapping. Always-clean keeps the Save/Reset bar live for editing.
ValidationReport::ok()
}
fn to_clip(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_default()
}
fn from_clip(&mut self, text: &str) -> bool {
serde_json::from_str::<Card>(text).map(|c| *self = c).is_ok()
}
}
/// **The Model (FC-1 / FC-3).** All of a [`CardView`]'s observable state in one
/// serializable, round-trippable struct: the live `card`, the edit-mode `baseline`
/// (the clean card that dirty-tracking / the form's *Reset* button revert to), the
/// read/`editing` flag, and the uniform `scale`. `card`/`baseline` are separate so
/// the form's unsaved-marker survives across frames even though the pure `view`
/// rebuilds the transient [`FormView`] buffer each frame (see [`CardView::view`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CardState {
/// The live card (reflects in-progress edits).
pub card: Card,
/// The clean card the edit form's dirty-marker / Reset revert to.
#[serde(default)]
pub baseline: Card,
/// Read (false) vs edit (true) mode.
#[serde(default)]
pub editing: bool,
/// Uniform scale (1.0 = native); drives the read-view text size.
#[serde(default = "default_scale")]
pub scale: f32,
}
fn default_scale() -> f32 {
1.0
}
/// **Every input the card accepts (FC-2).** The *only* legal way to mutate
/// [`CardState`] — funneled through [`CardView::update`].
#[derive(Clone, Debug)]
pub enum Msg {
/// Flip read ⇄ edit mode (the "Edit" / "Done editing" toggle).
ToggleEdit,
/// Set edit mode explicitly (programmatic / headless entry point).
SetEditing(bool),
/// Set the uniform scale (clamped to `0.25..=4.0` in `update`).
SetScale(f32),
/// Apply the edit form's result: the whole edited card (baseline preserved, so
/// dirty-tracking keeps working). Emitted by the pure `view` in edit mode.
EditCard(Card),
/// Commit the current edits: the *Save* button (`baseline = card`).
Save,
/// Wholesale replace the card (paste): `card` **and** `baseline` adopt it, so a
/// pasted card starts clean.
SetCard(Card),
}
/// **Side work as data (FC-8).** A card does no I/O — the uninhabited enum is the
/// honest statement of that.
#[derive(Clone, Debug)]
pub enum Effect {}
/// The [`Facet`] driver: a [`Card`] + ui state (scale, read/edit mode) as a single
/// [`Elm`](facett_core::Elm) component. Read mode paints the themed card; edit mode
/// renders a transient [`FormView<Card>`] over the card's own fields (children stay
/// read-mode). `copy()` emits the whole card as JSON.
pub struct CardView {
/// Tab / heading label.
pub title: String,
/// All observable state (FC-3).
state: CardState,
}
impl CardView {
/// New view over `card`, titled `title`, starting in read mode (and clean:
/// `baseline == card`).
pub fn new(title: impl Into<String>, card: Card) -> Self {
let title = title.into();
let state = CardState { baseline: card.clone(), card, editing: false, scale: 1.0 };
Self { title, state }
}
/// The live card (reflects in-progress edits).
pub fn card(&self) -> &Card {
&self.state.card
}
/// Whether the view is currently in edit mode.
pub fn is_editing(&self) -> bool {
self.state.editing
}
/// Switch read/edit mode programmatically (headless-test entry point).
pub fn set_editing(&mut self, editing: bool) {
let _ = self.update(Msg::SetEditing(editing));
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; a card does no
/// I/O, so the returned [`Effect`] vec is always empty.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::ToggleEdit => self.state.editing = !self.state.editing,
Msg::SetEditing(b) => self.state.editing = b,
Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
Msg::EditCard(c) => self.state.card = c,
Msg::Save => self.state.baseline = self.state.card.clone(),
Msg::SetCard(c) => {
self.state.baseline = c.clone();
self.state.card = c;
}
}
Vec::new()
}
/// **FC-9 / FC-4** — a **pure** function of `&self`: it paints and *returns* the
/// [`Msg`]s it produced, mutating neither the model nor any I/O.
///
/// Edit mode is the interesting case: `FormView::ui` mutates its model in place,
/// so calling it on `self.form` would break purity. Instead we build a **local,
/// throw-away** `FormView` seeded so `baseline == self.state.baseline` and
/// `model == self.state.card` — then run it. All mutation lands on that local
/// buffer; the only thing that escapes `&self` is the resulting card as an
/// `EditCard` (and a `Save` when the form's Save button fires). egui's own
/// per-widget state (text cursors, …) persists via egui memory keyed on the
/// stable field ids, so rebuilding the buffer each frame is seamless.
pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
let mut msgs = Vec::new();
let editing = self.state.editing;
// Mode toggle bar.
ui.horizontal(|ui| {
let label = if editing { "Done editing" } else { "Edit" };
if ui.selectable_label(editing, label).clicked() {
msgs.push(Msg::ToggleEdit);
}
let th = theme(ui);
ui.label(
egui::RichText::new(if editing { "edit" } else { "read" })
.color(th.text_dim)
.small(),
);
});
ui.separator();
if editing {
// Edit mode: render the card's own fields as a bound facett-form over a
// LOCAL buffer (baseline = clean, model = current) — pure w.r.t. `self`.
let mut form = FormView::new(self.title.clone(), self.state.baseline.clone());
*form.model_mut() = self.state.card.clone();
form.ui(ui);
let edited = form.model().clone();
if edited != self.state.card {
// Covers both live edits and a Reset click (edited == baseline).
msgs.push(Msg::EditCard(edited));
}
if form.take_submitted().is_some() {
msgs.push(Msg::Save);
}
} else {
// Read mode: themed card + nested sub-cards, scaled.
let prev = ui.style().text_styles.clone();
if (self.state.scale - 1.0).abs() > f32::EPSILON {
ui.style_mut().text_styles.iter_mut().for_each(|(_, font)| {
font.size *= self.state.scale;
});
}
self.state.card.show_read(ui, "root");
ui.style_mut().text_styles = prev;
}
// ── render-lane emit: this pure-view path RAN ─────────────────────────
#[cfg(feature = "testmatrix")]
{
let card = &self.state.card;
facett_core::testmatrix::emit(
"facett-card::CardView::view",
"ui_render",
// OK = a read-mode card has content to paint (fields or children),
// or it is in edit mode (the bound form always renders its section).
editing || !card.fields.is_empty() || !card.children.is_empty(),
&format!(
"editing={} fields={} children={} descendants={}",
editing,
card.fields.len(),
card.children.len(),
card.descendant_count(),
),
);
}
msgs
}
}
// ── typed copy/paste (§16) — the whole card as JSON Text ──────────────────────
impl CopySource for CardView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
let s = serde_json::to_string_pretty(&self.state.card).ok()?;
(!s.is_empty()).then_some(ClipPayload::Text(s))
}
}
impl PasteTarget for CardView {
fn accepts(&self, k: ClipKind) -> bool {
matches!(k, ClipKind::Text)
}
fn paste_payload(&mut self, p: &ClipPayload) {
if let Ok(c) = serde_json::from_str::<Card>(&p.as_text()) {
let _ = self.update(Msg::SetCard(c));
}
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for CardView {
type Model = CardState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &CardState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
CardView::update(self, msg)
}
fn view(&self, ui: &mut Ui) -> Vec<Msg> {
CardView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for CardView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`), plus the splice below.
// **Form 3** (`custom_state_json`) because the card publishes a **richer**
// `state_json` than plain `serde(state())`: a structural projection of the card
// (id / title / per-field label·kind·display-value / recursive children with
// counts) — NOT the raw model (which carries the edit `baseline` + option sets).
// This preserves the exact observable state_json the demo mega-matrix reads.
facett_core::impl_facet_via_elm!(CardView, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"title": self.title,
"editing": self.state.editing,
"scale": self.state.scale,
"card": card_state_json(&self.state.card),
})
}
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.selectable().copyable().pasteable().themeable().scalable()
}
fn scale(&self) -> f32 {
self.state.scale
}
fn set_scale(&mut self, scale: f32) {
let _ = self.update(Msg::SetScale(scale));
}
fn selection_json(&self) -> serde_json::Value {
// The whole card is the "selection" for clipboard purposes.
card_state_json(&self.state.card)
}
/// Facet-level copy: the whole card (incl. nested children) as pretty JSON.
fn copy(&mut self) -> Option<String> {
let s = serde_json::to_string_pretty(&self.state.card).ok()?;
if s.is_empty() { None } else { Some(s) }
}
/// Facet-level paste: replace the card from pasted JSON (via `update`).
fn paste(&mut self, text: &str) -> bool {
match serde_json::from_str::<Card>(text) {
Ok(c) => {
let _ = self.update(Msg::SetCard(c));
true
}
Err(_) => false,
}
}
});
/// Structural JSON for a card: id, title, fields (label/kind/value), and the
/// children recursively (with a `children_count` for quick assertions).
fn card_state_json(card: &Card) -> serde_json::Value {
serde_json::json!({
"id": card.id,
"title": card.title,
"fields": card.fields.iter().map(CardField::to_state_json).collect::<Vec<_>>(),
"children_count": card.children.len(),
"descendant_count": card.descendant_count(),
"children": card.children.iter().map(card_state_json).collect::<Vec<_>>(),
})
}
/// A tinted "chip" — a rounded, accent-tinted pill (status/enum + tags). The fill
/// is the glow at low alpha, stroked with the base colour; theme-aware.
///
/// FC-4: the chip is custom-painted, so on its own it emits no AccessKit node.
/// We allocate a labelled node (role `Label`) over the chip's rect, keyed on the
/// chip's *text* (a stable domain key, not a loop index — FC-5), so a screen
/// reader / `get_by_label` can find it. `count` (e.g. an enum's option-set size)
/// rides along as the AccessKit `numeric_value` when present.
fn chip(ui: &mut Ui, text: &str, base: egui::Color32, glow: egui::Color32, count: Option<f64>) {
let th = theme(ui);
let galley = ui.painter().layout_no_wrap(
text.to_string(),
egui::FontId::proportional(12.0),
th.text,
);
let pad = egui::vec2(8.0, 3.0);
let (rect, _) = ui.allocate_exact_size(galley.size() + pad * 2.0, egui::Sense::hover());
let fill = egui::Color32::from_rgba_unmultiplied(glow.r(), glow.g(), glow.b(), 40);
let cr = egui::CornerRadius::same(6);
ui.painter().rect_filled(rect, cr, fill);
ui.painter().rect_stroke(rect, cr, egui::Stroke::new(1.0_f32, base), egui::StrokeKind::Inside);
ui.painter().galley(rect.min + pad, galley, th.text);
// The value-bearing AccessKit node over the painted pill.
let mut sem = Semantics::new(egui::WidgetType::Label, text);
if let Some(c) = count {
sem = sem.value(c);
}
let base_id = ui.id();
let _ = facett_core::a11y::node(ui, base_id, ("card-chip", text), egui::Sense::hover(), rect, sem);
}
/// A small round avatar: a filled disc with the name's first letter, accent-tinted.
///
/// FC-4: the disc + initial are painter-only, so we allocate a labelled
/// AccessKit node (role `Label`, label = the full name) over the avatar's rect,
/// keyed on the name (stable domain key, not a loop index — FC-5). This makes
/// the avatar findable / announceable even though only an initial is drawn.
fn avatar(ui: &mut Ui, name: &str, accent: egui::Color32) {
let r = 9.0;
let (rect, _) = ui.allocate_exact_size(egui::vec2(r * 2.0, r * 2.0), egui::Sense::hover());
let c = rect.center();
let fill = egui::Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 60);
ui.painter().circle_filled(c, r, fill);
ui.painter().circle_stroke(c, r, egui::Stroke::new(1.0_f32, accent));
let initial = name.chars().next().unwrap_or('?').to_uppercase().to_string();
ui.painter().text(c, egui::Align2::CENTER_CENTER, initial, egui::FontId::proportional(11.0), accent);
// The avatar's AccessKit node carries the *full* name (not just the initial).
let base_id = ui.id();
let sem = Semantics::new(egui::WidgetType::Label, name);
let _ = facett_core::a11y::node(ui, base_id, ("card-avatar", name), egui::Sense::hover(), rect, sem);
}
/// Render a float without a trailing `.0` for whole numbers.
fn trim_float(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", n as i64)
} else {
format!("{n}")
}
}
#[cfg(test)]
mod tests;