Skip to main content

dioxus_mind_elixir/plugins/
mod.rs

1//! Typed optional extensions. All document changes still use core transactions.
2pub mod export;
3pub mod gesture;
4use crate::{MindMapData, MindMapError, MindMapHandle, Node, Operation, Placement};
5use std::collections::BTreeMap;
6
7/// Optional interaction capability, scoped to a document handle.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9pub enum Capability {
10    /// Structural shortcuts and navigation.
11    Keyboard,
12    /// Toggle selection and drag a selection rectangle.
13    MultiSelect,
14    /// Move selected branches with pointer gestures.
15    DragDrop,
16    /// Pan when a drag approaches the viewport edge.
17    EdgePan,
18    /// Context commands.
19    ContextMenu,
20    /// Toolbar commands.
21    Toolbar,
22    /// Shared outline.
23    Outline,
24    /// File export.
25    Export,
26}
27/// A checked command understood by built-in and host menus.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Command {
30    /// Insert a child of the selected node.
31    Child,
32    /// Insert a sibling after the selected node.
33    SiblingAfter,
34    /// Insert a sibling before the selected node.
35    SiblingBefore,
36    /// Insert a new parent.
37    Parent,
38    /// Delete selected branches.
39    Delete,
40    /// Copy selected branches after the selection.
41    Duplicate,
42    /// Toggle children visibility.
43    Fold,
44    /// Move a node under its previous sibling.
45    Indent,
46    /// Move a node after its parent.
47    Outdent,
48    /// Undo a transaction.
49    Undo,
50    /// Redo a transaction.
51    Redo,
52}
53/// Registration metadata. No dynamic code loader or global registry is used.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct Plugin {
56    /// Stable unique registration key.
57    pub id: String,
58    /// Declared optional view capabilities.
59    pub capabilities: Vec<Capability>,
60    /// Commands offered to menus/toolbars.
61    pub commands: Vec<Command>,
62}
63impl Plugin {
64    /// Declare a host plugin.
65    pub fn new(id: impl Into<String>, capabilities: Vec<Capability>) -> Self {
66        Self {
67            id: id.into(),
68            capabilities,
69            commands: Vec::new(),
70        }
71    }
72    /// Standard interaction and presentation plugins, individually removable.
73    pub fn builtins() -> Vec<Self> {
74        [
75            Capability::Keyboard,
76            Capability::MultiSelect,
77            Capability::DragDrop,
78            Capability::EdgePan,
79            Capability::ContextMenu,
80            Capability::Toolbar,
81            Capability::Outline,
82            Capability::Export,
83        ]
84        .into_iter()
85        .map(|capability| {
86            let mut plugin = Self::new(format!("builtin.{capability:?}"), vec![capability]);
87            if matches!(capability, Capability::Toolbar | Capability::ContextMenu) {
88                plugin.commands = vec![
89                    Command::Child,
90                    Command::SiblingAfter,
91                    Command::Parent,
92                    Command::Duplicate,
93                    Command::Fold,
94                    Command::Delete,
95                    Command::Undo,
96                    Command::Redo,
97                ];
98            }
99            plugin
100        })
101        .collect()
102    }
103}
104/// Minimal registry, usable independently of Dioxus for validation.
105#[derive(Clone, Debug, Default)]
106pub struct PluginRegistry(BTreeMap<String, (Plugin, bool)>);
107impl PluginRegistry {
108    /// Register exactly once; duplicate IDs are errors.
109    pub fn register(&mut self, plugin: Plugin) -> Result<(), MindMapError> {
110        if plugin.id.trim().is_empty() || self.0.contains_key(&plugin.id) {
111            return Err(MindMapError::InvalidOperation(
112                "Empty or duplicate plugin ID".into(),
113            ));
114        }
115        self.0.insert(plugin.id.clone(), (plugin, true));
116        Ok(())
117    }
118    /// Enable/disable without changing document history.
119    pub fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<(), MindMapError> {
120        self.0
121            .get_mut(id)
122            .ok_or_else(|| MindMapError::InvalidOperation(format!("Unknown plugin: {id}")))?
123            .1 = enabled;
124        Ok(())
125    }
126    /// Remove registration and its capabilities.
127    pub fn unregister(&mut self, id: &str) -> Result<(), MindMapError> {
128        self.0
129            .remove(id)
130            .ok_or_else(|| MindMapError::InvalidOperation(format!("Unknown plugin: {id}")))?;
131        Ok(())
132    }
133    /// Whether a registration exists and is enabled.
134    pub fn is_enabled(&self, id: &str) -> bool {
135        self.0.get(id).is_some_and(|(_, enabled)| *enabled)
136    }
137    /// Whether an enabled plugin provides this capability.
138    pub fn provides(&self, capability: Capability) -> bool {
139        self.0
140            .values()
141            .any(|(plugin, enabled)| *enabled && plugin.capabilities.contains(&capability))
142    }
143    /// Deduplicated commands in registration order.
144    pub fn commands(&self) -> Vec<Command> {
145        let mut commands = Vec::new();
146        for (plugin, enabled) in self.0.values() {
147            if *enabled {
148                for command in &plugin.commands {
149                    if !commands.contains(command) {
150                        commands.push(*command);
151                    }
152                }
153            }
154        }
155        commands
156    }
157}
158/// Every text the components show, supplied by the host. The default is English;
159/// hosts replace any field (or all of them) with their own wording or language.
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub struct Labels {
162    /// Topic of nodes created by commands.
163    pub new_topic: String,
164    /// Command: insert a child.
165    pub add_child: String,
166    /// Command: insert a sibling after.
167    pub add_sibling: String,
168    /// Command: insert a sibling before.
169    pub insert_before: String,
170    /// Command: insert a parent.
171    pub insert_parent: String,
172    /// Command: delete.
173    pub delete: String,
174    /// Command: duplicate.
175    pub duplicate: String,
176    /// Command: expand or collapse.
177    pub fold: String,
178    /// Command: indent.
179    pub indent: String,
180    /// Command: outdent.
181    pub outdent: String,
182    /// Command: undo.
183    pub undo: String,
184    /// Command: redo.
185    pub redo: String,
186    /// Accessible name of the map.
187    pub map: String,
188    /// Accessible name of the outline.
189    pub outline: String,
190    /// Accessible name of the outline breadcrumbs.
191    pub breadcrumbs: String,
192    /// Accessible name of the toolbar.
193    pub toolbar: String,
194    /// Accessible name of the topic editor.
195    pub edit_topic: String,
196    /// Collapse button.
197    pub collapse: String,
198    /// Expand button.
199    pub expand: String,
200    /// Outline row toggle.
201    pub toggle_children: String,
202    /// Outline row action that focuses a branch.
203    pub focus_branch: String,
204    /// Marker of a node with a note.
205    pub has_note: String,
206    /// Marker of a node with a picture.
207    pub has_image: String,
208    /// Shown when a picture cannot be loaded.
209    pub image_unavailable: String,
210    /// Status while dragging branches.
211    pub moving: String,
212    /// Drop target hint.
213    pub drop_here: String,
214}
215impl Default for Labels {
216    fn default() -> Self {
217        let s = String::from;
218        Self {
219            new_topic: s("New node"),
220            add_child: s("Add child"),
221            add_sibling: s("Add sibling"),
222            insert_before: s("Insert before"),
223            insert_parent: s("Insert parent"),
224            delete: s("Delete"),
225            duplicate: s("Duplicate"),
226            fold: s("Expand / collapse"),
227            indent: s("Indent"),
228            outdent: s("Outdent"),
229            undo: s("Undo"),
230            redo: s("Redo"),
231            map: s("Mind map"),
232            outline: s("Outline"),
233            breadcrumbs: s("Breadcrumbs"),
234            toolbar: s("Mind map toolbar"),
235            edit_topic: s("Edit node"),
236            collapse: s("Collapse"),
237            expand: s("Expand"),
238            toggle_children: s("Toggle children"),
239            focus_branch: s("Focus branch"),
240            has_note: s("Has note"),
241            has_image: s("Has image"),
242            image_unavailable: s("Image unavailable"),
243            moving: s("Moving branches ยท Esc to cancel"),
244            drop_here: s("Drop to move"),
245        }
246    }
247}
248impl Labels {
249    /// Label of a command.
250    pub fn command(&self, command: Command) -> &str {
251        match command {
252            Command::Child => &self.add_child,
253            Command::SiblingAfter => &self.add_sibling,
254            Command::SiblingBefore => &self.insert_before,
255            Command::Parent => &self.insert_parent,
256            Command::Delete => &self.delete,
257            Command::Duplicate => &self.duplicate,
258            Command::Fold => &self.fold,
259            Command::Indent => &self.indent,
260            Command::Outdent => &self.outdent,
261            Command::Undo => &self.undo,
262            Command::Redo => &self.redo,
263        }
264    }
265}
266/// Resolve an ID's parent using the full tree, including collapsed descendants.
267pub fn parent<'a>(root: &'a Node, id: &str) -> Option<&'a Node> {
268    if root.children.iter().any(|child| child.id == id) {
269        Some(root)
270    } else {
271        root.children.iter().find_map(|child| parent(child, id))
272    }
273}
274/// Resolve a command to a single core operation. IDs are deterministic and checked.
275pub fn command_operation(
276    data: &MindMapData,
277    ids: &[String],
278    command: Command,
279    new_topic: &str,
280) -> Result<Operation, MindMapError> {
281    let id = ids
282        .first()
283        .ok_or_else(|| MindMapError::InvalidOperation("Select a node first".into()))?;
284    let node = data
285        .node_data
286        .find(id)
287        .ok_or_else(|| MindMapError::NodeNotFound(id.clone()))?;
288    let fresh = || -> Result<Node, MindMapError> {
289        let mut n = 1u64;
290        loop {
291            let candidate = format!("plugin-{n}");
292            if data.node_data.find(&candidate).is_none() {
293                return Ok(Node::new(candidate, new_topic));
294            }
295            n = n
296                .checked_add(1)
297                .ok_or_else(|| MindMapError::InvalidOperation("Node ID space exhausted".into()))?;
298        }
299    };
300    let operation = match command {
301        Command::Child => Operation::Insert {
302            node: fresh()?,
303            placement: Placement::In(id.clone()),
304        },
305        Command::SiblingAfter => Operation::Insert {
306            node: fresh()?,
307            placement: Placement::After(id.clone()),
308        },
309        Command::SiblingBefore => Operation::Insert {
310            node: fresh()?,
311            placement: Placement::Before(id.clone()),
312        },
313        Command::Parent => Operation::InsertParent {
314            target: id.clone(),
315            parent: fresh()?,
316        },
317        Command::Delete => Operation::Remove(ids.to_vec()),
318        Command::Duplicate => Operation::Copy {
319            ids: ids.to_vec(),
320            placement: Placement::After(id.clone()),
321        },
322        Command::Fold => Operation::SetExpanded {
323            id: id.clone(),
324            expanded: !node.expanded,
325        },
326        Command::Indent => {
327            let p = parent(&data.node_data, id).ok_or(MindMapError::RootOperation)?;
328            let index = p
329                .children
330                .iter()
331                .position(|n| n.id == *id)
332                .ok_or(MindMapError::StaleOperation)?;
333            let previous = index
334                .checked_sub(1)
335                .and_then(|i| p.children.get(i))
336                .ok_or_else(|| MindMapError::InvalidOperation("No previous sibling".into()))?;
337            Operation::Move {
338                ids: ids.to_vec(),
339                placement: Placement::In(previous.id.clone()),
340            }
341        }
342        Command::Outdent => {
343            let p = parent(&data.node_data, id).ok_or(MindMapError::RootOperation)?;
344            if p.id == data.node_data.id {
345                return Err(MindMapError::RootOperation);
346            }
347            Operation::Move {
348                ids: ids.to_vec(),
349                placement: Placement::After(p.id.clone()),
350            }
351        }
352        Command::Undo | Command::Redo => {
353            return Err(MindMapError::InvalidOperation(
354                "History command has no tree operation".into(),
355            ));
356        }
357    };
358    Ok(operation)
359}
360/// Execute a command through the shared checked handle. New nodes use `new_topic`.
361pub fn execute(
362    handle: MindMapHandle,
363    command: Command,
364    new_topic: &str,
365) -> Result<(), MindMapError> {
366    match command {
367        Command::Undo => handle.undo(),
368        Command::Redo => handle.redo(),
369        _ => handle
370            .apply(command_operation(
371                &handle.document()?,
372                &handle.selection()?,
373                command,
374                new_topic,
375            )?)
376            .map(|_| ()),
377    }
378}