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::{Align, 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, Direction::Horizontal, true, &mut ctx);
29    ctx.css
30}
31
32fn emit_node(
33    tree: &LayoutTree,
34    nid: NodeId,
35    parent_axis: Direction,
36    is_root: bool,
37    ctx: &mut EmitCtx,
38) {
39    let Some(node) = tree.node(nid) else { return };
40    match node {
41        Node::Panel {
42            kind, constraints, ..
43        } => {
44            write_panel_rule(kind, constraints, parent_axis, &mut ctx.css);
45        }
46        Node::Row { gap, children } => {
47            let sel = container_selector(is_root, &mut ctx.counter);
48            write_container_rule(&sel, "row", *gap, is_root, &mut ctx.css);
49            emit_children(tree, children, Direction::Horizontal, ctx);
50        }
51        Node::Col { gap, children } => {
52            let sel = container_selector(is_root, &mut ctx.counter);
53            write_container_rule(&sel, "column", *gap, is_root, &mut ctx.css);
54            emit_children(tree, children, Direction::Vertical, ctx);
55        }
56        Node::TaffyPassthrough { style, children } if style.display == taffy::Display::Grid => {
57            let sel = container_selector(is_root, &mut ctx.counter);
58            write_grid_rule(&sel, style, is_root, &mut ctx.css);
59            emit_grid_children(tree, children, &mut ctx.counter, &mut ctx.css);
60        }
61        Node::TaffyPassthrough { children, .. } => {
62            let sel = container_selector(is_root, &mut ctx.counter);
63            write_passthrough_rule(&sel, is_root, &mut ctx.css);
64            emit_children(tree, children, parent_axis, ctx);
65        }
66    }
67}
68
69fn emit_children(tree: &LayoutTree, children: &[NodeId], axis: Direction, ctx: &mut EmitCtx) {
70    for &child_id in children {
71        emit_node(tree, child_id, axis, false, ctx);
72    }
73}
74
75fn container_selector(is_root: bool, counter: &mut u32) -> String {
76    match is_root {
77        true => "[data-pane-root]".to_string(),
78        false => {
79            *counter += 1;
80            format!("[data-pane-node=\"{}\"]", counter)
81        }
82    }
83}
84
85fn write_container_rule(
86    selector: &str,
87    direction: &str,
88    gap: f32,
89    is_root: bool,
90    css: &mut String,
91) {
92    let _ = write!(
93        css,
94        "{selector} {{ display: flex; flex-direction: {direction}; gap: {gap}px;"
95    );
96    if !is_root {
97        css.push_str(" flex-grow: 1; flex-basis: 0px; flex-shrink: 1;");
98    }
99    css.push_str(" }\n");
100}
101
102fn write_panel_rule(
103    kind: &str,
104    constraints: &Constraints,
105    parent_axis: Direction,
106    css: &mut String,
107) {
108    let _ = write!(css, "[data-pane=\"{kind}\"] {{ ");
109    write_flex_sizing(constraints, css);
110    write_min_max(constraints, parent_axis, css);
111    write_cross_axis_constraints(constraints, css);
112    write_align_self(constraints, css);
113    css.push_str(" }\n");
114}
115
116enum GridMode {
117    Fixed(usize),
118    AutoRepeat { kind: &'static str, min_px: f32 },
119}
120
121fn auto_repeat_kind(count: taffy::style::RepetitionCount) -> Option<&'static str> {
122    match count {
123        taffy::style::RepetitionCount::AutoFill => Some("auto-fill"),
124        taffy::style::RepetitionCount::AutoFit => Some("auto-fit"),
125        taffy::style::RepetitionCount::Count(_) => None,
126    }
127}
128
129fn detect_grid_mode(columns: &[taffy::style::GridTemplateComponent<String>]) -> GridMode {
130    let Some(taffy::style::GridTemplateComponent::Repeat(rep)) = columns.first() else {
131        return GridMode::Fixed(columns.len());
132    };
133    let Some(kind) = auto_repeat_kind(rep.count) else {
134        return GridMode::Fixed(columns.len());
135    };
136    let min_px = rep
137        .tracks
138        .first()
139        .map(|t| t.min_sizing_function().into_raw().value())
140        .unwrap_or(0.0);
141    GridMode::AutoRepeat { kind, min_px }
142}
143
144fn write_grid_rule(selector: &str, style: &taffy::Style, is_root: bool, css: &mut String) {
145    let _ = write!(css, "{selector} {{ display: grid;");
146    match detect_grid_mode(&style.grid_template_columns) {
147        GridMode::Fixed(cols) => {
148            let _ = write!(css, " grid-template-columns: repeat({cols}, 1fr);");
149        }
150        GridMode::AutoRepeat { kind, min_px } => {
151            let _ = write!(
152                css,
153                " grid-template-columns: repeat({kind}, minmax({min_px}px, 1fr));"
154            );
155        }
156    }
157    match style.grid_auto_rows.is_empty() {
158        true => {}
159        false if is_auto_rows(&style.grid_auto_rows) => {
160            css.push_str(" grid-auto-rows: auto;");
161        }
162        false => css.push_str(" grid-auto-rows: 1fr;"),
163    }
164    let gap = style.gap.width.into_raw().value();
165    if gap > 0.0 {
166        let _ = write!(css, " gap: {gap}px;");
167    }
168    if !is_root {
169        css.push_str(" flex-grow: 1; flex-basis: 0px;");
170    }
171    css.push_str(" }\n");
172}
173
174fn emit_grid_children(tree: &LayoutTree, children: &[NodeId], counter: &mut u32, css: &mut String) {
175    for &child_id in children {
176        match tree.node(child_id) {
177            Some(Node::TaffyPassthrough { style, .. }) => {
178                let sel = container_selector(false, counter);
179                write_grid_card_rule(&sel, style, css);
180                emit_grid_card_panels(tree, child_id, css);
181            }
182            Some(Node::Panel {
183                kind, constraints, ..
184            }) => {
185                write_panel_rule(kind, constraints, Direction::Horizontal, css);
186            }
187            _ => {}
188        }
189    }
190}
191
192fn write_grid_card_rule(sel: &str, style: &taffy::Style, css: &mut String) {
193    let _ = write!(css, "{sel} {{ display: flex;");
194    match grid_column_placement(style) {
195        GridColumnPlacement::FullWidth => {
196            css.push_str(" grid-column: 1 / -1;");
197        }
198        GridColumnPlacement::Span(n) if n > 1 => {
199            let _ = write!(css, " grid-column: span {n};");
200        }
201        _ => {}
202    }
203    css.push_str(" flex-grow: 1; flex-basis: 0px; flex-shrink: 1; }\n");
204}
205
206fn emit_grid_card_panels(tree: &LayoutTree, card_id: NodeId, css: &mut String) {
207    let Some(node) = tree.node(card_id) else {
208        return;
209    };
210    for &grandchild in node.children() {
211        let Some(Node::Panel {
212            kind, constraints, ..
213        }) = tree.node(grandchild)
214        else {
215            continue;
216        };
217        write_panel_rule(kind, constraints, Direction::Horizontal, css);
218    }
219}
220
221enum GridColumnPlacement {
222    Span(u16),
223    FullWidth,
224}
225
226fn grid_column_placement(style: &taffy::Style) -> GridColumnPlacement {
227    match (&style.grid_column.start, &style.grid_column.end) {
228        (taffy::GridPlacement::Line(s), taffy::GridPlacement::Line(e))
229            if s.as_i16() == 1 && e.as_i16() == -1 =>
230        {
231            GridColumnPlacement::FullWidth
232        }
233        (_, taffy::GridPlacement::Span(n)) => GridColumnPlacement::Span(*n),
234        _ => GridColumnPlacement::Span(1),
235    }
236}
237
238fn is_auto_rows(tracks: &[taffy::style::TrackSizingFunction]) -> bool {
239    let auto_track = taffy::prelude::minmax(
240        taffy::style::MinTrackSizingFunction::auto(),
241        taffy::style::MaxTrackSizingFunction::auto(),
242    );
243    matches!(tracks.first(), Some(t) if *t == auto_track)
244}
245
246fn write_passthrough_rule(selector: &str, is_root: bool, css: &mut String) {
247    let _ = write!(css, "{selector} {{ display: flex;");
248    if !is_root {
249        css.push_str(" flex-grow: 1; flex-basis: 0px; flex-shrink: 1;");
250    }
251    css.push_str(" }\n");
252}
253
254fn write_flex_sizing(constraints: &Constraints, css: &mut String) {
255    match (constraints.grow, constraints.fixed) {
256        (Some(g), _) => {
257            let _ = write!(css, "flex-grow: {g}; flex-basis: 0px; flex-shrink: 1;");
258        }
259        (_, Some(f)) => {
260            let _ = write!(css, "flex-grow: 0; flex-basis: {f}px; flex-shrink: 0;");
261        }
262        (None, None) => {
263            css.push_str("flex-grow: 1; flex-basis: 0px; flex-shrink: 1;");
264        }
265    }
266}
267
268/// Emit CSS with `@media` wrappers for adaptive breakpoints.
269///
270/// Each entry is `(min_width_px, layout)`. Breakpoints must be sorted ascending
271/// by min_width. The first breakpoint gets only a max-width query, the last gets
272/// only a min-width query, and middle breakpoints get both.
273pub fn emit_adaptive(breakpoints: &[(u32, &Layout)]) -> String {
274    let mut css = String::new();
275    let len = breakpoints.len();
276    for (i, (min_width, layout)) in breakpoints.iter().enumerate() {
277        let inner = emit(layout);
278        let query = match (i, i + 1 < len) {
279            (0, true) => {
280                let next_min = breakpoints[i + 1].0;
281                format!("@media (max-width: {}px)", next_min.saturating_sub(1))
282            }
283            (0, false) => {
284                // Single breakpoint — no media query needed
285                css.push_str(&inner);
286                continue;
287            }
288            (_, true) => {
289                let next_min = breakpoints[i + 1].0;
290                format!(
291                    "@media (min-width: {min_width}px) and (max-width: {}px)",
292                    next_min.saturating_sub(1)
293                )
294            }
295            (_, false) => format!("@media (min-width: {min_width}px)"),
296        };
297        let _ = write!(css, "{query} {{\n{inner}}}\n");
298    }
299    css
300}
301
302fn write_min_max(constraints: &Constraints, axis: Direction, css: &mut String) {
303    let (min_prop, max_prop) = match axis {
304        Direction::Horizontal => ("min-width", "max-width"),
305        Direction::Vertical => ("min-height", "max-height"),
306    };
307    if let Some(min) = constraints.min {
308        let _ = write!(css, " {min_prop}: {min}px;");
309    }
310    if let Some(max) = constraints.max {
311        let _ = write!(css, " {max_prop}: {max}px;");
312    }
313}
314
315fn write_cross_axis_constraints(constraints: &Constraints, css: &mut String) {
316    if let Some(v) = constraints.min_width {
317        let _ = write!(css, " min-width: {v}px;");
318    }
319    if let Some(v) = constraints.max_width {
320        let _ = write!(css, " max-width: {v}px;");
321    }
322    if let Some(v) = constraints.min_height {
323        let _ = write!(css, " min-height: {v}px;");
324    }
325    if let Some(v) = constraints.max_height {
326        let _ = write!(css, " max-height: {v}px;");
327    }
328}
329
330fn write_align_self(constraints: &Constraints, css: &mut String) {
331    let Some(align) = constraints.align else {
332        return;
333    };
334    let value = match align {
335        Align::Start => "start",
336        Align::Center => "center",
337        Align::End => "end",
338        Align::Stretch => return,
339    };
340    let _ = write!(css, " align-self: {value};");
341}