ochrepad-core 0.3.0

Text model and editing engine of the ochrepad text editor (internal; no stable API)
Documentation
//! Macro playback. Recording happens in [`Editor::apply`]: while recording,
//! every successful recordable action is appended.

use std::fmt;

use serde::{Deserialize, Serialize};

use crate::action::{Action, ActionError};
use crate::editor::Editor;
use crate::view::ViewId;

/// A saved macro.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NamedMacro {
    pub name: String,
    /// An optional key binding, e.g. "Ctrl+Shift+1" (the app parses it).
    #[serde(default)]
    pub shortcut: Option<String>,
    pub actions: Vec<Action>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Repeat {
    Times(usize),
    /// Until an action fails (typically a Find with no more matches), a run
    /// starts and ends with the primary caret at the end of the document
    /// (so a "process this line, move down" macro handles the last line
    /// once), or a run changes nothing.
    UntilEnd,
}

/// Safety limit for [`Repeat::UntilEnd`].
pub const MAX_UNTIL_END_RUNS: usize = 1_000_000;

/// Where playback stopped.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MacroError {
    /// 1-based run number.
    pub run: usize,
    /// 1-based step within the run.
    pub step: usize,
    pub action: Action,
    pub error: ActionError,
}

impl fmt::Display for MacroError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "the macro stopped at step {} of run {}: {}",
            self.step, self.run, self.error
        )
    }
}

impl std::error::Error for MacroError {}

impl Action {
    /// Whether the macro recorder keeps this action. Mouse-made selections
    /// (absolute positions) and undo/redo are left out, as in Notepad++.
    pub fn is_recordable(&self) -> bool {
        !matches!(
            self,
            Action::SetSelections(_)
                | Action::ColumnSelect { .. }
                | Action::Undo
                | Action::Redo
                | Action::Fold
                | Action::Unfold
                | Action::ToggleFold
                | Action::FoldAll
                | Action::UnfoldAll
                | Action::FoldLevel(_)
                | Action::UnfoldLevel(_)
        )
    }
}

/// (revision, selections, caret at end of document).
type MacroState = (u64, crate::selection::SelectionSet, bool);

/// `action` with its search, if it has one, not wrapping round.
fn without_wrap(action: &Action) -> Action {
    let mut action = action.clone();
    match &mut action {
        Action::Find { query, .. } | Action::Replace { query, .. } => query.options.wrap = false,
        _ => {}
    }
    action
}

impl Editor {
    /// Plays `actions` on `view`. The whole playback is one undo step and
    /// is not itself recorded. Returns the number of complete runs.
    ///
    /// With [`Repeat::Times`] the first failing action aborts playback and is
    /// reported. With [`Repeat::UntilEnd`] a failure is the normal way to
    /// stop.
    pub fn play_macro(
        &mut self,
        view: ViewId,
        actions: &[Action],
        repeat: Repeat,
    ) -> Result<usize, MacroError> {
        let fail = |error| MacroError {
            run: 1,
            step: 1,
            action: actions.first().cloned().unwrap_or(Action::Undo),
            error,
        };
        self.begin_undo_group(view).map_err(fail)?;
        let recording = self.take_recording();
        let result = self.run_macro(view, actions, repeat);
        self.restore_recording(recording);
        self.end_undo_group(view).map_err(fail)?;
        result
    }

    fn run_macro(
        &mut self,
        view: ViewId,
        actions: &[Action],
        repeat: Repeat,
    ) -> Result<usize, MacroError> {
        if actions.is_empty() {
            return Ok(0);
        }
        let limit = match repeat {
            Repeat::Times(n) => n,
            Repeat::UntilEnd => MAX_UNTIL_END_RUNS,
        };
        let until_end = repeat == Repeat::UntilEnd;
        // Run to the end of the file, a search must not wrap round to the
        // start: it fails at the last match, which ends the runs (a macro
        // that finds and bookmarks would otherwise go round for ever).
        let actions: Vec<Action> = if until_end {
            actions.iter().map(without_wrap).collect()
        } else {
            actions.to_vec()
        };
        let mut runs = 0;
        while runs < limit {
            let before = self.macro_state(view);
            for (i, action) in actions.iter().enumerate() {
                // Going to a bookmark round past the end (or the start)
                // ends a run to the end, as a search that would wrap does.
                let line_before = until_end.then(|| self.caret_line(view)).flatten();
                if let Err(error) = self.apply(view, action.clone()) {
                    if until_end {
                        return Ok(runs);
                    }
                    return Err(MacroError {
                        run: runs + 1,
                        step: i + 1,
                        action: action.clone(),
                        error,
                    });
                }
                let wrapped = match (action, line_before, self.caret_line(view)) {
                    (Action::NextBookmark, Some(before), Some(after)) => after <= before,
                    (Action::PrevBookmark, Some(before), Some(after)) => after >= before,
                    _ => false,
                };
                if wrapped {
                    return Ok(runs);
                }
            }
            runs += 1;
            if until_end {
                let after = self.macro_state(view);
                let at_end = |s: &Option<MacroState>| s.as_ref().is_some_and(|s| s.2);
                if after == before || (at_end(&before) && at_end(&after)) {
                    break;
                }
            }
        }
        Ok(runs)
    }

    /// The line of `view`'s primary caret.
    fn caret_line(&self, view: ViewId) -> Option<usize> {
        let v = self.view(view)?;
        let b = self.buffer(v.buffer())?;
        Some(b.doc().line_of(v.selections.primary().head))
    }

    /// (revision, selections, caret at end) — to detect runs that change
    /// nothing and the end of the document.
    fn macro_state(&self, view: ViewId) -> Option<MacroState> {
        let v = self.view(view)?;
        let b = self.buffer(v.buffer())?;
        let at_end = v.selections.primary().head >= b.doc().len();
        Some((b.revision(), v.selections.clone(), at_end))
    }
}