egui_arbor/state.rs
1//! State management for the outliner widget.
2//!
3//! This module provides the [`OutlinerState`] struct which tracks the expansion
4//! and editing state of nodes in the outliner. The state integrates with egui's
5//! memory system to persist across frames.
6
7use crate::drag_drop::DragDropState;
8use std::collections::HashSet;
9use std::hash::Hash;
10
11/// State for box selection operations.
12///
13/// Tracks the start position and whether a box selection is currently active.
14#[derive(Clone, Debug, PartialEq)]
15pub struct BoxSelectionState {
16 /// The starting position of the box selection in screen coordinates.
17 pub start_pos: egui::Pos2,
18 /// Whether the box selection is currently active.
19 pub active: bool,
20}
21
22impl BoxSelectionState {
23 /// Creates a new box selection state.
24 pub fn new(start_pos: egui::Pos2) -> Self {
25 Self {
26 start_pos,
27 active: true,
28 }
29 }
30}
31
32/// State for an outliner widget instance.
33///
34/// This struct tracks which collection nodes are expanded and which node (if any)
35/// is currently being edited. The state is generic over the node ID type and
36/// integrates with egui's memory system for automatic persistence.
37///
38/// # Type Parameters
39///
40/// * `Id` - The type used to identify nodes. Must implement `Hash`, `Eq`, and `Clone`.
41///
42/// # Examples
43///
44/// ```
45/// use egui_arbor::OutlinerState;
46/// use std::collections::HashSet;
47///
48/// let mut state = OutlinerState::<String>::default();
49///
50/// // Toggle expansion state
51/// state.toggle_expanded(&"node1".to_string());
52/// assert!(state.is_expanded(&"node1".to_string()));
53///
54/// // Start editing a node
55/// state.start_editing("node2".to_string(), "Node 2".to_string());
56/// assert!(state.is_editing(&"node2".to_string()));
57/// ```
58#[derive(Clone, Debug)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60pub struct OutlinerState<Id>
61where
62 Id: Hash + Eq + Clone + Send + Sync,
63{
64 /// Set of expanded collection node IDs.
65 ///
66 /// A node ID in this set indicates that the collection node is expanded
67 /// and its children should be visible.
68 expanded: HashSet<Id>,
69
70 /// The ID of the node currently being edited, if any.
71 ///
72 /// When `Some(id)`, the node with the given ID is in edit mode (e.g., for renaming).
73 /// Only one node can be edited at a time.
74 editing: Option<Id>,
75
76 /// The text being edited for the current node.
77 ///
78 /// This stores the text content while editing is in progress.
79 /// This field is not persisted across frames (it's transient state).
80 #[cfg_attr(feature = "serde", serde(skip))]
81 editing_text: String,
82
83 /// Drag-and-drop state for this outliner.
84 ///
85 /// Tracks the current drag operation, hover targets, and drop positions.
86 /// This field is not persisted across frames (it's transient state).
87 #[cfg_attr(feature = "serde", serde(skip))]
88 drag_drop: DragDropState<Id>,
89
90 /// The ID of the last selected node for shift-click range selection.
91 ///
92 /// This is used to determine the range when shift-clicking.
93 /// This field is not persisted across frames (it's transient state).
94 #[cfg_attr(feature = "serde", serde(skip))]
95 last_selected: Option<Id>,
96
97 /// State for box selection.
98 ///
99 /// Tracks the start position and current state of a box selection operation.
100 /// This field is not persisted across frames (it's transient state).
101 #[cfg_attr(feature = "serde", serde(skip))]
102 box_selection: Option<BoxSelectionState>,
103
104 /// IDs of all nodes being dragged in a multi-drag operation.
105 ///
106 /// This is set when a drag starts and includes all selected nodes.
107 /// This field is not persisted across frames (it's transient state).
108 #[cfg_attr(feature = "serde", serde(skip))]
109 dragging_nodes: Vec<Id>,
110}
111
112impl<Id> Default for OutlinerState<Id>
113where
114 Id: Hash + Eq + Clone + Send + Sync,
115{
116 /// Creates a new outliner state with no expanded nodes and no editing node.
117 fn default() -> Self {
118 Self {
119 expanded: HashSet::new(),
120 editing: None,
121 editing_text: String::new(),
122 drag_drop: DragDropState::new(),
123 last_selected: None,
124 box_selection: None,
125 dragging_nodes: Vec::new(),
126 }
127 }
128}
129
130impl<Id> OutlinerState<Id>
131where
132 Id: Hash + Eq + Clone + Send + Sync,
133{
134 /// Loads the outliner state from egui's memory system.
135 ///
136 /// If no state exists for the given ID, returns a default empty state.
137 ///
138 /// # Parameters
139 ///
140 /// * `ctx` - The egui context to load state from
141 /// * `id` - The unique identifier for this outliner instance
142 ///
143 /// # Examples
144 ///
145 /// ```no_run
146 /// # use egui_arbor::OutlinerState;
147 /// # fn example(ctx: &egui::Context) {
148 /// let state = OutlinerState::<String>::load(ctx, egui::Id::new("my_outliner"));
149 /// # }
150 /// ```
151 pub fn load(ctx: &egui::Context, id: egui::Id) -> Self
152 where
153 Id: 'static,
154 {
155 ctx.data_mut(|d| d.get_persisted(id).unwrap_or_default())
156 }
157
158 /// Stores the outliner state to egui's memory system.
159 ///
160 /// The state will be persisted across frames and can be retrieved using
161 /// [`load`](Self::load) with the same ID.
162 ///
163 /// # Parameters
164 ///
165 /// * `ctx` - The egui context to store state in
166 /// * `id` - The unique identifier for this outliner instance
167 ///
168 /// # Examples
169 ///
170 /// ```no_run
171 /// # use egui_arbor::OutlinerState;
172 /// # fn example(ctx: &egui::Context) {
173 /// let mut state = OutlinerState::<String>::default();
174 /// state.toggle_expanded(&"node1".to_string());
175 /// state.store(ctx, egui::Id::new("my_outliner"));
176 /// # }
177 /// ```
178 pub fn store(&self, ctx: &egui::Context, id: egui::Id)
179 where
180 Id: 'static,
181 {
182 ctx.data_mut(|d| d.insert_persisted(id, self.clone()));
183 }
184
185 /// Checks if a node is currently expanded.
186 ///
187 /// # Parameters
188 ///
189 /// * `id` - The ID of the node to check
190 ///
191 /// # Returns
192 ///
193 /// `true` if the node is expanded, `false` otherwise.
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// # use egui_arbor::OutlinerState;
199 /// let mut state = OutlinerState::<String>::default();
200 /// state.set_expanded(&"node1".to_string(), true);
201 /// assert!(state.is_expanded(&"node1".to_string()));
202 /// ```
203 pub fn is_expanded(&self, id: &Id) -> bool {
204 self.expanded.contains(id)
205 }
206
207 /// Toggles the expansion state of a node.
208 ///
209 /// If the node is currently expanded, it will be collapsed.
210 /// If the node is currently collapsed, it will be expanded.
211 ///
212 /// # Parameters
213 ///
214 /// * `id` - The ID of the node to toggle
215 ///
216 /// # Examples
217 ///
218 /// ```
219 /// # use egui_arbor::OutlinerState;
220 /// let mut state = OutlinerState::<String>::default();
221 /// state.toggle_expanded(&"node1".to_string());
222 /// assert!(state.is_expanded(&"node1".to_string()));
223 /// state.toggle_expanded(&"node1".to_string());
224 /// assert!(!state.is_expanded(&"node1".to_string()));
225 /// ```
226 pub fn toggle_expanded(&mut self, id: &Id) {
227 if self.expanded.contains(id) {
228 self.expanded.remove(id);
229 } else {
230 self.expanded.insert(id.clone());
231 }
232 }
233
234 /// Sets the expansion state of a node.
235 ///
236 /// # Parameters
237 ///
238 /// * `id` - The ID of the node to modify
239 /// * `expanded` - `true` to expand the node, `false` to collapse it
240 ///
241 /// # Examples
242 ///
243 /// ```
244 /// # use egui_arbor::OutlinerState;
245 /// let mut state = OutlinerState::<String>::default();
246 /// state.set_expanded(&"node1".to_string(), true);
247 /// assert!(state.is_expanded(&"node1".to_string()));
248 /// state.set_expanded(&"node1".to_string(), false);
249 /// assert!(!state.is_expanded(&"node1".to_string()));
250 /// ```
251 pub fn set_expanded(&mut self, id: &Id, expanded: bool) {
252 if expanded {
253 self.expanded.insert(id.clone());
254 } else {
255 self.expanded.remove(id);
256 }
257 }
258
259 /// Checks if a node is currently being edited.
260 ///
261 /// # Parameters
262 ///
263 /// * `id` - The ID of the node to check
264 ///
265 /// # Returns
266 ///
267 /// `true` if the node is being edited, `false` otherwise.
268 ///
269 /// # Examples
270 ///
271 /// ```
272 /// # use egui_arbor::OutlinerState;
273 /// let mut state = OutlinerState::<String>::default();
274 /// state.start_editing("node1".to_string(), "Node 1".to_string());
275 /// assert!(state.is_editing(&"node1".to_string()));
276 /// ```
277 pub fn is_editing(&self, id: &Id) -> bool {
278 self.editing.as_ref() == Some(id)
279 }
280
281 /// Starts editing a node.
282 ///
283 /// This will stop editing any previously edited node, as only one node
284 /// can be edited at a time.
285 ///
286 /// # Parameters
287 ///
288 /// * `id` - The ID of the node to start editing
289 /// * `initial_text` - The initial text to display in the edit field
290 ///
291 /// # Examples
292 ///
293 /// ```
294 /// # use egui_arbor::OutlinerState;
295 /// let mut state = OutlinerState::<String>::default();
296 /// state.start_editing("node1".to_string(), "Initial Name".to_string());
297 /// assert!(state.is_editing(&"node1".to_string()));
298 /// ```
299 pub fn start_editing(&mut self, id: Id, initial_text: String) {
300 self.editing = Some(id);
301 self.editing_text = initial_text;
302 }
303
304 /// Stops editing the currently edited node, if any.
305 ///
306 /// # Examples
307 ///
308 /// ```
309 /// # use egui_arbor::OutlinerState;
310 /// let mut state = OutlinerState::<String>::default();
311 /// state.start_editing("node1".to_string(), "Name".to_string());
312 /// state.stop_editing();
313 /// assert!(!state.is_editing(&"node1".to_string()));
314 /// ```
315 pub fn stop_editing(&mut self) {
316 self.editing = None;
317 self.editing_text.clear();
318 }
319
320 /// Returns a mutable reference to the editing text.
321 ///
322 /// This allows the text edit widget to modify the text directly.
323 pub fn editing_text_mut(&mut self) -> &mut String {
324 &mut self.editing_text
325 }
326
327 /// Returns a reference to the editing text.
328 pub fn editing_text(&self) -> &str {
329 &self.editing_text
330 }
331
332 /// Returns a reference to the drag-drop state.
333 ///
334 /// # Examples
335 ///
336 /// ```
337 /// # use egui_arbor::OutlinerState;
338 /// let state = OutlinerState::<String>::default();
339 /// assert!(!state.drag_drop().is_dragging());
340 /// ```
341 pub fn drag_drop(&self) -> &DragDropState<Id> {
342 &self.drag_drop
343 }
344
345 /// Returns a mutable reference to the drag-drop state.
346 ///
347 /// # Examples
348 ///
349 /// ```
350 /// # use egui_arbor::OutlinerState;
351 /// let mut state = OutlinerState::<String>::default();
352 /// state.drag_drop_mut().start_drag("node1".to_string());
353 /// assert!(state.drag_drop().is_dragging());
354 /// ```
355 pub fn drag_drop_mut(&mut self) -> &mut DragDropState<Id> {
356 &mut self.drag_drop
357 }
358
359 /// Sets the last selected node for shift-click range selection.
360 ///
361 /// # Parameters
362 ///
363 /// * `id` - The ID of the last selected node
364 pub fn set_last_selected(&mut self, id: Option<Id>) {
365 self.last_selected = id;
366 }
367
368 /// Returns the ID of the last selected node, if any.
369 pub fn last_selected(&self) -> Option<&Id> {
370 self.last_selected.as_ref()
371 }
372
373 /// Starts a box selection operation.
374 ///
375 /// # Parameters
376 ///
377 /// * `start_pos` - The starting position in screen coordinates
378 pub fn start_box_selection(&mut self, start_pos: egui::Pos2) {
379 self.box_selection = Some(BoxSelectionState {
380 start_pos,
381 active: true,
382 });
383 }
384
385 /// Returns the current box selection state, if any.
386 pub fn box_selection(&self) -> Option<&BoxSelectionState> {
387 self.box_selection.as_ref()
388 }
389
390 /// Ends the current box selection operation.
391 pub fn end_box_selection(&mut self) {
392 self.box_selection = None;
393 }
394
395 /// Sets the nodes being dragged in a multi-drag operation.
396 pub fn set_dragging_nodes(&mut self, nodes: Vec<Id>) {
397 self.dragging_nodes = nodes;
398 }
399
400 /// Returns the nodes being dragged in a multi-drag operation.
401 pub fn dragging_nodes(&self) -> &[Id] {
402 &self.dragging_nodes
403 }
404
405 /// Clears the dragging nodes list.
406 pub fn clear_dragging_nodes(&mut self) {
407 self.dragging_nodes.clear();
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::traits::DropPosition;
415
416 #[test]
417 fn test_default_state() {
418 let state = OutlinerState::<String>::default();
419 assert!(!state.is_expanded(&"test".to_string()));
420 assert!(!state.is_editing(&"test".to_string()));
421 assert!(!state.drag_drop().is_dragging());
422 assert_eq!(state.last_selected(), None);
423 assert_eq!(state.box_selection(), None);
424 assert!(state.dragging_nodes().is_empty());
425 }
426
427 #[test]
428 fn test_expansion() {
429 let mut state = OutlinerState::<String>::default();
430 let id = "node1".to_string();
431
432 assert!(!state.is_expanded(&id));
433
434 state.set_expanded(&id, true);
435 assert!(state.is_expanded(&id));
436
437 state.set_expanded(&id, false);
438 assert!(!state.is_expanded(&id));
439 }
440
441 #[test]
442 fn test_toggle_expansion() {
443 let mut state = OutlinerState::<String>::default();
444 let id = "node1".to_string();
445
446 state.toggle_expanded(&id);
447 assert!(state.is_expanded(&id));
448
449 state.toggle_expanded(&id);
450 assert!(!state.is_expanded(&id));
451 }
452
453 #[test]
454 fn test_multiple_expansions() {
455 let mut state = OutlinerState::<String>::default();
456 let id1 = "node1".to_string();
457 let id2 = "node2".to_string();
458 let id3 = "node3".to_string();
459
460 state.set_expanded(&id1, true);
461 state.set_expanded(&id2, true);
462 state.set_expanded(&id3, true);
463
464 assert!(state.is_expanded(&id1));
465 assert!(state.is_expanded(&id2));
466 assert!(state.is_expanded(&id3));
467
468 state.set_expanded(&id2, false);
469 assert!(state.is_expanded(&id1));
470 assert!(!state.is_expanded(&id2));
471 assert!(state.is_expanded(&id3));
472 }
473
474 #[test]
475 fn test_editing() {
476 let mut state = OutlinerState::<String>::default();
477 let id1 = "node1".to_string();
478 let id2 = "node2".to_string();
479
480 assert!(!state.is_editing(&id1));
481
482 state.start_editing(id1.clone(), "Node 1".to_string());
483 assert!(state.is_editing(&id1));
484 assert!(!state.is_editing(&id2));
485 assert_eq!(state.editing_text(), "Node 1");
486
487 state.start_editing(id2.clone(), "Node 2".to_string());
488 assert!(!state.is_editing(&id1));
489 assert!(state.is_editing(&id2));
490 assert_eq!(state.editing_text(), "Node 2");
491
492 state.stop_editing();
493 assert!(!state.is_editing(&id1));
494 assert!(!state.is_editing(&id2));
495 assert_eq!(state.editing_text(), "");
496 }
497
498 #[test]
499 fn test_editing_same_node_twice() {
500 let mut state = OutlinerState::<String>::default();
501 let id = "node1".to_string();
502
503 state.start_editing(id.clone(), "First".to_string());
504 assert!(state.is_editing(&id));
505 assert_eq!(state.editing_text(), "First");
506
507 state.start_editing(id.clone(), "Second".to_string());
508 assert!(state.is_editing(&id));
509 assert_eq!(state.editing_text(), "Second");
510 }
511
512 #[test]
513 fn test_drag_drop_state_access() {
514 let mut state = OutlinerState::<u64>::default();
515
516 assert!(!state.drag_drop().is_dragging());
517
518 state.drag_drop_mut().start_drag(42);
519 assert!(state.drag_drop().is_dragging());
520 assert_eq!(state.drag_drop().dragging_id(), Some(&42));
521 }
522
523 #[test]
524 fn test_last_selected() {
525 let mut state = OutlinerState::<u64>::default();
526
527 assert_eq!(state.last_selected(), None);
528
529 state.set_last_selected(Some(1));
530 assert_eq!(state.last_selected(), Some(&1));
531
532 state.set_last_selected(Some(2));
533 assert_eq!(state.last_selected(), Some(&2));
534
535 state.set_last_selected(None);
536 assert_eq!(state.last_selected(), None);
537 }
538
539 #[test]
540 fn test_box_selection_lifecycle() {
541 let mut state = OutlinerState::<u64>::default();
542
543 assert_eq!(state.box_selection(), None);
544
545 let start_pos = egui::pos2(10.0, 20.0);
546 state.start_box_selection(start_pos);
547
548 let box_sel = state.box_selection();
549 assert!(box_sel.is_some());
550 assert_eq!(box_sel.unwrap().start_pos, start_pos);
551 assert!(box_sel.unwrap().active);
552
553 state.end_box_selection();
554 assert_eq!(state.box_selection(), None);
555 }
556
557 #[test]
558 fn test_box_selection_state_new() {
559 let pos = egui::pos2(5.0, 10.0);
560 let box_sel = BoxSelectionState::new(pos);
561
562 assert_eq!(box_sel.start_pos, pos);
563 assert!(box_sel.active);
564 }
565
566 #[test]
567 fn test_dragging_nodes() {
568 let mut state = OutlinerState::<u64>::default();
569
570 assert!(state.dragging_nodes().is_empty());
571
572 let nodes = vec![1, 2, 3];
573 state.set_dragging_nodes(nodes.clone());
574 assert_eq!(state.dragging_nodes(), &[1, 2, 3]);
575
576 state.clear_dragging_nodes();
577 assert!(state.dragging_nodes().is_empty());
578 }
579
580 #[test]
581 fn test_dragging_nodes_update() {
582 let mut state = OutlinerState::<u64>::default();
583
584 state.set_dragging_nodes(vec![1, 2]);
585 assert_eq!(state.dragging_nodes().len(), 2);
586
587 state.set_dragging_nodes(vec![3, 4, 5]);
588 assert_eq!(state.dragging_nodes().len(), 3);
589 assert_eq!(state.dragging_nodes(), &[3, 4, 5]);
590 }
591
592 #[test]
593 fn test_combined_state_operations() {
594 let mut state = OutlinerState::<u64>::default();
595
596 // Expand some nodes
597 state.set_expanded(&1, true);
598 state.set_expanded(&2, true);
599
600 // Start editing
601 state.start_editing(3, "Node 3".to_string());
602
603 // Set last selected
604 state.set_last_selected(Some(4));
605
606 // Start drag
607 state.drag_drop_mut().start_drag(5);
608
609 // Verify all states are maintained
610 assert!(state.is_expanded(&1));
611 assert!(state.is_expanded(&2));
612 assert!(state.is_editing(&3));
613 assert_eq!(state.last_selected(), Some(&4));
614 assert!(state.drag_drop().is_dragging_node(&5));
615 }
616
617 #[test]
618 fn test_expansion_persistence() {
619 let mut state = OutlinerState::<u64>::default();
620
621 // Expand multiple nodes
622 for i in 1..=10 {
623 state.set_expanded(&i, true);
624 }
625
626 // Verify all are expanded
627 for i in 1..=10 {
628 assert!(state.is_expanded(&i));
629 }
630
631 // Collapse some
632 state.set_expanded(&3, false);
633 state.set_expanded(&7, false);
634
635 // Verify correct state
636 assert!(state.is_expanded(&1));
637 assert!(state.is_expanded(&2));
638 assert!(!state.is_expanded(&3));
639 assert!(state.is_expanded(&4));
640 assert!(!state.is_expanded(&7));
641 assert!(state.is_expanded(&10));
642 }
643
644 #[test]
645 fn test_drag_drop_integration() {
646 let mut state = OutlinerState::<u64>::default();
647
648 // Start drag
649 state.drag_drop_mut().start_drag(1);
650 state.set_dragging_nodes(vec![1, 2, 3]);
651
652 // Update hover
653 state.drag_drop_mut().update_hover(4, DropPosition::Inside);
654
655 // Verify state
656 assert!(state.drag_drop().is_dragging());
657 assert_eq!(state.dragging_nodes(), &[1, 2, 3]);
658 assert!(state.drag_drop().is_hover_target(&4));
659
660 // End drag
661 let result = state.drag_drop_mut().end_drag();
662 assert!(result.is_some());
663
664 // Clear dragging nodes
665 state.clear_dragging_nodes();
666 assert!(state.dragging_nodes().is_empty());
667 }
668
669 #[test]
670 fn test_state_isolation() {
671 let mut state1 = OutlinerState::<u64>::default();
672 let mut state2 = OutlinerState::<u64>::default();
673
674 state1.set_expanded(&1, true);
675 state2.set_expanded(&2, true);
676
677 assert!(state1.is_expanded(&1));
678 assert!(!state1.is_expanded(&2));
679 assert!(!state2.is_expanded(&1));
680 assert!(state2.is_expanded(&2));
681 }
682}