aetna-core 0.3.3

Aetna — backend-agnostic UI library core
Documentation
//! Item anatomy — shadcn-shaped object rows with media, content, and actions.
//!
//! Use `item(...)` for clickable rows that display an object rather than
//! a command: recent repositories, files, people, projects, notifications,
//! search results, and settings shortcuts. It mirrors shadcn/ui's `Item`
//! vocabulary (`ItemGroup`, `ItemMedia`, `ItemContent`, `ItemTitle`,
//! `ItemDescription`, `ItemActions`) so app authors and LLMs have a
//! familiar name to reach for instead of building raw focusable rows.

use std::panic::Location;

use crate::anim::Timing;
use crate::cursor::Cursor;
use crate::metrics::MetricsRole;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::separator::separator;
use crate::widgets::text::text;
use crate::{IntoIconSource, icon};

#[track_caller]
pub fn item_group<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    El::new(Kind::Custom("item_group"))
        .at_loc(Location::caller())
        .children(children)
        .axis(Axis::Column)
        .align(Align::Stretch)
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .default_gap(tokens::SPACE_1)
}

#[track_caller]
pub fn item<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    let rail = El::new(Kind::Custom("item_rail"))
        .fill(tokens::PRIMARY)
        .default_radius(tokens::RADIUS_PILL)
        .width(Size::Fixed(3.0))
        .height(Size::Fill(1.0))
        .opacity(0.0)
        .animate(Timing::SPRING_QUICK);

    let content = row(children)
        .at_loc(Location::caller())
        .align(Align::Center)
        .justify(Justify::Start)
        .default_gap(tokens::SPACE_3)
        .default_padding(Sides::xy(tokens::SPACE_3, tokens::SPACE_2))
        .width(Size::Fill(1.0))
        .height(Size::Hug);

    El::new(Kind::Custom("item"))
        .at_loc(Location::caller())
        .style_profile(StyleProfile::Surface)
        .metrics_role(MetricsRole::ListItem)
        .focusable()
        .paint_overflow(Sides::all(tokens::RING_WIDTH))
        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
        .cursor(Cursor::Pointer)
        .children([rail, content])
        .axis(Axis::Overlay)
        .align(Align::Stretch)
        .justify(Justify::Start)
        .default_radius(tokens::RADIUS_MD)
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .animate(Timing::SPRING_QUICK)
}

#[track_caller]
pub fn item_header<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    row(children)
        .at_loc(Location::caller())
        .align(Align::Center)
        .width(Size::Fill(1.0))
        .height(Size::Hug)
}

#[track_caller]
pub fn item_footer<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    row(children)
        .at_loc(Location::caller())
        .align(Align::Center)
        .width(Size::Fill(1.0))
        .height(Size::Hug)
}

#[track_caller]
pub fn item_media<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    El::new(Kind::Custom("item_media"))
        .at_loc(Location::caller())
        .style_profile(StyleProfile::Surface)
        .children(children)
        .axis(Axis::Overlay)
        .align(Align::Center)
        .justify(Justify::Center)
        .fill(tokens::MUTED)
        .stroke(tokens::BORDER)
        .default_radius(tokens::RADIUS_SM)
        .width(Size::Fixed(32.0))
        .height(Size::Fixed(32.0))
}

#[track_caller]
pub fn item_media_icon(source: impl IntoIconSource) -> El {
    item_media([icon(source)
        .icon_size(tokens::ICON_SM)
        .color(tokens::MUTED_FOREGROUND)])
    .at_loc(Location::caller())
}

#[track_caller]
pub fn item_content<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    column(children)
        .at_loc(Location::caller())
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .default_gap(0.0)
}

#[track_caller]
pub fn item_title(title: impl Into<String>) -> El {
    text(title)
        .at_loc(Location::caller())
        .label()
        .font_weight(FontWeight::Semibold)
        .ellipsis()
        .width(Size::Fill(1.0))
}

#[track_caller]
pub fn item_description(description: impl Into<String>) -> El {
    text(description)
        .at_loc(Location::caller())
        .caption()
        .muted()
        .ellipsis()
        .width(Size::Fill(1.0))
}

#[track_caller]
pub fn item_actions<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    row(children)
        .at_loc(Location::caller())
        .align(Align::Center)
        .justify(Justify::End)
        .default_gap(tokens::SPACE_2)
        .width(Size::Hug)
        .height(Size::Hug)
}

#[track_caller]
pub fn item_separator() -> El {
    separator().at_loc(Location::caller())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn item_is_a_tactile_object_row() {
        let row = item([
            item_media_icon("folder"),
            item_content([item_title("whisper-git"), item_description("/home/example")]),
        ]);

        assert_eq!(row.kind, Kind::Custom("item"));
        assert_eq!(row.metrics_role, Some(MetricsRole::ListItem));
        assert_eq!(row.axis, Axis::Overlay);
        assert_eq!(row.align, Align::Stretch);
        assert_eq!(row.width, Size::Fill(1.0));
        assert_eq!(row.radius, crate::tree::Corners::all(tokens::RADIUS_MD));
        assert!(row.focusable);
        assert_eq!(row.cursor, Some(Cursor::Pointer));
        assert_eq!(row.fill, None, "default item rests transparent");
        assert_eq!(row.children[0].kind, Kind::Custom("item_rail"));
        assert_eq!(row.children[0].opacity, 0.0);
        assert!(
            row.children[0].animate.is_some(),
            "rail opacity should ease"
        );
        assert_eq!(row.children[1].axis, Axis::Row);
        assert_eq!(
            row.children[1].padding,
            Sides::xy(tokens::SPACE_3, tokens::SPACE_2)
        );
        assert!(row.animate.is_some(), "item fill/stroke should ease");
    }

    #[test]
    fn item_current_and_selected_enable_accent_rail() {
        let current = item([item_title("Current")]).current();
        assert_eq!(current.surface_role, SurfaceRole::Current);
        assert_eq!(current.fill, Some(tokens::ACCENT.with_alpha(24)));
        assert_eq!(current.children[0].kind, Kind::Custom("item_rail"));
        assert_eq!(current.children[0].opacity, 1.0);
        assert_eq!(current.children[0].fill, Some(tokens::PRIMARY));

        let selected = item([item_title("Selected")]).selected();
        assert_eq!(selected.surface_role, SurfaceRole::Selected);
        assert_eq!(selected.fill, Some(tokens::PRIMARY.with_alpha(18)));
        assert_eq!(selected.children[0].opacity, 1.0);
        assert_eq!(selected.children[0].fill, Some(tokens::PRIMARY));
    }

    #[test]
    fn item_media_icon_uses_stock_icon_slot() {
        let media = item_media_icon("folder");

        assert_eq!(media.kind, Kind::Custom("item_media"));
        assert_eq!(media.axis, Axis::Overlay);
        assert_eq!(media.width, Size::Fixed(32.0));
        assert_eq!(media.height, Size::Fixed(32.0));
        assert_eq!(media.fill, Some(tokens::MUTED));
        assert_eq!(media.stroke, Some(tokens::BORDER));
        assert_eq!(media.children.len(), 1);
    }

    #[test]
    fn item_content_builds_title_description_stack() {
        let content = item_content([item_title("Repository"), item_description("Parent path")]);

        assert_eq!(content.axis, Axis::Column);
        assert_eq!(content.width, Size::Fill(1.0));
        assert_eq!(content.gap, 0.0);
        assert_eq!(content.children.len(), 2);
        assert_eq!(content.children[0].text.as_deref(), Some("Repository"));
        assert_eq!(content.children[0].text_role, TextRole::Label);
        assert_eq!(content.children[1].text.as_deref(), Some("Parent path"));
        assert_eq!(content.children[1].text_role, TextRole::Caption);
        assert_eq!(
            content.children[1].text_color,
            Some(tokens::MUTED_FOREGROUND)
        );
    }

    #[test]
    fn item_group_stacks_related_items() {
        let group = item_group([
            item([item_title("One")]),
            item_separator(),
            item([item_title("Two")]),
        ]);

        assert_eq!(group.kind, Kind::Custom("item_group"));
        assert_eq!(group.axis, Axis::Column);
        assert_eq!(group.width, Size::Fill(1.0));
        assert_eq!(group.gap, tokens::SPACE_1);
        assert_eq!(group.children.len(), 3);
    }
}