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(_)
65                | Action::ColumnSelect { .. }
66                | Action::Undo
67                | Action::Redo
68                | Action::Fold
69                | Action::Unfold
70                | Action::ToggleFold
71                | Action::FoldAll
72                | Action::UnfoldAll
73                | Action::FoldLevel(_)
74                | Action::UnfoldLevel(_)
75        )
76    }
77}
78
79/// (revision, selections, caret at end of document).
80type MacroState = (u64, crate::selection::SelectionSet, bool);
81
82/// `action` with its search, if it has one, not wrapping round.
83fn without_wrap(action: &Action) -> Action {
84    let mut action = action.clone();
85    match &mut action {
86        Action::Find { query, .. } | Action::Replace { query, .. } => query.options.wrap = false,
87        _ => {}
88    }
89    action
90}
91
92impl Editor {
93    /// Plays `actions` on `view`. The whole playback is one undo step and
94    /// is not itself recorded. Returns the number of complete runs.
95    ///
96    /// With [`Repeat::Times`] the first failing action aborts playback and is
97    /// reported. With [`Repeat::UntilEnd`] a failure is the normal way to
98    /// stop.
99    pub fn play_macro(
100        &mut self,
101        view: ViewId,
102        actions: &[Action],
103        repeat: Repeat,
104    ) -> Result<usize, MacroError> {
105        let fail = |error| MacroError {
106            run: 1,
107            step: 1,
108            action: actions.first().cloned().unwrap_or(Action::Undo),
109            error,
110        };
111        self.begin_undo_group(view).map_err(fail)?;
112        let recording = self.take_recording();
113        let result = self.run_macro(view, actions, repeat);
114        self.restore_recording(recording);
115        self.end_undo_group(view).map_err(fail)?;
116        result
117    }
118
119    fn run_macro(
120        &mut self,
121        view: ViewId,
122        actions: &[Action],
123        repeat: Repeat,
124    ) -> Result<usize, MacroError> {
125        if actions.is_empty() {
126            return Ok(0);
127        }
128        let limit = match repeat {
129            Repeat::Times(n) => n,
130            Repeat::UntilEnd => MAX_UNTIL_END_RUNS,
131        };
132        let until_end = repeat == Repeat::UntilEnd;
133        // Run to the end of the file, a search must not wrap round to the
134        // start: it fails at the last match, which ends the runs (a macro
135        // that finds and bookmarks would otherwise go round for ever).
136        let actions: Vec<Action> = if until_end {
137            actions.iter().map(without_wrap).collect()
138        } else {
139            actions.to_vec()
140        };
141        let mut runs = 0;
142        while runs < limit {
143            let before = self.macro_state(view);
144            for (i, action) in actions.iter().enumerate() {
145                // Going to a bookmark round past the end (or the start)
146                // ends a run to the end, as a search that would wrap does.
147                let line_before = until_end.then(|| self.caret_line(view)).flatten();
148                if let Err(error) = self.apply(view, action.clone()) {
149                    if until_end {
150                        return Ok(runs);
151                    }
152                    return Err(MacroError {
153                        run: runs + 1,
154                        step: i + 1,
155                        action: action.clone(),
156                        error,
157                    });
158                }
159                let wrapped = match (action, line_before, self.caret_line(view)) {
160                    (Action::NextBookmark, Some(before), Some(after)) => after <= before,
161                    (Action::PrevBookmark, Some(before), Some(after)) => after >= before,
162                    _ => false,
163                };
164                if wrapped {
165                    return Ok(runs);
166                }
167            }
168            runs += 1;
169            if until_end {
170                let after = self.macro_state(view);
171                let at_end = |s: &Option<MacroState>| s.as_ref().is_some_and(|s| s.2);
172                if after == before || (at_end(&before) && at_end(&after)) {
173                    break;
174                }
175            }
176        }
177        Ok(runs)
178    }
179
180    /// The line of `view`'s primary caret.
181    fn caret_line(&self, view: ViewId) -> Option<usize> {
182        let v = self.view(view)?;
183        let b = self.buffer(v.buffer())?;
184        Some(b.doc().line_of(v.selections.primary().head))
185    }
186
187    /// (revision, selections, caret at end) — to detect runs that change
188    /// nothing and the end of the document.
189    fn macro_state(&self, view: ViewId) -> Option<MacroState> {
190        let v = self.view(view)?;
191        let b = self.buffer(v.buffer())?;
192        let at_end = v.selections.primary().head >= b.doc().len();
193        Some((b.revision(), v.selections.clone(), at_end))
194    }
195}