Skip to main content

nightshade_api/web/
undo_tree.rs

1//! A branching, signal-backed undo history and a component that renders it as a tree.
2
3use leptos::prelude::*;
4
5#[derive(Clone)]
6struct Node<T> {
7    value: T,
8    parent: Option<usize>,
9    label: String,
10}
11
12/// A reactive, branching undo history. Every edit becomes a node whose parent is
13/// the state it was made from, so undoing and then editing forks a new branch
14/// rather than discarding the old one. `Copy`, so it can be captured freely in
15/// closures.
16pub struct UndoHistory<T: Send + Sync + 'static> {
17    nodes: RwSignal<Vec<Node<T>>>,
18    current: RwSignal<usize>,
19}
20
21impl<T: Send + Sync + 'static> Clone for UndoHistory<T> {
22    fn clone(&self) -> Self {
23        *self
24    }
25}
26
27impl<T: Send + Sync + 'static> Copy for UndoHistory<T> {}
28
29/// A flattened view of one history node for rendering: its `id`, `label`, tree
30/// `depth`, and whether it is the `current` state.
31#[derive(Clone)]
32pub struct HistoryNode {
33    /// The node's index, accepted by `UndoHistory::restore` and the restore callback.
34    pub id: usize,
35    /// The label given when the node was pushed.
36    pub label: String,
37    /// Distance from the root, used to indent the row.
38    pub depth: usize,
39    /// Whether this node is the currently active state.
40    pub current: bool,
41}
42
43impl<T: Clone + Send + Sync + 'static> UndoHistory<T> {
44    /// Creates a history seeded with `initial` as the root state.
45    pub fn new(initial: T) -> Self {
46        Self {
47            nodes: RwSignal::new(vec![Node {
48                value: initial,
49                parent: None,
50                label: "initial".to_string(),
51            }]),
52            current: RwSignal::new(0),
53        }
54    }
55
56    /// Reactively reads the value at the current node.
57    pub fn value(&self) -> T {
58        let current = self.current.get();
59        self.nodes.with(|nodes| nodes[current].value.clone())
60    }
61
62    /// Records `value` as a child of the current node and makes it current.
63    pub fn push(&self, value: T, label: impl Into<String>) {
64        let parent = self.current.get_untracked();
65        let id = self.nodes.with_untracked(Vec::len);
66        self.nodes.update(|nodes| {
67            nodes.push(Node {
68                value,
69                parent: Some(parent),
70                label: label.into(),
71            });
72        });
73        self.current.set(id);
74    }
75
76    /// Moves to the parent of the current node, if any.
77    pub fn undo(&self) {
78        let parent = self
79            .nodes
80            .with_untracked(|nodes| nodes[self.current.get_untracked()].parent);
81        if let Some(parent) = parent {
82            self.current.set(parent);
83        }
84    }
85
86    /// Moves to the most recently added child of the current node, if any.
87    pub fn redo(&self) {
88        let current = self.current.get_untracked();
89        let child = self.nodes.with_untracked(|nodes| {
90            nodes
91                .iter()
92                .enumerate()
93                .rev()
94                .find(|(_, node)| node.parent == Some(current))
95                .map(|(index, _)| index)
96        });
97        if let Some(child) = child {
98            self.current.set(child);
99        }
100    }
101
102    /// Jumps directly to the node with the given id, ignoring out-of-range ids.
103    pub fn restore(&self, id: usize) {
104        if self.nodes.with_untracked(|nodes| id < nodes.len()) {
105            self.current.set(id);
106        }
107    }
108
109    /// Reactively returns every node as a `HistoryNode` for rendering the tree.
110    pub fn rows(&self) -> Vec<HistoryNode> {
111        let current = self.current.get();
112        self.nodes.with(|nodes| {
113            nodes
114                .iter()
115                .enumerate()
116                .map(|(id, node)| {
117                    let mut depth = 0;
118                    let mut cursor = node.parent;
119                    while let Some(parent) = cursor {
120                        depth += 1;
121                        cursor = nodes[parent].parent;
122                    }
123                    HistoryNode {
124                        id,
125                        label: node.label.clone(),
126                        depth,
127                        current: id == current,
128                    }
129                })
130                .collect()
131        })
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::UndoHistory;
138    use leptos::prelude::*;
139
140    #[test]
141    fn undo_redo_and_branching_navigate_the_tree() {
142        let _owner = Owner::new();
143        _owner.set();
144        let history = UndoHistory::new("a".to_string());
145        history.push("b".to_string(), "b");
146        history.push("c".to_string(), "c");
147        assert_eq!(history.value(), "c");
148        history.undo();
149        assert_eq!(history.value(), "b");
150        history.undo();
151        assert_eq!(history.value(), "a");
152        history.redo();
153        assert_eq!(history.value(), "b");
154        history.push("d".to_string(), "d");
155        assert_eq!(history.value(), "d");
156        assert_eq!(history.rows().len(), 4);
157    }
158}
159
160/// Renders history `nodes` newest-first as an indented list of buttons. The
161/// current node is marked, and clicking a row runs `on_restore` with that node's
162/// id.
163#[component]
164pub fn UndoTree(
165    #[prop(into)] nodes: Signal<Vec<HistoryNode>>,
166    on_restore: Callback<usize>,
167) -> impl IntoView {
168    view! {
169        <div class="nightshade-undo-tree">
170            {move || {
171                nodes
172                    .get()
173                    .into_iter()
174                    .rev()
175                    .map(|node| {
176                        let id = node.id;
177                        let indent = format!("padding-left:{}px", 8 + node.depth * 14);
178                        view! {
179                            <button
180                                class="nightshade-undo-node"
181                                class:current=node.current
182                                style=indent
183                                on:click=move |_| on_restore.run(id)
184                            >
185                                <span class="nightshade-undo-dot"></span>
186                                <span class="nightshade-undo-label">{node.label}</span>
187                            </button>
188                        }
189                    })
190                    .collect_view()
191            }}
192        </div>
193    }
194}