rdom-tui 0.5.0

Terminal rendering layer for rdom-core — flexbox layout, TUI styles, key/mouse events. Use rdom-core directly for headless DOM manipulation.
Documentation
//! Undo / redo — reverses + re-applies the last `EditEntry` from
//! the focused editable's `EditorState`.
//!
//! Called from `App::handle_event` as a default action on
//! `Ctrl-Z` / `Cmd-Z` (undo) and `Ctrl-Y` / `Cmd-Shift-Z` (redo).
//!
//! Semantics:
//!
//! - Undo replaces the post-edit bytes with the pre-edit `old`
//!   string, restores `caret_before`, and moves the entry to the
//!   redo stack.
//! - Redo replaces the pre-edit bytes with `new`, restores
//!   `caret_after`, and moves the entry back onto the undo stack.
//! - Both fire `input` (not `beforeinput`) on the editable. Browsers
//!   treat history operations as atomic — the mutation is already
//!   decided, handlers just get notified.
//! - No `beforeinput` means no cancellation. An app that wants
//!   veto power over undo/redo should listen for `keydown` and
//!   call `prevent_default` on the triggering `Ctrl-Z`.

use rdom_core::Selection;

use crate::node::nearest_editable_ancestor;
use crate::runtime::editing::editor_state::{EditEntry, HistoryItem};
use crate::{TuiDom, TuiEvent};

/// Result of an undo/redo attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UndoOutcome {
    /// The history moved one step (a mutation was applied and
    /// `input` fired).
    Applied,
    /// No focused editable or the stack was empty — caller
    /// treats as a no-op.
    Noop,
}

/// Pop the top undo entry from the focused editable's state,
/// apply its reverse to the DOM, push onto the redo stack, and
/// fire `input`. Returns `Applied` when something happened.
pub fn undo(dom: &mut TuiDom) -> UndoOutcome {
    let Some(editable) = focused_editable(dom) else {
        return UndoOutcome::Noop;
    };
    let Some(item) = pop_entry(dom, editable, StackSide::Undo) else {
        return UndoOutcome::Noop;
    };
    // Reverse the parts last-applied-first, then restore the caret
    // from before the whole step.
    for part in item.iter().rev() {
        apply_reverse(dom, part);
    }
    if let Some(first) = item.first() {
        dom.set_selection(Some(Selection::caret(first.caret_before)));
    }
    push_entry(dom, editable, item, StackSide::Redo);
    // Fire `input` with HistoryUndo inputType — DOM convention is
    // `data: null` for history events; listeners read the new
    // value off the target's text content / `value` attribute.
    let mut ev = TuiEvent::input(rdom_core::InputType::HistoryUndo, None);
    crate::tui_event::dispatch_to_live(dom, editable, &mut ev);
    UndoOutcome::Applied
}

/// Pop the top redo entry from the focused editable's state,
/// re-apply its forward edit, push back onto the undo stack, and
/// fire `input`. Returns `Applied` when something happened.
pub fn redo(dom: &mut TuiDom) -> UndoOutcome {
    let Some(editable) = focused_editable(dom) else {
        return UndoOutcome::Noop;
    };
    let Some(item) = pop_entry(dom, editable, StackSide::Redo) else {
        return UndoOutcome::Noop;
    };
    for part in &item {
        apply_forward(dom, part);
    }
    if let Some(last) = item.last() {
        dom.set_selection(Some(Selection::caret(last.caret_after)));
    }
    push_entry(dom, editable, item, StackSide::Undo);
    let mut ev = TuiEvent::input(rdom_core::InputType::HistoryRedo, None);
    crate::tui_event::dispatch_to_live(dom, editable, &mut ev);
    UndoOutcome::Applied
}

// ── Internals ──────────────────────────────────────────────────────

fn focused_editable(dom: &TuiDom) -> Option<rdom_core::NodeId> {
    let focused = dom.focused()?;
    nearest_editable_ancestor(dom, focused)
}

/// Re-apply `entry`'s forward edit to the DOM and restore
/// `caret_after`. Used by redo and by paste-integration in B.5.
fn apply_forward(dom: &mut TuiDom, entry: &EditEntry) {
    // Pre-apply state: the text node has `old` at
    // [range.start..range.start + old.len()). Replace with `new`.
    // The caret is restored by the caller once the whole step is done.
    let end = entry.range.start + entry.old.len();
    let _ = dom
        .node_mut(entry.node)
        .edit_text(entry.range.start, end, &entry.new);
}

/// Reverse `entry` — replace `new` with `old`, restore
/// `caret_before`. Used by undo.
fn apply_reverse(dom: &mut TuiDom, entry: &EditEntry) {
    let end = entry.range.start + entry.new.len();
    let _ = dom
        .node_mut(entry.node)
        .edit_text(entry.range.start, end, &entry.old);
}

#[derive(Debug, Clone, Copy)]
enum StackSide {
    Undo,
    Redo,
}

fn pop_entry(
    dom: &mut TuiDom,
    editable: rdom_core::NodeId,
    side: StackSide,
) -> Option<HistoryItem> {
    let mut node = dom.node_mut(editable);
    let state = node.ext_mut()?.editor_state.as_mut()?;
    match side {
        StackSide::Undo => state.pop_undo(),
        StackSide::Redo => state.pop_redo(),
    }
}

fn push_entry(dom: &mut TuiDom, editable: rdom_core::NodeId, entry: HistoryItem, side: StackSide) {
    if let Some(ext) = dom.node_mut(editable).ext_mut()
        && let Some(state) = ext.editor_state.as_mut()
    {
        // An undo / redo is a user edit of the value (HTML dirty value
        // flag); `push_entry` runs once per transition.
        ext.form_state.get_mut().value_user_edited = true;
        match side {
            StackSide::Undo => state.push_undo(entry),
            StackSide::Redo => state.push_redo(entry),
        }
    }
}

#[cfg(test)]
mod tests;

#[cfg(test)]
mod coalescing_tests;