From 51591bde5b343f6bd7e33c8ea67425a96a2415df Mon Sep 17 00:00:00 2001 From: Eugene Yokota Date: Sat, 6 May 2023 20:07:47 -0400 Subject: [PATCH] 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. --- .../scala/sbt/internal/util/Terminal.scala | 31 +++++ main/src/main/scala/sbt/Main.scala | 1 + .../main/scala/sbt/TemplateCommandUtil.scala | 121 +++++++++++++----- 3 files changed, 122 insertions(+), 31 deletions(-) diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala index 27b1e29c4..41b687ca5 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/Terminal.scala @@ -176,9 +176,40 @@ trait Terminal extends AutoCloseable { else 0 } 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 { + 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 // Disable noisy jline log spam if (System.getProperty("sbt.jline.verbose", "false") != "true") diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 88babfe0b..2fcca3a43 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -110,6 +110,7 @@ private[sbt] object xMain { Seq(defaults, early), runEarly(DefaultsCommand) :: runEarly(InitCommand) :: BootCommand :: Nil ) + .put(BasicKeys.detachStdio, detachStdio) StandardMain.runManaged(state) } case _ if clientModByEnv || userCommands.exists(isClient) => diff --git a/main/src/main/scala/sbt/TemplateCommandUtil.scala b/main/src/main/scala/sbt/TemplateCommandUtil.scala index 31b02ffe0..9cc326c7b 100644 --- a/main/src/main/scala/sbt/TemplateCommandUtil.scala +++ b/main/src/main/scala/sbt/TemplateCommandUtil.scala @@ -15,6 +15,7 @@ import sbt.BasicCommandStrings.TerminateAction import sbt.SlashSyntax0._ import sbt.io._, syntax._ import sbt.util._ +import sbt.internal.util.{ ConsoleAppender, Terminal => ITerminal } import sbt.internal.util.complete.{ DefaultParsers, Parser }, DefaultParsers._ import xsbti.AppConfiguration import sbt.librarymanagement._ @@ -161,39 +162,96 @@ private[sbt] object TemplateCommandUtil { private final val ScalaToolkitSlug = "scala/toolkit.local" private final val TypelevelToolkitSlug = "typelevel/toolkit.local" private final val SbtCrossPlatformSlug = "sbt/cross-platform.local" - private def fortifyArgs(): List[String] = { - val templates = List( - ScalaToolkitSlug -> "Scala Toolkit (beta) by Scala Center and VirtusLab", - TypelevelToolkitSlug -> "Toolkit to start building Typelevel apps", - SbtCrossPlatformSlug -> "A cross-JVM/JS/Native project", - "scala/scala-seed.g8" -> "Scala 2 seed template", - "playframework/play-scala-seed.g8" -> "A Play project in Scala", - "playframework/play-java-seed.g8" -> "A Play project in Java", - "scala-js/vite.g8" -> "A Scala.JS + Vite project", - "holdenk/sparkProjectTemplate.g8" -> "A Scala Spark project", - "spotify/scio.g8" -> "A Scio project", - "disneystreaming/smithy4s.g8" -> "A Smithy4s project", - ) - val mappingList = templates.zipWithIndex.map { - case (v, idx) => (idx + 1) -> v + private lazy val term: ITerminal = ITerminal.get + 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", + TypelevelToolkitSlug -> "Toolkit to start building Typelevel apps", + SbtCrossPlatformSlug -> "A cross-JVM/JS/Native project", + "scala/scala-seed.g8" -> "Scala 2 seed template", + "playframework/play-scala-seed.g8" -> "A Play project in Scala", + "playframework/play-java-seed.g8" -> "A Play project in Java", + "scala-js/vite.g8" -> "A Scala.JS + Vite project", + "holdenk/sparkProjectTemplate.g8" -> "A Scala Spark project", + "spotify/scio.g8" -> "A Scio 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 { + case (v, idx) => nonMoveLetters(idx).toString -> v + } + val out = term.printStream + out.println("") + out.println("Welcome to sbt new!") + out.println("Here are some templates to get started:") + val ans = askTemplate(mappingList, 0) + val mappings = Map(mappingList: _*) + 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) + } } - System.out.println("") - System.out.println("Welcome to sbt new!") - System.out.println("Here are some templates to get started:") - mappingList.foreach { - case (k, (slug, desc)) => - val key = if (k < 10) s" $k" else k.toString - System.out.println(s" $key) ${slug.padTo(33, ' ')} - $desc") + } + + 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() } - 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 + out.println(" q) quit") + out.flush() } private def ask(question: String, default: String): String = { System.out.print(s"$question (default: $default): ") - val ans0 = System.console().readLine() + val ans0 = System.console.readLine() if (ans0 == "") default else ans0 } @@ -212,16 +270,17 @@ private[sbt] object TemplateCommandUtil { private final val defaultScalaV = "3.2.2" private def scalaToolkitTemplate(): Unit = { - val defaultScalaToolkitV = "0.1.6" + val defaultScalaToolkitV = "0.1.7" val scalaV = ask("Scala version", defaultScalaV) val toolkitV = ask("Scala Toolkit version", defaultScalaToolkitV) val content = s""" -val toolkit = "org.scala-lang" %% "toolkit" % "$toolkitV" -// val toolkitTest = "org.scala-lang" %% "toolkit-test" % "$toolkitV" +val toolkitV = "$toolkitV" +val toolkit = "org.scala-lang" %% "toolkit" % toolkitV +val toolkitTest = "org.scala-lang" %% "toolkit-test" % toolkitV ThisBuild / scalaVersion := "$scalaV" libraryDependencies += toolkit -// libraryDependencies += (toolkitTest % Test) +libraryDependencies += (toolkitTest % Test) """ IO.write(new File("build.sbt"), content) copyResource("ScalaMain.scala.txt", new File("src/main/scala/example/Main.scala"))