shoin 0.2.2

A no-clutter terminal Markdown editor with modal editing
//! `Action` is the single vocabulary of the editor. SPEC.md §7.2.
//!
//! Config strings map to these names. Everything that mutates the buffer is an
//! `Action`, dispatched from one place — which is what makes undo grouping,
//! dirty tracking, and (post-v1) macro recording tractable.

use crate::text::motion::Motion;

/// What an operator applies to. An object and a motion are two ways of naming
/// a range, so they reach the same `operate_*` helpers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Target {
    Motion(Motion),
    /// `iw`, `a(` — the object's key and whether it takes its delimiters.
    Object { key: char, around: bool },
    /// The doubled form: `dd`, `yy`, `>>`.
    Line,
    /// Visual mode — whatever is selected.
    Selection,
}

/// Where a panel starts looking. Deliberately NOT a project root: Shoin edits
/// prose, and a directory of notes is not a checkout — `.git` has no more to
/// say about where a text begins than any other dotfile.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Root {
    /// The edited file's own directory — the near view.
    File,
    /// `$HOME` — everything you might have written.
    Home,
    /// The folder Shoin was launched from — the whole vault, when the note
    /// being edited sits in one of its subfolders.
    Cwd,
}

impl Root {
    /// `[tree] root`: what `file_tree` / `find_file` open on. Only the near
    /// views are offered — `$HOME` already has its own keys. `None` for a
    /// spelling this does not know, so the config check can name it.
    pub fn parse(s: &str) -> Option<Root> {
        Some(match s.trim().to_ascii_lowercase().as_str() {
            "file" => Root::File,
            "cwd" => Root::Cwd,
            _ => return None,
        })
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
    // --- movement ---
    Move(Motion),
    /// `H`/`M`/`L` and friends resolve against the viewport, which only the app
    /// can see; the grammar emits the motion and `app` finishes it.
    SelectObject { key: char, around: bool },
    SwapSelectionEnds,

    // --- mode changes ---
    Insert,
    Append,
    InsertLineStart,
    AppendLineEnd,
    OpenBelow,
    OpenAbove,
    Visual,
    VisualLine,
    NormalMode,
    Command,
    SearchForward,
    SearchBackward,

    // --- operators (applied to a motion, an object, a line, or a selection) ---
    Operator { op: Operator, target: Target },

    // --- direct edits ---
    DeleteChar,
    DeleteCharBack,
    /// `D` / `C` — to the end of the line.
    DeleteToEol,
    ChangeToEol,
    /// `Y` — the whole line, without waiting for a motion.
    YankLine,
    /// `n` / `N`.
    SearchNext { reverse: bool },
    /// `;` / `,`.
    RepeatFind { reverse: bool },
    /// `*`.
    SearchWordUnderCursor,
    /// `Ctrl-e` / `Ctrl-y` — scroll the page a row, leaving the cursor where
    /// it is unless it would go off screen.
    ScrollView { down: bool },
    /// `gp` — a new paragraph below, in Insert.
    AppendParagraph,
    /// `<C-w>` window commands, by their second key.
    Window(char),
    /// `<leader>sv`/`<leader>ss` — split the focused pane, or close it if the
    /// window is already split. Toggle symmetry: every panel is bound to a key
    /// that both opens and closes it, which is how this editor reconciles
    /// having chrome at all with the no-interface ethos.
    ToggleSplit { vertical: bool },
    ClosePane,
    OnlyPane,
    PasteAfter,
    PasteBefore,
    ReplaceChar(char),
    JoinLines,
    ToggleCase,
    /// `Alt-j` / `Alt-k` — move the list item under the cursor, children and
    /// all, past its next / previous sibling (past `count` of them).
    ListItemMove { down: bool },
    /// `move_lines_down` / `move_lines_up` — the lines of the Visual selection
    /// (the cursor's line outside Visual) past the next / previous `count`
    /// lines, still selected. Plain lines: no list knowledge, no renumbering.
    MoveLines { down: bool },
    /// Insert-mode `Tab` / `Shift-Tab` on a list item: `>al` / `<al` without
    /// leaving Insert.
    ListShift { outdent: bool },
    Undo,
    Redo,
    Repeat,

    // --- writer verbs (SPEC.md §7.3) ---
    ToggleBold,
    ToggleItalic,
    ToggleHighlight,
    ToggleCode,
    InsertLink,
    ToggleTask,
    AlignTable,
    SetHeading(u8),
    ClearHeading,

    // --- app ---
    Save,
    SaveStayInsert,
    Quit { force: bool },
    WriteQuit,
    CycleFocus,
    ToggleTypewriter,
    ToggleZen,
    /// Read-only mode on or off, as `:view` does. Not an edit, so it works
    /// from inside read-only mode — that is the point of it.
    ToggleView,
    /// The line-number gutter, off ↔ whichever mode (`relative`/`absolute`)
    /// was last chosen. `:number [mode]` reaches the modes directly.
    ToggleNumbers,
    /// Flip `layout.conceal` — live preview vs. every line raw.
    ToggleConceal,
    /// `:align` with no argument, reachable from a key: left → center → right
    /// → justified → left. No single value is "off", so cycling is the only
    /// way a leader binding reaches every value.
    CycleAlign,
    /// Toggle the file-tree pane, rooted at the edited file's own folder or at
    /// `$HOME`. Inside the tree, `-` and `+` move the root from there.
    FileTree { root: Root },
    /// Toggle the fuzzy file finder overlay, rooted at the edited file's own
    /// directory or at `$HOME`.
    FindFile { root: Root },
    /// The same overlay over the open buffers.
    FindBuffer,
    /// `<C-^>` — back to the document this pane was showing before. Vim's
    /// alternate file, and the way back out of a followed link.
    AlternateBuffer,

    /// Open the shortcuts/keybindings reference overlay (`:help shortcuts`,
    /// one key away). `:help [topic]` reaches the same overlay for any topic;
    /// q / Esc closes it, same as it closes `:help`.
    Help,

    /// `gf` — open what the cursor is on: a `[[note]]`, an unexpanded
    /// `![[embed]]`, or the path in a `[text](path)`. A note that does not
    /// exist yet is CREATED — in a vault, the link is written before the note.
    FollowLink,
    /// `gx` — hand what the cursor is on to the operating system. Kept apart
    /// from `FollowLink` on purpose: one key that sometimes opens a buffer and
    /// sometimes launches a browser is a key you hesitate before pressing.
    OpenExternal,

    /// Recognized key, deliberately does nothing.
    Nop,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Operator {
    Delete,
    Change,
    Yank,
    Indent,
    Outdent,
    Lowercase,
    Uppercase,
}

impl Action {
    /// Whether this changes the text, or starts Insert to change it. What
    /// read-only mode refuses; everything else (moving, searching, yanking,
    /// panels, saving) still works.
    pub fn edits(&self) -> bool {
        match self {
            Action::Operator { op, .. } => *op != Operator::Yank,
            Action::Insert
            | Action::Append
            | Action::InsertLineStart
            | Action::AppendLineEnd
            | Action::OpenBelow
            | Action::OpenAbove
            | Action::AppendParagraph
            | Action::DeleteChar
            | Action::DeleteCharBack
            | Action::DeleteToEol
            | Action::ChangeToEol
            | Action::PasteAfter
            | Action::PasteBefore
            | Action::ReplaceChar(_)
            | Action::JoinLines
            | Action::ToggleCase
            | Action::ListItemMove { .. }
            | Action::MoveLines { .. }
            | Action::ListShift { .. }
            | Action::Undo
            | Action::Redo
            | Action::Repeat
            | Action::ToggleBold
            | Action::ToggleItalic
            | Action::ToggleHighlight
            | Action::ToggleCode
            | Action::InsertLink
            | Action::ToggleTask
            | Action::AlignTable
            | Action::SetHeading(_)
            | Action::ClearHeading => true,
            _ => false,
        }
    }

    /// Parse the action name used in `shoin.conf` `[keys.*]` tables.
    pub fn from_config_name(name: &str) -> Option<Action> {
        Some(match name {
            "" | "nop" | "none" => Action::Nop,
            "save" | "write" => Action::Save,
            "save_stay_insert" => Action::SaveStayInsert,
            "save_quit" | "write_quit" | "wq" => Action::WriteQuit,
            "quit" => Action::Quit { force: false },
            "quit!" | "force_quit" => Action::Quit { force: true },
            "insert" => Action::Insert,
            "append" => Action::Append,
            "insert_line_start" => Action::InsertLineStart,
            "append_line_end" => Action::AppendLineEnd,
            "open_below" => Action::OpenBelow,
            "open_above" => Action::OpenAbove,
            "normal_mode" | "escape" => Action::NormalMode,
            "visual" => Action::Visual,
            "visual_line" => Action::VisualLine,
            "command" => Action::Command,
            "search_forward" | "search" => Action::SearchForward,
            "search_backward" => Action::SearchBackward,
            "undo" => Action::Undo,
            "redo" => Action::Redo,
            "repeat" => Action::Repeat,
            "paste_after" | "paste" => Action::PasteAfter,
            "paste_before" => Action::PasteBefore,
            "delete_char" => Action::DeleteChar,
            "join_lines" => Action::JoinLines,
            "scroll_view_down" | "scroll_down" => Action::ScrollView { down: true },
            "scroll_view_up" | "scroll_up" => Action::ScrollView { down: false },
            "list_item_down" => Action::ListItemMove { down: true },
            "list_item_up" => Action::ListItemMove { down: false },
            "move_lines_down" | "move_line_down" => Action::MoveLines { down: true },
            "move_lines_up" | "move_line_up" => Action::MoveLines { down: false },
            "list_indent" => Action::ListShift { outdent: false },
            "list_outdent" => Action::ListShift { outdent: true },
            "toggle_bold" | "bold" => Action::ToggleBold,
            "toggle_italic" | "italic" => Action::ToggleItalic,
            "toggle_highlight" | "highlight" => Action::ToggleHighlight,
            "toggle_code" | "code" => Action::ToggleCode,
            "insert_link" | "link" => Action::InsertLink,
            "toggle_task" | "task" => Action::ToggleTask,
            "align_table" | "table" => Action::AlignTable,
            "clear_heading" => Action::ClearHeading,
            "cycle_focus" | "focus" => Action::CycleFocus,
            "toggle_typewriter" | "typewriter" => Action::ToggleTypewriter,
            "toggle_conceal" | "conceal" => Action::ToggleConceal,
            "toggle_zen" | "zen" => Action::ToggleZen,
            "toggle_view" | "view" | "read_only" => Action::ToggleView,
            "toggle_numbers" | "numbers" | "number" => Action::ToggleNumbers,
            "cycle_align" | "align" => Action::CycleAlign,
            "help" | "shortcuts" => Action::Help,
            "file_tree" | "file_explorer" | "tree" => Action::FileTree { root: Root::File },
            "file_tree_home" | "file_explorer_home" => Action::FileTree { root: Root::Home },
            "find_file" | "fuzzy_find" | "files" => Action::FindFile { root: Root::File },
            "find_file_home" | "fuzzy_find_home" => Action::FindFile { root: Root::Home },
            "file_tree_cwd" | "file_explorer_cwd" => Action::FileTree { root: Root::Cwd },
            "find_file_cwd" | "fuzzy_find_cwd" => Action::FindFile { root: Root::Cwd },
            "alternate_buffer" | "alternate" | "alt_buffer" => Action::AlternateBuffer,
            "follow_link" | "follow" | "goto_file" => Action::FollowLink,
            "open_external" | "open_url" | "xdg_open" => Action::OpenExternal,
            "find_buffer" | "buffers" | "switch_buffer" => Action::FindBuffer,
            "split_vertical" | "vsplit" => Action::ToggleSplit { vertical: true },
            "split_horizontal" | "split" => Action::ToggleSplit { vertical: false },
            "close_pane" => Action::ClosePane,
            "only_pane" | "only" => Action::OnlyPane,
            _ => {
                if let Some(n) = name.strip_prefix("heading_") {
                    return n.parse::<u8>().ok().filter(|n| (1..=6).contains(n)).map(Action::SetHeading);
                }
                return None;
            }
        })
    }
}