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::compiler::Axis;
6use panes::{Constraints, Layout, LayoutTree, Node, NodeId};
7
8/// Mutable state threaded through recursive CSS emission.
9struct EmitCtx {
10    css: String,
11    counter: u32,
12}
13
14/// Emit a CSS string from a `Layout` tree.
15///
16/// The browser acts as the layout solver via flexbox properties.
17/// Panels use `[data-pane="kind"]` selectors, containers use
18/// `[data-pane-node="N"]`, and the root uses `[data-pane-root]`.
19pub fn emit(layout: &Layout) -> String {
20    let tree = layout.tree();
21    let Some(root_id) = tree.root() else {
22        return String::new();
23    };
24    let mut ctx = EmitCtx {
25        css: String::new(),
26        counter: 0,
27    };
28    emit_node(tree, root_id, Axis::Horizontal, true, &mut ctx);
29    ctx.css
30}
31
32fn emit_node(tree: &LayoutTree, nid: NodeId, parent_axis: Axis, is_root: bool, ctx: &mut EmitCtx) {
33    let Some(node) = tree.node(nid) else { return };
34    match node {
35        Node::Panel {
36            kind, constraints, ..
37        } => {
38            write_panel_rule(kind, constraints, parent_axis, &mut ctx.css);
39        }
40        Node::Row { gap, children } => {
41            let sel = container_selector(is_root, &mut ctx.counter);
42            write_container_rule(&sel, "row", *gap, is_root, &mut ctx.css);
43            emit_children(tree, children, Axis::Horizontal, ctx);
44        }
45        Node::Col { gap, children } => {
46            let sel = container_selector(is_root, &mut ctx.counter);
47            write_container_rule(&sel, "column", *gap, is_root, &mut ctx.css);
48            emit_children(tree, children, Axis::Vertical, ctx);
49        }
50        Node::TaffyPassthrough { style, children } if style.display == taffy::Display::Grid => {
51            let sel = container_selector(is_root, &mut ctx.counter);
52            write_grid_rule(&sel, style, is_root, &mut ctx.css);
53            emit_grid_children(tree, children, &mut ctx.counter, &mut ctx.css);
54        }
55        Node::TaffyPassthrough { children, .. } => {
56            let sel = container_selector(is_root, &mut ctx.counter);
57            write_passthrough_rule(&sel, is_root, &mut ctx.css);
58            emit_children(tree, children, parent_axis, ctx);
59        }
60    }
61}
62
63fn emit_children(tree: &LayoutTree, children: &[NodeId], axis: Axis, ctx: &mut EmitCtx) {
64    for &child_id in children {
65        emit_node(tree, child_id, axis, false, ctx);
66    }
67}
68
69fn container_selector(is_root: bool, counter: &mut u32) -> String {
70    match is_root {
71        true => "[data-pane-root]".to_string(),
72        false => {
73            *counter += 1;
74            format!("[data-pane-node=\"{}\"]", counter)
75        }
76    }
77}
78
79fn write_container_rule(
80    selector: &str,
81    direction: &str,
82    gap: f32,
83    is_root: bool,
84    css: &mut String,
85) {
86    let _ = write!(
87        css,
88        "{selector} {{ display: flex; flex-direction: {direction}; gap: {gap}px;"
89    );
90    if !is_root {
91        css.push_str(" flex-grow: 1; flex-basis: 0px; flex-shrink: 1;");
92    }
93    css.push_str(" }\n");
94}
95
96fn write_panel_rule(kind: &str, constraints: &Constraints, parent_axis: Axis, css: &mut String) {
97    let _ = write!(css, "[data-pane=\"{kind}\"] {{ ");
98    write_flex_sizing(constraints, css);
99    write_min_max(constraints, parent_axis, css);
100    css.push_str(" }\n");
101}
102
103enum GridMode {
104    Fixed(usize),
105    AutoRepeat { kind: &'static str, min_px: f32 },
106}
107
108fn auto_repeat_kind(count: taffy::style::RepetitionCount) -> Option<&'static str> {
109    match count {
110        taffy::style::RepetitionCount::AutoFill => Some("auto-fill"),
111        taffy::style::RepetitionCount::AutoFit => Some("auto-fit"),
112        taffy::style::RepetitionCount::Count(_) => None,
113    }
114}
115
116fn detect_grid_mode(columns: &[taffy::style::GridTemplateComponent<String>]) -> GridMode {
117    let Some(taffy::style::GridTemplateComponent::Repeat(rep)) = columns.first() else {
118        return GridMode::Fixed(columns.len());
119    };
120    let Some(kind) = auto_repeat_kind(rep.count) else {
121        return GridMode::Fixed(columns.len());
122    };
123    let min_px = rep
124        .tracks
125        .first()
126        .map(|t| t.min_sizing_function().into_raw().value())
127        .unwrap_or(0.0);
128    GridMode::AutoRepeat { kind, min_px }
129}
130
131fn write_grid_rule(selector: &str, style: &taffy::Style, is_root: bool, css: &mut String) {
132    let _ = write!(css, "{selector} {{ display: grid;");
133    match detect_grid_mode(&style.grid_template_columns) {
134        GridMode::Fixed(cols) => {
135            let _ = write!(css, " grid-template-columns: repeat({cols}, 1fr);");
136        }
137        GridMode::AutoRepeat { kind, min_px } => {
138            let _ = write!(
139                css,
140                " grid-template-columns: repeat({kind}, minmax({min_px}px, 1fr));"
141            );
142        }
143    }
144    if !style.grid_auto_rows.is_empty() {
145        css.push_str(" grid-auto-rows: 1fr;");
146    }
147    let gap = style.gap.width.into_raw().value();
148    if gap > 0.0 {
149        let _ = write!(css, " gap: {gap}px;");
150    }
151    if !is_root {
152        css.push_str(" flex-grow: 1; flex-basis: 0px;");
153    }
154    css.push_str(" }\n");
155}
156
157fn emit_grid_children(tree: &LayoutTree, children: &[NodeId], counter: &mut u32, css: &mut String) {
158    for &child_id in children {
159        match tree.node(child_id) {
160            Some(Node::TaffyPassthrough { style, .. }) => {
161                let sel = container_selector(false, counter);
162                write_grid_card_rule(&sel, style, css);
163                emit_grid_card_panels(tree, child_id, css);
164            }
165            Some(Node::Panel {
166                kind, constraints, ..
167            }) => {
168                write_panel_rule(kind, constraints, Axis::Horizontal, css);
169            }
170            _ => {}
171        }
172    }
173}
174
175fn write_grid_card_rule(sel: &str, style: &taffy::Style, css: &mut String) {
176    let span = grid_column_span(style);
177    let _ = write!(css, "{sel} {{ display: flex;");
178    if span > 1 {
179        let _ = write!(css, " grid-column: span {span};");
180    }
181    css.push_str(" flex-grow: 1; flex-basis: 0px; flex-shrink: 1; }\n");
182}
183
184fn emit_grid_card_panels(tree: &LayoutTree, card_id: NodeId, css: &mut String) {
185    let Some(node) = tree.node(card_id) else {
186        return;
187    };
188    for &grandchild in node.children() {
189        let Some(Node::Panel {
190            kind, constraints, ..
191        }) = tree.node(grandchild)
192        else {
193            continue;
194        };
195        write_panel_rule(kind, constraints, Axis::Horizontal, css);
196    }
197}
198
199fn grid_column_span(style: &taffy::Style) -> u16 {
200    match style.grid_column.end {
201        taffy::GridPlacement::Span(n) => n,
202        _ => 1,
203    }
204}
205
206fn write_passthrough_rule(selector: &str, is_root: bool, css: &mut String) {
207    let _ = write!(css, "{selector} {{ display: flex;");
208    if !is_root {
209        css.push_str(" flex-grow: 1; flex-basis: 0px; flex-shrink: 1;");
210    }
211    css.push_str(" }\n");
212}
213
214fn write_flex_sizing(constraints: &Constraints, css: &mut String) {
215    match (constraints.grow, constraints.fixed) {
216        (Some(g), _) => {
217            let _ = write!(css, "flex-grow: {g}; flex-basis: 0px; flex-shrink: 1;");
218        }
219        (_, Some(f)) => {
220            let _ = write!(css, "flex-grow: 0; flex-basis: {f}px; flex-shrink: 0;");
221        }
222        (None, None) => {
223            css.push_str("flex-grow: 1; flex-basis: 0px; flex-shrink: 1;");
224        }
225    }
226}
227
228/// Emit CSS with `@media` wrappers for adaptive breakpoints.
229///
230/// Each entry is `(min_width_px, layout)`. Breakpoints must be sorted ascending
231/// by min_width. The first breakpoint gets only a max-width query, the last gets
232/// only a min-width query, and middle breakpoints get both.
233pub fn emit_adaptive(breakpoints: &[(u32, &Layout)]) -> String {
234    let mut css = String::new();
235    let len = breakpoints.len();
236    for (i, (min_width, layout)) in breakpoints.iter().enumerate() {
237        let inner = emit(layout);
238        let query = match (i, i + 1 < len) {
239            (0, true) => {
240                let next_min = breakpoints[i + 1].0;
241                format!("@media (max-width: {}px)", next_min.saturating_sub(1))
242            }
243            (0, false) => {
244                // Single breakpoint — no media query needed
245                css.push_str(&inner);
246                continue;
247            }
248            (_, true) => {
249                let next_min = breakpoints[i + 1].0;
250                format!(
251                    "@media (min-width: {min_width}px) and (max-width: {}px)",
252                    next_min.saturating_sub(1)
253                )
254            }
255            (_, false) => format!("@media (min-width: {min_width}px)"),
256        };
257        let _ = write!(css, "{query} {{\n{inner}}}\n");
258    }
259    css
260}
261
262fn write_min_max(constraints: &Constraints, axis: Axis, css: &mut String) {
263    let (min_prop, max_prop) = match axis {
264        Axis::Horizontal => ("min-width", "max-width"),
265        Axis::Vertical => ("min-height", "max-height"),
266    };
267    if let Some(min) = constraints.min {
268        let _ = write!(css, " {min_prop}: {min}px;");
269    }
270    if let Some(max) = constraints.max {
271        let _ = write!(css, " {max_prop}: {max}px;");
272    }
273}