From 2f8edb12325b5926be8eae158853325d426cbd6d Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Thu, 2 Jan 2025 14:32:41 +0000 Subject: [PATCH] xterm.js keyboard input plus example --- .github/workflows/ci.yml | 4 +- .../js/src/main/scala/terminus/Terminal.scala | 26 ++++++- .../main/scala/terminus/XtermJsOptions.scala | 16 ++++ .../main/scala/terminus/XtermJsTerminal.scala | 8 ++ docs/src/pages/directory.conf | 1 + docs/src/pages/examples.md | 4 + .../main/scala/terminus/examples/Prompt.scala | 74 +++++++++++++++++++ 7 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 docs/src/pages/examples.md create mode 100644 examples/js/src/main/scala/terminus/examples/Prompt.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9acab49..727ca96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,11 +83,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p unidocs/target core/native/target core/js/target core/jvm/target project/target + run: mkdir -p unidocs/target core/native/target core/js/target examples/js/target core/jvm/target examples/jvm/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: tar cf targets.tar unidocs/target core/native/target core/js/target core/jvm/target project/target + run: tar cf targets.tar unidocs/target core/native/target core/js/target examples/js/target core/jvm/target examples/jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') diff --git a/core/js/src/main/scala/terminus/Terminal.scala b/core/js/src/main/scala/terminus/Terminal.scala index 7ccff1d..4ca78ae 100644 --- a/core/js/src/main/scala/terminus/Terminal.scala +++ b/core/js/src/main/scala/terminus/Terminal.scala @@ -16,8 +16,12 @@ package terminus +import org.scalajs.dom import org.scalajs.dom.HTMLElement -import org.scalajs.dom.document + +import scala.collection.mutable +import scala.concurrent.Future +import scala.concurrent.Promise class Terminal(root: HTMLElement, options: XtermJsOptions) extends effect.Color[Terminal], @@ -26,8 +30,23 @@ class Terminal(root: HTMLElement, options: XtermJsOptions) effect.Erase, effect.Writer { + private val keyBuffer: mutable.ArrayDeque[Promise[String]] = + new mutable.ArrayDeque[Promise[String]](8) + private val terminal = new XtermJsTerminal(options) terminal.open(root) + terminal.onKey { (event: XtermKeyEvent) => + keyBuffer.removeHead().success(event.domEvent.key) + () + } + + /** Block reading a Javascript keycode */ + def readKey(): Future[String] = { + val promise = Promise[String]() + keyBuffer.append(promise) + + promise.future + } def flush(): Unit = () @@ -40,9 +59,12 @@ class Terminal(root: HTMLElement, options: XtermJsOptions) type Program[A] = Terminal ?=> A object Terminal extends Color, Cursor, Display, Erase, Writer { + def readKey(): Program[Future[String]] = + terminal ?=> terminal.readKey() + def run[A](id: String, rows: Int = 24, cols: Int = 80)(f: Program[A]): A = { val options = XtermJsOptions(rows, cols) - run(document.getElementById(id).asInstanceOf[HTMLElement], options)(f) + run(dom.document.getElementById(id).asInstanceOf[HTMLElement], options)(f) } def run[A](element: HTMLElement, options: XtermJsOptions)( diff --git a/core/js/src/main/scala/terminus/XtermJsOptions.scala b/core/js/src/main/scala/terminus/XtermJsOptions.scala index 2be3318..d0d0dd7 100644 --- a/core/js/src/main/scala/terminus/XtermJsOptions.scala +++ b/core/js/src/main/scala/terminus/XtermJsOptions.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2024 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package terminus import scala.scalajs.js diff --git a/core/js/src/main/scala/terminus/XtermJsTerminal.scala b/core/js/src/main/scala/terminus/XtermJsTerminal.scala index 189f100..f2bff4d 100644 --- a/core/js/src/main/scala/terminus/XtermJsTerminal.scala +++ b/core/js/src/main/scala/terminus/XtermJsTerminal.scala @@ -21,11 +21,19 @@ import org.scalajs.dom import scala.annotation.unused import scala.scalajs.js import scala.scalajs.js.annotation.JSGlobal +import org.scalajs.dom.KeyboardEvent + +@js.native +trait XtermKeyEvent extends js.Object { + val key: String = js.native + val domEvent: KeyboardEvent = js.native +} @js.native @JSGlobal("Terminal") /** Minimal definition of the Terminal type from xterm.js */ class XtermJsTerminal(@unused options: XtermJsOptions) extends js.Object { + val onKey: js.Function1[js.Function1[XtermKeyEvent, Unit], Unit] = js.native def open(element: dom.HTMLElement): Unit = js.native def write(data: String): Unit = js.native } diff --git a/docs/src/pages/directory.conf b/docs/src/pages/directory.conf index 665400e..8e29a7a 100644 --- a/docs/src/pages/directory.conf +++ b/docs/src/pages/directory.conf @@ -1,5 +1,6 @@ laika.navigationOrder = [ README.md + examples.md jvm.md js.md ] diff --git a/docs/src/pages/examples.md b/docs/src/pages/examples.md new file mode 100644 index 0000000..9069888 --- /dev/null +++ b/docs/src/pages/examples.md @@ -0,0 +1,4 @@ +# Examples + +@:doodle("prompt", "Prompt.go") + diff --git a/examples/js/src/main/scala/terminus/examples/Prompt.scala b/examples/js/src/main/scala/terminus/examples/Prompt.scala new file mode 100644 index 0000000..2ea02ac --- /dev/null +++ b/examples/js/src/main/scala/terminus/examples/Prompt.scala @@ -0,0 +1,74 @@ +package terminus.examples + +import terminus.* +import scala.scalajs.js.annotation.* +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global + +@JSExportTopLevel("Prompt") +object Prompt { + enum KeyCode { + case Down + case Up + case Enter + } + + // Clear the text we've written + def clear(): Program[Unit] = { + Terminal.cursor.move(1, -4) + Terminal.erase.down() + Terminal.cursor.column(1) + } + + // Write an option the user can choose. The currently selected option is highlighted. + def writeChoice(description: String, selected: Boolean): Program[Unit] = + if selected then + Terminal.display.bold(Terminal.write(s"> ${description}\r\n")) + else Terminal.write(s" ${description}\r\n") + + // Write the UI + def write(selected: Int): Program[Unit] = { + Terminal.write("How cool is this?\r\n") + writeChoice("Very cool", selected == 0) + writeChoice("Way cool", selected == 1) + writeChoice("So cool", selected == 2) + Terminal.flush() + } + + // Convert Javascript key to KeyCode + // See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key + def read(): Program[Future[KeyCode]] = { + Terminal.readKey().flatMap( keyCode => + keyCode match { + case "Enter" => Future.successful(KeyCode.Enter) + case "ArrowDown" => Future.successful(KeyCode.Down) + case "ArrowUp" => Future.successful(KeyCode.Up) + case other => read() + } + ) + } + + def loop(idx: Int): Program[Future[Int]] = { + write(idx) + read().flatMap(keyCode => + keyCode match { + case KeyCode.Up => + clear() + loop(if idx == 0 then 2 else idx - 1) + + case KeyCode.Down => + clear() + loop(if idx == 2 then 0 else idx + 1) + + case KeyCode.Enter => Future.successful(idx) + } + ) + } + @JSExport + def go(id: String) = + Terminal.run(id, rows = 16) { + loop(0).map(idx => + Terminal.write(s"You selected $idx") + ) + } +}