Skip to main content

damascene_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
6// Lock in full per-item documentation for this module (issue #73).
7#![warn(missing_docs)]
8
9use std::panic::Location;
10use std::sync::Arc;
11
12use crate::image::Image;
13use crate::layout::VirtualItems;
14use crate::math::{MathDisplay, MathExpr};
15
16use super::layout_types::{Align, Axis, Size};
17use super::node::El;
18use super::semantics::Kind;
19
20/// A vertical container — the layout fallback.
21///
22/// Reach for a named widget first: [`card`] / [`titled_card`] for boxed
23/// surfaces; [`sidebar`] for nav rails; [`toolbar`] for page headers;
24/// [`item`] for object rows; [`form_item`] / [`field_row`] for stacked
25/// fields. `column` is the right answer when no widget shape fits.
26///
27/// Defaults match CSS flex's `display: flex; flex-direction: column`:
28/// `axis = Column`, `align = Stretch`, `width = Hug`, `height = Hug`,
29/// `gap = 0`. Children shrink to content on the main axis (height)
30/// and stretch to the column's width on the cross axis.
31///
32/// To claim the parent's extent (the analog of `width: 100%` /
33/// `flex: 1`), set `.width(Size::Fill(1.0))` /
34/// `.height(Size::Fill(1.0))`. To space children apart, set
35/// `.gap(tokens::SPACE_*)` — CSS-style opt-in spacing.
36///
37/// Switch `align` to `Center` / `Start` / `End` and children shrink
38/// to their content width so the alignment can position them — the
39/// same as CSS `align-items` non-stretch semantics.
40///
41/// **Smell:** `column([...]).fill(CARD).stroke(BORDER).radius(...)`
42/// reinvents [`card`]; `column([...]).fill(CARD).stroke(BORDER).width(SIDEBAR_WIDTH)`
43/// reinvents [`sidebar`]. Use the named widget — same recipe, the right
44/// surface role, less to forget.
45///
46/// [`card`]: crate::widgets::card::card
47/// [`titled_card`]: crate::widgets::card::titled_card
48/// [`sidebar`]: crate::widgets::sidebar::sidebar
49/// [`toolbar`]: crate::widgets::toolbar::toolbar
50/// [`item`]: crate::widgets::item::item
51/// [`form_item`]: crate::widgets::form::form_item
52/// [`field_row`]: crate::widgets::form::field_row
53#[track_caller]
54pub fn column<I, E>(children: I) -> El
55where
56    I: IntoIterator<Item = E>,
57    E: Into<El>,
58{
59    El::new(Kind::Group)
60        .at_loc(Location::caller())
61        .children(children)
62        .axis(Axis::Column)
63}
64
65/// A horizontal container — the layout fallback.
66///
67/// Reach for a named widget first: [`item`] for clickable object rows
68/// (recent file, repo, project, person, asset entry — anywhere you'd
69/// otherwise build a focusable row with stacked text and trailing
70/// buttons); [`toolbar`] for page chrome; [`field_row`] for label +
71/// control; [`tabs_list`] for segmented controls; [`breadcrumb_list`] /
72/// [`pagination_content`] for navigation rows. `row` is the right
73/// answer when no widget shape fits.
74///
75/// Defaults match CSS flex's `display: flex; flex-direction: row`:
76/// `axis = Row`, `align = Stretch`, `width = Hug`, `height = Hug`,
77/// `gap = 0`. Children shrink to content on the main axis (width)
78/// and stretch to the row's height on the cross axis.
79///
80/// `Stretch` is the cross-axis default the same way `align-items:
81/// stretch` is in CSS. For typical content rows (`[icon, text,
82/// button]`) you almost always want `.align(Center)` to vertically
83/// center the children — the CSS-Tailwind muscle memory of
84/// `flex items-center`. Without it, smaller fixed-size children
85/// (badges, icons) sit at the top of the row, just like CSS does.
86///
87/// To space children apart, set `.gap(tokens::SPACE_*)` — opt-in
88/// like CSS.
89///
90/// **Smell:** a focusable, keyed `row([column([t1, t2]), button, button])`
91/// used as a clickable resource entry — that's [`item`], not a hand-rolled
92/// row. The named widget gives you hover, press, focus, the rail, and
93/// the slots (`item_media`, `item_content`, `item_actions`) for free.
94///
95/// [`item`]: crate::widgets::item::item
96/// [`toolbar`]: crate::widgets::toolbar::toolbar
97/// [`field_row`]: crate::widgets::form::field_row
98/// [`tabs_list`]: crate::widgets::tabs::tabs_list
99/// [`breadcrumb_list`]: crate::widgets::breadcrumb::breadcrumb_list
100/// [`pagination_content`]: crate::widgets::pagination::pagination_content
101#[track_caller]
102pub fn row<I, E>(children: I) -> El
103where
104    I: IntoIterator<Item = E>,
105    E: Into<El>,
106{
107    El::new(Kind::Group)
108        .at_loc(Location::caller())
109        .children(children)
110        .axis(Axis::Row)
111}
112
113/// An overlay stack; children share the parent's rect.
114///
115/// For modals, sheets, popovers, and tooltips reach for the named
116/// widget instead — [`dialog`], [`sheet`], [`popover`], `.tooltip(...)`.
117/// `stack` is the layered-visuals primitive (focus rings, custom
118/// badges painted over content) that those widgets compose against.
119///
120/// [`dialog`]: crate::widgets::dialog::dialog
121/// [`sheet`]: crate::widgets::sheet::sheet
122/// [`popover`]: crate::widgets::popover::popover
123#[track_caller]
124pub fn stack<I, E>(children: I) -> El
125where
126    I: IntoIterator<Item = E>,
127    E: Into<El>,
128{
129    El::new(Kind::Group)
130        .at_loc(Location::caller())
131        .children(children)
132        .axis(Axis::Overlay)
133}
134
135/// A vertical scroll viewport. Children stack as in [`column()`]; the
136/// container clips overflow and translates content by the current scroll
137/// offset. Wheel events over the viewport update the offset.
138///
139/// Give it a `.key("...")` so the offset persists by name across
140/// rebuilds — without a key, the offset is keyed by sibling index and
141/// resets if structure shifts.
142#[track_caller]
143pub fn scroll<I, E>(children: I) -> El
144where
145    I: IntoIterator<Item = E>,
146    E: Into<El>,
147{
148    El::new(Kind::Scroll)
149        .at_loc(Location::caller())
150        .children(children)
151        .axis(Axis::Column)
152        .width(Size::Fill(1.0))
153        .height(Size::Fill(1.0))
154        .clip()
155        .scrollable()
156        .scrollbar()
157}
158
159/// A pan/zoom viewport — a clipped window onto a content layer the user
160/// can drag to pan and wheel to zoom (anchored under the cursor). The
161/// CSS `overflow: hidden` wrapper around a `transform: translate() scale()`
162/// content box, with the gestures built in.
163///
164/// `children` are laid out once in un-transformed **content space** (as
165/// if they filled the viewport); the layout pass then bakes the current
166/// pan/zoom into their rects, and paint scales their `font_size` /
167/// `padding` / `radius` / `stroke` / `shadow` to match — so nothing inside
168/// needs to multiply by the zoom by hand. Because the transform lands in
169/// the computed rects, hit-testing, links, and selection work through the
170/// zoom automatically.
171///
172/// Give it a `.key("...")` so the pan/zoom persists by name across
173/// rebuilds (like [`scroll()`]). Tune the zoom range with
174/// [`min_zoom`](El::min_zoom) / [`max_zoom`](El::max_zoom) and the pan
175/// gesture with [`pan_button`](El::pan_button) / [`pan_modifier`](El::pan_modifier).
176/// Drive it programmatically (fit-to-content, reset, center) with
177/// [`crate::viewport::ViewportRequest`], or let the widget keep the
178/// content framed itself with [`fit_policy`](El::fit_policy)
179/// ([`FitPolicy::Contain`](crate::viewport::FitPolicy::Contain) for
180/// viewers that open fit and release on the first gesture,
181/// [`Lock`](crate::viewport::FitPolicy::Lock) for always-fit thumbnails).
182/// Chrome reads the view back with
183/// [`BuildCx::viewport_view`](crate::event::BuildCx::viewport_view) /
184/// [`viewport_at_home`](crate::event::BuildCx::viewport_at_home) /
185/// [`viewport_content_bounds`](crate::event::BuildCx::viewport_content_bounds).
186#[track_caller]
187pub fn viewport<I, E>(children: I) -> El
188where
189    I: IntoIterator<Item = E>,
190    E: Into<El>,
191{
192    El::new(Kind::Viewport)
193        .at_loc(Location::caller())
194        .children(children)
195        .axis(Axis::Overlay)
196        .width(Size::Fill(1.0))
197        .height(Size::Fill(1.0))
198        .clip()
199        .with_viewport_cfg(crate::viewport::ViewportConfig::default())
200}
201
202/// Scale `child` to the largest size that *fits inside* this container
203/// while preserving `aspect` (width ÷ height), centered — the CSS
204/// `object-fit: contain` shape for arbitrary subtrees (the [`image()`]
205/// and [`surface()`] builders have it built in via
206/// [`crate::image::ImageFit`]; this brings the same math to anything
207/// else: an SVG preview pane, a fixed-ratio chart, a video frame
208/// wrapper).
209///
210/// The container fills its parent by default (override with the usual
211/// size modifiers — any non-`Hug` size works); the child is laid out
212/// at the fitted rect, letterboxed on the slack axis. Aspect must be
213/// positive.
214///
215/// When the ratio should come from the child's own intrinsic size
216/// instead, use [`fit_contain_intrinsic`].
217#[track_caller]
218pub fn fit_contain(child: impl Into<El>, aspect: f32) -> El {
219    assert!(
220        aspect > 0.0 && aspect.is_finite(),
221        "fit_contain: aspect must be a positive ratio (got {aspect})"
222    );
223    El::new(Kind::Group)
224        .at_loc(Location::caller())
225        .child(child)
226        .width(Size::Fill(1.0))
227        .height(Size::Fill(1.0))
228        .layout(move |ctx| vec![fit_rect(ctx.container, aspect, /* cover: */ false)])
229}
230
231/// [`fit_contain`] with the aspect ratio taken from the child's
232/// intrinsic `(width, height)` measure each layout. Falls back to
233/// filling the container when the child has no measurable size (e.g.
234/// an empty group).
235#[track_caller]
236pub fn fit_contain_intrinsic(child: impl Into<El>) -> El {
237    El::new(Kind::Group)
238        .at_loc(Location::caller())
239        .child(child)
240        .width(Size::Fill(1.0))
241        .height(Size::Fill(1.0))
242        .layout(move |ctx| {
243            let (w, h) = (ctx.measure)(&ctx.children[0]);
244            if w <= 0.0 || h <= 0.0 {
245                return vec![ctx.container];
246            }
247            vec![fit_rect(ctx.container, w / h, /* cover: */ false)]
248        })
249}
250
251/// Scale `child` to the smallest size that *covers* this container
252/// while preserving `aspect` (width ÷ height), centered and clipped —
253/// the CSS `object-fit: cover` shape for arbitrary subtrees. The
254/// overflow on the slack axis is clipped to the container. Aspect must
255/// be positive.
256#[track_caller]
257pub fn fit_cover(child: impl Into<El>, aspect: f32) -> El {
258    assert!(
259        aspect > 0.0 && aspect.is_finite(),
260        "fit_cover: aspect must be a positive ratio (got {aspect})"
261    );
262    El::new(Kind::Group)
263        .at_loc(Location::caller())
264        .child(child)
265        .width(Size::Fill(1.0))
266        .height(Size::Fill(1.0))
267        .clip()
268        .layout(move |ctx| vec![fit_rect(ctx.container, aspect, /* cover: */ true)])
269}
270
271/// The centered rect of ratio `aspect` that fits inside (`cover =
272/// false`) or covers (`cover = true`) `container`.
273fn fit_rect(container: super::geometry::Rect, aspect: f32, cover: bool) -> super::geometry::Rect {
274    let (cw, ch) = (container.w.max(0.0), container.h.max(0.0));
275    let scale_w = cw / aspect; // height if width-constrained
276    let (w, h) = if (ch <= scale_w) != cover {
277        // Height is the binding axis.
278        (ch * aspect, ch)
279    } else {
280        // Width is the binding axis.
281        (cw, scale_w)
282    };
283    super::geometry::Rect::new(
284        container.x + (cw - w) / 2.0,
285        container.y + (ch - h) / 2.0,
286        w,
287        h,
288    )
289}
290
291/// Block whose direct children flow inline (text leaves + embeds +
292/// hard breaks). Models HTML's `<p>` shape: heterogeneous children,
293/// attributed runs, optional inline embeds. Children are styled via
294/// the existing modifier chain (`.bold()`, `.italic()`, `.color(c)`,
295/// `.code()`, `.link(url)`, etc.) — there is no parallel
296/// `RichText`/`TextRun` type.
297///
298/// ```ignore
299/// text_runs([
300///     text("Damascene — "),
301///     text("rich text").bold(),
302///     text(" composition."),
303///     hard_break(),
304///     text("Custom shaders, custom layouts, "),
305///     text("virtual_list").code(),
306///     text(" — and inline runs."),
307/// ])
308/// ```
309#[track_caller]
310pub fn text_runs<I, E>(children: I) -> El
311where
312    I: IntoIterator<Item = E>,
313    E: Into<El>,
314{
315    El::new(Kind::Inlines)
316        .at_loc(Location::caller())
317        .axis(Axis::Column)
318        .align(Align::Start)
319        .width(Size::Fill(1.0))
320        .children(children)
321}
322
323/// Forced line break inside a [`text_runs`] block. Mirrors HTML's
324/// `<br>`. Outside an `Inlines` parent, lays out as a zero-size leaf.
325#[track_caller]
326pub fn hard_break() -> El {
327    El::new(Kind::HardBreak)
328        .at_loc(Location::caller())
329        .width(Size::Hug)
330        .height(Size::Hug)
331}
332
333/// Native mathematical notation. Alias for [`math_inline`].
334#[track_caller]
335pub fn math(expr: impl Into<Arc<MathExpr>>) -> El {
336    math_inline(expr)
337}
338
339/// Math notation in compact in-line style (TeX text style); the El
340/// hugs the rendered expression.
341#[track_caller]
342pub fn math_inline(expr: impl Into<Arc<MathExpr>>) -> El {
343    El::new(Kind::Math)
344        .at_loc(Location::caller())
345        .math_expr(expr)
346        .math_display(MathDisplay::Inline)
347        .width(Size::Hug)
348        .height(Size::Hug)
349}
350
351/// Math notation in display style for standalone equations; fills the
352/// available width.
353#[track_caller]
354pub fn math_block(expr: impl Into<Arc<MathExpr>>) -> El {
355    El::new(Kind::Math)
356        .at_loc(Location::caller())
357        .math_expr(expr)
358        .math_display(MathDisplay::Block)
359        .width(Size::Fill(1.0))
360        .height(Size::Hug)
361}
362
363/// Virtualized vertical list of `count` rows of fixed height
364/// `row_height`. Two contracts worth knowing:
365///
366/// - The row builder is `Fn + Send + Sync + 'static` (it's retained in
367///   the tree), so display data it captures must be shared, not
368///   borrowed — `Arc<Mutex<Vec<Row>>>` (or `Arc<RwLock<…>>`) on the
369///   app struct, with a clone moved into the closure, is the intended
370///   shape for data that mutates between frames.
371/// - The builder runs **every frame** for each visible row. Side
372///   effects inside it (queueing a thumbnail decode, a stat call) must
373///   be deduplicated by the app, and `BuildCx::visible_range` gives
374///   the eviction signal for results whose rows scrolled away.
375///
376/// The library calls `build_row(i)` only for indices
377/// whose rect intersects the visible viewport, then lays them out at
378/// the scroll-shifted Y. `.gap(...)` contributes spacing between rows,
379/// matching column-style layout. Authors typically key rows with a
380/// stable identifier (`button("foo").key("msg-abc")`) so hover/press/
381/// focus state survives scrolling.
382///
383/// The returned El defaults to `Size::Fill(1.0)` on both axes (it's a
384/// viewport — its size is decided by the parent). `Size::Hug` would
385/// defeat virtualization and panics at layout time.
386#[track_caller]
387pub fn virtual_list<F>(count: usize, row_height: f32, build_row: F) -> El
388where
389    F: Fn(usize) -> El + Send + Sync + 'static,
390{
391    let mut el = El::new(Kind::VirtualList)
392        .at_loc(Location::caller())
393        .axis(Axis::Column)
394        .align(Align::Stretch)
395        .width(Size::Fill(1.0))
396        .height(Size::Fill(1.0))
397        .clip()
398        .scrollable()
399        .scrollbar();
400    el.virtual_items = Some(Box::new(VirtualItems::new(count, row_height, build_row)));
401    el
402}
403
404/// Variable-height variant of [`virtual_list`]. `row_key(i)` must
405/// return a stable identity for the logical row at `i`; the dynamic
406/// list uses those identities to preserve an in-viewport anchor while
407/// rows are inserted, removed, measured, or remeasured at a new width.
408/// Each row sizes itself from its own content (`Size::Hug` or
409/// `Size::Fixed` on the main axis); `estimated_row_height` is used for
410/// unmeasured rows when the library positions the visible window and
411/// computes the scrollbar thumb. `.gap(...)` contributes spacing
412/// between rows, matching column-style layout.
413///
414/// Use this when row heights are content-driven (diff hunks, expanded
415/// rows, comment threads) and a single `row_height` would either waste
416/// space or truncate. For genuinely uniform lists prefer
417/// [`virtual_list`] — its O(1) range math is cheaper and free of any
418/// estimate/measure jitter.
419#[track_caller]
420pub fn virtual_list_dyn<K, F>(
421    count: usize,
422    estimated_row_height: f32,
423    row_key: K,
424    build_row: F,
425) -> El
426where
427    K: Fn(usize) -> String + Send + Sync + 'static,
428    F: Fn(usize) -> El + Send + Sync + 'static,
429{
430    let mut el = El::new(Kind::VirtualList)
431        .at_loc(Location::caller())
432        .axis(Axis::Column)
433        .align(Align::Stretch)
434        .width(Size::Fill(1.0))
435        .height(Size::Fill(1.0))
436        .clip()
437        .scrollable()
438        .scrollbar();
439    el.virtual_items = Some(Box::new(VirtualItems::new_dyn(
440        count,
441        estimated_row_height,
442        row_key,
443        build_row,
444    )));
445    el
446}
447
448/// A `Fill(1)` filler. Inside a `row` it pushes siblings to the right;
449/// inside a `column` it pushes siblings to the bottom.
450#[track_caller]
451pub fn spacer() -> El {
452    El::new(Kind::Spacer)
453        .at_loc(Location::caller())
454        .width(Size::Fill(1.0))
455        .height(Size::Fill(1.0))
456}
457
458/// A fixed-column grid — the CSS `display: grid;
459/// grid-template-columns: repeat(cols, 1fr); gap: G` shape. Cells pack
460/// left-to-right, top-to-bottom into rows of `cols` equal-width
461/// columns separated by `gap` on both axes; the final partial row is
462/// padded with invisible fillers so its cells align with the columns
463/// above. Focusable cells get 2D arrow-key navigation
464/// ([`crate::tree::ArrowNav::Grid`]) for free.
465///
466/// Each cell is stretched to its column width (`Size::Fill`); give
467/// cells an explicit `.height(...)` (or let content hug). For large /
468/// unbounded item sets, use [`virtual_grid`].
469///
470/// Derive `cols` from the viewport for responsive galleries:
471/// `(cx.viewport_width().unwrap_or(1280.0) / MIN_CELL_W).max(1.0) as
472/// usize` — the same value is available in `on_event` via
473/// `EventCx::viewport_width`, so navigation math agrees with layout.
474#[track_caller]
475pub fn grid<I, E>(cols: usize, gap: f32, cells: I) -> El
476where
477    I: IntoIterator<Item = E>,
478    E: Into<El>,
479{
480    let cols = cols.max(1);
481    let cells: Vec<El> = cells.into_iter().map(Into::into).collect();
482    let rows: Vec<El> = cells
483        .chunks(cols)
484        .map(|chunk| grid_row(chunk.len(), cols, gap, chunk.iter().cloned()))
485        .collect();
486    El::new(Kind::Group)
487        .at_loc(Location::caller())
488        .children(rows)
489        .axis(Axis::Column)
490        .gap(gap)
491        .width(Size::Fill(1.0))
492        .arrow_nav(crate::tree::ArrowNav::Grid)
493}
494
495/// Virtualized fixed-column grid over `count` items of uniform
496/// `cell_height` — the photo-wall / thumbnail-browser shape. Wraps
497/// [`virtual_list`] with the row/column packing every consumer was
498/// hand-rolling: rows of `cols` equal-width cells, `gap` on both axes,
499/// partial tail row padded so columns align.
500///
501/// `build_cell(i)` is called only for items whose row intersects the
502/// viewport, every frame they're visible — the same contract (and the
503/// same `Send + Sync + 'static` capture rules and side-effect dedup
504/// responsibility) as [`virtual_list`] row builders. Query the visible
505/// item range as `visible_range(key)` row range × `cols`.
506///
507/// Programmatic scrolling: `ScrollRequest` rows are item `index /
508/// cols`. Arrow-key navigation covers the *realized* rows (the
509/// virtualization window); stepping focus past the realized edge is a
510/// known gap shared with all virtualized content.
511#[track_caller]
512pub fn virtual_grid<F>(count: usize, cols: usize, cell_height: f32, gap: f32, build_cell: F) -> El
513where
514    F: Fn(usize) -> El + Send + Sync + 'static,
515{
516    let cols = cols.max(1);
517    let row_count = count.div_ceil(cols);
518    let build_cell = Arc::new(build_cell);
519    let mut el = El::new(Kind::VirtualList)
520        .at_loc(Location::caller())
521        .axis(Axis::Column)
522        .align(Align::Stretch)
523        .gap(gap)
524        .width(Size::Fill(1.0))
525        .height(Size::Fill(1.0))
526        .clip()
527        .scrollable()
528        .scrollbar()
529        .arrow_nav(crate::tree::ArrowNav::Grid);
530    el.virtual_items = Some(Box::new(VirtualItems::new(row_count, cell_height, {
531        let build_cell = Arc::clone(&build_cell);
532        move |row| {
533            let start = row * cols;
534            let filled = cols.min(count - start);
535            grid_row(
536                filled,
537                cols,
538                gap,
539                (start..start + filled).map(|i| build_cell(i)),
540            )
541        }
542    })));
543    el
544}
545
546/// One packed grid row: `filled` real cells plus invisible fillers out
547/// to `cols`, each at equal `Fill` width, separated by `gap`.
548fn grid_row(filled: usize, cols: usize, gap: f32, cells: impl Iterator<Item = El>) -> El {
549    let mut children: Vec<El> = cells
550        .take(filled)
551        .map(|c| c.width(Size::Fill(1.0)))
552        .collect();
553    for _ in filled..cols {
554        // Invisible filler keeps the partial tail row's columns aligned
555        // with the rows above. Plain group, not `spacer()` — spacers
556        // fill height too, which would stretch a hugging row.
557        children.push(
558            El::new(Kind::Group)
559                .width(Size::Fill(1.0))
560                .height(Size::Fixed(0.0)),
561        );
562    }
563    El::new(Kind::Group)
564        .axis(Axis::Row)
565        .gap(gap)
566        .width(Size::Fill(1.0))
567        .children(children)
568}
569
570/// A raster image element. The El hugs the image's natural pixel
571/// size by default; set [`El::width`] / [`El::height`] for an
572/// explicit box, and [`El::image_fit`] to control projection.
573///
574/// ```
575/// use damascene_core::prelude::*;
576/// let pixels = vec![0u8; 4 * 4 * 4];
577/// let img = Image::from_rgba8(4, 4, pixels);
578/// let _ = image(img).image_fit(ImageFit::Cover).radius(8.0);
579/// ```
580#[track_caller]
581pub fn image(img: impl Into<Image>) -> El {
582    El::new(Kind::Image).at_loc(Location::caller()).image(img)
583}
584
585/// An app-supplied vector asset. By default Damascene preserves authored
586/// fills, strokes, and gradients through the painted vector path; call
587/// [`El::vector_mask`] when the asset should be treated as a one-colour
588/// coverage mask. Companion to [`crate::icon`] for content that
589/// doesn't fit icon conventions: arbitrary-aspect bounding boxes,
590/// programmatic construction each frame. Pairs with
591/// [`crate::vector::PathBuilder`] for ergonomic path construction.
592///
593/// # Sizing
594///
595/// The default size matches the asset's view-box dimensions in logical
596/// pixels. Set [`El::width`] / [`El::height`] / [`El::fill_size`] to
597/// override. Painted vectors are tessellated into the resolved rect;
598/// mask vectors sample the backend MSDF atlas across that rect.
599///
600/// # Caching
601///
602/// The asset's [`VectorAsset::content_hash`](crate::vector::VectorAsset::content_hash)
603/// is the backend cache key. Apps that build the same shape twice (two
604/// commits sharing a merge connector geometry, two flowchart edges with
605/// the same arc) can share backend work; per-frame-unique geometry gets
606/// one cache entry per unique shape.
607///
608/// ```ignore
609/// use damascene_core::prelude::*;
610/// use damascene_core::tree::Color;
611///
612/// let curve = PathBuilder::new()
613///     .move_to(0.0, 0.0)
614///     .cubic_to(20.0, 0.0, 0.0, 60.0, 20.0, 60.0)
615///     .stroke_solid(Color::srgb_u8(80, 200, 240), 2.0)
616///     .stroke_line_cap(VectorLineCap::Round)
617///     .build();
618/// let asset = VectorAsset::from_paths([0.0, 0.0, 20.0, 60.0], vec![curve]);
619/// let _ = vector(asset);
620/// ```
621#[track_caller]
622pub fn vector(asset: crate::vector::VectorAsset) -> El {
623    let [_, _, vw, vh] = asset.view_box;
624    El::new(Kind::Vector)
625        .at_loc(Location::caller())
626        .width(Size::Fixed(vw.max(0.0)))
627        .height(Size::Fixed(vh.max(0.0)))
628        .vector_source(std::sync::Arc::new(asset))
629}
630
631/// An app-owned-texture surface. Damascene composites the texture into
632/// the paint stream at the El's resolved rect — no upload, no per-frame
633/// copy. The default size matches the texture's pixel dimensions; set
634/// [`El::width`] / [`El::height`] (or `.fill_size()`) for an explicit
635/// box.
636///
637/// # Sizing, projection, and transforms
638///
639/// The texture's pixel dimensions are **independent of the rendered
640/// size**. By default the widget stretches the texture across the
641/// resolved rect ([`crate::image::ImageFit::Fill`]); reach for
642/// [`El::surface_fit`] to letterbox-preserve aspect ratio
643/// ([`crate::image::ImageFit::Contain`]), crop-cover
644/// ([`crate::image::ImageFit::Cover`]), or paint at natural size
645/// ([`crate::image::ImageFit::None`]). [`El::surface_transform`]
646/// composes an affine on top — rotate, mirror, zoom/pan — applied
647/// around the centre of the post-fit rect.
648///
649/// Picking a sizing strategy:
650/// - For pixel-accurate display, size the widget to the texture's
651///   pixel dimensions (the default constructor does this for you).
652/// - For a 3D viewport or video frame whose source resolution should
653///   track the rendered size, the app should re-allocate its texture
654///   to match the resolved rect (read it via `UiState::rect_of_key`
655///   after `prepare()`).
656/// - For an animated image whose natural dimensions are fixed
657///   (decoded GIF / WebP / APNG, decoded video frame),
658///   `surface_fit(Contain)` letterboxes into any layout rect with
659///   no per-resize allocation.
660///
661/// # z-order, scissor, hit-test
662///
663/// The widget participates in layout, scissor, scrolling, hit-test,
664/// and z-order like any other El: siblings declared before this one
665/// paint underneath, siblings after paint on top. The auto-clip
666/// scissor clamps painted content to the El's content rect — affines
667/// or `Cover` projections that overflow are cropped.
668///
669/// ```ignore
670/// // Pseudocode — the AppTexture comes from a backend constructor.
671/// use damascene_core::prelude::*;
672/// let tex: AppTexture = /* damascene_wgpu::app_texture(...) */ todo!();
673/// let _ = surface(tex)
674///     .fill_size()
675///     .surface_fit(ImageFit::Contain)
676///     .surface_alpha(SurfaceAlpha::Opaque)
677///     .surface_transform(Affine2::rotate(0.1));
678/// ```
679#[track_caller]
680pub fn surface(texture: crate::surface::AppTexture) -> El {
681    let (w, h) = texture.size_px();
682    El::new(Kind::Surface)
683        .at_loc(Location::caller())
684        .width(Size::Fixed(w as f32))
685        .height(Size::Fixed(h as f32))
686        .surface_source(crate::surface::SurfaceSource::Texture(texture))
687}
688
689/// A small, polished, hardware-accelerated 3D scene — point scatter, lit
690/// meshes, and lines — as a first-class element. Unlike [`surface`], the
691/// app does not own a renderer or a device: it describes the scene with a
692/// [`crate::scene::SceneSpec`] and the backend renders it. Fills its area
693/// by default (it has no intrinsic pixel size); size it like any El.
694///
695/// Two GPU-asynchrony notes worth knowing up front:
696/// - **Hover picks are a frame late** — see
697///   [`BuildCx::hovered_scene_point`](crate::BuildCx::hovered_scene_point).
698/// - **Label depth-occlusion lags similarly**: labels are culled against
699///   a depth map read back asynchronously from the previous frame, and
700///   are hidden until the first map arrives. During fast camera motion a
701///   label can appear/disappear a frame later than the geometry that
702///   occludes it; at interactive orbit speeds this is imperceptible.
703///
704/// Geometry handles ([`crate::scene::PointsHandle`] et al.) must be
705/// created once and cached in app state — see the handle-pattern notes
706/// on [`crate::scene::GeometryHandle`].
707///
708/// ```ignore
709/// use damascene_core::prelude::*;
710/// use damascene_core::scene::{GridPlanes, SceneSpec};
711///
712/// let scene = SceneSpec::new().points(scatter).mesh(model).grid(GridPlanes::XZ);
713/// let _ = chart3d(scene).key("scene");
714/// ```
715#[track_caller]
716pub fn chart3d(scene: crate::scene::SceneSpec) -> El {
717    El::new(Kind::Scene3D)
718        .at_loc(Location::caller())
719        .width(Size::Fill(1.0))
720        .height(Size::Fill(1.0))
721        .scene_source(scene)
722}
723
724/// A 2D plot: line/scatter data over auto-scaled, pannable/zoomable axes.
725///
726/// Backed by [`crate::plot::PlotSpec`] — a fluent builder of marks + axis
727/// scales — and resolved (in `draw_ops`) to an orthographic
728/// `DrawOp::Scene3D` data layer plus themed axis/grid/crosshair chrome (see
729/// `docs/PLOT2D_PLAN.md`). Fills its area like `chart3d`; give it a
730/// `.key(...)` so its pan/zoom [`crate::plot::PlotView`] persists across
731/// rebuilds and can be read back for the virtual-data pull.
732///
733/// ```ignore
734/// use damascene_core::prelude::*;
735/// use damascene_core::plot::{PlotSpec, Scale};
736///
737/// let spec = PlotSpec::new().x(Scale::time()).line(&cpu).line(&mem);
738/// let _ = plot(spec).key("metrics");
739/// ```
740#[track_caller]
741pub fn plot(spec: crate::plot::PlotSpec) -> El {
742    El::new(Kind::Plot)
743        .at_loc(Location::caller())
744        .width(Size::Fill(1.0))
745        .height(Size::Fill(1.0))
746        .plot_source(spec)
747}
748
749/// A 1-pixel separator line.
750#[track_caller]
751pub fn divider() -> El {
752    El::new(Kind::Divider)
753        .at_loc(Location::caller())
754        .height(Size::Fixed(1.0))
755        .width(Size::Fill(1.0))
756        .fill(crate::tokens::BORDER)
757}
758
759// ---------- &str → El convenience ----------
760//
761// Lets `titled_card("Title", ["a body line"])` work without `text(...)`.
762
763impl From<&str> for El {
764    fn from(s: &str) -> Self {
765        crate::widgets::text::text(s)
766    }
767}
768
769impl From<String> for El {
770    fn from(s: String) -> Self {
771        crate::widgets::text::text(s)
772    }
773}
774
775impl From<&String> for El {
776    fn from(s: &String) -> Self {
777        crate::widgets::text::text(s.as_str())
778    }
779}