aetna_core/tree/constructors.rs
1//! Free constructors for common [`El`] tree shapes.
2//!
3//! Kept separate from the core `El` type so the central node definition
4//! stays focused on fields and chainable modifiers.
5
6use std::panic::Location;
7use std::sync::Arc;
8
9use crate::image::Image;
10use crate::layout::VirtualItems;
11use crate::math::{MathDisplay, MathExpr};
12
13use super::layout_types::{Align, Axis, Size};
14use super::node::El;
15use super::semantics::Kind;
16
17/// A vertical container — the layout fallback.
18///
19/// Reach for a named widget first: [`card`] / [`titled_card`] for boxed
20/// surfaces; [`sidebar`] for nav rails; [`toolbar`] for page headers;
21/// [`item`] for object rows; [`form_item`] / [`field_row`] for stacked
22/// fields. `column` is the right answer when no widget shape fits.
23///
24/// Defaults match CSS flex's `display: flex; flex-direction: column`:
25/// `axis = Column`, `align = Stretch`, `width = Hug`, `height = Hug`,
26/// `gap = 0`. Children shrink to content on the main axis (height)
27/// and stretch to the column's width on the cross axis.
28///
29/// To claim the parent's extent (the analog of `width: 100%` /
30/// `flex: 1`), set `.width(Size::Fill(1.0))` /
31/// `.height(Size::Fill(1.0))`. To space children apart, set
32/// `.gap(tokens::SPACE_*)` — CSS-style opt-in spacing.
33///
34/// Switch `align` to `Center` / `Start` / `End` and children shrink
35/// to their content width so the alignment can position them — the
36/// same as CSS `align-items` non-stretch semantics.
37///
38/// **Smell:** `column([...]).fill(CARD).stroke(BORDER).radius(...)`
39/// reinvents [`card`]; `column([...]).fill(CARD).stroke(BORDER).width(SIDEBAR_WIDTH)`
40/// reinvents [`sidebar`]. Use the named widget — same recipe, the right
41/// surface role, less to forget.
42///
43/// [`card`]: crate::widgets::card::card
44/// [`titled_card`]: crate::widgets::card::titled_card
45/// [`sidebar`]: crate::widgets::sidebar::sidebar
46/// [`toolbar`]: crate::widgets::toolbar::toolbar
47/// [`item`]: crate::widgets::item::item
48/// [`form_item`]: crate::widgets::form::form_item
49/// [`field_row`]: crate::widgets::form::field_row
50#[track_caller]
51pub fn column<I, E>(children: I) -> El
52where
53 I: IntoIterator<Item = E>,
54 E: Into<El>,
55{
56 El::new(Kind::Group)
57 .at_loc(Location::caller())
58 .children(children)
59 .axis(Axis::Column)
60}
61
62/// A horizontal container — the layout fallback.
63///
64/// Reach for a named widget first: [`item`] for clickable object rows
65/// (recent file, repo, project, person, asset entry — anywhere you'd
66/// otherwise build a focusable row with stacked text and trailing
67/// buttons); [`toolbar`] for page chrome; [`field_row`] for label +
68/// control; [`tabs_list`] for segmented controls; [`breadcrumb_list`] /
69/// [`pagination_content`] for navigation rows. `row` is the right
70/// answer when no widget shape fits.
71///
72/// Defaults match CSS flex's `display: flex; flex-direction: row`:
73/// `axis = Row`, `align = Stretch`, `width = Hug`, `height = Hug`,
74/// `gap = 0`. Children shrink to content on the main axis (width)
75/// and stretch to the row's height on the cross axis.
76///
77/// `Stretch` is the cross-axis default the same way `align-items:
78/// stretch` is in CSS. For typical content rows (`[icon, text,
79/// button]`) you almost always want `.align(Center)` to vertically
80/// center the children — the CSS-Tailwind muscle memory of
81/// `flex items-center`. Without it, smaller fixed-size children
82/// (badges, icons) sit at the top of the row, just like CSS does.
83///
84/// To space children apart, set `.gap(tokens::SPACE_*)` — opt-in
85/// like CSS.
86///
87/// **Smell:** a focusable, keyed `row([column([t1, t2]), button, button])`
88/// used as a clickable resource entry — that's [`item`], not a hand-rolled
89/// row. The named widget gives you hover, press, focus, the rail, and
90/// the slots (`item_media`, `item_content`, `item_actions`) for free.
91///
92/// [`item`]: crate::widgets::item::item
93/// [`toolbar`]: crate::widgets::toolbar::toolbar
94/// [`field_row`]: crate::widgets::form::field_row
95/// [`tabs_list`]: crate::widgets::tabs::tabs_list
96/// [`breadcrumb_list`]: crate::widgets::breadcrumb::breadcrumb_list
97/// [`pagination_content`]: crate::widgets::pagination::pagination_content
98#[track_caller]
99pub fn row<I, E>(children: I) -> El
100where
101 I: IntoIterator<Item = E>,
102 E: Into<El>,
103{
104 El::new(Kind::Group)
105 .at_loc(Location::caller())
106 .children(children)
107 .axis(Axis::Row)
108}
109
110/// An overlay stack; children share the parent's rect.
111///
112/// For modals, sheets, popovers, and tooltips reach for the named
113/// widget instead — [`dialog`], [`sheet`], [`popover`], `.tooltip(...)`.
114/// `stack` is the layered-visuals primitive (focus rings, custom
115/// badges painted over content) that those widgets compose against.
116///
117/// [`dialog`]: crate::widgets::dialog::dialog
118/// [`sheet`]: crate::widgets::sheet::sheet
119/// [`popover`]: crate::widgets::popover::popover
120#[track_caller]
121pub fn stack<I, E>(children: I) -> El
122where
123 I: IntoIterator<Item = E>,
124 E: Into<El>,
125{
126 El::new(Kind::Group)
127 .at_loc(Location::caller())
128 .children(children)
129 .axis(Axis::Overlay)
130}
131
132/// A vertical scroll viewport. Children stack as in [`column()`]; the
133/// container clips overflow and translates content by the current scroll
134/// offset. Wheel events over the viewport update the offset.
135///
136/// Give it a `.key("...")` so the offset persists by name across
137/// rebuilds — without a key, the offset is keyed by sibling index and
138/// resets if structure shifts.
139#[track_caller]
140pub fn scroll<I, E>(children: I) -> El
141where
142 I: IntoIterator<Item = E>,
143 E: Into<El>,
144{
145 El::new(Kind::Scroll)
146 .at_loc(Location::caller())
147 .children(children)
148 .axis(Axis::Column)
149 .width(Size::Fill(1.0))
150 .height(Size::Fill(1.0))
151 .clip()
152 .scrollable()
153 .scrollbar()
154}
155
156/// Block whose direct children flow inline (text leaves + embeds +
157/// hard breaks). Models HTML's `<p>` shape: heterogeneous children,
158/// attributed runs, optional inline embeds. Children are styled via
159/// the existing modifier chain (`.bold()`, `.italic()`, `.color(c)`,
160/// `.code()`, `.link(url)`, etc.) — there is no parallel
161/// `RichText`/`TextRun` type.
162///
163/// ```ignore
164/// text_runs([
165/// text("Aetna — "),
166/// text("rich text").bold(),
167/// text(" composition."),
168/// hard_break(),
169/// text("Custom shaders, custom layouts, "),
170/// text("virtual_list").code(),
171/// text(" — and inline runs."),
172/// ])
173/// ```
174#[track_caller]
175pub fn text_runs<I, E>(children: I) -> El
176where
177 I: IntoIterator<Item = E>,
178 E: Into<El>,
179{
180 El::new(Kind::Inlines)
181 .at_loc(Location::caller())
182 .axis(Axis::Column)
183 .align(Align::Start)
184 .width(Size::Fill(1.0))
185 .children(children)
186}
187
188/// Forced line break inside a [`text_runs`] block. Mirrors HTML's
189/// `<br>`. Outside an `Inlines` parent, lays out as a zero-size leaf.
190#[track_caller]
191pub fn hard_break() -> El {
192 El::new(Kind::HardBreak)
193 .at_loc(Location::caller())
194 .width(Size::Hug)
195 .height(Size::Hug)
196}
197
198#[track_caller]
199pub fn math(expr: impl Into<Arc<MathExpr>>) -> El {
200 math_inline(expr)
201}
202
203#[track_caller]
204pub fn math_inline(expr: impl Into<Arc<MathExpr>>) -> El {
205 El::new(Kind::Math)
206 .at_loc(Location::caller())
207 .math_expr(expr)
208 .math_display(MathDisplay::Inline)
209 .width(Size::Hug)
210 .height(Size::Hug)
211}
212
213#[track_caller]
214pub fn math_block(expr: impl Into<Arc<MathExpr>>) -> El {
215 El::new(Kind::Math)
216 .at_loc(Location::caller())
217 .math_expr(expr)
218 .math_display(MathDisplay::Block)
219 .width(Size::Fill(1.0))
220 .height(Size::Hug)
221}
222
223/// Virtualized vertical list of `count` rows of fixed height
224/// `row_height`. The library calls `build_row(i)` only for indices
225/// whose rect intersects the visible viewport, then lays them out at
226/// the scroll-shifted Y. Authors typically key rows with a stable
227/// identifier (`button("foo").key("msg-abc")`) so hover/press/focus
228/// state survives scrolling.
229///
230/// The returned El defaults to `Size::Fill(1.0)` on both axes (it's a
231/// viewport — its size is decided by the parent). `Size::Hug` would
232/// defeat virtualization and panics at layout time.
233#[track_caller]
234pub fn virtual_list<F>(count: usize, row_height: f32, build_row: F) -> El
235where
236 F: Fn(usize) -> El + Send + Sync + 'static,
237{
238 let mut el = El::new(Kind::VirtualList)
239 .at_loc(Location::caller())
240 .axis(Axis::Column)
241 .align(Align::Stretch)
242 .width(Size::Fill(1.0))
243 .height(Size::Fill(1.0))
244 .clip()
245 .scrollable()
246 .scrollbar();
247 el.virtual_items = Some(VirtualItems::new(count, row_height, build_row));
248 el
249}
250
251/// Variable-height variant of [`virtual_list`]. Each row sizes itself
252/// from its own content (`Size::Hug` or `Size::Fixed` on the main
253/// axis); `estimated_row_height` is used for unmeasured rows when the
254/// library positions the visible window and computes the scrollbar
255/// thumb. The first time a row enters the viewport its actual intrinsic
256/// height is measured at the viewport width and cached on `UiState`,
257/// so scroll math converges as the user scrolls.
258///
259/// Use this when row heights are content-driven (diff hunks, expanded
260/// rows, comment threads) and a single `row_height` would either waste
261/// space or truncate. For genuinely uniform lists prefer
262/// [`virtual_list`] — its O(1) range math is cheaper and free of any
263/// estimate/measure jitter.
264#[track_caller]
265pub fn virtual_list_dyn<F>(count: usize, estimated_row_height: f32, build_row: F) -> El
266where
267 F: Fn(usize) -> El + Send + Sync + 'static,
268{
269 let mut el = El::new(Kind::VirtualList)
270 .at_loc(Location::caller())
271 .axis(Axis::Column)
272 .align(Align::Stretch)
273 .width(Size::Fill(1.0))
274 .height(Size::Fill(1.0))
275 .clip()
276 .scrollable()
277 .scrollbar();
278 el.virtual_items = Some(VirtualItems::new_dyn(
279 count,
280 estimated_row_height,
281 build_row,
282 ));
283 el
284}
285
286/// A `Fill(1)` filler. Inside a `row` it pushes siblings to the right;
287/// inside a `column` it pushes siblings to the bottom.
288#[track_caller]
289pub fn spacer() -> El {
290 El::new(Kind::Spacer)
291 .at_loc(Location::caller())
292 .width(Size::Fill(1.0))
293 .height(Size::Fill(1.0))
294}
295
296/// A raster image element. The El hugs the image's natural pixel
297/// size by default; set [`El::width`] / [`El::height`] for an
298/// explicit box, and [`El::image_fit`] to control projection.
299///
300/// ```
301/// use aetna_core::prelude::*;
302/// let pixels = vec![0u8; 4 * 4 * 4];
303/// let img = Image::from_rgba8(4, 4, pixels);
304/// let _ = image(img).image_fit(ImageFit::Cover).radius(8.0);
305/// ```
306#[track_caller]
307pub fn image(img: impl Into<Image>) -> El {
308 El::new(Kind::Image).at_loc(Location::caller()).image(img)
309}
310
311/// An app-supplied vector asset. By default Aetna preserves authored
312/// fills, strokes, and gradients through the painted vector path; call
313/// [`El::vector_mask`] when the asset should be treated as a one-colour
314/// coverage mask. Companion to [`crate::icon`] for content that
315/// doesn't fit icon conventions: arbitrary-aspect bounding boxes,
316/// programmatic construction each frame. Pairs with
317/// [`crate::vector::PathBuilder`] for ergonomic path construction.
318///
319/// # Sizing
320///
321/// The default size matches the asset's view-box dimensions in logical
322/// pixels. Set [`El::width`] / [`El::height`] / [`El::fill_size`] to
323/// override. Painted vectors are tessellated into the resolved rect;
324/// mask vectors sample the backend MSDF atlas across that rect.
325///
326/// # Caching
327///
328/// The asset's [`VectorAsset::content_hash`](crate::vector::VectorAsset::content_hash)
329/// is the backend cache key. Apps that build the same shape twice (two
330/// commits sharing a merge connector geometry, two flowchart edges with
331/// the same arc) can share backend work; per-frame-unique geometry gets
332/// one cache entry per unique shape.
333///
334/// ```ignore
335/// use aetna_core::prelude::*;
336/// use aetna_core::tree::Color;
337///
338/// let curve = PathBuilder::new()
339/// .move_to(0.0, 0.0)
340/// .cubic_to(20.0, 0.0, 0.0, 60.0, 20.0, 60.0)
341/// .stroke_solid(Color::rgb(80, 200, 240), 2.0)
342/// .stroke_line_cap(VectorLineCap::Round)
343/// .build();
344/// let asset = VectorAsset::from_paths([0.0, 0.0, 20.0, 60.0], vec![curve]);
345/// let _ = vector(asset);
346/// ```
347#[track_caller]
348pub fn vector(asset: crate::vector::VectorAsset) -> El {
349 let [_, _, vw, vh] = asset.view_box;
350 El::new(Kind::Vector)
351 .at_loc(Location::caller())
352 .width(Size::Fixed(vw.max(0.0)))
353 .height(Size::Fixed(vh.max(0.0)))
354 .vector_source(std::sync::Arc::new(asset))
355}
356
357/// An app-owned-texture surface. Aetna composites the texture into
358/// the paint stream at the El's resolved rect — no upload, no per-frame
359/// copy. The default size matches the texture's pixel dimensions; set
360/// [`El::width`] / [`El::height`] (or `.fill_size()`) for an explicit
361/// box.
362///
363/// # Sizing, projection, and transforms
364///
365/// The texture's pixel dimensions are **independent of the rendered
366/// size**. By default the widget stretches the texture across the
367/// resolved rect ([`crate::image::ImageFit::Fill`]); reach for
368/// [`El::surface_fit`] to letterbox-preserve aspect ratio
369/// ([`crate::image::ImageFit::Contain`]), crop-cover
370/// ([`crate::image::ImageFit::Cover`]), or paint at natural size
371/// ([`crate::image::ImageFit::None`]). [`El::surface_transform`]
372/// composes an affine on top — rotate, mirror, zoom/pan — applied
373/// around the centre of the post-fit rect.
374///
375/// Picking a sizing strategy:
376/// - For pixel-accurate display, size the widget to the texture's
377/// pixel dimensions (the default constructor does this for you).
378/// - For a 3D viewport or video frame whose source resolution should
379/// track the rendered size, the app should re-allocate its texture
380/// to match the resolved rect (read it via `UiState::rect_of_key`
381/// after `prepare()`).
382/// - For an animated image whose natural dimensions are fixed
383/// (decoded GIF / WebP / APNG, decoded video frame),
384/// `surface_fit(Contain)` letterboxes into any layout rect with
385/// no per-resize allocation.
386///
387/// # z-order, scissor, hit-test
388///
389/// The widget participates in layout, scissor, scrolling, hit-test,
390/// and z-order like any other El: siblings declared before this one
391/// paint underneath, siblings after paint on top. The auto-clip
392/// scissor clamps painted content to the El's content rect — affines
393/// or `Cover` projections that overflow are cropped.
394///
395/// ```ignore
396/// // Pseudocode — the AppTexture comes from a backend constructor.
397/// use aetna_core::prelude::*;
398/// let tex: AppTexture = /* aetna_wgpu::app_texture(...) */ todo!();
399/// let _ = surface(tex)
400/// .fill_size()
401/// .surface_fit(ImageFit::Contain)
402/// .surface_alpha(SurfaceAlpha::Opaque)
403/// .surface_transform(Affine2::rotate(0.1));
404/// ```
405#[track_caller]
406pub fn surface(texture: crate::surface::AppTexture) -> El {
407 let (w, h) = texture.size_px();
408 El::new(Kind::Surface)
409 .at_loc(Location::caller())
410 .width(Size::Fixed(w as f32))
411 .height(Size::Fixed(h as f32))
412 .surface_source(crate::surface::SurfaceSource::Texture(texture))
413}
414
415/// A 1-pixel separator line.
416#[track_caller]
417pub fn divider() -> El {
418 El::new(Kind::Divider)
419 .at_loc(Location::caller())
420 .height(Size::Fixed(1.0))
421 .width(Size::Fill(1.0))
422 .fill(crate::tokens::BORDER)
423}
424
425// ---------- &str → El convenience ----------
426//
427// Lets `titled_card("Title", ["a body line"])` work without `text(...)`.
428
429impl From<&str> for El {
430 fn from(s: &str) -> Self {
431 crate::widgets::text::text(s)
432 }
433}
434
435impl From<String> for El {
436 fn from(s: String) -> Self {
437 crate::widgets::text::text(s)
438 }
439}
440
441impl From<&String> for El {
442 fn from(s: &String) -> Self {
443 crate::widgets::text::text(s.as_str())
444 }
445}