diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33667df..2501d66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: timeout-minutes: 60 steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -45,7 +45,7 @@ jobs: - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -83,15 +83,15 @@ 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 examples/js/target core/jvm/target examples/jvm/target project/target + run: mkdir -p ui/js/target unidocs/target core/native/target core/js/target ui/jvm/target examples/js/target core/jvm/target examples/jvm/target ui/native/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 examples/js/target core/jvm/target examples/jvm/target project/target + run: tar cf targets.tar ui/js/target unidocs/target core/native/target core/js/target ui/jvm/target examples/js/target core/jvm/target examples/jvm/target ui/native/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }}-${{ matrix.project }} path: targets.tar @@ -107,7 +107,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -117,7 +117,7 @@ jobs: - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -128,7 +128,7 @@ jobs: run: sbt +update - name: Download target directories (3, rootJS) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJS @@ -138,7 +138,7 @@ jobs: rm targets.tar - name: Download target directories (3, rootJVM) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJVM @@ -148,7 +148,7 @@ jobs: rm targets.tar - name: Download target directories (3, rootNative) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootNative @@ -191,7 +191,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -201,7 +201,7 @@ jobs: - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -226,7 +226,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -236,7 +236,7 @@ jobs: - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 diff --git a/.gitignore b/.gitignore index fd4b1fc..f32f502 100644 --- a/.gitignore +++ b/.gitignore @@ -144,5 +144,6 @@ local.properties .bloop project/metals.sbt .vscode +.scala-build .sbt-hydra-history diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..2b63e3b --- /dev/null +++ b/.sbtopts @@ -0,0 +1 @@ +-J-Xmx4G diff --git a/.scalafmt.conf b/.scalafmt.conf index 352c57d..167fb68 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,3 +1,4 @@ -version = "3.8.1" +version = "3.11.0" runner.dialect = scala3 rewrite.scala3.convertToNewSyntax = true +rewrite.scala3.optionalBraces.enabled = true diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b2c7b95 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,21 @@ +# Terminus + +Terminus is a Scala terminal toolkit, cross-platform across JVM, JS, and Scala Native. It is split into two main modules: + +- **`core/`** — low-level effect traits and ANSI escape code primitives +- **`ui/`** — cell buffer–based terminal UI toolkit built on top of core + +## Notes + +Design notes and architectural decisions are kept in `notes/`: + +- [`notes/ui-architecture.md`](notes/ui-architecture.md) — cell buffer model, component/layout system, effect layer, wide character support +- [`notes/styling-design.md`](notes/styling-design.md) — agreed design direction for styling (style split, no cascade, CSS box model, separators) + +## Build + +``` +sbt compile +sbt test +sbt 'uiNative/runMain terminus.ui.demo' +``` diff --git a/build.sbt b/build.sbt index 6da3f96..d705ded 100644 --- a/build.sbt +++ b/build.sbt @@ -20,7 +20,7 @@ import laika.config.ApiLinks import laika.theme.Theme import laika.helium.config.TextLink -ThisBuild / tlBaseVersion := "0.4" // your current series x.y +ThisBuild / tlBaseVersion := "0.5" // your current series x.y Global / onChangedBuildSource := ReloadOnSourceChanges @@ -33,8 +33,6 @@ ThisBuild / developers := List( tlGitHubDev("noelwelsh", "Noel Welsh") ) -ThisBuild / sonatypeCredentialHost := xerial.sbt.Sonatype.sonatypeLegacy - lazy val scala3 = "3.3.4" ThisBuild / crossScalaVersions := List(scala3) @@ -61,10 +59,25 @@ commands += Command.command("build") { state => state } +// Dependencies + +val catsCore = Def.setting("org.typelevel" %%% "cats-core" % "2.13.0") + +val jline = Def.setting("org.jline" % "jline" % "4.0.12") + +val scalajsDom = Def.setting("org.scala-js" %%% "scalajs-dom" % "2.8.1") + +val munitVersion = "1.3.0" +val munit = Def.setting("org.scalameta" %%% "munit" % munitVersion % "test") +val munitScalaCheck = + Def.setting("org.scalameta" %%% "munit-scalacheck" % munitVersion % "test") + +// Projects and Settings + lazy val commonSettings = Seq( libraryDependencies ++= Seq( - Dependencies.munit.value, - Dependencies.munitScalaCheck.value + munit.value, + munitScalaCheck.value ), startYear := Some(2024), licenses := List( @@ -72,19 +85,25 @@ lazy val commonSettings = Seq( ) ) -lazy val root = tlCrossRootProject.aggregate(core, unidocs) +lazy val root = tlCrossRootProject.aggregate(core, ui, unidocs) lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) .in(file("core")) .settings( commonSettings, - libraryDependencies ++= Seq( - Dependencies.catsCore.value - ), + libraryDependencies ++= Seq(catsCore.value), name := "terminus-core" ) - .jvmSettings(libraryDependencies += Dependencies.jline.value) - .jsSettings(libraryDependencies += Dependencies.scalajsDom.value) + .jvmSettings(libraryDependencies += jline.value) + .jsSettings(libraryDependencies += scalajsDom.value) + +lazy val ui = crossProject(JSPlatform, JVMPlatform, NativePlatform) + .in(file("ui")) + .settings( + name := "terminus-ui", + commonSettings + ) + .dependsOn(core) lazy val docs = project diff --git a/core/js/src/main/scala/terminus/Terminal.scala b/core/js/src/main/scala/terminus/Terminal.scala index ea9cb71..4bbeeae 100644 --- a/core/js/src/main/scala/terminus/Terminal.scala +++ b/core/js/src/main/scala/terminus/Terminal.scala @@ -24,12 +24,12 @@ import scala.concurrent.Future import scala.concurrent.Promise class Terminal(root: HTMLElement, options: XtermJsOptions) - extends effect.Color[Terminal], + extends effect.Color, effect.Cursor, - effect.Format[Terminal], + effect.Format, effect.Erase, effect.Dimensions, - effect.Writer { + effect.Writer: private val keyBuffer: mutable.ArrayDeque[Promise[String]] = new mutable.ArrayDeque[Promise[String]](8) @@ -42,12 +42,11 @@ class Terminal(root: HTMLElement, options: XtermJsOptions) } /** Block reading a Javascript keycode */ - def readKey(): Future[String] = { + def readKey(): Future[String] = val promise = Promise[String]() keyBuffer.append(promise) promise.future - } def flush(): Unit = () @@ -62,22 +61,18 @@ class Terminal(root: HTMLElement, options: XtermJsOptions) def setDimensions(dimensions: effect.TerminalDimensions): Unit = terminal.resize(dimensions.columns, dimensions.rows) -} type Program[A] = Terminal ?=> A -object Terminal extends Color, Cursor, Format, Erase, Dimensions, Writer { +object Terminal extends Color, Cursor, Format, Erase, Dimensions, Writer: def readKey(): Program[Future[String]] = terminal ?=> terminal.readKey() - def run[A](id: String, cols: Int = 80, rows: Int = 24)(f: Program[A]): A = { + def run[A](id: String, cols: Int = 80, rows: Int = 24)(f: Program[A]): A = val options = XtermJsOptions(cols, rows) run(dom.document.getElementById(id).asInstanceOf[HTMLElement], options)(f) - } def run[A](element: HTMLElement, options: XtermJsOptions)( f: Program[A] - ): A = { + ): A = val terminal = Terminal(element, options) f(using terminal) - } -} diff --git a/core/js/src/main/scala/terminus/XtermJsOptions.scala b/core/js/src/main/scala/terminus/XtermJsOptions.scala index e146eb8..43c0790 100644 --- a/core/js/src/main/scala/terminus/XtermJsOptions.scala +++ b/core/js/src/main/scala/terminus/XtermJsOptions.scala @@ -19,11 +19,9 @@ package terminus import scala.scalajs.js @js.native -trait XtermJsOptions extends js.Object { +trait XtermJsOptions extends js.Object: val cols: Int = js.native val rows: Int = js.native -} -object XtermJsOptions { +object XtermJsOptions: def apply(cols: Int = 80, rows: Int = 24): XtermJsOptions = js.Dynamic.literal(cols = cols, rows = rows).asInstanceOf[XtermJsOptions] -} diff --git a/core/js/src/main/scala/terminus/XtermJsTerminal.scala b/core/js/src/main/scala/terminus/XtermJsTerminal.scala index ccca1fa..ae8bfca 100644 --- a/core/js/src/main/scala/terminus/XtermJsTerminal.scala +++ b/core/js/src/main/scala/terminus/XtermJsTerminal.scala @@ -24,15 +24,14 @@ import scala.scalajs.js import scala.scalajs.js.annotation.JSGlobal @js.native -trait XtermKeyEvent extends js.Object { +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 { +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 @@ -41,4 +40,3 @@ class XtermJsTerminal(@unused options: XtermJsOptions) extends js.Object { def cols: Int = js.native def rows: Int = js.native def resize(cols: Int, rows: Int): Unit = js.native -} diff --git a/core/jvm/src/main/scala/terminus/JLineTerminal.scala b/core/jvm/src/main/scala/terminus/JLineTerminal.scala index ced35a3..60e80f5 100644 --- a/core/jvm/src/main/scala/terminus/JLineTerminal.scala +++ b/core/jvm/src/main/scala/terminus/JLineTerminal.scala @@ -25,29 +25,26 @@ import terminus.effect.TerminalKeyReader import scala.concurrent.duration.Duration -class JLineTerminal(terminal: JTerminal) extends Terminal, TerminalKeyReader { +class JLineTerminal(terminal: JTerminal) extends Terminal, TerminalKeyReader: private val reader = terminal.reader() private val writer = terminal.writer() def peek(duration: Duration): Timeout | Eof | Char = - reader.peek(duration.toMillis) match { + reader.peek(duration.toMillis) match case -2 => Timeout case -1 => Eof case char => char.toChar - } def read(duration: Duration): Timeout | Eof | Char = - reader.read(duration.toMillis) match { + reader.read(duration.toMillis) match case -2 => Timeout case -1 => Eof case char => char.toChar - } def read(): Eof | Char = - reader.read() match { + reader.read() match case -1 => Eof case char => char.toChar - } def flush(): Unit = writer.flush() @@ -55,46 +52,37 @@ class JLineTerminal(terminal: JTerminal) extends Terminal, TerminalKeyReader { def write(string: String): Unit = writer.write(string) - def raw[A](f: Terminal ?=> A): A = { + def raw[A](f: () => A): A = val attrs = terminal.enterRawMode() - try { - val result = f(using this) + try + val result = f() result - } finally { - terminal.setAttributes(attrs) - } - } + finally terminal.setAttributes(attrs) - def getDimensions: effect.TerminalDimensions = { + def getDimensions: effect.TerminalDimensions = val size = terminal.getSize TerminalDimensions(size.getColumns, size.getRows) - } def setDimensions(dimensions: TerminalDimensions): Unit = terminal.setSize(Size(dimensions.columns, dimensions.rows)) - def application[A](f: Terminal ?=> A): A = { - try { + def application[A](f: () => A): A = + try terminal.puts(Capability.keypad_xmit) - val result = f(using this) + val result = f() result - } finally { + finally val _ = terminal.puts(Capability.keypad_local) - } - } - def alternateScreen[A](f: Terminal ?=> A): A = { - try { + def alternateScreen[A](f: () => A): A = + try terminal.puts(Capability.enter_ca_mode) - val result = f(using this) + val result = f() result - } finally { + finally val _ = terminal.puts(Capability.exit_ca_mode) - } - } def close(): Unit = terminal.close() -} object JLineTerminal extends Color, Cursor, @@ -105,16 +93,14 @@ object JLineTerminal ApplicationMode, RawMode, Reader, - Writer { + Writer: def apply: JLineTerminal = new JLineTerminal( TerminalBuilder.builder().build() ) - def run[A](f: Program[A]): A = { + def run[A](f: Program[A]): A = val terminal = Terminal.apply val result = f(using terminal) terminal.close() result - } -} diff --git a/core/jvm/src/main/scala/terminus/Prompt.scala b/core/jvm/src/main/scala/terminus/Prompt.scala index 1f854b2..d3c53d2 100644 --- a/core/jvm/src/main/scala/terminus/Prompt.scala +++ b/core/jvm/src/main/scala/terminus/Prompt.scala @@ -18,7 +18,7 @@ package terminus import terminus.example.Prompt -@main def prompt(): Unit = { +@main def prompt(): Unit = val idx = Terminal.run( Terminal.raw { @@ -27,4 +27,3 @@ import terminus.example.Prompt ) println(s"Selected $idx") -} diff --git a/core/jvm/src/main/scala/terminus/Terminal.scala b/core/jvm/src/main/scala/terminus/Terminal.scala index e107875..e9f5a7e 100644 --- a/core/jvm/src/main/scala/terminus/Terminal.scala +++ b/core/jvm/src/main/scala/terminus/Terminal.scala @@ -17,17 +17,17 @@ package terminus trait Terminal - extends effect.AlternateScreenMode[Terminal], - effect.ApplicationMode[Terminal], - effect.Color[Terminal], + extends effect.AlternateScreenMode, + effect.ApplicationMode, + effect.Color, effect.Cursor, - effect.Format[Terminal], + effect.Format, effect.Dimensions, effect.Erase, effect.KeyReader, effect.NonBlockingReader, effect.Peeker, - effect.RawMode[Terminal], + effect.RawMode, effect.Reader, effect.Writer type Program[A] = Terminal ?=> A @@ -45,6 +45,5 @@ object Terminal Peeker, RawMode, Reader, - Writer { + Writer: export JLineTerminal.* -} diff --git a/core/jvm/src/test/scala/terminus/effect/DimensionsSuite.scala b/core/jvm/src/test/scala/terminus/effect/DimensionsSuite.scala index 1130868..098ec0c 100644 --- a/core/jvm/src/test/scala/terminus/effect/DimensionsSuite.scala +++ b/core/jvm/src/test/scala/terminus/effect/DimensionsSuite.scala @@ -19,7 +19,7 @@ package terminus.effect import munit.FunSuite import terminus.JLineTerminal -class DimensionsSuite extends FunSuite { +class DimensionsSuite extends FunSuite: test("Should set and get the dimensions of the current terminal size") { @@ -32,4 +32,3 @@ class DimensionsSuite extends FunSuite { assertEquals(outputDimensions, userInputDimensions) } -} diff --git a/core/native/src/main/scala/terminus/NativeTerminal.scala b/core/native/src/main/scala/terminus/NativeTerminal.scala index d2914bf..33a507f 100644 --- a/core/native/src/main/scala/terminus/NativeTerminal.scala +++ b/core/native/src/main/scala/terminus/NativeTerminal.scala @@ -24,15 +24,12 @@ import scala.concurrent.duration.Duration import scala.scalanative.libc import scala.scalanative.meta.LinktimeInfo import scala.scalanative.posix -import scala.scalanative.unsigned.UInt +import scala.scalanative.unsigned.* import scalanative.unsafe.* /** A Terminal implementation for Scala Native. */ -object NativeTerminal - extends Terminal, - WithEffect[Terminal], - TerminalKeyReader { +object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader: private given termiosAccess: TermiosAccess[?] = if LinktimeInfo.isMac then clongTermiosAccess @@ -51,29 +48,27 @@ object NativeTerminal private val termios = Termios(using termiosAccess) - def run[A](f: Program[A]): A = { + def run[A](f: Program[A]): A = val result = f(using this) result - } - def read(): Eof | Char = { + def read(): Eof | Char = val buf: Ptr[Byte] = stackalloc[Byte]() val count = posix.unistd.read(posix.unistd.STDIN_FILENO, buf, UInt.valueOf(1)) if count == 0 then Eof else (!buf).toChar - } - def read(duration: Duration): Timeout | Eof | Char = { + def read(duration: Duration): Timeout | Eof | Char = Zone { val origAttrs = termios.getAttributes() val attrs = termios.getAttributes() - try { - attrs.setSpecialCharacter(posix.termios.VMIN, 0) + try + attrs.setSpecialCharacter(posix.termios.VMIN, 0.toUByte) attrs.setSpecialCharacter( posix.termios.VTIME, - (duration.toMillis / 100).toByte + (duration.toMillis / 100).toUByte ) termios.setAttributes(attrs) @@ -83,54 +78,42 @@ object NativeTerminal if count == 0 then Timeout else if count == -1 then Eof - else { - (!buf).toChar - } + else (!buf).toChar - } finally { - termios.setAttributes(origAttrs) - } + finally termios.setAttributes(origAttrs) } - } - def flush(): Unit = { - val _ = libc.stdio.fflush(libc.stdio.stdin) + def flush(): Unit = + val _ = libc.stdio.fflush(libc.stdio.stdout) () - } - def write(char: Char): Unit = { - val _ = libc.stdio.fputc(char, libc.stdio.stdout) - () - } + def write(char: Char): Unit = + // Writing wide characters cannot be done with fputc. We either need to add + // support for fputwc or we use the method to write a String. Using the + // method to write a String is simpler for now. + write(char.toString) - def write(string: String): Unit = { + def write(string: String): Unit = // print(string) Zone { val _ = libc.stdio.fputs(toCString(string), libc.stdio.stdout) () } - } - def application[A](f: (terminus.Terminal) ?=> A): A = { + def application[A](f: () => A): A = withEffect(AnsiCodes.mode.application.on, AnsiCodes.mode.application.off)(f) - } - def alternateScreen[A](f: (terminus.Terminal) ?=> A): A = { + def alternateScreen[A](f: () => A): A = withEffect( AnsiCodes.mode.alternateScreen.on, AnsiCodes.mode.alternateScreen.off )(f) - } - def raw[A](f: Terminal ?=> A): A = { + def raw[A](f: () => A): A = Zone { val origAttrs = termios.getAttributes() - try { + try termios.setRawMode() - f(using this) - } finally { - termios.setAttributes(origAttrs) - } + f() + finally termios.setAttributes(origAttrs) } - } -} diff --git a/core/native/src/main/scala/terminus/Prompt.scala b/core/native/src/main/scala/terminus/Prompt.scala index ffd2dbc..eaaa3f3 100644 --- a/core/native/src/main/scala/terminus/Prompt.scala +++ b/core/native/src/main/scala/terminus/Prompt.scala @@ -18,7 +18,7 @@ package terminus import terminus.example.Prompt -@main def prompt(): Unit = { +@main def prompt(): Unit = val idx = Terminal.run { Terminal.raw { Prompt[Terminal](Terminal).loop(0) @@ -26,4 +26,3 @@ import terminus.example.Prompt } println(s"Selected $idx") -} diff --git a/core/native/src/main/scala/terminus/Terminal.scala b/core/native/src/main/scala/terminus/Terminal.scala index 083200e..b67763f 100644 --- a/core/native/src/main/scala/terminus/Terminal.scala +++ b/core/native/src/main/scala/terminus/Terminal.scala @@ -17,15 +17,15 @@ package terminus trait Terminal - extends effect.AlternateScreenMode[Terminal], - effect.ApplicationMode[Terminal], - effect.Color[Terminal], + extends effect.AlternateScreenMode, + effect.ApplicationMode, + effect.Color, effect.Cursor, - effect.Format[Terminal], + effect.Format, effect.Erase, effect.KeyReader, effect.NonBlockingReader, - effect.RawMode[Terminal], + effect.RawMode, effect.Reader, effect.Writer type Program[A] = Terminal ?=> A @@ -42,12 +42,10 @@ object Terminal Peeker, RawMode, Reader, - Writer { + Writer: - def run[A](f: Program[A]): A = { + def run[A](f: Program[A]): A = val terminal = NativeTerminal val result = f(using terminal) result - } -} diff --git a/core/native/src/main/scala/terminus/Termios.scala b/core/native/src/main/scala/terminus/Termios.scala index 7f18df6..dfcb5fb 100644 --- a/core/native/src/main/scala/terminus/Termios.scala +++ b/core/native/src/main/scala/terminus/Termios.scala @@ -37,7 +37,7 @@ import scala.scalanative.unsafe.Zone * The termios structure type a Termios instance uses. See [[TermiosStruct]] * for more details */ -class Termios[T](using accessor: TermiosAccess[T]) { +class Termios[T](using accessor: TermiosAccess[T]): /** Allocates a new termios structure defined by [[this.T]], in the given * [[Zone]] and copies the current terminal settings into it, returning a @@ -61,4 +61,3 @@ class Termios[T](using accessor: TermiosAccess[T]) { attrs.removeLocalFlags(posix.termios.ECHO | posix.termios.ICANON) accessor.set(attrs) } -} diff --git a/core/native/src/main/scala/terminus/TermiosAccess.scala b/core/native/src/main/scala/terminus/TermiosAccess.scala index 4bd5b51..b83e063 100644 --- a/core/native/src/main/scala/terminus/TermiosAccess.scala +++ b/core/native/src/main/scala/terminus/TermiosAccess.scala @@ -20,6 +20,7 @@ import scala.annotation.implicitNotFound import scala.scalanative.posix import scala.scalanative.posix.unistd.STDIN_FILENO import scala.scalanative.unsafe.* +import scala.scalanative.unsigned.* /** Typeclass for handling the messiness of reading, updating, and writing * termios structs in a platform specific way. @@ -44,7 +45,7 @@ import scala.scalanative.unsafe.* @implicitNotFound( "There is no terminus.TermiosAccess instance defined for the given termios struct." ) -trait TermiosAccess[T] { +trait TermiosAccess[T]: /** The flag value used by the termios struct the typeclass is defined for, is * either a CInt or CLong @@ -89,15 +90,20 @@ trait TermiosAccess[T] { /** Remove flags from termios c_lflag struct member */ def removeLocalFlags(attrs: Ptr[T], flags: CInt): Unit + /** Get the value at the given index of the termios c_cc struct member. Valid + * indices are defined as constants such as `VMIN` and `VTIME` in + * [[scala.scalanative.posix.termios]] + */ + def getSpecialCharacter(attrs: Ptr[T], idx: CInt): UByte + /** Set the value at the given index of the termios c_cc struct member. Valid * indices are defined as constants such as `VMIN` and `VTIME` in * [[scala.scalanative.posix.termios]] */ - def setSpecialCharacter(attrs: Ptr[T], idx: CInt, value: CChar): Unit -} + def setSpecialCharacter(attrs: Ptr[T], idx: CInt, value: UByte): Unit /** Extension methods to simplify modifying a termios struct */ -extension [T](ptr: Ptr[T])(using au: TermiosAccess[T]) { +extension [T](ptr: Ptr[T])(using au: TermiosAccess[T]) def addInputFlags(flags: CInt): Unit = au.addInputFlags(ptr, flags) def removeInputFlags(flags: CInt): Unit = au.removeInputFlags(ptr, flags) def addOutputFlags(flags: CInt): Unit = au.addOutputFlags(ptr, flags) @@ -106,99 +112,102 @@ extension [T](ptr: Ptr[T])(using au: TermiosAccess[T]) { def removeControlFlags(flags: CInt): Unit = au.removeControlFlags(ptr, flags) def addLocalFlags(flags: CInt): Unit = au.addLocalFlags(ptr, flags) def removeLocalFlags(flags: CInt): Unit = au.removeLocalFlags(ptr, flags) - def setSpecialCharacter(idx: CInt, value: CChar) = + def getSpecialCharacter(idx: CInt): UByte = + au.getSpecialCharacter(ptr, idx) + def setSpecialCharacter(idx: CInt, value: UByte) = au.setSpecialCharacter(ptr, idx, value) -} /** [[TermiosAccess]] instance for structs with CLong bitflags */ given clongTermiosAccess: TermiosAccess[TermiosStruct.clong_flags] = - new TermiosAccess[TermiosStruct.clong_flags] { + import posix.termiosOps.* + + new TermiosAccess[TermiosStruct.clong_flags]: override type FlagType = CLong - override def get(using Zone): Ptr[TermiosStruct.clong_flags] = { + override def get(using Zone): Ptr[TermiosStruct.clong_flags] = val attrs: Ptr[TermiosStruct.clong_flags] = alloc[TermiosStruct.clong_flags]() posix.termios.tcgetattr(STDIN_FILENO, attrs) attrs - } - override def set(ptr: Ptr[TermiosStruct.clong_flags]): Unit = { + override def set(ptr: Ptr[TermiosStruct.clong_flags]): Unit = val _ = - posix.termios.tcsetattr(STDIN_FILENO, posix.termios.TCSAFLUSH, ptr) + posix.termios.tcsetattr(STDIN_FILENO, posix.termios.TCSANOW, ptr) () - } override def addInputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._1 = attrs._1 | flags + attrs.c_iflag = attrs.c_iflag | flags.toUInt override def removeInputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._1 = attrs._1 & ~flags + attrs.c_iflag = attrs.c_iflag & ~(flags.toUInt) override def addOutputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._2 = attrs._2 | flags + attrs.c_oflag = attrs.c_oflag | flags.toUInt override def removeOutputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._2 = attrs._2 & ~flags + attrs.c_oflag = attrs.c_oflag & ~(flags.toUInt) override def addControlFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._3 = attrs._3 | flags + attrs.c_cflag = attrs.c_cflag | flags.toUInt override def removeControlFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._3 = attrs._3 & ~flags + attrs.c_cflag = attrs.c_cflag & ~(flags.toUInt) override def addLocalFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._4 = attrs._4 | flags + attrs.c_lflag = attrs.c_lflag | flags.toUInt override def removeLocalFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._4 = attrs._4 & ~flags + attrs.c_lflag = attrs.c_lflag & ~(flags.toUInt) + + override def getSpecialCharacter( + attrs: Ptr[TermiosStruct.clong_flags], + idx: CInt + ): UByte = attrs.c_cc(idx) override def setSpecialCharacter( attrs: Ptr[TermiosStruct.clong_flags], idx: CInt, - value: CChar - ): Unit = attrs._5(idx) = value - } + value: UByte + ): Unit = attrs.c_cc(idx) = value /** [[TermiosAccess]] instance for structs with CInt bitflags */ given cintTermiosAccess: TermiosAccess[TermiosStruct.cint_flags] = - new TermiosAccess[TermiosStruct.cint_flags] { + new TermiosAccess[TermiosStruct.cint_flags]: override type FlagType = CInt - override def get(using Zone): Ptr[TermiosStruct.cint_flags] = { + override def get(using Zone): Ptr[TermiosStruct.cint_flags] = val attrs: Ptr[TermiosStruct.cint_flags] = alloc[TermiosStruct.cint_flags]() tcgetattr(STDIN_FILENO, attrs) attrs - } - override def set(ptr: Ptr[TermiosStruct.cint_flags]): Unit = { - val _ = tcsetattr(STDIN_FILENO, posix.termios.TCSAFLUSH, ptr) + override def set(ptr: Ptr[TermiosStruct.cint_flags]): Unit = + val _ = tcsetattr(STDIN_FILENO, posix.termios.TCSANOW, ptr) () - } override def addInputFlags( attrs: Ptr[TermiosStruct.cint_flags], @@ -246,11 +255,18 @@ given cintTermiosAccess: TermiosAccess[TermiosStruct.cint_flags] = flags: CInt ): Unit = attrs._4 = attrs._4 & ~flags + override def getSpecialCharacter( + attrs: Ptr[TermiosStruct.cint_flags], + idx: CInt + ): UByte = + val c_cc = attrs._5 + c_cc(idx) + override def setSpecialCharacter( attrs: Ptr[TermiosStruct.cint_flags], idx: CInt, - value: CChar - ): Unit = attrs._5(idx) = value + value: UByte + ): Unit = attrs._5(idx) = value.toUByte // Custom `tcgetattr` and `tcsetattr` definitions, since we can't use the ones defined in scala native since // they use CLong for bitflags. These should point to the functions defined in the systems termios library @@ -265,4 +281,3 @@ given cintTermiosAccess: TermiosAccess[TermiosStruct.cint_flags] = optionalActions: CInt, termios_p: Ptr[TermiosStruct.cint_flags] ): CInt = extern - } diff --git a/core/native/src/main/scala/terminus/TermiosStruct.scala b/core/native/src/main/scala/terminus/TermiosStruct.scala index 911509b..e654622 100644 --- a/core/native/src/main/scala/terminus/TermiosStruct.scala +++ b/core/native/src/main/scala/terminus/TermiosStruct.scala @@ -33,7 +33,7 @@ import scala.scalanative.unsafe.CStruct7 * @see * [[https://github.com/scala-native/scala-native/issues/4143 Scala Native termios linux issue]] */ -object TermiosStruct { +object TermiosStruct: // Custom flag types using CInt private type linux_tcflag_t = CInt private type linux_speed_t = CInt @@ -44,7 +44,7 @@ object TermiosStruct { linux_tcflag_t, /* c_oflag - output flags */ linux_tcflag_t, /* c_cflag - control flags */ linux_tcflag_t, /* c_lflag - local flags */ - posix.termios.c_cc, /* cc_t c_cc[NCCS] - control chars */ + posix.termios.cc_t_arr, /* cc_t c_cc[NCCS] - control chars */ linux_speed_t, /* c_ispeed - input speed */ linux_speed_t /* c_ospeed - output speed */ ] @@ -53,4 +53,3 @@ object TermiosStruct { * CLong sized bitflags */ type clong_flags = posix.termios.termios -} diff --git a/core/native/src/test/scala/terminus/TermiosAccessSuite.scala b/core/native/src/test/scala/terminus/TermiosAccessSuite.scala new file mode 100644 index 0000000..1099b39 --- /dev/null +++ b/core/native/src/test/scala/terminus/TermiosAccessSuite.scala @@ -0,0 +1,68 @@ +/* + * 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 munit.FunSuite + +import scala.scalanative.posix +import scala.scalanative.unsafe.CInt +import scala.scalanative.unsafe.Ptr +import scala.scalanative.unsafe.Zone +import scala.scalanative.unsigned.* + +class CLongTermiosAccessSuite extends FunSuite: + val termios = clongTermiosAccess + + def testSettingSpecialCharacter( + attrs: Ptr[TermiosStruct.clong_flags], + idx: CInt, + value: UByte, + name: String + )(using Zone): Unit = + attrs.setSpecialCharacter(idx, value) + termios.set(attrs) + val updated = termios.get + val read = updated.getSpecialCharacter(idx) + assertEquals(read, value, name) + + test("Test reading special characters returns set value") { + Zone { + val orig = termios.get + try + val attrs = termios.get + + List( + (posix.termios.VEOF, "VEOF"), + (posix.termios.VEOL, "VEOL"), + (posix.termios.VERASE, "VERASE"), + (posix.termios.VINTR, "VINTR"), + (posix.termios.VKILL, "VKILL"), + (posix.termios.VMIN, "VMIN"), + (posix.termios.VQUIT, "VQUIT"), + (posix.termios.VSTART, "VSTART"), + (posix.termios.VSTOP, "VSTOP"), + (posix.termios.VSUSP, "VSUSP"), + (posix.termios.VTIME, "VTIME") + ).foreach { (idx, name) => + testSettingSpecialCharacter(attrs, idx, 1.toUByte, name) + testSettingSpecialCharacter(attrs, idx, 2.toUByte, name) + testSettingSpecialCharacter(attrs, idx, 3.toUByte, name) + testSettingSpecialCharacter(attrs, idx, 4.toUByte, name) + } + finally termios.set(orig) + } + } diff --git a/core/shared/src/main/scala/terminus/AlternateScreenMode.scala b/core/shared/src/main/scala/terminus/AlternateScreenMode.scala index 1ed5e1f..0d58125 100644 --- a/core/shared/src/main/scala/terminus/AlternateScreenMode.scala +++ b/core/shared/src/main/scala/terminus/AlternateScreenMode.scala @@ -16,7 +16,7 @@ package terminus -trait AlternateScreenMode { +trait AlternateScreenMode: /** Run the given terminal program `f` in alternate screen mode, which means * that whatever is displayed by `f` will not been shown when the program @@ -24,6 +24,5 @@ trait AlternateScreenMode { */ def alternateScreen[F <: effect.Effect, A]( f: F ?=> A - ): (F & effect.AlternateScreenMode[F]) ?=> A = - effect ?=> effect.alternateScreen(f) -} + ): (F & effect.AlternateScreenMode) ?=> A = + effect ?=> effect.alternateScreen(() => f(using effect)) diff --git a/core/shared/src/main/scala/terminus/ApplicationMode.scala b/core/shared/src/main/scala/terminus/ApplicationMode.scala index 05cd353..0777f40 100644 --- a/core/shared/src/main/scala/terminus/ApplicationMode.scala +++ b/core/shared/src/main/scala/terminus/ApplicationMode.scala @@ -16,7 +16,7 @@ package terminus -trait ApplicationMode { +trait ApplicationMode: /** Run the given terminal program `f` in application mode, which changes the * input sent to the program when arrow keys are pressed. See @@ -24,6 +24,5 @@ trait ApplicationMode { */ def application[F <: effect.Effect, A]( f: F ?=> A - ): (F & effect.ApplicationMode[F]) ?=> A = - effect ?=> effect.application(f) -} + ): (F & effect.ApplicationMode) ?=> A = + effect ?=> effect.application(() => f(using effect)) diff --git a/core/shared/src/main/scala/terminus/Color.scala b/core/shared/src/main/scala/terminus/Color.scala index 2862ca5..9aa8d39 100644 --- a/core/shared/src/main/scala/terminus/Color.scala +++ b/core/shared/src/main/scala/terminus/Color.scala @@ -16,178 +16,175 @@ package terminus -trait Color { - object foreground { - def default[F <: effect.Writer, A]( +trait Color: + object foreground: + def default[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.default(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.default(() => f(using effect)) - def black[F <: effect.Writer, A]( + def black[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.black(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.black(() => f(using effect)) - def red[F <: effect.Writer, A]( + def red[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.red(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.red(() => f(using effect)) - def green[F <: effect.Writer, A]( + def green[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.green(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.green(() => f(using effect)) - def yellow[F <: effect.Writer, A]( + def yellow[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.yellow(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.yellow(() => f(using effect)) - def blue[F <: effect.Writer, A]( + def blue[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.blue(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.blue(() => f(using effect)) - def magenta[F <: effect.Writer, A]( + def magenta[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.magenta(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.magenta(() => f(using effect)) - def cyan[F <: effect.Writer, A]( + def cyan[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.cyan(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.cyan(() => f(using effect)) - def white[F <: effect.Writer, A]( + def white[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.white(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.white(() => f(using effect)) - def brightBlack[F <: effect.Writer, A]( + def brightBlack[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.brightBlack(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.brightBlack(() => f(using effect)) - def brightRed[F <: effect.Writer, A]( + def brightRed[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.brightRed(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.brightRed(() => f(using effect)) - def brightGreen[F <: effect.Writer, A]( + def brightGreen[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.brightGreen(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.brightGreen(() => f(using effect)) - def brightYellow[F <: effect.Writer, A]( + def brightYellow[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.brightYellow(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.brightYellow(() => f(using effect)) - def brightBlue[F <: effect.Writer, A]( + def brightBlue[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.brightBlue(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.brightBlue(() => f(using effect)) - def brightMagenta[F <: effect.Writer, A]( + def brightMagenta[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.brightMagenta(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.brightMagenta(() => f(using effect)) - def brightCyan[F <: effect.Writer, A]( + def brightCyan[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.brightCyan(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.brightCyan(() => f(using effect)) - def brightWhite[F <: effect.Writer, A]( + def brightWhite[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.foreground.brightWhite(f) - } + ): (F & effect.Color) ?=> A = + effect ?=> effect.foreground.brightWhite(() => f(using effect)) - object background { - def default[F <: effect.Writer, A]( + object background: + def default[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.default(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.default(() => f(using effect)) - def black[F <: effect.Writer, A]( + def black[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.black(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.black(() => f(using effect)) - def red[F <: effect.Writer, A]( + def red[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.red(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.red(() => f(using effect)) - def green[F <: effect.Writer, A]( + def green[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.green(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.green(() => f(using effect)) - def yellow[F <: effect.Writer, A]( + def yellow[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.yellow(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.yellow(() => f(using effect)) - def blue[F <: effect.Writer, A]( + def blue[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.blue(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.blue(() => f(using effect)) - def magenta[F <: effect.Writer, A]( + def magenta[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.magenta(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.magenta(() => f(using effect)) - def cyan[F <: effect.Writer, A]( + def cyan[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.cyan(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.cyan(() => f(using effect)) - def white[F <: effect.Writer, A]( + def white[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.white(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.white(() => f(using effect)) - def brightBlack[F <: effect.Writer, A]( + def brightBlack[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.brightBlack(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.brightBlack(() => f(using effect)) - def brightRed[F <: effect.Writer, A]( + def brightRed[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.brightRed(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.brightRed(() => f(using effect)) - def brightGreen[F <: effect.Writer, A]( + def brightGreen[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.brightGreen(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.brightGreen(() => f(using effect)) - def brightYellow[F <: effect.Writer, A]( + def brightYellow[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.brightYellow(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.brightYellow(() => f(using effect)) - def brightBlue[F <: effect.Writer, A]( + def brightBlue[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.brightBlue(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.brightBlue(() => f(using effect)) - def brightMagenta[F <: effect.Writer, A]( + def brightMagenta[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.brightMagenta(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.brightMagenta(() => f(using effect)) - def brightCyan[F <: effect.Writer, A]( + def brightCyan[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.brightCyan(f) + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.brightCyan(() => f(using effect)) - def brightWhite[F <: effect.Writer, A]( + def brightWhite[F, A]( f: F ?=> A - ): (F & effect.Color[F]) ?=> A = - effect ?=> effect.background.brightWhite(f) - } -} + ): (F & effect.Color) ?=> A = + effect ?=> effect.background.brightWhite(() => f(using effect)) diff --git a/core/shared/src/main/scala/terminus/Cursor.scala b/core/shared/src/main/scala/terminus/Cursor.scala index 6ea6a29..6bea320 100644 --- a/core/shared/src/main/scala/terminus/Cursor.scala +++ b/core/shared/src/main/scala/terminus/Cursor.scala @@ -16,8 +16,21 @@ package terminus -trait Cursor { - object cursor { +trait Cursor: + object cursor: + + /** Run the given program `f` with the cursor hidden, restoring cursor + * visibility when `f` completes. + */ + def hidden[F, A](f: F ?=> A): (F & effect.Cursor) ?=> A = + effect ?=> effect.cursor.hidden(() => f(using effect)) + + /** Run the given program `f` with the cursor visible, hiding the cursor + * again when `f` completes. Useful to show the cursor within a [[hidden]] + * block. + */ + def visible[F, A](f: F ?=> A): (F & effect.Cursor) ?=> A = + effect ?=> effect.cursor.visible(() => f(using effect)) /** Move cursor to given column. The left-most column is 1, and coordinates * increase to the right. @@ -56,5 +69,3 @@ trait Cursor { */ def left(columns: Int = 1): effect.Cursor ?=> Unit = effect ?=> effect.cursor.left(columns) - } -} diff --git a/core/shared/src/main/scala/terminus/Dimensions.scala b/core/shared/src/main/scala/terminus/Dimensions.scala index d76762b..d89a963 100644 --- a/core/shared/src/main/scala/terminus/Dimensions.scala +++ b/core/shared/src/main/scala/terminus/Dimensions.scala @@ -19,8 +19,8 @@ package terminus import terminus.effect.TerminalDimensions /** Functionalities related to the dimensions of the terminal */ -trait Dimensions { - object dimensions { +trait Dimensions: + object dimensions: def get: effect.Dimensions ?=> TerminalDimensions = effect ?=> effect.getDimensions @@ -28,5 +28,3 @@ trait Dimensions { dimensions: TerminalDimensions ): (F & effect.Dimensions) ?=> Unit = effect ?=> effect.setDimensions(dimensions) - } -} diff --git a/core/shared/src/main/scala/terminus/Erase.scala b/core/shared/src/main/scala/terminus/Erase.scala index 1eee4a2..4e18f09 100644 --- a/core/shared/src/main/scala/terminus/Erase.scala +++ b/core/shared/src/main/scala/terminus/Erase.scala @@ -16,9 +16,9 @@ package terminus -trait Erase { +trait Erase: - object erase { + object erase: /** Erase the entire screen and move the cursor to the top-left. */ def screen(): effect.Erase ?=> Unit = @@ -35,5 +35,3 @@ trait Erase { /** Erase the current line. */ def line(): effect.Erase ?=> Unit = effect ?=> effect.erase.line() - } -} diff --git a/core/shared/src/main/scala/terminus/Format.scala b/core/shared/src/main/scala/terminus/Format.scala index 61fbe41..2e625a0 100644 --- a/core/shared/src/main/scala/terminus/Format.scala +++ b/core/shared/src/main/scala/terminus/Format.scala @@ -16,116 +16,113 @@ package terminus -trait Format { - object format { - def bold[F <: effect.Writer, A](f: F ?=> A): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.bold(f) +trait Format: + object format: + def bold[F, A](f: F ?=> A): (F & effect.Format) ?=> A = + effect ?=> effect.format.bold(() => f(using effect)) - def light[F <: effect.Writer, A]( + def light[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.light(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.light(() => f(using effect)) - def normal[F <: effect.Writer, A]( + def normal[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.normal(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.normal(() => f(using effect)) - object underline { - def none[F <: effect.Writer, A]( + object underline: + def none[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.none(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.none(() => f(using effect)) - def straight[F <: effect.Writer, A]( + def straight[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.straight(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.straight(() => f(using effect)) - def double[F <: effect.Writer, A]( + def double[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.double(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.double(() => f(using effect)) - def curly[F <: effect.Writer, A]( + def curly[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.curly(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.curly(() => f(using effect)) - def dotted[F <: effect.Writer, A]( + def dotted[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.dotted(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.dotted(() => f(using effect)) - def dashed[F <: effect.Writer, A]( + def dashed[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.dashed(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.dashed(() => f(using effect)) - def default[F <: effect.Writer, A]( + def default[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.default(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.default(() => f(using effect)) - def black[F <: effect.Writer, A]( + def black[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.black(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.black(() => f(using effect)) - def red[F <: effect.Writer, A]( + def red[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.red(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.red(() => f(using effect)) - def green[F <: effect.Writer, A]( + def green[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.green(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.green(() => f(using effect)) - def yellow[F <: effect.Writer, A]( + def yellow[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.yellow(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.yellow(() => f(using effect)) - def blue[F <: effect.Writer, A]( + def blue[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.blue(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.blue(() => f(using effect)) - def magenta[F <: effect.Writer, A]( + def magenta[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.magenta(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.magenta(() => f(using effect)) - def cyan[F <: effect.Writer, A]( + def cyan[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.cyan(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.cyan(() => f(using effect)) - def white[F <: effect.Writer, A]( + def white[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.underline.white(f) - } + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.underline.white(() => f(using effect)) - def blink[F <: effect.Writer, A]( + def blink[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.blink(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.blink(() => f(using effect)) - def invert[F <: effect.Writer, A]( + def invert[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.invert(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.invert(() => f(using effect)) - def invisible[F <: effect.Writer, A]( + def invisible[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.invisible(f) + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.invisible(() => f(using effect)) - def strikethrough[F <: effect.Writer, A]( + def strikethrough[F, A]( f: F ?=> A - ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.strikethrough(f) - } -} + ): (F & effect.Format) ?=> A = + effect ?=> effect.format.strikethrough(() => f(using effect)) diff --git a/core/shared/src/main/scala/terminus/Key.scala b/core/shared/src/main/scala/terminus/Key.scala index c0a43f0..38784aa 100644 --- a/core/shared/src/main/scala/terminus/Key.scala +++ b/core/shared/src/main/scala/terminus/Key.scala @@ -27,7 +27,7 @@ import cats.Show * as constants on the companion object. */ final case class Key(modifiers: KeyModifier, code: KeyCode) -object Key { +object Key: /** Construct a Key from an unmodified Char */ def apply(char: Char): Key = @@ -196,7 +196,7 @@ object Key { val controlShiftPageUp = Key(KeyModifier.ControlShift, KeyCode.PageUp) val controlShiftPageDown = Key(KeyModifier.ControlShift, KeyCode.PageDown) - given Show[Key] = (key: Key) => { + given Show[Key] = (key: Key) => val modifiersStr = List( if key.modifiers.hasControl then Some("Control-") else None, if key.modifiers.hasShift then Some("Shift-") else None, @@ -206,7 +206,7 @@ object Key { if key.modifiers.hasMeta then Some("Meta-") else None ).flatten.mkString - val codeStr = key.code match { + val codeStr = key.code match case KeyCode.Character(' ') => "Space" case KeyCode.Character('\t') => "Tab" case KeyCode.Character('\n') => "Newline" @@ -214,8 +214,5 @@ object Key { case KeyCode.F(value) => s"F$value" case KeyCode.Unknown(code) => s"Unknown($code)" case other => other.toString - } modifiersStr + codeStr - } -} diff --git a/core/shared/src/main/scala/terminus/KeyCode.scala b/core/shared/src/main/scala/terminus/KeyCode.scala index 026fdb5..e911ef2 100644 --- a/core/shared/src/main/scala/terminus/KeyCode.scala +++ b/core/shared/src/main/scala/terminus/KeyCode.scala @@ -16,7 +16,7 @@ package terminus -enum KeyCode { +enum KeyCode: case BackTab case Backspace case CapsLock @@ -47,4 +47,3 @@ enum KeyCode { * interpret. The entire sequence is contained here for debugging purposes. */ case Unknown(code: String) -} diff --git a/core/shared/src/main/scala/terminus/KeyModifier.scala b/core/shared/src/main/scala/terminus/KeyModifier.scala index ab77e57..fd23f8f 100644 --- a/core/shared/src/main/scala/terminus/KeyModifier.scala +++ b/core/shared/src/main/scala/terminus/KeyModifier.scala @@ -17,7 +17,7 @@ package terminus opaque type KeyModifier = Byte -object KeyModifier { +object KeyModifier: val Shift: KeyModifier = 0x0001 val Control: KeyModifier = 0x0002 val Alt: KeyModifier = 0x0004 @@ -33,8 +33,7 @@ object KeyModifier { val None: KeyModifier = 0x0000 val ControlShift = Control.or(Shift) -} -extension (modifier: KeyModifier) { +extension (modifier: KeyModifier) def and(other: KeyModifier): KeyModifier = (modifier & other).toByte @@ -58,4 +57,3 @@ extension (modifier: KeyModifier) { def hasMeta: Boolean = modifier.and(KeyModifier.Meta) != KeyModifier.None -} diff --git a/core/shared/src/main/scala/terminus/KeyReader.scala b/core/shared/src/main/scala/terminus/KeyReader.scala index 5e4e4e4..c8abfe8 100644 --- a/core/shared/src/main/scala/terminus/KeyReader.scala +++ b/core/shared/src/main/scala/terminus/KeyReader.scala @@ -16,7 +16,7 @@ package terminus -trait KeyReader { +trait KeyReader: /** Read a [[Key]] from the terminal. * @@ -27,4 +27,3 @@ trait KeyReader { */ def readKey(): effect.KeyReader ?=> Eof | Key = effect ?=> effect.readKey() -} diff --git a/core/shared/src/main/scala/terminus/NonBlockingReader.scala b/core/shared/src/main/scala/terminus/NonBlockingReader.scala index de89353..cc98086 100644 --- a/core/shared/src/main/scala/terminus/NonBlockingReader.scala +++ b/core/shared/src/main/scala/terminus/NonBlockingReader.scala @@ -18,8 +18,7 @@ package terminus import scala.concurrent.duration.Duration -trait NonBlockingReader { +trait NonBlockingReader: def read(duration: Duration): effect.NonBlockingReader ?=> Timeout | Eof | Char = effect ?=> effect.read(duration) -} diff --git a/core/shared/src/main/scala/terminus/Peeker.scala b/core/shared/src/main/scala/terminus/Peeker.scala index 481919d..07c10fe 100644 --- a/core/shared/src/main/scala/terminus/Peeker.scala +++ b/core/shared/src/main/scala/terminus/Peeker.scala @@ -18,7 +18,6 @@ package terminus import scala.concurrent.duration.Duration -trait Peeker { +trait Peeker: def peek(duration: Duration): effect.Peeker ?=> Timeout | Eof | Char = effect ?=> effect.peek(duration) -} diff --git a/core/shared/src/main/scala/terminus/RawMode.scala b/core/shared/src/main/scala/terminus/RawMode.scala index ae58e52..d049b88 100644 --- a/core/shared/src/main/scala/terminus/RawMode.scala +++ b/core/shared/src/main/scala/terminus/RawMode.scala @@ -16,12 +16,11 @@ package terminus -trait RawMode { +trait RawMode: /** Run the given terminal program `f` in raw mode, which means that the * program can read user input a character at a time. In canonical mode, * which is the default, user input is only available a line at a time. */ - def raw[F <: effect.Effect, A](f: F ?=> A): (F & effect.RawMode[F]) ?=> A = - effect ?=> effect.raw(f) -} + def raw[F <: effect.Effect, A](f: F ?=> A): (F & effect.RawMode) ?=> A = + effect ?=> effect.raw(() => f(using effect)) diff --git a/core/shared/src/main/scala/terminus/Reader.scala b/core/shared/src/main/scala/terminus/Reader.scala index de30524..1f64272 100644 --- a/core/shared/src/main/scala/terminus/Reader.scala +++ b/core/shared/src/main/scala/terminus/Reader.scala @@ -16,7 +16,6 @@ package terminus -trait Reader { +trait Reader: def read(): effect.Reader ?=> Eof | Char = effect ?=> effect.read() -} diff --git a/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala b/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala index 058f741..b3664dd 100644 --- a/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala +++ b/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala @@ -19,11 +19,11 @@ package terminus import scala.collection.mutable final class StringBuilderTerminal() - extends effect.Color[StringBuilderTerminal], + extends effect.Color, effect.Cursor, - effect.Format[StringBuilderTerminal], + effect.Format, effect.Erase, - effect.Writer { + effect.Writer: private val builder = mutable.StringBuilder() @@ -39,19 +39,15 @@ final class StringBuilderTerminal() /** Get the value accumulated in the internal string builder, clearing the * buffer in the process. */ - def result(): String = { + def result(): String = val s = builder.result() builder.clear() s - } -} -object StringBuilderTerminal { +object StringBuilderTerminal: type Program[A] = StringBuilderTerminal ?=> A - def run[A](f: Program[A]): String = { + def run[A](f: Program[A]): String = val terminal = StringBuilderTerminal() val _ = f(using terminal) terminal.result() - } -} diff --git a/core/shared/src/main/scala/terminus/Writer.scala b/core/shared/src/main/scala/terminus/Writer.scala index 4b90430..a8dd13f 100644 --- a/core/shared/src/main/scala/terminus/Writer.scala +++ b/core/shared/src/main/scala/terminus/Writer.scala @@ -17,7 +17,7 @@ package terminus /** Interface for writing to a console. */ -trait Writer { +trait Writer: /** Write a character to the console. */ def write(char: Char): effect.Writer ?=> Unit = @@ -27,7 +27,10 @@ trait Writer { def write(string: String): effect.Writer ?=> Unit = effect ?=> effect.write(string) + /** Write a newline. */ + val newline: effect.Writer ?=> Unit = + write('\n') + /** Flush the current output, causing it to be shown on the console. */ def flush(): effect.Writer ?=> Unit = effect ?=> effect.flush() -} diff --git a/core/shared/src/main/scala/terminus/effect/AlternateScreenMode.scala b/core/shared/src/main/scala/terminus/effect/AlternateScreenMode.scala index cdcf798..648da7b 100644 --- a/core/shared/src/main/scala/terminus/effect/AlternateScreenMode.scala +++ b/core/shared/src/main/scala/terminus/effect/AlternateScreenMode.scala @@ -16,11 +16,10 @@ package terminus.effect -trait AlternateScreenMode[+F <: Effect] { self: F => +trait AlternateScreenMode: /** Run the given terminal program `f` in alternate screen mode, which means * that whatever is displayed by `f` will not been shown when the program * exits, and similarly key presses will not be saved in the history buffer. */ - def alternateScreen[A](f: F ?=> A): A -} + def alternateScreen[A](f: () => A): A diff --git a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala index 6348b05..5f00551 100644 --- a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala +++ b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala @@ -24,7 +24,7 @@ package terminus.effect * - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html * - https://ghostty.org/docs/vt */ -object AnsiCodes { +object AnsiCodes: import Ascii.* /** The Control Sequencer Introducer code, which starts many escape codes. It @@ -36,9 +36,8 @@ object AnsiCodes { * by zero or more arguments. The arguments will printed semi-colon separated * before the terminator. */ - def csi(terminator: String, args: String*): String = { + def csi(terminator: String, args: String*): String = s"$csiCode${args.mkString(";")}$terminator" - } /** Create a Select Graphic Rendition code, which is a form of CSI code that * controls graphics effects. @@ -47,7 +46,7 @@ object AnsiCodes { csi("m", n) /** Codes for manipulating the cursor */ - object cursor { + object cursor: /** Restore the previously saved cursor state, or reset it to default values * if no state has been saved. @@ -61,26 +60,28 @@ object AnsiCodes { val save: String = s"${ESC}7" - object style { + /** Hide the cursor. */ + val hide: String = csi("?25l") + + /** Show the cursor. */ + val show: String = csi("?25h") + + object style: val default = s"${ESC}0 q" - object block { + object block: val blink = s"${ESC}1 q" val steady = s"${ESC}2 q" - } - object underline { + object underline: val blink = s"${ESC}3 q" val steady = s"${ESC}4 q" - } - object bar { + object bar: val blink = s"${ESC}5 q" val steady = s"${ESC}6 q" - } - } - object down { + object down: /** Move down the given number of lines and to the beginning of the line. */ @@ -90,9 +91,8 @@ object AnsiCodes { /** Move down the given number of lines. */ def apply(n: Int): String = csi("B", n.toString) - } - object up { + object up: /** Move up the given number of lines and to the beginning of the line. */ @@ -102,7 +102,6 @@ object AnsiCodes { /** Move up the given number of lines. */ def apply(n: Int): String = csi("A", n.toString) - } /** Move the given number of characters to the right. */ def forward(n: Int): String = @@ -123,17 +122,17 @@ object AnsiCodes { */ def to(x: Int, y: Int): String = csi("H", y.toString, x.toString) - } - object format { - object bold { + object format: + object bold: val on: String = sgr("1") val off: String = sgr("22") - } - object light { + object italic: + val on: String = sgr("3") + val off: String = sgr("23") + object light: val on: String = sgr("2") val off: String = sgr("22") - } /** The support for underlines follows the Kitty extension defined at * https://sw.kovidgoyal.net/kitty/underlines/ @@ -141,7 +140,7 @@ object AnsiCodes { * Off and straight are backwards compatible, but older terminals might not * support the others. */ - object underline { + object underline: val off: String = sgr("24") // Backwards compatible variant val straight: String = sgr("4") // Backwards compatible variant val double: String = sgr("4:2") @@ -159,30 +158,24 @@ object AnsiCodes { val magenta: String = sgr("55") val cyan: String = sgr("56") val white: String = sgr("57") - } - object blink { + object blink: val on: String = sgr("5") val off: String = sgr("25") - } - object invert { + object invert: val on: String = sgr("7") val off: String = sgr("27") - } - object invisible { + object invisible: val on: String = sgr("8") val off: String = sgr("28") - } - object strikethrough { + object strikethrough: val on: String = sgr("9") val off: String = sgr("29") - } - } - object erase { + object erase: /** Erase the entire screen and move the cursor to the top-left. */ val screen: String = csi("J", "2") @@ -195,30 +188,26 @@ object AnsiCodes { /** Erase the current line. */ val line: String = csi("K", "2") - } - object mode { + object mode: /** Application mode, which seems to only change the codes sent by some * keys. See https://www.vt100.net/docs/vt510-rm/DECCKM */ - object application { + object application: val on: String = csi("?1h") val off: String = csi("?1l") - } /** Alternate screen mode, which means content will not be shown when the * program exits and key presses will not be saved to the history buffer. * See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html and search * for "alternate screen" */ - object alternateScreen { + object alternateScreen: val on: String = csi("?1049h") val off: String = csi("?11049l") - } - } - object scroll { + object scroll: /** Scroll the display up the given number of rows. Defaults to 1 row. */ def up(lines: Int = 1): String = @@ -227,10 +216,9 @@ object AnsiCodes { /** Scroll the display down the given number of rows. Defaults to 1 row. */ def down(lines: Int = 1): String = csi("T", lines.toString) - } /** Set foreground color. */ - object foreground { + object foreground: val default: String = sgr("39") val black: String = sgr("30") val red: String = sgr("31") @@ -248,10 +236,9 @@ object AnsiCodes { val brightMagenta: String = sgr("95") val brightCyan: String = sgr("96") val brightWhite: String = sgr("97") - } /** Set background color. */ - object background { + object background: val default: String = sgr("49") val black: String = sgr("40") val red: String = sgr("41") @@ -269,5 +256,3 @@ object AnsiCodes { val brightMagenta: String = sgr("105") val brightCyan: String = sgr("106") val brightWhite: String = sgr("107") - } -} diff --git a/core/shared/src/main/scala/terminus/effect/ApplicationMode.scala b/core/shared/src/main/scala/terminus/effect/ApplicationMode.scala index a717d89..0aced5b 100644 --- a/core/shared/src/main/scala/terminus/effect/ApplicationMode.scala +++ b/core/shared/src/main/scala/terminus/effect/ApplicationMode.scala @@ -16,12 +16,10 @@ package terminus.effect -trait ApplicationMode[+F <: Effect] { self: F => +trait ApplicationMode: /** Run the given terminal program `f` in application mode, which changes the * input sent to the program when arrow keys are pressed. See * https://invisible-island.net/xterm/xterm.faq.html#xterm_arrows */ - def application[A](f: F ?=> A): A - -} + def application[A](f: () => A): A diff --git a/core/shared/src/main/scala/terminus/effect/Ascii.scala b/core/shared/src/main/scala/terminus/effect/Ascii.scala index e4976c8..ae05748 100644 --- a/core/shared/src/main/scala/terminus/effect/Ascii.scala +++ b/core/shared/src/main/scala/terminus/effect/Ascii.scala @@ -19,7 +19,7 @@ package terminus.effect /** Ascii character codes and Ascii related extension methods. See: * https://www.ascii-code.com/ */ -object Ascii { +object Ascii: /** Null character */ val NUL: Char = '\u0000' @@ -138,4 +138,3 @@ object Ascii { * }}} */ def isPrintableChar: Boolean = char >= 0x20 && char != DEL -} diff --git a/core/shared/src/main/scala/terminus/effect/Color.scala b/core/shared/src/main/scala/terminus/effect/Color.scala index 3244d2a..e58e403 100644 --- a/core/shared/src/main/scala/terminus/effect/Color.scala +++ b/core/shared/src/main/scala/terminus/effect/Color.scala @@ -16,113 +16,111 @@ package terminus.effect -trait Color[+F <: Writer] extends WithStack[F] { self: F => - object foreground { +trait Color extends WithStack: + self: Writer => + object foreground: private val foregroundStack: Stack = Stack(AnsiCodes.foreground.default) - def default[A](f: F ?=> A): A = + def default[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.default)(f) - def black[A](f: F ?=> A): A = + def black[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.black)(f) - def red[A](f: F ?=> A): A = + def red[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.red)(f) - def green[A](f: F ?=> A): A = + def green[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.green)(f) - def yellow[A](f: F ?=> A): A = + def yellow[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.yellow)(f) - def blue[A](f: F ?=> A): A = + def blue[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.blue)(f) - def magenta[A](f: F ?=> A): A = + def magenta[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.magenta)(f) - def cyan[A](f: F ?=> A): A = + def cyan[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.cyan)(f) - def white[A](f: F ?=> A): A = + def white[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.white)(f) - def brightBlack[A](f: F ?=> A): A = + def brightBlack[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.brightBlack)(f) - def brightRed[A](f: F ?=> A): A = + def brightRed[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.brightRed)(f) - def brightGreen[A](f: F ?=> A): A = + def brightGreen[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.brightGreen)(f) - def brightYellow[A](f: F ?=> A): A = + def brightYellow[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.brightYellow)(f) - def brightBlue[A](f: F ?=> A): A = + def brightBlue[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.brightBlue)(f) - def brightMagenta[A](f: F ?=> A): A = + def brightMagenta[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.brightMagenta)(f) - def brightCyan[A](f: F ?=> A): A = + def brightCyan[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.brightCyan)(f) - def brightWhite[A](f: F ?=> A): A = + def brightWhite[A](f: () => A): A = withStack(foregroundStack, AnsiCodes.foreground.brightWhite)(f) - } - object background { + object background: private val backgroundStack: Stack = Stack(AnsiCodes.background.default) - def default[A](f: F ?=> A): A = + def default[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.default)(f) - def black[A](f: F ?=> A): A = + def black[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.black)(f) - def red[A](f: F ?=> A): A = + def red[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.red)(f) - def green[A](f: F ?=> A): A = + def green[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.green)(f) - def yellow[A](f: F ?=> A): A = + def yellow[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.yellow)(f) - def blue[A](f: F ?=> A): A = + def blue[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.blue)(f) - def magenta[A](f: F ?=> A): A = + def magenta[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.magenta)(f) - def cyan[A](f: F ?=> A): A = + def cyan[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.cyan)(f) - def white[A](f: F ?=> A): A = + def white[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.white)(f) - def brightBlack[A](f: F ?=> A): A = + def brightBlack[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.brightBlack)(f) - def brightRed[A](f: F ?=> A): A = + def brightRed[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.brightRed)(f) - def brightGreen[A](f: F ?=> A): A = + def brightGreen[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.brightGreen)(f) - def brightYellow[A](f: F ?=> A): A = + def brightYellow[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.brightYellow)(f) - def brightBlue[A](f: F ?=> A): A = + def brightBlue[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.brightBlue)(f) - def brightMagenta[A](f: F ?=> A): A = + def brightMagenta[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.brightMagenta)(f) - def brightCyan[A](f: F ?=> A): A = + def brightCyan[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.brightCyan)(f) - def brightWhite[A](f: F ?=> A): A = + def brightWhite[A](f: () => A): A = withStack(backgroundStack, AnsiCodes.background.brightWhite)(f) - } -} diff --git a/core/shared/src/main/scala/terminus/effect/Cursor.scala b/core/shared/src/main/scala/terminus/effect/Cursor.scala index 3d2f464..8e90a1a 100644 --- a/core/shared/src/main/scala/terminus/effect/Cursor.scala +++ b/core/shared/src/main/scala/terminus/effect/Cursor.scala @@ -17,8 +17,25 @@ package terminus.effect /** Functionality for manipulating the terminal's cursor. */ -trait Cursor extends Writer { - object cursor { +trait Cursor extends Writer: + object cursor: + + /** Run the given program `f` with the cursor hidden, restoring cursor + * visibility when `f` completes. + */ + def hidden[A](f: () => A): A = + write(AnsiCodes.cursor.hide) + try f() + finally write(AnsiCodes.cursor.show) + + /** Run the given program `f` with the cursor visible, hiding the cursor + * again when `f` completes. Useful to show the cursor within a [[hidden]] + * block. + */ + def visible[A](f: () => A): A = + write(AnsiCodes.cursor.show) + try f() + finally write(AnsiCodes.cursor.hide) /** Move cursor to given column. The left-most column is 1, and coordinates * increase to the right. @@ -35,13 +52,12 @@ trait Cursor extends Writer { /** Move the cursor position relative to the current position. Coordinates * are given in characters / cells. */ - def move(x: Int, y: Int): Unit = { + def move(x: Int, y: Int): Unit = if x < 0 then write(AnsiCodes.cursor.backward(-x)) else write(AnsiCodes.cursor.forward(x)) if y < 0 then write(AnsiCodes.cursor.up(-y)) else write(AnsiCodes.cursor.down(y)) - } /** Move the cursor up the given number of rows. The column of the cursor * remains unchanged. Defaults to 1 row. @@ -62,5 +78,3 @@ trait Cursor extends Writer { */ def left(columns: Int = 1): Unit = write(AnsiCodes.cursor.backward(columns)) - } -} diff --git a/core/shared/src/main/scala/terminus/effect/Dimensions.scala b/core/shared/src/main/scala/terminus/effect/Dimensions.scala index e30f504..6db2607 100644 --- a/core/shared/src/main/scala/terminus/effect/Dimensions.scala +++ b/core/shared/src/main/scala/terminus/effect/Dimensions.scala @@ -19,9 +19,8 @@ package terminus.effect //import org.jline.terminal.Size /** Functionalities related to the dimensions of the terminal */ -trait Dimensions extends Effect { +trait Dimensions extends Effect: def getDimensions: TerminalDimensions def setDimensions(dimensions: TerminalDimensions): Unit -} final case class TerminalDimensions(columns: Int, rows: Int) diff --git a/core/shared/src/main/scala/terminus/effect/Erase.scala b/core/shared/src/main/scala/terminus/effect/Erase.scala index 6312b0c..dcfe4a3 100644 --- a/core/shared/src/main/scala/terminus/effect/Erase.scala +++ b/core/shared/src/main/scala/terminus/effect/Erase.scala @@ -17,8 +17,8 @@ package terminus.effect /** Functionality for clearing contents on the terminal. */ -trait Erase extends Writer { - object erase { +trait Erase extends Writer: + object erase: /** Erase the entire screen and move the cursor to the top-left. */ def screen(): Unit = @@ -35,5 +35,3 @@ trait Erase extends Writer { /** Erase the current line. */ def line(): Unit = write(AnsiCodes.erase.line) - } -} diff --git a/core/shared/src/main/scala/terminus/effect/Format.scala b/core/shared/src/main/scala/terminus/effect/Format.scala index b9b2351..32786a3 100644 --- a/core/shared/src/main/scala/terminus/effect/Format.scala +++ b/core/shared/src/main/scala/terminus/effect/Format.scala @@ -17,91 +17,91 @@ package terminus.effect /** Terminal effects that can change character formatting properties. */ -trait Format[+F <: Writer] extends WithStack[F], WithToggle[F] { self: F => - object format { +trait Format extends WithStack, WithToggle: + self: Writer => + object format: // Bold and light share this stack private val fontWeightStack = Stack(AnsiCodes.format.bold.off) - def bold[A](f: F ?=> A): A = + def bold[A](f: () => A): A = withStack(fontWeightStack, AnsiCodes.format.bold.on)(f) - def light[A](f: F ?=> A): A = + def light[A](f: () => A): A = withStack(fontWeightStack, AnsiCodes.format.light.on)(f) /** Normal weight text, neither bold nor light. */ - def normal[A](f: F ?=> A): A = + def normal[A](f: () => A): A = withStack(fontWeightStack, AnsiCodes.format.bold.off)(f) - object underline { + object underline: private val underlineStyleStack = Stack(AnsiCodes.format.underline.off) - def none[A](f: F ?=> A): A = + def none[A](f: () => A): A = withStack(underlineStyleStack, AnsiCodes.format.underline.off)(f) - def straight[A](f: F ?=> A): A = + def straight[A](f: () => A): A = withStack(underlineStyleStack, AnsiCodes.format.underline.straight)(f) - def double[A](f: F ?=> A): A = + def double[A](f: () => A): A = withStack(underlineStyleStack, AnsiCodes.format.underline.double)(f) - def curly[A](f: F ?=> A): A = + def curly[A](f: () => A): A = withStack(underlineStyleStack, AnsiCodes.format.underline.curly)(f) - def dotted[A](f: F ?=> A): A = + def dotted[A](f: () => A): A = withStack(underlineStyleStack, AnsiCodes.format.underline.dotted)(f) - def dashed[A](f: F ?=> A): A = + def dashed[A](f: () => A): A = withStack(underlineStyleStack, AnsiCodes.format.underline.dashed)(f) private val underlineColorStack = Stack( AnsiCodes.format.underline.default ) - def default[A](f: F ?=> A): A = + def default[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.default)(f) - def black[A](f: F ?=> A): A = + def black[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.black)(f) - def red[A](f: F ?=> A): A = + def red[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.red)(f) - def green[A](f: F ?=> A): A = + def green[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.green)(f) - def yellow[A](f: F ?=> A): A = + def yellow[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.yellow)(f) - def blue[A](f: F ?=> A): A = + def blue[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.blue)(f) - def magenta[A](f: F ?=> A): A = + def magenta[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.magenta)(f) - def cyan[A](f: F ?=> A): A = + def cyan[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.cyan)(f) - def white[A](f: F ?=> A): A = + def white[A](f: () => A): A = withStack(underlineColorStack, AnsiCodes.format.underline.white)(f) - } private val blinkToggle = Toggle(AnsiCodes.format.blink.on, AnsiCodes.format.blink.off) - def blink[A](f: F ?=> A): A = + def blink[A](f: () => A): A = withToggle(blinkToggle)(f) private val invertToggle = Toggle(AnsiCodes.format.invert.on, AnsiCodes.format.invert.off) - def invert[A](f: F ?=> A): A = + def invert[A](f: () => A): A = withToggle(invertToggle)(f) private val invisibleToggle = Toggle(AnsiCodes.format.invisible.on, AnsiCodes.format.invisible.off) - def invisible[A](f: F ?=> A): A = + def invisible[A](f: () => A): A = withToggle(invisibleToggle)(f) private val strikethroughToggle = Toggle( @@ -109,7 +109,5 @@ trait Format[+F <: Writer] extends WithStack[F], WithToggle[F] { self: F => AnsiCodes.format.strikethrough.off ) - def strikethrough[A](f: F ?=> A): A = + def strikethrough[A](f: () => A): A = withToggle(strikethroughToggle)(f) - } -} diff --git a/core/shared/src/main/scala/terminus/effect/KeyReader.scala b/core/shared/src/main/scala/terminus/effect/KeyReader.scala index 25765e0..09a84de 100644 --- a/core/shared/src/main/scala/terminus/effect/KeyReader.scala +++ b/core/shared/src/main/scala/terminus/effect/KeyReader.scala @@ -19,6 +19,5 @@ package terminus.effect import terminus.Eof import terminus.Key -trait KeyReader extends Effect { +trait KeyReader extends Effect: def readKey(): Eof | Key -} diff --git a/core/shared/src/main/scala/terminus/effect/NonBlockingReader.scala b/core/shared/src/main/scala/terminus/effect/NonBlockingReader.scala index a08f2b1..9906ec2 100644 --- a/core/shared/src/main/scala/terminus/effect/NonBlockingReader.scala +++ b/core/shared/src/main/scala/terminus/effect/NonBlockingReader.scala @@ -21,6 +21,5 @@ import terminus.Timeout import scala.concurrent.duration.Duration -trait NonBlockingReader extends Effect { +trait NonBlockingReader extends Effect: def read(duration: Duration): Timeout | Eof | Char -} diff --git a/core/shared/src/main/scala/terminus/effect/Peeker.scala b/core/shared/src/main/scala/terminus/effect/Peeker.scala index 4a09b83..8c53376 100644 --- a/core/shared/src/main/scala/terminus/effect/Peeker.scala +++ b/core/shared/src/main/scala/terminus/effect/Peeker.scala @@ -21,6 +21,5 @@ import terminus.Timeout import scala.concurrent.duration.Duration -trait Peeker extends Effect { +trait Peeker extends Effect: def peek(duration: Duration): Timeout | Eof | Char -} diff --git a/core/shared/src/main/scala/terminus/effect/RawMode.scala b/core/shared/src/main/scala/terminus/effect/RawMode.scala index b51cbb6..f09581f 100644 --- a/core/shared/src/main/scala/terminus/effect/RawMode.scala +++ b/core/shared/src/main/scala/terminus/effect/RawMode.scala @@ -16,11 +16,10 @@ package terminus.effect -trait RawMode[+F <: Effect] { self: F => +trait RawMode: /** Run the given terminal program `f` in raw mode, which means that the * program can read user input a character at a time. In canonical mode, * which is the default, user input is only available a line at a time. */ - def raw[A](f: F ?=> A): A -} + def raw[A](f: () => A): A diff --git a/core/shared/src/main/scala/terminus/effect/Reader.scala b/core/shared/src/main/scala/terminus/effect/Reader.scala index aedf743..144b232 100644 --- a/core/shared/src/main/scala/terminus/effect/Reader.scala +++ b/core/shared/src/main/scala/terminus/effect/Reader.scala @@ -18,6 +18,5 @@ package terminus.effect import terminus.Eof -trait Reader extends Effect { +trait Reader extends Effect: def read(): Eof | Char -} diff --git a/core/shared/src/main/scala/terminus/effect/Scroll.scala b/core/shared/src/main/scala/terminus/effect/Scroll.scala index 3b673de..07e99fd 100644 --- a/core/shared/src/main/scala/terminus/effect/Scroll.scala +++ b/core/shared/src/main/scala/terminus/effect/Scroll.scala @@ -17,8 +17,8 @@ package terminus.effect /** Functionality for scrolling the terminal. */ -trait Scroll extends Writer { - object scroll { +trait Scroll extends Writer: + object scroll: /** Scroll the display up the given number of rows. Defaults to 1 row. */ def up(lines: Int = 1): Unit = @@ -27,6 +27,3 @@ trait Scroll extends Writer { /** Scroll the display down the given number of rows. Defaults to 1 row. */ def down(lines: Int = 1): Unit = write(AnsiCodes.scroll.down(lines)) - } - -} diff --git a/core/shared/src/main/scala/terminus/effect/Stack.scala b/core/shared/src/main/scala/terminus/effect/Stack.scala index 2eeaaa3..8db1e55 100644 --- a/core/shared/src/main/scala/terminus/effect/Stack.scala +++ b/core/shared/src/main/scala/terminus/effect/Stack.scala @@ -28,30 +28,23 @@ import scala.collection.mutable * @param `reset`: * The escape code to emit when there are no elements left on the stack. */ -final case class Stack(reset: String) { +final case class Stack(reset: String): private val stack: mutable.Stack[String] = mutable.Stack() def push(code: String, writer: Writer): Unit = - stack.headOption match { + stack.headOption match case None => stack.push(code) writer.write(code) case Some(value) => - if value == code then { - stack.push(code) - } else { + if value == code then stack.push(code) + else stack.push(code) writer.write(code) - } - } - def pop(writer: Writer): Unit = { + def pop(writer: Writer): Unit = stack.pop() - stack.headOption match { + stack.headOption match case None => writer.write(reset) case Some(value) => writer.write(value) - } - } - -} diff --git a/core/shared/src/main/scala/terminus/effect/StringBufferReader.scala b/core/shared/src/main/scala/terminus/effect/StringBufferReader.scala index d4565d0..7e059e4 100644 --- a/core/shared/src/main/scala/terminus/effect/StringBufferReader.scala +++ b/core/shared/src/main/scala/terminus/effect/StringBufferReader.scala @@ -27,17 +27,15 @@ class StringBufferReader(input: String) extends Reader, NonBlockingReader, KeyReader, - TerminalKeyReader { + TerminalKeyReader: private var index: Int = 0 def read(): Eof | Char = if index >= input.size then Eof - else { + else val char = input(index) index = index + 1 char - } def read(duration: Duration): Timeout | Eof | Char = read() -} diff --git a/core/shared/src/main/scala/terminus/effect/TerminalKeyReader.scala b/core/shared/src/main/scala/terminus/effect/TerminalKeyReader.scala index 97d7017..7f6bf36 100644 --- a/core/shared/src/main/scala/terminus/effect/TerminalKeyReader.scala +++ b/core/shared/src/main/scala/terminus/effect/TerminalKeyReader.scala @@ -16,8 +16,13 @@ package terminus.effect +import terminus.Eof +import terminus.Key +import terminus.KeyMappings +import terminus.KeySequence +import terminus.Timeout + import scala.annotation.tailrec -import terminus.{Eof, Key, KeyMappings, KeySequence, Timeout} import scala.concurrent.duration.* /** An implementation of KeyReader that interprets the standard terminal escape @@ -25,7 +30,7 @@ import scala.concurrent.duration.* * being pressed and another key before we decide they are separate key * presses. */ -trait TerminalKeyReader(timeout: Duration = 100.millis) extends KeyReader { +trait TerminalKeyReader(timeout: Duration = 100.millis) extends KeyReader: self: NonBlockingReader & Reader => // Some references on parsing terminal codes: @@ -35,26 +40,22 @@ trait TerminalKeyReader(timeout: Duration = 100.millis) extends KeyReader { val input = read() input match - case Eof => Eof + case Eof => Eof case c: Char => KeyMappings.default.get(c) match - case None => Key(c) - case Some(k: Key) => k + case None => Key(c) + case Some(k: Key) => k case Some(ks: KeySequence) => - read(timeout) match { + read(timeout) match case Eof => ks.root case Timeout => ks.root case cx => readKeySequence(s"$c$cx", ks) - } @tailrec - private def readKeySequence(acc: String, sequence: KeySequence): Eof | Key = { + private def readKeySequence(acc: String, sequence: KeySequence): Eof | Key = if sequence.sequences.contains(acc) then sequence.sequences(acc) else if sequence.subSequences.contains(acc) then - read() match { + read() match case Eof => Eof case c: Char => readKeySequence(acc + c.toString, sequence) - } else Key.unknown(acc) - } -} diff --git a/core/shared/src/main/scala/terminus/effect/Toggle.scala b/core/shared/src/main/scala/terminus/effect/Toggle.scala index b576a85..a402e99 100644 --- a/core/shared/src/main/scala/terminus/effect/Toggle.scala +++ b/core/shared/src/main/scala/terminus/effect/Toggle.scala @@ -30,18 +30,15 @@ package terminus.effect * @param `reset`: * The escape code to emit when exiting a block. */ -final class Toggle(set: String, reset: String) { +final class Toggle(set: String, reset: String): private var count: Int = 0 /** Toggle on the effect, indicating we're entering a block. */ - def on(writer: Writer): Unit = { + def on(writer: Writer): Unit = if count == 0 then writer.write(set) count = count + 1 - } /** Toggle off the effect, indicating we're exiting a block. */ - def off(writer: Writer): Unit = { + def off(writer: Writer): Unit = if count == 1 then writer.write(reset) count = count - 1 - } -} diff --git a/core/shared/src/main/scala/terminus/effect/WithEffect.scala b/core/shared/src/main/scala/terminus/effect/WithEffect.scala index 39dd666..31541f2 100644 --- a/core/shared/src/main/scala/terminus/effect/WithEffect.scala +++ b/core/shared/src/main/scala/terminus/effect/WithEffect.scala @@ -16,18 +16,15 @@ package terminus.effect -trait WithEffect[+F <: Writer] { self: F => - protected def withEffect[A](on: String, off: String)(f: F ?=> A): A = { +trait WithEffect: + self: Writer => + protected def withEffect[A](on: String, off: String)(f: () => A): A = write(on) - try { - f(using this) - } finally { + try + f() + finally write(off) - } - } - protected def withEffect[A](on: String)(f: F ?=> A): A = { + protected def withEffect[A](on: String)(f: () => A): A = write(on) - f(using this) - } -} + f() diff --git a/core/shared/src/main/scala/terminus/effect/WithStack.scala b/core/shared/src/main/scala/terminus/effect/WithStack.scala index 199fac7..6231f18 100644 --- a/core/shared/src/main/scala/terminus/effect/WithStack.scala +++ b/core/shared/src/main/scala/terminus/effect/WithStack.scala @@ -17,17 +17,15 @@ package terminus.effect /** Utility trait for working with `Stack`. */ -trait WithStack[+F <: Writer] { self: F => +trait WithStack: + self: Writer => /** Use `withStack` to ensure a stack is pushed on before `f` is evaluated, * and popped when `f` finishes. */ - protected def withStack[A](stack: Stack, code: String)(f: F ?=> A): A = { + protected def withStack[A](stack: Stack, code: String)(f: () => A): A = stack.push(code, self) - try { - f(using this) - } finally { + try + f() + finally stack.pop(self) - } - } -} diff --git a/core/shared/src/main/scala/terminus/effect/WithToggle.scala b/core/shared/src/main/scala/terminus/effect/WithToggle.scala index e0aeda1..b16bfe5 100644 --- a/core/shared/src/main/scala/terminus/effect/WithToggle.scala +++ b/core/shared/src/main/scala/terminus/effect/WithToggle.scala @@ -17,17 +17,15 @@ package terminus.effect /** Utility trait for working with `Toggle`. */ -trait WithToggle[+F <: Writer] { self: F => +trait WithToggle: + self: Writer => /** Use `withToggle` to ensure a toggle is turned on before `f` is evaluated, * and turned off when `f` finishes. */ - protected def withToggle[A](toggle: Toggle)(f: F ?=> A): A = { + protected def withToggle[A](toggle: Toggle)(f: () => A): A = toggle.on(self) - try { - f(using this) - } finally { + try + f() + finally toggle.off(self) - } - } -} diff --git a/core/shared/src/main/scala/terminus/effect/Writer.scala b/core/shared/src/main/scala/terminus/effect/Writer.scala index fec7c29..e55656e 100644 --- a/core/shared/src/main/scala/terminus/effect/Writer.scala +++ b/core/shared/src/main/scala/terminus/effect/Writer.scala @@ -17,7 +17,7 @@ package terminus.effect /** Interface for writing to a console. */ -trait Writer extends Effect { +trait Writer extends Effect: /** Write a character to the console. */ def write(char: Char): Unit @@ -27,4 +27,3 @@ trait Writer extends Effect { /** Flush the current output, causing it to be shown on the console. */ def flush(): Unit -} diff --git a/core/shared/src/main/scala/terminus/example/Prompt.scala b/core/shared/src/main/scala/terminus/example/Prompt.scala index 5492704..0a8db36 100644 --- a/core/shared/src/main/scala/terminus/example/Prompt.scala +++ b/core/shared/src/main/scala/terminus/example/Prompt.scala @@ -22,20 +22,19 @@ import terminus.effect import scala.annotation.tailrec class Prompt[ - Terminal <: effect.Color[Terminal] & effect.Cursor & - effect.Format[Terminal] & effect.Erase & effect.KeyReader & effect.Writer -](terminal: Color & Cursor & Format & Erase & KeyReader & Writer) { + Terminal <: effect.Color & effect.Cursor & effect.Format & effect.Erase & + effect.KeyReader & effect.Writer +](terminal: Color & Cursor & Format & Erase & KeyReader & Writer): type Program[A] = Terminal ?=> A type PromptKey = KeyCode.Enter.type | KeyCode.Up.type | KeyCode.Down.type // Clear the text we've written - def clear(): Program[Unit] = { + 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] = @@ -44,32 +43,29 @@ class Prompt[ else terminal.write(s" ${description}\r\n") // Write the UI - def write(selected: Int): Program[Unit] = { + 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() - } @tailrec - final def read(): Program[PromptKey] = { - terminal.readKey() match { + final def read(): Program[PromptKey] = + terminal.readKey() match case Eof => throw new Exception("Received an EOF") case key: Key => - key match { - case Key(_, KeyCode.Enter) => KeyCode.Enter - case Key(_, KeyCode.Up) => KeyCode.Up - case Key(_, KeyCode.Down) => KeyCode.Down - case other => read() - } - } - } + key match + case Key(_, KeyCode.Enter) => KeyCode.Enter + case Key(_, KeyCode.Character('\n')) => KeyCode.Enter + case Key(_, KeyCode.Up) => KeyCode.Up + case Key(_, KeyCode.Down) => KeyCode.Down + case other => read() - def loop(idx: Int): Program[Int] = { + def loop(idx: Int): Program[Int] = write(idx) - read() match { + read() match case KeyCode.Up => clear() loop(if idx == 0 then 2 else idx - 1) @@ -79,6 +75,3 @@ class Prompt[ loop(if idx == 2 then 0 else idx + 1) case KeyCode.Enter => idx - } - } -} diff --git a/core/shared/src/test/scala/terminus/KeyModifierSuite.scala b/core/shared/src/test/scala/terminus/KeyModifierSuite.scala index 3967861..70b5400 100644 --- a/core/shared/src/test/scala/terminus/KeyModifierSuite.scala +++ b/core/shared/src/test/scala/terminus/KeyModifierSuite.scala @@ -18,7 +18,7 @@ package terminus import munit.FunSuite -class KeyModifierSuite extends FunSuite { +class KeyModifierSuite extends FunSuite: test("predicates return true when modifier is present") { assert(KeyModifier.Shift.hasShift) assert(KeyModifier.Control.hasControl) @@ -32,4 +32,3 @@ class KeyModifierSuite extends FunSuite { assert(KeyModifier.Shift.or(KeyModifier.Control).hasShift) assert(KeyModifier.Shift.or(KeyModifier.Control).hasControl) } -} diff --git a/core/shared/src/test/scala/terminus/KeyShowSuite.scala b/core/shared/src/test/scala/terminus/KeyShowSuite.scala index c8bae60..8c71837 100644 --- a/core/shared/src/test/scala/terminus/KeyShowSuite.scala +++ b/core/shared/src/test/scala/terminus/KeyShowSuite.scala @@ -16,10 +16,10 @@ package terminus -import munit.FunSuite import cats.syntax.show.* +import munit.FunSuite -class KeyShowSuite extends FunSuite { +class KeyShowSuite extends FunSuite: test("Show[Key] produces the expected string representation") { val testCases = Map[Key, String]( // Basic character keys @@ -91,4 +91,3 @@ class KeyShowSuite extends FunSuite { ) } } -} diff --git a/core/shared/src/test/scala/terminus/effect/AnsiCodesSuite.scala b/core/shared/src/test/scala/terminus/effect/AnsiCodesSuite.scala index 8e2be5a..b8dc7bd 100644 --- a/core/shared/src/test/scala/terminus/effect/AnsiCodesSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/AnsiCodesSuite.scala @@ -18,7 +18,7 @@ package terminus.effect import munit.FunSuite -class AnsiCodesSuite extends FunSuite { +class AnsiCodesSuite extends FunSuite: test("csi is the correct code") { assertEquals(AnsiCodes.csiCode, "[") } @@ -34,4 +34,3 @@ class AnsiCodesSuite extends FunSuite { test("csi strings with multiple arguments are correctly constructed") { assertEquals(AnsiCodes.csi("m", "4", "1"), "") } -} diff --git a/core/shared/src/test/scala/terminus/effect/AsciiSuite.scala b/core/shared/src/test/scala/terminus/effect/AsciiSuite.scala index ed50d91..3fbcef4 100644 --- a/core/shared/src/test/scala/terminus/effect/AsciiSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/AsciiSuite.scala @@ -18,7 +18,7 @@ package terminus.effect import munit.FunSuite -class AsciiSuite extends FunSuite { +class AsciiSuite extends FunSuite: import Ascii.* test("String escape characters match their Ascii characters") { @@ -118,4 +118,3 @@ class AsciiSuite extends FunSuite { assertEquals(US.isPrintableChar, false) assertEquals(DEL.isControlChar, false) } -} diff --git a/core/shared/src/test/scala/terminus/effect/ColorSuite.scala b/core/shared/src/test/scala/terminus/effect/ColorSuite.scala index 547f584..7617fd6 100644 --- a/core/shared/src/test/scala/terminus/effect/ColorSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/ColorSuite.scala @@ -19,15 +19,15 @@ package terminus.effect import munit.FunSuite import terminus.StringBuilderTerminal -class ColorSuite extends FunSuite { +class ColorSuite extends FunSuite: test( "Foreground color code reverts to enclosing color after leaving inner colored block" ) { val result = StringBuilderTerminal.run { t ?=> - t.foreground.blue { + t.foreground.blue { () => t.write("Blue ") - t.foreground.red { t.write("Red ") } + t.foreground.red { () => t.write("Red ") } t.write("Blue ") } } @@ -37,4 +37,3 @@ class ColorSuite extends FunSuite { s"${AnsiCodes.foreground.blue}Blue ${AnsiCodes.foreground.red}Red ${AnsiCodes.foreground.blue}Blue ${AnsiCodes.foreground.default}" ) } -} diff --git a/core/shared/src/test/scala/terminus/effect/CursorSuite.scala b/core/shared/src/test/scala/terminus/effect/CursorSuite.scala index af574e4..3e8fc59 100644 --- a/core/shared/src/test/scala/terminus/effect/CursorSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/CursorSuite.scala @@ -19,7 +19,7 @@ package terminus.effect import munit.FunSuite import terminus.StringBuilderTerminal -class CursorSuite extends FunSuite { +class CursorSuite extends FunSuite: test("cursor.left moves cursor left by default 1 column") { val terminal = new StringBuilderTerminal() @@ -64,4 +64,3 @@ class CursorSuite extends FunSuite { val left = terminal.result() assertEquals(left, s"${Ascii.ESC}[1D") } -} diff --git a/core/shared/src/test/scala/terminus/effect/FormatSuite.scala b/core/shared/src/test/scala/terminus/effect/FormatSuite.scala index 98ee47c..ab312df 100644 --- a/core/shared/src/test/scala/terminus/effect/FormatSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/FormatSuite.scala @@ -19,13 +19,13 @@ package terminus.effect import munit.FunSuite import terminus.StringBuilderTerminal -class FormatSuite extends FunSuite { +class FormatSuite extends FunSuite: test("Bold and light stack in nested scopes") { val result = StringBuilderTerminal.run { t ?=> - t.format.bold { + t.format.bold { () => t.write("Bold ") - t.format.light { t.write("Light ") } + t.format.light { () => t.write("Light ") } t.write("Bold ") } } @@ -39,9 +39,9 @@ class FormatSuite extends FunSuite { test("Blink toggles off only when exiting outermost block") { val result = StringBuilderTerminal.run { t ?=> - t.format.blink { + t.format.blink { () => t.write("Blink ") - t.format.blink { t.write("Blink ") } + t.format.blink { () => t.write("Blink ") } t.write("Blink ") } } @@ -51,4 +51,3 @@ class FormatSuite extends FunSuite { s"${AnsiCodes.format.blink.on}Blink Blink Blink ${AnsiCodes.format.blink.off}" ) } -} diff --git a/core/shared/src/test/scala/terminus/effect/TerminalKeyReaderSuite.scala b/core/shared/src/test/scala/terminus/effect/TerminalKeyReaderSuite.scala index c0150bf..b2e8429 100644 --- a/core/shared/src/test/scala/terminus/effect/TerminalKeyReaderSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/TerminalKeyReaderSuite.scala @@ -19,7 +19,7 @@ package terminus.effect import munit.FunSuite import terminus.Key -class TerminalKeyReaderSuite extends FunSuite { +class TerminalKeyReaderSuite extends FunSuite: test("The expected key is generated given the input") { // The following list is taken from Textual, with some sequences edited out // https://github.com/Textualize/textual/blob/main/src/textual/_ansi_sequences.py @@ -387,4 +387,3 @@ class TerminalKeyReaderSuite extends FunSuite { assertEquals(StringBufferReader(input).readKey(), key, s"Input is $input") } } -} diff --git a/docs/src/pages/design.md b/docs/src/pages/design.md index 1c9003a..7ba5f9c 100644 --- a/docs/src/pages/design.md +++ b/docs/src/pages/design.md @@ -16,14 +16,18 @@ We want to support differences across terminals, but where common functionality A backend specific `Terminal` type is the union of the interfaces, or effects, that describe the functionality it supports. For example, here is the JVM `Terminal` type at the time of writing: ```scala -trait Terminal - extends effect.Color[Terminal], +type Terminal = + effect.AlternateScreenMode, + effect.ApplicationMode, + effect.Color, effect.Cursor, - effect.Format[Terminal], + effect.Format, + effect.Dimensions, effect.Erase, - effect.AlternateScreenMode[Terminal], - effect.ApplicationMode[Terminal], - effect.RawMode[Terminal], + effect.KeyReader, + effect.NonBlockingReader, + effect.Peeker, + effect.RawMode, effect.Reader, effect.Writer ``` @@ -78,17 +82,25 @@ There are a few things to note: 1. `Erase` extends `Writer`, and uses the `write` method from `Writer`. 2. The methods are name-spaced by putting them inside an object named `erase`. This is aesthetic choice, leading to method calls like `erase.up()` and `cursor.up()` instead of `eraseUp()` and `cursorUp()`. -There is one final kind of effect that is more involved, which is an effect that only applies to some scope. @:api(terminus.effect.Format) is an example, as is @:api(terminues.effect.Color). These all change the way output is formatted, but only for the `Program` they take as a parameter. Here's part of the implementation of `Format`, which is a bit simpler than `Color`. +There is one final kind of effect that is more involved, which is an effect that only applies to some scope. @:api(terminus.effect.Format) is an example, as is @:api(terminus.effect.Color). These all change the way output is formatted, but only for the `Program` they take as a parameter. For example, when we create a program like ```scala -trait Format[+F <: Writer] extends WithStack[F], WithToggle[F] { self: F => +Terminal.invert(Terminal.write("Inverted")) +``` + +we want only the `Program` inside the call to `Terminal.invert` (that `Program` is `Terminal.write("Inverted")`) to be formatted with inverted text. + +Here's part of the implementation of `Format`, which is a bit simpler than `Color`. + +```scala +trait Format extends WithStack, WithToggle { self: Writer => object format { // ... private val invertToggle = Toggle(AnsiCodes.format.invert.on, AnsiCodes.format.invert.off) - def invert[A](f: F ?=> A): A = + def invert[A](f: () => A): A = withToggle(invertToggle)(f) // ... @@ -96,26 +108,27 @@ trait Format[+F <: Writer] extends WithStack[F], WithToggle[F] { self: F => } ``` -When we create a program like +The types look a bit odd. The `invert` method doesn't receive a `Program`, with type `F ?=> A`, but a thunk with type `() => A`. The reason for this is that it's a bit tricky to write down the type of `F` at this point. It's whatever terminal effects the program `f` needs, which must also be the same as the type that the particular instance of `Format` is mixed into. We can define this, using a type parameter on `Format`, like so: ```scala -Terminal.invert(Terminal.write("Inverted")) +trait Format[F <: Writer] extends WithStack[F], WithToggle[F] { self: F => + def invert[A](f: F ?=> A): A = + withToggle(invertToggle)(f(self)) +} ``` -we want only the `Program` inside the call to `Terminal.invert` (that `Program` is `Terminal.write("Inverted")`) to be formatted with inverted text. Inversion is relatively simply, as it is either on or off. This is what the call to `withToggle` does: it sends the code to turn on inverted text when we enter the block and turns it off when we exit. It also handles nested calls correctly. For more complicated cases, like color, there is also a stack abstraction which reverts to the previous setting when a block exits. - -The types deserve some explanation. We call `invert` with some `Program` type. Remember that `Program` is `Terminal ?=> A` and `Terminal` is backend specific. In the `Format` effect we need to actually carry out the effects within the `Program`, so we need to pass an instance of `Terminal` to the `Program`. Where do we get this instance from? It is `this`. - -How do we make sure that `Terminal` is actually of the same type as `this`? Firstly, in `invert` the type of a program is `F ?=> A`, where `F` is a type parameter of `Format`. The meaning of `F` is all the effect types that make up a particular `Terminal` type. If you look at the definition of the JVM terminal above you will see +A previous version of Terminus did this. However this ends up causing problems in other parts of the code. The issue is that `F` ends up a recursive type, which we define like ```scala - effect.Format[Terminal], +Terminal extends Format[Terminal] ``` -so `F` *is* `Terminal`. The [self type](https://docs.scala-lang.org/tour/self-types.html) ensures that instances of `Format` are mixed into the correct type, and hence `this` is `F`. +We can only define recursive types in concrete types like a `trait` as done above. This in turn means that we cannot write down the types of programs that work across multiple backends, unless we can arrange in advance for all backends to implement some common type. This goes against the idea of different backends implementing only the features they support. + +Our solution is to push the `F` type, and the application to the `Program`, into what we call program syntax. What `invert` receives is a thunk that wraps up the application of `Program` to `F`. Let's look at this "syntax" now. -## Implementing Programs +## Program Syntax Now let's look at how the `Program` types are implemented. We'll start with @:api(terminus.Writer), as it is very simple. @@ -139,6 +152,8 @@ trait Writer { Firstly, notice this is the `Writer` *program* **not** the `Writer` effect. They are separate concepts. A `Program` is just `Terminal ?=> A`. In the specific case of `Writer` these programs simply require a `Writer` effect and then call the appropriate method on that effect. +These definitions are very simple, and the user could write them themselves. However, it makes life easier if we provide these definitions for them. This is why we call this "program syntax"; we're providing some syntax to make writing programs easier. + `Format` is a bit more complex. Here's the definition, with most of the methods removed. ```scala @@ -147,17 +162,25 @@ trait Format { // ... - def invert[F <: effect.Writer, A]( + def invert[F, A]( f: F ?=> A ): (F & effect.Format[F]) ?=> A = - effect ?=> effect.format.invert(f) + effect ?=> effect.format.invert(() => f(using effect)) // ... } } ``` -`invert` wraps a `Program` with another `Program`. What is the type of the inner program, the method argument `f`? It is whatever effects it requires to run, with the constraint that these effects much include the `Writer` effect. This is represented by the type parameter `F`. The result of calling `invert` is another program that requires all the effects of the argument `f` *and* the `Format` effect. In this way programs are constructed to require only the effects they need to run. So long as we apply them to a concrete `Terminal` type that is a super-type of these effects they can be run. +`invert` wraps a `Program` with another `Program`. What is the type of the inner program, the method argument `f`? It is whatever effects it requires to run, represented by the type parameter `F`. The result of calling `invert` is another program that requires all the effects of the argument `f` *and* the `Format` effect. In this way programs are constructed to require only the effects they need to run. So long as we apply them to a concrete `Terminal` type that is a super-type of these effects they can be run. At the point of use all these types are known and type inference is easy. + +Now the bit that ties into the definition of the `Format` effect above. Notice in the body of `invert` we construct the thunk + +```scala +() => f(using effect) +``` + +This applies `f` to the instance of the effect type `F`, but by wrapping it in a thunk it is delayed until the `invert` effect chooses to run it—which is after the inversion escape code has been sent to the terminal. This gives the effect full control over order-of-evaluation, but still keeps type inference simple. Hence the effect types and the program syntax work together to implement a system that is correct, usable, and has simple types. ## Low-level Code diff --git a/notes/styling-design.md b/notes/styling-design.md new file mode 100644 index 0000000..916d6b0 --- /dev/null +++ b/notes/styling-design.md @@ -0,0 +1,44 @@ +# Styling Design + +## Style split + +Three distinct levels: + +- **Cell-level `Style`** — color, bold, italic, underline, etc. Used everywhere, passed directly to `Buffer.putString` / `Buffer.put`. Already implemented. +- **Component-level style** — padding, border, background fill. Parameters on the component itself, not on `Style`. +- **Parent-context style** — gutters, alignment modes. Expressed through a more specific `RenderContext` subtype, only accessible when the parent provides it (compile-time enforcement). + +## No cascade + +No CSS-style cascade. Scala provides sufficient abstraction (`Style` is a case class, `.copy()` works, shared styles are plain `val`s or functions). A cascade would add action-at-a-distance complexity for no gain. If ambient style context is ever needed, a `using` parameter is the right mechanism. + +## CSS box model as reference + +Content → padding → border → margin is a good foundation. Well understood, maps cleanly to terminal cells. Copying it saves users learning a new mental model. + +## Component-specific styles + +Different components may have style properties that don't apply universally. Example: `Columns`/`Grid` could expose gutter width to children via a typed `RenderContext` subtype. A child that tries to specify a gutter in a plain `Column` context would be a compile error — unlike CSS where unknown properties silently do nothing. + +## Labelled borders + +TUIs commonly embed text in a border (e.g. a title or status in the top edge of a box — as seen in Claude Code's input panel). This is a string spliced into the horizontal run of the border, replacing some `─` characters. Alignment (left/centre/right) and width clamping are the main concerns. This lives in `Box.render` and is deferred to later work. + +## Separators vs borders + +Separators (horizontal/vertical lines *between* children) are structurally different from borders (owned by a single component). Separators are a **rendering policy of the parent container**, not a child component. A `Column` or `Row` with separators owns the decision and knows which join characters to use (┳ ┻ ┣ ┫ etc.) based on position. Children don't need to know about them. Junction resolution between child borders and parent separators is deferred — keep simple initially. + +## Block vs inline (deferred) + +The current `Text` component conflates a block element (box with border, background fill, padding) and an inline element (styled text content). This means `content: Style` has a `bg` field that can diverge from `ComponentStyle.background`, requiring the user to keep them in sync manually. + +The clean resolution: content style `bg = Color.Default` means "transparent — inherit from the component background", matching CSS's default behaviour. An explicit `bg` on a content style is an intentional override (like a highlighted ``). This requires a concept of cell-style transparency/inheritance, which is deferred until inline/span styling is tackled. + +## Implementation order + +Rough priority for immediate work: +1. Fill style (background colour for entire component area, including empty cells) +2. Separate box-border style from content style in `Text` +3. Padding +4. Separators (later, as a parent container feature) +5. Alignment / gutter (later, tied to dynamic layout and typed `RenderContext`) diff --git a/notes/ui-architecture.md b/notes/ui-architecture.md new file mode 100644 index 0000000..fad4b13 --- /dev/null +++ b/notes/ui-architecture.md @@ -0,0 +1,127 @@ +# UI Module Architecture + +## Overview + +The `ui/` module builds a terminal UI toolkit on top of the core effect layer. It uses a **capability-passing** style throughout: effects and reactive context are passed as Scala 3 context parameters (`using` / `?=>`), making dependencies explicit and enforced at compile time. + +Rendering uses a **cell buffer** model: components write into an in-memory 2D grid of cells, then a single controlled flush to the terminal at the end of each frame. + +## The two-structure problem + +Every UI framework must express two distinct but intertwined structures: + +1. **Layout** — tree-shaped. Components are nested inside containers. +2. **Events / control flow** — graph-shaped, often circular. A button may need to reference an input field (to clear it on click), and the input field may need to reference the button (to enable or disable it). This mutual dependency cannot be expressed as a tree. + +Different frameworks resolve this tension differently: + +- **Smalltalk**: layout as data, events as side effects. Recursion handled by delayed state mutation. +- **FRP**: layout as data, events as first-class streams. Recursion handled via fixed-point combinators, but requires higher-order functional style. +- **Immediate mode / Jetpack Compose**: layout as an *effect* (mutating implicit global state). The call stack implicitly represents the layout tree, eliminating one explicit structure. Events are values; the reactive runtime re-runs affected computations. + +Our approach follows the Jetpack Compose model but makes the implicit capabilities explicit via context parameters. The "immediate mode re-runs everything each frame" behaviour is an artifact of game-engine contexts and languages with limited graph-structure support — not a fundamental property of the model. + +### Solving the circular event dependency + +The circular dependency between components is broken by **signals**: shared reactive values that components reference indirectly. Neither component holds a reference to the other; both hold a reference to the same `Signal`. This is the "events as values" solution. + +Rather than FRP's higher-order style (`signal.map(v => ...)`) the reactive context is a capability. Reading a signal inside a component body — `signal.get` — implicitly registers the component as a subscriber via the `ComponentContext` in scope. This is the "events as capabilities" solution: first-order code, no explicit subscription plumbing. + +## Key types + +### Rendering + +- **`Cell(codePoint: Int, style: Style)`** — a single terminal cell. `codePoint = 0` is the continuation sentinel for the right half of wide characters. +- **`Buffer(width, height)`** — row-major flat array of cells. Out-of-bounds writes are silently ignored (component isolation). Key methods: `put`, `fill`, `putString`, `render`, `renderDiff`. +- **`Rect(x, y, width, height)`** — 0-based position + size. 1-based conversion happens only inside `Buffer.render`. +- **`Style`** — cell-level attributes: fg/bg color, bold, italic, underline, blink, invert, strikethrough. + +### Capabilities + +- **`RenderContext`** — layout effect. Components add children to it; the call stack implicitly encodes the layout tree. `RootContext` flushes to terminal; `ChildContext` renders into a buffer region. +- **`EventContext`** — the reactive runtime. Owns signals (their lifetime is tied to the context), registers key handlers, and schedules re-renders when signals change. +- **`ComponentContext`** — the reactive scope for a single component during render. `Signal.get` uses it to register the component as a subscriber. When the signal changes, `invalidate()` is called on all subscribers, triggering a re-render. + +### Signals + +```scala +trait ReadSignal[A]: + def get: A + +trait Signal[A] extends ReadSignal[A]: + def set(a: A): Unit + def update(f: A => A): Unit = set(f(get)) +``` + +Signals are created and owned by an `EventContext`. Their lifetime matches the context's lifetime — useful for screen-level resource management. + +Currently `get` has no tracking: any signal change triggers a full-frame re-render regardless of which components read that signal. The intended future API is `get(using ComponentContext)` (tracked) and `peek: A` (untracked, for use in event handlers), which would enable subtree re-rendering. + +### `AppContext` and content type aliases + +`AppContext` is a trait combining `ComponentContext`, `EventContext`, and `RenderContext`. It is the single context type passed through the component tree. Layout components (Row, Column) use `AppContext.child(parent, rc)` to produce a child context that delegates signal creation and event registration to the parent but uses a new `RenderContext` for sub-layout. + +To avoid verbose context function types at every call site: + +```scala +// Content of a leaf component (e.g. Text): can read signals, no sub-layout +type LeafContent[A] = ComponentContext ?=> A + +// Body of a layout component (e.g. Row, Column): full reactive + layout context +type AppContent[A] = AppContext ?=> A +``` + +### Component trait + +```scala +trait Component: + def size: Size + def render(bounds: Rect, buf: Buffer): Unit +``` + +## Layout + +Two-phase: layout pass (size accumulation via `add`) then render pass (writing to buffer via `render`). + +- **`Row`** — horizontal layout, accumulates x offsets. +- **`Column`** — vertical layout, accumulates y offsets. +- **`FullScreen`** — root context, creates buffer sized to content (not terminal dimensions — avoids `effect.Dimensions` which is not implemented on Scala Native). + +## Lifecycle + +1. **Setup** (runs once): the app function is called with `EventContext` and `RenderContext` in scope. Signals are created, key handlers registered, and the component tree built. +2. **Render** (runs on signal change): the component tree's render methods are called. Leaf components evaluate their `LeafContent` closures with a fresh `ComponentContext`, which re-registers signal dependencies and returns the current value. +3. **Event dispatch**: the event loop reads a key, dispatches to registered handlers (global handlers first, then focused component — focus model TBD). Handlers update signals via `set` or `update`. +4. **Invalidation and re-render**: `Signal.set` calls `invalidate()` on all subscribed `ComponentContext`s. Initially this triggers a full-frame re-render; later, subtree re-rendering will be possible once bounds are captured in the `ComponentContext`. + +## Effect layer + +`terminus.ui.Terminal = effect.AlternateScreenMode & effect.Erase & effect.Color & effect.Cursor & effect.Format & effect.Writer` + +Components do not receive `Terminal`. Only `Buffer.render` / `Buffer.renderDiff` touch the terminal. + +The interactive event loop additionally requires `effect.KeyReader & effect.RawMode`. + +## Wide character support + +`CharWidth.of(codePoint)` returns 0, 1, or 2 per Unicode TR#11. Wide characters occupy two cells: left cell holds the character, right cell holds `Cell.continuation`. `putString` handles this automatically. + +## Double buffering + +`Buffer.renderDiff(previous)` compares two same-sized buffers and emits only changed cells, with cursor-position tracking to suppress redundant `cursor.to` calls for adjacent changes. + +## Deferred work + +- **Focus model**: the most immediate missing piece. Event dispatch currently only uses global handlers registered via `AppContext.onKey`. A focused component should receive key events first; global handlers intercept only what the focused component doesn't consume. Focus will need to be a signal (so components can react to focus changes) and the event loop needs to track the currently focused component. + +- **Dynamic layout**: component sizes are currently fixed at construction time (e.g. `Text(width, height)`). The layout pass runs once during setup, so components cannot resize in response to signal changes. Dynamic layout requires either re-running the setup phase on resize, or a proper two-pass layout system (measure → arrange) that runs each frame. + +- **More components**: at minimum, a text input / field component is needed for interactive use. + +- **Subtree re-rendering**: `ComponentContext` currently triggers a full-frame re-render. To support subtree re-rendering, `ComponentContext` needs to capture the component's bounds and buffer reference so it can re-render only the affected region. Depends on signal tracking (`get(using ComponentContext)`) being implemented first. + +- **Derived signals**: `ReadSignal.map` is deferred. Manual handler functions updating signals are sufficient for now. + +- **Resize handling**: terminal resize events (`SIGWINCH`) are not yet handled. Dynamic layout is a prerequisite for this to be useful. + +- **`effect.Dimensions` on Scala Native**: requires Posix `ioctl` — may be better to contribute upstream than implement in Terminus. diff --git a/project/Dependencies.scala b/project/Dependencies.scala deleted file mode 100644 index 4863ded..0000000 --- a/project/Dependencies.scala +++ /dev/null @@ -1,45 +0,0 @@ -import sbt.* -import org.scalajs.sbtplugin.ScalaJSPlugin -import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.* -import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport.* - -object Dependencies { - // Library Versions - val catsVersion = "2.13.0" - val catsEffectVersion = "3.5.7" - val fs2Version = "3.11.0" - - val scalatagsVersion = "0.13.1" - val scalajsDomVersion = "2.8.0" - - val jlineVersion = "3.29.0" - - val scalaCheckVersion = "1.15.4" - val munitVersion = "1.1.0" - val munitScalacheckVersion = "1.1.0" - val munitCatsEffectVersion = "2.0.0" - - // Libraries - val catsEffect = - Def.setting("org.typelevel" %%% "cats-effect" % catsEffectVersion) - val catsCore = Def.setting("org.typelevel" %%% "cats-core" % catsVersion) - val catsFree = Def.setting("org.typelevel" %%% "cats-free" % catsVersion) - val fs2 = Def.setting("co.fs2" %%% "fs2-core" % fs2Version) - - val scalatags = Def.setting("com.lihaoyi" %%% "scalatags" % scalatagsVersion) - val scalajsDom = - Def.setting("org.scala-js" %%% "scalajs-dom" % scalajsDomVersion) - - val jline = - Def.setting("org.jline" % "jline" % jlineVersion) - - val munit = Def.setting("org.scalameta" %%% "munit" % munitVersion % "test") - val munitScalaCheck = - Def.setting( - "org.scalameta" %%% "munit-scalacheck" % munitScalacheckVersion % "test" - ) - val munitCatsEffect = - Def.setting( - "org.typelevel" %%% "munit-cats-effect" % munitCatsEffectVersion % Test - ) -} diff --git a/project/build.properties b/project/build.properties index e88a0d8..df061f4 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.6 +sbt.version=1.12.9 diff --git a/project/plugins.sbt b/project/plugins.sbt index c237a3b..d317226 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,9 +1,9 @@ -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.18.2") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.21.0") addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.4") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") -addSbtPlugin("com.github.sbt" % "sbt-unidoc" % "0.5.0") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.2") -addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.7.7") -addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.7.7") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.7") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.0") +addSbtPlugin("com.github.sbt" % "sbt-unidoc" % "0.6.1") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.6") +addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.8.5") +addSbtPlugin("org.typelevel" % "sbt-typelevel-site" % "0.8.5") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.11") addSbtPlugin("org.creativescala" % "creative-scala-theme" % "0.5.2") diff --git a/ui/native/src/main/scala/terminus/ui/Example.scala b/ui/native/src/main/scala/terminus/ui/Example.scala new file mode 100644 index 0000000..df39c19 --- /dev/null +++ b/ui/native/src/main/scala/terminus/ui/Example.scala @@ -0,0 +1,120 @@ +/* + * 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.ui + +import terminus.Key +import terminus.NativeTerminal +import terminus.ui.component.Text +import terminus.ui.style.Color +import terminus.ui.style.ComponentStyle +import terminus.ui.style.Style +import terminus.ui.style.Underline + +// Run with: sbt 'uiNative/runMain terminus.ui.demo' +@main def demo(): Unit = + val program: FullScreen.Program[Unit] = + FullScreen { + + // Row 1: text style attributes + Row { + Text(24, 3, content = Style(bold = true))( + "Bold 💪" + ) + Text(24, 3, content = Style(italic = true))( + "Italic ✨" + ) + Text(24, 3, content = Style(strikethrough = true))( + "Strikethrough ❌" + ) + } + + // Row 2: underline variants and invert + Row { + Text(24, 3, content = Style(underline = Underline.Straight))( + "Straight underline" + ) + Text(24, 3, content = Style(underline = Underline.Curly))( + "Curly underline" + ) + Text(24, 3, content = Style(invert = true))( + "Inverted 🔄" + ) + } + + // Row 3: component styling — coloured borders and background fill + Row { + Column { + Text( + 24, + 3, + box = ComponentStyle(borderStyle = Style(fg = Color.Red)), + content = Style(fg = Color.Red, bold = true) + )("🔴 Red — 红色") + Text( + 24, + 3, + box = ComponentStyle(borderStyle = Style(fg = Color.Green)), + content = Style(fg = Color.Green, bold = true) + )("🟢 Green — 緑") + Text( + 24, + 3, + box = ComponentStyle(borderStyle = Style(fg = Color.Blue)), + content = Style(fg = Color.Blue, bold = true) + )("🔵 Blue — 青色") + } + Text( + 24, + 9, + box = ComponentStyle( + background = Style(bg = Color.Yellow), + borderStyle = Style(fg = Color.BrightBlack) + ), + content = Style(fg = Color.Black, bg = Color.Yellow) + )("Column on the left\nhas coloured\nborders.") + } + } + + NativeTerminal.run { + program + Terminal.newline + } + +// Run with: sbt 'uiNative/runMain terminus.ui.interactiveDemo' +@main def interactiveDemo(): Unit = + val program: FullScreen.InteractiveProgram[Unit] = + FullScreen.run { ctx ?=> + val count = ctx.createSignal(0) + + ctx.onKey(Key.up) { count.update(_ + 1) } + ctx.onKey(Key.down) { count.update(_ - 1) } + ctx.onKey(Key('q')) { ctx.stop() } + ctx.onKey(Key.controlC) { ctx.stop() } + + Column { + Text(40, 3)( + s"Count: ${count.get} (↑/↓ to change, q to quit)" + ) + Text(40, 3)( + if count.get > 0 then "Positive" + else if count.get < 0 then "Negative" + else "Zero" + ) + } + } + + NativeTerminal.run(program) diff --git a/ui/shared/src/main/scala/terminus/ui/AppContext.scala b/ui/shared/src/main/scala/terminus/ui/AppContext.scala new file mode 100644 index 0000000..ce14ac6 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/AppContext.scala @@ -0,0 +1,61 @@ +/* + * 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.ui + +import terminus.Key + +/** Combined capability context for interactive component bodies. + * + * Bundles [[ComponentContext]], [[EventContext]], and [[RenderContext]] into a + * single capability so layout components (Row, Column) can thread all three + * through their bodies without requiring three separate context parameters. + * + * Layout components create a child AppContext that forwards ComponentContext + * and EventContext from the parent while substituting their own RenderContext. + */ +trait AppContext extends ComponentContext, EventContext, RenderContext + +/** Content of a leaf component (e.g. Text). + * + * The [[ComponentContext]] in scope enables signal reads to register the + * component as a subscriber for future subtree re-render support. + */ +type LeafContent[A] = ComponentContext ?=> A + +/** Body of a layout component (e.g. Row, Column) or the top-level app body. + * + * Provides the full reactive + layout capability set: signals, key handlers, + * and sub-component layout. + */ +type AppContent[A] = AppContext ?=> A + +object AppContext: + + /** Creates a child AppContext that delegates [[ComponentContext]] and + * [[EventContext]] to the parent, but uses rc as the [[RenderContext]]. + * + * Used by layout components (Row, Column) to substitute their own layout + * context while preserving the reactive context from the parent. + */ + def child(parent: AppContext, rc: RenderContext): AppContext = + new AppContext: + private[ui] def invalidate(): Unit = parent.invalidate() + def createSignal[A](initial: A): Signal[A] = parent.createSignal(initial) + def onKey(key: Key)(handler: => Unit): Unit = parent.onKey(key)(handler) + def stop(): Unit = parent.stop() + def size: Size = rc.size + def add(component: Component): Unit = rc.add(component) diff --git a/ui/shared/src/main/scala/terminus/ui/Buffer.scala b/ui/shared/src/main/scala/terminus/ui/Buffer.scala new file mode 100644 index 0000000..55ed8fe --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Buffer.scala @@ -0,0 +1,229 @@ +/* + * 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.ui + +import terminus.effect.AnsiCodes +import terminus.ui.style.Color +import terminus.ui.style.Style + +/** A 2D grid of terminal cells that components render into. + * + * Coordinates are 0-based. Out-of-bounds writes are silently ignored, + * providing isolation between components — a buggy component cannot corrupt + * cells outside its allocated region. + * + * Call [[render]] once all components have written to the buffer to flush the + * contents to the terminal. + */ +final class Buffer(val width: Int, val height: Int): + // Row-major flat array: index = y * width + x + private val cells: Array[Cell] = Array.fill(width * height)(Cell.empty) + + /** Write a single cell at (x, y). Out-of-bounds writes are ignored. */ + def put(x: Int, y: Int, cell: Cell): Unit = + if x >= 0 && x < width && y >= 0 && y < height then + cells(y * width + x) = cell + + /** Fill a rectangular region with a cell. Clips to buffer bounds. */ + def fill(rect: Rect, cell: Cell): Unit = + val x0 = rect.x.max(0) + val y0 = rect.y.max(0) + val x1 = rect.right.min(width) + val y1 = rect.bottom.min(height) + var y = y0 + while y < y1 do + var x = x0 + while x < x1 do + cells(y * width + x) = cell + x += 1 + y += 1 + + /** Write a string starting at (x, y), wrapping on newline characters. + * + * Iterates over Unicode code points (not raw Java chars) so that characters + * outside the Basic Multilingual Plane — including most emoji — are handled + * correctly. Wide characters (display width 2) occupy two buffer cells: the + * left cell holds the character and the right cell holds + * [[Cell.continuation]] as a sentinel. Zero-width code points (combining + * marks, variation selectors, ZWJ) are skipped; full grapheme cluster + * composition is not currently supported. Newline characters (`\n`) advance + * to the next row and reset the column back to [[x]]. Clips to buffer + * bounds. + */ + def putString(x: Int, y: Int, s: String, style: Style): Unit = + var col = x + var row = y + var i = 0 + while i < s.length do + val cp = Character.codePointAt(s, i) + i += Character.charCount(cp) + if cp == '\n'.toInt then + col = x + row += 1 + else + CharWidth.of(cp) match + case 0 => () // zero-width: skip + case 1 => + put(col, row, Cell(cp, style)) + col += 1 + case _ => // 2 + put(col, row, Cell(cp, style)) + put(col + 1, row, Cell.continuation) + col += 2 + + /** Flush the entire buffer to the terminal. + * + * Iterates cells row by row, emitting SGR attribute codes only when style + * changes, and using absolute cursor positioning at the start of each row. + * Continuation cells (right half of wide characters) are skipped. Emits a + * full SGR reset before and after rendering. + */ + def render(using t: Terminal): Unit = + t.write(AnsiCodes.sgr("0")) + var currentStyle = Style.default + var y = 0 + while y < height do + t.cursor.to(1, y + 1) // 1-based terminal coordinates + var x = 0 + while x < width do + val cell = cells(y * width + x) + if cell.codePoint != 0 then // skip continuation cells + currentStyle = emitStyle(currentStyle, cell.style) + t.write(new String(Character.toChars(cell.codePoint))) + x += 1 + y += 1 + t.write(AnsiCodes.sgr("0")) + t.flush() + + /** Flush only cells that differ from [[previous]] to the terminal. + * + * Compares this buffer against [[previous]] cell by cell and emits only + * changed positions, minimising the number of escape codes written. Cursor + * movement is suppressed when changed cells are horizontally adjacent, + * avoiding a `cursor.to` call for every single character in a long run of + * changes. + * + * The two buffers must have the same dimensions. [[previous]] is not + * modified; callers are responsible for swapping or copying buffers between + * frames. + */ + def renderDiff(previous: Buffer)(using t: Terminal): Unit = + require( + width == previous.width && height == previous.height, + s"Buffer dimensions must match for diff render: ${width}x${height} vs ${previous.width}x${previous.height}" + ) + t.write(AnsiCodes.sgr("0")) + var currentStyle = Style.default + // Track where the terminal cursor currently is (0-based). + // (-1, -1) means "unknown / needs explicit move". + var cursorX = -1 + var cursorY = -1 + var y = 0 + while y < height do + var x = 0 + while x < width do + val curr = cells(y * width + x) + val prev = previous.cells(y * width + x) + if curr != prev then + if curr.codePoint != 0 then // skip continuation cells + if !(cursorX == x && cursorY == y) then + t.cursor.to(x + 1, y + 1) // 1-based + currentStyle = emitStyle(currentStyle, curr.style) + t.write(new String(Character.toChars(curr.codePoint))) + // Wide chars advance the cursor by 2; narrow by 1 + val advance = CharWidth.of(curr.codePoint).max(1) + cursorX = x + advance + cursorY = y + x += 1 + y += 1 + t.write(AnsiCodes.sgr("0")) + t.flush() + + /** Emit SGR codes for any attributes that differ between [[from]] and [[to]], + * and return [[to]] as the new current style. + */ + private def emitStyle(from: Style, to: Style)(using t: Terminal): Style = + if to != from then + if to.fg != from.fg then t.write(fgCode(to.fg)) + if to.bg != from.bg then t.write(bgCode(to.bg)) + if to.bold != from.bold then + t.write(if to.bold then AnsiCodes.format.bold.on + else AnsiCodes.format.bold.off) + if to.italic != from.italic then + t.write(if to.italic then AnsiCodes.format.italic.on + else AnsiCodes.format.italic.off) + if to.underline != from.underline then + t.write(underlineCode(to.underline)) + if to.blink != from.blink then + t.write(if to.blink then AnsiCodes.format.blink.on + else AnsiCodes.format.blink.off) + if to.invert != from.invert then + t.write(if to.invert then AnsiCodes.format.invert.on + else AnsiCodes.format.invert.off) + if to.strikethrough != from.strikethrough then + t.write(if to.strikethrough then AnsiCodes.format.strikethrough.on + else AnsiCodes.format.strikethrough.off) + to + + private def underlineCode(underline: style.Underline): String = + underline match + case style.Underline.None => AnsiCodes.format.underline.off + case style.Underline.Straight => AnsiCodes.format.underline.straight + case style.Underline.Double => AnsiCodes.format.underline.double + case style.Underline.Curly => AnsiCodes.format.underline.curly + case style.Underline.Dotted => AnsiCodes.format.underline.dotted + case style.Underline.Dashed => AnsiCodes.format.underline.dashed + + private def fgCode(color: Color): String = + color match + case Color.Default => AnsiCodes.foreground.default + case Color.Black => AnsiCodes.foreground.black + case Color.Red => AnsiCodes.foreground.red + case Color.Green => AnsiCodes.foreground.green + case Color.Yellow => AnsiCodes.foreground.yellow + case Color.Blue => AnsiCodes.foreground.blue + case Color.Magenta => AnsiCodes.foreground.magenta + case Color.Cyan => AnsiCodes.foreground.cyan + case Color.White => AnsiCodes.foreground.white + case Color.BrightBlack => AnsiCodes.foreground.brightBlack + case Color.BrightRed => AnsiCodes.foreground.brightRed + case Color.BrightGreen => AnsiCodes.foreground.brightGreen + case Color.BrightYellow => AnsiCodes.foreground.brightYellow + case Color.BrightBlue => AnsiCodes.foreground.brightBlue + case Color.BrightMagenta => AnsiCodes.foreground.brightMagenta + case Color.BrightCyan => AnsiCodes.foreground.brightCyan + case Color.BrightWhite => AnsiCodes.foreground.brightWhite + + private def bgCode(color: Color): String = + color match + case Color.Default => AnsiCodes.background.default + case Color.Black => AnsiCodes.background.black + case Color.Red => AnsiCodes.background.red + case Color.Green => AnsiCodes.background.green + case Color.Yellow => AnsiCodes.background.yellow + case Color.Blue => AnsiCodes.background.blue + case Color.Magenta => AnsiCodes.background.magenta + case Color.Cyan => AnsiCodes.background.cyan + case Color.White => AnsiCodes.background.white + case Color.BrightBlack => AnsiCodes.background.brightBlack + case Color.BrightRed => AnsiCodes.background.brightRed + case Color.BrightGreen => AnsiCodes.background.brightGreen + case Color.BrightYellow => AnsiCodes.background.brightYellow + case Color.BrightBlue => AnsiCodes.background.brightBlue + case Color.BrightMagenta => AnsiCodes.background.brightMagenta + case Color.BrightCyan => AnsiCodes.background.brightCyan + case Color.BrightWhite => AnsiCodes.background.brightWhite diff --git a/ui/shared/src/main/scala/terminus/ui/Cell.scala b/ui/shared/src/main/scala/terminus/ui/Cell.scala new file mode 100644 index 0000000..c898b0b --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Cell.scala @@ -0,0 +1,41 @@ +/* + * 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.ui + +import terminus.ui.style.Style + +/** A single terminal cell: a Unicode code point with associated styling. + * + * The code point is stored as an [[Int]] to accommodate the full Unicode + * range, including characters outside the Basic Multilingual Plane (code + * points > U+FFFF) such as emoji. During rendering, [[Character.toChars]] is + * used to convert back to the one or two [[Char]] values needed by the + * terminal. + * + * Use [[Cell.empty]] for an unstyled space and [[Cell.continuation]] to mark + * the right half of a wide (2-cell) character. + */ +final case class Cell(codePoint: Int, style: Style) +object Cell: + /** An unstyled space cell — the default state of every buffer position. */ + val empty: Cell = Cell(' '.toInt, Style.default) + + /** Sentinel marking the right half of a wide (2-cell) character. The flush + * loop skips these cells; the preceding wide character already occupies both + * columns. + */ + val continuation: Cell = Cell(0, Style.default) diff --git a/ui/shared/src/main/scala/terminus/ui/CharWidth.scala b/ui/shared/src/main/scala/terminus/ui/CharWidth.scala new file mode 100644 index 0000000..a9008f6 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/CharWidth.scala @@ -0,0 +1,115 @@ +/* + * 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.ui + +/** Terminal display width of Unicode code points. + * + * Terminals render characters into cells. Most characters occupy one cell, but + * East Asian Wide characters (CJK ideographs, many emoji, etc.) occupy two, + * and combining/zero-width characters occupy zero. + * + * The ranges here follow Unicode TR#11 (East Asian Width) and the widely-used + * wcwidth conventions. They cover the most common wide characters: CJK + * ideographs, Hangul, Japanese kana, fullwidth forms, and emoji. + */ +object CharWidth: + + /** Returns the number of terminal columns a code point occupies: 0, 1, or 2. + * + * Zero-width code points (combining marks, variation selectors, ZWJ) should + * be attached to the preceding character; callers that do not support + * grapheme cluster composition may ignore them. + */ + def of(codePoint: Int): Int = + if isZeroWidth(codePoint) then 0 + else if isWide(codePoint) then 2 + else 1 + + private def isZeroWidth(cp: Int): Boolean = + cp == 0 || + // Combining Diacritical Marks + (cp >= 0x0300 && cp <= 0x036f) || + // Combining Diacritical Marks Extended / Supplement + (cp >= 0x1ab0 && cp <= 0x1aff) || + (cp >= 0x1dc0 && cp <= 0x1dff) || + // Hebrew / Arabic combining + (cp >= 0x0591 && cp <= 0x05bd) || + cp == 0x05bf || + (cp >= 0x05c1 && cp <= 0x05c2) || + (cp >= 0x05c4 && cp <= 0x05c5) || + cp == 0x05c7 || + (cp >= 0x0610 && cp <= 0x061a) || + (cp >= 0x064b && cp <= 0x065f) || + cp == 0x0670 || + (cp >= 0x06d6 && cp <= 0x06e4) || + (cp >= 0x06e7 && cp <= 0x06e8) || + (cp >= 0x06ea && cp <= 0x06ed) || + // Zero-width spaces / joiners + (cp >= 0x200b && cp <= 0x200f) || + cp == 0x200d || // Zero Width Joiner + // Variation Selectors (text/emoji presentation) + (cp >= 0xfe00 && cp <= 0xfe0f) || + (cp >= 0xe0100 && cp <= 0xe01ef) || + // BOM / Zero Width No-Break Space + cp == 0xfeff || + // Combining Half Marks + (cp >= 0xfe20 && cp <= 0xfe2f) || + // Tags block (used in emoji flag sequences) + (cp >= 0xe0000 && cp <= 0xe007f) + + private def isWide(cp: Int): Boolean = + // Hangul Jamo + (cp >= 0x1100 && cp <= 0x115f) || + // CJK brackets + cp == 0x2329 || cp == 0x232a || + // Miscellaneous Symbols (☀ ⚠ etc.) — many displayed as emoji in modern terminals + (cp >= 0x2600 && cp <= 0x26ff) || + // Dingbats (✨ ✅ ❌ etc.) + (cp >= 0x2700 && cp <= 0x27bf) || + // CJK Radicals Supplement .. CJK Symbols and Punctuation + (cp >= 0x2e80 && cp <= 0x303e) || + // Hiragana .. CJK Unified Ideographs Extension A + (cp >= 0x3040 && cp <= 0x4dbf) || + // CJK Unified Ideographs .. Yi Radicals + (cp >= 0x4e00 && cp <= 0xa4cf) || + // Hangul Jamo Extended-A + (cp >= 0xa960 && cp <= 0xa97f) || + // Hangul Syllables .. Hangul Jamo Extended-B + (cp >= 0xac00 && cp <= 0xd7ff) || + // CJK Compatibility Ideographs + (cp >= 0xf900 && cp <= 0xfaff) || + // Vertical Forms + (cp >= 0xfe10 && cp <= 0xfe19) || + // CJK Compatibility Forms .. Small Form Variants + (cp >= 0xfe30 && cp <= 0xfe6f) || + // Fullwidth Latin, punctuation, and signs + (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || + // Kana Supplement / Extended-A / Extended-B + (cp >= 0x1b000 && cp <= 0x1b2ff) || + // Enclosed Ideographic Supplement + (cp >= 0x1f004 && cp <= 0x1f004) || + (cp >= 0x1f0cf && cp <= 0x1f0cf) || + (cp >= 0x1f18b && cp <= 0x1f251) || + // Miscellaneous Symbols and Pictographs .. Supplemental Symbols and + // Pictographs (includes Geometric Shapes Extended U+1F780–U+1F7FF, + // where coloured circle emoji such as 🟢 U+1F7E2 live) + (cp >= 0x1f300 && cp <= 0x1faff) || + // CJK Unified Ideographs Extension B and beyond + (cp >= 0x20000 && cp <= 0x2fffd) || + // CJK Unified Ideographs Extension G and beyond + (cp >= 0x30000 && cp <= 0x3fffd) diff --git a/ui/shared/src/main/scala/terminus/ui/Column.scala b/ui/shared/src/main/scala/terminus/ui/Column.scala new file mode 100644 index 0000000..95d64a6 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Column.scala @@ -0,0 +1,47 @@ +/* + * 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.ui + +import scala.collection.mutable + +class Column() extends ChildContext, Component: + private val children: mutable.ArrayBuffer[Component] = + mutable.ArrayBuffer.empty + + def size: Size = + children.foldLeft(Size.zero)((acc, c) => acc.column(c.size)) + + def add(component: Component): Unit = + children += component + + def render(bounds: Rect, buf: Buffer): Unit = + var y = bounds.y + children.foreach { child => + val childSize = child.size + child.render(Rect(bounds.x, y, childSize.width, childSize.height), buf) + y += childSize.height + } + +object Column: + def apply[A]( + f: AppContent[A] + )(using parent: AppContext): A = + val column = new Column() + given AppContext = AppContext.child(parent, column) + val result = f + parent.add(column) + result diff --git a/ui/shared/src/main/scala/terminus/ui/Component.scala b/ui/shared/src/main/scala/terminus/ui/Component.scala new file mode 100644 index 0000000..a561b7e --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Component.scala @@ -0,0 +1,24 @@ +/* + * 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.ui + +/** A component is something that can be rendered to the terminal and + * participates in layout. + */ +trait Component: + def size: Size + def render(bounds: Rect, buf: Buffer): Unit diff --git a/ui/shared/src/main/scala/terminus/ui/ComponentContext.scala b/ui/shared/src/main/scala/terminus/ui/ComponentContext.scala new file mode 100644 index 0000000..46e1006 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/ComponentContext.scala @@ -0,0 +1,30 @@ +/* + * 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.ui + +/** Reactive scope for a component render pass. + * + * Signal reads inside a component's content use the ComponentContext in scope + * to register the component as a subscriber, so that signal changes can + * trigger a targeted re-render. + * + * For the initial full-frame re-render implementation this is a stub; the full + * dependency-tracking mechanism will be wired in when subtree re-render is + * implemented. + */ +trait ComponentContext: + private[ui] def invalidate(): Unit diff --git a/ui/shared/src/main/scala/terminus/ui/EventContext.scala b/ui/shared/src/main/scala/terminus/ui/EventContext.scala new file mode 100644 index 0000000..a39e399 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/EventContext.scala @@ -0,0 +1,61 @@ +/* + * 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.ui + +import terminus.Key + +import scala.collection.mutable + +/** The reactive runtime for an interactive UI screen. + * + * Creates and owns [[Signal]]s (their lifetime matches this context's), + * registers key handlers, and drives re-renders when signals change. + * + * A new EventContext per screen gives each screen its own isolated reactive + * graph and key handler table, which is automatically cleaned up when the + * screen is left. + */ +trait EventContext: + def createSignal[A](initial: A): Signal[A] + def onKey(key: Key)(handler: => Unit): Unit + + /** Stop the event loop, causing [[FullScreen.run]] to return. */ + def stop(): Unit + +private[ui] final class EventContextImpl extends EventContext: + private var _needsRerender: Boolean = false + private var _running: Boolean = true + private val keyHandlers: mutable.Map[Key, mutable.ListBuffer[() => Unit]] = + mutable.Map.empty + + private[ui] def needsRerender: Boolean = _needsRerender + private[ui] def running: Boolean = _running + private[ui] def clearRerender(): Unit = _needsRerender = false + private[ui] def scheduleRerender(): Unit = _needsRerender = true + + def createSignal[A](initial: A): Signal[A] = + new SignalImpl(initial, this) + + def onKey(key: Key)(handler: => Unit): Unit = + keyHandlers.getOrElseUpdate(key, mutable.ListBuffer.empty) += (() => + handler + ) + + def stop(): Unit = _running = false + + private[ui] def dispatch(key: Key): Unit = + keyHandlers.get(key).foreach(_.foreach(_.apply())) diff --git a/ui/shared/src/main/scala/terminus/ui/FullScreen.scala b/ui/shared/src/main/scala/terminus/ui/FullScreen.scala new file mode 100644 index 0000000..abe9f96 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/FullScreen.scala @@ -0,0 +1,139 @@ +/* + * 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.ui + +import terminus.AlternateScreenMode +import terminus.Cursor +import terminus.Erase +import terminus.Key +import terminus.KeyReader +import terminus.RawMode +import terminus.Writer +import terminus.effect + +import scala.collection.mutable + +/** A RootContext that acts as a column and renders into a cell buffer. */ +class FullScreen() extends RootContext: + private val children: mutable.ArrayBuffer[Component] = + mutable.ArrayBuffer.empty + + def size: Size = + children.foldLeft(Size.zero)((acc, c) => acc.column(c.size)) + + def add(component: Component): Unit = + children += component + + private[ui] def toBuffer(): Buffer = + val currentSize = size + val buf = Buffer(currentSize.width, currentSize.height) + var y = 0 + children.foreach { child => + val childSize = child.size + child.render(Rect(0, y, childSize.width, childSize.height), buf) + y += childSize.height + } + buf + + def render(using Terminal): Unit = + toBuffer().render + +object FullScreen: + type Terminal = effect.AlternateScreenMode & effect.Erase & + terminus.ui.Terminal + type Program[A] = FullScreen.Terminal ?=> A + + type InteractiveTerminal = + FullScreen.Terminal & effect.KeyReader & effect.RawMode + type InteractiveProgram[A] = InteractiveTerminal ?=> A + + object Terminal extends AlternateScreenMode, Cursor, Erase, Writer + object InteractiveTerminal + extends AlternateScreenMode, + Cursor, + Erase, + KeyReader, + RawMode, + Writer + + def apply[A](f: AppContent[A]): FullScreen.Program[A] = + val fullScreen = new FullScreen() + given AppContext = noopAppContext(fullScreen) + val result = f + FullScreen.Terminal.erase.screen() + fullScreen.render + result + + /** Run an interactive event loop. + * + * `f` is called once to build the component tree and register event + * handlers. The tree is then rendered, and the loop blocks on key input, + * dispatching to registered handlers and re-rendering whenever a signal + * changes. The loop exits when [[EventContext.stop]] is called or stdin + * reaches EOF. + */ + def run(f: AppContent[Unit]): InteractiveProgram[Unit] = t ?=> + val ec = new EventContextImpl() + val fullScreen = new FullScreen() + + given AppContext = new AppContext: + private[ui] def invalidate(): Unit = ec.scheduleRerender() + def createSignal[A](initial: A): Signal[A] = ec.createSignal(initial) + def onKey(key: Key)(handler: => Unit): Unit = ec.onKey(key)(handler) + def stop(): Unit = ec.stop() + def size: Size = fullScreen.size + def add(component: Component): Unit = fullScreen.add(component) + + f // build component tree and register handlers + + InteractiveTerminal.cursor.hidden { + InteractiveTerminal.raw { + InteractiveTerminal.erase.screen() + + var prevBuffer: Option[Buffer] = None + + def renderFrame(): Unit = + val buf = fullScreen.toBuffer() + prevBuffer match + case None => buf.render + case Some(p) => buf.renderDiff(p) + prevBuffer = Some(buf) + ec.clearRerender() + + renderFrame() + + while ec.running do + InteractiveTerminal.readKey() match + case terminus.Eof => ec.stop() + case key: Key => + ec.dispatch(key) + if ec.needsRerender then renderFrame() + } + } + + /** A no-op AppContext for non-interactive (single-render) use. */ + private def noopAppContext(fullScreen: FullScreen): AppContext = + new AppContext: + private[ui] def invalidate(): Unit = () + def createSignal[A](initial: A): Signal[A] = new Signal[A]: + private var value = initial + def get: A = value + def set(a: A): Unit = value = a + def onKey(key: Key)(handler: => Unit): Unit = () + def stop(): Unit = () + def size: Size = fullScreen.size + def add(component: Component): Unit = fullScreen.add(component) diff --git a/ui/shared/src/main/scala/terminus/ui/Rect.scala b/ui/shared/src/main/scala/terminus/ui/Rect.scala new file mode 100644 index 0000000..5cb18d8 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Rect.scala @@ -0,0 +1,32 @@ +/* + * 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.ui + +/** The position and size of a component within the terminal grid. + * + * Coordinates are 0-based, with (0, 0) at the top-left. x increases to the + * right, y increases downward. Conversion to 1-based terminal coordinates + * happens only at flush time inside [[Buffer.render]]. + */ +final case class Rect(x: Int, y: Int, width: Int, height: Int): + def size: Size = Size(width, height) + + /** Exclusive right edge. */ + def right: Int = x + width + + /** Exclusive bottom edge. */ + def bottom: Int = y + height diff --git a/ui/shared/src/main/scala/terminus/ui/RenderContext.scala b/ui/shared/src/main/scala/terminus/ui/RenderContext.scala new file mode 100644 index 0000000..1ac14fa --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/RenderContext.scala @@ -0,0 +1,27 @@ +/* + * 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.ui + +trait RenderContext: + def size: Size + def add(component: Component): Unit + +trait RootContext extends RenderContext: + def render(using Terminal): Unit + +trait ChildContext extends RenderContext: + def render(bounds: Rect, buf: Buffer): Unit diff --git a/ui/shared/src/main/scala/terminus/ui/Row.scala b/ui/shared/src/main/scala/terminus/ui/Row.scala new file mode 100644 index 0000000..040ebc6 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Row.scala @@ -0,0 +1,47 @@ +/* + * 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.ui + +import scala.collection.mutable + +class Row() extends ChildContext, Component: + private val children: mutable.ArrayBuffer[Component] = + mutable.ArrayBuffer.empty + + def size: Size = + children.foldLeft(Size.zero)((acc, c) => acc.row(c.size)) + + def add(component: Component): Unit = + children += component + + def render(bounds: Rect, buf: Buffer): Unit = + var x = bounds.x + children.foreach { child => + val childSize = child.size + child.render(Rect(x, bounds.y, childSize.width, childSize.height), buf) + x += childSize.width + } + +object Row: + def apply[A]( + f: AppContent[A] + )(using parent: AppContext): A = + val row = new Row() + given AppContext = AppContext.child(parent, row) + val result = f + parent.add(row) + result diff --git a/ui/shared/src/main/scala/terminus/ui/Signal.scala b/ui/shared/src/main/scala/terminus/ui/Signal.scala new file mode 100644 index 0000000..a390264 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Signal.scala @@ -0,0 +1,43 @@ +/* + * 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.ui + +/** Read-only view of a reactive value. + * + * Passed to components that only need to observe a value, not change it. + */ +trait ReadSignal[A]: + def get: A + +/** A readable and writable reactive value owned by an [[EventContext]]. + * + * Calling [[set]] or [[update]] schedules a re-render of the UI. The signal's + * lifetime is tied to the [[EventContext]] that created it. + */ +trait Signal[A] extends ReadSignal[A]: + def set(a: A): Unit + def update(f: A => A): Unit = set(f(get)) + +private[ui] final class SignalImpl[A]( + private var value: A, + private val ec: EventContextImpl +) extends Signal[A]: + def get: A = value + + def set(a: A): Unit = + value = a + ec.scheduleRerender() diff --git a/ui/shared/src/main/scala/terminus/ui/Size.scala b/ui/shared/src/main/scala/terminus/ui/Size.scala new file mode 100644 index 0000000..2af470c --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Size.scala @@ -0,0 +1,34 @@ +/* + * 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.ui + +/** Represents the size of component in terms of temrinal cells. */ +final case class Size(width: Int, height: Int): + + /** Combine two sizes into a row. That is, add together width and take the max + * height. + */ + def row(that: Size): Size = + Size(this.width + that.width, this.height.max(that.height)) + + /** Combine two sizes into a column. That is, take the max of width and add + * together the height. + */ + def column(that: Size): Size = + Size(this.width.max(that.width), this.height + that.height) +object Size: + val zero: Size = Size(0, 0) diff --git a/ui/shared/src/main/scala/terminus/ui/Terminal.scala b/ui/shared/src/main/scala/terminus/ui/Terminal.scala new file mode 100644 index 0000000..1ce4be4 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Terminal.scala @@ -0,0 +1,24 @@ +/* + * 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.ui + +import terminus.effect + +/** The minimal terminal effects needed by a UI. */ +type Terminal = effect.Cursor & effect.Writer + +object Terminal extends terminus.Cursor, terminus.Writer diff --git a/ui/shared/src/main/scala/terminus/ui/component/Text.scala b/ui/shared/src/main/scala/terminus/ui/component/Text.scala new file mode 100644 index 0000000..7796258 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/component/Text.scala @@ -0,0 +1,50 @@ +/* + * 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.ui.component + +import terminus.ui.Buffer +import terminus.ui.Component +import terminus.ui.Rect +import terminus.ui.RenderContext +import terminus.ui.Size +import terminus.ui.style.ComponentStyle +import terminus.ui.style.Style +import terminus.ui.tool.Box + +object Text: + def component( + width: Int, + height: Int, + text: => String, + box: ComponentStyle = ComponentStyle.default, + content: Style = Style.default + ): Component = + new Component: + val size: Size = Size(width, height) + + def render(bounds: Rect, buf: Buffer): Unit = + Box.render(bounds, box, buf) + val inner = Box.innerRect(bounds, box) + buf.putString(inner.x, inner.y, text, content) + + def apply( + width: Int, + height: Int, + box: ComponentStyle = ComponentStyle.default, + content: Style = Style.default + )(text: => String)(using ctx: RenderContext): Unit = + ctx.add(component(width, height, text, box, content)) diff --git a/ui/shared/src/main/scala/terminus/ui/style/Border.scala b/ui/shared/src/main/scala/terminus/ui/style/Border.scala new file mode 100644 index 0000000..6c5be3d --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/style/Border.scala @@ -0,0 +1,53 @@ +/* + * 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.ui.style + +final case class Border( + topLeft: Char, + topRight: Char, + horizontal: Char, + vertical: Char, + bottomLeft: Char, + bottomRight: Char +) +object Border: + val single = Border( + topLeft = '┌', + topRight = '┐', + horizontal = '─', + vertical = '│', + bottomLeft = '└', + bottomRight = '┘' + ) + + val double = Border( + topLeft = '╔', + topRight = '╗', + horizontal = '═', + vertical = '║', + bottomLeft = '╚', + bottomRight = '╝' + ) + + val ascii = Border( + topLeft = '+', + topRight = '+', + horizontal = '-', + vertical = '|', + bottomLeft = '+', + bottomRight = '+' + ) diff --git a/ui/shared/src/main/scala/terminus/ui/style/Color.scala b/ui/shared/src/main/scala/terminus/ui/style/Color.scala new file mode 100644 index 0000000..ec943b0 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/style/Color.scala @@ -0,0 +1,37 @@ +/* + * 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.ui.style + +/** An ANSI terminal color, usable as a foreground or background cell color. */ +enum Color: + case Default + case Black + case Red + case Green + case Yellow + case Blue + case Magenta + case Cyan + case White + case BrightBlack + case BrightRed + case BrightGreen + case BrightYellow + case BrightBlue + case BrightMagenta + case BrightCyan + case BrightWhite diff --git a/ui/shared/src/main/scala/terminus/ui/style/ComponentStyle.scala b/ui/shared/src/main/scala/terminus/ui/style/ComponentStyle.scala new file mode 100644 index 0000000..d1be669 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/style/ComponentStyle.scala @@ -0,0 +1,42 @@ +/* + * 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.ui.style + +/** Component-level styling: border, background fill, and padding. + * + * This is distinct from the cell-level [[Style]], which controls text + * attributes (colour, bold, etc.). [[ComponentStyle]] controls the structural + * appearance of a component's bounding box. + * + * @param border + * The border to draw around the component. [[None]] means no border. + * @param borderStyle + * Cell-level style applied to border characters. + * @param background + * Cell-level style used to fill the interior (empty cells get this style). + * @param padding + * Number of cells between the border and the content on each side. + */ +final case class ComponentStyle( + border: Option[Border] = Some(Border.single), + borderStyle: Style = Style.default, + background: Style = Style.default, + padding: Int = 0 +) +object ComponentStyle: + val default: ComponentStyle = ComponentStyle() + val none: ComponentStyle = ComponentStyle(border = None) diff --git a/ui/shared/src/main/scala/terminus/ui/style/Style.scala b/ui/shared/src/main/scala/terminus/ui/style/Style.scala new file mode 100644 index 0000000..c7c945e --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/style/Style.scala @@ -0,0 +1,31 @@ +/* + * 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.ui.style + +/** Styling attributes for a single terminal cell. */ +final case class Style( + fg: Color = Color.Default, + bg: Color = Color.Default, + bold: Boolean = false, + italic: Boolean = false, + underline: Underline = Underline.None, + invert: Boolean = false, + strikethrough: Boolean = false, + blink: Boolean = false +) +object Style: + val default: Style = Style() diff --git a/ui/shared/src/main/scala/terminus/ui/style/Underline.scala b/ui/shared/src/main/scala/terminus/ui/style/Underline.scala new file mode 100644 index 0000000..b535418 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/style/Underline.scala @@ -0,0 +1,31 @@ +/* + * 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.ui.style + +/** The underline style for a terminal cell. + * + * [[Straight]] and [[None]] are universally supported. [[Double]], [[Curly]], + * [[Dotted]], and [[Dashed]] follow the Kitty extension and may not be + * supported by all terminals. + */ +enum Underline: + case None + case Straight + case Double + case Curly + case Dotted + case Dashed diff --git a/ui/shared/src/main/scala/terminus/ui/tool/Box.scala b/ui/shared/src/main/scala/terminus/ui/tool/Box.scala new file mode 100644 index 0000000..57d88d5 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/tool/Box.scala @@ -0,0 +1,87 @@ +/* + * 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.ui.tool + +import terminus.ui.Buffer +import terminus.ui.Cell +import terminus.ui.Rect +import terminus.ui.style.ComponentStyle + +object Box: + + /** The content rect inside a box: bounds shrunk by one cell for the border + * (if present) and then by [[ComponentStyle.padding]] on each side. + */ + def innerRect(bounds: Rect, style: ComponentStyle): Rect = + val offset = (if style.border.isDefined then 1 else 0) + style.padding + Rect( + bounds.x + offset, + bounds.y + offset, + bounds.width - 2 * offset, + bounds.height - 2 * offset + ) + + /** Draw a box into the buffer at the given bounds. + * + * Fills the interior with the background style, then draws the border (if + * present) using the border style. The minimum usable size when a border is + * present is 2×2. Out-of-bounds writes are silently clipped by the buffer. + */ + def render(bounds: Rect, style: ComponentStyle, buf: Buffer): Unit = + val borderOffset = if style.border.isDefined then 1 else 0 + + // Fill interior with background style + val fillRect = Rect( + bounds.x + borderOffset, + bounds.y + borderOffset, + bounds.width - 2 * borderOffset, + bounds.height - 2 * borderOffset + ) + if fillRect.width > 0 && fillRect.height > 0 then + buf.fill(fillRect, Cell(' '.toInt, style.background)) + + // Draw border if present + style.border.foreach { border => + val x0 = bounds.x + val y0 = bounds.y + val x1 = bounds.x + bounds.width - 1 + val y1 = bounds.y + bounds.height - 1 + val bs = style.borderStyle + + // Top row + buf.put(x0, y0, Cell(border.topLeft.toInt, bs)) + var x = x0 + 1 + while x < x1 do + buf.put(x, y0, Cell(border.horizontal.toInt, bs)) + x += 1 + buf.put(x1, y0, Cell(border.topRight.toInt, bs)) + + // Sides + var y = y0 + 1 + while y < y1 do + buf.put(x0, y, Cell(border.vertical.toInt, bs)) + buf.put(x1, y, Cell(border.vertical.toInt, bs)) + y += 1 + + // Bottom row + buf.put(x0, y1, Cell(border.bottomLeft.toInt, bs)) + x = x0 + 1 + while x < x1 do + buf.put(x, y1, Cell(border.horizontal.toInt, bs)) + x += 1 + buf.put(x1, y1, Cell(border.bottomRight.toInt, bs)) + } diff --git a/ui/shared/src/test/scala/terminus/ui/BufferSuite.scala b/ui/shared/src/test/scala/terminus/ui/BufferSuite.scala new file mode 100644 index 0000000..58807c7 --- /dev/null +++ b/ui/shared/src/test/scala/terminus/ui/BufferSuite.scala @@ -0,0 +1,239 @@ +/* + * 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.ui + +import munit.FunSuite +import terminus.StringBuilderTerminal +import terminus.effect.AnsiCodes +import terminus.ui.style.Style + +class BufferSuite extends FunSuite: + + // Shorthand escape-code strings used in assertions + val reset: String = AnsiCodes.sgr("0") + val boldOn: String = AnsiCodes.format.bold.on + + /** Run f with a StringBuilderTerminal and return the captured output. */ + def capture(f: Terminal ?=> Unit): String = + val t = StringBuilderTerminal() + f(using t) + t.result() + + /** Move cursor to 1-based (col, row). */ + def moveTo(col: Int, row: Int): String = AnsiCodes.cursor.to(col, row) + + // --------------------------------------------------------------------------- + // renderDiff — no changes + // --------------------------------------------------------------------------- + + test("renderDiff emits only resets when nothing has changed") { + val prev = Buffer(3, 1) + val curr = Buffer(3, 1) + prev.put(0, 0, Cell('A'.toInt, Style.default)) + curr.put(0, 0, Cell('A'.toInt, Style.default)) + + val out = capture(curr.renderDiff(prev)) + assertEquals(out, reset + reset) + } + + // --------------------------------------------------------------------------- + // renderDiff — single cell changed + // --------------------------------------------------------------------------- + + test("renderDiff moves to the changed cell and writes it") { + val prev = Buffer(3, 1) + val curr = Buffer(3, 1) + prev.put(0, 0, Cell('A'.toInt, Style.default)) + curr.put(0, 0, Cell('B'.toInt, Style.default)) + + val out = capture(curr.renderDiff(prev)) + assertEquals(out, reset + moveTo(1, 1) + "B" + reset) + } + + test("renderDiff writes a changed cell not at the origin") { + val prev = Buffer(3, 1) + val curr = Buffer(3, 1) + prev.put(2, 0, Cell('A'.toInt, Style.default)) + curr.put(2, 0, Cell('Z'.toInt, Style.default)) + + val out = capture(curr.renderDiff(prev)) + assertEquals(out, reset + moveTo(3, 1) + "Z" + reset) + } + + // --------------------------------------------------------------------------- + // renderDiff — cursor optimisation: adjacent changed cells + // --------------------------------------------------------------------------- + + test("renderDiff suppresses cursor move for adjacent changed cells") { + val prev = Buffer(3, 1) + val curr = Buffer(3, 1) + // Both cells at x=0 and x=1 change + prev.put(0, 0, Cell('A'.toInt, Style.default)) + prev.put(1, 0, Cell('B'.toInt, Style.default)) + curr.put(0, 0, Cell('C'.toInt, Style.default)) + curr.put(1, 0, Cell('D'.toInt, Style.default)) + + val out = capture(curr.renderDiff(prev)) + // Only one cursor.to at the start; 'D' follows 'C' without a move + assertEquals(out, reset + moveTo(1, 1) + "C" + "D" + reset) + } + + test("renderDiff emits cursor move for non-adjacent changed cells") { + val prev = Buffer(4, 1) + val curr = Buffer(4, 1) + // Cells at x=0 and x=3 change; x=1 and x=2 are unchanged + prev.put(0, 0, Cell('A'.toInt, Style.default)) + prev.put(3, 0, Cell('B'.toInt, Style.default)) + curr.put(0, 0, Cell('X'.toInt, Style.default)) + curr.put(3, 0, Cell('Y'.toInt, Style.default)) + + val out = capture(curr.renderDiff(prev)) + assertEquals(out, reset + moveTo(1, 1) + "X" + moveTo(4, 1) + "Y" + reset) + } + + // --------------------------------------------------------------------------- + // renderDiff — changes on different rows + // --------------------------------------------------------------------------- + + test("renderDiff handles changes on different rows") { + val prev = Buffer(2, 2) + val curr = Buffer(2, 2) + prev.put(0, 0, Cell('A'.toInt, Style.default)) + prev.put(0, 1, Cell('B'.toInt, Style.default)) + curr.put(0, 0, Cell('X'.toInt, Style.default)) + curr.put(0, 1, Cell('Y'.toInt, Style.default)) + + val out = capture(curr.renderDiff(prev)) + assertEquals( + out, + reset + moveTo(1, 1) + "X" + moveTo(1, 2) + "Y" + reset + ) + } + + // --------------------------------------------------------------------------- + // renderDiff — style changes + // --------------------------------------------------------------------------- + + test("renderDiff emits SGR codes when style changes") { + val prev = Buffer(2, 1) + val curr = Buffer(2, 1) + prev.put(0, 0, Cell('A'.toInt, Style.default)) + curr.put(0, 0, Cell('A'.toInt, Style(bold = true))) + + val out = capture(curr.renderDiff(prev)) + assertEquals(out, reset + moveTo(1, 1) + boldOn + "A" + reset) + } + + test("renderDiff does not emit SGR codes when style is unchanged") { + val prev = Buffer(2, 1) + val curr = Buffer(2, 1) + prev.put(0, 0, Cell('A'.toInt, Style(bold = true))) + curr.put(0, 0, Cell('B'.toInt, Style(bold = true))) + + val out = capture(curr.renderDiff(prev)) + // bold was on from the reset start; no bold code needed before 'B' + assertEquals(out, reset + moveTo(1, 1) + boldOn + "B" + reset) + } + + // --------------------------------------------------------------------------- + // renderDiff — wide characters + // --------------------------------------------------------------------------- + + test("renderDiff skips continuation cells") { + val prev = Buffer(4, 1) + val curr = Buffer(4, 1) + // Wide char 😀 at x=0 (continuation at x=1) unchanged; narrow char at x=2 changes + val smile = "😀".codePointAt(0) + prev.put(0, 0, Cell(smile, Style.default)) + prev.put(1, 0, Cell.continuation) + prev.put(2, 0, Cell('A'.toInt, Style.default)) + curr.put(0, 0, Cell(smile, Style.default)) + curr.put(1, 0, Cell.continuation) + curr.put(2, 0, Cell('B'.toInt, Style.default)) + + val out = capture(curr.renderDiff(prev)) + assertEquals(out, reset + moveTo(3, 1) + "B" + reset) + } + + test("renderDiff writes a changed wide character") { + val prev = Buffer(4, 1) + val curr = Buffer(4, 1) + val smile = "😀".codePointAt(0) + val wave = "👋".codePointAt(0) + prev.put(0, 0, Cell(smile, Style.default)) + prev.put(1, 0, Cell.continuation) + curr.put(0, 0, Cell(wave, Style.default)) + curr.put(1, 0, Cell.continuation) + + val out = capture(curr.renderDiff(prev)) + assertEquals(out, reset + moveTo(1, 1) + "👋" + reset) + } + + // --------------------------------------------------------------------------- + // renderDiff — dimension mismatch + // --------------------------------------------------------------------------- + + test("renderDiff throws when buffer dimensions differ") { + val prev = Buffer(3, 1) + val curr = Buffer(4, 1) + intercept[IllegalArgumentException] { + capture(curr.renderDiff(prev)) + } + } + + // --------------------------------------------------------------------------- + // putString — newline handling + // --------------------------------------------------------------------------- + + /** Render the whole buffer and return the captured output. */ + def renderFull(buf: Buffer): String = + capture(buf.render) + + test("putString writes a simple string on one row") { + val buf = Buffer(3, 1) + buf.putString(0, 0, "ABC", Style.default) + val out = renderFull(buf) + assertEquals(out, reset + moveTo(1, 1) + "A" + "B" + "C" + reset) + } + + test("putString newline advances to next row and resets column") { + val buf = Buffer(3, 2) + buf.putString(0, 0, "AB\nCD", Style.default) + // Row 1: "AB", Row 2: "CD" + val out = renderFull(buf) + assertEquals( + out, + reset + moveTo(1, 1) + "A" + "B" + " " + moveTo( + 1, + 2 + ) + "C" + "D" + " " + reset + ) + } + + test("putString respects starting x when resetting after newline") { + val buf = Buffer(4, 2) + buf.putString(1, 0, "AB\nCD", Style.default) + // Starts at col 1; after newline resets to col 1 on row 2 + val out = renderFull(buf) + assertEquals( + out, + reset + moveTo(1, 1) + " " + "A" + "B" + " " + moveTo( + 1, + 2 + ) + " " + "C" + "D" + " " + reset + ) + } diff --git a/ui/shared/src/test/scala/terminus/ui/CharWidthSuite.scala b/ui/shared/src/test/scala/terminus/ui/CharWidthSuite.scala new file mode 100644 index 0000000..ef60d4d --- /dev/null +++ b/ui/shared/src/test/scala/terminus/ui/CharWidthSuite.scala @@ -0,0 +1,152 @@ +/* + * 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.ui + +import munit.FunSuite + +class CharWidthSuite extends FunSuite: + + /** Convenience: get the width of the first code point in a string literal. */ + def width(s: String): Int = CharWidth.of(s.codePointAt(0)) + + // --------------------------------------------------------------------------- + // Width 1 — narrow characters + // --------------------------------------------------------------------------- + + test("ASCII letters are width 1") { + assertEquals(width("A"), 1) + assertEquals(width("z"), 1) + } + + test("ASCII digits are width 1") { + assertEquals(width("0"), 1) + assertEquals(width("9"), 1) + } + + test("ASCII punctuation is width 1") { + assertEquals(width("!"), 1) + assertEquals(width("-"), 1) + } + + test("Latin Extended letters are width 1") { + assertEquals(width("é"), 1) // U+00E9 + assertEquals(width("ñ"), 1) // U+00F1 + } + + test("Greek letters are width 1") { + assertEquals(width("α"), 1) // U+03B1 + assertEquals(width("Ω"), 1) // U+03A9 + } + + // --------------------------------------------------------------------------- + // Width 0 — zero-width characters + // --------------------------------------------------------------------------- + + test("combining grave accent is width 0") { + assertEquals(width("\u0300"), 0) // COMBINING GRAVE ACCENT + } + + test("combining acute accent is width 0") { + assertEquals(width("\u0301"), 0) // COMBINING ACUTE ACCENT + } + + test("zero width joiner is width 0") { + assertEquals(width("\u200D"), 0) // ZWJ (used in emoji sequences) + } + + test("variation selector 16 is width 0") { + assertEquals(width("\uFE0F"), 0) // emoji presentation selector + } + + test("zero width space is width 0") { + assertEquals(width("\u200B"), 0) + } + + // --------------------------------------------------------------------------- + // Width 2 — CJK and East Asian Wide + // --------------------------------------------------------------------------- + + test("CJK unified ideographs are width 2") { + assertEquals(width("中"), 2) // U+4E2D + assertEquals(width("日"), 2) // U+65E5 + assertEquals(width("한"), 2) // U+D55C Hangul + } + + test("Hiragana and Katakana are width 2") { + assertEquals(width("あ"), 2) // U+3042 Hiragana + assertEquals(width("ア"), 2) // U+30A2 Katakana + } + + test("fullwidth Latin letters are width 2") { + assertEquals(width("A"), 2) // U+FF21 FULLWIDTH LATIN CAPITAL A + } + + // --------------------------------------------------------------------------- + // Width 2 — Emoji (Miscellaneous Symbols and Dingbats) + // --------------------------------------------------------------------------- + + test("sparkles ✨ (U+2728) is width 2") { + assertEquals(width("✨"), 2) + } + + test("cross mark ❌ (U+274C) is width 2") { + assertEquals(width("❌"), 2) + } + + test("check mark ✅ (U+2705) is width 2") { + assertEquals(width("✅"), 2) + } + + test("raised fist ✊ (U+270A) is width 2") { + assertEquals(width("✊"), 2) + } + + test("sun ☀ (U+2600) is width 2") { + assertEquals(width("☀"), 2) + } + + test("warning sign ⚠ (U+26A0) is width 2") { + assertEquals(width("⚠"), 2) + } + + // --------------------------------------------------------------------------- + // Width 2 — Emoji above U+1F000 (Supplementary Multilingual Plane) + // --------------------------------------------------------------------------- + + test("grinning face 😀 (U+1F600) is width 2") { + assertEquals(width("😀"), 2) + } + + test("flexed biceps 💪 (U+1F4AA) is width 2") { + assertEquals(width("💪"), 2) + } + + test("red circle 🔴 (U+1F534) is width 2") { + assertEquals(width("🔴"), 2) + } + + test("green circle 🟢 (U+1F7E2) is width 2") { + assertEquals(width("🟢"), 2) + } + + test("blue circle 🔵 (U+1F535) is width 2") { + assertEquals(width("🔵"), 2) + } + + test("counterclockwise arrows 🔄 (U+1F504) is width 2") { + assertEquals(width("🔄"), 2) + } diff --git a/ui/shared/src/test/scala/terminus/ui/LayoutSuite.scala b/ui/shared/src/test/scala/terminus/ui/LayoutSuite.scala new file mode 100644 index 0000000..91fd817 --- /dev/null +++ b/ui/shared/src/test/scala/terminus/ui/LayoutSuite.scala @@ -0,0 +1,231 @@ +/* + * 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.ui + +import munit.FunSuite +import terminus.StringBuilderTerminal +import terminus.effect.AnsiCodes +import terminus.ui.style.Style + +class LayoutSuite extends FunSuite: + + /** A minimal component that writes a single character at its origin. */ + def cell(char: Char, width: Int = 1, height: Int = 1): Component = + new Component: + val size: Size = Size(width, height) + def render(bounds: Rect, buf: Buffer): Unit = + buf.put(bounds.x, bounds.y, Cell(char.toInt, Style.default)) + + /** A component that fills its entire bounds with a single character. */ + def filledCell(char: Char, width: Int = 1, height: Int = 1): Component = + new Component: + val size: Size = Size(width, height) + def render(bounds: Rect, buf: Buffer): Unit = + val c = Cell(char.toInt, Style.default) + var y = bounds.y + while y < bounds.y + height do + var x = bounds.x + while x < bounds.x + width do + buf.put(x, y, c) + x += 1 + y += 1 + + /** Render a component into a fresh buffer and return the terminal output. */ + def renderToString(component: Component): String = + val buf = Buffer(component.size.width, component.size.height) + component.render( + Rect(0, 0, component.size.width, component.size.height), + buf + ) + val t = StringBuilderTerminal() + buf.render(using t) + t.result() + + val reset: String = AnsiCodes.sgr("0") + def moveTo(col: Int, row: Int): String = AnsiCodes.cursor.to(col, row) + + // --------------------------------------------------------------------------- + // Row — size accumulation + // --------------------------------------------------------------------------- + + test("Row size is zero with no children") { + val row = new Row() + assertEquals(row.size, Size(0, 0)) + } + + test("Row size sums widths and takes max height") { + val row = new Row() + row.add(cell('A', width = 3, height = 2)) + row.add(cell('B', width = 5, height = 1)) + // width = 3 + 5 = 8, height = max(2, 1) = 2 + assertEquals(row.size, Size(8, 2)) + } + + // --------------------------------------------------------------------------- + // Row — layout + // --------------------------------------------------------------------------- + + test("Row places a single child at the origin") { + val row = new Row() + row.add(cell('A')) + val out = renderToString(row) + assertEquals(out, reset + moveTo(1, 1) + "A" + reset) + } + + test("Row places two children left to right") { + val row = new Row() + row.add(cell('A')) + row.add(cell('B')) + // Both in row 1; 'A' at col 1, 'B' at col 2 (adjacent — no extra moveTo) + val out = renderToString(row) + assertEquals(out, reset + moveTo(1, 1) + "A" + "B" + reset) + } + + test("Row places three children left to right") { + val row = new Row() + row.add(cell('A')) + row.add(cell('B')) + row.add(cell('C')) + val out = renderToString(row) + assertEquals(out, reset + moveTo(1, 1) + "A" + "B" + "C" + reset) + } + + test("Row respects child width when advancing x offset") { + val row = new Row() + row.add(filledCell('A', width = 3, height = 1)) + row.add(filledCell('B', width = 3, height = 1)) + // 'A' fills cols 1-3, 'B' fills cols 4-6 (adjacent runs — no extra moveTo) + val out = renderToString(row) + assertEquals(out, reset + moveTo(1, 1) + "AAA" + "BBB" + reset) + } + + // --------------------------------------------------------------------------- + // Column — size accumulation + // --------------------------------------------------------------------------- + + test("Column size is zero with no children") { + val col = new Column() + assertEquals(col.size, Size(0, 0)) + } + + test("Column size takes max width and sums heights") { + val col = new Column() + col.add(cell('A', width = 3, height = 1)) + col.add(cell('B', width = 5, height = 2)) + // width = max(3, 5) = 5, height = 1 + 2 = 3 + assertEquals(col.size, Size(5, 3)) + } + + // --------------------------------------------------------------------------- + // Column — layout + // --------------------------------------------------------------------------- + + test("Column places a single child at the origin") { + val col = new Column() + col.add(cell('A')) + val out = renderToString(col) + assertEquals(out, reset + moveTo(1, 1) + "A" + reset) + } + + test("Column places two children top to bottom") { + val col = new Column() + col.add(cell('A')) + col.add(cell('B')) + // 'A' at row 1, 'B' at row 2 + val out = renderToString(col) + assertEquals(out, reset + moveTo(1, 1) + "A" + moveTo(1, 2) + "B" + reset) + } + + test("Column places three children top to bottom") { + val col = new Column() + col.add(cell('A')) + col.add(cell('B')) + col.add(cell('C')) + val out = renderToString(col) + assertEquals( + out, + reset + moveTo(1, 1) + "A" + moveTo(1, 2) + "B" + moveTo( + 1, + 3 + ) + "C" + reset + ) + } + + test("Column respects child height when advancing y offset") { + val col = new Column() + col.add(filledCell('A', width = 1, height = 3)) + col.add(filledCell('B', width = 1, height = 3)) + // 'A' fills rows 1-3, 'B' fills rows 4-6 + val out = renderToString(col) + assertEquals( + out, + reset + + moveTo(1, 1) + "A" + + moveTo(1, 2) + "A" + + moveTo(1, 3) + "A" + + moveTo(1, 4) + "B" + + moveTo(1, 5) + "B" + + moveTo(1, 6) + "B" + + reset + ) + } + + // --------------------------------------------------------------------------- + // Nesting — Row inside Column and Column inside Row + // --------------------------------------------------------------------------- + + test("Column containing a Row: children laid out correctly") { + // Column: Row("A","B") on top, Row("C","D") below + val col = new Column() + val top = new Row() + top.add(cell('A')) + top.add(cell('B')) + val bot = new Row() + bot.add(cell('C')) + bot.add(cell('D')) + col.add(top) + col.add(bot) + // Buffer is 2 wide x 2 tall + // top row at y=0: 'A' at (0,0), 'B' at (1,0) + // bot row at y=1: 'C' at (0,1), 'D' at (1,1) + val out = renderToString(col) + assertEquals( + out, + reset + moveTo(1, 1) + "A" + "B" + moveTo(1, 2) + "C" + "D" + reset + ) + } + + test("Row containing a Column: children laid out correctly") { + // Row: Column("A","C") on left, Column("B","D") on right + val row = new Row() + val left = new Column() + left.add(cell('A')) + left.add(cell('C')) + val right = new Column() + right.add(cell('B')) + right.add(cell('D')) + row.add(left) + row.add(right) + // Buffer is 2 wide x 2 tall + // left column at x=0: 'A' at (0,0), 'C' at (0,1) + // right column at x=1: 'B' at (1,0), 'D' at (1,1) + val out = renderToString(row) + assertEquals( + out, + reset + moveTo(1, 1) + "A" + "B" + moveTo(1, 2) + "C" + "D" + reset + ) + }