package dotty.tools
package dotc
package repl
package ammonite
package terminal
import scala.annotation.tailrec
import scala.collection.mutable
/**
* The core logic around a terminal; it defines the base `filters` API
* through which anything (including basic cursor-navigation and typing)
* interacts with the terminal.
*
* Maintains basic invariants, such as "cursor should always be within
* the buffer", and "ansi terminal should reflect most up to date TermState"
*/
object Terminal {
/**
* Computes how tall a line of text is when wrapped at `width`.
*
* Even 0-character lines still take up one row!
*
* width = 2
* 0 -> 1
* 1 -> 1
* 2 -> 1
* 3 -> 2
* 4 -> 2
* 5 -> 3
*/
def fragHeight(length: Int, width: Int) = math.max(1, (length - 1) / width + 1)
def splitBuffer(buffer: Vector[Char]) = {
val frags = mutable.Buffer.empty[Int]
frags.append(0)
for(c <- buffer){
if (c == '\n') frags.append(0)
else frags(frags.length - 1) = frags.last + 1
}
frags
}
def calculateHeight(buffer: Vector[Char],
width: Int,
prompt: String): Seq[Int] = {
val rowLengths = splitBuffer(buffer)
calculateHeight0(rowLengths, width - prompt.length)
}
/**
* Given a buffer with characters and newlines, calculates how high
* the buffer is and where the cursor goes inside of it.
*/
def calculateHeight0(rowLengths: Seq[Int],
width: Int): Seq[Int] = {
val fragHeights =
rowLengths
.inits
.toVector
.reverse // We want shortest-to-longest, inits gives longest-to-shortest
.filter(_.nonEmpty) // Without the first empty prefix
.map{ x =>
fragHeight(
// If the frag barely fits on one line, give it
// an extra spot for the cursor on the next line
x.last + 1,
width
)
}
// Debug("fragHeights " + fragHeights)
fragHeights
}
def positionCursor(cursor: Int,
rowLengths: Seq[Int],
fragHeights: Seq[Int],
width: Int) = {
var leftoverCursor = cursor
// Debug("leftoverCursor " + leftoverCursor)
var totalPreHeight = 0
var done = false
// Don't check if the cursor exceeds the last chunk, because
// even if it does there's nowhere else for it to go
for(i <- 0 until rowLengths.length -1 if !done) {
// length of frag and the '\n' after it
val delta = rowLengths(i) + 1
// Debug("delta " + delta)
val nextCursor = leftoverCursor - delta
if (nextCursor >= 0) {
// Debug("nextCursor " + nextCursor)
leftoverCursor = nextCursor
totalPreHeight += fragHeights(i)
}else done = true
}
val cursorY = totalPreHeight + leftoverCursor / width
val cursorX = leftoverCursor % width
(cursorY, cursorX)
}
type Action = (Vector[Char], Int) => (Vector[Char], Int)
type MsgAction = (Vector[Char], Int) => (Vector[Char], Int, String)
def noTransform(x: Vector[Char], i: Int) = (Ansi.Str.parse(x), i)
/**
* Blockingly reads a line from the given input stream and returns it.
*
* @param prompt The prompt to display when requesting input
* @param reader The input-stream where characters come in, e.g. System.in
* @param writer The output-stream where print-outs go, e.g. System.out
* @param filters A set of actions that can be taken depending on the input,
* @param displayTransform code to manipulate the display of the buffer and
* cursor, without actually changing the logical
* values inside them.
*/
def readLine(prompt: Prompt,
reader: java.io.Reader,
writer: java.io.Writer,
filters: Filter,
displayTransform: (Vector[Char], Int) => (Ansi.Str, Int) = noTransform)
: Option[String] = {
/**
* Erases the previous line and re-draws it with the new buffer and
* cursor.
*
* Relies on `ups` to know how "tall" the previous line was, to go up
* and erase that many rows in the console. Performs a lot of horrific
* math all over the place, incredibly prone to off-by-ones, in order
* to at the end of the day position the cursor in the right spot.
*/
def redrawLine(buffer: Ansi.Str,
cursor: Int,
ups: Int,
rowLengths: Seq[Int],
fullPrompt: Boolean = true,
newlinePrompt: Boolean = false) = {
// Enable this in certain cases (e.g. cursor near the value you are
// interested into) see what's going on with all the ansi screen-cursor
// movement
def debugDelay() = if (false){
Thread.sleep(200)
writer.flush()
}
val promptLine =
if (fullPrompt) prompt.full
else prompt.lastLine
val promptWidth = if(newlinePrompt) 0 else prompt.lastLine.length
val actualWidth = width - promptWidth
ansi.up(ups)
ansi.left(9999)
ansi.clearScreen(0)
writer.write(promptLine.toString)
if (newlinePrompt) writer.write("\n")
// I'm not sure why this is necessary, but it seems that without it, a
// cursor that "barely" overshoots the end of a line, at the end of the
// buffer, does not properly wrap and ends up dangling off the
// right-edge of the terminal window!
//
// This causes problems later since the cursor is at the wrong X/Y,
// confusing the rest of the math and ending up over-shooting on the
// `ansi.up` calls, over-writing earlier lines. This prints a single
// space such that instead of dangling it forces the cursor onto the
// next line for-realz. If it isn't dangling the extra space is a no-op
val lineStuffer = ' '
// Under `newlinePrompt`, we print the thing almost-verbatim, since we
// want to avoid breaking code by adding random indentation. If not, we
// are guaranteed that the lines are short, so we can indent the newlines
// without fear of wrapping
val newlineReplacement =
if (newlinePrompt) {
Array(lineStuffer, '\n')
} else {
val indent = " " * prompt.lastLine.length
Array('\n', indent:_*)
}
writer.write(
buffer.render.flatMap{
case '\n' => newlineReplacement
case x => Array(x)
}.toArray
)
writer.write(lineStuffer)
val fragHeights = calculateHeight0(rowLengths, actualWidth)
val (cursorY, cursorX) = positionCursor(
cursor,
rowLengths,
fragHeights,
actualWidth
)
ansi.up(fragHeights.sum - 1)
ansi.left(9999)
ansi.down(cursorY)
ansi.right(cursorX)
if (!newlinePrompt) ansi.right(prompt.lastLine.length)
writer.flush()
}
@tailrec
def readChar(lastState: TermState, ups: Int, fullPrompt: Boolean = true): Option[String] = {
val moreInputComing = reader.ready()
lazy val (transformedBuffer0, cursorOffset) = displayTransform(
lastState.buffer,
lastState.cursor
)
lazy val transformedBuffer = transformedBuffer0 ++ lastState.msg
lazy val lastOffsetCursor = lastState.cursor + cursorOffset
lazy val rowLengths = splitBuffer(
lastState.buffer ++ lastState.msg.plainText
)
val narrowWidth = width - prompt.lastLine.length
val newlinePrompt = rowLengths.exists(_ >= narrowWidth)
val promptWidth = if(newlinePrompt) 0 else prompt.lastLine.length
val actualWidth = width - promptWidth
val newlineUp = if (newlinePrompt) 1 else 0
if (!moreInputComing) redrawLine(
transformedBuffer,
lastOffsetCursor,
ups,
rowLengths,
fullPrompt,
newlinePrompt
)
lazy val (oldCursorY, _) = positionCursor(
lastOffsetCursor,
rowLengths,
calculateHeight0(rowLengths, actualWidth),
actualWidth
)
def updateState(s: LazyList[Int],
b: Vector[Char],
c: Int,
msg: Ansi.Str): (Int, TermState) = {
val newCursor = math.max(math.min(c, b.length), 0)
val nextUps =
if (moreInputComing) ups
else oldCursorY + newlineUp
val newState = TermState(s, b, newCursor, msg)
(nextUps, newState)
}
// `.get` because we assume that *some* filter is going to match each
// character, even if only to dump the character to the screen. If nobody
// matches the character then we can feel free to blow up
filters.op(TermInfo(lastState, actualWidth)).get match {
case Printing(TermState(s, b, c, msg), stdout) =>
writer.write(stdout)
val (nextUps, newState) = updateState(s, b, c, msg)
readChar(newState, nextUps)
case TermState(s, b, c, msg) =>
val (nextUps, newState) = updateState(s, b, c, msg)
readChar(newState, nextUps, false)
case Result(s) =>
redrawLine(
transformedBuffer, lastState.buffer.length,
oldCursorY + newlineUp, rowLengths, false, newlinePrompt
)
writer.write(10)
writer.write(13)
writer.flush()
Some(s)
case ClearScreen(ts) =>
ansi.clearScreen(2)
ansi.up(9999)
ansi.left(9999)
readChar(ts, ups)
case Exit =>
None
}
}
lazy val ansi = new AnsiNav(writer)
lazy val (width, _, initialConfig) = TTY.init()
try {
readChar(TermState(LazyList.continually(reader.read()), Vector.empty, 0, ""), 0)
}finally{
// Don't close these! Closing these closes stdin/stdout,
// which seems to kill the entire program
// reader.close()
// writer.close()
TTY.stty(initialConfig)
}
}
}
object Prompt {
implicit def construct(prompt: String): Prompt = {
val parsedPrompt = Ansi.Str.parse(prompt)
val index = parsedPrompt.plainText.lastIndexOf('\n')
val (_, last) = parsedPrompt.splitAt(index+1)
Prompt(parsedPrompt, last)
}
}
case class Prompt(full: Ansi.Str, lastLine: Ansi.Str)