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"))