Skip to main content

oino_ui/
layout.rs

1//! Layout node (§4.3): `Layout`, [`Direction`], [`Constraint`], [`Flex`] and [`Margin`].
2//!
3//! Data only — the solver lives in the renderers (ratatui 0.29 in the TUI, a port of
4//! the same formulation in the app, checked against `tests/layout_vectors.json` in WP-U2).
5
6use serde::{Deserialize, Serialize};
7
8use crate::node::{Common, IntoNode, Node};
9
10/// Layout main axis.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Direction {
13    /// Top to bottom.
14    #[default]
15    Vertical,
16    /// Left to right.
17    Horizontal,
18}
19
20/// ratatui `Flex`.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum Flex {
23    /// Legacy ratatui behaviour (extra space to the last element).
24    Legacy,
25    /// Extra space after the children (ratatui 0.29 default).
26    #[default]
27    Start,
28    /// Extra space before the children.
29    End,
30    /// Extra space split around the children.
31    Center,
32    /// Equal space between children.
33    SpaceBetween,
34    /// Equal space around children.
35    SpaceAround,
36}
37
38/// ratatui `Constraint` in its serde form: `{"Length":3}`; a bare integer `n`
39/// deserializes as `{"Length": n}` (§4.3).
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
41pub enum Constraint {
42    /// Exactly `n` cells.
43    Length(u16),
44    /// Percentage of the whole area (0..=100).
45    Percentage(u16),
46    /// `a / max(b, 1)` of the whole area, serialized as `[a, b]`.
47    Ratio(u32, u32),
48    /// At least `n` cells; grows like `Fill(1)` when `Flex != Legacy`.
49    Min(u16),
50    /// At most `n` cells (prefers exactly `n`).
51    Max(u16),
52    /// Excess space proportional to the weight.
53    Fill(u16),
54}
55
56#[derive(Deserialize)]
57#[serde(untagged)]
58enum ConstraintRaw {
59    Bare(u16),
60    Tagged(ConstraintTagged),
61}
62
63#[derive(Deserialize)]
64enum ConstraintTagged {
65    Length(u16),
66    Percentage(u16),
67    /// `[a, b]`.
68    Ratio([u32; 2]),
69    Min(u16),
70    Max(u16),
71    Fill(u16),
72}
73
74impl<'de> Deserialize<'de> for Constraint {
75    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
76        match ConstraintRaw::deserialize(deserializer)? {
77            ConstraintRaw::Bare(n) => Ok(Self::Length(n)),
78            ConstraintRaw::Tagged(tagged) => Ok(match tagged {
79                ConstraintTagged::Length(n) => Self::Length(n),
80                ConstraintTagged::Percentage(p) => Self::Percentage(p),
81                ConstraintTagged::Ratio([a, b]) => Self::Ratio(a, b),
82                ConstraintTagged::Min(n) => Self::Min(n),
83                ConstraintTagged::Max(n) => Self::Max(n),
84                ConstraintTagged::Fill(w) => Self::Fill(w),
85            }),
86        }
87    }
88}
89
90/// Inset of the whole layout area: a number (all sides) or `{horizontal, vertical}`.
91#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(untagged)]
93pub enum Margin {
94    /// Same inset on all sides.
95    Uniform(u16),
96    /// Separate horizontal / vertical insets.
97    Axes {
98        /// Left and right inset.
99        horizontal: u16,
100        /// Top and bottom inset.
101        vertical: u16,
102    },
103}
104
105impl Margin {
106    /// Same inset on all sides.
107    #[must_use]
108    pub fn uniform(n: u16) -> Self {
109        Self::Uniform(n)
110    }
111
112    /// Separate horizontal / vertical insets.
113    #[must_use]
114    pub fn hv(horizontal: u16, vertical: u16) -> Self {
115        Self::Axes {
116            horizontal,
117            vertical,
118        }
119    }
120}
121
122/// ratatui `Layout` as a node: data, not geometry (§4.3). There is no `split`;
123/// children are attached and the renderer solves the main-axis sizes.
124#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct Layout {
127    /// Common node props (`key`, `style`, `block`, `fallback`).
128    #[serde(flatten)]
129    pub common: Common,
130    /// Main axis (default `Vertical`).
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub direction: Option<Direction>,
133    /// One constraint per child; missing ones are `Fill(1)`, extras ignored.
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub constraints: Vec<Constraint>,
136    /// Children (≤ 64, §4.8).
137    pub children: Vec<Node>,
138    /// Gap between children, in cells.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub spacing: Option<u16>,
141    /// Inset of the whole area.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub margin: Option<Margin>,
144    /// ratatui `Flex` (default `Start`).
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub flex: Option<Flex>,
147    /// Horizontal only: below this many columns, children lay out vertically in
148    /// auto mode (§4.3). `0` disables.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub collapse: Option<u16>,
151}
152
153impl Layout {
154    /// Vertical layout with one constraint per child.
155    #[must_use]
156    pub fn vertical(constraints: impl IntoIterator<Item = Constraint>) -> Self {
157        Self {
158            direction: Some(Direction::Vertical),
159            constraints: constraints.into_iter().collect(),
160            ..Self::default()
161        }
162    }
163
164    /// Horizontal layout with one constraint per child.
165    #[must_use]
166    pub fn horizontal(constraints: impl IntoIterator<Item = Constraint>) -> Self {
167        Self {
168            direction: Some(Direction::Horizontal),
169            constraints: constraints.into_iter().collect(),
170            ..Self::default()
171        }
172    }
173
174    /// Sets the constraints.
175    #[must_use]
176    pub fn constraints(mut self, constraints: impl IntoIterator<Item = Constraint>) -> Self {
177        self.constraints = constraints.into_iter().collect();
178        self
179    }
180
181    /// Attaches children (`header.into()` converts builder values to [`Node`]).
182    #[must_use]
183    pub fn children(mut self, children: impl IntoIterator<Item = Node>) -> Self {
184        self.children = children.into_iter().collect();
185        self
186    }
187
188    /// Sets the gap between children.
189    #[must_use]
190    pub fn spacing(mut self, spacing: u16) -> Self {
191        self.spacing = Some(spacing);
192        self
193    }
194
195    /// Sets the inset of the whole area.
196    #[must_use]
197    pub fn margin(mut self, margin: Margin) -> Self {
198        self.margin = Some(margin);
199        self
200    }
201
202    /// Sets the `Flex` mode.
203    #[must_use]
204    pub fn flex(mut self, flex: Flex) -> Self {
205        self.flex = Some(flex);
206        self
207    }
208
209    /// Sets the horizontal collapse threshold (§4.3).
210    #[must_use]
211    pub fn collapse(mut self, collapse: u16) -> Self {
212        self.collapse = Some(collapse);
213        self
214    }
215}
216
217impl IntoNode for Layout {}
218impl From<Layout> for Node {
219    fn from(l: Layout) -> Self {
220        Node::Layout(l)
221    }
222}
223#[cfg(test)]
224mod tests {
225    #![allow(
226        clippy::unwrap_used,
227        clippy::expect_used,
228        clippy::panic,
229        reason = "tests assert by unwrapping"
230    )]
231    use super::*;
232
233    #[test]
234    fn bare_integer_is_length() {
235        let c: Constraint = serde_json::from_str("3").expect("bare int parses");
236        assert_eq!(c, Constraint::Length(3));
237        let c: Constraint = serde_json::from_str(r#"{"Fill":1}"#).expect("tagged parses");
238        assert_eq!(c, Constraint::Fill(1));
239        let c: Constraint = serde_json::from_str(r#"{"Ratio":[1,4]}"#).expect("ratio parses");
240        assert_eq!(c, Constraint::Ratio(1, 4));
241    }
242}