Make new more interactive

If the terminal supports ANSI control sequence,
this displays the template list in an interactive way.
The focused template is rendered reversed,
and arrow key can be used to move the focus up/down.
This commit is contained in:
Eugene Yokota 2023-05-06 20:07:47 -04:00
parent 3d1349a37d
commit 51591bde5b
3 changed files with 122 additions and 31 deletions

View File

@ -176,9 +176,40 @@ trait Terminal extends AutoCloseable {
else 0 else 0
} }
private[sbt] def flush(): Unit = printStream.flush() private[sbt] def flush(): Unit = printStream.flush()
private[sbt] def readArrow: Int = withRawInput {
val in = System.in
val ESC = '\u001B'
val EOT = '\u0004'
var result: Int = -1
def readBracket: Int =
in.read() match {
case '[' => readAnsiControl
case _ => 0
}
def readAnsiControl: Int =
in.read() match {
case 'A' => Terminal.VK_UP
case 'B' => Terminal.VK_DOWN
case 'C' => Terminal.VK_RIGHT
case 'D' => Terminal.VK_LEFT
case _ => 0
}
in.read() match {
case ESC => readBracket
// Ctrl+D to quit
case EOT => -1
case c => c
}
}
} }
object Terminal { object Terminal {
private[sbt] final val VK_UP = 256
private[sbt] final val VK_DOWN = 257
private[sbt] final val VK_RIGHT = 258
private[sbt] final val VK_LEFT = 259
val NO_BOOT_CLIENTS_CONNECTED: Int = -2 val NO_BOOT_CLIENTS_CONNECTED: Int = -2
// Disable noisy jline log spam // Disable noisy jline log spam
if (System.getProperty("sbt.jline.verbose", "false") != "true") if (System.getProperty("sbt.jline.verbose", "false") != "true")

View File

@ -110,6 +110,7 @@ private[sbt] object xMain {
Seq(defaults, early), Seq(defaults, early),
runEarly(DefaultsCommand) :: runEarly(InitCommand) :: BootCommand :: Nil runEarly(DefaultsCommand) :: runEarly(InitCommand) :: BootCommand :: Nil
) )
.put(BasicKeys.detachStdio, detachStdio)
StandardMain.runManaged(state) StandardMain.runManaged(state)
} }
case _ if clientModByEnv || userCommands.exists(isClient) => case _ if clientModByEnv || userCommands.exists(isClient) =>

View File

@ -15,6 +15,7 @@ import sbt.BasicCommandStrings.TerminateAction
import sbt.SlashSyntax0._ import sbt.SlashSyntax0._
import sbt.io._, syntax._ import sbt.io._, syntax._
import sbt.util._ import sbt.util._
import sbt.internal.util.{ ConsoleAppender, Terminal => ITerminal }
import sbt.internal.util.complete.{ DefaultParsers, Parser }, DefaultParsers._ import sbt.internal.util.complete.{ DefaultParsers, Parser }, DefaultParsers._
import xsbti.AppConfiguration import xsbti.AppConfiguration
import sbt.librarymanagement._ import sbt.librarymanagement._
@ -161,8 +162,10 @@ private[sbt] object TemplateCommandUtil {
private final val ScalaToolkitSlug = "scala/toolkit.local" private final val ScalaToolkitSlug = "scala/toolkit.local"
private final val TypelevelToolkitSlug = "typelevel/toolkit.local" private final val TypelevelToolkitSlug = "typelevel/toolkit.local"
private final val SbtCrossPlatformSlug = "sbt/cross-platform.local" private final val SbtCrossPlatformSlug = "sbt/cross-platform.local"
private def fortifyArgs(): List[String] = { private lazy val term: ITerminal = ITerminal.get
val templates = List( private lazy val isAnsiSupported = term.isAnsiSupported
private lazy val nonMoveLetters = ('a' to 'z').toList diff List('h', 'j', 'k', 'l', 'q')
private lazy val templates = List(
ScalaToolkitSlug -> "Scala Toolkit (beta) by Scala Center and VirtusLab", ScalaToolkitSlug -> "Scala Toolkit (beta) by Scala Center and VirtusLab",
TypelevelToolkitSlug -> "Toolkit to start building Typelevel apps", TypelevelToolkitSlug -> "Toolkit to start building Typelevel apps",
SbtCrossPlatformSlug -> "A cross-JVM/JS/Native project", SbtCrossPlatformSlug -> "A cross-JVM/JS/Native project",
@ -174,26 +177,81 @@ private[sbt] object TemplateCommandUtil {
"spotify/scio.g8" -> "A Scio project", "spotify/scio.g8" -> "A Scio project",
"disneystreaming/smithy4s.g8" -> "A Smithy4s project", "disneystreaming/smithy4s.g8" -> "A Smithy4s project",
) )
private def fortifyArgs(): List[String] =
if (System.console eq null) Nil
else
ITerminal.withStreams(true, false) {
val mappingList = templates.zipWithIndex.map { val mappingList = templates.zipWithIndex.map {
case (v, idx) => (idx + 1) -> v case (v, idx) => nonMoveLetters(idx).toString -> v
} }
System.out.println("") val out = term.printStream
System.out.println("Welcome to sbt new!") out.println("")
System.out.println("Here are some templates to get started:") out.println("Welcome to sbt new!")
mappingList.foreach { out.println("Here are some templates to get started:")
case (k, (slug, desc)) => val ans = askTemplate(mappingList, 0)
val key = if (k < 10) s" $k" else k.toString val mappings = Map(mappingList: _*)
System.out.println(s" $key) ${slug.padTo(33, ' ')} - $desc")
}
System.out.println(" q) quit")
val ans = ask("Select a template", "1")
val mappings = Map((mappingList.map { case (k, v) => k.toString -> v }): _*)
mappings.get(ans).map(_._1).toList mappings.get(ans).map(_._1).toList
} }
private def askTemplate(mappingList: List[(String, (String, String))], focus: Int): String = {
val msg = "Select a template"
displayMappings(mappingList, focus)
val focusValue = ('a' + focus).toChar.toString
if (!isAnsiSupported) ask(msg, focusValue)
else {
val out = term.printStream
out.print(s"$msg: ")
val ans0 = term.readArrow
def printThenReturn(ans: String): String = {
out.println(ans) // this is necessary to move the cursor
out.flush()
ans
}
ans0 match {
case '\r' | '\n' => printThenReturn(focusValue)
case 'q' | 'Q' | -1 => printThenReturn("")
case 'j' | 'J' | ITerminal.VK_DOWN =>
clearMenu(mappingList)
askTemplate(mappingList, math.min(focus + 1, mappingList.size - 1))
case 'k' | 'K' | ITerminal.VK_UP =>
clearMenu(mappingList)
askTemplate(mappingList, math.max(focus - 1, 0))
case c if nonMoveLetters.contains(c.toChar) =>
printThenReturn(c.toChar.toString)
case _ =>
clearMenu(mappingList)
askTemplate(mappingList, focus)
}
}
}
private def clearMenu(mappingList: List[(String, (String, String))]): Unit = {
val out = term.printStream
out.print(ConsoleAppender.CursorLeft1000)
out.print(ConsoleAppender.cursorUp(mappingList.size + 1))
}
private def displayMappings(mappingList: List[(String, (String, String))], focus: Int): Unit = {
import scala.Console.{ RESET, REVERSED }
val out = term.printStream
mappingList.zipWithIndex.foreach {
case ((k, (slug, desc)), idx) =>
if (idx == focus && isAnsiSupported) {
out.print(REVERSED)
}
out.print(s" $k) ${slug.padTo(33, ' ')} - $desc")
if (idx == focus && isAnsiSupported) {
out.print(RESET)
}
out.println()
}
out.println(" q) quit")
out.flush()
}
private def ask(question: String, default: String): String = { private def ask(question: String, default: String): String = {
System.out.print(s"$question (default: $default): ") System.out.print(s"$question (default: $default): ")
val ans0 = System.console().readLine() val ans0 = System.console.readLine()
if (ans0 == "") default if (ans0 == "") default
else ans0 else ans0
} }
@ -212,16 +270,17 @@ private[sbt] object TemplateCommandUtil {
private final val defaultScalaV = "3.2.2" private final val defaultScalaV = "3.2.2"
private def scalaToolkitTemplate(): Unit = { private def scalaToolkitTemplate(): Unit = {
val defaultScalaToolkitV = "0.1.6" val defaultScalaToolkitV = "0.1.7"
val scalaV = ask("Scala version", defaultScalaV) val scalaV = ask("Scala version", defaultScalaV)
val toolkitV = ask("Scala Toolkit version", defaultScalaToolkitV) val toolkitV = ask("Scala Toolkit version", defaultScalaToolkitV)
val content = s""" val content = s"""
val toolkit = "org.scala-lang" %% "toolkit" % "$toolkitV" val toolkitV = "$toolkitV"
// val toolkitTest = "org.scala-lang" %% "toolkit-test" % "$toolkitV" val toolkit = "org.scala-lang" %% "toolkit" % toolkitV
val toolkitTest = "org.scala-lang" %% "toolkit-test" % toolkitV
ThisBuild / scalaVersion := "$scalaV" ThisBuild / scalaVersion := "$scalaV"
libraryDependencies += toolkit libraryDependencies += toolkit
// libraryDependencies += (toolkitTest % Test) libraryDependencies += (toolkitTest % Test)
""" """
IO.write(new File("build.sbt"), content) IO.write(new File("build.sbt"), content)
copyResource("ScalaMain.scala.txt", new File("src/main/scala/example/Main.scala")) copyResource("ScalaMain.scala.txt", new File("src/main/scala/example/Main.scala"))