aboutsummaryrefslogtreecommitdiff
path: root/src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala
diff options
context:
space:
mode:
Diffstat (limited to 'src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala')
-rw-r--r--src/dotty/tools/dotc/repl/ammonite/filters/HistoryFilter.scala334
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"
+}