iced_code_editor/canvas_editor/
history.rs

1//! Command history management for undo/redo functionality.
2//!
3//! This module provides thread-safe command history tracking with configurable
4//! size limits and save point tracking for modified state detection.
5//!
6//! # Examples
7//!
8//! ## Basic Usage
9//!
10//! ```
11//! use iced_code_editor::CommandHistory;
12//!
13//! // Create a history with a limit of 100 operations
14//! let history = CommandHistory::new(100);
15//!
16//! // Check state
17//! assert_eq!(history.undo_count(), 0);
18//! assert_eq!(history.redo_count(), 0);
19//! assert!(!history.can_undo());
20//! ```
21//!
22//! ## Dynamic Configuration
23//!
24//! ```
25//! use iced_code_editor::CommandHistory;
26//!
27//! let history = CommandHistory::new(100);
28//!
29//! // Adjust history size based on available memory
30//! history.set_max_size(500);
31//! assert_eq!(history.max_size(), 500);
32//!
33//! // Clear all history when starting a new document
34//! history.clear();
35//! ```
36//!
37//! ## Save Point Tracking
38//!
39//! ```
40//! use iced_code_editor::CommandHistory;
41//!
42//! let history = CommandHistory::new(100);
43//!
44//! // Mark the current state as saved
45//! history.mark_saved();
46//! assert!(!history.is_modified());
47//!
48//! // After user makes changes...
49//! // history.push(some_command);
50//! // assert!(history.is_modified());
51//! ```
52
53// Allow unwrap on Mutex since this is safe in the single-threaded GUI context
54// The mutex is only used for interior mutability, not actual multi-threading
55#![allow(clippy::unwrap_used)]
56// The Mutex cannot be poisoned in our single-threaded context, so panics documented
57// below would never actually occur in practice
58#![allow(clippy::missing_panics_doc)]
59
60use super::command::{Command, CompositeCommand};
61use crate::text_buffer::TextBuffer;
62use std::sync::{Arc, Mutex};
63
64/// Manages command history for undo/redo operations.
65///
66/// The history maintains two stacks:
67/// - Undo stack: Commands that can be undone
68/// - Redo stack: Commands that can be redone (cleared when new commands are added)
69///
70/// Thread-safe using Arc<Mutex<>> for interior mutability.
71#[derive(Debug, Clone)]
72pub struct CommandHistory {
73    inner: Arc<Mutex<HistoryInner>>,
74}
75
76#[derive(Debug)]
77struct HistoryInner {
78    /// Stack of commands that can be undone
79    undo_stack: Vec<Box<dyn Command>>,
80    /// Stack of commands that can be redone
81    redo_stack: Vec<Box<dyn Command>>,
82    /// Maximum number of commands to keep in history
83    max_size: usize,
84    /// Index in undo_stack where document was last saved (None if never saved)
85    save_point: Option<usize>,
86    /// Current composite command being built (for grouping)
87    current_group: Option<CompositeCommand>,
88}
89
90impl CommandHistory {
91    /// Creates a new command history with the specified size limit.
92    ///
93    /// # Arguments
94    ///
95    /// * `max_size` - Maximum number of commands to keep in history
96    ///
97    /// # Returns
98    ///
99    /// A new `CommandHistory` instance
100    ///
101    /// # Example
102    ///
103    /// ```
104    /// use iced_code_editor::CommandHistory;
105    ///
106    /// let history = CommandHistory::new(100);
107    /// ```
108    pub fn new(max_size: usize) -> Self {
109        Self {
110            inner: Arc::new(Mutex::new(HistoryInner {
111                undo_stack: Vec::with_capacity(max_size.min(100)),
112                redo_stack: Vec::with_capacity(max_size.min(100)),
113                max_size,
114                save_point: None,
115                current_group: None,
116            })),
117        }
118    }
119
120    /// Adds a command to the history.
121    ///
122    /// This clears the redo stack and adds the command to the undo stack.
123    /// If currently grouping commands, adds to the current group instead.
124    ///
125    /// # Arguments
126    ///
127    /// * `command` - The command to add
128    pub fn push(&self, command: Box<dyn Command>) {
129        let mut inner = self.inner.lock().unwrap();
130
131        // If we're building a composite, add to it
132        if let Some(ref mut group) = inner.current_group {
133            group.add(command);
134            return;
135        }
136
137        // Clear redo stack when new command is added
138        inner.redo_stack.clear();
139
140        // Add to undo stack
141        inner.undo_stack.push(command);
142
143        // Enforce size limit
144        if inner.undo_stack.len() > inner.max_size {
145            inner.undo_stack.remove(0);
146            // Adjust save point if it exists
147            if let Some(ref mut sp) = inner.save_point {
148                if *sp > 0 {
149                    *sp -= 1;
150                } else {
151                    inner.save_point = None;
152                }
153            }
154        }
155
156        // Update save point - we've made changes
157        // The save point is now invalid unless it's still at the current position
158    }
159
160    /// Undoes the last command.
161    ///
162    /// # Arguments
163    ///
164    /// * `buffer` - The text buffer to modify
165    /// * `cursor` - The cursor position to update
166    ///
167    /// # Returns
168    ///
169    /// `true` if a command was undone, `false` if nothing to undo
170    pub fn undo(
171        &self,
172        buffer: &mut TextBuffer,
173        cursor: &mut (usize, usize),
174    ) -> bool {
175        let mut inner = self.inner.lock().unwrap();
176
177        // End any current grouping
178        if inner.current_group.is_some() {
179            Self::end_group_internal(&mut inner);
180        }
181
182        if let Some(mut command) = inner.undo_stack.pop() {
183            command.undo(buffer, cursor);
184            inner.redo_stack.push(command);
185            true
186        } else {
187            false
188        }
189    }
190
191    /// Redoes the last undone command.
192    ///
193    /// # Arguments
194    ///
195    /// * `buffer` - The text buffer to modify
196    /// * `cursor` - The cursor position to update
197    ///
198    /// # Returns
199    ///
200    /// `true` if a command was redone, `false` if nothing to redo
201    pub fn redo(
202        &self,
203        buffer: &mut TextBuffer,
204        cursor: &mut (usize, usize),
205    ) -> bool {
206        let mut inner = self.inner.lock().unwrap();
207
208        if let Some(mut command) = inner.redo_stack.pop() {
209            command.execute(buffer, cursor);
210            inner.undo_stack.push(command);
211            true
212        } else {
213            false
214        }
215    }
216
217    /// Returns whether there are commands that can be undone.
218    #[must_use]
219    pub fn can_undo(&self) -> bool {
220        let inner = self.inner.lock().unwrap();
221        !inner.undo_stack.is_empty() || inner.current_group.is_some()
222    }
223
224    /// Returns whether there are commands that can be redone.
225    #[must_use]
226    pub fn can_redo(&self) -> bool {
227        let inner = self.inner.lock().unwrap();
228        !inner.redo_stack.is_empty()
229    }
230
231    /// Marks the current position as the save point.
232    ///
233    /// This is used to track whether the document has been modified since
234    /// the last save. Call this after successfully saving the file.
235    pub fn mark_saved(&self) {
236        let mut inner = self.inner.lock().unwrap();
237        inner.save_point = Some(inner.undo_stack.len());
238    }
239
240    /// Returns whether the document has been modified since the last save.
241    ///
242    /// # Returns
243    ///
244    /// `true` if there are unsaved changes, `false` otherwise
245    #[must_use]
246    pub fn is_modified(&self) -> bool {
247        let inner = self.inner.lock().unwrap();
248
249        // If we're currently in a group, we're modified
250        if inner.current_group.is_some() {
251            return true;
252        }
253
254        match inner.save_point {
255            None => !inner.undo_stack.is_empty(),
256            Some(sp) => sp != inner.undo_stack.len(),
257        }
258    }
259
260    /// Clears all history.
261    ///
262    /// This removes all undo/redo commands and resets the save point.
263    /// Useful when starting a new document or resetting the editor state.
264    ///
265    /// # Example
266    ///
267    /// ```
268    /// use iced_code_editor::CommandHistory;
269    ///
270    /// let history = CommandHistory::new(100);
271    /// // ... perform some operations ...
272    ///
273    /// // Clear everything when opening a new document
274    /// history.clear();
275    /// assert_eq!(history.undo_count(), 0);
276    /// assert_eq!(history.redo_count(), 0);
277    /// assert!(!history.is_modified());
278    /// ```
279    pub fn clear(&self) {
280        let mut inner = self.inner.lock().unwrap();
281        inner.undo_stack.clear();
282        inner.redo_stack.clear();
283        inner.save_point = None;
284        inner.current_group = None;
285    }
286
287    /// Begins grouping subsequent commands into a composite.
288    ///
289    /// All commands added via `push()` will be grouped together until
290    /// `end_group()` is called. This is useful for grouping consecutive
291    /// typing operations.
292    ///
293    /// # Arguments
294    ///
295    /// * `description` - Description for the composite command
296    pub fn begin_group(&self, description: &str) {
297        let mut inner = self.inner.lock().unwrap();
298        if inner.current_group.is_none() {
299            inner.current_group =
300                Some(CompositeCommand::new(description.to_string()));
301        }
302    }
303
304    /// Ends the current command grouping.
305    ///
306    /// The grouped commands are added to the history as a single composite
307    /// command. If no commands were grouped, nothing is added.
308    pub fn end_group(&self) {
309        let mut inner = self.inner.lock().unwrap();
310        Self::end_group_internal(&mut inner);
311    }
312
313    /// Internal helper to end grouping (used when lock is already held).
314    fn end_group_internal(inner: &mut HistoryInner) {
315        if let Some(group) = inner.current_group.take()
316            && !group.is_empty()
317        {
318            // Clear redo stack
319            inner.redo_stack.clear();
320
321            // Add composite to undo stack
322            inner.undo_stack.push(Box::new(group));
323
324            // Enforce size limit
325            if inner.undo_stack.len() > inner.max_size {
326                inner.undo_stack.remove(0);
327                if let Some(ref mut sp) = inner.save_point {
328                    if *sp > 0 {
329                        *sp -= 1;
330                    } else {
331                        inner.save_point = None;
332                    }
333                }
334            }
335        }
336    }
337
338    /// Returns the maximum history size.
339    ///
340    /// # Returns
341    ///
342    /// The maximum number of commands that can be stored in history.
343    ///
344    /// # Example
345    ///
346    /// ```
347    /// use iced_code_editor::CommandHistory;
348    ///
349    /// let history = CommandHistory::new(100);
350    /// assert_eq!(history.max_size(), 100);
351    /// ```
352    #[must_use]
353    pub fn max_size(&self) -> usize {
354        let inner = self.inner.lock().unwrap();
355        inner.max_size
356    }
357
358    /// Sets the maximum history size.
359    ///
360    /// If the current history exceeds the new size, older commands are removed.
361    /// This is useful for adjusting memory usage based on system resources.
362    ///
363    /// # Arguments
364    ///
365    /// * `max_size` - New maximum size (number of commands to keep)
366    ///
367    /// # Example
368    ///
369    /// ```
370    /// use iced_code_editor::CommandHistory;
371    ///
372    /// let history = CommandHistory::new(100);
373    ///
374    /// // Increase limit for memory-rich environments
375    /// history.set_max_size(500);
376    /// assert_eq!(history.max_size(), 500);
377    ///
378    /// // Decrease limit for constrained environments
379    /// history.set_max_size(50);
380    /// assert_eq!(history.max_size(), 50);
381    /// ```
382    pub fn set_max_size(&self, max_size: usize) {
383        let mut inner = self.inner.lock().unwrap();
384        inner.max_size = max_size;
385
386        // Trim if necessary
387        while inner.undo_stack.len() > max_size {
388            inner.undo_stack.remove(0);
389            if let Some(ref mut sp) = inner.save_point {
390                if *sp > 0 {
391                    *sp -= 1;
392                } else {
393                    inner.save_point = None;
394                }
395            }
396        }
397    }
398
399    /// Returns the current number of undo operations available.
400    ///
401    /// This can be useful for displaying history statistics or managing
402    /// UI state (e.g., enabling/disabling undo buttons).
403    ///
404    /// # Returns
405    ///
406    /// The number of commands that can be undone.
407    ///
408    /// # Example
409    ///
410    /// ```
411    /// use iced_code_editor::CommandHistory;
412    ///
413    /// let history = CommandHistory::new(100);
414    /// assert_eq!(history.undo_count(), 0);
415    ///
416    /// // After adding commands...
417    /// // assert!(history.undo_count() > 0);
418    /// ```
419    #[must_use]
420    pub fn undo_count(&self) -> usize {
421        let inner = self.inner.lock().unwrap();
422        inner.undo_stack.len()
423    }
424
425    /// Returns the current number of redo operations available.
426    ///
427    /// This can be useful for displaying history statistics or managing
428    /// UI state (e.g., enabling/disabling redo buttons).
429    ///
430    /// # Returns
431    ///
432    /// The number of commands that can be redone.
433    ///
434    /// # Example
435    ///
436    /// ```
437    /// use iced_code_editor::CommandHistory;
438    ///
439    /// let history = CommandHistory::new(100);
440    /// assert_eq!(history.redo_count(), 0);
441    ///
442    /// // After undoing some commands...
443    /// // assert!(history.redo_count() > 0);
444    /// ```
445    #[must_use]
446    pub fn redo_count(&self) -> usize {
447        let inner = self.inner.lock().unwrap();
448        inner.redo_stack.len()
449    }
450}
451
452// Implement Default for convenient usage
453impl Default for CommandHistory {
454    fn default() -> Self {
455        Self::new(100)
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::canvas_editor::command::InsertCharCommand;
463
464    #[test]
465    fn test_new_history() {
466        let history = CommandHistory::new(50);
467        assert_eq!(history.max_size(), 50);
468        assert!(!history.can_undo());
469        assert!(!history.can_redo());
470    }
471
472    #[test]
473    fn test_push_and_undo() {
474        let mut buffer = TextBuffer::new("hello");
475        let mut cursor = (0, 5);
476        let history = CommandHistory::new(10);
477
478        let mut cmd = InsertCharCommand::new(0, 5, '!', cursor);
479        cmd.execute(&mut buffer, &mut cursor);
480        history.push(Box::new(cmd));
481
482        assert!(history.can_undo());
483        assert_eq!(buffer.line(0), "hello!");
484
485        history.undo(&mut buffer, &mut cursor);
486        assert_eq!(buffer.line(0), "hello");
487        assert_eq!(cursor, (0, 5));
488    }
489
490    #[test]
491    fn test_redo() {
492        let mut buffer = TextBuffer::new("hello");
493        let mut cursor = (0, 5);
494        let history = CommandHistory::new(10);
495
496        let mut cmd = InsertCharCommand::new(0, 5, '!', cursor);
497        cmd.execute(&mut buffer, &mut cursor);
498        history.push(Box::new(cmd));
499
500        history.undo(&mut buffer, &mut cursor);
501        assert_eq!(buffer.line(0), "hello");
502
503        assert!(history.can_redo());
504        history.redo(&mut buffer, &mut cursor);
505        assert_eq!(buffer.line(0), "hello!");
506        assert_eq!(cursor, (0, 6));
507    }
508
509    #[test]
510    fn test_save_point() {
511        let mut buffer = TextBuffer::new("hello");
512        let mut cursor = (0, 5);
513        let history = CommandHistory::new(10);
514
515        assert!(!history.is_modified()); // New document is not modified
516
517        let mut cmd = InsertCharCommand::new(0, 5, '!', cursor);
518        cmd.execute(&mut buffer, &mut cursor);
519        history.push(Box::new(cmd));
520
521        assert!(history.is_modified()); // Now modified
522
523        history.mark_saved();
524        assert!(!history.is_modified()); // Saved
525
526        let mut cmd2 = InsertCharCommand::new(0, 6, '?', cursor);
527        cmd2.execute(&mut buffer, &mut cursor);
528        history.push(Box::new(cmd2));
529
530        assert!(history.is_modified()); // Modified again
531    }
532
533    #[test]
534    fn test_clear() {
535        let mut buffer = TextBuffer::new("hello");
536        let mut cursor = (0, 5);
537        let history = CommandHistory::new(10);
538
539        let mut cmd = InsertCharCommand::new(0, 5, '!', cursor);
540        cmd.execute(&mut buffer, &mut cursor);
541        history.push(Box::new(cmd));
542
543        assert!(history.can_undo());
544        history.clear();
545        assert!(!history.can_undo());
546        assert!(!history.is_modified());
547    }
548
549    #[test]
550    fn test_size_limit() {
551        let mut buffer = TextBuffer::new("a");
552        let mut cursor = (0, 1);
553        let history = CommandHistory::new(3);
554
555        // Add 5 commands (exceeds limit of 3)
556        for i in 0..5 {
557            let mut cmd = InsertCharCommand::new(0, 1 + i, 'x', cursor);
558            cmd.execute(&mut buffer, &mut cursor);
559            cursor.1 += 1;
560            history.push(Box::new(cmd));
561        }
562
563        // Should only have 3 in history
564        assert_eq!(history.undo_count(), 3);
565    }
566
567    #[test]
568    fn test_grouping() {
569        let mut buffer = TextBuffer::new("hello");
570        let mut cursor = (0, 5);
571        let history = CommandHistory::new(10);
572
573        history.begin_group("typing");
574
575        // Add multiple characters
576        for ch in "!!!".chars() {
577            let mut cmd = InsertCharCommand::new(0, cursor.1, ch, cursor);
578            cmd.execute(&mut buffer, &mut cursor);
579            // Don't manually increment cursor - execute() does it
580            history.push(Box::new(cmd));
581        }
582
583        history.end_group();
584
585        assert_eq!(buffer.line(0), "hello!!!");
586        assert_eq!(history.undo_count(), 1); // All grouped into one
587
588        // Single undo should remove all three characters
589        history.undo(&mut buffer, &mut cursor);
590        assert_eq!(buffer.line(0), "hello");
591        assert_eq!(cursor, (0, 5));
592    }
593
594    #[test]
595    fn test_push_clears_redo() {
596        let mut buffer = TextBuffer::new("hello");
597        let mut cursor = (0, 5);
598        let history = CommandHistory::new(10);
599
600        let mut cmd1 = InsertCharCommand::new(0, 5, '!', cursor);
601        cmd1.execute(&mut buffer, &mut cursor);
602        history.push(Box::new(cmd1));
603
604        history.undo(&mut buffer, &mut cursor);
605        assert!(history.can_redo());
606
607        // Push new command should clear redo stack
608        let mut cmd2 = InsertCharCommand::new(0, 5, '?', cursor);
609        cmd2.execute(&mut buffer, &mut cursor);
610        history.push(Box::new(cmd2));
611
612        assert!(!history.can_redo());
613    }
614}