diff options
Diffstat (limited to 'src/dotty/tools/dotc/repl/ammonite/filters/UndoFilter.scala')
-rw-r--r-- | src/dotty/tools/dotc/repl/ammonite/filters/UndoFilter.scala | 157 |
1 files changed, 157 insertions, 0 deletions
diff --git a/src/dotty/tools/dotc/repl/ammonite/filters/UndoFilter.scala b/src/dotty/tools/dotc/repl/ammonite/filters/UndoFilter.scala new file mode 100644 index 000000000..c265a7a4c --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/filters/UndoFilter.scala @@ -0,0 +1,157 @@ +package dotty.tools +package dotc +package repl +package ammonite +package terminal +package filters + +import terminal.FilterTools._ +import terminal.LazyList.~: +import terminal._ +import scala.collection.mutable + +/** + * A filter that implements "undo" functionality in the ammonite REPL. It + * shares the same `Ctrl -` hotkey that the bash undo, but shares behavior + * with the undo behavior in desktop text editors: + * + * - Multiple `delete`s in a row get collapsed + * - In addition to edits you can undo cursor movements: undo will bring your + * cursor back to location of previous edits before it undoes them + * - Provides "redo" functionality under `Alt -`/`Esc -`: un-undo the things + * you didn't actually want to undo! + * + * @param maxUndo: the maximum number of undo-frames that are stored. + */ +case class UndoFilter(maxUndo: Int = 25) extends DelegateFilter { + def identifier = "UndoFilter" + /** + * The current stack of states that undo/redo would cycle through. + * + * Not really the appropriate data structure, since when it reaches + * `maxUndo` in length we remove one element from the start whenever we + * append one element to the end, which costs `O(n)`. On the other hand, + * It also costs `O(n)` to maintain the buffer of previous states, and + * so `n` is probably going to be pretty small anyway (tens?) so `O(n)` + * is perfectly fine. + */ + val undoBuffer = mutable.Buffer[(Vector[Char], Int)](Vector[Char]() -> 0) + + /** + * The current position in the undoStack that the terminal is currently in. + */ + var undoIndex = 0 + /** + * An enum representing what the user is "currently" doing. Used to + * collapse sequential actions into one undo step: e.g. 10 plai + * chars typed becomes 1 undo step, or 10 chars deleted becomes one undo + * step, but 4 chars typed followed by 3 chars deleted followed by 3 chars + * typed gets grouped into 3 different undo steps + */ + var state = UndoState.Default + def currentUndo = undoBuffer(undoBuffer.length - undoIndex - 1) + + def undo(b: Vector[Char], c: Int) = { + val msg = + if (undoIndex >= undoBuffer.length - 1) UndoFilter.cannotUndoMsg + else { + undoIndex += 1 + state = UndoState.Default + UndoFilter.undoMsg + } + val (b1, c1) = currentUndo + (b1, c1, msg) + } + + def redo(b: Vector[Char], c: Int) = { + val msg = + if (undoIndex <= 0) UndoFilter.cannotRedoMsg + else { + undoIndex -= 1 + state = UndoState.Default + UndoFilter.redoMsg + } + + currentUndo + val (b1, c1) = currentUndo + (b1, c1, msg) + } + + def wrap(bc: (Vector[Char], Int, Ansi.Str), rest: LazyList[Int]) = { + val (b, c, msg) = bc + TS(rest, b, c, msg) + } + + def pushUndos(b: Vector[Char], c: Int) = { + val (lastB, lastC) = currentUndo + // Since we don't have access to the `typingFilter` in this code, we + // instead attempt to reverse-engineer "what happened" to the buffer by + // comparing the old one with the new. + // + // It turns out that it's not that hard to identify the few cases we care + // about, since they're all result in either 0 or 1 chars being different + // between old and new buffers. + val newState = + // Nothing changed means nothing changed + if (lastC == c && lastB == b) state + // if cursor advanced 1, and buffer grew by 1 at the cursor, we're typing + else if (lastC + 1 == c && lastB == b.patch(c-1, Nil, 1)) UndoState.Typing + // cursor moved left 1, and buffer lost 1 char at that point, we're deleting + else if (lastC - 1 == c && lastB.patch(c, Nil, 1) == b) UndoState.Deleting + // cursor didn't move, and buffer lost 1 char at that point, we're also deleting + else if (lastC == c && lastB.patch(c - 1, Nil, 1) == b) UndoState.Deleting + // cursor moved around but buffer didn't change, we're navigating + else if (lastC != c && lastB == b) UndoState.Navigating + // otherwise, sit in the "Default" state where every change is recorded. + else UndoState.Default + + if (state != newState || newState == UndoState.Default && (lastB, lastC) != (b, c)) { + // If something changes: either we enter a new `UndoState`, or we're in + // the `Default` undo state and the terminal buffer/cursor change, then + // truncate the `undoStack` and add a new tuple to the stack that we can + // build upon. This means that we lose all ability to re-do actions after + // someone starts making edits, which is consistent with most other + // editors + state = newState + undoBuffer.remove(undoBuffer.length - undoIndex, undoIndex) + undoIndex = 0 + + if (undoBuffer.length == maxUndo) undoBuffer.remove(0) + + undoBuffer.append(b -> c) + } else if (undoIndex == 0 && (b, c) != undoBuffer(undoBuffer.length - 1)) { + undoBuffer(undoBuffer.length - 1) = (b, c) + } + + state = newState + } + + def filter = Filter.merge( + Filter.wrap("undoFilterWrapped") { + case TS(q ~: rest, b, c, _) => + pushUndos(b, c) + None + }, + Filter("undoFilter") { + case TS(31 ~: rest, b, c, _) => wrap(undo(b, c), rest) + case TS(27 ~: 114 ~: rest, b, c, _) => wrap(undo(b, c), rest) + case TS(27 ~: 45 ~: rest, b, c, _) => wrap(redo(b, c), rest) + } + ) +} + + +sealed class UndoState(override val toString: String) +object UndoState { + val Default = new UndoState("Default") + val Typing = new UndoState("Typing") + val Deleting = new UndoState("Deleting") + val Navigating = new UndoState("Navigating") +} + +object UndoFilter { + val undoMsg = Ansi.Color.Blue(" ...undoing last action, `Alt -` or `Esc -` to redo") + val cannotUndoMsg = Ansi.Color.Blue(" ...no more actions to undo") + val redoMsg = Ansi.Color.Blue(" ...redoing last action") + val cannotRedoMsg = Ansi.Color.Blue(" ...no more actions to redo") +} |