Skip to main content

dioxus_mind_elixir/
types.rs

1use std::{collections::HashSet, error::Error, fmt};
2
3use serde::{Deserialize, Serialize};
4
5/// Document layout; numeric values match Mind Elixir JSON.
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(try_from = "u8", into = "u8")]
8pub enum LayoutDirection {
9    /// All branches left of the root.
10    Left,
11    /// All branches right of the root.
12    #[default]
13    Right,
14    /// Main branches distributed between both sides.
15    Side,
16    /// Root above the branches.
17    Down,
18}
19impl TryFrom<u8> for LayoutDirection {
20    type Error = String;
21    fn try_from(value: u8) -> Result<Self, Self::Error> {
22        match value {
23            0 => Ok(Self::Left),
24            1 => Ok(Self::Right),
25            2 => Ok(Self::Side),
26            3 => Ok(Self::Down),
27            _ => Err(format!("Invalid layout direction: {value}")),
28        }
29    }
30}
31impl From<LayoutDirection> for u8 {
32    fn from(value: LayoutDirection) -> Self {
33        match value {
34            LayoutDirection::Left => 0,
35            LayoutDirection::Right => 1,
36            LayoutDirection::Side => 2,
37            LayoutDirection::Down => 3,
38        }
39    }
40}
41
42/// Per-main-branch side, independent of the document layout.
43#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(try_from = "u8", into = "u8")]
45pub enum NodeDirection {
46    /// Left side.
47    Left,
48    /// Right side.
49    Right,
50}
51impl TryFrom<u8> for NodeDirection {
52    type Error = String;
53    fn try_from(value: u8) -> Result<Self, Self::Error> {
54        match value {
55            0 => Ok(Self::Left),
56            1 => Ok(Self::Right),
57            _ => Err(format!("Invalid node direction: {value}")),
58        }
59    }
60}
61impl From<NodeDirection> for u8 {
62    fn from(value: NodeDirection) -> Self {
63        match value {
64            NodeDirection::Left => 0,
65            NodeDirection::Right => 1,
66        }
67    }
68}
69
70/// Supported plain-text styles, mapped to upstream field names.
71#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct NodeStyle {
74    /// CSS font size.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub font_size: Option<String>,
77    /// CSS font family.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub font_family: Option<String>,
80    /// Text color.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub color: Option<String>,
83    /// Background.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub background: Option<String>,
86    /// CSS font weight.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub font_weight: Option<String>,
89    /// Node width.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub width: Option<String>,
92    /// Border shorthand.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub border: Option<String>,
95    /// Text decoration.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub text_decoration: Option<String>,
98}
99
100fn present_json<'de, D: serde::Deserializer<'de>>(
101    deserializer: D,
102) -> Result<Option<serde_json::Value>, D::Error> {
103    serde_json::Value::deserialize(deserializer).map(Some)
104}
105
106fn expanded_default() -> bool {
107    true
108}
109
110/// A node picture. The host owns the bytes; `url` is passed through the view's
111/// `image_url` resolver (if any) before rendering. Width and height are display
112/// CSS pixels and keep the picture's aspect ratio.
113#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase", deny_unknown_fields)]
115pub struct NodeImage {
116    /// Host reference or URL.
117    pub url: String,
118    /// Display width in CSS pixels.
119    pub width: u32,
120    /// Display height in CSS pixels.
121    pub height: u32,
122    /// Optional alternative text.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub alt: Option<String>,
125}
126impl NodeImage {
127    /// Reject empty references and sizes outside 1..=4096 pixels.
128    pub fn validate(&self) -> Result<(), MindMapError> {
129        if self.url.trim().is_empty() {
130            return Err(MindMapError::InvalidOperation("Image URL is empty".into()));
131        }
132        if !(1..=4096).contains(&self.width) || !(1..=4096).contains(&self.height) {
133            return Err(MindMapError::InvalidOperation(
134                "Image size must be 1-4096 pixels".into(),
135            ));
136        }
137        Ok(())
138    }
139}
140
141/// A plain-text node in the persistent nested tree.
142#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase", deny_unknown_fields)]
144pub struct Node {
145    /// Stable ID, unique within this document.
146    pub id: String,
147    /// Plain-text label, including optional line breaks.
148    pub topic: String,
149    /// Ordered children.
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub children: Vec<Node>,
152    /// Whether children are visible; missing means expanded.
153    #[serde(default = "expanded_default")]
154    pub expanded: bool,
155    /// Side assigned to a main branch.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub direction: Option<NodeDirection>,
158    /// Explicit per-node appearance.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub style: Option<NodeStyle>,
161    /// Explicit branch stroke, inherited by descendants.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub branch_color: Option<String>,
164    /// Plain-text note; not rendered on the canvas (hosts show it in details).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub note: Option<String>,
167    /// Optional picture rendered above the topic.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub image: Option<NodeImage>,
170    /// Opaque JSON metadata, including explicit null, preserved without interpretation.
171    #[serde(
172        default,
173        deserialize_with = "present_json",
174        skip_serializing_if = "Option::is_none"
175    )]
176    pub metadata: Option<serde_json::Value>,
177}
178impl Node {
179    /// Construct a leaf.
180    pub fn new(id: impl Into<String>, topic: impl Into<String>) -> Self {
181        Self {
182            id: id.into(),
183            topic: topic.into(),
184            children: Vec::new(),
185            expanded: true,
186            direction: None,
187            style: None,
188            branch_color: None,
189            note: None,
190            image: None,
191            metadata: None,
192        }
193    }
194    /// Set ordered children.
195    pub fn with_children(mut self, children: Vec<Node>) -> Self {
196        self.children = children;
197        self
198    }
199    /// Find a node, including collapsed descendants.
200    pub fn find(&self, id: &str) -> Option<&Node> {
201        let mut pending = vec![self];
202        while let Some(node) = pending.pop() {
203            if node.id == id {
204                return Some(node);
205            }
206            pending.extend(node.children.iter().rev());
207        }
208        None
209    }
210    /// Validate this subtree before cloning or inserting externally supplied nodes.
211    pub fn validate(&self) -> Result<(), MindMapError> {
212        let mut pending = vec![(self, 0usize)];
213        let mut ids = HashSet::new();
214        while let Some((node, depth)) = pending.pop() {
215            if depth > 128 {
216                return Err(MindMapError::TreeTooDeep);
217            }
218            if node.id.trim().is_empty() {
219                return Err(MindMapError::EmptyNodeId);
220            }
221            if !ids.insert(node.id.as_str()) {
222                return Err(MindMapError::DuplicateNodeId(node.id.clone()));
223            }
224            if let Some(image) = &node.image {
225                image.validate()?;
226            }
227            pending.extend(node.children.iter().map(|child| (child, depth + 1)));
228        }
229        Ok(())
230    }
231    pub(crate) fn find_mut(&mut self, id: &str) -> Option<&mut Node> {
232        if self.id == id {
233            return Some(self);
234        }
235        self.children
236            .iter_mut()
237            .find_map(|child| child.find_mut(id))
238    }
239}
240
241/// Nested persistent document. Runtime selection, viewports and history are excluded.
242#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "camelCase", deny_unknown_fields)]
244pub struct MindMapData {
245    /// Root of the document.
246    pub node_data: Node,
247    /// Layout with upstream numeric encoding.
248    #[serde(default)]
249    pub direction: LayoutDirection,
250    /// Compact spacing (still overridable by host CSS variables).
251    #[serde(default)]
252    pub compact: bool,
253    /// Opaque document metadata, including explicit null.
254    #[serde(
255        default,
256        deserialize_with = "present_json",
257        skip_serializing_if = "Option::is_none"
258    )]
259    pub meta: Option<serde_json::Value>,
260}
261impl MindMapData {
262    /// Construct a right-facing document.
263    pub fn new(root: Node) -> Self {
264        Self {
265            node_data: root,
266            direction: LayoutDirection::Right,
267            compact: false,
268            meta: None,
269        }
270    }
271    /// Reject empty/duplicate IDs and trees deeper than 128 levels.
272    pub fn validate(&self) -> Result<(), MindMapError> {
273        self.node_data.validate()
274    }
275
276    /// Strict JSON import. Unknown fields (including rich content) are errors.
277    pub fn from_json(json: &str) -> Result<Self, MindMapError> {
278        let mut deserializer = serde_json::Deserializer::from_str(json);
279        let document: Self =
280            serde_path_to_error::deserialize(&mut deserializer).map_err(|error| {
281                MindMapError::InvalidData {
282                    path: error.path().to_string(),
283                    message: error.inner().to_string(),
284                }
285            })?;
286        deserializer
287            .end()
288            .map_err(|error| MindMapError::InvalidData {
289                path: String::new(),
290                message: error.to_string(),
291            })?;
292        document.validate()?;
293        Ok(document)
294    }
295    /// Validate and export all supported persistent fields.
296    pub fn to_json(&self) -> Result<String, MindMapError> {
297        self.validate()?;
298        serde_json::to_string_pretty(self).map_err(|error| MindMapError::InvalidData {
299            path: String::new(),
300            message: error.to_string(),
301        })
302    }
303}
304
305/// A recoverable document, operation or renderer failure.
306#[derive(Clone, Debug, PartialEq, Eq)]
307#[non_exhaustive]
308pub enum MindMapError {
309    /// A node ID was empty.
310    EmptyNodeId,
311    /// Duplicate ID in one document.
312    DuplicateNodeId(String),
313    /// Recursive rendering depth limit exceeded.
314    TreeTooDeep,
315    /// Unknown/invalid JSON field, including its path.
316    InvalidData {
317        /// Field path.
318        path: String,
319        /// Parse diagnostic.
320        message: String,
321    },
322    /// Requested node does not exist.
323    NodeNotFound(String),
324    /// Operation would remove or relocate the root.
325    RootOperation,
326    /// Destination is inside the moved subtree.
327    AncestorMove,
328    /// Mutation is disabled.
329    ReadOnly,
330    /// Before hook vetoed the operation.
331    Rejected,
332    /// State changed while a callback was running.
333    StaleOperation,
334    /// History has no entry in this direction.
335    NoHistory,
336    /// Supplied input is invalid.
337    InvalidOperation(String),
338    /// Mounted node measurement failed.
339    Measurement(String),
340    /// Non-finite or empty geometry.
341    InvalidGeometry,
342}
343impl fmt::Display for MindMapError {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        match self {
346            Self::EmptyNodeId => f.write_str("Node IDs must not be empty"),
347            Self::DuplicateNodeId(id) => write!(f, "Duplicate node ID: {id}"),
348            Self::TreeTooDeep => f.write_str("At most 128 nested levels are supported"),
349            Self::InvalidData { path, message } => {
350                write!(f, "Invalid document at {path}: {message}")
351            }
352            Self::NodeNotFound(id) => write!(f, "Node not found: {id}"),
353            Self::RootOperation => f.write_str("The root cannot be removed or relocated"),
354            Self::AncestorMove => f.write_str("Cannot move a node into its own subtree"),
355            Self::ReadOnly => f.write_str("The document is read-only"),
356            Self::Rejected => f.write_str("The operation was rejected by the before hook"),
357            Self::StaleOperation => f.write_str("The document changed during the operation"),
358            Self::NoHistory => f.write_str("No history entry is available"),
359            Self::InvalidOperation(message) => write!(f, "Invalid operation: {message}"),
360            Self::Measurement(message) => write!(f, "Cannot measure the mind map: {message}"),
361            Self::InvalidGeometry => f.write_str("The renderer returned invalid geometry"),
362        }
363    }
364}
365impl Error for MindMapError {}