Skip to main content

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}