Skip to content

Commit

Permalink
Add Javascript backend (#6)
Browse files Browse the repository at this point in the history
* Implement and document bindings for xterm.js, providing most of the Terminus API plus some JS specific parts.

* Use this implementation to add examples to the documentation
  • Loading branch information
noelwelsh authored Jan 2, 2025
1 parent 540c5d3 commit 92ccb56
Show file tree
Hide file tree
Showing 16 changed files with 676 additions and 134 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
30 changes: 28 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform)
name := "terminus-core"
)
.jvmSettings(libraryDependencies += Dependencies.jline.value)
.jsSettings(libraryDependencies += Dependencies.scalajsDom.value)

lazy val docs =
project
Expand All @@ -95,16 +96,24 @@ lazy val docs =
)
),
mdocIn := file("docs/src/pages"),
Laika / sourceDirectories ++= Seq(),
Laika / sourceDirectories ++= Seq(
file("docs/src/css"),
file("docs/src/js"),
(examples.js / Compile / fastOptJS / artifactPath).value
.getParentFile() / s"${(examples.js / moduleName).value}-fastopt"
),
laikaTheme := CreativeScalaTheme.empty
.addJs(laika.ast.Path.Root / "xterm.js")
.addJs(laika.ast.Path.Root / "main.js")
.addCss(laika.ast.Path.Root / "xterm.css")
.build,
laikaExtensions ++= Seq(
laika.format.Markdown.GitHubFlavor,
laika.config.SyntaxHighlighting
),
tlSite := Def
.sequential(
(examples.js / Compile / fastLinkJS),
mdoc.toTask(""),
laikaSite
)
Expand All @@ -121,6 +130,23 @@ lazy val unidocs = project
ScalaUnidoc / unidoc / unidocProjectFilter :=
inAnyProject -- inProjects(
docs,
core.js
core.js,
examples.js
)
)

lazy val examples = crossProject(JSPlatform, JVMPlatform)
.in(file("examples"))
.settings(
commonSettings,
moduleName := "terminus-examples"
)
.jvmConfigure(
_.settings(mimaPreviousArtifacts := Set.empty)
.dependsOn(core.jvm)
)
.jsConfigure(
_.settings(mimaPreviousArtifacts := Set.empty)
.dependsOn(core.js)
)
.dependsOn(core)
76 changes: 76 additions & 0 deletions core/js/src/main/scala/terminus/Terminal.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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 org.scalajs.dom
import org.scalajs.dom.HTMLElement

import scala.collection.mutable
import scala.concurrent.Future
import scala.concurrent.Promise

class Terminal(root: HTMLElement, options: XtermJsOptions)
extends effect.Color[Terminal],
effect.Cursor,
effect.Display[Terminal],
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 = ()

def write(string: String): Unit =
terminal.write(string)

def write(char: Char): Unit =
terminal.write(char.toString())
}
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(dom.document.getElementById(id).asInstanceOf[HTMLElement], options)(f)
}

def run[A](element: HTMLElement, options: XtermJsOptions)(
f: Program[A]
): A = {
val terminal = Terminal(element, options)
f(using terminal)
}
}
29 changes: 29 additions & 0 deletions core/js/src/main/scala/terminus/XtermJsOptions.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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

@js.native
trait XtermJsOptions extends js.Object {
val cols: Int = js.native
val rows: Int = js.native
}
object XtermJsOptions {
def apply(rows: Int = 80, cols: Int = 24): XtermJsOptions =
js.Dynamic.literal(rows = rows, cols = cols).asInstanceOf[XtermJsOptions]
}
39 changes: 39 additions & 0 deletions core/js/src/main/scala/terminus/XtermJsTerminal.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 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
}
2 changes: 1 addition & 1 deletion core/shared/src/main/scala/terminus/effect/AnsiCodes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ object AnsiCodes {
/** The Control Sequencer Introducer code, which starts many escape codes. It
* is ESC[
*/
val csiCode: String = "$esc["
val csiCode: String = s"${esc}["

/** Create a CSI escape code. The terminator must be specifed first, followed
* by zero or more arguments. The arguments will printed semi-colon separated
Expand Down
68 changes: 34 additions & 34 deletions core/shared/src/main/scala/terminus/effect/Color.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,106 +19,106 @@ package terminus.effect
trait Color[+F <: Writer] extends WithEffect[F] { self: F =>
object foreground {
def default[A](f: F ?=> A): A =
withEffect("39")(f)
withEffect(AnsiCodes.foreground.default)(f)

def black[A](f: F ?=> A): A =
withEffect("30")(f)
withEffect(AnsiCodes.foreground.black)(f)

def red[A](f: F ?=> A): A =
withEffect("31")(f)
withEffect(AnsiCodes.foreground.red)(f)

def green[A](f: F ?=> A): A =
withEffect("32")(f)
withEffect(AnsiCodes.foreground.green)(f)

def yellow[A](f: F ?=> A): A =
withEffect("33")(f)
withEffect(AnsiCodes.foreground.yellow)(f)

def blue[A](f: F ?=> A): A =
withEffect("34")(f)
withEffect(AnsiCodes.foreground.blue)(f)

def magenta[A](f: F ?=> A): A =
withEffect("35")(f)
withEffect(AnsiCodes.foreground.magenta)(f)

def cyan[A](f: F ?=> A): A =
withEffect("36")(f)
withEffect(AnsiCodes.foreground.cyan)(f)

def white[A](f: F ?=> A): A =
withEffect("37")(f)
withEffect(AnsiCodes.foreground.white)(f)

def brightBlack[A](f: F ?=> A): A =
withEffect("90")(f)
withEffect(AnsiCodes.foreground.brightBlack)(f)

def brightRed[A](f: F ?=> A): A =
withEffect("91")(f)
withEffect(AnsiCodes.foreground.brightRed)(f)

def brightGreen[A](f: F ?=> A): A =
withEffect("92")(f)
withEffect(AnsiCodes.foreground.brightGreen)(f)

def brightYellow[A](f: F ?=> A): A =
withEffect("93")(f)
withEffect(AnsiCodes.foreground.brightYellow)(f)

def brightBlue[A](f: F ?=> A): A =
withEffect("94")(f)
withEffect(AnsiCodes.foreground.brightBlue)(f)
def brightMagenta[A](f: F ?=> A): A =
withEffect("95")(f)
withEffect(AnsiCodes.foreground.brightMagenta)(f)

def brightCyan[A](f: F ?=> A): A =
withEffect("96")(f)
withEffect(AnsiCodes.foreground.brightCyan)(f)

def brightWhite[A](f: F ?=> A): A =
withEffect("97")(f)
withEffect(AnsiCodes.foreground.brightWhite)(f)
}

object background {
def default[A](f: F ?=> A): A =
withEffect("49")(f)
withEffect(AnsiCodes.background.default)(f)

def black[A](f: F ?=> A): A =
withEffect("40")(f)
withEffect(AnsiCodes.background.black)(f)

def red[A](f: F ?=> A): A =
withEffect("41")(f)
withEffect(AnsiCodes.background.red)(f)

def green[A](f: F ?=> A): A =
withEffect("42")(f)
withEffect(AnsiCodes.background.green)(f)

def yellow[A](f: F ?=> A): A =
withEffect("43")(f)
withEffect(AnsiCodes.background.yellow)(f)

def blue[A](f: F ?=> A): A =
withEffect("44")(f)
withEffect(AnsiCodes.background.blue)(f)

def magenta[A](f: F ?=> A): A =
withEffect("45")(f)
withEffect(AnsiCodes.background.magenta)(f)

def cyan[A](f: F ?=> A): A =
withEffect("46")(f)
withEffect(AnsiCodes.background.cyan)(f)

def white[A](f: F ?=> A): A =
withEffect("47")(f)
withEffect(AnsiCodes.background.white)(f)

def brightBlack[A](f: F ?=> A): A =
withEffect("100")(f)
withEffect(AnsiCodes.background.brightBlack)(f)

def brightRed[A](f: F ?=> A): A =
withEffect("101")(f)
withEffect(AnsiCodes.background.brightRed)(f)

def brightGreen[A](f: F ?=> A): A =
withEffect("102")(f)
withEffect(AnsiCodes.background.brightGreen)(f)

def brightYellow[A](f: F ?=> A): A =
withEffect("103")(f)
withEffect(AnsiCodes.background.brightYellow)(f)

def brightBlue[A](f: F ?=> A): A =
withEffect("104")(f)
withEffect(AnsiCodes.background.brightBlue)(f)

def brightMagenta[A](f: F ?=> A): A =
withEffect("105")(f)
withEffect(AnsiCodes.background.brightMagenta)(f)

def brightCyan[A](f: F ?=> A): A =
withEffect("106")(f)
withEffect(AnsiCodes.background.brightCyan)(f)

def brightWhite[A](f: F ?=> A): A =
withEffect("107")(f)
withEffect(AnsiCodes.background.brightWhite)(f)
}
}
Loading

0 comments on commit 92ccb56

Please sign in to comment.