aboutsummaryrefslogtreecommitdiff
path: root/compiler/src/dotty/tools/dotc/repl/ammonite/Terminal.scala
blob: 4b18b38e3a74d8fe11e3673cec4eb01c5e67b86b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
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)