perspective_client/config/viewer/layout.rs
1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use serde::{Deserialize, Serialize};
14
15/// The direction a [`SplitLayout`] divides its space in: `horizontal` places
16/// children side by side as columns, `vertical` stacks them as rows.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
18#[serde(rename_all = "lowercase")]
19pub enum Orientation {
20 /// Children side by side, as columns.
21 Horizontal,
22
23 /// Children stacked, as rows.
24 Vertical,
25}
26
27/// A dashboard's panel arrangement: nested splits whose leaves are tab stacks
28/// of panel ids, naming each panel at most once.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ts_rs::TS)]
30#[serde(tag = "type")]
31pub enum Layout {
32 /// Space divided among children, side-by-side or stacked.
33 #[serde(rename = "split-layout")]
34 Split(SplitLayout),
35
36 /// A leaf node holding one or more named tabs (a stack).
37 #[serde(rename = "tab-layout")]
38 Tab(TabLayout),
39}
40
41/// A row or column of child layouts, nested to form a grid.
42#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ts_rs::TS)]
43pub struct SplitLayout {
44 /// Child layouts, in left-to-right or top-to-bottom order.
45 pub children: Vec<Layout>,
46
47 /// Each child's share of the split, one per child, as a proportion of
48 /// their total.
49 pub sizes: Vec<f64>,
50
51 /// Whether children sit side by side (`horizontal`) or stacked
52 /// (`vertical`).
53 pub orientation: Orientation,
54}
55
56/// A stack of panels shown as tabs, one visible at a time.
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ts_rs::TS)]
58pub struct TabLayout {
59 /// Panel ids (slot names) in this stack.
60 pub tabs: Vec<String>,
61
62 /// Index of the selected tab within `tabs`.
63 #[serde(skip_serializing_if = "Option::is_none")]
64 #[ts(optional)]
65 pub selected: Option<usize>,
66}
67
68impl Layout {
69 /// Check that this tree is well-formed and names each of `ids` at most
70 /// once, returning the `ids` it leaves unplaced.
71 pub fn validate(&self, ids: &[String]) -> Result<Vec<String>, String> {
72 let mut seen = Vec::new();
73 self.validate_node("layout", ids, &mut seen)?;
74 Ok(ids
75 .iter()
76 .filter(|id| !seen.contains(id))
77 .cloned()
78 .collect())
79 }
80
81 fn validate_node(
82 &self,
83 path: &str,
84 ids: &[String],
85 seen: &mut Vec<String>,
86 ) -> Result<(), String> {
87 match self {
88 Layout::Split(split) => {
89 if split.sizes.len() != split.children.len() {
90 return Err(format!(
91 "Invalid layout: {path}.sizes has {} entries for {} children",
92 split.sizes.len(),
93 split.children.len()
94 ));
95 }
96
97 if let Some(size) = split.sizes.iter().find(|x| !x.is_finite() || **x < 0.0) {
98 return Err(format!("Invalid layout: {path}.sizes contains {size}"));
99 }
100
101 if !split.sizes.is_empty() && split.sizes.iter().sum::<f64>() <= 0.0 {
102 return Err(format!("Invalid layout: {path}.sizes sum to zero"));
103 }
104
105 for (index, child) in split.children.iter().enumerate() {
106 child.validate_node(&format!("{path}.children[{index}]"), ids, seen)?;
107 }
108
109 Ok(())
110 },
111 Layout::Tab(tab) => {
112 for name in &tab.tabs {
113 if !ids.contains(name) {
114 return Err(format!(
115 "Invalid layout: {path}.tabs names unknown panel \"{name}\" (panels: \
116 {ids:?})"
117 ));
118 }
119
120 if seen.contains(name) {
121 return Err(format!(
122 "Invalid layout: panel \"{name}\" appears more than once"
123 ));
124 }
125
126 seen.push(name.clone());
127 }
128
129 Ok(())
130 },
131 }
132 }
133
134 /// Collect every panel id (slot name) in the tree, depth-first.
135 pub fn slot_names(&self) -> Vec<String> {
136 match self {
137 Layout::Split(split) => split.children.iter().flat_map(Layout::slot_names).collect(),
138 Layout::Tab(tab) => tab.tabs.clone(),
139 }
140 }
141
142 /// Collect every panel id that is *hidden* — i.e. in a stack (tab-layout)
143 /// at an index other than its `selected` one — in depth-first order. The
144 /// complement of the visible set: every other panel is the front of its
145 /// stack (single-tab leaves are trivially visible).
146 pub fn hidden_slot_names(&self) -> Vec<String> {
147 match self {
148 Layout::Split(split) => split
149 .children
150 .iter()
151 .flat_map(Layout::hidden_slot_names)
152 .collect(),
153 Layout::Tab(tab) => {
154 let selected = tab.selected.unwrap_or(0);
155 tab.tabs
156 .iter()
157 .enumerate()
158 .filter(|(idx, _)| *idx != selected)
159 .map(|(_, name)| name.clone())
160 .collect()
161 },
162 }
163 }
164
165 /// Rewrite every panel id through `f`, returning the remapped tree. Ids for
166 /// which `f` returns `None` are left unchanged. Used by `restoreWorkspace`,
167 /// which recreates panels under fresh (collision-free) ids and must point
168 /// the saved layout tree at them.
169 pub fn remap(&self, f: &impl Fn(&str) -> Option<String>) -> Layout {
170 match self {
171 Layout::Split(split) => Layout::Split(SplitLayout {
172 children: split.children.iter().map(|c| c.remap(f)).collect(),
173 sizes: split.sizes.clone(),
174 orientation: split.orientation,
175 }),
176 Layout::Tab(tab) => Layout::Tab(TabLayout {
177 tabs: tab
178 .tabs
179 .iter()
180 .map(|name| f(name).unwrap_or_else(|| name.clone()))
181 .collect(),
182 selected: tab.selected,
183 }),
184 }
185 }
186}