From 5b374b9d697ea8a63b56d57ceee7af987dbcf812 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Sun, 5 Apr 2026 23:39:55 +0100 Subject: [PATCH 01/27] Simplify types on Color and Format Terminal programs that wrap other terminal programs have a simpler representation. Instead of using recursive types, which ultimately cannot be expressed without creating a concrete type, we ask the "syntax" to create a thunk that applies the effects, and pass that the inner program. See `design.md` for a fuller description. Update implementations accordingly. Update to Scala Native 0.5.10. Reading key presses is currently broken on MacOS, which I think traces back to the `read(timeout)` method, which in turn probably goes to the termios calls not working correctly. --- .../js/src/main/scala/terminus/Terminal.scala | 4 +- .../main/scala/terminus/JLineTerminal.scala | 12 +- .../src/main/scala/terminus/Terminal.scala | 10 +- .../main/scala/terminus/NativeTerminal.scala | 58 ++++- .../src/main/scala/terminus/Terminal.scala | 10 +- .../main/scala/terminus/TermiosAccess.scala | 21 +- .../main/scala/terminus/TermiosStruct.scala | 2 +- .../scala/terminus/AlternateScreenMode.scala | 4 +- .../main/scala/terminus/ApplicationMode.scala | 4 +- .../src/main/scala/terminus/Color.scala | 204 +++++++++--------- .../src/main/scala/terminus/Format.scala | 130 +++++------ .../src/main/scala/terminus/RawMode.scala | 4 +- .../terminus/StringBuilderTerminal.scala | 4 +- .../terminus/effect/AlternateScreenMode.scala | 4 +- .../terminus/effect/ApplicationMode.scala | 4 +- .../main/scala/terminus/effect/Color.scala | 70 +++--- .../main/scala/terminus/effect/Format.scala | 46 ++-- .../main/scala/terminus/effect/RawMode.scala | 4 +- .../terminus/effect/TerminalKeyReader.scala | 7 +- .../scala/terminus/effect/WithEffect.scala | 10 +- .../scala/terminus/effect/WithStack.scala | 6 +- .../scala/terminus/effect/WithToggle.scala | 6 +- .../main/scala/terminus/example/Prompt.scala | 4 +- .../test/scala/terminus/KeyShowSuite.scala | 2 +- .../scala/terminus/effect/ColorSuite.scala | 4 +- .../scala/terminus/effect/FormatSuite.scala | 8 +- docs/src/pages/design.md | 67 ++++-- project/Dependencies.scala | 8 +- project/build.properties | 2 +- project/plugins.sbt | 4 +- 30 files changed, 397 insertions(+), 326 deletions(-) diff --git a/core/js/src/main/scala/terminus/Terminal.scala b/core/js/src/main/scala/terminus/Terminal.scala index ea9cb71..cf31ce1 100644 --- a/core/js/src/main/scala/terminus/Terminal.scala +++ b/core/js/src/main/scala/terminus/Terminal.scala @@ -24,9 +24,9 @@ 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 { diff --git a/core/jvm/src/main/scala/terminus/JLineTerminal.scala b/core/jvm/src/main/scala/terminus/JLineTerminal.scala index ced35a3..75cc77f 100644 --- a/core/jvm/src/main/scala/terminus/JLineTerminal.scala +++ b/core/jvm/src/main/scala/terminus/JLineTerminal.scala @@ -55,10 +55,10 @@ 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) + val result = f() result } finally { terminal.setAttributes(attrs) @@ -73,20 +73,20 @@ class JLineTerminal(terminal: JTerminal) extends Terminal, TerminalKeyReader { def setDimensions(dimensions: TerminalDimensions): Unit = terminal.setSize(Size(dimensions.columns, dimensions.rows)) - def application[A](f: Terminal ?=> A): A = { + def application[A](f: () => A): A = { try { terminal.puts(Capability.keypad_xmit) - val result = f(using this) + val result = f() result } finally { val _ = terminal.puts(Capability.keypad_local) } } - def alternateScreen[A](f: Terminal ?=> A): A = { + def alternateScreen[A](f: () => A): A = { try { terminal.puts(Capability.enter_ca_mode) - val result = f(using this) + val result = f() result } finally { val _ = terminal.puts(Capability.exit_ca_mode) diff --git a/core/jvm/src/main/scala/terminus/Terminal.scala b/core/jvm/src/main/scala/terminus/Terminal.scala index e107875..d61ed53 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 diff --git a/core/native/src/main/scala/terminus/NativeTerminal.scala b/core/native/src/main/scala/terminus/NativeTerminal.scala index d2914bf..763779c 100644 --- a/core/native/src/main/scala/terminus/NativeTerminal.scala +++ b/core/native/src/main/scala/terminus/NativeTerminal.scala @@ -29,10 +29,7 @@ import scala.scalanative.unsigned.UInt 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 @@ -111,26 +108,71 @@ object NativeTerminal } } - 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 { termios.setRawMode() - f(using this) + f() } finally { termios.setAttributes(origAttrs) } } } } + +// import scala.scalanative.posix.termios +// import scala.scalanative.posix.unistd.STDIN_FILENO + +// object Termios { +// def getAttributes()(using Zone): Ptr[termios.termios] = { +// val attrs: Ptr[termios.termios] = +// alloc[TermiosStruct.clong_flags]() +// termios.tcgetattr(STDIN_FILENO, attrs) +// attrs +// } + +// def setAttributes(ptr: Ptr[termios.termios]): Unit = { +// termios.tcsetattr(STDIN_FILENO, termios.TCSAFLUSH, ptr) +// // TODO: Error handling +// () +// } + +// /** 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[termios.termios], +// idx: CInt, +// value: CUnsignedChar +// ): Unit = { +// attrs._5(idx) = value +// } + +// /** Places the terminal in raw mode. +// * +// * @see +// * [[https://en.wikipedia.org/wiki/Terminal_mode Terminal Modes]] +// */ +// def setRawMode(): Unit = +// Zone { +// println("Setting raw mode") +// val attrs = getAttributes() +// // attrs.removeLocalFlags(posix.termios.ECHO | posix.termios.ICANON) +// attrs._4 = +// attrs._4 & ~((posix.termios.ECHO | posix.termios.ICANON).toUInt) +// setAttributes(attrs) +// } +// } diff --git a/core/native/src/main/scala/terminus/Terminal.scala b/core/native/src/main/scala/terminus/Terminal.scala index 083200e..99f2ca6 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 diff --git a/core/native/src/main/scala/terminus/TermiosAccess.scala b/core/native/src/main/scala/terminus/TermiosAccess.scala index 4bd5b51..33451fd 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. @@ -132,55 +133,55 @@ given clongTermiosAccess: TermiosAccess[TermiosStruct.clong_flags] = attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._1 = attrs._1 | flags + attrs._1 = attrs._1 | flags.toUInt override def removeInputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._1 = attrs._1 & ~flags + attrs._1 = attrs._1 & ~(flags.toUInt) override def addOutputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._2 = attrs._2 | flags + attrs._2 = attrs._2 | flags.toUInt override def removeOutputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._2 = attrs._2 & ~flags + attrs._2 = attrs._2 & ~(flags.toUInt) override def addControlFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._3 = attrs._3 | flags + attrs._3 = attrs._3 | flags.toUInt override def removeControlFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._3 = attrs._3 & ~flags + attrs._3 = attrs._3 & ~(flags.toUInt) override def addLocalFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._4 = attrs._4 | flags + attrs._4 = attrs._4 | flags.toUInt override def removeLocalFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._4 = attrs._4 & ~flags + attrs._4 = attrs._4 & ~(flags.toUInt) override def setSpecialCharacter( attrs: Ptr[TermiosStruct.clong_flags], idx: CInt, value: CChar - ): Unit = attrs._5(idx) = value + ): Unit = attrs._5(idx) = value.toUByte } /** [[TermiosAccess]] instance for structs with CInt bitflags */ @@ -250,7 +251,7 @@ given cintTermiosAccess: TermiosAccess[TermiosStruct.cint_flags] = attrs: Ptr[TermiosStruct.cint_flags], idx: CInt, value: CChar - ): Unit = attrs._5(idx) = value + ): 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 diff --git a/core/native/src/main/scala/terminus/TermiosStruct.scala b/core/native/src/main/scala/terminus/TermiosStruct.scala index 911509b..7346521 100644 --- a/core/native/src/main/scala/terminus/TermiosStruct.scala +++ b/core/native/src/main/scala/terminus/TermiosStruct.scala @@ -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 */ ] diff --git a/core/shared/src/main/scala/terminus/AlternateScreenMode.scala b/core/shared/src/main/scala/terminus/AlternateScreenMode.scala index 1ed5e1f..be33c83 100644 --- a/core/shared/src/main/scala/terminus/AlternateScreenMode.scala +++ b/core/shared/src/main/scala/terminus/AlternateScreenMode.scala @@ -24,6 +24,6 @@ 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..8a15ced 100644 --- a/core/shared/src/main/scala/terminus/ApplicationMode.scala +++ b/core/shared/src/main/scala/terminus/ApplicationMode.scala @@ -24,6 +24,6 @@ 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..1130ef7 100644 --- a/core/shared/src/main/scala/terminus/Color.scala +++ b/core/shared/src/main/scala/terminus/Color.scala @@ -18,176 +18,176 @@ package terminus trait Color { object foreground { - def default[F <: effect.Writer, A]( + 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]( + 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/Format.scala b/core/shared/src/main/scala/terminus/Format.scala index 61fbe41..1dc2a5c 100644 --- a/core/shared/src/main/scala/terminus/Format.scala +++ b/core/shared/src/main/scala/terminus/Format.scala @@ -18,114 +18,114 @@ 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) + 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]( + 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/RawMode.scala b/core/shared/src/main/scala/terminus/RawMode.scala index ae58e52..c0e8173 100644 --- a/core/shared/src/main/scala/terminus/RawMode.scala +++ b/core/shared/src/main/scala/terminus/RawMode.scala @@ -22,6 +22,6 @@ trait RawMode { * 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/StringBuilderTerminal.scala b/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala index 058f741..ac5c3b8 100644 --- a/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala +++ b/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala @@ -19,9 +19,9 @@ 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 { diff --git a/core/shared/src/main/scala/terminus/effect/AlternateScreenMode.scala b/core/shared/src/main/scala/terminus/effect/AlternateScreenMode.scala index cdcf798..717b6d6 100644 --- a/core/shared/src/main/scala/terminus/effect/AlternateScreenMode.scala +++ b/core/shared/src/main/scala/terminus/effect/AlternateScreenMode.scala @@ -16,11 +16,11 @@ 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/ApplicationMode.scala b/core/shared/src/main/scala/terminus/effect/ApplicationMode.scala index a717d89..d6de5eb 100644 --- a/core/shared/src/main/scala/terminus/effect/ApplicationMode.scala +++ b/core/shared/src/main/scala/terminus/effect/ApplicationMode.scala @@ -16,12 +16,12 @@ 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/Color.scala b/core/shared/src/main/scala/terminus/effect/Color.scala index 3244d2a..ad4ad5c 100644 --- a/core/shared/src/main/scala/terminus/effect/Color.scala +++ b/core/shared/src/main/scala/terminus/effect/Color.scala @@ -16,113 +16,113 @@ package terminus.effect -trait Color[+F <: Writer] extends WithStack[F] { self: F => +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 { 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/Format.scala b/core/shared/src/main/scala/terminus/effect/Format.scala index b9b2351..981d491 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 => +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 { 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,7 @@ 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/RawMode.scala b/core/shared/src/main/scala/terminus/effect/RawMode.scala index b51cbb6..1cbd612 100644 --- a/core/shared/src/main/scala/terminus/effect/RawMode.scala +++ b/core/shared/src/main/scala/terminus/effect/RawMode.scala @@ -16,11 +16,11 @@ 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/TerminalKeyReader.scala b/core/shared/src/main/scala/terminus/effect/TerminalKeyReader.scala index 97d7017..b467c17 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 diff --git a/core/shared/src/main/scala/terminus/effect/WithEffect.scala b/core/shared/src/main/scala/terminus/effect/WithEffect.scala index 39dd666..7ac0938 100644 --- a/core/shared/src/main/scala/terminus/effect/WithEffect.scala +++ b/core/shared/src/main/scala/terminus/effect/WithEffect.scala @@ -16,18 +16,18 @@ 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) + 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..e39b5bd 100644 --- a/core/shared/src/main/scala/terminus/effect/WithStack.scala +++ b/core/shared/src/main/scala/terminus/effect/WithStack.scala @@ -17,15 +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) + 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..3e1a7fc 100644 --- a/core/shared/src/main/scala/terminus/effect/WithToggle.scala +++ b/core/shared/src/main/scala/terminus/effect/WithToggle.scala @@ -17,15 +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) + f() } finally { toggle.off(self) } diff --git a/core/shared/src/main/scala/terminus/example/Prompt.scala b/core/shared/src/main/scala/terminus/example/Prompt.scala index 5492704..9f3e7b1 100644 --- a/core/shared/src/main/scala/terminus/example/Prompt.scala +++ b/core/shared/src/main/scala/terminus/example/Prompt.scala @@ -22,8 +22,8 @@ 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 <: effect.Color & effect.Cursor & effect.Format & effect.Erase & + effect.KeyReader & effect.Writer ](terminal: Color & Cursor & Format & Erase & KeyReader & Writer) { type Program[A] = Terminal ?=> A diff --git a/core/shared/src/test/scala/terminus/KeyShowSuite.scala b/core/shared/src/test/scala/terminus/KeyShowSuite.scala index c8bae60..48fa0a6 100644 --- a/core/shared/src/test/scala/terminus/KeyShowSuite.scala +++ b/core/shared/src/test/scala/terminus/KeyShowSuite.scala @@ -16,8 +16,8 @@ package terminus -import munit.FunSuite import cats.syntax.show.* +import munit.FunSuite class KeyShowSuite extends FunSuite { test("Show[Key] produces the expected string representation") { diff --git a/core/shared/src/test/scala/terminus/effect/ColorSuite.scala b/core/shared/src/test/scala/terminus/effect/ColorSuite.scala index 547f584..e28452f 100644 --- a/core/shared/src/test/scala/terminus/effect/ColorSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/ColorSuite.scala @@ -25,9 +25,9 @@ class ColorSuite extends FunSuite { ) { 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 ") } } diff --git a/core/shared/src/test/scala/terminus/effect/FormatSuite.scala b/core/shared/src/test/scala/terminus/effect/FormatSuite.scala index 98ee47c..3477cc7 100644 --- a/core/shared/src/test/scala/terminus/effect/FormatSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/FormatSuite.scala @@ -23,9 +23,9 @@ 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 ") } } 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/project/Dependencies.scala b/project/Dependencies.scala index 4863ded..7d69de4 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -10,13 +10,13 @@ object Dependencies { val fs2Version = "3.11.0" val scalatagsVersion = "0.13.1" - val scalajsDomVersion = "2.8.0" + val scalajsDomVersion = "2.8.1" - val jlineVersion = "3.29.0" + val jlineVersion = "4.0.10" val scalaCheckVersion = "1.15.4" - val munitVersion = "1.1.0" - val munitScalacheckVersion = "1.1.0" + val munitVersion = "1.2.4" + val munitScalacheckVersion = "1.2.0" val munitCatsEffectVersion = "2.0.0" // Libraries diff --git a/project/build.properties b/project/build.properties index e88a0d8..08a6fc0 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.6 +sbt.version=1.12.8 diff --git a/project/plugins.sbt b/project/plugins.sbt index c237a3b..06e84cd 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.scala-native" % "sbt-scala-native" % "0.5.10") addSbtPlugin("org.creativescala" % "creative-scala-theme" % "0.5.2") From cacf17ee71c5154091beba51a3c8eaef20dc6acc Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Mon, 13 Apr 2026 18:13:33 +0100 Subject: [PATCH 02/27] Don't clear terminal input queue in read - Calling read(duration) doesn't clear the input queue when changing the terminal settings. This is usually what we want, as this method is used to read multi-byte character sequences. This fixes a problem we observed on Scala Native - Add a few tests for the CLong termios variant. - Add a method to get special characters, used in testing. - A bit of cleanup --- .../main/scala/terminus/NativeTerminal.scala | 51 +------------ .../main/scala/terminus/TermiosAccess.scala | 53 ++++++++++---- .../main/scala/terminus/TermiosStruct.scala | 1 + .../scala/terminus/TermiosAccessSpec.scala | 15 ++++ .../scala/terminus/TermiosAccessSuite.scala | 72 +++++++++++++++++++ 5 files changed, 129 insertions(+), 63 deletions(-) create mode 100644 core/native/src/test/scala/terminus/TermiosAccessSpec.scala create mode 100644 core/native/src/test/scala/terminus/TermiosAccessSuite.scala diff --git a/core/native/src/main/scala/terminus/NativeTerminal.scala b/core/native/src/main/scala/terminus/NativeTerminal.scala index 763779c..cd423aa 100644 --- a/core/native/src/main/scala/terminus/NativeTerminal.scala +++ b/core/native/src/main/scala/terminus/NativeTerminal.scala @@ -24,7 +24,7 @@ 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.* @@ -67,10 +67,10 @@ object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader { val origAttrs = termios.getAttributes() val attrs = termios.getAttributes() try { - attrs.setSpecialCharacter(posix.termios.VMIN, 0) + attrs.setSpecialCharacter(posix.termios.VMIN, 0.toUByte) attrs.setSpecialCharacter( posix.termios.VTIME, - (duration.toMillis / 100).toByte + (duration.toMillis / 100).toUByte ) termios.setAttributes(attrs) @@ -131,48 +131,3 @@ object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader { } } } - -// import scala.scalanative.posix.termios -// import scala.scalanative.posix.unistd.STDIN_FILENO - -// object Termios { -// def getAttributes()(using Zone): Ptr[termios.termios] = { -// val attrs: Ptr[termios.termios] = -// alloc[TermiosStruct.clong_flags]() -// termios.tcgetattr(STDIN_FILENO, attrs) -// attrs -// } - -// def setAttributes(ptr: Ptr[termios.termios]): Unit = { -// termios.tcsetattr(STDIN_FILENO, termios.TCSAFLUSH, ptr) -// // TODO: Error handling -// () -// } - -// /** 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[termios.termios], -// idx: CInt, -// value: CUnsignedChar -// ): Unit = { -// attrs._5(idx) = value -// } - -// /** Places the terminal in raw mode. -// * -// * @see -// * [[https://en.wikipedia.org/wiki/Terminal_mode Terminal Modes]] -// */ -// def setRawMode(): Unit = -// Zone { -// println("Setting raw mode") -// val attrs = getAttributes() -// // attrs.removeLocalFlags(posix.termios.ECHO | posix.termios.ICANON) -// attrs._4 = -// attrs._4 & ~((posix.termios.ECHO | posix.termios.ICANON).toUInt) -// setAttributes(attrs) -// } -// } diff --git a/core/native/src/main/scala/terminus/TermiosAccess.scala b/core/native/src/main/scala/terminus/TermiosAccess.scala index 33451fd..0bb3aa0 100644 --- a/core/native/src/main/scala/terminus/TermiosAccess.scala +++ b/core/native/src/main/scala/terminus/TermiosAccess.scala @@ -90,11 +90,17 @@ 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 */ @@ -107,12 +113,16 @@ 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] = + import posix.termiosOps.* + new TermiosAccess[TermiosStruct.clong_flags] { override type FlagType = CLong @@ -125,7 +135,7 @@ given clongTermiosAccess: TermiosAccess[TermiosStruct.clong_flags] = 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) () } @@ -133,55 +143,60 @@ given clongTermiosAccess: TermiosAccess[TermiosStruct.clong_flags] = attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._1 = attrs._1 | flags.toUInt + attrs.c_iflag = attrs.c_iflag | flags.toUInt override def removeInputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._1 = attrs._1 & ~(flags.toUInt) + attrs.c_iflag = attrs.c_iflag & ~(flags.toUInt) override def addOutputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._2 = attrs._2 | flags.toUInt + attrs.c_oflag = attrs.c_oflag | flags.toUInt override def removeOutputFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._2 = attrs._2 & ~(flags.toUInt) + attrs.c_oflag = attrs.c_oflag & ~(flags.toUInt) override def addControlFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._3 = attrs._3 | flags.toUInt + attrs.c_cflag = attrs.c_cflag | flags.toUInt override def removeControlFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._3 = attrs._3 & ~(flags.toUInt) + attrs.c_cflag = attrs.c_cflag & ~(flags.toUInt) override def addLocalFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._4 = attrs._4 | flags.toUInt + attrs.c_lflag = attrs.c_lflag | flags.toUInt override def removeLocalFlags( attrs: Ptr[TermiosStruct.clong_flags], flags: CInt ): Unit = - attrs._4 = attrs._4 & ~(flags.toUInt) + 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.toUByte + value: UByte + ): Unit = attrs.c_cc(idx) = value } /** [[TermiosAccess]] instance for structs with CInt bitflags */ @@ -197,7 +212,7 @@ given cintTermiosAccess: TermiosAccess[TermiosStruct.cint_flags] = } override def set(ptr: Ptr[TermiosStruct.cint_flags]): Unit = { - val _ = tcsetattr(STDIN_FILENO, posix.termios.TCSAFLUSH, ptr) + val _ = tcsetattr(STDIN_FILENO, posix.termios.TCSANOW, ptr) () } @@ -247,10 +262,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 + 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 diff --git a/core/native/src/main/scala/terminus/TermiosStruct.scala b/core/native/src/main/scala/terminus/TermiosStruct.scala index 7346521..a917959 100644 --- a/core/native/src/main/scala/terminus/TermiosStruct.scala +++ b/core/native/src/main/scala/terminus/TermiosStruct.scala @@ -19,6 +19,7 @@ package terminus import scala.scalanative.posix import scala.scalanative.unsafe.CInt import scala.scalanative.unsafe.CStruct7 +import scala.scalanative.unsafe.CUnsignedLong /** Type aliases for the two possible termios structures, one with CInt bitflags * and one with CLong bitflags. CLong types are the default in scala-native, diff --git a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala new file mode 100644 index 0000000..2501d37 --- /dev/null +++ b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala @@ -0,0 +1,15 @@ +/* + * 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. + */ 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..ae06086 --- /dev/null +++ b/core/native/src/test/scala/terminus/TermiosAccessSuite.scala @@ -0,0 +1,72 @@ +/* + * 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) + } + } + } +} From b54691eee93ee3eb1bc1d55b65f492e9714b95ff Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Mon, 13 Apr 2026 18:22:53 +0100 Subject: [PATCH 03/27] Update dependencies --- core/native/src/test/scala/terminus/TermiosAccessSpec.scala | 1 + project/Dependencies.scala | 6 +++--- project/plugins.sbt | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala index 2501d37..3c21f4f 100644 --- a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala +++ b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala @@ -13,3 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 7d69de4..f27322d 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -12,11 +12,11 @@ object Dependencies { val scalatagsVersion = "0.13.1" val scalajsDomVersion = "2.8.1" - val jlineVersion = "4.0.10" + val jlineVersion = "4.0.12" val scalaCheckVersion = "1.15.4" - val munitVersion = "1.2.4" - val munitScalacheckVersion = "1.2.0" + val munitVersion = "1.3.0" + val munitScalacheckVersion = "1.3.0" val munitCatsEffectVersion = "2.0.0" // Libraries diff --git a/project/plugins.sbt b/project/plugins.sbt index 06e84cd..31e80e0 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -5,5 +5,5 @@ 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.10") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.11") addSbtPlugin("org.creativescala" % "creative-scala-theme" % "0.5.2") From f8eb5ed098fd4467db9a1f114aa1c0e662b2919f Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Mon, 13 Apr 2026 19:06:54 +0100 Subject: [PATCH 04/27] Accept newline as enter Ghostty sends newline when enter is pressed. --- core/shared/src/main/scala/terminus/example/Prompt.scala | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/shared/src/main/scala/terminus/example/Prompt.scala b/core/shared/src/main/scala/terminus/example/Prompt.scala index 9f3e7b1..0442421 100644 --- a/core/shared/src/main/scala/terminus/example/Prompt.scala +++ b/core/shared/src/main/scala/terminus/example/Prompt.scala @@ -59,10 +59,11 @@ class Prompt[ 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() + 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() } } } From 894ea264d720435db7f638c3c1bed630f2860c7b Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Tue, 14 Apr 2026 04:37:52 +0100 Subject: [PATCH 05/27] Remove unused import --- core/native/src/main/scala/terminus/TermiosStruct.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/core/native/src/main/scala/terminus/TermiosStruct.scala b/core/native/src/main/scala/terminus/TermiosStruct.scala index a917959..7346521 100644 --- a/core/native/src/main/scala/terminus/TermiosStruct.scala +++ b/core/native/src/main/scala/terminus/TermiosStruct.scala @@ -19,7 +19,6 @@ package terminus import scala.scalanative.posix import scala.scalanative.unsafe.CInt import scala.scalanative.unsafe.CStruct7 -import scala.scalanative.unsafe.CUnsignedLong /** Type aliases for the two possible termios structures, one with CInt bitflags * and one with CLong bitflags. CLong types are the default in scala-native, From 091b84c86ebf23097b34256131ee01fe1bf4e9f2 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 11:22:37 +0100 Subject: [PATCH 06/27] Initial sketch of UI framework - UIs are modelled as effects on a `RenderContext` - Rendering is done via a cell buffer, which will allow double buffering and diffing in the future. - Very basic fixed size layout. - Fix `NativeTerminal` to support printing wide characters. --- .github/workflows/ci.yml | 4 +- build.sbt | 10 +- .../main/scala/terminus/NativeTerminal.scala | 6 +- .../src/main/scala/terminus/Writer.scala | 4 + .../src/main/scala/terminus/ui/Buffer.scala | 135 ++++++++++++++++++ .../src/main/scala/terminus/ui/Cell.scala | 24 ++++ .../main/scala/terminus/ui/Component.scala | 24 ++++ .../main/scala/terminus/ui/FullScreen.scala | 61 ++++++++ .../src/main/scala/terminus/ui/Rect.scala | 32 +++++ .../scala/terminus/ui/RenderContext.scala | 27 ++++ .../src/main/scala/terminus/ui/Row.scala | 47 ++++++ .../src/main/scala/terminus/ui/Size.scala | 36 +++++ .../src/main/scala/terminus/ui/Terminal.scala | 24 ++++ .../scala/terminus/ui/component/Text.scala | 45 ++++++ .../main/scala/terminus/ui/style/Border.scala | 54 +++++++ .../main/scala/terminus/ui/style/Color.scala | 37 +++++ .../main/scala/terminus/ui/style/Style.scala | 26 ++++ .../src/main/scala/terminus/ui/tool/Box.scala | 58 ++++++++ 18 files changed, 649 insertions(+), 5 deletions(-) create mode 100644 ui/shared/src/main/scala/terminus/ui/Buffer.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/Cell.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/Component.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/FullScreen.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/Rect.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/RenderContext.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/Row.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/Size.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/Terminal.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/component/Text.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/style/Border.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/style/Color.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/style/Style.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/tool/Box.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33667df..f067685 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,11 +83,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - run: mkdir -p unidocs/target core/native/target core/js/target 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') diff --git a/build.sbt b/build.sbt index 6da3f96..5fc4d94 100644 --- a/build.sbt +++ b/build.sbt @@ -72,7 +72,7 @@ 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")) @@ -86,6 +86,14 @@ lazy val core = crossProject(JSPlatform, JVMPlatform, NativePlatform) .jvmSettings(libraryDependencies += Dependencies.jline.value) .jsSettings(libraryDependencies += Dependencies.scalajsDom.value) +lazy val ui = crossProject(JSPlatform, JVMPlatform, NativePlatform) + .in(file("ui")) + .settings( + name := "terminus-ui", + commonSettings + ) + .dependsOn(core) + lazy val docs = project .in(file("docs")) diff --git a/core/native/src/main/scala/terminus/NativeTerminal.scala b/core/native/src/main/scala/terminus/NativeTerminal.scala index cd423aa..a05fa03 100644 --- a/core/native/src/main/scala/terminus/NativeTerminal.scala +++ b/core/native/src/main/scala/terminus/NativeTerminal.scala @@ -96,8 +96,10 @@ object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader { } def write(char: Char): Unit = { - val _ = libc.stdio.fputc(char, libc.stdio.stdout) - () + // 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 = { diff --git a/core/shared/src/main/scala/terminus/Writer.scala b/core/shared/src/main/scala/terminus/Writer.scala index 4b90430..41b9190 100644 --- a/core/shared/src/main/scala/terminus/Writer.scala +++ b/core/shared/src/main/scala/terminus/Writer.scala @@ -27,6 +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/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..fc47784 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Buffer.scala @@ -0,0 +1,135 @@ +/* + * 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 +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 horizontally starting at (x, y). Clips to buffer bounds. */ + def putString(x: Int, y: Int, s: String, style: Style): Unit = + var i = 0 + while i < s.length do + put(x + i, y, Cell(s.charAt(i), style)) + i += 1 + + /** Flush the 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. + * 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.style != currentStyle then + if cell.style.fg != currentStyle.fg then + t.write(fgCode(cell.style.fg)) + if cell.style.bg != currentStyle.bg then + t.write(bgCode(cell.style.bg)) + if cell.style.bold != currentStyle.bold then + t.write( + if cell.style.bold then AnsiCodes.format.bold.on + else AnsiCodes.format.bold.off + ) + currentStyle = cell.style + t.write(cell.char) + x += 1 + y += 1 + + t.write(AnsiCodes.sgr("0")) + t.flush() + + 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..937f51b --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Cell.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.ui.style.Style + +/** A single terminal cell: a character with associated styling. */ +final case class Cell(char: Char, style: Style) +object Cell: + val empty: Cell = Cell(' ', Style.default) 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/FullScreen.scala b/ui/shared/src/main/scala/terminus/ui/FullScreen.scala new file mode 100644 index 0000000..3c29e9c --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/FullScreen.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.AlternateScreenMode +import terminus.Cursor +import terminus.Erase +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 + + private var childrenSize: Size = Size.zero + + def size: Size = childrenSize + + def add(component: Component): Unit = + childrenSize = childrenSize.column(component.size) + children += component + + def render(using Terminal): Unit = + val buf = Buffer(childrenSize.width, childrenSize.height) + var y = 0 + children.foreach { child => + child.render(Rect(0, y, child.size.width, child.size.height), buf) + y += child.size.height + } + buf.render + +object FullScreen: + type Terminal = effect.AlternateScreenMode & effect.Erase & + terminus.ui.Terminal + type Program[A] = FullScreen.Terminal ?=> A + + object Terminal extends AlternateScreenMode, Cursor, Erase, Writer + + def apply[A](f: RenderContext ?=> A): FullScreen.Program[A] = + val fullScreen = new FullScreen() + val result = f(using fullScreen) + FullScreen.Terminal.erase.screen() + fullScreen.render + result 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..a5b0415 --- /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 + + private var childrenSize: Size = Size.zero + + def size: Size = childrenSize + + def add(component: Component): Unit = + childrenSize = childrenSize.row(component.size) + children += component + + def render(bounds: Rect, buf: Buffer): Unit = + var x = bounds.x + children.foreach { child => + child.render(Rect(x, bounds.y, child.size.width, child.size.height), buf) + x += child.size.width + } + +object Row: + def apply[A]( + f: RenderContext ?=> A + )(using parent: RenderContext): A = + val row = new Row() + val result = f(using row) + parent.add(row) + result 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..0898d01 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/Size.scala @@ -0,0 +1,36 @@ +/* + * 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..a611c1d --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/component/Text.scala @@ -0,0 +1,45 @@ +/* + * 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.Border +import terminus.ui.style.Style +import terminus.ui.tool.Box + +object Text: + def component( + width: Int, + height: Int, + content: String, + style: Style = Style.default + ): Component = + new Component: + val size: Size = Size(width, height) + + def render(bounds: Rect, buf: Buffer): Unit = + Box.render(bounds, Border.single, style, buf) + buf.putString(bounds.x + 1, bounds.y + 1, content, style) + + def apply(width: Int, height: Int)( + content: String + )(using ctx: RenderContext): Unit = + ctx.add(component(width, height, 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..a5087ff --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/style/Border.scala @@ -0,0 +1,54 @@ +/* + * 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/Style.scala b/ui/shared/src/main/scala/terminus/ui/style/Style.scala new file mode 100644 index 0000000..2d2cd9a --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/style/Style.scala @@ -0,0 +1,26 @@ +/* + * 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 +) +object Style: + val default: Style = Style() 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..d77e907 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/tool/Box.scala @@ -0,0 +1,58 @@ +/* + * 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.Border +import terminus.ui.style.Style + +object Box: + + /** Draw a bordered box into the buffer at the given bounds. The border + * occupies the outermost cells, so the minimum usable width and height is 2. + * Out-of-bounds writes are silently clipped by the buffer. + */ + def render(bounds: Rect, border: Border, style: Style, buf: Buffer): Unit = + val x0 = bounds.x + val y0 = bounds.y + val x1 = bounds.x + bounds.width - 1 // inclusive right edge + val y1 = bounds.y + bounds.height - 1 // inclusive bottom edge + + // Top row + buf.put(x0, y0, Cell(border.topLeft, style)) + var x = x0 + 1 + while x < x1 do + buf.put(x, y0, Cell(border.horizontal, style)) + x += 1 + buf.put(x1, y0, Cell(border.topRight, style)) + + // Sides + var y = y0 + 1 + while y < y1 do + buf.put(x0, y, Cell(border.vertical, style)) + buf.put(x1, y, Cell(border.vertical, style)) + y += 1 + + // Bottom row + buf.put(x0, y1, Cell(border.bottomLeft, style)) + x = x0 + 1 + while x < x1 do + buf.put(x, y1, Cell(border.horizontal, style)) + x += 1 + buf.put(x1, y1, Cell(border.bottomRight, style)) From 2745055cef139c5621c206de5952bf3e01f0de48 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 11:32:55 +0100 Subject: [PATCH 07/27] Update plugins --- build.sbt | 2 -- project/build.properties | 2 +- project/plugins.sbt | 10 +++++----- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/build.sbt b/build.sbt index 5fc4d94..3e542a2 100644 --- a/build.sbt +++ b/build.sbt @@ -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) diff --git a/project/build.properties b/project/build.properties index 08a6fc0..df061f4 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.12.8 +sbt.version=1.12.9 diff --git a/project/plugins.sbt b/project/plugins.sbt index 31e80e0..d317226 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,9 +1,9 @@ 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.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") From 56b4014a4de96cc742b32e140cb76a609408d0b0 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 11:33:03 +0100 Subject: [PATCH 08/27] Rebuild workflows --- .github/workflows/ci.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f067685..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 @@ -91,7 +91,7 @@ jobs: - 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 From 922048f4c3178d1a809c4891f6bf0c7a50c7b63b Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 11:44:27 +0100 Subject: [PATCH 09/27] Reformat to braceless style - Update scalafmt - Add scalafmt setting to rewrite to braceless style - Reformat --- .scalafmt.conf | 3 +- .../js/src/main/scala/terminus/Terminal.scala | 15 +- .../main/scala/terminus/XtermJsOptions.scala | 6 +- .../main/scala/terminus/XtermJsTerminal.scala | 6 +- .../main/scala/terminus/JLineTerminal.scala | 46 ++- core/jvm/src/main/scala/terminus/Prompt.scala | 3 +- .../src/main/scala/terminus/Terminal.scala | 3 +- .../terminus/effect/DimensionsSuite.scala | 3 +- .../main/scala/terminus/NativeTerminal.scala | 46 +-- .../src/main/scala/terminus/Prompt.scala | 3 +- .../src/main/scala/terminus/Terminal.scala | 6 +- .../src/main/scala/terminus/Termios.scala | 3 +- .../main/scala/terminus/TermiosAccess.scala | 27 +- .../main/scala/terminus/TermiosStruct.scala | 3 +- .../scala/terminus/TermiosAccessSpec.scala | 1 - .../scala/terminus/TermiosAccessSuite.scala | 12 +- .../scala/terminus/AlternateScreenMode.scala | 3 +- .../main/scala/terminus/ApplicationMode.scala | 3 +- .../src/main/scala/terminus/Color.scala | 9 +- .../src/main/scala/terminus/Cursor.scala | 6 +- .../src/main/scala/terminus/Dimensions.scala | 6 +- .../src/main/scala/terminus/Erase.scala | 6 +- .../src/main/scala/terminus/Format.scala | 9 +- core/shared/src/main/scala/terminus/Key.scala | 9 +- .../src/main/scala/terminus/KeyCode.scala | 3 +- .../src/main/scala/terminus/KeyModifier.scala | 6 +- .../src/main/scala/terminus/KeyReader.scala | 3 +- .../scala/terminus/NonBlockingReader.scala | 3 +- .../src/main/scala/terminus/Peeker.scala | 3 +- .../src/main/scala/terminus/RawMode.scala | 3 +- .../src/main/scala/terminus/Reader.scala | 3 +- .../terminus/StringBuilderTerminal.scala | 12 +- .../src/main/scala/terminus/Writer.scala | 3 +- .../terminus/effect/AlternateScreenMode.scala | 3 +- .../scala/terminus/effect/AnsiCodes.scala | 272 ------------------ .../terminus/effect/ApplicationMode.scala | 4 +- .../main/scala/terminus/effect/Ascii.scala | 3 +- .../main/scala/terminus/effect/Color.scala | 10 +- .../main/scala/terminus/effect/Cursor.scala | 9 +- .../scala/terminus/effect/Dimensions.scala | 3 +- .../main/scala/terminus/effect/Erase.scala | 6 +- .../main/scala/terminus/effect/Format.scala | 10 +- .../scala/terminus/effect/KeyReader.scala | 3 +- .../terminus/effect/NonBlockingReader.scala | 3 +- .../main/scala/terminus/effect/Peeker.scala | 3 +- .../main/scala/terminus/effect/RawMode.scala | 3 +- .../main/scala/terminus/effect/Reader.scala | 3 +- .../main/scala/terminus/effect/Scroll.scala | 7 +- .../main/scala/terminus/effect/Stack.scala | 19 +- .../terminus/effect/StringBufferReader.scala | 6 +- .../terminus/effect/TerminalKeyReader.scala | 18 +- .../main/scala/terminus/effect/Toggle.scala | 9 +- .../scala/terminus/effect/WithEffect.scala | 15 +- .../scala/terminus/effect/WithStack.scala | 12 +- .../scala/terminus/effect/WithToggle.scala | 12 +- .../main/scala/terminus/effect/Writer.scala | 3 +- .../main/scala/terminus/example/Prompt.scala | 24 +- .../scala/terminus/KeyModifierSuite.scala | 3 +- .../test/scala/terminus/KeyShowSuite.scala | 3 +- .../terminus/effect/AnsiCodesSuite.scala | 3 +- .../scala/terminus/effect/AsciiSuite.scala | 3 +- .../scala/terminus/effect/ColorSuite.scala | 3 +- .../scala/terminus/effect/CursorSuite.scala | 3 +- .../scala/terminus/effect/FormatSuite.scala | 3 +- .../effect/TerminalKeyReaderSuite.scala | 3 +- .../src/main/scala/terminus/ui/Size.scala | 6 +- .../main/scala/terminus/ui/style/Border.scala | 3 +- 67 files changed, 171 insertions(+), 598 deletions(-) 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/core/js/src/main/scala/terminus/Terminal.scala b/core/js/src/main/scala/terminus/Terminal.scala index cf31ce1..4bbeeae 100644 --- a/core/js/src/main/scala/terminus/Terminal.scala +++ b/core/js/src/main/scala/terminus/Terminal.scala @@ -29,7 +29,7 @@ class Terminal(root: HTMLElement, options: XtermJsOptions) 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 75cc77f..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: () => A): A = { + def raw[A](f: () => A): A = val attrs = terminal.enterRawMode() - try { + 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: () => A): A = { - try { + def application[A](f: () => A): A = + try terminal.puts(Capability.keypad_xmit) val result = f() result - } finally { + finally val _ = terminal.puts(Capability.keypad_local) - } - } - def alternateScreen[A](f: () => A): A = { - try { + def alternateScreen[A](f: () => A): A = + try terminal.puts(Capability.enter_ca_mode) 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 d61ed53..e9f5a7e 100644 --- a/core/jvm/src/main/scala/terminus/Terminal.scala +++ b/core/jvm/src/main/scala/terminus/Terminal.scala @@ -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 a05fa03..3e108eb 100644 --- a/core/native/src/main/scala/terminus/NativeTerminal.scala +++ b/core/native/src/main/scala/terminus/NativeTerminal.scala @@ -29,7 +29,7 @@ import scala.scalanative.unsigned.* import scalanative.unsafe.* /** A Terminal implementation for Scala Native. */ -object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader { +object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader: private given termiosAccess: TermiosAccess[?] = if LinktimeInfo.isMac then clongTermiosAccess @@ -48,25 +48,23 @@ object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader { 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 { + try attrs.setSpecialCharacter(posix.termios.VMIN, 0.toUByte) attrs.setSpecialCharacter( posix.termios.VTIME, @@ -80,56 +78,42 @@ object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader { 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 = { + def flush(): Unit = val _ = libc.stdio.fflush(libc.stdio.stdin) () - } - def write(char: Char): Unit = { + 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: () => A): A = { + def application[A](f: () => A): A = withEffect(AnsiCodes.mode.application.on, AnsiCodes.mode.application.off)(f) - } - def alternateScreen[A](f: () => A): A = { + def alternateScreen[A](f: () => A): A = withEffect( AnsiCodes.mode.alternateScreen.on, AnsiCodes.mode.alternateScreen.off )(f) - } - def raw[A](f: () => A): A = { + def raw[A](f: () => A): A = Zone { val origAttrs = termios.getAttributes() - try { + try termios.setRawMode() f() - } finally { - termios.setAttributes(origAttrs) - } + 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 99f2ca6..b67763f 100644 --- a/core/native/src/main/scala/terminus/Terminal.scala +++ b/core/native/src/main/scala/terminus/Terminal.scala @@ -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 0bb3aa0..b83e063 100644 --- a/core/native/src/main/scala/terminus/TermiosAccess.scala +++ b/core/native/src/main/scala/terminus/TermiosAccess.scala @@ -45,7 +45,7 @@ import scala.scalanative.unsigned.* @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 @@ -101,10 +101,9 @@ trait TermiosAccess[T] { * [[scala.scalanative.posix.termios]] */ 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) @@ -117,27 +116,24 @@ extension [T](ptr: Ptr[T])(using au: TermiosAccess[T]) { 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] = import posix.termiosOps.* - new TermiosAccess[TermiosStruct.clong_flags] { + 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.TCSANOW, ptr) () - } override def addInputFlags( attrs: Ptr[TermiosStruct.clong_flags], @@ -197,24 +193,21 @@ given clongTermiosAccess: TermiosAccess[TermiosStruct.clong_flags] = idx: CInt, 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 = { + 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], @@ -265,10 +258,9 @@ given cintTermiosAccess: TermiosAccess[TermiosStruct.cint_flags] = override def getSpecialCharacter( attrs: Ptr[TermiosStruct.cint_flags], idx: CInt - ): UByte = { + ): UByte = val c_cc = attrs._5 c_cc(idx) - } override def setSpecialCharacter( attrs: Ptr[TermiosStruct.cint_flags], @@ -289,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 7346521..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 @@ -53,4 +53,3 @@ object TermiosStruct { * CLong sized bitflags */ type clong_flags = posix.termios.termios -} diff --git a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala index 3c21f4f..2501d37 100644 --- a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala +++ b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala @@ -13,4 +13,3 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - diff --git a/core/native/src/test/scala/terminus/TermiosAccessSuite.scala b/core/native/src/test/scala/terminus/TermiosAccessSuite.scala index ae06086..1099b39 100644 --- a/core/native/src/test/scala/terminus/TermiosAccessSuite.scala +++ b/core/native/src/test/scala/terminus/TermiosAccessSuite.scala @@ -24,7 +24,7 @@ import scala.scalanative.unsafe.Ptr import scala.scalanative.unsafe.Zone import scala.scalanative.unsigned.* -class CLongTermiosAccessSuite extends FunSuite { +class CLongTermiosAccessSuite extends FunSuite: val termios = clongTermiosAccess def testSettingSpecialCharacter( @@ -32,18 +32,17 @@ class CLongTermiosAccessSuite extends FunSuite { idx: CInt, value: UByte, name: String - )(using Zone): Unit = { + )(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 { + try val attrs = termios.get List( @@ -64,9 +63,6 @@ class CLongTermiosAccessSuite extends FunSuite { testSettingSpecialCharacter(attrs, idx, 3.toUByte, name) testSettingSpecialCharacter(attrs, idx, 4.toUByte, name) } - } finally { - termios.set(orig) - } + 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 be33c83..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 @@ -26,4 +26,3 @@ trait AlternateScreenMode { f: F ?=> A ): (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 8a15ced..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 @@ -26,4 +26,3 @@ trait ApplicationMode { f: F ?=> A ): (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 1130ef7..9aa8d39 100644 --- a/core/shared/src/main/scala/terminus/Color.scala +++ b/core/shared/src/main/scala/terminus/Color.scala @@ -16,8 +16,8 @@ package terminus -trait Color { - object foreground { +trait Color: + object foreground: def default[F, A]( f: F ?=> A ): (F & effect.Color) ?=> A = @@ -102,9 +102,8 @@ trait Color { f: F ?=> A ): (F & effect.Color) ?=> A = effect ?=> effect.foreground.brightWhite(() => f(using effect)) - } - object background { + object background: def default[F, A]( f: F ?=> A ): (F & effect.Color) ?=> A = @@ -189,5 +188,3 @@ trait Color { f: F ?=> A ): (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..97bd275 100644 --- a/core/shared/src/main/scala/terminus/Cursor.scala +++ b/core/shared/src/main/scala/terminus/Cursor.scala @@ -16,8 +16,8 @@ package terminus -trait Cursor { - object cursor { +trait Cursor: + object cursor: /** Move cursor to given column. The left-most column is 1, and coordinates * increase to the right. @@ -56,5 +56,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 1dc2a5c..2e625a0 100644 --- a/core/shared/src/main/scala/terminus/Format.scala +++ b/core/shared/src/main/scala/terminus/Format.scala @@ -16,8 +16,8 @@ package terminus -trait Format { - object format { +trait Format: + object format: def bold[F, A](f: F ?=> A): (F & effect.Format) ?=> A = effect ?=> effect.format.bold(() => f(using effect)) @@ -31,7 +31,7 @@ trait Format { ): (F & effect.Format) ?=> A = effect ?=> effect.format.normal(() => f(using effect)) - object underline { + object underline: def none[F, A]( f: F ?=> A ): (F & effect.Format) ?=> A = @@ -106,7 +106,6 @@ trait Format { f: F ?=> A ): (F & effect.Format) ?=> A = effect ?=> effect.format.underline.white(() => f(using effect)) - } def blink[F, A]( f: F ?=> A @@ -127,5 +126,3 @@ trait Format { f: F ?=> A ): (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 c0e8173..d049b88 100644 --- a/core/shared/src/main/scala/terminus/RawMode.scala +++ b/core/shared/src/main/scala/terminus/RawMode.scala @@ -16,7 +16,7 @@ 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, @@ -24,4 +24,3 @@ trait RawMode { */ 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 ac5c3b8..b3664dd 100644 --- a/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala +++ b/core/shared/src/main/scala/terminus/StringBuilderTerminal.scala @@ -23,7 +23,7 @@ final class StringBuilderTerminal() effect.Cursor, 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 41b9190..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 = @@ -34,4 +34,3 @@ trait Writer { /** 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 717b6d6..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 { +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: () => 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..8b13789 100644 --- a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala +++ b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala @@ -1,273 +1 @@ -/* - * 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.effect - -/** The codes for controlling terminal functionality, commonly known as ANSI - * escape codes. See: - * - * - https://en.wikipedia.org/wiki/ANSI_escape_code - * - https://www.man7.org/linux/man-pages/man4/console_codes.4.html - * - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html - * - https://ghostty.org/docs/vt - */ -object AnsiCodes { - import Ascii.* - - /** The Control Sequencer Introducer code, which starts many escape codes. It - * is ESC[ - */ - val csiCode: String = s"${ESC}[" - - /** Create a CSI escape code. The terminator must be specifed first, followed - * by zero or more arguments. The arguments will printed semi-colon separated - * before the terminator. - */ - 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. - */ - def sgr(n: String): String = - csi("m", n) - - /** Codes for manipulating the cursor */ - object cursor { - - /** Restore the previously saved cursor state, or reset it to default values - * if no state has been saved. - */ - val restore: String = - s"${ESC}8" - - /** Save the cursor location, character set, pending wrap state, SGR - * attributes, and origin mode. - */ - val save: String = - s"${ESC}7" - - object style { - val default = s"${ESC}0 q" - - object block { - val blink = s"${ESC}1 q" - val steady = s"${ESC}2 q" - } - - object underline { - val blink = s"${ESC}3 q" - val steady = s"${ESC}4 q" - } - - object bar { - val blink = s"${ESC}5 q" - val steady = s"${ESC}6 q" - } - } - - object down { - - /** Move down the given number of lines and to the beginning of the line. - */ - def line(n: Int): String = - csi("E", n.toString) - - /** Move down the given number of lines. */ - def apply(n: Int): String = - csi("B", n.toString) - } - - object up { - - /** Move up the given number of lines and to the beginning of the line. - */ - def line(n: Int): String = - csi("F", n.toString) - - /** 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 = - csi("C", n.toString) - - /** Move the given number of characters to the left. */ - def backward(n: Int): String = - csi("D", n.toString) - - /** Move the cursor to the given column. The left-most column is 1, and - * coordinates increase to the right. - */ - def column(n: Int): String = - csi("G", n.toString) - - /** Move the cursor to the given position, where (1, 1) is the top left - * corner and coordinates increase to the right and down. - */ - def to(x: Int, y: Int): String = - csi("H", y.toString, x.toString) - } - - object format { - object bold { - val on: String = sgr("1") - val off: String = sgr("22") - } - 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/ - * - * Off and straight are backwards compatible, but older terminals might not - * support the others. - */ - object underline { - val off: String = sgr("24") // Backwards compatible variant - val straight: String = sgr("4") // Backwards compatible variant - val double: String = sgr("4:2") - val curly: String = sgr("4:3") - val dotted: String = sgr("4:4") - val dashed: String = sgr("4:5") - - // Not sure this will work. - val default: String = sgr("59") - val black: String = sgr("50") - val red: String = sgr("51") - val green: String = sgr("52") - val yellow: String = sgr("53") - val blue: String = sgr("54") - val magenta: String = sgr("55") - val cyan: String = sgr("56") - val white: String = sgr("57") - } - - object blink { - val on: String = sgr("5") - val off: String = sgr("25") - } - - object invert { - val on: String = sgr("7") - val off: String = sgr("27") - } - - object invisible { - val on: String = sgr("8") - val off: String = sgr("28") - } - - object strikethrough { - val on: String = sgr("9") - val off: String = sgr("29") - } - } - - object erase { - - /** Erase the entire screen and move the cursor to the top-left. */ - val screen: String = csi("J", "2") - - /** Erase from current cursor position to the end of the screen. */ - val down: String = csi("J", "0") - - /** Erase from current cursor position to the start of the screen. */ - val up: String = csi("J", "1") - - /** Erase the current line. */ - val line: String = csi("K", "2") - } - - 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 { - 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 { - val on: String = csi("?1049h") - val off: String = csi("?11049l") - } - } - - object scroll { - - /** Scroll the display up the given number of rows. Defaults to 1 row. */ - def up(lines: Int = 1): String = - csi("S", lines.toString) - - /** 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 { - val default: String = sgr("39") - val black: String = sgr("30") - val red: String = sgr("31") - val green: String = sgr("32") - val yellow: String = sgr("33") - val blue: String = sgr("34") - val magenta: String = sgr("35") - val cyan: String = sgr("36") - val white: String = sgr("37") - val brightBlack: String = sgr("90") - val brightRed: String = sgr("91") - val brightGreen: String = sgr("92") - val brightYellow: String = sgr("93") - val brightBlue: String = sgr("94") - val brightMagenta: String = sgr("95") - val brightCyan: String = sgr("96") - val brightWhite: String = sgr("97") - } - - /** Set background color. */ - object background { - val default: String = sgr("49") - val black: String = sgr("40") - val red: String = sgr("41") - val green: String = sgr("42") - val yellow: String = sgr("43") - val blue: String = sgr("44") - val magenta: String = sgr("45") - val cyan: String = sgr("46") - val white: String = sgr("47") - val brightBlack: String = sgr("100") - val brightRed: String = sgr("101") - val brightGreen: String = sgr("102") - val brightYellow: String = sgr("103") - val brightBlue: String = sgr("104") - 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 d6de5eb..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 { +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: () => 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 ad4ad5c..e58e403 100644 --- a/core/shared/src/main/scala/terminus/effect/Color.scala +++ b/core/shared/src/main/scala/terminus/effect/Color.scala @@ -16,8 +16,9 @@ package terminus.effect -trait Color extends WithStack { self: Writer => - object foreground { +trait Color extends WithStack: + self: Writer => + object foreground: private val foregroundStack: Stack = Stack(AnsiCodes.foreground.default) def default[A](f: () => A): A = @@ -69,9 +70,8 @@ trait Color extends WithStack { self: Writer => 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: () => A): A = @@ -124,5 +124,3 @@ trait Color extends WithStack { self: Writer => 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..865996b 100644 --- a/core/shared/src/main/scala/terminus/effect/Cursor.scala +++ b/core/shared/src/main/scala/terminus/effect/Cursor.scala @@ -17,8 +17,8 @@ package terminus.effect /** Functionality for manipulating the terminal's cursor. */ -trait Cursor extends Writer { - object cursor { +trait Cursor extends Writer: + object cursor: /** Move cursor to given column. The left-most column is 1, and coordinates * increase to the right. @@ -35,13 +35,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 +61,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 981d491..32786a3 100644 --- a/core/shared/src/main/scala/terminus/effect/Format.scala +++ b/core/shared/src/main/scala/terminus/effect/Format.scala @@ -17,8 +17,9 @@ package terminus.effect /** Terminal effects that can change character formatting properties. */ -trait Format extends WithStack, WithToggle { self: Writer => - 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) @@ -33,7 +34,7 @@ trait Format extends WithStack, WithToggle { self: Writer => 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: () => A): A = @@ -84,7 +85,6 @@ trait Format extends WithStack, WithToggle { self: Writer => 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) @@ -111,5 +111,3 @@ trait Format extends WithStack, WithToggle { self: Writer => 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 1cbd612..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 { +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: () => 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 b467c17..7f6bf36 100644 --- a/core/shared/src/main/scala/terminus/effect/TerminalKeyReader.scala +++ b/core/shared/src/main/scala/terminus/effect/TerminalKeyReader.scala @@ -30,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: @@ -40,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 7ac0938..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 { self: Writer => - protected def withEffect[A](on: String, off: String)(f: () => A): A = { +trait WithEffect: + self: Writer => + protected def withEffect[A](on: String, off: String)(f: () => A): A = write(on) - try { + try f() - } finally { + finally write(off) - } - } - protected def withEffect[A](on: String)(f: () => A): A = { + protected def withEffect[A](on: String)(f: () => A): A = write(on) f() - } -} diff --git a/core/shared/src/main/scala/terminus/effect/WithStack.scala b/core/shared/src/main/scala/terminus/effect/WithStack.scala index e39b5bd..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 { self: Writer => +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: () => A): A = { + protected def withStack[A](stack: Stack, code: String)(f: () => A): A = stack.push(code, self) - try { + try f() - } finally { + 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 3e1a7fc..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 { self: Writer => +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: () => A): A = { + protected def withToggle[A](toggle: Toggle)(f: () => A): A = toggle.on(self) - try { + try f() - } finally { + 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 0442421..0a8db36 100644 --- a/core/shared/src/main/scala/terminus/example/Prompt.scala +++ b/core/shared/src/main/scala/terminus/example/Prompt.scala @@ -24,18 +24,17 @@ import scala.annotation.tailrec class Prompt[ Terminal <: effect.Color & effect.Cursor & effect.Format & effect.Erase & effect.KeyReader & effect.Writer -](terminal: Color & Cursor & Format & Erase & KeyReader & 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,33 +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 { + 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) @@ -80,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 48fa0a6..8c71837 100644 --- a/core/shared/src/test/scala/terminus/KeyShowSuite.scala +++ b/core/shared/src/test/scala/terminus/KeyShowSuite.scala @@ -19,7 +19,7 @@ package terminus 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 e28452f..7617fd6 100644 --- a/core/shared/src/test/scala/terminus/effect/ColorSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/ColorSuite.scala @@ -19,7 +19,7 @@ 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" ) { @@ -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 3477cc7..ab312df 100644 --- a/core/shared/src/test/scala/terminus/effect/FormatSuite.scala +++ b/core/shared/src/test/scala/terminus/effect/FormatSuite.scala @@ -19,7 +19,7 @@ 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 ?=> @@ -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/ui/shared/src/main/scala/terminus/ui/Size.scala b/ui/shared/src/main/scala/terminus/ui/Size.scala index 0898d01..2af470c 100644 --- a/ui/shared/src/main/scala/terminus/ui/Size.scala +++ b/ui/shared/src/main/scala/terminus/ui/Size.scala @@ -17,7 +17,7 @@ package terminus.ui /** Represents the size of component in terms of temrinal cells. */ -final case class Size(width: Int, height: Int) { +final case class Size(width: Int, height: Int): /** Combine two sizes into a row. That is, add together width and take the max * height. @@ -30,7 +30,5 @@ final case class Size(width: Int, height: Int) { */ def column(that: Size): Size = Size(this.width.max(that.width), this.height + that.height) -} -object Size { +object Size: val zero: Size = Size(0, 0) -} diff --git a/ui/shared/src/main/scala/terminus/ui/style/Border.scala b/ui/shared/src/main/scala/terminus/ui/style/Border.scala index a5087ff..6c5be3d 100644 --- a/ui/shared/src/main/scala/terminus/ui/style/Border.scala +++ b/ui/shared/src/main/scala/terminus/ui/style/Border.scala @@ -24,7 +24,7 @@ final case class Border( bottomLeft: Char, bottomRight: Char ) -object Border { +object Border: val single = Border( topLeft = '┌', topRight = '┐', @@ -51,4 +51,3 @@ object Border { bottomLeft = '+', bottomRight = '+' ) -} From e1423d9488806350e81a1da58a5a344b59d270e7 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 12:41:25 +0100 Subject: [PATCH 10/27] Move dependencies into build.sbt This prevents scalafmt reformatting these into Scala 3 style. Also reinstate AnsiCodes.scala, which got deleted somewhere along the way. --- build.sbt | 27 +- .../scala/terminus/TermiosAccessSpec.scala | 1 + .../scala/terminus/effect/AnsiCodes.scala | 248 ++++++++++++++++++ project/Dependencies.scala | 45 ---- 4 files changed, 269 insertions(+), 52 deletions(-) delete mode 100644 project/Dependencies.scala diff --git a/build.sbt b/build.sbt index 3e542a2..7fc0647 100644 --- a/build.sbt +++ b/build.sbt @@ -59,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( @@ -76,13 +91,11 @@ 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")) diff --git a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala index 2501d37..3c21f4f 100644 --- a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala +++ b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala @@ -13,3 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + diff --git a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala index 8b13789..e18aa86 100644 --- a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala +++ b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala @@ -1 +1,249 @@ +/* + * 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.effect + +/** The codes for controlling terminal functionality, commonly known as ANSI + * escape codes. See: + * + * - https://en.wikipedia.org/wiki/ANSI_escape_code + * - https://www.man7.org/linux/man-pages/man4/console_codes.4.html + * - https://invisible-island.net/xterm/ctlseqs/ctlseqs.html + * - https://ghostty.org/docs/vt + */ +object AnsiCodes: + import Ascii.* + + /** The Control Sequencer Introducer code, which starts many escape codes. It + * is ESC[ + */ + val csiCode: String = s"${ESC}[" + + /** Create a CSI escape code. The terminator must be specifed first, followed + * by zero or more arguments. The arguments will printed semi-colon separated + * before the terminator. + */ + 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. + */ + def sgr(n: String): String = + csi("m", n) + + /** Codes for manipulating the cursor */ + object cursor: + + /** Restore the previously saved cursor state, or reset it to default values + * if no state has been saved. + */ + val restore: String = + s"${ESC}8" + + /** Save the cursor location, character set, pending wrap state, SGR + * attributes, and origin mode. + */ + val save: String = + s"${ESC}7" + + object style: + val default = s"${ESC}0 q" + + object block: + val blink = s"${ESC}1 q" + val steady = s"${ESC}2 q" + + object underline: + val blink = s"${ESC}3 q" + val steady = s"${ESC}4 q" + + object bar: + val blink = s"${ESC}5 q" + val steady = s"${ESC}6 q" + + object down: + + /** Move down the given number of lines and to the beginning of the line. + */ + def line(n: Int): String = + csi("E", n.toString) + + /** Move down the given number of lines. */ + def apply(n: Int): String = + csi("B", n.toString) + + object up: + + /** Move up the given number of lines and to the beginning of the line. + */ + def line(n: Int): String = + csi("F", n.toString) + + /** 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 = + csi("C", n.toString) + + /** Move the given number of characters to the left. */ + def backward(n: Int): String = + csi("D", n.toString) + + /** Move the cursor to the given column. The left-most column is 1, and + * coordinates increase to the right. + */ + def column(n: Int): String = + csi("G", n.toString) + + /** Move the cursor to the given position, where (1, 1) is the top left + * corner and coordinates increase to the right and down. + */ + def to(x: Int, y: Int): String = + csi("H", y.toString, x.toString) + + object format: + object bold: + val on: String = sgr("1") + val off: String = sgr("22") + 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/ + * + * Off and straight are backwards compatible, but older terminals might not + * support the others. + */ + object underline: + val off: String = sgr("24") // Backwards compatible variant + val straight: String = sgr("4") // Backwards compatible variant + val double: String = sgr("4:2") + val curly: String = sgr("4:3") + val dotted: String = sgr("4:4") + val dashed: String = sgr("4:5") + + // Not sure this will work. + val default: String = sgr("59") + val black: String = sgr("50") + val red: String = sgr("51") + val green: String = sgr("52") + val yellow: String = sgr("53") + val blue: String = sgr("54") + val magenta: String = sgr("55") + val cyan: String = sgr("56") + val white: String = sgr("57") + + object blink: + val on: String = sgr("5") + val off: String = sgr("25") + + object invert: + val on: String = sgr("7") + val off: String = sgr("27") + + object invisible: + val on: String = sgr("8") + val off: String = sgr("28") + + object strikethrough: + val on: String = sgr("9") + val off: String = sgr("29") + + object erase: + + /** Erase the entire screen and move the cursor to the top-left. */ + val screen: String = csi("J", "2") + + /** Erase from current cursor position to the end of the screen. */ + val down: String = csi("J", "0") + + /** Erase from current cursor position to the start of the screen. */ + val up: String = csi("J", "1") + + /** Erase the current line. */ + val line: String = csi("K", "2") + + 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: + 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: + val on: String = csi("?1049h") + val off: String = csi("?11049l") + + object scroll: + + /** Scroll the display up the given number of rows. Defaults to 1 row. */ + def up(lines: Int = 1): String = + csi("S", lines.toString) + + /** 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: + val default: String = sgr("39") + val black: String = sgr("30") + val red: String = sgr("31") + val green: String = sgr("32") + val yellow: String = sgr("33") + val blue: String = sgr("34") + val magenta: String = sgr("35") + val cyan: String = sgr("36") + val white: String = sgr("37") + val brightBlack: String = sgr("90") + val brightRed: String = sgr("91") + val brightGreen: String = sgr("92") + val brightYellow: String = sgr("93") + val brightBlue: String = sgr("94") + val brightMagenta: String = sgr("95") + val brightCyan: String = sgr("96") + val brightWhite: String = sgr("97") + + /** Set background color. */ + object background: + val default: String = sgr("49") + val black: String = sgr("40") + val red: String = sgr("41") + val green: String = sgr("42") + val yellow: String = sgr("43") + val blue: String = sgr("44") + val magenta: String = sgr("45") + val cyan: String = sgr("46") + val white: String = sgr("47") + val brightBlack: String = sgr("100") + val brightRed: String = sgr("101") + val brightGreen: String = sgr("102") + val brightYellow: String = sgr("103") + val brightBlue: String = sgr("104") + val brightMagenta: String = sgr("105") + val brightCyan: String = sgr("106") + val brightWhite: String = sgr("107") diff --git a/project/Dependencies.scala b/project/Dependencies.scala deleted file mode 100644 index f27322d..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.1" - - val jlineVersion = "4.0.12" - - val scalaCheckVersion = "1.15.4" - val munitVersion = "1.3.0" - val munitScalacheckVersion = "1.3.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 - ) -} From e0c427a24dda825016cb87d856322aec9f6575e0 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 12:42:14 +0100 Subject: [PATCH 11/27] UI example running in Scala Native --- .../src/main/scala/terminus/ui/Example.scala | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 ui/native/src/main/scala/terminus/ui/Example.scala 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..b069ab4 --- /dev/null +++ b/ui/native/src/main/scala/terminus/ui/Example.scala @@ -0,0 +1,35 @@ +/* + * 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.NativeTerminal +import terminus.ui.component.Text + +@main def boxes(): Unit = + val program: FullScreen.Program[Unit] = + FullScreen { + Row { + Text(20, 5)("Box A") + Text(20, 5)("Box B") + } + Text(20, 5)("Box C") + } + + NativeTerminal.run { + program + Terminal.newline + } From 0e536b333ade2bc20749a726091382cfe16dc316 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 12:42:33 +0100 Subject: [PATCH 12/27] Increase heap size in sbt --- .sbtopts | 1 + 1 file changed, 1 insertion(+) create mode 100644 .sbtopts diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..2b63e3b --- /dev/null +++ b/.sbtopts @@ -0,0 +1 @@ +-J-Xmx4G From 6e3f6f2bc91ff0d55147bc5b139cba99f91540cf Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 12:43:13 +0100 Subject: [PATCH 13/27] Ignore .scala-build --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 14b92ad69169c3b2ff65b256388bbe0234ad1fe7 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 13:12:48 +0100 Subject: [PATCH 14/27] Extend support styles Support italic, underline, etc. --- .../scala/terminus/effect/AnsiCodes.scala | 3 ++ .../src/main/scala/terminus/ui/Buffer.scala | 31 +++++++++++++++++++ .../main/scala/terminus/ui/style/Style.scala | 7 ++++- .../scala/terminus/ui/style/Underline.scala | 15 +++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 ui/shared/src/main/scala/terminus/ui/style/Underline.scala diff --git a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala index e18aa86..6ba82a7 100644 --- a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala +++ b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala @@ -121,6 +121,9 @@ object AnsiCodes: object bold: val on: String = sgr("1") val off: String = sgr("22") + object italic: + val on: String = sgr("3") + val off: String = sgr("23") object light: val on: String = sgr("2") val off: String = sgr("22") diff --git a/ui/shared/src/main/scala/terminus/ui/Buffer.scala b/ui/shared/src/main/scala/terminus/ui/Buffer.scala index fc47784..2f0b066 100644 --- a/ui/shared/src/main/scala/terminus/ui/Buffer.scala +++ b/ui/shared/src/main/scala/terminus/ui/Buffer.scala @@ -86,6 +86,28 @@ final class Buffer(val width: Int, val height: Int): if cell.style.bold then AnsiCodes.format.bold.on else AnsiCodes.format.bold.off ) + if cell.style.italic != currentStyle.italic then + t.write( + if cell.style.italic then AnsiCodes.format.italic.on + else AnsiCodes.format.italic.off + ) + if cell.style.underline != currentStyle.underline then + t.write(underlineCode(cell.style.underline)) + if cell.style.blink != currentStyle.blink then + t.write( + if cell.style.blink then AnsiCodes.format.blink.on + else AnsiCodes.format.blink.off + ) + if cell.style.invert != currentStyle.invert then + t.write( + if cell.style.invert then AnsiCodes.format.invert.on + else AnsiCodes.format.invert.off + ) + if cell.style.strikethrough != currentStyle.strikethrough then + t.write( + if cell.style.strikethrough then AnsiCodes.format.strikethrough.on + else AnsiCodes.format.strikethrough.off + ) currentStyle = cell.style t.write(cell.char) x += 1 @@ -94,6 +116,15 @@ final class Buffer(val width: Int, val height: Int): t.write(AnsiCodes.sgr("0")) t.flush() + 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 diff --git a/ui/shared/src/main/scala/terminus/ui/style/Style.scala b/ui/shared/src/main/scala/terminus/ui/style/Style.scala index 2d2cd9a..c7c945e 100644 --- a/ui/shared/src/main/scala/terminus/ui/style/Style.scala +++ b/ui/shared/src/main/scala/terminus/ui/style/Style.scala @@ -20,7 +20,12 @@ package terminus.ui.style final case class Style( fg: Color = Color.Default, bg: Color = Color.Default, - bold: Boolean = false + 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..72f6799 --- /dev/null +++ b/ui/shared/src/main/scala/terminus/ui/style/Underline.scala @@ -0,0 +1,15 @@ +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 From a1a684a9b83b3599d96e853427d89da46b19d62f Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 13:47:54 +0100 Subject: [PATCH 15/27] Support wide characters - Support characters that have a width or 0 or 2. This enables support for emojis and east Asian languages. - Add very basic styling support to allow use of extended styling - Update example to show new features --- .../src/main/scala/terminus/ui/Example.scala | 46 +++++- .../src/main/scala/terminus/ui/Buffer.scala | 99 +++++++----- .../src/main/scala/terminus/ui/Cell.scala | 23 ++- .../main/scala/terminus/ui/CharWidth.scala | 115 +++++++++++++ .../scala/terminus/ui/component/Text.scala | 4 +- .../scala/terminus/ui/style/Underline.scala | 16 ++ .../src/main/scala/terminus/ui/tool/Box.scala | 16 +- .../scala/terminus/ui/CharWidthSuite.scala | 152 ++++++++++++++++++ 8 files changed, 415 insertions(+), 56 deletions(-) create mode 100644 ui/shared/src/main/scala/terminus/ui/CharWidth.scala create mode 100644 ui/shared/src/test/scala/terminus/ui/CharWidthSuite.scala diff --git a/ui/native/src/main/scala/terminus/ui/Example.scala b/ui/native/src/main/scala/terminus/ui/Example.scala index b069ab4..059f71e 100644 --- a/ui/native/src/main/scala/terminus/ui/Example.scala +++ b/ui/native/src/main/scala/terminus/ui/Example.scala @@ -18,15 +18,53 @@ package terminus.ui import terminus.NativeTerminal import terminus.ui.component.Text +import terminus.ui.style.Color +import terminus.ui.style.Style +import terminus.ui.style.Underline -@main def boxes(): Unit = +// 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, 5, Style(bold = true))( + "Bold 💪" + ) + Text(24, 5, Style(italic = true))( + "Italic ✨" + ) + Text(24, 5, Style(strikethrough = true))( + "Strikethrough ❌" + ) + } + + // Row 2: underline variants and invert + Row { + Text(24, 5, Style(underline = Underline.Straight))( + "Straight underline" + ) + Text(24, 5, Style(underline = Underline.Curly))( + "Curly underline" + ) + Text(24, 5, Style(invert = true))( + "Inverted 🔄" + ) + } + + // Row 3: colours + wide characters (emoji and CJK) Row { - Text(20, 5)("Box A") - Text(20, 5)("Box B") + Text(24, 5, Style(fg = Color.Red, bold = true))( + "🔴 Red — 红色" + ) + Text(24, 5, Style(fg = Color.Green, bold = true))( + "🟢 Green — 緑" + ) + Text(24, 5, Style(fg = Color.Blue, bold = true))( + "🔵 Blue — 青色" + ) } - Text(20, 5)("Box C") } NativeTerminal.run { diff --git a/ui/shared/src/main/scala/terminus/ui/Buffer.scala b/ui/shared/src/main/scala/terminus/ui/Buffer.scala index 2f0b066..6f725da 100644 --- a/ui/shared/src/main/scala/terminus/ui/Buffer.scala +++ b/ui/shared/src/main/scala/terminus/ui/Buffer.scala @@ -16,7 +16,6 @@ package terminus.ui -import terminus.effect import terminus.effect.AnsiCodes import terminus.ui.style.Color import terminus.ui.style.Style @@ -53,18 +52,38 @@ final class Buffer(val width: Int, val height: Int): x += 1 y += 1 - /** Write a string horizontally starting at (x, y). Clips to buffer bounds. */ + /** Write a string horizontally starting at (x, y). + * + * 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. Clips to buffer bounds. + */ def putString(x: Int, y: Int, s: String, style: Style): Unit = + var col = x var i = 0 while i < s.length do - put(x + i, y, Cell(s.charAt(i), style)) - i += 1 + val cp = Character.codePointAt(s, i) + i += Character.charCount(cp) + CharWidth.of(cp) match + case 0 => () // zero-width: skip + case 1 => + put(col, y, Cell(cp, style)) + col += 1 + case _ => // 2 + put(col, y, Cell(cp, style)) + put(col + 1, y, Cell.continuation) + col += 2 /** Flush the 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. - * Emits a full SGR reset before and after rendering. + * 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")) @@ -76,40 +95,42 @@ final class Buffer(val width: Int, val height: Int): var x = 0 while x < width do val cell = cells(y * width + x) - if cell.style != currentStyle then - if cell.style.fg != currentStyle.fg then - t.write(fgCode(cell.style.fg)) - if cell.style.bg != currentStyle.bg then - t.write(bgCode(cell.style.bg)) - if cell.style.bold != currentStyle.bold then - t.write( - if cell.style.bold then AnsiCodes.format.bold.on - else AnsiCodes.format.bold.off - ) - if cell.style.italic != currentStyle.italic then - t.write( - if cell.style.italic then AnsiCodes.format.italic.on - else AnsiCodes.format.italic.off - ) - if cell.style.underline != currentStyle.underline then - t.write(underlineCode(cell.style.underline)) - if cell.style.blink != currentStyle.blink then - t.write( - if cell.style.blink then AnsiCodes.format.blink.on - else AnsiCodes.format.blink.off - ) - if cell.style.invert != currentStyle.invert then - t.write( - if cell.style.invert then AnsiCodes.format.invert.on - else AnsiCodes.format.invert.off - ) - if cell.style.strikethrough != currentStyle.strikethrough then - t.write( - if cell.style.strikethrough then AnsiCodes.format.strikethrough.on - else AnsiCodes.format.strikethrough.off - ) - currentStyle = cell.style - t.write(cell.char) + if cell.codePoint != 0 then // skip continuation cells + if cell.style != currentStyle then + if cell.style.fg != currentStyle.fg then + t.write(fgCode(cell.style.fg)) + if cell.style.bg != currentStyle.bg then + t.write(bgCode(cell.style.bg)) + if cell.style.bold != currentStyle.bold then + t.write( + if cell.style.bold then AnsiCodes.format.bold.on + else AnsiCodes.format.bold.off + ) + if cell.style.italic != currentStyle.italic then + t.write( + if cell.style.italic then AnsiCodes.format.italic.on + else AnsiCodes.format.italic.off + ) + if cell.style.underline != currentStyle.underline then + t.write(underlineCode(cell.style.underline)) + if cell.style.blink != currentStyle.blink then + t.write( + if cell.style.blink then AnsiCodes.format.blink.on + else AnsiCodes.format.blink.off + ) + if cell.style.invert != currentStyle.invert then + t.write( + if cell.style.invert then AnsiCodes.format.invert.on + else AnsiCodes.format.invert.off + ) + if cell.style.strikethrough != currentStyle.strikethrough then + t.write( + if cell.style.strikethrough then + AnsiCodes.format.strikethrough.on + else AnsiCodes.format.strikethrough.off + ) + currentStyle = cell.style + t.write(new String(Character.toChars(cell.codePoint))) x += 1 y += 1 diff --git a/ui/shared/src/main/scala/terminus/ui/Cell.scala b/ui/shared/src/main/scala/terminus/ui/Cell.scala index 937f51b..c898b0b 100644 --- a/ui/shared/src/main/scala/terminus/ui/Cell.scala +++ b/ui/shared/src/main/scala/terminus/ui/Cell.scala @@ -18,7 +18,24 @@ package terminus.ui import terminus.ui.style.Style -/** A single terminal cell: a character with associated styling. */ -final case class Cell(char: Char, 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: - val empty: Cell = Cell(' ', Style.default) + /** 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/component/Text.scala b/ui/shared/src/main/scala/terminus/ui/component/Text.scala index a611c1d..5cf025a 100644 --- a/ui/shared/src/main/scala/terminus/ui/component/Text.scala +++ b/ui/shared/src/main/scala/terminus/ui/component/Text.scala @@ -39,7 +39,7 @@ object Text: Box.render(bounds, Border.single, style, buf) buf.putString(bounds.x + 1, bounds.y + 1, content, style) - def apply(width: Int, height: Int)( + def apply(width: Int, height: Int, style: Style = Style.default)( content: String )(using ctx: RenderContext): Unit = - ctx.add(component(width, height, content)) + ctx.add(component(width, height, content, 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 index 72f6799..b535418 100644 --- a/ui/shared/src/main/scala/terminus/ui/style/Underline.scala +++ b/ui/shared/src/main/scala/terminus/ui/style/Underline.scala @@ -1,3 +1,19 @@ +/* + * Copyright 2024 Creative Scala + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package terminus.ui.style /** The underline style for a terminal cell. diff --git a/ui/shared/src/main/scala/terminus/ui/tool/Box.scala b/ui/shared/src/main/scala/terminus/ui/tool/Box.scala index d77e907..c20ee77 100644 --- a/ui/shared/src/main/scala/terminus/ui/tool/Box.scala +++ b/ui/shared/src/main/scala/terminus/ui/tool/Box.scala @@ -35,24 +35,24 @@ object Box: val y1 = bounds.y + bounds.height - 1 // inclusive bottom edge // Top row - buf.put(x0, y0, Cell(border.topLeft, style)) + buf.put(x0, y0, Cell(border.topLeft.toInt, style)) var x = x0 + 1 while x < x1 do - buf.put(x, y0, Cell(border.horizontal, style)) + buf.put(x, y0, Cell(border.horizontal.toInt, style)) x += 1 - buf.put(x1, y0, Cell(border.topRight, style)) + buf.put(x1, y0, Cell(border.topRight.toInt, style)) // Sides var y = y0 + 1 while y < y1 do - buf.put(x0, y, Cell(border.vertical, style)) - buf.put(x1, y, Cell(border.vertical, style)) + buf.put(x0, y, Cell(border.vertical.toInt, style)) + buf.put(x1, y, Cell(border.vertical.toInt, style)) y += 1 // Bottom row - buf.put(x0, y1, Cell(border.bottomLeft, style)) + buf.put(x0, y1, Cell(border.bottomLeft.toInt, style)) x = x0 + 1 while x < x1 do - buf.put(x, y1, Cell(border.horizontal, style)) + buf.put(x, y1, Cell(border.horizontal.toInt, style)) x += 1 - buf.put(x1, y1, Cell(border.bottomRight, style)) + buf.put(x1, y1, Cell(border.bottomRight.toInt, style)) 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) + } From 9f4bff9f15ab885c0719611b36323210a3afc043 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 15:39:04 +0100 Subject: [PATCH 16/27] Add buffer differencing - Add method `renderDiff` that only renders the difference between two buffers. - Add tests for diffing --- .../src/main/scala/terminus/ui/Buffer.scala | 107 ++++++---- .../test/scala/terminus/ui/BufferSuite.scala | 196 ++++++++++++++++++ 2 files changed, 267 insertions(+), 36 deletions(-) create mode 100644 ui/shared/src/test/scala/terminus/ui/BufferSuite.scala diff --git a/ui/shared/src/main/scala/terminus/ui/Buffer.scala b/ui/shared/src/main/scala/terminus/ui/Buffer.scala index 6f725da..67065a9 100644 --- a/ui/shared/src/main/scala/terminus/ui/Buffer.scala +++ b/ui/shared/src/main/scala/terminus/ui/Buffer.scala @@ -78,7 +78,7 @@ final class Buffer(val width: Int, val height: Int): put(col + 1, y, Cell.continuation) col += 2 - /** Flush the buffer to the terminal. + /** 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. @@ -87,7 +87,6 @@ final class Buffer(val width: Int, val height: Int): */ def render(using t: Terminal): Unit = t.write(AnsiCodes.sgr("0")) - var currentStyle = Style.default var y = 0 while y < height do @@ -96,47 +95,83 @@ final class Buffer(val width: Int, val height: Int): while x < width do val cell = cells(y * width + x) if cell.codePoint != 0 then // skip continuation cells - if cell.style != currentStyle then - if cell.style.fg != currentStyle.fg then - t.write(fgCode(cell.style.fg)) - if cell.style.bg != currentStyle.bg then - t.write(bgCode(cell.style.bg)) - if cell.style.bold != currentStyle.bold then - t.write( - if cell.style.bold then AnsiCodes.format.bold.on - else AnsiCodes.format.bold.off - ) - if cell.style.italic != currentStyle.italic then - t.write( - if cell.style.italic then AnsiCodes.format.italic.on - else AnsiCodes.format.italic.off - ) - if cell.style.underline != currentStyle.underline then - t.write(underlineCode(cell.style.underline)) - if cell.style.blink != currentStyle.blink then - t.write( - if cell.style.blink then AnsiCodes.format.blink.on - else AnsiCodes.format.blink.off - ) - if cell.style.invert != currentStyle.invert then - t.write( - if cell.style.invert then AnsiCodes.format.invert.on - else AnsiCodes.format.invert.off - ) - if cell.style.strikethrough != currentStyle.strikethrough then - t.write( - if cell.style.strikethrough then - AnsiCodes.format.strikethrough.on - else AnsiCodes.format.strikethrough.off - ) - currentStyle = cell.style + 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 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..2e29a45 --- /dev/null +++ b/ui/shared/src/test/scala/terminus/ui/BufferSuite.scala @@ -0,0 +1,196 @@ +/* + * 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)) + } + } From 81a5a5dd385a0059e3f6a43b3e2e8a9549f7201a Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 17:00:45 +0100 Subject: [PATCH 17/27] Add Column layout component Column is the vertical counterpart to Row: it stacks children top to bottom, accumulating height and taking the max width. Includes layout tests for both Row and Column covering size accumulation, single-axis placement, multi-size children, and nested Row/Column combinations. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/scala/terminus/ui/Example.scala | 35 +-- .../src/main/scala/terminus/ui/Column.scala | 47 ++++ .../test/scala/terminus/ui/LayoutSuite.scala | 231 ++++++++++++++++++ 3 files changed, 298 insertions(+), 15 deletions(-) create mode 100644 ui/shared/src/main/scala/terminus/ui/Column.scala create mode 100644 ui/shared/src/test/scala/terminus/ui/LayoutSuite.scala diff --git a/ui/native/src/main/scala/terminus/ui/Example.scala b/ui/native/src/main/scala/terminus/ui/Example.scala index 059f71e..5c6888f 100644 --- a/ui/native/src/main/scala/terminus/ui/Example.scala +++ b/ui/native/src/main/scala/terminus/ui/Example.scala @@ -29,40 +29,45 @@ import terminus.ui.style.Underline // Row 1: text style attributes Row { - Text(24, 5, Style(bold = true))( + Text(24, 3, Style(bold = true))( "Bold 💪" ) - Text(24, 5, Style(italic = true))( + Text(24, 3, Style(italic = true))( "Italic ✨" ) - Text(24, 5, Style(strikethrough = true))( + Text(24, 3, Style(strikethrough = true))( "Strikethrough ❌" ) } // Row 2: underline variants and invert Row { - Text(24, 5, Style(underline = Underline.Straight))( + Text(24, 3, Style(underline = Underline.Straight))( "Straight underline" ) - Text(24, 5, Style(underline = Underline.Curly))( + Text(24, 3, Style(underline = Underline.Curly))( "Curly underline" ) - Text(24, 5, Style(invert = true))( + Text(24, 3, Style(invert = true))( "Inverted 🔄" ) } - // Row 3: colours + wide characters (emoji and CJK) + // Column nested inside a Row: demonstrates Column layout Row { - Text(24, 5, Style(fg = Color.Red, bold = true))( - "🔴 Red — 红色" - ) - Text(24, 5, Style(fg = Color.Green, bold = true))( - "🟢 Green — 緑" - ) - Text(24, 5, Style(fg = Color.Blue, bold = true))( - "🔵 Blue — 青色" + Column { + Text(24, 3, Style(fg = Color.Red, bold = true))( + "🔴 Red — 红色" + ) + Text(24, 3, Style(fg = Color.Green, bold = true))( + "🟢 Green — 緑" + ) + Text(24, 3, Style(fg = Color.Blue, bold = true))( + "🔵 Blue — 青色" + ) + } + Text(24, 9, Style(fg = Color.Yellow))( + "Column on the left\nhas three coloured\nrows stacked." ) } } 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..5be9175 --- /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 + + private var childrenSize: Size = Size.zero + + def size: Size = childrenSize + + def add(component: Component): Unit = + childrenSize = childrenSize.column(component.size) + children += component + + def render(bounds: Rect, buf: Buffer): Unit = + var y = bounds.y + children.foreach { child => + child.render(Rect(bounds.x, y, child.size.width, child.size.height), buf) + y += child.size.height + } + +object Column: + def apply[A]( + f: RenderContext ?=> A + )(using parent: RenderContext): A = + val column = new Column() + val result = f(using column) + parent.add(column) + result 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 + ) + } From 4a010d08f9481d4901988e0202610c8a0e0eee48 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 17:01:06 +0100 Subject: [PATCH 18/27] Handle newlines in Buffer.putString MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newline characters were treated as width-1 glyphs, writing a literal \n into a buffer cell. At render time this emitted a terminal line-feed mid-row, shifting the cursor and causing subsequent cells to land on the wrong row — text after the first \n was invisible because the next row's render immediately overwrote it. putString now resets the column to x and advances the row on \n, matching the expected behaviour for multi-line text content. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/scala/terminus/ui/Buffer.scala | 29 ++++++++----- .../test/scala/terminus/ui/BufferSuite.scala | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/ui/shared/src/main/scala/terminus/ui/Buffer.scala b/ui/shared/src/main/scala/terminus/ui/Buffer.scala index 67065a9..55ed8fe 100644 --- a/ui/shared/src/main/scala/terminus/ui/Buffer.scala +++ b/ui/shared/src/main/scala/terminus/ui/Buffer.scala @@ -52,7 +52,7 @@ final class Buffer(val width: Int, val height: Int): x += 1 y += 1 - /** Write a string horizontally starting at (x, y). + /** 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 @@ -60,23 +60,30 @@ final class Buffer(val width: Int, val height: Int): * 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. Clips to buffer bounds. + * 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) - CharWidth.of(cp) match - case 0 => () // zero-width: skip - case 1 => - put(col, y, Cell(cp, style)) - col += 1 - case _ => // 2 - put(col, y, Cell(cp, style)) - put(col + 1, y, Cell.continuation) - col += 2 + 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. * diff --git a/ui/shared/src/test/scala/terminus/ui/BufferSuite.scala b/ui/shared/src/test/scala/terminus/ui/BufferSuite.scala index 2e29a45..58807c7 100644 --- a/ui/shared/src/test/scala/terminus/ui/BufferSuite.scala +++ b/ui/shared/src/test/scala/terminus/ui/BufferSuite.scala @@ -194,3 +194,46 @@ class BufferSuite extends FunSuite: 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 + ) + } From a4f2b919ac9195ce211764faaacd410f78053e2f Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 17:27:46 +0100 Subject: [PATCH 19/27] Add CLAUDE.md and design notes Documents the UI module architecture and styling design decisions for persistence across machines and conversations. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 21 +++++++++++++++++++++ notes/styling-design.md | 38 ++++++++++++++++++++++++++++++++++++++ notes/ui-architecture.md | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 CLAUDE.md create mode 100644 notes/styling-design.md create mode 100644 notes/ui-architecture.md 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/notes/styling-design.md b/notes/styling-design.md new file mode 100644 index 0000000..d65a096 --- /dev/null +++ b/notes/styling-design.md @@ -0,0 +1,38 @@ +# 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. + +## 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..99b19a4 --- /dev/null +++ b/notes/ui-architecture.md @@ -0,0 +1,40 @@ +# UI Module Architecture + +## Overview + +The `ui/` module builds a terminal UI toolkit on top of the core effect layer. 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. + +## Key types + +- **`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. +- **`Component`** — trait with `size: Size` and `render(bounds: Rect, buf: Buffer): Unit`. +- **`RenderContext`** — trait that components add children to. `RootContext` flushes to terminal; `ChildContext` renders into a buffer region. + +## 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). + +## 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. + +## 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 + +- `effect.Dimensions` on Scala Native (requires Posix `ioctl` — may be better to contribute upstream than implement in Terminus). From 526be59f324cdb9022012e96554e453de9e8eb54 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 17:49:40 +0100 Subject: [PATCH 20/27] Add component-level styling Introduces ComponentStyle (border, borderStyle, background, padding) separate from the cell-level Style. Box now fills the interior with the background style and applies borderStyle to border characters. Text splits into box and content style parameters, using Box.innerRect to position content correctly regardless of padding. Co-Authored-By: Claude Sonnet 4.6 --- notes/styling-design.md | 6 ++ .../src/main/scala/terminus/ui/Example.scala | 54 +++++++---- .../scala/terminus/ui/component/Text.scala | 23 +++-- .../terminus/ui/style/ComponentStyle.scala | 42 ++++++++ .../src/main/scala/terminus/ui/tool/Box.scala | 95 ++++++++++++------- 5 files changed, 159 insertions(+), 61 deletions(-) create mode 100644 ui/shared/src/main/scala/terminus/ui/style/ComponentStyle.scala diff --git a/notes/styling-design.md b/notes/styling-design.md index d65a096..916d6b0 100644 --- a/notes/styling-design.md +++ b/notes/styling-design.md @@ -28,6 +28,12 @@ TUIs commonly embed text in a border (e.g. a title or status in the top edge of 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: diff --git a/ui/native/src/main/scala/terminus/ui/Example.scala b/ui/native/src/main/scala/terminus/ui/Example.scala index 5c6888f..6f3cdc0 100644 --- a/ui/native/src/main/scala/terminus/ui/Example.scala +++ b/ui/native/src/main/scala/terminus/ui/Example.scala @@ -19,6 +19,7 @@ package terminus.ui 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 @@ -29,46 +30,61 @@ import terminus.ui.style.Underline // Row 1: text style attributes Row { - Text(24, 3, Style(bold = true))( + Text(24, 3, content = Style(bold = true))( "Bold 💪" ) - Text(24, 3, Style(italic = true))( + Text(24, 3, content = Style(italic = true))( "Italic ✨" ) - Text(24, 3, Style(strikethrough = true))( + Text(24, 3, content = Style(strikethrough = true))( "Strikethrough ❌" ) } // Row 2: underline variants and invert Row { - Text(24, 3, Style(underline = Underline.Straight))( + Text(24, 3, content = Style(underline = Underline.Straight))( "Straight underline" ) - Text(24, 3, Style(underline = Underline.Curly))( + Text(24, 3, content = Style(underline = Underline.Curly))( "Curly underline" ) - Text(24, 3, Style(invert = true))( + Text(24, 3, content = Style(invert = true))( "Inverted 🔄" ) } - // Column nested inside a Row: demonstrates Column layout + // Row 3: component styling — coloured borders and background fill Row { Column { - Text(24, 3, Style(fg = Color.Red, bold = true))( - "🔴 Red — 红色" - ) - Text(24, 3, Style(fg = Color.Green, bold = true))( - "🟢 Green — 緑" - ) - Text(24, 3, Style(fg = Color.Blue, bold = true))( - "🔵 Blue — 青色" - ) + 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, Style(fg = Color.Yellow))( - "Column on the left\nhas three coloured\nrows stacked." - ) + 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.") } } diff --git a/ui/shared/src/main/scala/terminus/ui/component/Text.scala b/ui/shared/src/main/scala/terminus/ui/component/Text.scala index 5cf025a..2d6c84d 100644 --- a/ui/shared/src/main/scala/terminus/ui/component/Text.scala +++ b/ui/shared/src/main/scala/terminus/ui/component/Text.scala @@ -21,7 +21,7 @@ import terminus.ui.Component import terminus.ui.Rect import terminus.ui.RenderContext import terminus.ui.Size -import terminus.ui.style.Border +import terminus.ui.style.ComponentStyle import terminus.ui.style.Style import terminus.ui.tool.Box @@ -29,17 +29,22 @@ object Text: def component( width: Int, height: Int, - content: String, - style: Style = Style.default + 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, Border.single, style, buf) - buf.putString(bounds.x + 1, bounds.y + 1, content, style) + 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, style: Style = Style.default)( - content: String - )(using ctx: RenderContext): Unit = - ctx.add(component(width, height, content, style)) + 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/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/tool/Box.scala b/ui/shared/src/main/scala/terminus/ui/tool/Box.scala index c20ee77..57d88d5 100644 --- a/ui/shared/src/main/scala/terminus/ui/tool/Box.scala +++ b/ui/shared/src/main/scala/terminus/ui/tool/Box.scala @@ -19,40 +19,69 @@ package terminus.ui.tool import terminus.ui.Buffer import terminus.ui.Cell import terminus.ui.Rect -import terminus.ui.style.Border -import terminus.ui.style.Style +import terminus.ui.style.ComponentStyle object Box: - /** Draw a bordered box into the buffer at the given bounds. The border - * occupies the outermost cells, so the minimum usable width and height is 2. - * Out-of-bounds writes are silently clipped by the buffer. + /** 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 render(bounds: Rect, border: Border, style: Style, buf: Buffer): Unit = - val x0 = bounds.x - val y0 = bounds.y - val x1 = bounds.x + bounds.width - 1 // inclusive right edge - val y1 = bounds.y + bounds.height - 1 // inclusive bottom edge - - // Top row - buf.put(x0, y0, Cell(border.topLeft.toInt, style)) - var x = x0 + 1 - while x < x1 do - buf.put(x, y0, Cell(border.horizontal.toInt, style)) - x += 1 - buf.put(x1, y0, Cell(border.topRight.toInt, style)) - - // Sides - var y = y0 + 1 - while y < y1 do - buf.put(x0, y, Cell(border.vertical.toInt, style)) - buf.put(x1, y, Cell(border.vertical.toInt, style)) - y += 1 - - // Bottom row - buf.put(x0, y1, Cell(border.bottomLeft.toInt, style)) - x = x0 + 1 - while x < x1 do - buf.put(x, y1, Cell(border.horizontal.toInt, style)) - x += 1 - buf.put(x1, y1, Cell(border.bottomRight.toInt, style)) + 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)) + } From cdbdb99683c86fe690a4f7b802632cd2f6b423e8 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 15 Apr 2026 21:40:07 +0100 Subject: [PATCH 21/27] Contexts recalculate size on each call This allows size to change dynamically, which in turn allows components to react to user input. --- ui/shared/src/main/scala/terminus/ui/Column.scala | 11 +++++------ .../src/main/scala/terminus/ui/FullScreen.scala | 14 +++++++------- ui/shared/src/main/scala/terminus/ui/Row.scala | 11 +++++------ 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/ui/shared/src/main/scala/terminus/ui/Column.scala b/ui/shared/src/main/scala/terminus/ui/Column.scala index 5be9175..1fe37bb 100644 --- a/ui/shared/src/main/scala/terminus/ui/Column.scala +++ b/ui/shared/src/main/scala/terminus/ui/Column.scala @@ -22,19 +22,18 @@ class Column() extends ChildContext, Component: private val children: mutable.ArrayBuffer[Component] = mutable.ArrayBuffer.empty - private var childrenSize: Size = Size.zero - - def size: Size = childrenSize + def size: Size = + children.foldLeft(Size.zero)((acc, c) => acc.column(c.size)) def add(component: Component): Unit = - childrenSize = childrenSize.column(component.size) children += component def render(bounds: Rect, buf: Buffer): Unit = var y = bounds.y children.foreach { child => - child.render(Rect(bounds.x, y, child.size.width, child.size.height), buf) - y += child.size.height + val childSize = child.size + child.render(Rect(bounds.x, y, childSize.width, childSize.height), buf) + y += childSize.height } object Column: diff --git a/ui/shared/src/main/scala/terminus/ui/FullScreen.scala b/ui/shared/src/main/scala/terminus/ui/FullScreen.scala index 3c29e9c..d59113c 100644 --- a/ui/shared/src/main/scala/terminus/ui/FullScreen.scala +++ b/ui/shared/src/main/scala/terminus/ui/FullScreen.scala @@ -29,20 +29,20 @@ class FullScreen() extends RootContext: private val children: mutable.ArrayBuffer[Component] = mutable.ArrayBuffer.empty - private var childrenSize: Size = Size.zero - - def size: Size = childrenSize + def size: Size = + children.foldLeft(Size.zero)((acc, c) => acc.column(c.size)) def add(component: Component): Unit = - childrenSize = childrenSize.column(component.size) children += component def render(using Terminal): Unit = - val buf = Buffer(childrenSize.width, childrenSize.height) + val currentSize = size + val buf = Buffer(currentSize.width, currentSize.height) var y = 0 children.foreach { child => - child.render(Rect(0, y, child.size.width, child.size.height), buf) - y += child.size.height + val childSize = child.size + child.render(Rect(0, y, childSize.width, childSize.height), buf) + y += childSize.height } buf.render diff --git a/ui/shared/src/main/scala/terminus/ui/Row.scala b/ui/shared/src/main/scala/terminus/ui/Row.scala index a5b0415..cde5a08 100644 --- a/ui/shared/src/main/scala/terminus/ui/Row.scala +++ b/ui/shared/src/main/scala/terminus/ui/Row.scala @@ -22,19 +22,18 @@ class Row() extends ChildContext, Component: private val children: mutable.ArrayBuffer[Component] = mutable.ArrayBuffer.empty - private var childrenSize: Size = Size.zero - - def size: Size = childrenSize + def size: Size = + children.foldLeft(Size.zero)((acc, c) => acc.row(c.size)) def add(component: Component): Unit = - childrenSize = childrenSize.row(component.size) children += component def render(bounds: Rect, buf: Buffer): Unit = var x = bounds.x children.foreach { child => - child.render(Rect(x, bounds.y, child.size.width, child.size.height), buf) - x += child.size.width + val childSize = child.size + child.render(Rect(x, bounds.y, childSize.width, childSize.height), buf) + x += childSize.width } object Row: From 0529a7dea5a15260fe8a28a3edc8d798439db299 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Thu, 16 Apr 2026 05:19:09 +0100 Subject: [PATCH 22/27] Remove empty file --- .../test/scala/terminus/TermiosAccessSpec.scala | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 core/native/src/test/scala/terminus/TermiosAccessSpec.scala diff --git a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala b/core/native/src/test/scala/terminus/TermiosAccessSpec.scala deleted file mode 100644 index 3c21f4f..0000000 --- a/core/native/src/test/scala/terminus/TermiosAccessSpec.scala +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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. - */ - From 97d41678c860e247c96e0674227195e8de59a170 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Thu, 16 Apr 2026 05:35:37 +0100 Subject: [PATCH 23/27] Bump base version --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 7fc0647..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 From 5b981967164803ed11e90886483d7ae3269173ca Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Fri, 17 Apr 2026 15:53:45 +0100 Subject: [PATCH 24/27] Add reactive event loop and signal model Introduces the capability-passing reactive model for interactive UIs: - Signal[A] / ReadSignal[A] for reactive state; set() schedules a re-render - EventContext owns signals and key handlers for a screen's lifetime - ComponentContext stub for future subtree re-render dependency tracking - AppContext combines ComponentContext, EventContext, and RenderContext into a single capability; Row/Column thread it through via AppContext.child - LeafContent[A] and AppContent[A] type aliases for component signatures - Text content changed to by-name (=> String) so it re-evaluates each frame - FullScreen.run implements the event loop: build tree once, render on signal change, dispatch keys to handlers, exit on stop() or EOF - Fix NativeTerminal.flush() flushing stdin instead of stdout --- .../main/scala/terminus/NativeTerminal.scala | 2 +- notes/ui-architecture.md | 85 ++++++++++++++++++- .../src/main/scala/terminus/ui/Example.scala | 26 ++++++ .../main/scala/terminus/ui/AppContext.scala | 61 +++++++++++++ .../src/main/scala/terminus/ui/Column.scala | 7 +- .../scala/terminus/ui/ComponentContext.scala | 30 +++++++ .../main/scala/terminus/ui/EventContext.scala | 61 +++++++++++++ .../main/scala/terminus/ui/FullScreen.scala | 84 +++++++++++++++++- .../src/main/scala/terminus/ui/Row.scala | 7 +- .../src/main/scala/terminus/ui/Signal.scala | 43 ++++++++++ .../scala/terminus/ui/component/Text.scala | 4 +- 11 files changed, 393 insertions(+), 17 deletions(-) create mode 100644 ui/shared/src/main/scala/terminus/ui/AppContext.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/ComponentContext.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/EventContext.scala create mode 100644 ui/shared/src/main/scala/terminus/ui/Signal.scala diff --git a/core/native/src/main/scala/terminus/NativeTerminal.scala b/core/native/src/main/scala/terminus/NativeTerminal.scala index 3e108eb..33a507f 100644 --- a/core/native/src/main/scala/terminus/NativeTerminal.scala +++ b/core/native/src/main/scala/terminus/NativeTerminal.scala @@ -84,7 +84,7 @@ object NativeTerminal extends Terminal, WithEffect, TerminalKeyReader: } def flush(): Unit = - val _ = libc.stdio.fflush(libc.stdio.stdin) + val _ = libc.stdio.fflush(libc.stdio.stdout) () def write(char: Char): Unit = diff --git a/notes/ui-architecture.md b/notes/ui-architecture.md index 99b19a4..d183556 100644 --- a/notes/ui-architecture.md +++ b/notes/ui-architecture.md @@ -2,16 +2,80 @@ ## Overview -The `ui/` module builds a terminal UI toolkit on top of the core effect layer. 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 `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. -- **`Component`** — trait with `size: Size` and `render(bounds: Rect, buf: Buffer): Unit`. -- **`RenderContext`** — trait that components add children to. `RootContext` flushes to terminal; `ChildContext` renders into a buffer region. + +### 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(using ComponentContext): A // tracked: registers a dependency + def peek: A // untracked: for use in event handlers + +trait Signal[A] extends ReadSignal[A]: + def set(a: A): Unit + def update(f: A => A): Unit = set(f(peek)) +``` + +Signals are created and owned by an `EventContext`. Their lifetime matches the context's lifetime — useful for screen-level resource management. + +### Component content type aliases + +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): can read signals, +// register handlers, and create sub-components +type AppContent[A] = ComponentContext & EventContext & RenderContext ?=> A +``` + +### Component trait + +```scala +trait Component: + def size: Size + def render(bounds: Rect, buf: Buffer): Unit +``` ## Layout @@ -21,12 +85,21 @@ Two-phase: layout pass (size accumulation via `add`) then render pass (writing t - **`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. @@ -37,4 +110,8 @@ Components do not receive `Terminal`. Only `Buffer.render` / `Buffer.renderDiff` ## Deferred work -- `effect.Dimensions` on Scala Native (requires Posix `ioctl` — may be better to contribute upstream than implement in Terminus). +- **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. +- **Focus model**: event dispatch to the focused component is not yet implemented. Current thinking: global handlers intercept first, then focused component's handlers. +- **Derived signals**: `ReadSignal.map` is deferred. Manual handler functions updating signals are sufficient for now. +- **Resize handling**: terminal resize events are not yet handled. +- **`effect.Dimensions` on Scala Native**: requires Posix `ioctl` — may be better to contribute upstream than implement in Terminus. diff --git a/ui/native/src/main/scala/terminus/ui/Example.scala b/ui/native/src/main/scala/terminus/ui/Example.scala index 6f3cdc0..064288b 100644 --- a/ui/native/src/main/scala/terminus/ui/Example.scala +++ b/ui/native/src/main/scala/terminus/ui/Example.scala @@ -16,6 +16,7 @@ package terminus.ui +import terminus.Key import terminus.NativeTerminal import terminus.ui.component.Text import terminus.ui.style.Color @@ -92,3 +93,28 @@ import terminus.ui.style.Underline 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.escape) { ctx.stop() } + ctx.onKey(Key.controlC) { ctx.stop() } + + Column { + Text(40, 3)( + s"Count: ${count.get} (↑/↓ to change, Esc 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/Column.scala b/ui/shared/src/main/scala/terminus/ui/Column.scala index 1fe37bb..95d64a6 100644 --- a/ui/shared/src/main/scala/terminus/ui/Column.scala +++ b/ui/shared/src/main/scala/terminus/ui/Column.scala @@ -38,9 +38,10 @@ class Column() extends ChildContext, Component: object Column: def apply[A]( - f: RenderContext ?=> A - )(using parent: RenderContext): A = + f: AppContent[A] + )(using parent: AppContext): A = val column = new Column() - val result = f(using column) + given AppContext = AppContext.child(parent, column) + val result = f parent.add(column) result 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 index d59113c..278959d 100644 --- a/ui/shared/src/main/scala/terminus/ui/FullScreen.scala +++ b/ui/shared/src/main/scala/terminus/ui/FullScreen.scala @@ -19,6 +19,9 @@ 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 @@ -35,7 +38,7 @@ class FullScreen() extends RootContext: def add(component: Component): Unit = children += component - def render(using Terminal): Unit = + private[ui] def toBuffer(): Buffer = val currentSize = size val buf = Buffer(currentSize.width, currentSize.height) var y = 0 @@ -44,18 +47,91 @@ class FullScreen() extends RootContext: child.render(Rect(0, y, childSize.width, childSize.height), buf) y += childSize.height } - buf.render + 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: RenderContext ?=> A): FullScreen.Program[A] = + def apply[A](f: AppContent[A]): FullScreen.Program[A] = val fullScreen = new FullScreen() - val result = f(using 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.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/Row.scala b/ui/shared/src/main/scala/terminus/ui/Row.scala index cde5a08..040ebc6 100644 --- a/ui/shared/src/main/scala/terminus/ui/Row.scala +++ b/ui/shared/src/main/scala/terminus/ui/Row.scala @@ -38,9 +38,10 @@ class Row() extends ChildContext, Component: object Row: def apply[A]( - f: RenderContext ?=> A - )(using parent: RenderContext): A = + f: AppContent[A] + )(using parent: AppContext): A = val row = new Row() - val result = f(using 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/component/Text.scala b/ui/shared/src/main/scala/terminus/ui/component/Text.scala index 2d6c84d..7796258 100644 --- a/ui/shared/src/main/scala/terminus/ui/component/Text.scala +++ b/ui/shared/src/main/scala/terminus/ui/component/Text.scala @@ -29,7 +29,7 @@ object Text: def component( width: Int, height: Int, - text: String, + text: => String, box: ComponentStyle = ComponentStyle.default, content: Style = Style.default ): Component = @@ -46,5 +46,5 @@ object Text: height: Int, box: ComponentStyle = ComponentStyle.default, content: Style = Style.default - )(text: String)(using ctx: RenderContext): Unit = + )(text: => String)(using ctx: RenderContext): Unit = ctx.add(component(width, height, text, box, content)) From 93cace52350e6a533c994be01f934c2695dc2623 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Fri, 17 Apr 2026 16:09:28 +0100 Subject: [PATCH 25/27] Add effects to show and hide the cursor - Add Ansi codes to show and hide cursor - Wrap in effects and syntax - Use in FullScreen app to hide the cursor --- .../src/main/scala/terminus/Cursor.scala | 13 ++++++ .../scala/terminus/effect/AnsiCodes.scala | 6 +++ .../main/scala/terminus/effect/Cursor.scala | 17 +++++++ .../main/scala/terminus/ui/FullScreen.scala | 44 ++++++++++--------- 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/core/shared/src/main/scala/terminus/Cursor.scala b/core/shared/src/main/scala/terminus/Cursor.scala index 97bd275..6bea320 100644 --- a/core/shared/src/main/scala/terminus/Cursor.scala +++ b/core/shared/src/main/scala/terminus/Cursor.scala @@ -19,6 +19,19 @@ package terminus 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. */ diff --git a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala index 6ba82a7..5f00551 100644 --- a/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala +++ b/core/shared/src/main/scala/terminus/effect/AnsiCodes.scala @@ -60,6 +60,12 @@ object AnsiCodes: val save: String = s"${ESC}7" + /** Hide the cursor. */ + val hide: String = csi("?25l") + + /** Show the cursor. */ + val show: String = csi("?25h") + object style: val default = s"${ESC}0 q" diff --git a/core/shared/src/main/scala/terminus/effect/Cursor.scala b/core/shared/src/main/scala/terminus/effect/Cursor.scala index 865996b..8e90a1a 100644 --- a/core/shared/src/main/scala/terminus/effect/Cursor.scala +++ b/core/shared/src/main/scala/terminus/effect/Cursor.scala @@ -20,6 +20,23 @@ package terminus.effect 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. */ diff --git a/ui/shared/src/main/scala/terminus/ui/FullScreen.scala b/ui/shared/src/main/scala/terminus/ui/FullScreen.scala index 278959d..abe9f96 100644 --- a/ui/shared/src/main/scala/terminus/ui/FullScreen.scala +++ b/ui/shared/src/main/scala/terminus/ui/FullScreen.scala @@ -100,27 +100,29 @@ object FullScreen: f // build component tree and register handlers - 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() + 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. */ From 9890d2f6218ca448c1f923696b1a364c64f0869c Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Fri, 17 Apr 2026 16:13:58 +0100 Subject: [PATCH 26/27] Change quit key to q This prevents lag when waiting to disambiguate ESC from an escape code. --- ui/native/src/main/scala/terminus/ui/Example.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/native/src/main/scala/terminus/ui/Example.scala b/ui/native/src/main/scala/terminus/ui/Example.scala index 064288b..df39c19 100644 --- a/ui/native/src/main/scala/terminus/ui/Example.scala +++ b/ui/native/src/main/scala/terminus/ui/Example.scala @@ -102,12 +102,12 @@ import terminus.ui.style.Underline ctx.onKey(Key.up) { count.update(_ + 1) } ctx.onKey(Key.down) { count.update(_ - 1) } - ctx.onKey(Key.escape) { ctx.stop() } + ctx.onKey(Key('q')) { ctx.stop() } ctx.onKey(Key.controlC) { ctx.stop() } Column { Text(40, 3)( - s"Count: ${count.get} (↑/↓ to change, Esc to quit)" + s"Count: ${count.get} (↑/↓ to change, q to quit)" ) Text(40, 3)( if count.get > 0 then "Positive" From f25964d0bbae8ed2af0105ea5feac4f8c20ea12a Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Fri, 17 Apr 2026 19:11:35 +0100 Subject: [PATCH 27/27] Update architecture notes and fix example quit key Correct the signal API in the notes to match the current implementation, add AppContext documentation, and expand deferred work with focus model, dynamic layout, and more components as immediate priorities. Change the interactive demo quit key from Esc to q. Co-Authored-By: Claude Sonnet 4.6 --- notes/ui-architecture.md | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/notes/ui-architecture.md b/notes/ui-architecture.md index d183556..fad4b13 100644 --- a/notes/ui-architecture.md +++ b/notes/ui-architecture.md @@ -46,17 +46,20 @@ Rather than FRP's higher-order style (`signal.map(v => ...)`) the reactive conte ```scala trait ReadSignal[A]: - def get(using ComponentContext): A // tracked: registers a dependency - def peek: A // untracked: for use in event handlers + def get: A trait Signal[A] extends ReadSignal[A]: def set(a: A): Unit - def update(f: A => A): Unit = set(f(peek)) + 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. -### Component content type aliases +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: @@ -64,9 +67,8 @@ To avoid verbose context function types at every call site: // 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): can read signals, -// register handlers, and create sub-components -type AppContent[A] = ComponentContext & EventContext & RenderContext ?=> A +// Body of a layout component (e.g. Row, Column): full reactive + layout context +type AppContent[A] = AppContext ?=> A ``` ### Component trait @@ -110,8 +112,16 @@ The interactive event loop additionally requires `effect.KeyReader & effect.RawM ## Deferred work -- **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. -- **Focus model**: event dispatch to the focused component is not yet implemented. Current thinking: global handlers intercept first, then focused component's handlers. +- **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 are not yet handled. + +- **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.