Skip to main content

panes_css/
emit.rs

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