diff options
Diffstat (limited to 'src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala')
-rw-r--r-- | src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala | 334 |
1 files changed, 334 insertions, 0 deletions
diff --git a/src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala b/src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala new file mode 100644 index 000000000..dac1c9d23 --- /dev/null +++ b/src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala @@ -0,0 +1,334 @@ +package dotty.tools +package dotc +package repl +package ammonite +package terminal +package filters + +import terminal.FilterTools._ +import terminal.LazyList._ +import terminal._ + +/** + * Provides history navigation up and down, saving the current line, a well + * as history-search functionality (`Ctrl R` in bash) letting you quickly find + * & filter previous commands by entering a sub-string. + */ +class HistoryFilter( + history: () => IndexedSeq[String], + commentStartColor: String, + commentEndColor: String +) extends DelegateFilter { + + + def identifier = "HistoryFilter" + /** + * `-1` means we haven't started looking at history, `n >= 0` means we're + * currently at history command `n` + */ + var historyIndex = -1 + + /** + * The term we're searching for, if any. + * + * - `None` means we're not searching for anything, e.g. we're just + * browsing history + * + * - `Some(term)` where `term` is not empty is what it normally looks + * like when we're searching for something + * + * - `Some(term)` where `term` is empty only really happens when you + * start searching and delete things, or if you `Ctrl-R` on an empty + * prompt + */ + var searchTerm: Option[Vector[Char]] = None + + /** + * Records the last buffer that the filter has observed while it's in + * search/history mode. If the new buffer differs from this, assume that + * some other filter modified the buffer and drop out of search/history + */ + var prevBuffer: Option[Vector[Char]] = None + + /** + * Kicks the HistoryFilter from passive-mode into search-history mode + */ + def startHistory(b: Vector[Char], c: Int): (Vector[Char], Int, String) = { + if (b.nonEmpty) searchTerm = Some(b) + up(Vector(), c) + } + + def searchHistory( + start: Int, + increment: Int, + buffer: Vector[Char], + skipped: Vector[Char] + ) = { + + def nextHistoryIndexFor(v: Vector[Char]) = { + HistoryFilter.findNewHistoryIndex(start, v, history(), increment, skipped) + } + + val (newHistoryIndex, newBuffer, newMsg, newCursor) = searchTerm match { + // We're not searching for anything, just browsing history. + // Pass in Vector.empty so we scroll through all items + case None => + val (i, b, c) = nextHistoryIndexFor(Vector.empty) + (i, b, "", 99999) + + // We're searching for some item with a particular search term + case Some(b) if b.nonEmpty => + val (i, b1, c) = nextHistoryIndexFor(b) + + val msg = + if (i.nonEmpty) "" + else commentStartColor + HistoryFilter.cannotFindSearchMessage + commentEndColor + + (i, b1, msg, c) + + // We're searching for nothing in particular; in this case, + // show a help message instead of an unhelpful, empty buffer + case Some(b) if b.isEmpty => + val msg = commentStartColor + HistoryFilter.emptySearchMessage + commentEndColor + // The cursor in this case always goes to zero + (Some(start), Vector(), msg, 0) + + } + + historyIndex = newHistoryIndex.getOrElse(-1) + + (newBuffer, newCursor, newMsg) + } + + def activeHistory = searchTerm.nonEmpty || historyIndex != -1 + def activeSearch = searchTerm.nonEmpty + + def up(b: Vector[Char], c: Int) = + searchHistory(historyIndex + 1, 1, b, b) + + def down(b: Vector[Char], c: Int) = + searchHistory(historyIndex - 1, -1, b, b) + + def wrap(rest: LazyList[Int], out: (Vector[Char], Int, String)) = + TS(rest, out._1, out._2, out._3) + + def ctrlR(b: Vector[Char], c: Int) = + if (activeSearch) up(b, c) + else { + searchTerm = Some(b) + up(Vector(), c) + } + + def printableChar(char: Char)(b: Vector[Char], c: Int) = { + searchTerm = searchTerm.map(_ :+ char) + searchHistory(historyIndex.max(0), 1, b :+ char, Vector()) + } + + def backspace(b: Vector[Char], c: Int) = { + searchTerm = searchTerm.map(_.dropRight(1)) + searchHistory(historyIndex, 1, b, Vector()) + } + + /** + * Predicate to check if either we're searching for a term or if we're in + * history-browsing mode and some predicate is true. + * + * Very often we want to capture keystrokes in search-mode more aggressively + * than in history-mode, e.g. search-mode drops you out more aggressively + * than history-mode does, and its up/down keys cycle through history more + * aggressively on every keystroke while history-mode only cycles when you + * reach the top/bottom line of the multi-line input. + */ + def searchOrHistoryAnd(cond: Boolean) = + activeSearch || (activeHistory && cond) + + val dropHistoryChars = Set(9, 13, 10) // Tab or Enter + + def endHistory() = { + historyIndex = -1 + searchTerm = None + } + + def filter = Filter.wrap("historyFilterWrap1") { + (ti: TermInfo) => { + prelude.op(ti) match { + case None => + prevBuffer = Some(ti.ts.buffer) + filter0.op(ti) match { + case Some(ts: TermState) => + prevBuffer = Some(ts.buffer) + Some(ts) + case x => x + } + case some => some + } + } + } + + def prelude: Filter = Filter("historyPrelude") { + case TS(inputs, b, c, _) if activeHistory && prevBuffer.exists(_ != b) => + endHistory() + prevBuffer = None + TS(inputs, b, c) + } + + def filter0: Filter = Filter("filter0") { + // Ways to kick off the history/search if you're not already in it + + // `Ctrl-R` + case TS(18 ~: rest, b, c, _) => wrap(rest, ctrlR(b, c)) + + // `Up` from the first line in the input + case TermInfo(TS(p"\u001b[A$rest", b, c, _), w) if firstRow(c, b, w) && !activeHistory => + wrap(rest, startHistory(b, c)) + + // `Ctrl P` + case TermInfo(TS(p"\u0010$rest", b, c, _), w) if firstRow(c, b, w) && !activeHistory => + wrap(rest, startHistory(b, c)) + + // `Page-Up` from first character starts history + case TermInfo(TS(p"\u001b[5~$rest", b, c, _), w) if c == 0 && !activeHistory => + wrap(rest, startHistory(b, c)) + + // Things you can do when you're already in the history search + + // Navigating up and down the history. Each up or down searches for + // the next thing that matches your current searchTerm + // Up + case TermInfo(TS(p"\u001b[A$rest", b, c, _), w) if searchOrHistoryAnd(firstRow(c, b, w)) => + wrap(rest, up(b, c)) + + // Ctrl P + case TermInfo(TS(p"\u0010$rest", b, c, _), w) if searchOrHistoryAnd(firstRow(c, b, w)) => + wrap(rest, up(b, c)) + + // `Page-Up` from first character cycles history up + case TermInfo(TS(p"\u001b[5~$rest", b, c, _), w) if searchOrHistoryAnd(c == 0) => + wrap(rest, up(b, c)) + + // Down + case TermInfo(TS(p"\u001b[B$rest", b, c, _), w) if searchOrHistoryAnd(lastRow(c, b, w)) => + wrap(rest, down(b, c)) + + // `Ctrl N` + + case TermInfo(TS(p"\u000e$rest", b, c, _), w) if searchOrHistoryAnd(lastRow(c, b, w)) => + wrap(rest, down(b, c)) + // `Page-Down` from last character cycles history down + case TermInfo(TS(p"\u001b[6~$rest", b, c, _), w) if searchOrHistoryAnd(c == b.length - 1) => + wrap(rest, down(b, c)) + + + // Intercept Backspace and delete a character in search-mode, preserving it, but + // letting it fall through and dropping you out of history-mode if you try to make + // edits + case TS(127 ~: rest, buffer, cursor, _) if activeSearch => + wrap(rest, backspace(buffer, cursor)) + + // Any other control characters drop you out of search mode, but only the + // set of `dropHistoryChars` drops you out of history mode + case TS(char ~: inputs, buffer, cursor, _) + if char.toChar.isControl && searchOrHistoryAnd(dropHistoryChars(char)) => + val newBuffer = + // If we're back to -1, it means we've wrapped around and are + // displaying the original search term with a wrap-around message + // in the terminal. Drop the message and just preserve the search term + if (historyIndex == -1) searchTerm.get + // If we're searching for an empty string, special-case this and return + // an empty buffer rather than the first history item (which would be + // the default) because that wouldn't make much sense + else if (searchTerm.exists(_.isEmpty)) Vector() + // Otherwise, pick whatever history entry we're at and use that + else history()(historyIndex).toVector + endHistory() + + TS(char ~: inputs, newBuffer, cursor) + + // Intercept every other printable character when search is on and + // enter it into the current search + case TS(char ~: rest, buffer, cursor, _) if activeSearch => + wrap(rest, printableChar(char.toChar)(buffer, cursor)) + + // If you're not in search but are in history, entering any printable + // characters kicks you out of it and preserves the current buffer. This + // makes it harder for you to accidentally lose work due to history-moves + case TS(char ~: rest, buffer, cursor, _) if activeHistory && !char.toChar.isControl => + historyIndex = -1 + TS(char ~: rest, buffer, cursor) + } +} + +object HistoryFilter { + + def mangleBuffer( + historyFilter: HistoryFilter, + buffer: Ansi.Str, + cursor: Int, + startColor: Ansi.Attr + ) = { + if (!historyFilter.activeSearch) buffer + else { + val (searchStart, searchEnd) = + if (historyFilter.searchTerm.get.isEmpty) (cursor, cursor+1) + else { + val start = buffer.plainText.indexOfSlice(historyFilter.searchTerm.get) + + val end = start + (historyFilter.searchTerm.get.length max 1) + (start, end) + } + + val newStr = buffer.overlay(startColor, searchStart, searchEnd) + newStr + } + } + + /** + * @param startIndex The first index to start looking from + * @param searchTerm The term we're searching from; can be empty + * @param history The history we're searching through + * @param indexIncrement Which direction to search, +1 or -1 + * @param skipped Any buffers which we should skip in our search results, + * e.g. because the user has seen them before. + */ + def findNewHistoryIndex( + startIndex: Int, + searchTerm: Vector[Char], + history: IndexedSeq[String], + indexIncrement: Int, + skipped: Vector[Char] + ) = { + /** + * `Some(i)` means we found a reasonable result at history element `i` + * `None` means we couldn't find anything, and should show a not-found + * error to the user + */ + def rec(i: Int): Option[Int] = history.lift(i) match { + // If i < 0, it means the user is pressing `down` too many times, which + // means it doesn't show anything but we shouldn't show an error + case None if i < 0 => Some(-1) + case None => None + case Some(s) if s.contains(searchTerm) && !s.contentEquals(skipped) => + Some(i) + case _ => rec(i + indexIncrement) + } + + val newHistoryIndex = rec(startIndex) + val foundIndex = newHistoryIndex.find(_ != -1) + val newBuffer = foundIndex match { + case None => searchTerm + case Some(i) => history(i).toVector + } + + val newCursor = foundIndex match { + case None => newBuffer.length + case Some(i) => history(i).indexOfSlice(searchTerm) + searchTerm.length + } + + (newHistoryIndex, newBuffer, newCursor) + } + + val emptySearchMessage = + s" ...enter the string to search for, then `up` for more" + val cannotFindSearchMessage = + s" ...can't be found in history; re-starting search" +} |