Skip to main content

panes_css/
emit.rs

1use std::fmt::Write as _;
2
3// `fmt::Write` for `String` is infallible. `let _ =` discards the unused `Result`.
4
5use panes::Direction;
6use panes::{
7    Align, Constraints, ExtentValue, HAlign, Layout, LayoutTree, Node, NodeId, OverlayAnchor,
8    OverlayDef, SizeMode, VAlign,
9};
10
11/// Mutable state threaded through recursive CSS emission.
12struct EmitCtx {
13    css: String,
14    counter: u32,
15    root_position_relative: bool,
16    transitions: bool,
17}
18
19/// Emit a CSS string from a `Layout` tree.
20///
21/// The browser acts as the layout solver via flexbox properties.
22/// Panels use `[data-pane="kind"]` selectors, containers use
23/// `[data-pane-node="N"]`, and the root uses `[data-pane-root]`.
24pub fn emit(layout: &Layout) -> String {
25    emit_tree(layout, false, false)
26}
27
28/// Emit CSS including absolute-positioned overlay rules.
29///
30/// The root selector gets `position: relative` so overlays can anchor
31/// against it. Each `OverlayDef` produces a `[data-pane-overlay="kind"]`
32/// rule with positioning, size, and z-index.
33pub fn emit_with_overlays(layout: &Layout, overlays: &[OverlayDef]) -> String {
34    emit_with_options(layout, overlays, false)
35}
36
37/// Emit CSS with transition properties on all panel selectors.
38///
39/// The root gets a `--pane-transition` custom property. Each panel selector
40/// gets a `transition` shorthand referencing that variable for position and
41/// size properties.
42pub fn emit_with_transitions(layout: &Layout) -> String {
43    emit_tree(layout, false, true)
44}
45
46/// Emit CSS with both overlay positioning and transition properties.
47pub fn emit_full(layout: &Layout, overlays: &[OverlayDef]) -> String {
48    emit_with_options(layout, overlays, true)
49}
50
51fn emit_with_options(layout: &Layout, overlays: &[OverlayDef], transitions: bool) -> String {
52    let mut css = emit_tree(layout, !overlays.is_empty(), transitions);
53    for (i, def) in overlays.iter().enumerate() {
54        write_overlay_rule(def, i + 1, &mut css);
55    }
56    css
57}
58
59fn emit_tree(layout: &Layout, root_position_relative: bool, transitions: bool) -> String {
60    let tree = layout.tree();
61    let Some(root_id) = tree.root() else {
62        return String::new();
63    };
64    let estimated_bytes = tree.node_count() * 80;
65    let mut ctx = EmitCtx {
66        css: String::with_capacity(estimated_bytes),
67        counter: 0,
68        root_position_relative,
69        transitions,
70    };
71    emit_node(tree, root_id, Direction::Horizontal, true, &mut ctx);
72    ctx.css
73}
74
75fn emit_node(
76    tree: &LayoutTree,
77    nid: NodeId,
78    parent_axis: Direction,
79    is_root: bool,
80    ctx: &mut EmitCtx,
81) {
82    let Some(node) = tree.node(nid) else { return };
83    match node {
84        Node::Panel {
85            kind, constraints, ..
86        } => {
87            write_panel_rule(
88                kind,
89                constraints,
90                parent_axis,
91                ctx.transitions,
92                &mut ctx.css,
93            );
94        }
95        Node::Row { gap, children } => {
96            emit_flex_container(
97                tree,
98                children,
99                "row",
100                *gap,
101                Direction::Horizontal,
102                is_root,
103                ctx,
104            );
105        }
106        Node::Col { gap, children } => {
107            emit_flex_container(
108                tree,
109                children,
110                "column",
111                *gap,
112                Direction::Vertical,
113                is_root,
114                ctx,
115            );
116        }
117        Node::TaffyPassthrough { style, children } if style.display == taffy::Display::Grid => {
118            write_container_selector(is_root, &mut ctx.counter, &mut ctx.css);
119            write_grid_rule(style, is_root, &mut ctx.css);
120            inject_root_extras(is_root, ctx);
121            emit_grid_children(tree, children, ctx);
122        }
123        Node::TaffyPassthrough { style, children }
124            if is_scrollable_container(style, tree, children) =>
125        {
126            write_container_selector(is_root, &mut ctx.counter, &mut ctx.css);
127            let axis = scroll_axis(style);
128            write_scrollable_rule(axis, is_root, &mut ctx.css);
129            inject_root_extras(is_root, ctx);
130            emit_scrollable_children(tree, children, axis, ctx);
131        }
132        Node::TaffyPassthrough { children, .. } => {
133            write_container_selector(is_root, &mut ctx.counter, &mut ctx.css);
134            write_passthrough_rule(is_root, &mut ctx.css);
135            inject_root_extras(is_root, ctx);
136            emit_children(tree, children, parent_axis, ctx);
137        }
138    }
139}
140
141/// Append root-only properties. Called after the rule body is written but
142/// before the closing ` }\n`. The rule writers end with ` }\n`, so we replace
143/// the last 3 bytes with the extras and re-close.
144///
145/// This approach keeps each rule writer self-contained while allowing the root
146/// to inject additional properties.
147fn inject_root_extras(is_root: bool, ctx: &mut EmitCtx) {
148    let extra = match (is_root, ctx.root_position_relative, ctx.transitions) {
149        (true, true, true) => " position: relative; --pane-transition: 0.2s ease;",
150        (true, true, false) => " position: relative;",
151        (true, false, true) => " --pane-transition: 0.2s ease;",
152        _ => return,
153    };
154    // Rule writers close with " }\n" (3 bytes). Reopen, append, re-close.
155    debug_assert!(ctx.css.ends_with(" }\n"));
156    ctx.css.truncate(ctx.css.len() - 3);
157    ctx.css.push_str(extra);
158    ctx.css.push_str(" }\n");
159}
160
161fn emit_children(tree: &LayoutTree, children: &[NodeId], axis: Direction, ctx: &mut EmitCtx) {
162    for &child_id in children {
163        emit_node(tree, child_id, axis, false, ctx);
164    }
165}
166
167/// Write the selector portion of a container rule directly into the buffer.
168fn write_container_selector(is_root: bool, counter: &mut u32, css: &mut String) {
169    match is_root {
170        true => css.push_str("[data-pane-root]"),
171        false => {
172            *counter += 1;
173            let _ = write!(css, "[data-pane-node=\"{}\"]", counter);
174        }
175    }
176}
177
178fn emit_flex_container(
179    tree: &LayoutTree,
180    children: &[NodeId],
181    direction: &str,
182    gap: f32,
183    axis: Direction,
184    is_root: bool,
185    ctx: &mut EmitCtx,
186) {
187    write_container_selector(is_root, &mut ctx.counter, &mut ctx.css);
188    let _ = write!(
189        ctx.css,
190        " {{ display: flex; flex-direction: {direction}; gap: {gap}px;"
191    );
192    write_container_flex(is_root, &mut ctx.css);
193    inject_root_extras(is_root, ctx);
194    emit_children(tree, children, axis, ctx);
195}
196
197fn write_container_flex(is_root: bool, css: &mut String) {
198    if !is_root {
199        css.push_str(" flex-grow: 1; flex-basis: 0px; flex-shrink: 1;");
200    }
201    css.push_str(" }\n");
202}
203
204fn write_panel_rule(
205    kind: &str,
206    constraints: &Constraints,
207    parent_axis: Direction,
208    transitions: bool,
209    css: &mut String,
210) {
211    let _ = write!(css, "[data-pane=\"{kind}\"] {{ ");
212    write_flex_sizing(constraints, parent_axis, css);
213    write_min_max(constraints, parent_axis, css);
214    write_cross_axis_constraints(constraints, css);
215    write_align_self(constraints, css);
216    write_transition(transitions, css);
217    css.push_str(" }\n");
218}
219
220enum GridMode {
221    Fixed(usize),
222    AutoRepeat { kind: &'static str, min_px: f32 },
223}
224
225fn auto_repeat_kind(count: taffy::style::RepetitionCount) -> Option<&'static str> {
226    match count {
227        taffy::style::RepetitionCount::AutoFill => Some("auto-fill"),
228        taffy::style::RepetitionCount::AutoFit => Some("auto-fit"),
229        taffy::style::RepetitionCount::Count(_) => None,
230    }
231}
232
233fn detect_grid_mode(columns: &[taffy::style::GridTemplateComponent<String>]) -> GridMode {
234    let Some(taffy::style::GridTemplateComponent::Repeat(rep)) = columns.first() else {
235        return GridMode::Fixed(columns.len());
236    };
237    let Some(kind) = auto_repeat_kind(rep.count) else {
238        return GridMode::Fixed(columns.len());
239    };
240    let min_px = rep
241        .tracks
242        .first()
243        .map(|t| t.min_sizing_function().into_raw().value())
244        .unwrap_or(0.0);
245    GridMode::AutoRepeat { kind, min_px }
246}
247
248fn write_grid_rule(style: &taffy::Style, is_root: bool, css: &mut String) {
249    css.push_str(" { display: grid;");
250    match detect_grid_mode(&style.grid_template_columns) {
251        GridMode::Fixed(cols) => {
252            let _ = write!(css, " grid-template-columns: repeat({cols}, 1fr);");
253        }
254        GridMode::AutoRepeat { kind, min_px } => {
255            let _ = write!(
256                css,
257                " grid-template-columns: repeat({kind}, minmax({min_px}px, 1fr));"
258            );
259        }
260    }
261    match style.grid_auto_rows.is_empty() {
262        true => {}
263        false if is_auto_rows(&style.grid_auto_rows) => {
264            css.push_str(" grid-auto-rows: auto;");
265        }
266        false => css.push_str(" grid-auto-rows: 1fr;"),
267    }
268    let gap = style.gap.width.into_raw().value();
269    if gap > 0.0 {
270        let _ = write!(css, " gap: {gap}px;");
271    }
272    if !is_root {
273        css.push_str(" flex-grow: 1; flex-basis: 0px;");
274    }
275    css.push_str(" }\n");
276}
277
278fn emit_grid_children(tree: &LayoutTree, children: &[NodeId], ctx: &mut EmitCtx) {
279    for &child_id in children {
280        match tree.node(child_id) {
281            Some(Node::TaffyPassthrough { style, .. }) => {
282                write_container_selector(false, &mut ctx.counter, &mut ctx.css);
283                write_grid_card_rule(style, &mut ctx.css);
284                emit_grid_card_panels(tree, child_id, ctx.transitions, &mut ctx.css);
285            }
286            Some(Node::Panel {
287                kind, constraints, ..
288            }) => {
289                write_panel_rule(
290                    kind,
291                    constraints,
292                    Direction::Horizontal,
293                    ctx.transitions,
294                    &mut ctx.css,
295                );
296            }
297            _ => {}
298        }
299    }
300}
301
302fn write_grid_card_rule(style: &taffy::Style, css: &mut String) {
303    css.push_str(" { display: flex;");
304    match grid_column_placement(style) {
305        GridColumnPlacement::FullWidth => {
306            css.push_str(" grid-column: 1 / -1;");
307        }
308        GridColumnPlacement::Span(n) if n > 1 => {
309            let _ = write!(css, " grid-column: span {n};");
310        }
311        _ => {}
312    }
313    css.push_str(" flex-grow: 1; flex-basis: 0px; flex-shrink: 1; }\n");
314}
315
316fn emit_grid_card_panels(tree: &LayoutTree, card_id: NodeId, transitions: bool, css: &mut String) {
317    let Some(node) = tree.node(card_id) else {
318        return;
319    };
320    for &grandchild in node.children() {
321        let Some(Node::Panel {
322            kind, constraints, ..
323        }) = tree.node(grandchild)
324        else {
325            continue;
326        };
327        write_panel_rule(kind, constraints, Direction::Horizontal, transitions, css);
328    }
329}
330
331enum GridColumnPlacement {
332    Span(u16),
333    FullWidth,
334}
335
336fn grid_column_placement(style: &taffy::Style) -> GridColumnPlacement {
337    match (&style.grid_column.start, &style.grid_column.end) {
338        (taffy::GridPlacement::Line(s), taffy::GridPlacement::Line(e))
339            if s.as_i16() == 1 && e.as_i16() == -1 =>
340        {
341            GridColumnPlacement::FullWidth
342        }
343        (_, taffy::GridPlacement::Span(n)) => GridColumnPlacement::Span(*n),
344        _ => GridColumnPlacement::Span(1),
345    }
346}
347
348fn is_auto_rows(tracks: &[taffy::style::TrackSizingFunction]) -> bool {
349    let auto_track = taffy::prelude::minmax(
350        taffy::style::MinTrackSizingFunction::auto(),
351        taffy::style::MaxTrackSizingFunction::auto(),
352    );
353    matches!(tracks.first(), Some(t) if *t == auto_track)
354}
355
356fn write_passthrough_rule(is_root: bool, css: &mut String) {
357    css.push_str(" { display: flex;");
358    write_container_flex(is_root, css);
359}
360
361/// A TaffyPassthrough is scrollable when it uses flex-row, nowrap, and all
362/// children are panels with a fixed width.
363fn is_scrollable_container(style: &taffy::Style, tree: &LayoutTree, children: &[NodeId]) -> bool {
364    style.display == taffy::Display::Flex
365        && matches!(
366            style.flex_direction,
367            taffy::FlexDirection::Row | taffy::FlexDirection::Column
368        )
369        && style.flex_wrap == taffy::FlexWrap::NoWrap
370        && !children.is_empty()
371        && children.iter().all(|&nid| {
372            matches!(
373                tree.node(nid),
374                Some(Node::Panel { constraints, .. }) if constraints.fixed.is_some()
375            )
376        })
377}
378
379#[derive(Clone, Copy)]
380enum ScrollAxis {
381    X,
382    Y,
383}
384
385fn scroll_axis(style: &taffy::Style) -> ScrollAxis {
386    match style.flex_direction {
387        taffy::FlexDirection::Column | taffy::FlexDirection::ColumnReverse => ScrollAxis::Y,
388        _ => ScrollAxis::X,
389    }
390}
391
392fn write_scrollable_rule(axis: ScrollAxis, is_root: bool, css: &mut String) {
393    css.push_str(" { display: flex;");
394    match axis {
395        ScrollAxis::X => {
396            css.push_str(" flex-direction: row;");
397            css.push_str(" overflow-x: auto; scroll-snap-type: x mandatory;");
398        }
399        ScrollAxis::Y => {
400            css.push_str(" flex-direction: column;");
401            css.push_str(" overflow-y: auto; scroll-snap-type: y mandatory;");
402        }
403    }
404    css.push_str(" overscroll-behavior: contain;");
405    write_container_flex(is_root, css);
406}
407
408fn emit_scrollable_children(
409    tree: &LayoutTree,
410    children: &[NodeId],
411    axis: ScrollAxis,
412    ctx: &mut EmitCtx,
413) {
414    let parent_axis = match axis {
415        ScrollAxis::X => Direction::Horizontal,
416        ScrollAxis::Y => Direction::Vertical,
417    };
418    for &child_id in children {
419        let Some(Node::Panel {
420            kind, constraints, ..
421        }) = tree.node(child_id)
422        else {
423            continue;
424        };
425        write_panel_rule(
426            kind,
427            constraints,
428            parent_axis,
429            ctx.transitions,
430            &mut ctx.css,
431        );
432        // Insert scroll-snap-align before the closing " }\n".
433        debug_assert!(ctx.css.ends_with(" }\n"));
434        ctx.css.truncate(ctx.css.len() - 3);
435        ctx.css.push_str(" scroll-snap-align: start; }\n");
436    }
437}
438
439fn write_flex_sizing(constraints: &Constraints, parent_axis: Direction, css: &mut String) {
440    match (constraints.grow, constraints.fixed) {
441        (Some(g), _) => {
442            let _ = write!(css, "flex-grow: {g}; flex-basis: 0px; flex-shrink: 1;");
443        }
444        (_, Some(f)) => {
445            let _ = write!(css, "flex-grow: 0; flex-basis: {f}px; flex-shrink: 0;");
446        }
447        (None, None) => {
448            css.push_str("flex-grow: 1; flex-basis: 0px; flex-shrink: 1;");
449        }
450    }
451    write_size_mode(constraints.size_mode, parent_axis, css);
452}
453
454fn write_size_mode(size_mode: Option<SizeMode>, parent_axis: Direction, css: &mut String) {
455    let Some(mode) = size_mode else { return };
456    let prop = match parent_axis {
457        Direction::Horizontal => "width",
458        Direction::Vertical => "height",
459    };
460    match mode {
461        SizeMode::MinContent => {
462            let _ = write!(css, " flex-basis: min-content; {prop}: min-content;");
463        }
464        SizeMode::MaxContent => {
465            let _ = write!(css, " flex-basis: max-content; {prop}: max-content;");
466        }
467        SizeMode::FitContent(v) => {
468            let _ = write!(
469                css,
470                " flex-basis: fit-content({v}px); {prop}: fit-content({v}px);"
471            );
472        }
473    }
474}
475
476/// Emit CSS with `@media` wrappers for adaptive breakpoints.
477///
478/// Each entry is `(min_width_px, layout)`. Breakpoints must be sorted ascending
479/// by min_width. The first breakpoint gets only a max-width query, the last gets
480/// only a min-width query, and middle breakpoints get both.
481pub fn emit_adaptive(breakpoints: &[(u32, &Layout)]) -> String {
482    let mut css = String::new();
483    let len = breakpoints.len();
484    for (i, (min_width, layout)) in breakpoints.iter().enumerate() {
485        let inner = emit_tree(layout, false, false);
486        match (i, i + 1 < len) {
487            (0, true) => {
488                let next_min = breakpoints[i + 1].0;
489                let _ = write!(
490                    css,
491                    "@media (max-width: {}px) {{\n{inner}}}\n",
492                    next_min.saturating_sub(1)
493                );
494            }
495            (0, false) => {
496                css.push_str(&inner);
497            }
498            (_, true) => {
499                let next_min = breakpoints[i + 1].0;
500                let _ = write!(
501                    css,
502                    "@media (min-width: {min_width}px) and (max-width: {}px) {{\n{inner}}}\n",
503                    next_min.saturating_sub(1)
504                );
505            }
506            (_, false) => {
507                let _ = write!(css, "@media (min-width: {min_width}px) {{\n{inner}}}\n");
508            }
509        }
510    }
511    css
512}
513
514fn write_min_max(constraints: &Constraints, axis: Direction, css: &mut String) {
515    let (min_prop, max_prop) = match axis {
516        Direction::Horizontal => ("min-width", "max-width"),
517        Direction::Vertical => ("min-height", "max-height"),
518    };
519    if let Some(min) = constraints.min {
520        let _ = write!(css, " {min_prop}: {min}px;");
521    }
522    if let Some(max) = constraints.max {
523        let _ = write!(css, " {max_prop}: {max}px;");
524    }
525}
526
527fn write_cross_axis_constraints(constraints: &Constraints, css: &mut String) {
528    if let Some(v) = constraints.min_width {
529        let _ = write!(css, " min-width: {v}px;");
530    }
531    if let Some(v) = constraints.max_width {
532        let _ = write!(css, " max-width: {v}px;");
533    }
534    if let Some(v) = constraints.min_height {
535        let _ = write!(css, " min-height: {v}px;");
536    }
537    if let Some(v) = constraints.max_height {
538        let _ = write!(css, " max-height: {v}px;");
539    }
540}
541
542fn write_align_self(constraints: &Constraints, css: &mut String) {
543    let Some(align) = constraints.align else {
544        return;
545    };
546    let value = match align {
547        Align::Start => "start",
548        Align::Center => "center",
549        Align::End => "end",
550        Align::Stretch => return,
551    };
552    let _ = write!(css, " align-self: {value};");
553}
554
555fn write_transition(transitions: bool, css: &mut String) {
556    match transitions {
557        true => css.push_str(concat!(
558            " transition: left var(--pane-transition),",
559            " top var(--pane-transition),",
560            " width var(--pane-transition),",
561            " height var(--pane-transition);"
562        )),
563        false => {}
564    }
565}
566
567fn write_overlay_rule(def: &OverlayDef, z_index: usize, css: &mut String) {
568    let kind = def.kind();
569    let _ = write!(css, "[data-pane-overlay=\"{kind}\"] {{ position: absolute;");
570    let _ = write!(css, " z-index: {z_index};");
571    write_overlay_anchor(def.anchor(), css);
572    write_overlay_extent("width", def.width(), css);
573    write_overlay_extent("height", def.height(), css);
574    css.push_str(" }\n");
575}
576
577fn write_overlay_anchor(anchor: &OverlayAnchor, css: &mut String) {
578    match anchor {
579        OverlayAnchor::Viewport {
580            h,
581            v,
582            margin_x,
583            margin_y,
584        } => write_viewport_anchor(*h, *v, *margin_x, *margin_y, css),
585        OverlayAnchor::Panel {
586            h,
587            v,
588            offset_x,
589            offset_y,
590            ..
591        } => write_viewport_anchor(*h, *v, *offset_x, *offset_y, css),
592    }
593}
594
595fn write_viewport_anchor(h: HAlign, v: VAlign, margin_x: f32, margin_y: f32, css: &mut String) {
596    let needs_translate_x = matches!(h, HAlign::Center);
597    let needs_translate_y = matches!(v, VAlign::Center);
598
599    match h {
600        HAlign::Left => {
601            let _ = write!(css, " left: {margin_x}px;");
602        }
603        HAlign::Center => {
604            css.push_str(" left: 50%;");
605        }
606        HAlign::Right => {
607            let _ = write!(css, " right: {margin_x}px;");
608        }
609    }
610
611    match v {
612        VAlign::Top => {
613            let _ = write!(css, " top: {margin_y}px;");
614        }
615        VAlign::Center => {
616            css.push_str(" top: 50%;");
617        }
618        VAlign::Bottom => {
619            let _ = write!(css, " bottom: {margin_y}px;");
620        }
621    }
622
623    match (needs_translate_x, needs_translate_y) {
624        (true, true) => css.push_str(" transform: translate(-50%, -50%);"),
625        (true, false) => css.push_str(" transform: translateX(-50%);"),
626        (false, true) => css.push_str(" transform: translateY(-50%);"),
627        (false, false) => {}
628    }
629}
630
631fn write_overlay_extent(prop: &str, extent: &panes::OverlayExtent, css: &mut String) {
632    match extent.value {
633        ExtentValue::Fixed(v) => {
634            let _ = write!(css, " {prop}: {v}px;");
635        }
636        ExtentValue::Percent(pct) => {
637            let _ = write!(css, " {prop}: {pct}%;");
638        }
639        ExtentValue::Full => {
640            let _ = write!(css, " {prop}: 100%;");
641        }
642    }
643    if let Some(min) = extent.min {
644        let _ = write!(css, " min-{prop}: {min}px;");
645    }
646    if let Some(max) = extent.max {
647        let _ = write!(css, " max-{prop}: {max}px;");
648    }
649}