teksilo_preview/catalog.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `WidgetCatalog` trait — the user-facing trait that widget authors
5//! implement to register a widget for previewing.
6//!
7//! Two traits coexist by design:
8//!
9//! - [`WidgetCatalog`] — static-method trait. Widget authors implement
10//! this on their widget type. The methods describe the widget's id,
11//! group, display name, variants, knobs, and the build closure that
12//! constructs an instance from a `KnobValues`.
13//!
14//! - [`CatalogEntry`] — object-safe erased trait. The `inventory` plugin
15//! registry collects `&'static dyn CatalogEntry`. Each entry forwards
16//! to the corresponding `WidgetCatalog` static methods. Authors do not
17//! implement this directly — the `register_widget_catalog!` macro
18//! generates a small zero-sized type that implements it and submits it
19//! to the inventory.
20
21use crate::knob::{KnobSpec, KnobValues};
22use crate::source_loc::SourceLoc;
23use crate::variant::PreviewVariant;
24use teksilo_core::widget::Widget;
25use teksilo_core::widget_id::WidgetId;
26
27/// How a catalog widget accepts children. Drives the designer's outline
28/// rendering and the runtime [`WidgetCatalog::build_with_children`]
29/// factory. Default is [`WidgetCategory::Leaf`].
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum WidgetCategory {
32 /// No children — a leaf control (Button, TextWidget, Slider, …).
33 Leaf,
34 /// Ordered bare children (VStack, HStack, ZStack, Grid, Padding, …).
35 ContainerA,
36 /// Named slots (Card → header/content/footer; Dialog; TabWidget; …).
37 ContainerB,
38}
39
40/// One child handed to [`WidgetCatalog::build_with_children`]: a
41/// pre-registered widget id plus, for [`WidgetCategory::ContainerB`]
42/// parents, the name of the slot it fills (`None` for the ordered
43/// children of a [`WidgetCategory::ContainerA`] parent).
44#[derive(Debug, Clone)]
45pub struct SlottedChild {
46 /// The named slot this child fills (`ContainerB`), or `None` for an
47 /// ordered bare child (`ContainerA`).
48 pub slot: Option<String>,
49 /// The pre-registered child widget, already inserted in the arena.
50 pub id: WidgetId,
51}
52
53/// Static-method trait implemented by widget authors.
54pub trait WidgetCatalog: 'static {
55 /// Stable, ASCII id (e.g. `"button"`, `"tag_chip"`). Used in
56 /// CLI args, navigator URLs, and persistence keys.
57 fn id() -> &'static str;
58
59 /// Group label for navigator organisation
60 /// (`"Controls"`, `"Containers"`, `"Composites"`, …).
61 fn group() -> &'static str;
62
63 /// Human-readable label shown in the navigator.
64 fn display_name() -> &'static str;
65
66 /// Named variants. At least one is required — typically a "default".
67 fn variants() -> Vec<PreviewVariant>;
68
69 /// Optional knob declarations. Empty by default — composite
70 /// widgets that build via `Scenario` variants leave this empty.
71 fn knobs() -> KnobSpec {
72 KnobSpec::empty()
73 }
74
75 /// Construct a fresh widget instance for the named variant, given
76 /// runtime knob values. The implementation typically dispatches on
77 /// the variant name to handle `Scenario` paths and otherwise builds
78 /// via the knob values for `Knobs` variants.
79 fn build(variant: &str, knobs: &KnobValues) -> Box<dyn Widget>;
80
81 /// An icon widget for the navigator palette and the designer's
82 /// outline tree. `None` (the default) leaves the consumer to
83 /// substitute a generic fallback. Returns a `Box<dyn Widget>` (not
84 /// an `IconWidget`) so this crate stays free of any widgets-crate
85 /// dependency.
86 fn icon() -> Option<Box<dyn Widget>> {
87 None
88 }
89
90 /// How this widget accepts children. The default,
91 /// [`WidgetCategory::Leaf`], is correct for every control; container
92 /// widgets override it.
93 fn category() -> WidgetCategory {
94 WidgetCategory::Leaf
95 }
96
97 /// Named slots for a [`WidgetCategory::ContainerB`] widget (e.g.
98 /// `Card` → `["header", "content", "footer"]`). Empty for `Leaf` and
99 /// `ContainerA`.
100 fn slots() -> &'static [&'static str] {
101 &[]
102 }
103
104 /// Build with pre-registered children injected. `ContainerA` folds
105 /// `children` as ordered bare children; `ContainerB` routes each by
106 /// its `slot` name; `Leaf` ignores them. The default ignores
107 /// `children` and delegates to [`build`](Self::build), so non-container
108 /// widgets need no override.
109 fn build_with_children(
110 variant: &str,
111 knobs: &KnobValues,
112 children: Vec<SlottedChild>,
113 ) -> Box<dyn Widget> {
114 let _ = children;
115 Self::build(variant, knobs)
116 }
117}
118
119/// Object-safe trait collected by `inventory`. Each implementor is a
120/// zero-sized shim generated by `register_widget_catalog!` that
121/// forwards to the corresponding `WidgetCatalog` impl.
122pub trait CatalogEntry: Sync {
123 fn id(&self) -> &'static str;
124 fn group(&self) -> &'static str;
125 fn display_name(&self) -> &'static str;
126 fn source(&self) -> SourceLoc;
127 fn variants(&self) -> Vec<PreviewVariant>;
128 fn knobs(&self) -> KnobSpec;
129 fn build(&self, variant: &str, knobs: &KnobValues) -> Box<dyn Widget>;
130
131 fn icon(&self) -> Option<Box<dyn Widget>> {
132 None
133 }
134 fn category(&self) -> WidgetCategory {
135 WidgetCategory::Leaf
136 }
137 fn slots(&self) -> &'static [&'static str] {
138 &[]
139 }
140 fn build_with_children(
141 &self,
142 variant: &str,
143 knobs: &KnobValues,
144 children: Vec<SlottedChild>,
145 ) -> Box<dyn Widget> {
146 let _ = children;
147 self.build(variant, knobs)
148 }
149}
150
151inventory::collect!(&'static dyn CatalogEntry);
152
153/// Register a `WidgetCatalog` impl with the global inventory.
154///
155/// Expand at module scope, alongside (or near) the `impl WidgetCatalog
156/// for X` block:
157///
158/// ```ignore
159/// impl WidgetCatalog for Button { /* ... */ }
160/// teksilo_preview::register_widget_catalog!(Button);
161/// ```
162///
163/// The macro captures `file!()` and `line!()` at the call site, so
164/// `entry.source()` returns the path of the file the macro expanded in
165/// — used by the previewer's `--file=PATH` resolution.
166/// Register a `WidgetCatalog` impl. Captures the file and line of the
167/// macro call site as the entry's source location — used by
168/// `previewer --file=PATH` resolution.
169#[macro_export]
170macro_rules! register_widget_catalog {
171 ($t:ty) => {
172 $crate::__register_widget_catalog_with!($t, file!(), line!());
173 };
174}
175
176/// Register a `WidgetCatalog` impl with an explicit source file path.
177/// Useful when several catalog impls live in a single shared
178/// `preview_catalog.rs` module — each call declares the source file
179/// that the user would open in their editor to find the widget
180/// itself, so that `previewer --file=<that path>` resolves to the
181/// right entry. The line value is set to 1 since the call site does
182/// not correspond to the widget's own location.
183#[macro_export]
184macro_rules! register_widget_catalog_at {
185 ($file:literal, $t:ty) => {
186 $crate::__register_widget_catalog_with!($t, $file, 1u32);
187 };
188}
189
190#[doc(hidden)]
191#[macro_export]
192macro_rules! __register_widget_catalog_with {
193 ($t:ty, $file:expr, $line:expr) => {
194 const _: () = {
195 #[allow(non_camel_case_types)]
196 struct __Entry;
197 impl $crate::CatalogEntry for __Entry {
198 fn id(&self) -> &'static str {
199 <$t as $crate::WidgetCatalog>::id()
200 }
201 fn group(&self) -> &'static str {
202 <$t as $crate::WidgetCatalog>::group()
203 }
204 fn display_name(&self) -> &'static str {
205 <$t as $crate::WidgetCatalog>::display_name()
206 }
207 fn source(&self) -> $crate::SourceLoc {
208 $crate::SourceLoc::new($file, $line)
209 }
210 fn variants(&self) -> ::std::vec::Vec<$crate::PreviewVariant> {
211 <$t as $crate::WidgetCatalog>::variants()
212 }
213 fn knobs(&self) -> $crate::KnobSpec {
214 <$t as $crate::WidgetCatalog>::knobs()
215 }
216 fn build(
217 &self,
218 variant: &str,
219 knobs: &$crate::KnobValues,
220 ) -> ::std::boxed::Box<dyn $crate::__widget::Widget> {
221 <$t as $crate::WidgetCatalog>::build(variant, knobs)
222 }
223 fn icon(
224 &self,
225 ) -> ::std::option::Option<::std::boxed::Box<dyn $crate::__widget::Widget>>
226 {
227 <$t as $crate::WidgetCatalog>::icon()
228 }
229 fn category(&self) -> $crate::WidgetCategory {
230 <$t as $crate::WidgetCatalog>::category()
231 }
232 fn slots(&self) -> &'static [&'static str] {
233 <$t as $crate::WidgetCatalog>::slots()
234 }
235 fn build_with_children(
236 &self,
237 variant: &str,
238 knobs: &$crate::KnobValues,
239 children: ::std::vec::Vec<$crate::SlottedChild>,
240 ) -> ::std::boxed::Box<dyn $crate::__widget::Widget> {
241 <$t as $crate::WidgetCatalog>::build_with_children(variant, knobs, children)
242 }
243 }
244 static __ENTRY: __Entry = __Entry;
245 $crate::__inventory::submit! {
246 &__ENTRY as &'static dyn $crate::CatalogEntry
247 }
248 };
249 };
250}