Skip to main content

ochrepad_core/
macros.rs

1//! Macro playback. Recording happens in [`Editor::apply`]: while recording,
2//! every successful recordable action is appended.
3
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::action::{Action, ActionError};
9use crate::editor::Editor;
10use crate::view::ViewId;
11
12/// A saved macro.
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub struct NamedMacro {
15    pub name: String,
16    /// An optional key binding, e.g. "Ctrl+Shift+1" (the app parses it).
17    #[serde(default)]
18    pub shortcut: Option<String>,
19    pub actions: Vec<Action>,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum Repeat {
24    Times(usize),
25    /// Until an action fails (typically a Find with no more matches), a run
26    /// starts and ends with the primary caret at the end of the document
27    /// (so a "process this line, move down" macro handles the last line
28    /// once), or a run changes nothing.
29    UntilEnd,
30}
31
32/// Safety limit for [`Repeat::UntilEnd`].
33pub const MAX_UNTIL_END_RUNS: usize = 1_000_000;
34
35/// Where playback stopped.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct MacroError {
38    /// 1-based run number.
39    pub run: usize,
40    /// 1-based step within the run.
41    pub step: usize,
42    pub action: Action,
43    pub error: ActionError,
44}
45
46impl fmt::Display for MacroError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(
49            f,
50            "the macro stopped at step {} of run {}: {}",
51            self.step, self.run, self.error
52        )
53    }
54}
55
56impl std::error::Error for MacroError {}
57
58impl Action {
59    /// Whether the macro recorder keeps this action. Mouse-made selections
60    /// (absolute positions) and undo/redo are left out, as in Notepad++.
61    pub fn is_recordable(&self) -> bool {
62        !matches!(
63            self,
64            Action::SetSelections(_) | Action::ColumnSelect { .. } | Action::Undo | Action::Redo
65        )
66    }
67}
68
69/// (revision, selections, caret at end of document).
70type MacroState = (u64, crate::selection::SelectionSet, bool);
71
72impl Editor {
73    /// Plays `actions` on `view`. The whole playback is one undo step and
74    /// is not itself recorded. Returns the number of complete runs.
75    ///
76    /// With [`Repeat::Times`] the first failing action aborts playback and is
77    /// reported. With [`Repeat::UntilEnd`] a failure is the normal way to
78    /// stop.
79    pub fn play_macro(
80        &mut self,
81        view: ViewId,
82        actions: &[Action],
83        repeat: Repeat,
84    ) -> Result<usize, MacroError> {
85        let fail = |error| MacroError {
86            run: 1,
87            step: 1,
88            action: actions.first().cloned().unwrap_or(Action::Undo),
89            error,
90        };
91        self.begin_undo_group(view).map_err(fail)?;
92        let recording = self.take_recording();
93        let result = self.run_macro(view, actions, repeat);
94        self.restore_recording(recording);
95        self.end_undo_group(view).map_err(fail)?;
96        result
97    }
98
99    fn run_macro(
100        &mut self,
101        view: ViewId,
102        actions: &[Action],
103        repeat: Repeat,
104    ) -> Result<usize, MacroError> {
105        if actions.is_empty() {
106            return Ok(0);
107        }
108        let limit = match repeat {
109            Repeat::Times(n) => n,
110            Repeat::UntilEnd => MAX_UNTIL_END_RUNS,
111        };
112        let until_end = repeat == Repeat::UntilEnd;
113        let mut runs = 0;
114        while runs < limit {
115            let before = self.macro_state(view);
116            for (i, action) in actions.iter().enumerate() {
117                if let Err(error) = self.apply(view, action.clone()) {
118                    if until_end {
119                        return Ok(runs);
120                    }
121                    return Err(MacroError {
122                        run: runs + 1,
123                        step: i + 1,
124                        action: action.clone(),
125                        error,
126                    });
127                }
128            }
129            runs += 1;
130            if until_end {
131                let after = self.macro_state(view);
132                let at_end = |s: &Option<MacroState>| s.as_ref().is_some_and(|s| s.2);
133                if after == before || (at_end(&before) && at_end(&after)) {
134                    break;
135                }
136            }
137        }
138        Ok(runs)
139    }
140
141    /// (revision, selections, caret at end) — to detect runs that change
142    /// nothing and the end of the document.
143    fn macro_state(&self, view: ViewId) -> Option<MacroState> {
144        let v = self.view(view)?;
145        let b = self.buffer(v.buffer())?;
146        let at_end = v.selections.primary().head >= b.doc().len();
147        Some((b.revision(), v.selections.clone(), at_end))
148    }
149}