teksilo-preview 0.9.1

Storybook-style preview registry for Teksilo widgets — catalog trait, typed knobs and variants, with no GUI dependency.
Documentation
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! `WidgetCatalog` trait — the user-facing trait that widget authors
//! implement to register a widget for previewing.
//!
//! Two traits coexist by design:
//!
//! - [`WidgetCatalog`] — static-method trait. Widget authors implement
//!   this on their widget type. The methods describe the widget's id,
//!   group, display name, variants, knobs, and the build closure that
//!   constructs an instance from a `KnobValues`.
//!
//! - [`CatalogEntry`] — object-safe erased trait. The `inventory` plugin
//!   registry collects `&'static dyn CatalogEntry`. Each entry forwards
//!   to the corresponding `WidgetCatalog` static methods. Authors do not
//!   implement this directly — the `register_widget_catalog!` macro
//!   generates a small zero-sized type that implements it and submits it
//!   to the inventory.

use crate::knob::{KnobSpec, KnobValues};
use crate::source_loc::SourceLoc;
use crate::variant::PreviewVariant;
use teksilo_core::widget::Widget;
use teksilo_core::widget_id::WidgetId;

/// How a catalog widget accepts children. Drives the designer's outline
/// rendering and the runtime [`WidgetCatalog::build_with_children`]
/// factory. Default is [`WidgetCategory::Leaf`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WidgetCategory {
    /// No children — a leaf control (Button, TextWidget, Slider, …).
    Leaf,
    /// Ordered bare children (VStack, HStack, ZStack, Grid, Padding, …).
    ContainerA,
    /// Named slots (Card → header/content/footer; Dialog; TabWidget; …).
    ContainerB,
}

/// One child handed to [`WidgetCatalog::build_with_children`]: a
/// pre-registered widget id plus, for [`WidgetCategory::ContainerB`]
/// parents, the name of the slot it fills (`None` for the ordered
/// children of a [`WidgetCategory::ContainerA`] parent).
#[derive(Debug, Clone)]
pub struct SlottedChild {
    /// The named slot this child fills (`ContainerB`), or `None` for an
    /// ordered bare child (`ContainerA`).
    pub slot: Option<String>,
    /// The pre-registered child widget, already inserted in the arena.
    pub id: WidgetId,
}

/// Static-method trait implemented by widget authors.
pub trait WidgetCatalog: 'static {
    /// Stable, ASCII id (e.g. `"button"`, `"tag_chip"`). Used in
    /// CLI args, navigator URLs, and persistence keys.
    fn id() -> &'static str;

    /// Group label for navigator organisation
    /// (`"Controls"`, `"Containers"`, `"Composites"`, …).
    fn group() -> &'static str;

    /// Human-readable label shown in the navigator.
    fn display_name() -> &'static str;

    /// Named variants. At least one is required — typically a "default".
    fn variants() -> Vec<PreviewVariant>;

    /// Optional knob declarations. Empty by default — composite
    /// widgets that build via `Scenario` variants leave this empty.
    fn knobs() -> KnobSpec {
        KnobSpec::empty()
    }

    /// Construct a fresh widget instance for the named variant, given
    /// runtime knob values. The implementation typically dispatches on
    /// the variant name to handle `Scenario` paths and otherwise builds
    /// via the knob values for `Knobs` variants.
    fn build(variant: &str, knobs: &KnobValues) -> Box<dyn Widget>;

    /// An icon widget for the navigator palette and the designer's
    /// outline tree. `None` (the default) leaves the consumer to
    /// substitute a generic fallback. Returns a `Box<dyn Widget>` (not
    /// an `IconWidget`) so this crate stays free of any widgets-crate
    /// dependency.
    fn icon() -> Option<Box<dyn Widget>> {
        None
    }

    /// How this widget accepts children. The default,
    /// [`WidgetCategory::Leaf`], is correct for every control; container
    /// widgets override it.
    fn category() -> WidgetCategory {
        WidgetCategory::Leaf
    }

    /// Named slots for a [`WidgetCategory::ContainerB`] widget (e.g.
    /// `Card` → `["header", "content", "footer"]`). Empty for `Leaf` and
    /// `ContainerA`.
    fn slots() -> &'static [&'static str] {
        &[]
    }

    /// Build with pre-registered children injected. `ContainerA` folds
    /// `children` as ordered bare children; `ContainerB` routes each by
    /// its `slot` name; `Leaf` ignores them. The default ignores
    /// `children` and delegates to [`build`](Self::build), so non-container
    /// widgets need no override.
    fn build_with_children(
        variant: &str,
        knobs: &KnobValues,
        children: Vec<SlottedChild>,
    ) -> Box<dyn Widget> {
        let _ = children;
        Self::build(variant, knobs)
    }
}

/// Object-safe trait collected by `inventory`. Each implementor is a
/// zero-sized shim generated by `register_widget_catalog!` that
/// forwards to the corresponding `WidgetCatalog` impl.
pub trait CatalogEntry: Sync {
    fn id(&self) -> &'static str;
    fn group(&self) -> &'static str;
    fn display_name(&self) -> &'static str;
    fn source(&self) -> SourceLoc;
    fn variants(&self) -> Vec<PreviewVariant>;
    fn knobs(&self) -> KnobSpec;
    fn build(&self, variant: &str, knobs: &KnobValues) -> Box<dyn Widget>;

    fn icon(&self) -> Option<Box<dyn Widget>> {
        None
    }
    fn category(&self) -> WidgetCategory {
        WidgetCategory::Leaf
    }
    fn slots(&self) -> &'static [&'static str] {
        &[]
    }
    fn build_with_children(
        &self,
        variant: &str,
        knobs: &KnobValues,
        children: Vec<SlottedChild>,
    ) -> Box<dyn Widget> {
        let _ = children;
        self.build(variant, knobs)
    }
}

inventory::collect!(&'static dyn CatalogEntry);

/// Register a `WidgetCatalog` impl with the global inventory.
///
/// Expand at module scope, alongside (or near) the `impl WidgetCatalog
/// for X` block:
///
/// ```ignore
/// impl WidgetCatalog for Button { /* ... */ }
/// teksilo_preview::register_widget_catalog!(Button);
/// ```
///
/// The macro captures `file!()` and `line!()` at the call site, so
/// `entry.source()` returns the path of the file the macro expanded in
/// — used by the previewer's `--file=PATH` resolution.
/// Register a `WidgetCatalog` impl. Captures the file and line of the
/// macro call site as the entry's source location — used by
/// `previewer --file=PATH` resolution.
#[macro_export]
macro_rules! register_widget_catalog {
    ($t:ty) => {
        $crate::__register_widget_catalog_with!($t, file!(), line!());
    };
}

/// Register a `WidgetCatalog` impl with an explicit source file path.
/// Useful when several catalog impls live in a single shared
/// `preview_catalog.rs` module — each call declares the source file
/// that the user would open in their editor to find the widget
/// itself, so that `previewer --file=<that path>` resolves to the
/// right entry. The line value is set to 1 since the call site does
/// not correspond to the widget's own location.
#[macro_export]
macro_rules! register_widget_catalog_at {
    ($file:literal, $t:ty) => {
        $crate::__register_widget_catalog_with!($t, $file, 1u32);
    };
}

#[doc(hidden)]
#[macro_export]
macro_rules! __register_widget_catalog_with {
    ($t:ty, $file:expr, $line:expr) => {
        const _: () = {
            #[allow(non_camel_case_types)]
            struct __Entry;
            impl $crate::CatalogEntry for __Entry {
                fn id(&self) -> &'static str {
                    <$t as $crate::WidgetCatalog>::id()
                }
                fn group(&self) -> &'static str {
                    <$t as $crate::WidgetCatalog>::group()
                }
                fn display_name(&self) -> &'static str {
                    <$t as $crate::WidgetCatalog>::display_name()
                }
                fn source(&self) -> $crate::SourceLoc {
                    $crate::SourceLoc::new($file, $line)
                }
                fn variants(&self) -> ::std::vec::Vec<$crate::PreviewVariant> {
                    <$t as $crate::WidgetCatalog>::variants()
                }
                fn knobs(&self) -> $crate::KnobSpec {
                    <$t as $crate::WidgetCatalog>::knobs()
                }
                fn build(
                    &self,
                    variant: &str,
                    knobs: &$crate::KnobValues,
                ) -> ::std::boxed::Box<dyn $crate::__widget::Widget> {
                    <$t as $crate::WidgetCatalog>::build(variant, knobs)
                }
                fn icon(
                    &self,
                ) -> ::std::option::Option<::std::boxed::Box<dyn $crate::__widget::Widget>>
                {
                    <$t as $crate::WidgetCatalog>::icon()
                }
                fn category(&self) -> $crate::WidgetCategory {
                    <$t as $crate::WidgetCatalog>::category()
                }
                fn slots(&self) -> &'static [&'static str] {
                    <$t as $crate::WidgetCatalog>::slots()
                }
                fn build_with_children(
                    &self,
                    variant: &str,
                    knobs: &$crate::KnobValues,
                    children: ::std::vec::Vec<$crate::SlottedChild>,
                ) -> ::std::boxed::Box<dyn $crate::__widget::Widget> {
                    <$t as $crate::WidgetCatalog>::build_with_children(variant, knobs, children)
                }
            }
            static __ENTRY: __Entry = __Entry;
            $crate::__inventory::submit! {
                &__ENTRY as &'static dyn $crate::CatalogEntry
            }
        };
    };
}