├── crossterm ├── cargo │ ├── rust-toolchain.toml │ ├── src │ │ ├── lib.rs │ │ ├── unify_errors.rs │ │ ├── jvm_unwrapper.rs │ │ └── api.rs │ └── Cargo.toml └── src │ └── java │ └── tui │ └── crossterm │ ├── Xy.java │ ├── KeyEventKind.java │ ├── Attributes.java │ ├── Duration.java │ ├── MouseButton.java │ ├── CursorShape.java │ ├── MouseEvent.java │ ├── KeyModifiers.java │ ├── KeyEvent.java │ ├── ClearType.java │ ├── KeyEventState.java │ ├── MediaKeyCode.java │ ├── ModifierKeyCode.java │ ├── Event.java │ ├── CrosstermJni.java │ ├── MouseEventKind.java │ ├── KeyboardEnhancementFlags.java │ ├── Attribute.java │ ├── NativeLoader.java │ ├── Color.java │ └── KeyCode.java ├── .gitignore ├── tui └── src │ └── scala │ └── tui │ ├── Point.scala │ ├── widgets │ ├── canvas │ │ ├── Layer.scala │ │ ├── Shape.scala │ │ ├── MapResolution.scala │ │ ├── Label.scala │ │ ├── Grid.scala │ │ ├── Points.scala │ │ ├── Rectangle.scala │ │ ├── Painter.scala │ │ ├── CharGrid.scala │ │ ├── BrailleGrid.scala │ │ ├── Context.scala │ │ ├── Line.scala │ │ └── CanvasWidget.scala │ ├── ClearWidget.scala │ ├── LineGaugeWidget.scala │ ├── SparklineWidget.scala │ ├── TabsWidget.scala │ ├── GaugeWidget.scala │ ├── ParagraphWidget.scala │ ├── BarChartWidget.scala │ ├── ListWidget.scala │ └── BlockWidget.scala │ ├── Margin.scala │ ├── StyledGrapheme.scala │ ├── Direction.scala │ ├── ResizeBehavior.scala │ ├── Alignment.scala │ ├── TerminalOptions.scala │ ├── Corner.scala │ ├── Grapheme.scala │ ├── Widget.scala │ ├── Viewport.scala │ ├── Backend.scala │ ├── CompletedFrame.scala │ ├── internal │ ├── debug_assert.scala │ ├── stepBy.scala │ ├── ranges.scala │ ├── saturating.scala │ ├── UnicodeSegmentation.scala │ └── breakableForeach.scala │ ├── Spans.scala │ ├── withTerminal.scala │ ├── Constraint.scala │ ├── Color.scala │ ├── StatefulWidget.scala │ ├── Span.scala │ ├── Cell.scala │ ├── Frame.scala │ ├── Rect.scala │ ├── Style.scala │ ├── Text.scala │ ├── Borders.scala │ ├── Modifier.scala │ ├── Terminal.scala │ └── CrosstermBackend.scala ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── .scalafmt.conf ├── cassowary └── src │ └── scala │ └── tui │ └── cassowary │ ├── cassowary.scala │ └── operators.scala ├── tests └── src │ └── scala │ └── tui │ ├── cassowary │ ├── Values.scala │ ├── RemovalTest.scala │ └── QuadrilateralTest.scala │ ├── widgets │ ├── SparklineTests.scala │ ├── CanvasTests.scala │ ├── BarchartTests.scala │ ├── BlockTests.scala │ └── ParagraphTests.scala │ ├── TestBackend.scala │ ├── bufferView.scala │ ├── StylesTests.scala │ └── TuiTest.scala ├── scripts └── src │ └── scala │ └── tui │ └── scripts │ ├── GenHeaders.scala │ ├── package.scala │ ├── PublishLocal.scala │ ├── GenNativeImage.scala │ ├── GenJniLibrary.scala │ └── Publish.scala ├── contributing.md ├── demo └── src │ └── scala │ └── tuiexamples │ ├── CustomWidgetExample.scala │ ├── LayoutExample.scala │ ├── Launcher.scala │ ├── demo │ └── Demo.scala │ ├── PopupExample.scala │ ├── TabsExample.scala │ ├── BlockExample.scala │ ├── BarChartExample.scala │ ├── TableExample.scala │ ├── GaugeExample.scala │ ├── SparklineExample.scala │ ├── CanvasExample.scala │ ├── ParagraphExample.scala │ └── UserInputExample.scala ├── LICENSE ├── bleep.yaml └── readme.md /crossterm/cargo/rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .idea 3 | .DS_Store 4 | *.metals 5 | .bsp 6 | .bleep 7 | .bloop 8 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/Xy.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public record Xy(int x, int y) { 4 | } 5 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Point.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | case class Point(x: Double, y: Double) 4 | 5 | object Point { 6 | val Zero: Point = Point(0.0, 0.0) 7 | } 8 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/KeyEventKind.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public enum KeyEventKind { 4 | Press, 5 | Repeat, 6 | Release, 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version=3.5.9 2 | maxColumn = 160 3 | rewrite.rules = [SortImports, RedundantBraces, RedundantParens, PreferCurlyFors] 4 | runner.dialect = scala213 5 | 6 | -------------------------------------------------------------------------------- /crossterm/cargo/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate core; 2 | 3 | pub mod api; 4 | pub mod jni_from_jvm; 5 | pub mod jni_to_jvm; 6 | pub mod jvm_unwrapper; 7 | pub mod unify_errors; -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/Attributes.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | import java.util.List; 4 | 5 | public record Attributes(List attributes) { 6 | } 7 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Layer.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | import tui.Color 4 | 5 | case class Layer( 6 | string: String, 7 | colors: Array[Color] 8 | ) 9 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Margin.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | case class Margin(vertical: Int, horizontal: Int) 4 | object Margin { 5 | def apply(value: Int): Margin = Margin(value, value) 6 | } 7 | -------------------------------------------------------------------------------- /tui/src/scala/tui/StyledGrapheme.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** A grapheme associated to a style. 4 | */ 5 | case class StyledGrapheme( 6 | symbol: Grapheme, 7 | style: Style 8 | ) 9 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Direction.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | sealed trait Direction 4 | 5 | object Direction { 6 | case object Horizontal extends Direction 7 | case object Vertical extends Direction 8 | } 9 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/Duration.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public record Duration ( 4 | long secs, 5 | // Always 0 <= nanos < NANOS_PER_SEC 6 | int nanos 7 | ) {} 8 | 9 | -------------------------------------------------------------------------------- /tui/src/scala/tui/ResizeBehavior.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | sealed trait ResizeBehavior 4 | object ResizeBehavior { 5 | case object Fixed extends ResizeBehavior 6 | case object Auto extends ResizeBehavior 7 | } 8 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Shape.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | /** Interface for all shapes that may be drawn on a Canvas widget. 4 | */ 5 | trait Shape { 6 | def draw(painter: Painter): Unit 7 | } 8 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Alignment.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | sealed trait Alignment 4 | object Alignment { 5 | case object Left extends Alignment 6 | case object Center extends Alignment 7 | case object Right extends Alignment 8 | } 9 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/MapResolution.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | sealed trait MapResolution 4 | object MapResolution { 5 | case object Low extends MapResolution 6 | case object High extends MapResolution 7 | } 8 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/MouseButton.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public enum MouseButton { 4 | /// Left mouse button. 5 | Left, 6 | /// Right mouse button. 7 | Right, 8 | /// Middle mouse button. 9 | Middle 10 | } 11 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Label.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | import tui.Spans 4 | 5 | /** Label to draw some text on the canvas 6 | */ 7 | case class Label( 8 | x: Double, 9 | y: Double, 10 | spans: Spans 11 | ) 12 | -------------------------------------------------------------------------------- /tui/src/scala/tui/TerminalOptions.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** Options to pass to `Terminal.with_options` 4 | * @param viewport 5 | * Viewport used to draw to the terminal 6 | */ 7 | case class TerminalOptions( 8 | viewport: Viewport 9 | ) 10 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/CursorShape.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | /// All supported cursor shapes 4 | /// 5 | /// # Note 6 | /// 7 | /// - Used with SetCursorShape 8 | public enum CursorShape { 9 | UnderScore, 10 | Line, 11 | Block 12 | } 13 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Corner.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | sealed trait Corner 4 | object Corner { 5 | case object TopLeft extends Corner 6 | case object TopRight extends Corner 7 | case object BottomRight extends Corner 8 | case object BottomLeft extends Corner 9 | } 10 | -------------------------------------------------------------------------------- /crossterm/cargo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "crossterm" 3 | version = "0.1.0" 4 | authors = ["Øyvind Raddum Berg "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | jni = "0.20.0" 9 | crossterm = "0.25" 10 | 11 | [lib] 12 | crate_type = ["cdylib"] 13 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Grapheme.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import tui.internal.Wcwidth 4 | 5 | case class Grapheme(str: String) { 6 | lazy val width: Int = math.max(0, str.codePoints().map(Wcwidth.of).sum()) 7 | } 8 | 9 | object Grapheme { 10 | val Empty: Grapheme = Grapheme(" ") 11 | } 12 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Widget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** Base requirements for a Widget 4 | */ 5 | trait Widget { 6 | 7 | /** Draws the current state of the widget in the given buffer. That is the only method required to implement a custom widget. 8 | */ 9 | def render(area: Rect, buf: Buffer): Unit 10 | } 11 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Viewport.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | case class Viewport( 4 | var area: Rect, 5 | resizeBehavior: ResizeBehavior 6 | ) 7 | 8 | object Viewport { 9 | // UNSTABLE 10 | def fixed(area: Rect): Viewport = 11 | Viewport( 12 | area, 13 | resizeBehavior = ResizeBehavior.Fixed 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Backend.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | trait Backend { 4 | def draw(content: Array[(Int, Int, Cell)]): Unit 5 | def hideCursor(): Unit 6 | def showCursor(): Unit 7 | def getCursor(): (Int, Int) 8 | def setCursor(x: Int, y: Int): Unit 9 | def clear(): Unit 10 | def size(): Rect 11 | def flush(): Unit 12 | } 13 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Grid.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | import tui.{Color, Point} 4 | 5 | trait Grid { 6 | def width: Int 7 | 8 | def height: Int 9 | 10 | def resolution: Point 11 | 12 | def paint(x: Int, y: Int, color: Color): Unit 13 | 14 | def save(): Layer 15 | 16 | def reset(): Unit 17 | } 18 | -------------------------------------------------------------------------------- /cassowary/src/scala/tui/cassowary/cassowary.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | package object cassowary { 4 | implicit class Unwrapper[L, R](private val e: Either[L, R]) extends AnyVal { 5 | def unwrap(): R = 6 | e match { 7 | case Left(e) => sys.error(s"failure: $e") 8 | case Right(t) => t 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tui/src/scala/tui/CompletedFrame.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** CompletedFrame represents the state of the terminal after all changes performed in the last `Terminal.draw` call have been applied. Therefore, it is only 4 | * valid until the next call to `Terminal.draw`. 5 | */ 6 | case class CompletedFrame( 7 | buffer: Buffer, 8 | area: Rect 9 | ) 10 | -------------------------------------------------------------------------------- /tui/src/scala/tui/internal/debug_assert.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package internal 3 | 4 | object debug_assert { 5 | def apply(pred: Boolean, msg: String, details: Any*): Unit = 6 | if (pred) () 7 | else { 8 | // todo: template in at correct place 9 | val formattedDetails = if (details.isEmpty) "" else details.mkString(" (", ", ", ")") 10 | sys.error(s"assertion failed: $msg$formattedDetails") 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/MouseEvent.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public record MouseEvent( 4 | /// The kind of mouse event that was caused. 5 | MouseEventKind kind, 6 | /// The column that the event occurred on. 7 | int column, 8 | /// The row that the event occurred on. 9 | int row, 10 | /// The key modifiers active when the event occurred. 11 | KeyModifiers modifiers 12 | ) { 13 | } 14 | 15 | -------------------------------------------------------------------------------- /tests/src/scala/tui/cassowary/Values.scala: -------------------------------------------------------------------------------- 1 | package tui.cassowary 2 | 3 | import scala.collection.mutable 4 | 5 | case class Values(values: mutable.Map[Variable, Double] = mutable.Map.empty) { 6 | def value_of(v: Variable): Double = values.getOrElse(v, 0.0) 7 | 8 | def update_values(changes: Iterable[(Variable, Double)]): Unit = 9 | changes.foreach { case (v, value) => 10 | println(s"$v changed to $value") 11 | values(v) = value 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/KeyModifiers.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public record KeyModifiers(int bits) { 4 | public static final int SHIFT = 0b0000_0001; 5 | public static final int CONTROL = 0b0000_0010; 6 | public static final int ALT = 0b0000_0100; 7 | public static final int SUPER = 0b0000_1000; 8 | public static final int HYPER = 0b0001_0000; 9 | public static final int META = 0b0010_0000; 10 | public static final int NONE = 0b0000_0000; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /tui/src/scala/tui/internal/stepBy.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package internal 3 | 4 | import scala.reflect.ClassTag 5 | 6 | object stepBy { 7 | implicit class StepBySyntax[T](private val ts: Array[T]) extends AnyVal { 8 | def stepBy(n: Int)(implicit CT: ClassTag[T]): Array[T] = { 9 | require(n > 0) 10 | val b = Array.newBuilder[T] 11 | var i = 0 12 | while (i < ts.length) { 13 | b += ts(i) 14 | i += n 15 | } 16 | b.result() 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tui/src/scala/tui/internal/ranges.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package internal 3 | 4 | object ranges { 5 | @inline def revRange(fromInclusive: Int, toExclusive: Int)(f: Int => Unit): Unit = { 6 | var idx = toExclusive - 1 7 | while (idx >= fromInclusive) { 8 | f(idx) 9 | idx -= 1 10 | } 11 | } 12 | 13 | @inline def range(fromInclusive: Int, toExclusive: Int)(f: Int => Unit): Unit = { 14 | var idx = fromInclusive 15 | while (idx < toExclusive) { 16 | f(idx) 17 | idx += 1 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/KeyEvent.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public record KeyEvent( 4 | /// The key itself. 5 | KeyCode code, 6 | /// Additional key modifiers. 7 | KeyModifiers modifiers, 8 | /// Kind of event. 9 | KeyEventKind kind, 10 | /// Keyboard state. 11 | /// 12 | /// Only set if [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 13 | /// [`PushKeyboardEnhancementFlags`]. 14 | KeyEventState state 15 | ) { 16 | } 17 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/ClearType.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | /// Different ways to clear the terminal buffer. 4 | public enum ClearType { 5 | /// All cells. 6 | All, 7 | /// All plus history 8 | Purge, 9 | /// All cells from the cursor position downwards. 10 | FromCursorDown, 11 | /// All cells from the cursor position upwards. 12 | FromCursorUp, 13 | /// All cells at the cursor row. 14 | CurrentLine, 15 | /// All cells from the cursor position until the new line. 16 | UntilNewLine 17 | } 18 | -------------------------------------------------------------------------------- /scripts/src/scala/tui/scripts/GenHeaders.scala: -------------------------------------------------------------------------------- 1 | package tui.scripts 2 | 3 | import bleep._ 4 | import bleep.plugin.jni.JniJavah 5 | 6 | object GenHeaders extends BleepScript("GenHeaders") { 7 | override def run(started: Started, commands: Commands, args: List[String]): Unit = { 8 | commands.compile(List(crosstermProject)) 9 | 10 | val javah = new JniJavah(started.logger, started.projectPaths(crosstermProject), started.bloopProject(crosstermProject)) 11 | val path = javah.javah() 12 | started.logger.withContext(path).warn("Generated") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Spans.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /// A string composed of clusters of graphemes, each with their own style. 4 | case class Spans(spans: Array[Span]) { 5 | /// Returns the width of the underlying string. 6 | def width: Int = 7 | spans.map(_.width).sum 8 | 9 | override def toString: String = spans.mkString("") 10 | } 11 | 12 | object Spans { 13 | def nostyle(s: String): Spans = from(Span.nostyle(s)) 14 | def styled(s: String, style: Style): Spans = from(Span.styled(s, style)) 15 | def from(spans: Span*): Spans = Spans(spans.toArray) 16 | } 17 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Points.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | package canvas 4 | 5 | /** A shape to draw a group of points with the given color 6 | */ 7 | case class Points( 8 | coords: Array[Point] = Array.empty, 9 | color: Color = Color.Reset 10 | ) extends Shape { 11 | override def draw(painter: Painter): Unit = 12 | this.coords.foreach { case Point(x, y) => 13 | painter.getPoint(x, y) match { 14 | case None => () 15 | case Some((x, y)) => 16 | painter.paint(x, y, this.color) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tui/src/scala/tui/internal/saturating.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package internal 3 | 4 | object saturating { 5 | implicit class IntOps(private val i1: Int) extends AnyVal { 6 | def saturating_add(i2: Int): Int = { 7 | val res = i1 + i2 8 | if (res < i2) Int.MaxValue else res 9 | } 10 | 11 | def saturating_sub_signed(i2: Int): Int = { 12 | val res = i1 - i2 13 | if (res > i2) Int.MinValue else res 14 | } 15 | 16 | def saturating_sub_unsigned(i2: Int): Int = { 17 | val res = i1 - i2 18 | math.max(0, res) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/src/scala/tui/widgets/SparklineTests.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | class SparklineTests extends TuiTest { 5 | test("it_does_not_panic_if_max_is_zero") { 6 | val widget = SparklineWidget(data = Array(0, 0, 0)) 7 | val area = Rect(0, 0, 3, 1) 8 | val buffer = Buffer.empty(area) 9 | widget.render(area, buffer) 10 | } 11 | 12 | test("it_does_not_panic_if_max_is_set_to_zero") { 13 | val widget = SparklineWidget(data = Array(0, 1, 2), max = Some(0)) 14 | val area = Rect(0, 0, 3, 1) 15 | val buffer = Buffer.empty(area) 16 | widget.render(area, buffer) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/ClearWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | import tui.internal.ranges 5 | 6 | /** A widget to clear/reset a certain area to allow overdrawing (e.g. for popups). 7 | * 8 | * This widget *cannot be used to clear the terminal on the first render* as `tui` assumes the render area is empty. Use `Terminal.clear` instead. 9 | */ 10 | case object ClearWidget extends Widget { 11 | override def render(area: Rect, buf: Buffer): Unit = 12 | ranges.range(area.left, area.right) { x => 13 | ranges.range(area.top, area.bottom) { y => 14 | buf.get(x, y).reset() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/KeyEventState.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public record KeyEventState(int bits) { 4 | /// The key event origins from the keypad. 5 | public static final int KEYPAD = 0b0000_0001; 6 | /// Caps Lock was enabled for this key event. 7 | /// 8 | /// **Note:** this is set for the initial press of Caps Lock itself. 9 | public static final int CAPS_LOCK = 0b0000_1000; 10 | /// Num Lock was enabled for this key event. 11 | /// 12 | /// **Note:** this is set for the initial press of Num Lock itself. 13 | public static final int NUM_LOCK = 0b0000_1000; 14 | public static final int NONE = 0b0000_0000; 15 | } 16 | -------------------------------------------------------------------------------- /scripts/src/scala/tui/scripts/package.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import bleep.model 4 | import bleep.model.CrossProjectName 5 | 6 | package object scripts { 7 | val crosstermProject: model.CrossProjectName = 8 | model.CrossProjectName(model.ProjectName("crossterm"), None) 9 | val demoProject: CrossProjectName = 10 | model.CrossProjectName(model.ProjectName("demo"), crossId = Some(model.CrossId("jvm213"))) 11 | 12 | // will publish these with dependencies 13 | def projectsToPublish(crossName: model.CrossProjectName): Boolean = 14 | crossName.name.value match { 15 | case "tui" => true 16 | case _ => false 17 | } 18 | 19 | val groupId = "com.olvind.tui" 20 | } 21 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | 2 | ## Building 3 | 4 | `tui-scala` helps dog-food the experimental [bleep](https://bleep.build/docs/) Scala build tool while it is approaching first public release. Keep an open mind! 5 | 6 | - `git clone https://github.com/oyvindberg/tui-scala` 7 | - [install bleep](https://bleep.build/docs/installing/) 8 | - (if you use bash, run `bleep install-tab-completions-bash` and start a new shell to get tab completions) 9 | - `git submodule init && git submodule update` 10 | - `bleep setup-ide jvm213` to enable IDE import (metals or intellij) 11 | - open in your IDE 12 | - `bleep run demo@jvm213 ` to run demos 13 | - `bleep gen-native-image` if you want to see how fast things get with native image compilation 14 | -------------------------------------------------------------------------------- /tui/src/scala/tui/withTerminal.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import tui.crossterm.{Command, CrosstermJni} 4 | 5 | object withTerminal { 6 | def apply[T](f: (CrosstermJni, Terminal) => T): T = { 7 | val jni = new CrosstermJni 8 | // setup terminal 9 | jni.enableRawMode() 10 | jni.execute(new Command.EnterAlternateScreen(), new Command.EnableMouseCapture()) 11 | 12 | val backend = new CrosstermBackend(jni) 13 | 14 | val terminal = Terminal.init(backend) 15 | 16 | try f(jni, terminal) 17 | finally { 18 | // restore terminal 19 | jni.disableRawMode() 20 | jni.execute(new Command.LeaveAlternateScreen(), new Command.DisableMouseCapture()) 21 | backend.showCursor() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tui/src/scala/tui/internal/UnicodeSegmentation.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package internal 3 | 4 | import java.text.BreakIterator 5 | import java.util.Locale 6 | 7 | object UnicodeSegmentation { 8 | 9 | def graphemes(str: String, isExtended: Boolean, locale: Locale = Locale.getDefault): Array[tui.Grapheme] = { 10 | val b = Array.newBuilder[tui.Grapheme] 11 | val boundary = BreakIterator.getCharacterInstance(locale) 12 | boundary.setText(str) 13 | 14 | var start = boundary.first 15 | var end = boundary.next 16 | while (end != BreakIterator.DONE) { 17 | val chunk = str.substring(start, end) 18 | b += Grapheme(chunk) 19 | start = end 20 | end = boundary.next 21 | } 22 | b.result() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/MediaKeyCode.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public enum MediaKeyCode { 4 | /// Play media key. 5 | Play, 6 | /// Pause media key. 7 | Pause, 8 | /// Play/Pause media key. 9 | PlayPause, 10 | /// Reverse media key. 11 | Reverse, 12 | /// Stop media key. 13 | Stop, 14 | /// Fast-forward media key. 15 | FastForward, 16 | /// Rewind media key. 17 | Rewind, 18 | /// Next-track media key. 19 | TrackNext, 20 | /// Previous-track media key. 21 | TrackPrevious, 22 | /// Record media key. 23 | Record, 24 | /// Lower-volume media key. 25 | LowerVolume, 26 | /// Raise-volume media key. 27 | RaiseVolume, 28 | /// Mute media key. 29 | MuteVolume, 30 | } -------------------------------------------------------------------------------- /scripts/src/scala/tui/scripts/PublishLocal.scala: -------------------------------------------------------------------------------- 1 | package tui.scripts 2 | 3 | import bleep._ 4 | import bleep.plugin.dynver.DynVerPlugin 5 | 6 | object PublishLocal extends BleepScript("PublishLocal") { 7 | def run(started: Started, commands: Commands, args: List[String]): Unit = { 8 | val dynVer = new DynVerPlugin(baseDirectory = started.buildPaths.buildDir.toFile, dynverSonatypeSnapshots = true) 9 | val projects = started.build.explodedProjects.keys.toArray.filter(projectsToPublish) 10 | 11 | commands.publishLocal( 12 | bleep.commands.PublishLocal.Options( 13 | groupId = groupId, 14 | version = dynVer.version, 15 | publishTarget = bleep.commands.PublishLocal.LocalIvy, 16 | projects = projects 17 | ) 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Constraint.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | sealed trait Constraint { 4 | def apply(length: Int): Int = 5 | this match { 6 | case Constraint.Percentage(p) => length * p / 100 7 | case Constraint.Ratio(num, den) => num * length / den 8 | case Constraint.Length(l) => length.min(l) 9 | case Constraint.Max(m) => length.min(m) 10 | case Constraint.Min(m) => length.max(m) 11 | } 12 | } 13 | 14 | object Constraint { 15 | case class Percentage(p: Int) extends Constraint { 16 | require(p >= 0 && p <= 100) 17 | } 18 | case class Ratio(num: Int, den: Int) extends Constraint 19 | case class Length(l: Int) extends Constraint 20 | case class Max(m: Int) extends Constraint 21 | case class Min(m: Int) extends Constraint 22 | } 23 | -------------------------------------------------------------------------------- /tests/src/scala/tui/cassowary/RemovalTest.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package cassowary 3 | 4 | import tui.cassowary.WeightedRelation._ 5 | import tui.cassowary.operators._ 6 | 7 | class RemovalTest extends TuiTest { 8 | ignore("remove_constraint") { 9 | val values = Values() 10 | 11 | val solver = Solver() 12 | 13 | val v = Variable() 14 | 15 | val constraint: Constraint = v | EQ(Strength.REQUIRED) | 100.0 16 | solver.add_constraint(constraint).unwrap() 17 | values.update_values(solver.fetch_changes()) 18 | 19 | assertEq(values.value_of(v), 100.0) 20 | 21 | solver.remove_constraint(constraint).unwrap() 22 | solver.add_constraint(v | EQ(Strength.REQUIRED) | 0.0).unwrap() 23 | values.update_values(solver.fetch_changes()) 24 | 25 | assertEq(values.value_of(v), 0.0) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Rectangle.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | package canvas 4 | 5 | /** Shape to draw a rectangle from a `Rect` with the given color 6 | */ 7 | case class Rectangle( 8 | x: Double, 9 | y: Double, 10 | width: Double, 11 | height: Double, 12 | color: Color 13 | ) extends Shape { 14 | override def draw(painter: Painter): Unit = { 15 | val lines = Array( 16 | Line(x1 = x, y1 = y, x2 = x, y2 = y + height, color = color), 17 | Line(x1 = x, y1 = y + height, x2 = x + width, y2 = y + height, color = color), 18 | Line(x1 = x + width, y1 = y, x2 = x + width, y2 = y + height, color = color), 19 | Line(x1 = x, y1 = y, x2 = x + width, y2 = y, color = color) 20 | ) 21 | lines.foreach { line => 22 | line.draw(painter) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/ModifierKeyCode.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | /// Represents a modifier key (as part of [`KeyCode::Modifier`]). 4 | public enum ModifierKeyCode { 5 | /// Left Shift key. 6 | LeftShift, 7 | /// Left Control key. 8 | LeftControl, 9 | /// Left Alt key. 10 | LeftAlt, 11 | /// Left Super key. 12 | LeftSuper, 13 | /// Left Hyper key. 14 | LeftHyper, 15 | /// Left Meta key. 16 | LeftMeta, 17 | /// Right Shift key. 18 | RightShift, 19 | /// Right Control key. 20 | RightControl, 21 | /// Right Alt key. 22 | RightAlt, 23 | /// Right Super key. 24 | RightSuper, 25 | /// Right Hyper key. 26 | RightHyper, 27 | /// Right Meta key. 28 | RightMeta, 29 | /// Iso Level3 Shift key. 30 | IsoLevel3Shift, 31 | /// Iso Level5 Shift key. 32 | IsoLevel5Shift, 33 | } -------------------------------------------------------------------------------- /crossterm/cargo/src/unify_errors.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use jni::errors::{Error as JniError}; 3 | use jni::sys::jint; 4 | 5 | pub enum UnifiedError { 6 | Jni(JniError), 7 | Io(io::Error), 8 | NotU16(jint), 9 | } 10 | 11 | pub type UnifiedResult = Result; 12 | 13 | pub trait UnifyErrors { 14 | fn unify_errors(self) -> Result; 15 | } 16 | 17 | impl UnifyErrors for Result { 18 | fn unify_errors(self) -> Result { 19 | match self { 20 | Ok(t) => Ok(t), 21 | Err(err) => Err(UnifiedError::Io(err)) 22 | } 23 | } 24 | } 25 | 26 | impl UnifyErrors for Result { 27 | fn unify_errors(self) -> Result { 28 | match self { 29 | Ok(t) => Ok(t), 30 | Err(jni_error) => Err(UnifiedError::Jni(jni_error)) 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /tui/src/scala/tui/Color.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | sealed trait Color 4 | 5 | object Color { 6 | case object Reset extends Color 7 | 8 | case object Black extends Color 9 | 10 | case object Red extends Color 11 | 12 | case object Green extends Color 13 | 14 | case object Yellow extends Color 15 | 16 | case object Blue extends Color 17 | 18 | case object Magenta extends Color 19 | 20 | case object Cyan extends Color 21 | 22 | case object Gray extends Color 23 | 24 | case object DarkGray extends Color 25 | 26 | case object LightRed extends Color 27 | 28 | case object LightGreen extends Color 29 | 30 | case object LightYellow extends Color 31 | 32 | case object LightBlue extends Color 33 | 34 | case object LightMagenta extends Color 35 | 36 | case object LightCyan extends Color 37 | 38 | case object White extends Color 39 | 40 | case class Rgb(r: Byte, g: Byte, b: Byte) extends Color 41 | 42 | case class Indexed(byte: Byte) extends Color 43 | } 44 | -------------------------------------------------------------------------------- /tests/src/scala/tui/TestBackend.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /// A backend used for the integration tests. 4 | case class TestBackend( 5 | var width: Int, 6 | var height: Int, 7 | var cursor: Boolean = false, 8 | var pos: (Int, Int) = (0, 0) 9 | ) extends Backend { 10 | val buffer: Buffer = Buffer.empty(Rect(0, 0, width, height)) 11 | 12 | def resize(width: Int, height: Int): Unit = { 13 | buffer.resize(Rect(0, 0, width, height)) 14 | this.width = width 15 | this.height = height 16 | } 17 | 18 | def draw(content: Array[(Int, Int, Cell)]): Unit = 19 | content.foreach { case (x, y, c) => buffer.set(x, y, c) } 20 | 21 | def hideCursor(): Unit = 22 | this.cursor = false 23 | 24 | def showCursor(): Unit = 25 | this.cursor = true 26 | 27 | def getCursor(): (Int, Int) = 28 | pos 29 | 30 | def setCursor(x: Int, y: Int): Unit = 31 | pos = (x, y) 32 | 33 | def clear(): Unit = 34 | buffer.reset() 35 | 36 | def size(): Rect = 37 | Rect(0, 0, width, height) 38 | 39 | def flush(): Unit = 40 | () 41 | } 42 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/Event.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public sealed interface Event permits Event.FocusGained, Event.FocusLost, Event.Key, Event.Mouse, Event.Paste, Event.Resize { 4 | /// The terminal gained focus 5 | record FocusGained() implements Event { 6 | } 7 | 8 | /// The terminal lost focus 9 | record FocusLost() implements Event { 10 | } 11 | 12 | /// A single key event with additional pressed modifiers. 13 | record Key(KeyEvent keyEvent) implements Event { 14 | } 15 | 16 | /// A single mouse event with additional pressed modifiers. 17 | record Mouse(MouseEvent mouseEvent) implements Event { 18 | } 19 | 20 | /// A string that was pasted into the terminal. Only emitted if bracketed paste has been 21 | /// enabled. 22 | record Paste(String string) implements Event { 23 | } 24 | 25 | /// An resize event with new dimensions after resize (columns, rows). 26 | /// **Note** that resize events can occur in batches. 27 | record Resize(int columns, int rows) implements Event { 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/src/scala/tui/widgets/CanvasTests.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | import tui.internal.ranges 5 | import tui.widgets.canvas.CanvasWidget 6 | 7 | class CanvasTests extends TuiTest { 8 | test("widgets_canvas_draw_labels") { 9 | val backend = TestBackend(5, 5) 10 | val terminal = Terminal.init(backend) 11 | terminal.draw { f => 12 | val label = "test" 13 | val canvas = CanvasWidget(backgroundColor = Color.Yellow, xBounds = Point(0.0, 5.0), yBounds = Point(0.0, 5.0)) { ctx => 14 | ctx.print(0.0, 0.0, Spans.from(Span.styled(label, Style(fg = Some(Color.Blue))))) 15 | } 16 | f.renderWidget(canvas, f.size); 17 | } 18 | 19 | val expected = Buffer.withLines(" ", " ", " ", " ", "test ") 20 | ranges.range(0, 5) { row => 21 | ranges.range(0, 5) { col => 22 | expected.get(col, row).setBg(Color.Yellow) 23 | () 24 | } 25 | } 26 | ranges.range(0, 4) { col => 27 | expected.get(col, 4).setFg(Color.Blue) 28 | () 29 | } 30 | assertBuffer(backend, expected) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tui/src/scala/tui/internal/breakableForeach.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package internal 3 | 4 | object breakableForeach { 5 | sealed trait Res 6 | case object Continue extends Res 7 | case object Break extends Res 8 | 9 | implicit class BreakableForeachIterator[T](private val it: Iterator[T]) extends AnyVal { 10 | @inline 11 | def breakableForeach(f: T => Res): Unit = { 12 | var continue = true 13 | while (it.hasNext && continue) 14 | f(it.next()) match { 15 | case Continue => 16 | () 17 | case Break => 18 | continue = false 19 | } 20 | } 21 | } 22 | implicit class BreakableForeachArray[T](private val ts: Array[T]) extends AnyVal { 23 | @inline 24 | def breakableForeach(f: (T, Int) => Res): Unit = { 25 | var continue = true 26 | var i = 0 27 | while (i < ts.length && continue) { 28 | f(ts(i), i) match { 29 | case Continue => 30 | () 31 | case Break => 32 | continue = false 33 | } 34 | i += 1 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/CustomWidgetExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.crossterm.CrosstermJni 5 | 6 | object CustomWidgetExample { 7 | case class Label(text: String) extends Widget { 8 | override def render(area: Rect, buf: Buffer): Unit = { 9 | buf.setString(area.left, area.top, text, Style.DEFAULT) 10 | () 11 | } 12 | } 13 | 14 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 15 | run_app(terminal, jni) 16 | } 17 | 18 | def run_app(terminal: Terminal, jni: CrosstermJni): Unit = 19 | while (true) { 20 | terminal.draw(f => ui(f)) 21 | jni.read() match { 22 | case key: tui.crossterm.Event.Key => 23 | key.keyEvent.code match { 24 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 25 | case _ => () 26 | } 27 | case _ => () 28 | } 29 | } 30 | 31 | def ui(f: Frame): Unit = { 32 | val size = f.size 33 | val label = Label(text = "Test") 34 | f.renderWidget(label, size) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Øyvind Raddum Berg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/CrosstermJni.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | import java.util.Arrays; 4 | import java.util.List; 5 | 6 | public class CrosstermJni { 7 | static { 8 | try { 9 | NativeLoader.load("crossterm"); 10 | } catch (RuntimeException e) { 11 | throw e; 12 | } catch (Exception e) { 13 | throw new RuntimeException(e); 14 | } 15 | } 16 | public native void flush(); 17 | 18 | public native boolean poll(Duration timeout); 19 | 20 | public native Event read(); 21 | 22 | public native Xy terminalSize(); 23 | 24 | public native Xy cursorPosition(); 25 | 26 | public native void disableRawMode(); 27 | 28 | public native void enableRawMode(); 29 | 30 | public native void enqueue(List commands); 31 | 32 | final public void enqueue(Command ...commands) { 33 | enqueue(java.util.Arrays.asList(commands)); 34 | } 35 | 36 | public native void execute(List commands); 37 | 38 | final public void execute(Command ...commands) { 39 | execute(Arrays.asList(commands)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/src/scala/tui/bufferView.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import tui.internal.saturating._ 4 | 5 | import scala.collection.mutable 6 | 7 | /// Returns a string representation of the given buffer for debugging purpose. 8 | object bufferView { 9 | def apply(buffer: Buffer): String = { 10 | val view = new StringBuilder(buffer.content.length + buffer.area.height * 3) 11 | val value: Iterator[mutable.ArraySeq[Cell]] = buffer.content.grouped(buffer.area.width) 12 | value.foreach { cells => 13 | val overwritten = mutable.ArrayBuffer.empty[(Int, String)] 14 | var skip: Int = 0 15 | view.append('"') 16 | cells.zipWithIndex.foreach { case (c, x) => 17 | if (skip == 0) { 18 | view.append(c.symbol.str) 19 | } else { 20 | overwritten += ((x, c.symbol.str)) 21 | } 22 | skip = math.max(skip, c.symbol.width).saturating_sub_unsigned(1); 23 | } 24 | view.append('"') 25 | if (overwritten.nonEmpty) { 26 | view.append(s" Hidden by multi-width symbols: $overwritten") 27 | } 28 | view.append('\n'); 29 | } 30 | view.toString() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tui/src/scala/tui/StatefulWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** A `StatefulWidget` is a widget that can take advantage of some local state to remember things between two draw calls. 4 | * 5 | * Most widgets can be drawn directly based on the input parameters. However, some features may require some kind of associated state to be implemented. 6 | * 7 | * For example, the `List` widget can highlight the item currently selected. This can be translated in an offset, which is the number of elements to skip in 8 | * order to have the selected item within the viewport currently allocated to this widget. The widget can therefore only provide the following behavior: 9 | * whenever the selected item is out of the viewport scroll to a predefined position (making the selected item the last viewable item or the one in the middle 10 | * for example). Nonetheless, if the widget has access to the last computed offset then it can implement a natural scrolling experience where the last offset 11 | * is reused until the selected item is out of the viewport. 12 | */ 13 | trait StatefulWidget { 14 | type State 15 | 16 | def render(area: Rect, buf: Buffer, state: State): Unit 17 | } 18 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/MouseEventKind.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public sealed interface MouseEventKind permits 4 | MouseEventKind.Down, 5 | MouseEventKind.Up, 6 | MouseEventKind.Drag, 7 | MouseEventKind.Moved, 8 | MouseEventKind.ScrollDown, 9 | MouseEventKind.ScrollUp { 10 | /// Pressed mouse button. Contains the button that was pressed. 11 | record Down(MouseButton mouseButton) implements MouseEventKind { 12 | } 13 | 14 | /// Released mouse button. Contains the button that was released. 15 | record Up(MouseButton mouseButton) implements MouseEventKind { 16 | } 17 | 18 | /// Moved the mouse cursor while pressing the contained mouse button. 19 | record Drag(MouseButton mouseButton) implements MouseEventKind { 20 | } 21 | 22 | /// Moved the mouse cursor while not pressing a mouse button. 23 | record Moved() implements MouseEventKind { 24 | } 25 | 26 | /// Scrolled mouse wheel downwards (towards the user). 27 | record ScrollDown() implements MouseEventKind { 28 | } 29 | 30 | /// Scrolled mouse wheel upwards (away from the user). 31 | record ScrollUp() implements MouseEventKind { 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Span.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import tui.internal.UnicodeSegmentation 4 | 5 | /** string where all graphemes have the same style. 6 | */ 7 | case class Span(content: String, style: Style) { 8 | 9 | /** Returns the width of the content held by this span. 10 | */ 11 | def width: Int = content.length 12 | 13 | /** Returns an iterator over the graphemes held by this span. 14 | * 15 | * @param baseStyle 16 | * the `Style` that will be patched with each grapheme `Style` to get the resulting `Style`. 17 | */ 18 | def styledGraphemes(baseStyle: Style): Array[StyledGrapheme] = 19 | UnicodeSegmentation 20 | .graphemes(content, isExtended = true) 21 | .map(g => 22 | StyledGrapheme( 23 | symbol = g, 24 | style = baseStyle.patch(style) 25 | ) 26 | ) 27 | .filter(s => s.symbol.str != "\n") 28 | } 29 | 30 | object Span { 31 | 32 | /** Create a span with no style. 33 | */ 34 | def nostyle(content: String): Span = 35 | Span(content = content, style = Style()) 36 | 37 | /** Create a span with a style. 38 | */ 39 | def styled(content: String, style: Style): Span = 40 | Span(content = content, style) 41 | } 42 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/KeyboardEnhancementFlags.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public record KeyboardEnhancementFlags(int bits) { 4 | /// Represent Escape and modified keys using CSI-u sequences, so they can be unambiguously 5 | /// read. 6 | public static final int DISAMBIGUATE_ESCAPE_CODES = 0b0000_0001; 7 | /// Add extra events with [`KeyEvent.kind`] set to [`KeyEventKind::Repeat`] or 8 | /// [`KeyEventKind::Release`] when keys are autorepeated or released. 9 | public static final int REPORT_EVENT_TYPES = 0b0000_0010; 10 | // Send [alternate keycodes](https://sw.kovidgoyal.net/kitty/keyboard-protocol/#key-codes) 11 | // in addition to the base keycode. 12 | // 13 | // *Note*: these are not yet supported by crossterm. 14 | public static final int REPORT_ALTERNATE_KEYS = 0b0000_0100; 15 | /// Represent all keyboard events as CSI-u sequences. This is required to get repeat/release 16 | /// events for plain-text keys. 17 | public static final int REPORT_ALL_KEYS_AS_ESCAPE_CODES = 0b0000_1000; 18 | // Send the Unicode codepoint as well as the keycode. 19 | // 20 | // *Note*: this is not yet supported by crossterm. 21 | public static final int REPORT_ASSOCIATED_TEXT = 0b0001_0000; 22 | } 23 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/LayoutExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.crossterm.CrosstermJni 5 | import tui.widgets._ 6 | 7 | object LayoutExample { 8 | def main(args: Array[String]): Unit = withTerminal((jni, terminal) => run_app(terminal, jni)) 9 | 10 | def run_app(terminal: Terminal, jni: CrosstermJni): Unit = 11 | while (true) { 12 | terminal.draw(f => ui(f)) 13 | jni.read() match { 14 | case key: tui.crossterm.Event.Key => 15 | key.keyEvent.code match { 16 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 17 | case _ => () 18 | } 19 | case _ => () 20 | } 21 | } 22 | 23 | def ui(f: Frame): Unit = { 24 | val chunks = Layout( 25 | direction = Direction.Vertical, 26 | constraints = Array(Constraint.Percentage(10), Constraint.Percentage(80), Constraint.Percentage(10)) 27 | ) 28 | .split(f.size) 29 | 30 | val block0 = BlockWidget(title = Some(Spans.nostyle("Block")), borders = Borders.ALL) 31 | f.renderWidget(block0, chunks(0)) 32 | val block1 = BlockWidget(title = Some(Spans.nostyle("Block 2")), borders = Borders.ALL) 33 | f.renderWidget(block1, chunks(2)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Painter.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | import tui.{Color, Point} 4 | 5 | case class Painter( 6 | context: Context, 7 | resolution: Point 8 | ) { 9 | 10 | /** Convert the (x, y) coordinates to location of a point on the grid 11 | */ 12 | def getPoint(x: Double, y: Double): Option[(Int, Int)] = { 13 | val left = this.context.xBounds.x 14 | val right = this.context.xBounds.y 15 | val top = this.context.yBounds.y 16 | val bottom = this.context.yBounds.x 17 | if (x < left || x > right || y < bottom || y > top) { 18 | return None 19 | } 20 | val width = math.abs(this.context.xBounds.y - this.context.xBounds.x) 21 | val height = math.abs(this.context.yBounds.y - this.context.yBounds.x) 22 | if (width == 0.0 || height == 0.0) { 23 | return None 24 | } 25 | val x0 = ((x - left) * this.resolution.x / width).toInt 26 | val y0 = ((top - y) * this.resolution.y / height).toInt 27 | Some((x0, y0)) 28 | } 29 | 30 | /** Paint a point of the grid 31 | */ 32 | def paint(x: Int, y: Int, color: Color): Unit = 33 | this.context.grid.paint(x, y, color) 34 | } 35 | 36 | object Painter { 37 | def from(context: Context): Painter = { 38 | val resolution = context.grid.resolution 39 | Painter(context, resolution) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/src/scala/tui/widgets/BarchartTests.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | class BarchartTests extends TuiTest { 5 | test("widgets_barchart_not_full_below_max_value") { 6 | val test_case = (expected: Buffer) => { 7 | val backend = TestBackend(30, 10) 8 | val terminal = Terminal.init(backend) 9 | 10 | terminal.draw { f => 11 | val barchart = BarChartWidget( 12 | block = Some(BlockWidget(borders = Borders.ALL)), 13 | barWidth = 7, 14 | barGap = 0, 15 | data = Array(("empty", 0), ("half", 50), ("almost", 99), ("full", 100)), 16 | max = Some(100) 17 | ) 18 | f.renderWidget(barchart, f.size); 19 | } 20 | assertBuffer(backend, expected) 21 | } 22 | 23 | // check that bars fill up correctly up to max value 24 | test_case( 25 | Buffer.withLines( 26 | "┌────────────────────────────┐", 27 | "│ ▇▇▇▇▇▇▇███████│", 28 | "│ ██████████████│", 29 | "│ ██████████████│", 30 | "│ ▄▄▄▄▄▄▄██████████████│", 31 | "│ █████████████████████│", 32 | "│ █████████████████████│", 33 | "│ ██50█████99█████100██│", 34 | "│empty half almost full │", 35 | "└────────────────────────────┘" 36 | ) 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/src/scala/tui/StylesTests.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | class StylesTests extends TuiTest { 4 | def styles(): Array[Style] = 5 | Array( 6 | Style(), 7 | Style().fg(Color.Yellow), 8 | Style().bg(Color.Yellow), 9 | Style().addModifier(Modifier.BOLD), 10 | Style().removeModifier(Modifier.BOLD), 11 | Style().addModifier(Modifier.ITALIC), 12 | Style().removeModifier(Modifier.ITALIC), 13 | Style().addModifier(Modifier.ITALIC | Modifier.BOLD), 14 | Style().removeModifier(Modifier.ITALIC | Modifier.BOLD) 15 | ) 16 | 17 | test("combined_patch_gives_same_result_as_individual_patch") { 18 | val styles2 = styles() 19 | for { 20 | a <- styles2 21 | b <- styles2 22 | c <- styles2 23 | d <- styles2 24 | } { 25 | val combined = a.patch(b.patch(c.patch(d))) 26 | assertEq(Style().patch(a).patch(b).patch(c).patch(d), Style().patch(combined)) 27 | } 28 | } 29 | test("flaff") { 30 | val both = Modifier.ITALIC | Modifier.BOLD 31 | assert(both.contains(Modifier.ITALIC)) 32 | assert(both.contains(Modifier.BOLD)) 33 | assert(!both.contains(Modifier.DIM)) 34 | 35 | val onlyBold = both.remove(Modifier.ITALIC) 36 | assert(!onlyBold.contains(Modifier.ITALIC)) 37 | assert(onlyBold.contains(Modifier.BOLD)) 38 | assert(!onlyBold.contains(Modifier.DIM)) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/Launcher.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui.crossterm.CrosstermJni 4 | 5 | object Launcher { 6 | val Demos: Map[String, Array[String] => Unit] = Map[String, Array[String] => Unit]( 7 | "barchart" -> BarChartExample.main, 8 | "block" -> BlockExample.main, 9 | "canvas" -> CanvasExample.main, 10 | "chart" -> ChartExample.main, 11 | "custom_widget" -> CustomWidgetExample.main, 12 | "demo" -> demo.Demo.main, 13 | "gauge" -> GaugeExample.main, 14 | "layout" -> LayoutExample.main, 15 | "list" -> ListExample.main, 16 | "paragraph" -> ParagraphExample.main, 17 | "popup" -> PopupExample.main, 18 | "sparkline" -> SparklineExample.main, 19 | "table" -> TableExample.main, 20 | "tabs" -> TabsExample.main, 21 | "user_input" -> UserInputExample.main 22 | ) 23 | 24 | def main(args: Array[String]): Unit = 25 | args.headOption match { 26 | case Some("check") => 27 | new CrosstermJni() // run for the side effect of testing jni library 28 | println("ok") 29 | case Some(name) => 30 | Demos.get(name) match { 31 | case Some(demo) => demo(args.drop(1)) 32 | case None => 33 | System.err.println(s"$name is not among ${Demos.keys.mkString(",")}") 34 | 35 | } 36 | case None => System.err.println(s"specify which demo one of ${Demos.keys.mkString(",")} as parameter") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Cell.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** A buffer cell 4 | */ 5 | case class Cell( 6 | var symbol: tui.Grapheme, 7 | var fg: Color, 8 | var bg: Color, 9 | var modifier: Modifier 10 | ) { 11 | override def clone(): Cell = 12 | new Cell(symbol, fg, bg, modifier) 13 | 14 | def setSymbol(symbol: String): this.type = 15 | setSymbol(Grapheme(symbol)) 16 | 17 | def setSymbol(symbol: tui.Grapheme): this.type = { 18 | this.symbol = symbol 19 | this 20 | } 21 | 22 | def setChar(ch: Char): this.type = { 23 | this.symbol = Grapheme(ch.toString) 24 | this 25 | } 26 | 27 | def setFg(color: Color): this.type = { 28 | this.fg = color 29 | this 30 | } 31 | 32 | def setBg(color: Color): this.type = { 33 | this.bg = color 34 | this 35 | } 36 | 37 | def setStyle(style: Style): this.type = { 38 | style.fg.foreach(fg = _) 39 | style.bg.foreach(bg = _) 40 | modifier = modifier.insert(style.addModifier).remove(style.subModifier) 41 | this 42 | } 43 | 44 | def style: Style = 45 | Style(fg = Some(fg), bg = Some(bg), addModifier = modifier) 46 | 47 | def reset(): Unit = { 48 | this.symbol = Grapheme.Empty 49 | this.fg = Color.Reset 50 | this.bg = Color.Reset 51 | this.modifier = Modifier.EMPTY 52 | } 53 | } 54 | 55 | object Cell { 56 | def default: Cell = Cell(Grapheme.Empty, fg = Color.Reset, bg = Color.Reset, modifier = Modifier.EMPTY) 57 | } 58 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/CharGrid.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | import tui.internal.ranges 4 | import tui.{Color, Point} 5 | 6 | case class CharGrid( 7 | width: Int, 8 | height: Int, 9 | cells: Array[Char], 10 | colors: Array[Color], 11 | cellChar: Char 12 | ) extends Grid { 13 | 14 | override def resolution: Point = 15 | Point(this.width.toDouble - 1.0, this.height.toDouble - 1.0) 16 | 17 | override def save(): Layer = 18 | Layer( 19 | string = new String(this.cells), 20 | colors = this.colors.map(identity) 21 | ) 22 | 23 | override def reset(): Unit = { 24 | ranges.range(0, this.cells.length) { i => 25 | this.cells(i) = ' ' 26 | } 27 | ranges.range(0, this.colors.length) { i => 28 | this.colors(i) = Color.Reset 29 | } 30 | } 31 | 32 | override def paint(x: Int, y: Int, color: Color): Unit = { 33 | val index = y * this.width + x 34 | if (index < this.cells.length) { 35 | this.cells(index) = this.cellChar 36 | } 37 | if (index < this.colors.length) { 38 | this.colors(index) = color 39 | } 40 | } 41 | } 42 | object CharGrid { 43 | def apply(width: Int, height: Int, cell_char: Char): CharGrid = { 44 | val length = width * height 45 | CharGrid( 46 | width, 47 | height, 48 | cells = Array.fill(length)(' '), 49 | colors = Array.fill(length)(Color.Reset), 50 | cell_char 51 | ) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/src/scala/tui/TuiTest.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import org.scalactic.{source, CanEqual, Prettifier, TypeCheckedTripleEquals} 4 | import org.scalatest.Assertion 5 | import org.scalatest.funsuite.AnyFunSuite 6 | 7 | trait TuiTest extends AnyFunSuite with TypeCheckedTripleEquals { 8 | def assertEq[L, R](actual: L, expected: R, msg: String = "")(implicit constraint: L CanEqual R, prettifier: Prettifier, pos: source.Position): Assertion = 9 | assert(actual === expected) 10 | 11 | def assertBuffer(actual: TestBackend, expected: Buffer): Unit = { 12 | assertEq(expected.area, actual.buffer.area) 13 | val diff = expected.diff(actual.buffer) 14 | if (diff.isEmpty) { 15 | return 16 | } 17 | 18 | val debug_info = new StringBuilder("Buffers are not equal") 19 | debug_info.append('\n') 20 | debug_info.append("Expected:") 21 | debug_info.append('\n') 22 | val expected_view = bufferView(expected) 23 | debug_info.append(expected_view) 24 | debug_info.append('\n') 25 | debug_info.append("Got:") 26 | debug_info.append('\n') 27 | val view = bufferView(actual.buffer) 28 | debug_info.append(view) 29 | debug_info.append('\n') 30 | 31 | debug_info.append("Diff:") 32 | debug_info.append('\n') 33 | val nice_diff = diff.zipWithIndex 34 | .map { case ((x, y, cell), i) => s"$i: at ($x, $y) expected ${expected.get(x, y)} got $cell" } 35 | .mkString("\n") 36 | debug_info.append(nice_diff) 37 | sys.error(debug_info.toString()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Frame.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** Represents a consistent terminal interface for rendering. 4 | * @param size 5 | * Terminal size, guaranteed not to change when rendering. 6 | * @param cursorPosition 7 | * Where should the cursor be after drawing this frame. If `None`, the cursor is hidden and its position is controlled by the backend. If `Some((x, /// y))`, 8 | * the cursor is shown and placed at `(x, y)` after the call to `Terminal.draw()`. 9 | */ 10 | case class Frame( 11 | buffer: Buffer, 12 | size: Rect, 13 | var cursorPosition: Option[(Int, Int)] 14 | ) { 15 | 16 | /** Render a `Widget` to the current buffer using `Widget.render`. 17 | */ 18 | def renderWidget(widget: Widget, area: Rect): Unit = 19 | widget.render(area, buffer) 20 | 21 | /** Render a `StatefulWidget` to the current buffer using `StatefulWidget.render`. 22 | * 23 | * The last argument should be an instance of the `StatefulWidget.State` associated to the given `StatefulWidget`. 24 | */ 25 | def renderStatefulWidget[W <: StatefulWidget](widget: W, area: Rect)(state: widget.State): Unit = 26 | widget.render(area, buffer, state) 27 | 28 | /** After drawing this frame, make the cursor visible and put it at the specified (x, y) coordinates. If this method is not called, the cursor will be hidden. 29 | * 30 | * Note that this will interfere with calls to `Terminal.hide_cursor()`, `Terminal.show_cursor()`, and `Terminal.set_cursor()`. Pick one of the APIs and 31 | * stick with it. 32 | */ 33 | def setCursor(x: Int, y: Int): Unit = 34 | cursorPosition = Some((x, y)) 35 | } 36 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/BrailleGrid.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | import tui.internal.ranges 4 | import tui.{symbols, Color, Point} 5 | 6 | case class BrailleGrid( 7 | width: Int, 8 | height: Int, 9 | cells: Array[Int], 10 | colors: Array[Color] 11 | ) extends Grid { 12 | override def resolution: Point = 13 | Point( 14 | this.width.toDouble * 2.0 - 1.0, 15 | this.height.toDouble * 4.0 - 1.0 16 | ) 17 | 18 | override def save(): Layer = 19 | Layer( 20 | string = new String(this.cells, 0, cells.length), 21 | colors = this.colors.clone() 22 | ) 23 | 24 | override def reset(): Unit = { 25 | ranges.range(0, this.cells.length) { i => 26 | this.cells(i) = symbols.braille.BLANK 27 | } 28 | ranges.range(0, this.colors.length) { i => 29 | this.colors(i) = Color.Reset 30 | } 31 | } 32 | 33 | override def paint(x: Int, y: Int, color: Color): Unit = { 34 | val index = y / 4 * this.width + x / 2 35 | if (index < this.cells.length) { 36 | val c = this.cells(index) 37 | val chosenDots = symbols.braille.DOTS(y % 4) 38 | val chosenDot = x % 2 match { 39 | case 0 => chosenDots._1 40 | case 1 => chosenDots._2 41 | } 42 | val newC = c | chosenDot 43 | this.cells(index) = newC 44 | } 45 | if (index < this.colors.length) { 46 | this.colors(index) = color 47 | } 48 | } 49 | } 50 | 51 | object BrailleGrid { 52 | def apply(width: Int, height: Int): BrailleGrid = { 53 | val length = width * height 54 | BrailleGrid( 55 | width, 56 | height, 57 | cells = Array.fill(length)(symbols.braille.BLANK), 58 | colors = Array.fill(length)(Color.Reset) 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Context.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets.canvas 2 | 3 | import tui.{symbols, Point, Spans} 4 | 5 | import scala.collection.mutable 6 | 7 | /** Holds the state of the Canvas when painting to it. 8 | */ 9 | case class Context( 10 | xBounds: Point, 11 | yBounds: Point, 12 | grid: Grid, 13 | var dirty: Boolean, 14 | layers: mutable.ArrayBuffer[Layer], 15 | labels: mutable.ArrayBuffer[Label] 16 | ) { 17 | 18 | /** Draw any object that may implement the Shape trait 19 | */ 20 | def draw(shape: Shape): Unit = { 21 | this.dirty = true 22 | val painter = Painter.from(this) 23 | shape.draw(painter) 24 | } 25 | 26 | /** Go one layer above in the canvas. 27 | */ 28 | def layer(): Unit = { 29 | this.layers.addOne(this.grid.save()) 30 | this.grid.reset() 31 | this.dirty = false 32 | } 33 | 34 | /** Print a string on the canvas at the given position 35 | */ 36 | def print(x: Double, y: Double, spans: Spans): Unit = 37 | this.labels.addOne(Label(x, y, spans)) 38 | 39 | /** Push the last layer if necessary 40 | */ 41 | def finish(): Unit = 42 | if (this.dirty) { 43 | this.layer() 44 | } 45 | } 46 | 47 | object Context { 48 | def apply( 49 | width: Int, 50 | height: Int, 51 | x_bounds: Point, 52 | y_bounds: Point, 53 | marker: symbols.Marker 54 | ): Context = { 55 | val grid: Grid = marker match { 56 | case symbols.Marker.Dot => CharGrid(width, height, '•') 57 | case symbols.Marker.Block => CharGrid(width, height, '▄') 58 | case symbols.Marker.Braille => BrailleGrid(width, height) 59 | } 60 | Context( 61 | x_bounds, 62 | y_bounds, 63 | grid, 64 | dirty = false, 65 | layers = mutable.ArrayBuffer.empty, 66 | labels = mutable.ArrayBuffer.empty 67 | ) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/demo/Demo.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | package demo 3 | 4 | import tui._ 5 | import tui.crossterm.CrosstermJni 6 | import tui.withTerminal 7 | 8 | import java.time.{Duration, Instant} 9 | import scala.Ordering.Implicits._ 10 | 11 | object Demo { 12 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 13 | // create app and run it 14 | val tick_rate = Duration.ofMillis(250) 15 | val app = App(title = "Crossterm Demo", enhanced_graphics = true) 16 | 17 | run_app(terminal, app, tick_rate, jni) 18 | } 19 | 20 | def run_app(terminal: Terminal, app: App, tick_rate: java.time.Duration, jni: CrosstermJni): Unit = { 21 | var last_tick = Instant.now() 22 | 23 | def elapsed = java.time.Duration.between(last_tick, java.time.Instant.now()) 24 | def timeout = { 25 | val timeout = tick_rate.minus(elapsed) 26 | new tui.crossterm.Duration(timeout.toSeconds, timeout.getNano) 27 | } 28 | 29 | while (true) { 30 | terminal.draw(f => ui.draw(f, app)) 31 | 32 | if (jni.poll(timeout)) { 33 | jni.read() match { 34 | case key: tui.crossterm.Event.Key => 35 | key.keyEvent.code match { 36 | case char: tui.crossterm.KeyCode.Char => app.on_key(char.c()) 37 | case _: tui.crossterm.KeyCode.Left => app.on_left() 38 | case _: tui.crossterm.KeyCode.Up => app.on_up() 39 | case _: tui.crossterm.KeyCode.Right => app.on_right() 40 | case _: tui.crossterm.KeyCode.Down => app.on_down() 41 | case _ => () 42 | } 43 | case _ => () 44 | } 45 | } 46 | if (elapsed >= tick_rate) { 47 | app.on_tick() 48 | last_tick = Instant.now() 49 | } 50 | if (app.should_quit) { 51 | return 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Rect.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** A simple rectangle used in the computation of the layout and to give widgets a hint about the area they are supposed to render to. 4 | */ 5 | case class Rect( 6 | x: Int, 7 | y: Int, 8 | width: Int, 9 | height: Int 10 | ) { 11 | def area: Int = 12 | width * height 13 | 14 | def left: Int = 15 | x 16 | 17 | def right: Int = 18 | x + width 19 | 20 | def top: Int = 21 | y 22 | 23 | def bottom: Int = 24 | y + height 25 | 26 | def inner(margin: Margin): Rect = 27 | if (width < 2 * margin.horizontal || height < 2 * margin.vertical) { 28 | Rect.default 29 | } else { 30 | Rect( 31 | x = x + margin.horizontal, 32 | y = y + margin.vertical, 33 | width = width - 2 * margin.horizontal, 34 | height = height - 2 * margin.vertical 35 | ) 36 | } 37 | 38 | def union(other: Rect): Rect = { 39 | val x1 = math.min(x, other.x) 40 | val y1 = math.min(y, other.y) 41 | val x2 = math.max(x + width, other.x + other.width) 42 | val y2 = math.max(y + height, other.y + other.height) 43 | Rect( 44 | x = x1, 45 | y = y1, 46 | width = x2 - x1, 47 | height = y2 - y1 48 | ) 49 | } 50 | 51 | def intersection(other: Rect): Rect = { 52 | val x1 = math.max(x, other.x) 53 | val y1 = math.max(y, other.y) 54 | val x2 = math.min(x + width, other.x + other.width) 55 | val y2 = math.min(y + height, other.y + other.height) 56 | Rect( 57 | x = x1, 58 | y = y1, 59 | width = x2 - x1, 60 | height = y2 - y1 61 | ) 62 | } 63 | 64 | def intersects(other: Rect): Boolean = 65 | x < (other.x + other.width) && (x + width) > other.x && y < (other.y + other.height) && (y + height) > other.y 66 | } 67 | 68 | object Rect { 69 | val default: Rect = Rect(x = 0, y = 0, width = 0, height = 0) 70 | } 71 | -------------------------------------------------------------------------------- /bleep.yaml: -------------------------------------------------------------------------------- 1 | $schema: https://raw.githubusercontent.com/oyvindberg/bleep/master/schema.json 2 | $version: 0.0.1-M27 3 | jvm: 4 | name: graalvm-java17:22.3.1 5 | projects: 6 | cassowary: 7 | extends: template-cross-scala 8 | crossterm: 9 | sourcegen: 10 | main: tui.scripts.GenJniLibrary 11 | project: scripts 12 | demo: 13 | dependencies: org.graalvm.nativeimage:svm:22.3.1 14 | dependsOn: tui 15 | extends: template-cross-scala 16 | platform: 17 | mainClass: tuiexamples.Launcher 18 | scripts: 19 | dependencies: 20 | - build.bleep::bleep-plugin-ci-release:${BLEEP_VERSION} 21 | - build.bleep::bleep-plugin-jni:${BLEEP_VERSION} 22 | - build.bleep::bleep-plugin-native-image:${BLEEP_VERSION} 23 | extends: 24 | - template-scala-2 25 | - template-scala-common 26 | tests: 27 | dependencies: org.scalatest::scalatest:3.2.15 28 | dependsOn: tui 29 | extends: template-cross-scala 30 | isTestProject: true 31 | tui: 32 | dependsOn: 33 | - cassowary 34 | - crossterm 35 | extends: template-cross-scala 36 | scripts: 37 | gen-headers: 38 | main: tui.scripts.GenHeaders 39 | project: scripts 40 | gen-native-image: 41 | main: tui.scripts.GenNativeImage 42 | project: scripts 43 | my-publish-local: 44 | main: tui.scripts.PublishLocal 45 | project: scripts 46 | publish: 47 | main: tui.scripts.Publish 48 | project: scripts 49 | templates: 50 | template-cross-scala: 51 | cross: 52 | jvm213: 53 | extends: template-scala-2 54 | jvm3: 55 | extends: template-scala-3 56 | extends: template-scala-common 57 | template-scala-2: 58 | scala: 59 | version: 2.13.10 60 | template-scala-3: 61 | scala: 62 | version: 3.2.2 63 | template-scala-common: 64 | platform: 65 | name: jvm 66 | scala: 67 | options: -encoding utf8 -feature -unchecked 68 | strict: true 69 | -------------------------------------------------------------------------------- /scripts/src/scala/tui/scripts/GenNativeImage.scala: -------------------------------------------------------------------------------- 1 | package tui.scripts 2 | 3 | import bleep._ 4 | import bleep.plugin.nativeimage.NativeImagePlugin 5 | 6 | import java.nio.file.Path 7 | 8 | object GenNativeImage extends BleepScript("GenNativeImage") { 9 | def run(started: Started, commands: Commands, args: List[String]): Unit = { 10 | commands.compile(List(demoProject)) 11 | 12 | val plugin = new NativeImagePlugin( 13 | project = started.bloopProject(demoProject), 14 | logger = started.logger, 15 | nativeImageOptions = List( 16 | "--verbose", 17 | "--no-fallback", 18 | "-H:+ReportExceptionStackTraces", 19 | "--initialize-at-build-time=scala.runtime.Statics$VM", 20 | "--initialize-at-build-time=scala.Symbol", 21 | "--initialize-at-build-time=scala.Symbol$", 22 | "--native-image-info", 23 | """-H:IncludeResources=libnative-arm64-darwin-crossterm.dylib""", 24 | """-H:IncludeResources=libnative-x86_64-darwin-crossterm.dylib""", 25 | """-H:IncludeResources=libnative-x86_64-linux-crossterm.so""", 26 | """-H:IncludeResources=native-x86_64-windows-crossterm.dll""", 27 | "-H:-UseServiceLoaderFeature" 28 | ), 29 | jvmCommand = started.jvmCommand, 30 | env = sys.env.toList ++ List(("USE_NATIVE_IMAGE_JAVA_PLATFORM_MODULE_SYSTEM", "false")) 31 | ) { 32 | // allow user to pass in name of generated binary as parameter 33 | override val nativeImageOutput: Path = args.headOption match { 34 | case Some(relPath) => 35 | // smoothen over some irritation from github action scripts 36 | val relPathNoExe = if (relPath.endsWith(".exe")) relPath.dropRight(".exe".length) else relPath 37 | started.pre.buildPaths.cwd / relPathNoExe 38 | case None => super.nativeImageOutput 39 | } 40 | } 41 | val path = plugin.nativeImage() 42 | started.logger.info(s"Created native-image at $path") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /scripts/src/scala/tui/scripts/GenJniLibrary.scala: -------------------------------------------------------------------------------- 1 | package tui.scripts 2 | 3 | import bleep._ 4 | import bleep.plugin.jni.{Cargo, JniNative, JniPackage} 5 | 6 | import java.nio.file.Path 7 | 8 | object GenJniLibrary extends bleep.BleepCodegenScript("GenJniLibrary") { 9 | 10 | def crosstermJniNativeLib(started: Started): JniNative = 11 | new JniNative( 12 | logger = started.logger, 13 | nativeCompileSourceDirectory = started.projectPaths(crosstermProject).dir / "cargo", 14 | nativeTargetDirectory = started.buildPaths.dotBleepDir, 15 | nativeBuildTool = new Cargo(release = true), 16 | libName = "crossterm", 17 | env = sys.env.toList 18 | ) { 19 | override lazy val nativePlatform: String = 20 | OsArch.current match { 21 | case OsArch.LinuxAmd64 => "x86_64-linux" 22 | case OsArch.WindowsAmd64 => "x86_64-windows" 23 | case OsArch.MacosAmd64 => "x86_64-darwin" 24 | case OsArch.MacosArm64(_) => "arm64-darwin" 25 | case other: OsArch.Other => sys.error(s"not implemented: $other") 26 | } 27 | } 28 | 29 | override def run(started: Started, commands: Commands, targets: List[GenJniLibrary.Target], args: List[String]): Unit = { 30 | val jniNative = crosstermJniNativeLib(started) 31 | val jniPackage = new JniPackage(started.buildPaths.buildDir, jniNative) { 32 | // override naming standard to match `NativeLoader.java` 33 | override lazy val managedNativeLibraries: Seq[(Path, RelPath)] = { 34 | val library: Path = jniNative.nativeCompile() 35 | val name = System.mapLibraryName(s"native-${jniNative.nativePlatform}-${jniNative.libName}") 36 | Seq(library -> new RelPath(List(name))) 37 | } 38 | } 39 | 40 | targets.foreach { target => 41 | // copy into place in resources directories 42 | val writtenPaths = jniPackage.copyTo(target.resources) 43 | writtenPaths.foreach(path => started.logger.withContext(path).info("wrote")) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/Attribute.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public enum Attribute { 4 | /// Resets all the attributes. 5 | Reset, 6 | /// Increases the text intensity. 7 | Bold, 8 | /// Decreases the text intensity. 9 | Dim, 10 | /// Emphasises the text. 11 | Italic, 12 | /// Underlines the text. 13 | Underlined, 14 | 15 | // Other types of underlining 16 | /// Double underlines the text. 17 | DoubleUnderlined, 18 | /// Undercurls the text. 19 | Undercurled, 20 | /// Underdots the text. 21 | Underdotted, 22 | /// Underdashes the text. 23 | Underdashed, 24 | 25 | /// Makes the text blinking (< 150 per minute). 26 | SlowBlink, 27 | /// Makes the text blinking (>= 150 per minute). 28 | RapidBlink, 29 | /// Swaps foreground and background colors. 30 | Reverse, 31 | /// Hides the text (also known as Conceal). 32 | Hidden, 33 | /// Crosses the text. 34 | CrossedOut, 35 | /// Sets the [Fraktur](https://en.wikipedia.org/wiki/Fraktur) typeface. 36 | /// 37 | /// Mostly used for [mathematical alphanumeric symbols](https://en.wikipedia.org/wiki/Mathematical_Alphanumeric_Symbols). 38 | Fraktur, 39 | /// Turns off the `Bold` attribute. - Inconsistent - Prefer to use NormalIntensity 40 | NoBold, 41 | /// Switches the text back to normal intensity (no bold, italic). 42 | NormalIntensity, 43 | /// Turns off the `Italic` attribute. 44 | NoItalic, 45 | /// Turns off the `Underlined` attribute. 46 | NoUnderline, 47 | /// Turns off the text blinking (`SlowBlink` or `RapidBlink`). 48 | NoBlink, 49 | /// Turns off the `Reverse` attribute. 50 | NoReverse, 51 | /// Turns off the `Hidden` attribute. 52 | NoHidden, 53 | /// Turns off the `CrossedOut` attribute. 54 | NotCrossedOut, 55 | /// Makes the text framed. 56 | Framed, 57 | /// Makes the text encircled. 58 | Encircled, 59 | /// Draws a line at the top of the text. 60 | OverLined, 61 | /// Turns off the `Frame` and `Encircled` attributes. 62 | NotFramedOrEncircled, 63 | /// Turns off the `OverLined` attribute. 64 | NotOverLined, 65 | } 66 | 67 | 68 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/LineGaugeWidget.scala: -------------------------------------------------------------------------------- 1 | package tui.widgets 2 | 3 | import tui.internal.ranges 4 | import tui._ 5 | import tui.internal.saturating.IntOps 6 | 7 | /** A compact widget to display a task progress over a single line. 8 | */ 9 | case class LineGaugeWidget( 10 | block: Option[BlockWidget] = None, 11 | ratio: GaugeWidget.Ratio = GaugeWidget.Ratio.Zero, 12 | label: Option[Spans] = None, 13 | lineSet: symbols.line.Set = symbols.line.NORMAL, 14 | style: Style = Style.DEFAULT, 15 | gaugeStyle: Style = Style.DEFAULT 16 | ) extends Widget { 17 | override def render(area: Rect, buf: Buffer): Unit = { 18 | buf.setStyle(area, style) 19 | val gauge_area = block match { 20 | case Some(b) => 21 | val inner_area = b.inner(area) 22 | b.render(area, buf) 23 | inner_area 24 | case None => area 25 | } 26 | 27 | if (gauge_area.height < 1) { 28 | return 29 | } 30 | 31 | val label = this.label.getOrElse(Spans.nostyle(s"${(ratio.value * 100.0).toInt}%")) 32 | val (col, row) = buf.setSpans( 33 | gauge_area.left, 34 | gauge_area.top, 35 | label, 36 | gauge_area.width 37 | ) 38 | val start = col + 1 39 | if (start >= gauge_area.right) { 40 | return 41 | } 42 | 43 | val end = start + (gauge_area.right.saturating_sub_unsigned(start).toDouble * ratio.value).floor.toInt 44 | ranges.range(start, end) { col => 45 | buf 46 | .get(col, row) 47 | .setSymbol(lineSet.horizontal) 48 | .setStyle( 49 | Style( 50 | fg = gaugeStyle.fg, 51 | bg = None, 52 | addModifier = gaugeStyle.addModifier, 53 | subModifier = gaugeStyle.subModifier 54 | ) 55 | ) 56 | () 57 | } 58 | ranges.range(end, gauge_area.right) { col => 59 | buf 60 | .get(col, row) 61 | .setSymbol(lineSet.horizontal) 62 | .setStyle( 63 | Style( 64 | fg = gaugeStyle.bg, 65 | bg = None, 66 | addModifier = gaugeStyle.addModifier, 67 | subModifier = gaugeStyle.subModifier 68 | ) 69 | ) 70 | () 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Style.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** `style` contains the primitives used to control how your user interface will look. 4 | * 5 | * Style let you control the main characteristics of the displayed elements. 6 | * 7 | * It represents an incremental change. If you apply the styles S1, S2, S3 to a cell of the terminal buffer, the style of this cell will be the result of the 8 | * merge of S1, S2 and S3, not just S3. 9 | */ 10 | case class Style( 11 | fg: Option[Color] = None, 12 | bg: Option[Color] = None, 13 | addModifier: Modifier = Modifier.EMPTY, 14 | subModifier: Modifier = Modifier.EMPTY 15 | ) { 16 | 17 | /** Changes the foreground color. 18 | */ 19 | def fg(color: Color): Style = 20 | copy(fg = Some(color)) 21 | 22 | /** Changes the background color. 23 | */ 24 | def bg(color: Color): Style = 25 | copy(bg = Some(color)) 26 | 27 | /** Changes the text emphasis. 28 | * 29 | * When applied, it adds the given modifier to the `Style` modifiers. 30 | */ 31 | def addModifier(modifier: Modifier): Style = 32 | copy( 33 | subModifier = subModifier.remove(modifier), 34 | addModifier = addModifier.insert(modifier) 35 | ) 36 | 37 | /** Changes the text emphasis. 38 | * 39 | * When applied, it removes the given modifier from the `Style` modifiers. 40 | */ 41 | def removeModifier(modifier: Modifier): Style = 42 | copy( 43 | addModifier = addModifier.remove(modifier), 44 | subModifier = subModifier.insert(modifier) 45 | ) 46 | 47 | /** Results in a combined style that is equivalent to applying the two individual styles to a style one after the other. 48 | */ 49 | def patch(other: Style): Style = 50 | Style( 51 | fg = other.fg.orElse(this.fg), 52 | bg = other.bg.orElse(this.bg), 53 | addModifier = addModifier.remove(other.subModifier).insert(other.addModifier), 54 | subModifier = subModifier.remove(other.addModifier).insert(other.subModifier) 55 | ) 56 | } 57 | 58 | object Style { 59 | val DEFAULT: Style = Style() 60 | 61 | /** Returns a `Style` resetting all properties. 62 | */ 63 | val RESET: Style = Style( 64 | fg = Some(Color.Reset), 65 | bg = Some(Color.Reset), 66 | addModifier = Modifier.EMPTY, 67 | subModifier = Modifier.ALL 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /crossterm/cargo/src/jvm_unwrapper.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use jni::{ 4 | errors::{Error as JniError, Result as JniResult}, 5 | JNIEnv, 6 | }; 7 | use jni::sys::jint; 8 | 9 | use crate::unify_errors::UnifiedError; 10 | 11 | // ensure that we always throw a JVM exception instead of `panic`ing 12 | pub trait JvmUnwrapper { 13 | fn jvm_unwrap(self, env: JNIEnv) -> T; 14 | } 15 | 16 | impl JvmUnwrapper for Result where T: Default { 17 | fn jvm_unwrap(self, env: JNIEnv) -> T { 18 | match self { 19 | Ok(t) => t, 20 | Err(err) => handle_error(env, err) 21 | } 22 | } 23 | } 24 | 25 | impl JvmUnwrapper for JniResult where T: Default { 26 | fn jvm_unwrap(self, env: JNIEnv) -> T { 27 | match self { 28 | Ok(t) => t, 29 | Err(err) => handle_jni_error(env, err) 30 | } 31 | } 32 | } 33 | 34 | impl JvmUnwrapper for Result where T: Default { 35 | fn jvm_unwrap(self, env: JNIEnv) -> T { 36 | match self { 37 | Ok(t) => t, 38 | Err(UnifiedError::Jni(jni_error)) => handle_jni_error(env, jni_error), 39 | Err(UnifiedError::Io(err)) => handle_error(env, err), 40 | Err(UnifiedError::NotU16(jint)) => handle_not_u16(env, jint), 41 | } 42 | } 43 | } 44 | 45 | fn handle_jni_error(env: JNIEnv, jni_error: JniError) -> T where T: Default { 46 | match jni_error { 47 | JniError::JavaException => { 48 | env.throw(env.exception_occurred().unwrap()).unwrap(); 49 | T::default() 50 | } 51 | err => { 52 | let runtime_exception = env.find_class("java/lang/RuntimeException").unwrap(); 53 | env.throw_new(runtime_exception, format!("Error from JNI: {err:?}")).unwrap(); 54 | T::default() 55 | } 56 | } 57 | } 58 | 59 | fn handle_error(env: JNIEnv, err: io::Error) -> T where T: Default { 60 | let runtime_exception = env.find_class("java/lang/RuntimeException").unwrap(); 61 | env.throw_new(runtime_exception, format!("IO error: {err:?}")).unwrap(); 62 | T::default() 63 | } 64 | 65 | fn handle_not_u16(env: JNIEnv, jint: jint) -> T where T: Default { 66 | let runtime_exception = env.find_class("java/lang/RuntimeException").unwrap(); 67 | env.throw_new(runtime_exception, format!("{jint:?} is not an u16")).unwrap(); 68 | T::default() 69 | } 70 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/SparklineWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | import tui.internal.ranges 5 | 6 | /** Widget to render a sparkline over one or more lines. 7 | * 8 | * @param block 9 | * A block to wrap the widget in 10 | * @param style 11 | * Widget style 12 | * @param data 13 | * A slice of the data to display 14 | * @param max 15 | * The maximum value to take to compute the maximum bar height (if nothing is specified, the widget uses the max of the dataset) 16 | * @param barSet 17 | * A set of bar symbols used to represent the give data 18 | */ 19 | case class SparklineWidget( 20 | block: Option[BlockWidget] = None, 21 | style: Style = Style.DEFAULT, 22 | data: collection.Seq[Int] = Nil, 23 | max: Option[Int] = None, 24 | barSet: symbols.bar.Set = symbols.bar.NINE_LEVELS 25 | ) extends Widget { 26 | def render(area: Rect, buf: Buffer): Unit = { 27 | val spark_area = block match { 28 | case Some(b) => 29 | val inner_area = b.inner(area) 30 | b.render(area, buf) 31 | inner_area 32 | case None => area 33 | } 34 | 35 | if (spark_area.height < 1) { 36 | return 37 | } 38 | 39 | val max = this.max match { 40 | case Some(v) => v 41 | case None => this.data.maxOption.getOrElse(1) 42 | } 43 | val max_index = math.min(spark_area.width, this.data.length) 44 | val data = this.data.take(max_index).toArray.map { e => 45 | if (max != 0) { 46 | e * spark_area.height * 8 / max 47 | } else { 48 | 0 49 | } 50 | } 51 | 52 | ranges.revRange(0, spark_area.height) { j => 53 | ranges.range(0, data.length) { i => 54 | val d = data(i) 55 | val symbol = d match { 56 | case 0 => barSet.empty 57 | case 1 => barSet.oneEighth 58 | case 2 => barSet.oneQuarter 59 | case 3 => barSet.threeEighths 60 | case 4 => barSet.half 61 | case 5 => barSet.fiveEighths 62 | case 6 => barSet.threeQuarters 63 | case 7 => barSet.sevenEighths 64 | case _ => barSet.full 65 | } 66 | buf 67 | .get(spark_area.left + i, spark_area.top + j) 68 | .setSymbol(symbol) 69 | .setStyle(style) 70 | 71 | if (d > 8) { 72 | data(i) -= 8 73 | } else { 74 | data(i) = 0 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/TabsWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | package tabs 4 | 5 | import tui.internal.ranges 6 | import tui.internal.saturating._ 7 | 8 | /** A widget to display available tabs in a multiple panels context. 9 | * 10 | * @param block 11 | * A block to wrap this widget in if necessary 12 | * @param titles 13 | * One title for each tab 14 | * @param selected 15 | * The index of the selected tabs 16 | * @param style 17 | * The style used to draw the text 18 | * @param highlightStyle 19 | * Style to apply to the selected item 20 | * @param divider 21 | * Tab divider 22 | */ 23 | case class TabsWidget( 24 | block: Option[BlockWidget] = None, 25 | titles: Array[Spans], 26 | selected: Int = 0, 27 | style: Style = Style.DEFAULT, 28 | highlightStyle: Style = Style.DEFAULT, 29 | divider: Span = Span.nostyle(symbols.line.VERTICAL) 30 | ) extends Widget { 31 | 32 | def render(area: Rect, buf: Buffer): Unit = { 33 | buf.setStyle(area, style) 34 | val tabs_area = block match { 35 | case Some(b) => 36 | val inner_area = b.inner(area) 37 | b.render(area, buf) 38 | inner_area 39 | case None => area 40 | } 41 | 42 | if (tabs_area.height < 1) { 43 | return 44 | } 45 | 46 | var x = tabs_area.left 47 | val titles_length = titles.length 48 | ranges.range(0, titles_length) { i => 49 | val title = titles(i) 50 | val last_title = titles_length - 1 == i 51 | x = x.saturating_add(1) 52 | val remaining_width = tabs_area.right.saturating_sub_unsigned(x) 53 | if (remaining_width == 0) { 54 | () 55 | } else { 56 | val pos = buf.setSpans(x, tabs_area.top, title, remaining_width) 57 | if (i == selected) { 58 | buf.setStyle( 59 | Rect( 60 | x, 61 | y = tabs_area.top, 62 | width = pos._1.saturating_sub_unsigned(x), 63 | height = 1 64 | ), 65 | highlightStyle 66 | ) 67 | } 68 | x = pos._1.saturating_add(1) 69 | val remaining_width1 = tabs_area.right.saturating_sub_unsigned(x) 70 | if (remaining_width1 == 0 || last_title) { 71 | () 72 | } else { 73 | val pos = buf.setSpan(x, tabs_area.top, divider, remaining_width1) 74 | x = pos._1 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/NativeLoader.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | import java.nio.file.Files; 4 | import java.nio.file.Path; 5 | 6 | class NativeLoader { 7 | public static void load(String nativeLibrary) throws Exception { 8 | try { 9 | System.loadLibrary(nativeLibrary); 10 | } catch (UnsatisfiedLinkError e) { 11 | loadPackaged(nativeLibrary); 12 | } 13 | } 14 | 15 | static void loadPackaged(String nativeLibrary) throws Exception { 16 | String lib = System.mapLibraryName("native-" + getPlatform() + "-" + nativeLibrary); 17 | var resourcePath = "/" + lib; 18 | var resourceStream = NativeLoader.class.getResourceAsStream(resourcePath); 19 | if (resourceStream == null) { 20 | throw new UnsatisfiedLinkError( 21 | "Native library " + lib + " (" + resourcePath + ") cannot be found on the classpath." 22 | ); 23 | } 24 | 25 | Path tmp = Files.createTempDirectory("jni-"); 26 | Path extractedPath = tmp.resolve(lib); 27 | 28 | try { 29 | Files.copy(resourceStream, extractedPath); 30 | } catch (Exception ex) { 31 | throw new UnsatisfiedLinkError("Error while extracting native library: " + ex.getMessage()); 32 | } 33 | 34 | System.load(extractedPath.toAbsolutePath().toString()); 35 | } 36 | 37 | private static String getPlatform() { 38 | if (System.getenv().containsKey("TUI_SCALA_PLATFORM")) { 39 | return System.getenv().get("TUI_SCALA_PLATFORM"); 40 | } 41 | String arch = System.getProperty("os.arch"); 42 | String name = System.getProperty("os.name"); 43 | String nameLower = name.toLowerCase(); 44 | boolean isAmd64 = arch.equals("x86_64") || arch.equals("amd64"); 45 | boolean isArm64 = arch.equals("aarch64") || arch.equals("arm64"); 46 | 47 | if (isAmd64 && nameLower.contains("win")) return "x86_64-windows"; 48 | if (isAmd64 && nameLower.contains("lin")) return "x86_64-linux"; 49 | if (isAmd64 && nameLower.contains("mac")) return "x86_64-darwin"; 50 | if (isArm64 && nameLower.contains("mac")) return "arm64-darwin"; 51 | throw new RuntimeException( 52 | "Platform detection does not understand os.name = " + name + " and os.arch = " + arch + ". " + 53 | "You can set environment variable TUI_SCALA_PLATFORM to x86_64-windows, x86_64-linux, x86_64-darwin, arm64-darwin to override. " + 54 | "Open an issue at https://github.com/oyvindberg/tui-scala/issues ." 55 | ); 56 | } 57 | } -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/Line.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | package canvas 4 | 5 | import tui.internal.ranges 6 | import tui.internal.saturating._ 7 | 8 | /** Shape to draw a line from (x1, y1) to (x2, y2) with the given color 9 | */ 10 | case class Line( 11 | x1: Double, 12 | y1: Double, 13 | x2: Double, 14 | y2: Double, 15 | color: Color 16 | ) extends Shape { 17 | override def draw(painter: Painter): Unit = { 18 | val (x1, y1) = painter.getPoint(this.x1, this.y1) match { 19 | case Some(c) => c 20 | case None => return 21 | } 22 | val (x2, y2) = painter.getPoint(this.x2, this.y2) match { 23 | case Some(c) => c 24 | case None => return 25 | } 26 | 27 | val (dx, x_range) = if (x2 >= x1) { 28 | (x2 - x1, Range.inclusive(x1, x2)) 29 | } else { 30 | (x1 - x2, Range.inclusive(x2, x1)) 31 | } 32 | val (dy, y_range) = if (y2 >= y1) { 33 | (y2 - y1, Range.inclusive(y1, y2)) 34 | } else { 35 | (y1 - y2, Range.inclusive(y2, y1)) 36 | } 37 | 38 | if (dx == 0) { 39 | y_range.foreach { y => 40 | painter.paint(x1, y, this.color); 41 | } 42 | } else if (dy == 0) { 43 | x_range.foreach { x => 44 | painter.paint(x, y1, this.color); 45 | } 46 | } else if (dy < dx) { 47 | if (x1 > x2) { 48 | Line.drawLineLow(painter, x2, y2, x1, y1, this.color) 49 | } else { 50 | Line.drawLineLow(painter, x1, y1, x2, y2, this.color) 51 | } 52 | } else if (y1 > y2) { 53 | Line.drawLineHigh(painter, x2, y2, x1, y1, this.color) 54 | } else { 55 | Line.drawLineHigh(painter, x1, y1, x2, y2, this.color) 56 | } 57 | } 58 | } 59 | 60 | object Line { 61 | def drawLineLow(painter: Painter, x1: Int, y1: Int, x2: Int, y2: Int, color: Color): Unit = { 62 | val dx = x2 - x1 63 | val dy = math.abs(y2 - y1) 64 | var d = 2 * dy - dx 65 | var y = y1 66 | ranges.range(x1, x2 + 1) { x => 67 | painter.paint(x, y, color) 68 | if (d > 0) { 69 | y = if (y1 > y2) { 70 | y.saturating_sub_unsigned(1) 71 | } else { 72 | y.saturating_add(1) 73 | } 74 | d -= 2 * dx 75 | } 76 | d += 2 * dy; 77 | } 78 | } 79 | 80 | def drawLineHigh(painter: Painter, x1: Int, y1: Int, x2: Int, y2: Int, color: Color): Unit = { 81 | val dx = math.abs(x2 - x1) 82 | val dy = y2 - y1 83 | var d = 2 * dx - dy 84 | var x = x1 85 | ranges.range(y1, y2 + 1) { y => 86 | painter.paint(x, y, color) 87 | if (d > 0) { 88 | x = if (x1 > x2) { 89 | x.saturating_sub_unsigned(1) 90 | } else { 91 | x.saturating_add(1) 92 | } 93 | d -= 2 * dy 94 | } 95 | d += 2 * dx; 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Text.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import scala.collection.Factory 4 | import scala.jdk.StreamConverters.StreamHasToScala 5 | 6 | /** Primitives for styled text. 7 | * 8 | * A terminal UI is at its root a lot of strings. In order to make it accessible and stylish, those strings may be associated to a set of styles. `tui` has 9 | * three ways to represent them: 10 | * - A single line string where all graphemes have the same style is represented by a `Span`. 11 | * - A single line string where each grapheme may have its own style is represented by `Spans`. 12 | * - A multiple line string where each grapheme may have its own style is represented by a `Text`. 13 | * 14 | * These types form a hierarchy: `Spans` is a collection of `Span` and each line of `Text` is a `Spans`. 15 | * 16 | * Keep it mind that a lot of widgets will use those types to advertise what kind of string is supported for their properties. Moreover, `tui` provides 17 | * convenient `From` implementations so that you can start by using simple `String` or `&str` and then promote them to the previous primitives when you need 18 | * additional styling capabilities. 19 | * 20 | * For example, for the `Block` widget, all the following calls are valid to set its `title` property (which is a `Spans` under the hood): 21 | * 22 | * A string split over multiple lines where each line is composed of several clusters, each with their own style. 23 | * 24 | * A `Text`, like a `Span`, can be constructed using one of the many `From` implementations or via the `Text.unstyled` and `Text.styled` methods. Helpfully, 25 | * `Text` also implements `Extend` which enables the concatenation of several `Text` blocks. 26 | */ 27 | case class Text(lines: Array[Spans]) { 28 | 29 | /** Returns the max width of all the lines. 30 | */ 31 | def width: Int = 32 | lines 33 | .map(_.width) 34 | .maxOption 35 | .getOrElse(0) 36 | 37 | /** Returns the height. 38 | */ 39 | def height: Int = 40 | lines.length 41 | 42 | /** Apply a new style to existing text. 43 | */ 44 | def overwrittenStyle(style: Style): Text = 45 | Text(lines.map { case Spans(spans) => 46 | Spans(spans.map { span => 47 | span.copy(style = span.style.patch(style)) 48 | }) 49 | }) 50 | } 51 | 52 | object Text { 53 | 54 | /** Create some text (potentially multiple lines) with no style. 55 | */ 56 | def nostyle(content: String): Text = 57 | Text(content.lines().map(Spans.nostyle).toScala(Factory.arrayFactory[Spans])) 58 | def from(span: Span): Text = 59 | from(Spans(Array(span))) 60 | def from(spans: Span*): Text = 61 | from(Spans(spans.toArray)) 62 | def from(spans: Spans): Text = 63 | Text(lines = Array(spans)) 64 | def fromSpans(spans: Spans*): Text = 65 | Text(lines = spans.toArray) 66 | } 67 | -------------------------------------------------------------------------------- /scripts/src/scala/tui/scripts/Publish.scala: -------------------------------------------------------------------------------- 1 | package tui.scripts 2 | 3 | import bleep._ 4 | import bleep.packaging.{packageLibraries, CoordinatesFor, PackagedLibrary, PublishLayout} 5 | import bleep.plugin.cirelease.CiReleasePlugin 6 | import bleep.plugin.dynver.DynVerPlugin 7 | import bleep.plugin.nosbt.InteractionService 8 | import bleep.plugin.pgp.PgpPlugin 9 | import bleep.plugin.sonatype.Sonatype 10 | import coursier.Info 11 | 12 | import scala.collection.immutable.SortedMap 13 | 14 | object Publish extends BleepScript("Publish") { 15 | 16 | def run(started: Started, commands: Commands, args: List[String]): Unit = { 17 | commands.compile(started.build.explodedProjects.keys.filter(projectsToPublish).toList) 18 | 19 | val dynVer = new DynVerPlugin(baseDirectory = started.buildPaths.buildDir.toFile, dynverSonatypeSnapshots = true) 20 | val pgp = new PgpPlugin( 21 | logger = started.logger, 22 | maybeCredentials = None, 23 | interactionService = InteractionService.DoesNotMaskYourPasswordExclamationOneOne 24 | ) 25 | val sonatype = new Sonatype( 26 | logger = started.logger, 27 | sonatypeBundleDirectory = started.buildPaths.dotBleepDir / "sonatype-bundle", 28 | sonatypeProfileName = "com.olvind", 29 | bundleName = "tui", 30 | version = dynVer.version, 31 | sonatypeCredentialHost = Sonatype.sonatypeLegacy 32 | ) 33 | val ciRelease = new CiReleasePlugin(started.logger, sonatype, dynVer, pgp) 34 | 35 | started.logger.info(dynVer.version) 36 | 37 | val info = Info( 38 | description = "TUI for scala", 39 | homePage = "https://github.com/oyvindberg/tui-scala/", 40 | developers = List( 41 | Info.Developer( 42 | "oyvindberg", 43 | "Øyvind Raddum Berg", 44 | "https://github.com/oyvindberg" 45 | ) 46 | ), 47 | publication = None, 48 | scm = CiReleasePlugin.inferScmInfo, 49 | licenseInfo = List( 50 | Info.License( 51 | "MIT", 52 | Some("http://opensource.org/licenses/MIT"), 53 | distribution = Some("repo"), 54 | comments = None 55 | ) 56 | ) 57 | ) 58 | 59 | val packagedLibraries: SortedMap[model.CrossProjectName, PackagedLibrary] = 60 | packageLibraries( 61 | started, 62 | coordinatesFor = CoordinatesFor.Default(groupId = groupId, version = dynVer.version), 63 | shouldInclude = projectsToPublish, 64 | publishLayout = PublishLayout.Maven(info) 65 | ) 66 | 67 | val files: Map[RelPath, Array[Byte]] = 68 | packagedLibraries.flatMap { case (_, PackagedLibrary(_, files)) => files.all } 69 | 70 | files.foreach { case (path, bytes) => 71 | started.logger.withContext(path)(_.asString).withContext(bytes.length).debug("will publish") 72 | } 73 | ciRelease.ciRelease(files) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Borders.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** Bitflags that can be composed to set the visible borders essentially on the block widget. 4 | */ 5 | case class Borders(bits: Int) { 6 | def fmt(sb: StringBuilder): Unit = { 7 | var first = true 8 | if (Borders.NONE.contains(this)) { 9 | if (!first) { 10 | sb.append(" | ") 11 | } 12 | first = false 13 | sb.append("NONE") 14 | } 15 | if (Borders.TOP.contains(this)) { 16 | if (!first) { 17 | sb.append(" | ") 18 | } 19 | first = false 20 | sb.append("TOP") 21 | } 22 | if (Borders.RIGHT.contains(this)) { 23 | if (!first) { 24 | sb.append(" | ") 25 | } 26 | first = false 27 | sb.append("RIGHT") 28 | } 29 | if (Borders.BOTTOM.contains(this)) { 30 | if (!first) { 31 | sb.append(" | ") 32 | } 33 | first = false 34 | sb.append("BOTTOM") 35 | } 36 | if (Borders.LEFT.contains(this)) { 37 | if (!first) { 38 | sb.append(" | ") 39 | } 40 | first = false 41 | sb.append("LEFT") 42 | } 43 | if (first) { 44 | sb.append("(empty)") 45 | } 46 | () 47 | } 48 | 49 | override def toString: String = { 50 | val sb = new StringBuilder() 51 | fmt(sb) 52 | sb.toString() 53 | } 54 | 55 | /** Returns `true` if all of the flags in `other` are contained within `self`. 56 | */ 57 | def contains(other: Borders): Boolean = 58 | other != Borders.EMPTY && (bits & other.bits) == other.bits 59 | 60 | def intersects(other: Borders): Boolean = 61 | Borders.EMPTY.bits != (bits & other.bits) 62 | 63 | /** Inserts the specified flags in-place. 64 | */ 65 | def insert(other: Borders): Borders = 66 | copy(bits = bits | other.bits) 67 | 68 | /** Removes the specified flags in-place. 69 | */ 70 | def remove(other: Borders): Borders = 71 | copy(bits = bits & Integer.reverse(other.bits)) 72 | 73 | def |(other: Borders): Borders = 74 | copy(bits = bits | other.bits) 75 | 76 | def -(other: Borders): Borders = 77 | remove(other) 78 | } 79 | 80 | object Borders { 81 | 82 | /** Show no border (default) 83 | */ 84 | val NONE: Borders = Borders(1 << 0) 85 | 86 | /** Show the top border 87 | */ 88 | val TOP: Borders = Borders(1 << 1) 89 | 90 | /** Show the right border 91 | */ 92 | val RIGHT: Borders = Borders(1 << 2) 93 | 94 | /** Show the bottom border 95 | */ 96 | val BOTTOM: Borders = Borders(1 << 3) 97 | 98 | /** Show the left border 99 | */ 100 | val LEFT: Borders = Borders(1 << 4) 101 | 102 | /** Returns an empty set of flags. 103 | */ 104 | val EMPTY: Borders = Borders(bits = 0) 105 | 106 | /** Show all borders 107 | */ 108 | val ALL: Borders = List(TOP, RIGHT, BOTTOM, LEFT).reduce(_ | _) 109 | } 110 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/PopupExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.widgets._ 5 | 6 | object PopupExample { 7 | case class App( 8 | var show_popup: Boolean = false 9 | ) 10 | 11 | def main(args: Array[String]): Unit = 12 | withTerminal { (jni, terminal) => 13 | val app = App() 14 | run_app(terminal, app, jni); 15 | 16 | } 17 | def run_app( 18 | terminal: Terminal, 19 | app: App, 20 | jni: tui.crossterm.CrosstermJni 21 | ): Unit = 22 | while (true) { 23 | terminal.draw(f => ui(f, app)) 24 | 25 | jni.read() match { 26 | case key: tui.crossterm.Event.Key => 27 | key.keyEvent.code match { 28 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 29 | case char: tui.crossterm.KeyCode.Char if char.c() == 'p' => app.show_popup = !app.show_popup 30 | case _ => () 31 | } 32 | case _ => () 33 | } 34 | } 35 | 36 | def ui(f: Frame, app: App): Unit = { 37 | val size = f.size 38 | 39 | val chunks = Layout(constraints = Array(Constraint.Percentage(20), Constraint.Percentage(80))) 40 | .split(size) 41 | 42 | val text = if (app.show_popup) { "Press p to close the popup" } 43 | else { "Press p to show the popup" } 44 | val paragraph = ParagraphWidget( 45 | text = Text.from(Span.styled(text, Style(addModifier = Modifier.SLOW_BLINK))), 46 | alignment = Alignment.Center, 47 | wrap = Some(ParagraphWidget.Wrap(trim = true)) 48 | ) 49 | f.renderWidget(paragraph, chunks(0)) 50 | 51 | val block = BlockWidget( 52 | title = Some(Spans.nostyle("Content")), 53 | borders = Borders.ALL, 54 | style = Style.DEFAULT.bg(Color.Blue) 55 | ) 56 | 57 | f.renderWidget(block, chunks(1)) 58 | 59 | if (app.show_popup) { 60 | val block = BlockWidget(title = Some(Spans.nostyle("Popup")), borders = Borders.ALL) 61 | val area = centered_rect(60, 20, size) 62 | f.renderWidget(ClearWidget, area); // this clears out the background 63 | f.renderWidget(block, area) 64 | } 65 | } 66 | 67 | /// helper function to create a centered rect using up certain percentage of the available rect `r` 68 | def centered_rect(percent_x: Int, percent_y: Int, r: Rect): Rect = { 69 | val popup_layout = Layout( 70 | direction = Direction.Vertical, 71 | constraints = Array( 72 | Constraint.Percentage((100 - percent_y) / 2), 73 | Constraint.Percentage(percent_y), 74 | Constraint.Percentage((100 - percent_y) / 2) 75 | ) 76 | ) 77 | .split(r) 78 | 79 | Layout( 80 | direction = Direction.Horizontal, 81 | constraints = Array( 82 | Constraint.Percentage((100 - percent_x) / 2), 83 | Constraint.Percentage(percent_x), 84 | Constraint.Percentage((100 - percent_x) / 2) 85 | ) 86 | ) 87 | .split(popup_layout(1))(1) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/TabsExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.crossterm.CrosstermJni 5 | import tui.widgets.tabs.TabsWidget 6 | import tui.widgets.BlockWidget 7 | 8 | object TabsExample { 9 | case class App( 10 | titles: Array[String], 11 | var index: Int = 0 12 | ) { 13 | 14 | def next(): Unit = 15 | index = (index + 1) % titles.length 16 | 17 | def previous(): Unit = 18 | if (index > 0) { 19 | index -= 1 20 | } else { 21 | index = titles.length - 1 22 | } 23 | } 24 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 25 | // create app and run it 26 | val app = App(titles = Array("Tab0", "Tab1", "Tab2", "Tab3")) 27 | run_app(terminal, app, jni); 28 | } 29 | 30 | def run_app(terminal: Terminal, app: App, jni: CrosstermJni): Unit = 31 | while (true) { 32 | terminal.draw(f => ui(f, app)) 33 | 34 | jni.read() match { 35 | case key: tui.crossterm.Event.Key => 36 | key.keyEvent.code match { 37 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 38 | case _: tui.crossterm.KeyCode.Right => app.next() 39 | case _: tui.crossterm.KeyCode.Left => app.previous() 40 | case _ => () 41 | } 42 | case _ => () 43 | } 44 | } 45 | 46 | def ui(f: Frame, app: App): Unit = { 47 | val chunks = Layout( 48 | direction = Direction.Vertical, 49 | margin = Margin(5, 5), 50 | constraints = Array(Constraint.Length(3), Constraint.Min(0)) 51 | ).split(f.size) 52 | 53 | val block = BlockWidget(style = Style(bg = Some(Color.White), fg = Some(Color.Black))) 54 | f.renderWidget(block, f.size) 55 | val titles = app.titles 56 | .map { t => 57 | val (first, rest) = t.splitAt(1) 58 | Spans.from( 59 | Span.styled(first, Style(fg = Some(Color.Yellow))), 60 | Span.styled(rest, Style(fg = Some(Color.Green))) 61 | ) 62 | } 63 | 64 | val tabs = TabsWidget( 65 | titles = titles, 66 | block = Some(BlockWidget(borders = Borders.ALL, title = Some(Spans.nostyle("Tabs")))), 67 | selected = app.index, 68 | style = Style(fg = Some(Color.Cyan)), 69 | highlightStyle = Style(addModifier = Modifier.BOLD, bg = Some(Color.Black)) 70 | ) 71 | f.renderWidget(tabs, chunks(0)) 72 | val inner = app.index match { 73 | case 0 => BlockWidget(title = Some(Spans.nostyle("Inner 0")), borders = Borders.ALL) 74 | case 1 => BlockWidget(title = Some(Spans.nostyle("Inner 1")), borders = Borders.ALL) 75 | case 2 => BlockWidget(title = Some(Spans.nostyle("Inner 2")), borders = Borders.ALL) 76 | case 3 => BlockWidget(title = Some(Spans.nostyle("Inner 3")), borders = Borders.ALL) 77 | case _ => sys.error("unreachable") 78 | } 79 | f.renderWidget(inner, chunks(1)) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/canvas/CanvasWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | package canvas 4 | 5 | import tui.Style 6 | import tui.internal.ranges 7 | 8 | /** The Canvas widget may be used to draw more detailed figures using braille patterns (each cell can have a braille character in 8 different positions). 9 | * 10 | * @param block 11 | * @param xBounds 12 | * @param yBounds 13 | * @param backgroundColor 14 | * @param marker 15 | * Change the type of points used to draw the shapes. By default the braille patterns are used as they provide a more fine grained result but you might want 16 | * to use the simple dot or block instead if the targeted terminal does not support those symbols. 17 | * @param painter 18 | */ 19 | case class CanvasWidget( 20 | block: Option[BlockWidget] = None, 21 | xBounds: Point = Point.Zero, 22 | yBounds: Point = Point.Zero, 23 | backgroundColor: Color = Color.Reset, 24 | marker: symbols.Marker = symbols.Marker.Braille 25 | )(painter: Context => Unit) 26 | extends Widget { 27 | override def render(area: Rect, buf: Buffer): Unit = { 28 | val canvas_area = this.block match { 29 | case Some(b) => 30 | val inner_area = b.inner(area) 31 | b.render(area, buf) 32 | inner_area 33 | case None => area 34 | } 35 | 36 | buf.setStyle(canvas_area, Style.DEFAULT.bg(this.backgroundColor)) 37 | 38 | // Create a blank context that match the size of the canvas 39 | val ctx = Context( 40 | canvas_area.width, 41 | canvas_area.height, 42 | this.xBounds, 43 | this.yBounds, 44 | this.marker 45 | ) 46 | // Paint to this context 47 | painter(ctx) 48 | ctx.finish() 49 | 50 | // Retreive painted points for each layer 51 | ctx.layers.foreach { layer => 52 | ranges.range(0, math.min(layer.string.length, layer.colors.length)) { i => 53 | val ch = layer.string.charAt(i) 54 | val color = layer.colors(i) 55 | if (ch != ' ' && ch != '\u2800') { 56 | val (x, y) = (i % canvas_area.width, i / canvas_area.width) 57 | buf 58 | .get(x + canvas_area.left, y + canvas_area.top) 59 | .setChar(ch) 60 | .setFg(color) 61 | () 62 | } 63 | } 64 | } 65 | 66 | // Finally draw the labels 67 | val left = this.xBounds.x 68 | val right = this.xBounds.y 69 | val top = this.yBounds.y 70 | val bottom = this.yBounds.x 71 | val width = math.abs(this.xBounds.y - this.xBounds.x) 72 | val height = math.abs(this.yBounds.y - this.yBounds.x) 73 | val resolution = { 74 | val width = (canvas_area.width - 1).toDouble 75 | val height = (canvas_area.height - 1).toDouble 76 | (width, height) 77 | } 78 | ranges.range(0, ctx.labels.length) { i => 79 | val l = ctx.labels(i) 80 | if (l.x >= left && l.x <= right && l.y <= top && l.y >= bottom) { 81 | val label = l 82 | val x = ((label.x - left) * resolution._1 / width).toInt + canvas_area.left 83 | val y = ((top - label.y) * resolution._2 / height).toInt + canvas_area.top 84 | buf.setSpans(x, y, label.spans, canvas_area.right - x) 85 | () 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/GaugeWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | import tui.Style 5 | import tui.internal.ranges 6 | 7 | /** A widget to display a task progress. 8 | */ 9 | case class GaugeWidget( 10 | block: Option[BlockWidget] = None, 11 | ratio: GaugeWidget.Ratio = GaugeWidget.Ratio.Zero, 12 | label: Option[Span] = None, 13 | useUnicode: Boolean = false, 14 | style: Style = Style.DEFAULT, 15 | gaugeStyle: Style = Style.DEFAULT 16 | ) extends Widget { 17 | 18 | override def render(area: Rect, buf: Buffer): Unit = { 19 | buf.setStyle(area, style) 20 | val gauge_area = block match { 21 | case Some(b) => 22 | val inner_area = b.inner(area) 23 | b.render(area, buf) 24 | inner_area 25 | 26 | case None => area 27 | } 28 | buf.setStyle(gauge_area, gaugeStyle) 29 | if (gauge_area.height < 1) { 30 | return 31 | } 32 | 33 | // compute label value and its position 34 | // label is put at the center of the gauge_area 35 | val label = { 36 | val pct = math.round(ratio.value * 100.0) 37 | this.label.getOrElse(Span.nostyle(pct.toString + "%")) 38 | } 39 | val clamped_label_width = gauge_area.width.min(label.width) 40 | val label_col = gauge_area.left + (gauge_area.width - clamped_label_width) / 2 41 | val label_row = gauge_area.top + gauge_area.height / 2 42 | 43 | // the gauge will be filled proportionally to the ratio 44 | val filled_width = gauge_area.width.toDouble * this.ratio.value 45 | val end: Int = if (useUnicode) { 46 | gauge_area.left + math.floor(filled_width).toInt 47 | } else { 48 | gauge_area.left + math.round(filled_width).toInt 49 | } 50 | ranges.range(gauge_area.top, gauge_area.bottom) { y => 51 | // render the filled area (left to end) 52 | ranges.range(gauge_area.left, end) { x => 53 | // spaces are needed to apply the background styling 54 | buf 55 | .get(x, y) 56 | .setSymbol(" ") 57 | .setFg(gaugeStyle.bg.getOrElse(Color.Reset)) 58 | .setBg(gaugeStyle.fg.getOrElse(Color.Reset)) 59 | () 60 | } 61 | if (useUnicode && ratio.value < 1.0) { 62 | buf 63 | .get(end, y) 64 | .setSymbol(getUnicodeBlock(filled_width % 1.0)) 65 | () 66 | } 67 | } 68 | // set the span 69 | buf.setSpan(label_col, label_row, label, clamped_label_width) 70 | () 71 | } 72 | 73 | def getUnicodeBlock(frac: Double): String = 74 | math.round(frac * 8.0) match { 75 | case 1 => symbols.block.ONE_EIGHTH 76 | case 2 => symbols.block.ONE_QUARTER 77 | case 3 => symbols.block.THREE_EIGHTHS 78 | case 4 => symbols.block.HALF 79 | case 5 => symbols.block.FIVE_EIGHTHS 80 | case 6 => symbols.block.THREE_QUARTERS 81 | case 7 => symbols.block.SEVEN_EIGHTHS 82 | case 8 => symbols.block.FULL 83 | case _ => " " 84 | } 85 | } 86 | 87 | object GaugeWidget { 88 | case class Ratio(value: Double) { 89 | require(value >= 0 && value <= 1, s"$value is not between 0 and 1") 90 | } 91 | 92 | object Ratio { 93 | val Zero = new Ratio(0.0) 94 | 95 | def percent(value: Double): Ratio = 96 | new Ratio(value / 100) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /crossterm/cargo/src/api.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryInto; 2 | use std::io::{stdout, Write}; 3 | use std::time::Duration; 4 | 5 | use crossterm::{cursor, event, terminal}; 6 | use jni::{ 7 | JNIEnv, 8 | objects::{JClass, JObject}, 9 | sys::{jboolean, jobject}, 10 | }; 11 | 12 | use crate::{ 13 | jni_from_jvm, 14 | jni_to_jvm, 15 | jvm_unwrapper::JvmUnwrapper, 16 | unify_errors::UnifyErrors, 17 | }; 18 | 19 | #[no_mangle] 20 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_terminalSize( 21 | env: JNIEnv, 22 | _class: JClass, 23 | ) -> jobject { 24 | let xy = terminal::size().unify_errors().and_then(|(x, y)| jni_to_jvm::xy(env, x, y).unify_errors()); 25 | return xy.jvm_unwrap(env).into_raw(); 26 | } 27 | 28 | #[no_mangle] 29 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_cursorPosition( 30 | env: JNIEnv, 31 | _class: JClass, 32 | ) -> jobject { 33 | let (x, y) = cursor::position().jvm_unwrap(env); 34 | let result = jni_to_jvm::xy(env, x, y); 35 | return result.jvm_unwrap(env).into_raw(); 36 | } 37 | 38 | #[no_mangle] 39 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_flush(env: JNIEnv, _class: JClass) { 40 | std::io::stdout().flush().jvm_unwrap(env); 41 | } 42 | 43 | #[no_mangle] 44 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_disableRawMode( 45 | env: JNIEnv, 46 | _class: JClass, 47 | ) { 48 | terminal::disable_raw_mode().jvm_unwrap(env) 49 | } 50 | 51 | #[no_mangle] 52 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_enableRawMode(env: JNIEnv, _class: JClass) { 53 | terminal::enable_raw_mode().jvm_unwrap(env) 54 | } 55 | 56 | #[no_mangle] 57 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_poll( 58 | env: JNIEnv, 59 | _class: JClass, 60 | timeout: JObject, 61 | ) -> jboolean { 62 | let secs_jlong = env.get_field(timeout, "secs", "J").and_then(|x| x.j()).jvm_unwrap(env); 63 | let nanos_jint = env.get_field(timeout, "nanos", "I").and_then(|x| x.i()).jvm_unwrap(env); 64 | let duration = Duration::new( 65 | secs_jlong.try_into().unwrap_or_default(), 66 | nanos_jint.try_into().unwrap_or_default(), 67 | ); 68 | let res = event::poll(duration).jvm_unwrap(env); 69 | return res.into(); 70 | } 71 | 72 | #[no_mangle] 73 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_read( 74 | env: JNIEnv, 75 | _class: JClass, 76 | ) -> jobject { 77 | let e = event::read().unify_errors().and_then(|e| jni_to_jvm::event(env, e).unify_errors()); 78 | return e.jvm_unwrap(env).into_raw(); 79 | } 80 | 81 | #[no_mangle] 82 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_enqueue( 83 | env: JNIEnv, 84 | _class: JClass, 85 | commands_list_object: JObject, 86 | ) { 87 | let mut stdout1 = stdout(); 88 | let writer = stdout1.by_ref(); 89 | jni_from_jvm::queue_commands(writer, env, commands_list_object).jvm_unwrap(env); 90 | } 91 | 92 | #[no_mangle] 93 | pub extern "system" fn Java_tui_crossterm_CrosstermJni_execute( 94 | env: JNIEnv, 95 | _class: JClass, 96 | commands_list_object: JObject, 97 | ) { 98 | let mut stdout1 = stdout(); 99 | let writer = stdout1.by_ref(); 100 | jni_from_jvm::queue_commands(writer, env, commands_list_object).jvm_unwrap(env); 101 | writer.flush().jvm_unwrap(env); 102 | } 103 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/BlockExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.crossterm.CrosstermJni 5 | import tui.widgets.BlockWidget 6 | 7 | object BlockExample { 8 | def main(args: Array[String]): Unit = withTerminal((jni, terminal) => run_app(terminal, jni)) 9 | 10 | def run_app(terminal: Terminal, jni: CrosstermJni): Unit = 11 | while (true) { 12 | terminal.draw(f => ui(f)) 13 | jni.read() match { 14 | case key: tui.crossterm.Event.Key => 15 | key.keyEvent.code match { 16 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 17 | case _ => () 18 | } 19 | case _ => () 20 | } 21 | } 22 | 23 | def ui(f: Frame): Unit = { 24 | // Wrapping block for a group 25 | // Just draw the block and the group on the same area and build the group 26 | // with at least a margin of 1 27 | val size = f.size 28 | 29 | // Surrounding block 30 | val block0 = BlockWidget( 31 | borders = Borders.ALL, 32 | title = Some(Spans.nostyle("Main block with round corners")), 33 | titleAlignment = Alignment.Center, 34 | borderType = BlockWidget.BorderType.Rounded 35 | ) 36 | f.renderWidget(block0, size) 37 | 38 | val chunks = Layout( 39 | direction = Direction.Vertical, 40 | margin = Margin(4), 41 | constraints = Array(Constraint.Percentage(50), Constraint.Percentage(50)) 42 | ) 43 | .split(f.size) 44 | 45 | // Top two inner blocks 46 | val top_chunks = Layout( 47 | direction = Direction.Horizontal, 48 | constraints = Array(Constraint.Percentage(50), Constraint.Percentage(50)) 49 | ) 50 | .split(chunks(0)) 51 | 52 | // Top left inner block with green background 53 | val block_top0 = BlockWidget( 54 | title = Some( 55 | Spans.from( 56 | Span.styled("With", Style.DEFAULT.fg(Color.Yellow)), 57 | Span.nostyle(" background") 58 | ) 59 | ), 60 | style = Style.DEFAULT.bg(Color.Green) 61 | ) 62 | f.renderWidget(block_top0, top_chunks(0)) 63 | 64 | // Top right inner block with styled title aligned to the right 65 | val block_top1 = BlockWidget( 66 | title = Some(Spans.from(Span.styled("Styled title", Style(fg = Some(Color.White), bg = Some(Color.Red), addModifier = Modifier.BOLD)))), 67 | titleAlignment = Alignment.Right 68 | ) 69 | f.renderWidget(block_top1, top_chunks(1)) 70 | 71 | // Bottom two inner blocks 72 | val bottom_chunks = Layout( 73 | direction = Direction.Horizontal, 74 | constraints = Array(Constraint.Percentage(50), Constraint.Percentage(50)) 75 | ) 76 | .split(chunks(1)) 77 | 78 | // Bottom left block with all default borders 79 | val block_bottom_0 = BlockWidget(title = Some(Spans.nostyle("With borders")), borders = Borders.ALL) 80 | f.renderWidget(block_bottom_0, bottom_chunks(0)) 81 | 82 | // Bottom right block with styled left and right border 83 | val block_bottom_1 = BlockWidget( 84 | title = Some(Spans.nostyle("With styled borders and doubled borders")), 85 | borderStyle = Style.DEFAULT.fg(Color.Cyan), 86 | borders = Borders.LEFT | Borders.RIGHT, 87 | borderType = BlockWidget.BorderType.Double 88 | ) 89 | f.renderWidget(block_bottom_1, bottom_chunks(1)) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Modifier.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | /** Modifier changes the way a piece of text is displayed. 4 | * 5 | * They are bitflags so they can easily be composed. 6 | */ 7 | case class Modifier(bits: Int) { 8 | def fmt(sb: StringBuilder): Unit = { 9 | var first = true 10 | if (Modifier.BOLD.contains(this)) { 11 | if (!first) { 12 | sb.append(" | ") 13 | } 14 | first = false 15 | sb.append("BOLD") 16 | } 17 | if (Modifier.DIM.contains(this)) { 18 | if (!first) { 19 | sb.append(" | ") 20 | } 21 | first = false 22 | sb.append("DIM") 23 | } 24 | if (Modifier.ITALIC.contains(this)) { 25 | if (!first) { 26 | sb.append(" | ") 27 | } 28 | first = false 29 | sb.append("ITALIC") 30 | } 31 | if (Modifier.UNDERLINED.contains(this)) { 32 | if (!first) { 33 | sb.append(" | ") 34 | } 35 | first = false 36 | sb.append("UNDERLINED") 37 | } 38 | if (Modifier.SLOW_BLINK.contains(this)) { 39 | if (!first) { 40 | sb.append(" | ") 41 | } 42 | first = false 43 | sb.append("SLOW_BLINK") 44 | } 45 | if (Modifier.RAPID_BLINK.contains(this)) { 46 | if (!first) { 47 | sb.append(" | ") 48 | } 49 | first = false 50 | sb.append("RAPID_BLINK") 51 | } 52 | if (Modifier.REVERSED.contains(this)) { 53 | if (!first) { 54 | sb.append(" | ") 55 | } 56 | first = false 57 | sb.append("REVERSED") 58 | } 59 | if (Modifier.HIDDEN.contains(this)) { 60 | if (!first) { 61 | sb.append(" | ") 62 | } 63 | first = false 64 | sb.append("HIDDEN") 65 | } 66 | if (Modifier.CROSSED_OUT.contains(this)) { 67 | if (!first) { 68 | sb.append(" | ") 69 | } 70 | first = false 71 | sb.append("CROSSED_OUT") 72 | } 73 | if (first) { 74 | sb.append("(empty)") 75 | } 76 | () 77 | } 78 | 79 | override def toString: String = { 80 | val sb = new StringBuilder() 81 | fmt(sb) 82 | sb.toString() 83 | } 84 | 85 | /** Returns `true` if all of the flags in `other` are contained within `self`. 86 | */ 87 | def contains(other: Modifier): Boolean = 88 | other != Modifier.EMPTY && (bits & other.bits) == other.bits 89 | 90 | /** Inserts the specified flags in-place. 91 | */ 92 | def insert(other: Modifier): Modifier = 93 | copy(bits = bits | other.bits) 94 | 95 | /** Removes the specified flags in-place. 96 | */ 97 | def remove(other: Modifier): Modifier = 98 | copy(bits = bits & ~other.bits) 99 | 100 | def |(mod: Modifier): Modifier = 101 | insert(mod) 102 | 103 | def -(mod: Modifier): Modifier = 104 | remove(mod) 105 | } 106 | 107 | object Modifier { 108 | val BOLD: Modifier = Modifier(bits = 1 << 0) 109 | val DIM: Modifier = Modifier(bits = 1 << 1) 110 | val ITALIC: Modifier = Modifier(bits = 1 << 2) 111 | val UNDERLINED: Modifier = Modifier(bits = 1 << 3) 112 | val SLOW_BLINK: Modifier = Modifier(bits = 1 << 4) 113 | val RAPID_BLINK: Modifier = Modifier(bits = 1 << 5) 114 | val REVERSED: Modifier = Modifier(bits = 1 << 6) 115 | val HIDDEN: Modifier = Modifier(bits = 1 << 7) 116 | val CROSSED_OUT: Modifier = Modifier(bits = 1 << 8) 117 | /// Returns an empty set of flags. 118 | val EMPTY: Modifier = Modifier(bits = 0) 119 | /// Returns the set containing all flags. 120 | val ALL: Modifier = Modifier(bits = Integer.parseInt("000111111111", 2)) 121 | } 122 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/ParagraphWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | import tui.internal.saturating._ 5 | import tui.internal.reflow.{LineComposer, LineTruncator, WordWrapper} 6 | 7 | /** A widget to display some text. 8 | * 9 | * @param text 10 | * The text to display 11 | * @param block 12 | * A block to wrap the widget in 13 | * @param style 14 | * Widget style 15 | * @param wrap 16 | * How to wrap the text 17 | * @param scroll 18 | * Scroll 19 | * @param alignment 20 | * Alignment of the text 21 | */ 22 | case class ParagraphWidget( 23 | text: Text, 24 | block: Option[BlockWidget] = None, 25 | style: Style = Style.DEFAULT, 26 | wrap: Option[ParagraphWidget.Wrap] = None, 27 | scroll: (Int, Int) = (0, 0), 28 | alignment: Alignment = Alignment.Left 29 | ) extends Widget { 30 | 31 | def render(area: Rect, buf: Buffer): Unit = { 32 | buf.setStyle(area, this.style) 33 | val text_area = block match { 34 | case Some(b) => 35 | val inner_area = b.inner(area) 36 | b.render(area, buf) 37 | inner_area 38 | case None => area 39 | } 40 | 41 | if (text_area.height < 1) { 42 | return 43 | } 44 | 45 | val styled: Array[StyledGrapheme] = 46 | text.lines.flatMap { case Spans(spans: Array[Span]) => 47 | spans.flatMap(span => span.styledGraphemes(this.style)) :+ StyledGrapheme(Grapheme("\n"), this.style) 48 | } 49 | 50 | val line_composer: LineComposer = 51 | wrap match { 52 | case Some(ParagraphWidget.Wrap(trim)) => WordWrapper(styled.iterator, text_area.width, trim) 53 | case None => 54 | val line_composer = LineTruncator(styled.iterator, text_area.width) 55 | alignment match { 56 | case Alignment.Left => line_composer.copy(horizontal_offset = this.scroll._2) 57 | case _ => line_composer 58 | } 59 | } 60 | 61 | var y = 0 62 | var continue = true 63 | while (continue) 64 | line_composer.next_line() match { 65 | case None => continue = false 66 | case Some((current_line, current_line_width)) => 67 | if (y >= this.scroll._1) { 68 | var x = ParagraphWidget.getLineOffset(current_line_width, text_area.width, this.alignment) 69 | current_line.foreach { case StyledGrapheme(symbol, style) => 70 | // If the symbol is empty, the last char which rendered last time will 71 | // leave on the line. It's a quick fix. 72 | val newSymbol = if (symbol.str.isEmpty) " " else symbol.str 73 | 74 | buf 75 | .get(text_area.left + x, text_area.top + y - this.scroll._1) 76 | .setSymbol(newSymbol) 77 | .setStyle(style) 78 | 79 | x += symbol.width 80 | } 81 | } 82 | y += 1 83 | if (y >= text_area.height + this.scroll._1) { 84 | continue = false 85 | } 86 | } 87 | } 88 | } 89 | object ParagraphWidget { 90 | def getLineOffset(lineWidth: Int, textAreaWidth: Int, alignment: Alignment): Int = 91 | alignment match { 92 | case Alignment.Center => (textAreaWidth / 2).saturating_sub_unsigned(lineWidth / 2) 93 | case Alignment.Right => textAreaWidth.saturating_sub_unsigned(lineWidth) 94 | case Alignment.Left => 0 95 | } 96 | 97 | /** Describes how to wrap text across lines. 98 | * 99 | * @param trim 100 | * Should leading whitespace be trimmed 101 | */ 102 | case class Wrap( 103 | trim: Boolean 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/Color.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | public sealed interface Color 4 | permits Color.Reset, 5 | Color.Black, 6 | Color.DarkGrey, 7 | Color.Red, 8 | Color.DarkRed, 9 | Color.Green, 10 | Color.DarkGreen, 11 | Color.Yellow, 12 | Color.DarkYellow, 13 | Color.Blue, 14 | Color.DarkBlue, 15 | Color.Magenta, 16 | Color.DarkMagenta, 17 | Color.Cyan, 18 | Color.DarkCyan, 19 | Color.White, 20 | Color.Grey, 21 | Color.Rgb, 22 | Color.AnsiValue { 23 | 24 | /// Represents a color. 25 | /// 26 | /// # Platform-specific Notes 27 | /// 28 | /// The following list of 16 base colors are available for almost all terminals (Windows 7 and 8 included). 29 | /// 30 | /// | Light | Dark | 31 | /// | :--------- | :------------ | 32 | /// | `DarkGrey` | `Black` | 33 | /// | `Red` | `DarkRed` | 34 | /// | `Green` | `DarkGreen` | 35 | /// | `Yellow` | `DarkYellow` | 36 | /// | `Blue` | `DarkBlue` | 37 | /// | `Magenta` | `DarkMagenta` | 38 | /// | `Cyan` | `DarkCyan` | 39 | /// | `White` | `Grey` | 40 | /// 41 | /// Most UNIX terminals and Windows 10 consoles support additional colors. 42 | /// See [`Color::Rgb`] or [`Color::AnsiValue`] for more info. 43 | /// Resets the terminal color. 44 | record Reset() implements Color { 45 | } 46 | 47 | /// Black color. 48 | record Black() implements Color { 49 | } 50 | 51 | /// Dark grey color. 52 | record DarkGrey() implements Color { 53 | } 54 | 55 | /// Light red color. 56 | record Red() implements Color { 57 | } 58 | 59 | /// Dark red color. 60 | record DarkRed() implements Color { 61 | } 62 | 63 | /// Light green color. 64 | record Green() implements Color { 65 | } 66 | 67 | /// Dark green color. 68 | record DarkGreen() implements Color { 69 | } 70 | 71 | /// Light yellow color. 72 | record Yellow() implements Color { 73 | } 74 | 75 | /// Dark yellow color. 76 | record DarkYellow() implements Color { 77 | } 78 | 79 | /// Light blue color. 80 | record Blue() implements Color { 81 | } 82 | 83 | /// Dark blue color. 84 | record DarkBlue() implements Color { 85 | } 86 | 87 | /// Light magenta color. 88 | record Magenta() implements Color { 89 | } 90 | 91 | /// Dark magenta color. 92 | record DarkMagenta() implements Color { 93 | } 94 | 95 | /// Light cyan color. 96 | record Cyan() implements Color { 97 | } 98 | 99 | /// Dark cyan color. 100 | record DarkCyan() implements Color { 101 | } 102 | 103 | /// White color. 104 | record White() implements Color { 105 | } 106 | 107 | /// Grey color. 108 | record Grey() implements Color { 109 | } 110 | 111 | /// An RGB color. See [RGB color model](https://en.wikipedia.org/wiki/RGB_color_model) for more info. 112 | /// 113 | /// Most UNIX terminals and Windows 10 supported only. 114 | /// See [Platform-specific notes](enum.Color.html#platform-specific-notes) for more info. 115 | record Rgb(int r, int g, int b) implements Color { 116 | } 117 | 118 | /// An ANSI color. See [256 colors - cheat sheet](https://jonasjacek.github.io/colors/) for more info. 119 | /// 120 | /// Most UNIX terminals and Windows 10 supported only. 121 | /// See [Platform-specific notes](enum.Color.html#platform-specific-notes) for more info. 122 | record AnsiValue(int color) implements Color { 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/BarChartExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.widgets.{BarChartWidget, BlockWidget} 5 | 6 | import java.time.{Duration, Instant} 7 | import scala.Ordering.Implicits._ 8 | 9 | object BarChartExample { 10 | 11 | case class App(var data: Array[(String, Int)]) { 12 | def on_tick(): Unit = 13 | data = data.last +: data.dropRight(1) 14 | } 15 | 16 | object App { 17 | // format: off 18 | val data: Array[(String, Int)] = Array(("B1", 9), ("B2", 12), ("B3", 5), ("B4", 8), ("B5", 2), ("B6", 4), ("B7", 5), ("B8", 9), ("B9", 14), ("B10", 15), ("B11", 1), ("B12", 0), ("B13", 4), ("B14", 6), ("B15", 4), ("B16", 6), ("B17", 4), ("B18", 7), ("B19", 13), ("B20", 8), ("B21", 11), ("B22", 9), ("B23", 3), ("B24", 5)) 19 | // format: on 20 | } 21 | 22 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 23 | // create app and run it 24 | val tick_rate = Duration.ofMillis(250) 25 | val app = new App(App.data) 26 | 27 | run_app(terminal, app, tick_rate, jni) 28 | } 29 | 30 | def run_app( 31 | terminal: Terminal, 32 | app: App, 33 | tick_rate: java.time.Duration, 34 | jni: tui.crossterm.CrosstermJni 35 | ): Unit = { 36 | var last_tick = Instant.now() 37 | 38 | def elapsed = java.time.Duration.between(last_tick, java.time.Instant.now()) 39 | def timeout = { 40 | val timeout = tick_rate.minus(elapsed) 41 | new tui.crossterm.Duration(timeout.toSeconds, timeout.getNano) 42 | } 43 | 44 | while (true) { 45 | terminal.draw(f => ui(f, app)) 46 | 47 | if (jni.poll(timeout)) { 48 | jni.read() match { 49 | case key: tui.crossterm.Event.Key => 50 | key.keyEvent.code match { 51 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 52 | case _ => () 53 | } 54 | case _ => () 55 | } 56 | } 57 | if (elapsed >= tick_rate) { 58 | app.on_tick() 59 | last_tick = Instant.now() 60 | } 61 | } 62 | } 63 | 64 | def ui(f: Frame, app: App): Unit = { 65 | val verticalChunks = Layout( 66 | direction = Direction.Vertical, 67 | margin = Margin(2, 2), 68 | constraints = Array(Constraint.Percentage(50), Constraint.Percentage(50)) 69 | ).split(f.size) 70 | 71 | val barchart1 = BarChartWidget( 72 | block = Some(BlockWidget(title = Some(Spans.nostyle("Data1")), borders = Borders.ALL)), 73 | data = app.data, 74 | barWidth = 9, 75 | barStyle = Style(fg = Some(Color.Yellow)), 76 | valueStyle = Style(fg = Some(Color.Black), bg = Some(Color.Yellow)) 77 | ) 78 | f.renderWidget(barchart1, verticalChunks(0)) 79 | 80 | val horizontalChunks = Layout( 81 | direction = Direction.Horizontal, 82 | constraints = Array(Constraint.Percentage(50), Constraint.Percentage(50)) 83 | ).split(verticalChunks(1)) 84 | 85 | val barchart2 = BarChartWidget( 86 | block = Some(BlockWidget(title = Some(Spans.nostyle("Data2")), borders = Borders.ALL)), 87 | barWidth = 5, 88 | barGap = 3, 89 | barStyle = Style(fg = Some(Color.Green)), 90 | valueStyle = Style(bg = Some(Color.Green), addModifier = Modifier.BOLD), 91 | data = app.data 92 | ) 93 | f.renderWidget(barchart2, horizontalChunks(0)) 94 | 95 | val barchart3 = BarChartWidget( 96 | block = Some(BlockWidget(title = Some(Spans.nostyle("Data3")), borders = Borders.ALL)), 97 | data = app.data, 98 | barStyle = Style(fg = Some(Color.Red)), 99 | barWidth = 7, 100 | barGap = 0, 101 | valueStyle = Style(bg = Some(Color.Red)), 102 | labelStyle = Style(fg = Some(Color.Cyan), addModifier = Modifier.ITALIC) 103 | ) 104 | f.renderWidget(barchart3, horizontalChunks(1)) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/TableExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.crossterm.CrosstermJni 5 | import tui.widgets._ 6 | 7 | object TableExample { 8 | val items: Array[Array[String]] = Array( 9 | Array("Row11", "Row12", "Row13"), 10 | Array("Row21", "Row22", "Row23"), 11 | Array("Row31", "Row32", "Row33"), 12 | Array("Row41", "Row42", "Row43"), 13 | Array("Row51", "Row52", "Row53"), 14 | Array("Row61", "Row62\nTest", "Row63"), 15 | Array("Row71", "Row72", "Row73"), 16 | Array("Row81", "Row82", "Row83"), 17 | Array("Row91", "Row92", "Row93"), 18 | Array("Row101", "Row102", "Row103"), 19 | Array("Row111", "Row112", "Row113"), 20 | Array("Row121", "Row122", "Row123"), 21 | Array("Row131", "Row132", "Row133"), 22 | Array("Row141", "Row142", "Row143"), 23 | Array("Row151", "Row152", "Row153"), 24 | Array("Row161", "Row162", "Row163"), 25 | Array("Row171", "Row172", "Row173"), 26 | Array("Row181", "Row182", "Row183"), 27 | Array("Row191", "Row192", "Row193") 28 | ) 29 | 30 | case class App( 31 | state: TableWidget.State, 32 | items: Array[Array[String]] 33 | ) { 34 | def next(): Unit = { 35 | val i = state.selected match { 36 | case Some(i) => 37 | if (i >= items.length - 1) { 38 | 0 39 | } else { 40 | i + 1 41 | } 42 | 43 | case None => 0 44 | } 45 | state.select(Some(i)) 46 | } 47 | 48 | def previous(): Unit = { 49 | val i = state.selected match { 50 | case Some(i) => 51 | if (i == 0) { 52 | items.length - 1 53 | } else { 54 | i - 1 55 | } 56 | case None => 0 57 | } 58 | state.select(Some(i)) 59 | } 60 | } 61 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 62 | // create app and run it 63 | val app = App(state = TableWidget.State(), items = items) 64 | run_app(terminal, app, jni) 65 | } 66 | 67 | def run_app(terminal: Terminal, app: App, jni: CrosstermJni): Unit = 68 | while (true) { 69 | terminal.draw(f => ui(f, app)) 70 | 71 | jni.read() match { 72 | case key: tui.crossterm.Event.Key => 73 | key.keyEvent.code match { 74 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 75 | case _: tui.crossterm.KeyCode.Down => app.next() 76 | case _: tui.crossterm.KeyCode.Up => app.previous() 77 | case _ => () 78 | } 79 | case _ => () 80 | } 81 | } 82 | 83 | def ui(f: Frame, app: App): Unit = { 84 | val rects = Layout(constraints = Array(Constraint.Percentage(100)), margin = Margin(5)).split(f.size) 85 | 86 | val selected_style = Style(addModifier = Modifier.REVERSED) 87 | val normal_style = Style(bg = Some(Color.Blue)) 88 | val header_cells = Array("Header1", "Header2", "Header3").map(h => TableWidget.Cell(Text.nostyle(h), style = Style(fg = Some(Color.Red)))) 89 | val header = TableWidget.Row(cells = header_cells, style = normal_style, bottomMargin = 1) 90 | 91 | val rows = app.items.map { item => 92 | val height = item.map(_.count(_ == '\n')).maxOption.getOrElse(0) + 1 93 | val cells = item.map(c => TableWidget.Cell(Text.nostyle(c))) 94 | TableWidget.Row(cells, height = height, bottomMargin = 1) 95 | } 96 | 97 | val t = TableWidget( 98 | block = Some(BlockWidget(borders = Borders.ALL, title = Some(Spans.nostyle("Table")))), 99 | widths = Array(Constraint.Percentage(50), Constraint.Length(30), Constraint.Min(10)), 100 | highlightStyle = selected_style, 101 | highlightSymbol = Some(">> "), 102 | header = Some(header), 103 | rows = rows 104 | ) 105 | f.renderStatefulWidget(t, rects(0))(app.state) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # tui-scala 2 | 3 | [![Build Status](https://github.com/oyvindberg/tui-scala/actions/workflows/build.yml/badge.svg)](https://github.com/oyvindberg/tui-scala/actions/workflows/build.yml) 4 | 5 | 6 | 7 | https://user-images.githubusercontent.com/247937/207265695-58d2eeac-2f62-4264-95f9-9e25b1f99964.mp4 8 | 9 | 10 | `tui-scala` is a [Scala](https://www.scala-lang.org) library to build rich terminal 11 | user interfaces and dashboards. It is a port of [tui-rs](https://github.com/fdehau/tui-rs), 12 | which is heavily inspired by the `Javascript` 13 | library [blessed-contrib](https://github.com/yaronn/blessed-contrib) and the 14 | `Go` library [termui](https://github.com/gizak/termui). 15 | 16 | **The port is now complete, and from here on it will diverge from the original design. See [roadmap](https://github.com/oyvindberg/tui-scala/issues/15) for immediate plans. 17 | There are some design/ideas tasks where you can help with ideas, POCs and implementation if you want to contribute!** 18 | 19 | The library uses [crossterm](https://github.com/crossterm-rs/crossterm) as a backend. 20 | `crossterm` handles differences between platforms, so everything should work on major operating systems, including windows. 21 | 22 | The integration with `crossterm` is published separately as a Java artifact, which calls native rust code through JNI. 23 | This integration works both when running on the JVM and when running as GraalVM native image. 24 | 25 | The library is based on the principle of immediate rendering with intermediate 26 | buffers. This means that at each new frame you should build all widgets that are 27 | supposed to be part of the UI. While providing a great flexibility for rich and 28 | interactive UI, this may introduce overhead for highly dynamic content. So, the 29 | implementation try to minimize the number of ansi escapes sequences generated to 30 | draw the updated UI. In practice, given the speed of the JVM the overhead rather 31 | comes from the terminal emulator than the library itself. 32 | 33 | Moreover, the library does not provide any input handling nor any event system, and 34 | you may rely on `crossterm` achieve such features. 35 | 36 | ### Widgets 37 | 38 | The library comes with a bunch of widgets: here is example code for all of them: 39 | 40 | * [BarChart](demo/src/scala/tuiexamples/BarChartExample.scala) 41 | * [Block](demo/src/scala/tuiexamples/BlockExample.scala) 42 | * [Canvas](demo/src/scala/tuiexamples/CanvasExample.scala) 43 | * [Chart](demo/src/scala/tuiexamples/ChartExample.scala) 44 | * [CustomWidget](demo/src/scala/tuiexamples/CustomWidgetExample.scala) 45 | * [Gauge](demo/src/scala/tuiexamples/GaugeExample.scala) 46 | * [Layout](demo/src/scala/tuiexamples/LayoutExample.scala) 47 | * [List](demo/src/scala/tuiexamples/ListExample.scala) 48 | * [Paragraph](demo/src/scala/tuiexamples/ParagraphExample.scala) 49 | * [Popup](demo/src/scala/tuiexamples/PopupExample.scala) 50 | * [Sparkline](demo/src/scala/tuiexamples/SparklineExample.scala) 51 | * [Table](demo/src/scala/tuiexamples/TableExample.scala) 52 | * [Tabs](demo/src/scala/tuiexamples/TabsExample.scala) 53 | * [UserInput](demo/src/scala/tuiexamples/UserInputExample.scala) 54 | 55 | Click on each item to see the source of the example. Run the examples with 56 | bleep (`bleep run demo@jvm213`), and quit by pressing `q`. 57 | 58 | The demo shown in the first video can be found here: 59 | * [Demo](demo/src/scala/tuiexamples/demo) 60 | 61 | ### Installation 62 | 63 | For sbt: 64 | 65 | ```scala 66 | libraryDependencies += "com.olvind.tui" %% "tui" % "" 67 | ``` 68 | 69 | If you only want `crossterm` to do low-level things, or if you want to experiment with making a TUI, these are the coordinates: 70 | ```scala 71 | libraryDependencies += "com.olvind.tui" % "crossterm" % "" 72 | ``` 73 | 74 | 75 | And then copy/paste one of the demos above to get started. 76 | 77 | It's cross published for scala 2.13 and 3. Note that scala 3 won't work with graalvm native image until 3.3. 78 | 79 | You'll need a recent JVM with support for sealed interfaces and records. likely 18. 80 | 81 | ### Contributing/building 82 | 83 | See [contributing](./contributing.md) 84 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/GaugeExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.crossterm.CrosstermJni 5 | import tui.widgets.{BlockWidget, GaugeWidget} 6 | 7 | import java.time.{Duration, Instant} 8 | import scala.math.Ordering.Implicits._ 9 | 10 | object GaugeExample { 11 | case class App( 12 | var progress1: Int = 0, 13 | var progress2: Int = 0, 14 | var progress3: Double = 0.45, 15 | var progress4: Int = 0 16 | ) { 17 | 18 | def on_tick(): Unit = { 19 | progress1 += 1 20 | if (progress1 > 100) { 21 | progress1 = 0 22 | } 23 | progress2 += 2 24 | if (progress2 > 100) { 25 | progress2 = 0 26 | } 27 | progress3 += 0.001 28 | if (progress3 > 1.0) { 29 | progress3 = 0.0 30 | } 31 | progress4 += 1 32 | if (progress4 > 100) { 33 | progress4 = 0 34 | } 35 | } 36 | } 37 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 38 | // create app and run it 39 | val tick_rate = Duration.ofMillis(250) 40 | val app = App() 41 | 42 | run_app(terminal, app, tick_rate, jni) 43 | } 44 | 45 | def run_app( 46 | terminal: Terminal, 47 | app: App, 48 | tick_rate: Duration, 49 | jni: CrosstermJni 50 | ): Unit = { 51 | var last_tick = Instant.now() 52 | 53 | def elapsed = java.time.Duration.between(last_tick, java.time.Instant.now()) 54 | 55 | def timeout = { 56 | val timeout = tick_rate.minus(elapsed) 57 | new tui.crossterm.Duration(timeout.toSeconds, timeout.getNano) 58 | } 59 | 60 | while (true) { 61 | terminal.draw(f => ui(f, app)) 62 | 63 | if (jni.poll(timeout)) { 64 | jni.read() match { 65 | case key: tui.crossterm.Event.Key => 66 | key.keyEvent.code match { 67 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 68 | case _ => () 69 | } 70 | case _ => () 71 | } 72 | } 73 | if (elapsed >= tick_rate) { 74 | app.on_tick() 75 | last_tick = Instant.now() 76 | } 77 | } 78 | } 79 | 80 | def ui(f: Frame, app: App): Unit = { 81 | val chunks = Layout( 82 | direction = Direction.Vertical, 83 | margin = Margin(2), 84 | constraints = Array(Constraint.Percentage(25), Constraint.Percentage(25), Constraint.Percentage(25), Constraint.Percentage(25)) 85 | ) 86 | .split(f.size) 87 | 88 | val gauge0 = GaugeWidget( 89 | block = Some(BlockWidget(title = Some(Spans.nostyle("Gauge1")), borders = Borders.ALL)), 90 | gaugeStyle = Style(fg = Some(Color.Yellow)), 91 | ratio = GaugeWidget.Ratio.percent(app.progress1) 92 | ) 93 | f.renderWidget(gauge0, chunks(0)) 94 | 95 | val gauge1 = GaugeWidget( 96 | block = Some(BlockWidget(title = Some(Spans.nostyle("Gauge2")), borders = Borders.ALL)), 97 | gaugeStyle = Style(fg = Some(Color.Magenta), bg = Some(Color.Green)), 98 | ratio = GaugeWidget.Ratio.percent(app.progress2), 99 | label = Some(Span.nostyle(s"${app.progress2}/100")) 100 | ) 101 | f.renderWidget(gauge1, chunks(1)) 102 | 103 | val gauge2 = GaugeWidget( 104 | block = Some(BlockWidget(title = Some(Spans.nostyle("Gauge3")), borders = Borders.ALL)), 105 | gaugeStyle = Style(fg = Some(Color.Yellow)), 106 | ratio = GaugeWidget.Ratio(app.progress3), 107 | label = Some( 108 | Span.styled( 109 | "%.2f".format(app.progress3 * 100.0), 110 | Style(fg = Some(Color.Red), addModifier = Modifier.ITALIC | Modifier.BOLD) 111 | ) 112 | ), 113 | useUnicode = true 114 | ) 115 | f.renderWidget(gauge2, chunks(2)) 116 | 117 | val gauge3 = GaugeWidget( 118 | block = Some(BlockWidget(title = Some(Spans.nostyle("Gauge4")))), 119 | gaugeStyle = Style(fg = Some(Color.Cyan), addModifier = Modifier.ITALIC), 120 | ratio = GaugeWidget.Ratio.percent(app.progress4), 121 | label = Some(Span.nostyle(s"${app.progress4}/100")) 122 | ) 123 | f.renderWidget(gauge3, chunks(3)) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | tags: [ 'v*' ] 5 | branches: [ 'master' ] 6 | pull_request: 7 | branches: [ 'master' ] 8 | 9 | jobs: 10 | build: 11 | name: Compile, test, check formatting 12 | timeout-minutes: 15 13 | runs-on: ubuntu-latest 14 | if: "!contains(github.event.head_commit.message, 'ci skip')" 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: bleep-build/bleep-setup-action@0.0.1 18 | - uses: coursier/cache-action@v6 19 | with: 20 | extraFiles: bleep.yaml 21 | 22 | - name: Scalafmt Check 23 | run: bleep fmt --check 24 | 25 | - name: Run tests 26 | run: | 27 | bleep compile 28 | bleep test 29 | 30 | build-native: 31 | name: Build JNI library on ${{ matrix.os }} 32 | runs-on: ${{ matrix.os }} 33 | timeout-minutes: 10 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | include: 38 | - os: ubuntu-20.04 39 | jni-folder: .bleep/generated-resources/crossterm/tui.scripts.GenJniLibrary 40 | - os: macos-latest 41 | jni-folder: .bleep/generated-resources/crossterm/tui.scripts.GenJniLibrary 42 | - os: macOS-m1 43 | jni-folder: .bleep/generated-resources/crossterm/tui.scripts.GenJniLibrary 44 | - os: windows-latest 45 | jni-folder: .bleep\generated-resources\crossterm\tui.scripts.GenJniLibrary 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: bleep-build/bleep-setup-action@0.0.1 49 | - uses: coursier/cache-action@v6 50 | with: 51 | extraFiles: bleep.yaml 52 | - name: Set up cargo cache 53 | uses: actions/cache@v3 54 | continue-on-error: false 55 | with: 56 | path: | 57 | ~/.cargo/bin/ 58 | ~/.cargo/registry/index/ 59 | ~/.cargo/registry/cache/ 60 | ~/.cargo/git/db/ 61 | target/ 62 | key: v1-${{ runner.arch }}-${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 63 | restore-keys: ${{ runner.arch }}-${{ runner.os }}-cargo- 64 | 65 | - name: Test jni library 66 | run: bleep run demo@jvm3 check 67 | if: runner.os != 'Windows' 68 | 69 | - name: Test jni library (windows) 70 | run: bleep run demo@jvm3 check 71 | shell: cmd 72 | if: runner.os == 'Windows' 73 | 74 | - name: Temporarily save package 75 | uses: actions/upload-artifact@v3 76 | with: 77 | name: tui.scripts.GenJniLibrary 78 | path: ${{ matrix.jni-folder }} 79 | retention-days: 1 80 | 81 | publish: 82 | timeout-minutes: 15 83 | runs-on: ubuntu-latest 84 | needs: [ build, build-native ] 85 | if: "startsWith(github.ref, 'refs/tags/v')" 86 | steps: 87 | - uses: actions/checkout@v4 88 | - uses: bleep-build/bleep-setup-action@0.0.1 89 | - id: get_version 90 | uses: battila7/get-version-action@v2 91 | - name: Download artifacts 92 | uses: actions/download-artifact@v3 93 | with: 94 | path: .bleep/generated-resources/crossterm/ 95 | - name: Display structure of downloaded files 96 | run: find .bleep/generated-resources/crossterm 97 | # next two tasks are optimization to avoid compiling rust code again 98 | - name: load build 99 | run: bleep projects 100 | - name: touch all downloaded files (for newer timestamp) 101 | run: find .bleep/generated-resources/crossterm | xargs touch 102 | - name: Release 103 | run: bleep publish 104 | env: 105 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 106 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 107 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 108 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 109 | - name: Upload-to-release 110 | uses: softprops/action-gh-release@v1 111 | with: 112 | name: "${{ steps.get_version.outputs.version-without-v }}" 113 | prerelease: false 114 | generate_release_notes: true 115 | files: | 116 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/SparklineExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.widgets.{BlockWidget, SparklineWidget} 5 | 6 | import java.time.{Duration, Instant} 7 | import scala.Ordering.Implicits._ 8 | import scala.collection.mutable 9 | import scala.util.Random 10 | 11 | object SparklineExample { 12 | case class RandomSignal( 13 | lower: Int, 14 | upper: Int, 15 | random: Random = new Random() 16 | ) { 17 | def iterator: Iterator[Int] = 18 | new Iterator[Int] { 19 | override def hasNext: Boolean = true 20 | 21 | override def next(): Int = random.nextInt(upper - lower) + lower 22 | } 23 | } 24 | 25 | case class App( 26 | signal: Iterator[Int], 27 | data1: mutable.ArrayDeque[Int], 28 | data2: mutable.ArrayDeque[Int], 29 | data3: mutable.ArrayDeque[Int] 30 | ) { 31 | def on_tick(): Unit = { 32 | val value1 = signal.next() 33 | data1.removeLast() 34 | data1.prepend(value1) 35 | val value2 = signal.next() 36 | data2.removeLast() 37 | data2.prepend(value2) 38 | val value3 = signal.next() 39 | data3.removeLast() 40 | data3.prepend(value3) 41 | } 42 | } 43 | 44 | object App { 45 | def apply(): App = { 46 | val signal = RandomSignal(lower = 0, upper = 100).iterator 47 | val data1 = mutable.ArrayDeque.from(signal.take(200)) 48 | val data2 = mutable.ArrayDeque.from(signal.take(200)) 49 | val data3 = mutable.ArrayDeque.from(signal.take(200)) 50 | App(signal, data1, data2, data3) 51 | } 52 | } 53 | 54 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 55 | // create app and run it 56 | val tick_rate = Duration.ofMillis(250) 57 | val app = App() 58 | run_app(terminal, app, tick_rate, jni); 59 | } 60 | 61 | def run_app( 62 | terminal: Terminal, 63 | app: App, 64 | tick_rate: Duration, 65 | jni: tui.crossterm.CrosstermJni 66 | ): Unit = { 67 | var last_tick = Instant.now() 68 | 69 | def elapsed = java.time.Duration.between(last_tick, java.time.Instant.now()) 70 | 71 | def timeout = { 72 | val timeout = tick_rate.minus(elapsed) 73 | new tui.crossterm.Duration(timeout.toSeconds, timeout.getNano) 74 | } 75 | 76 | while (true) { 77 | terminal.draw(f => ui(f, app)) 78 | 79 | if (jni.poll(timeout)) { 80 | jni.read() match { 81 | case key: tui.crossterm.Event.Key => 82 | key.keyEvent.code match { 83 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 84 | case _ => () 85 | } 86 | case _ => () 87 | } 88 | } 89 | if (elapsed >= tick_rate) { 90 | app.on_tick() 91 | last_tick = Instant.now() 92 | } 93 | } 94 | } 95 | 96 | def ui(f: Frame, app: App): Unit = { 97 | val chunks = Layout( 98 | direction = Direction.Vertical, 99 | margin = Margin(2, 2), 100 | constraints = Array(Constraint.Length(3), Constraint.Length(3), Constraint.Length(7), Constraint.Min(0)) 101 | ).split(f.size) 102 | 103 | val sparkline0 = SparklineWidget( 104 | block = Some( 105 | BlockWidget( 106 | title = Some(Spans.nostyle("Data1")), 107 | borders = Borders.LEFT | Borders.RIGHT 108 | ) 109 | ), 110 | data = app.data1, 111 | style = Style(fg = Some(Color.Yellow)) 112 | ) 113 | f.renderWidget(sparkline0, chunks(0)) 114 | 115 | val sparkline1 = SparklineWidget( 116 | block = Some(BlockWidget(title = Some(Spans.nostyle("Data2")), borders = Borders.LEFT | Borders.RIGHT)), 117 | data = app.data2, 118 | style = Style(bg = Some(Color.Green)) 119 | ) 120 | f.renderWidget(sparkline1, chunks(1)) 121 | // Multiline 122 | val sparkline2 = SparklineWidget( 123 | block = Some(BlockWidget(title = Some(Spans.nostyle("Data3")), borders = Borders.LEFT | Borders.RIGHT)), 124 | style = Style(fg = Some(Color.Red)), 125 | data = app.data3 126 | ) 127 | f.renderWidget(sparkline2, chunks(2)) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/BarChartWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | import tui.internal.ranges._ 5 | import tui.{Grapheme, Style} 6 | 7 | /** Display multiple bars in a single widgets 8 | * 9 | * @param block 10 | * Block to wrap the widget in 11 | * @param barWidth 12 | * The width of each bar 13 | * @param barGap 14 | * The gap between each bar 15 | * @param barSet 16 | * Set of symbols used to display the data 17 | * @param barStyle 18 | * Style of the bars 19 | * @param valueStyle 20 | * Style of the values printed at the bottom of each bar 21 | * @param labelStyle 22 | * Style of the labels printed under each bar 23 | * @param style 24 | * Style for the widget 25 | * @param data 26 | * Slice of (label, value) pair to plot on the chart 27 | * @param max 28 | * Value necessary for a bar to reach the maximum height (if no value is specified, the maximum value in the data is taken as reference) 29 | */ 30 | case class BarChartWidget( 31 | block: Option[BlockWidget] = None, 32 | barWidth: Int = 1, 33 | barGap: Int = 1, 34 | barSet: symbols.bar.Set = symbols.bar.NINE_LEVELS, 35 | barStyle: Style = Style.DEFAULT, 36 | valueStyle: Style = Style.DEFAULT, 37 | labelStyle: Style = Style.DEFAULT, 38 | style: Style = Style.DEFAULT, 39 | data: Array[(String, Int)] = Array.empty, 40 | max: Option[Int] = None 41 | ) extends Widget { 42 | 43 | /** Values to display on the bar (computed when the data is passed to the widget) 44 | */ 45 | private lazy val values: Array[Grapheme] = data.collect { case (_, v) => Grapheme(v.toString) } 46 | 47 | override def render(area: Rect, buf: Buffer): Unit = { 48 | buf.setStyle(area, style) 49 | 50 | val chart_area: Rect = block match { 51 | case Some(b) => 52 | val inner_area = b.inner(area) 53 | b.render(area, buf) 54 | inner_area 55 | case None => area 56 | } 57 | 58 | if (chart_area.height < 2) { 59 | return 60 | } 61 | 62 | val max = this.max.getOrElse(data.maxByOption { case (_, value) => value }.fold(0) { case (_, value) => value }) 63 | 64 | val max_index = math.min( 65 | chart_area.width / (barWidth + barGap), 66 | data.length 67 | ) 68 | 69 | case class Data(label: String, var value: Int) 70 | val data2 = this.data.take(max_index).map { case (l, v) => Data(l, v * (chart_area.height - 1) * 8 / math.max(max, 1)) }.zipWithIndex 71 | 72 | revRange(0, chart_area.height - 1) { j => 73 | data2.foreach { case (d, i) => 74 | val symbol = d.value match { 75 | case 0 => barSet.empty 76 | case 1 => barSet.oneEighth 77 | case 2 => barSet.oneQuarter 78 | case 3 => barSet.threeEighths 79 | case 4 => barSet.half 80 | case 5 => barSet.fiveEighths 81 | case 6 => barSet.threeQuarters 82 | case 7 => barSet.sevenEighths 83 | case _ => barSet.full 84 | } 85 | range(0, barWidth) { x => 86 | buf 87 | .get( 88 | chart_area.left + i * (barWidth + barGap) + x, 89 | chart_area.top + j 90 | ) 91 | .setSymbol(symbol) 92 | .setStyle(barStyle) 93 | () 94 | } 95 | 96 | if (d.value > 8) { 97 | d.value = d.value - 8 98 | } else { 99 | d.value = 0 100 | } 101 | } 102 | } 103 | data.take(max_index).zipWithIndex.foreach { case ((label, value), i) => 104 | if (value != 0) { 105 | val value_label = values(i) 106 | val width = value_label.width 107 | if (width < barWidth) { 108 | buf.setString( 109 | chart_area.left 110 | + i * (barWidth + barGap) 111 | + (barWidth - width) / 2, 112 | chart_area.bottom - 2, 113 | value_label.str, 114 | valueStyle 115 | ) 116 | } 117 | } 118 | buf.setStringn( 119 | chart_area.left + i * (barWidth + barGap), 120 | chart_area.bottom - 1, 121 | label, 122 | barWidth, 123 | labelStyle 124 | ) 125 | 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/CanvasExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.crossterm.CrosstermJni 5 | import tui.widgets.BlockWidget 6 | import tui.widgets.canvas.{CanvasWidget, MapResolution, Rectangle, WorldMap} 7 | 8 | import java.time.{Duration, Instant} 9 | import scala.Ordering.Implicits._ 10 | 11 | object CanvasExample { 12 | case class App( 13 | var x: Double = 0.0, 14 | var y: Double = 0.0, 15 | var ball: Rectangle = Rectangle( 16 | x = 10.0, 17 | y = 30.0, 18 | width = 10.0, 19 | height = 10.0, 20 | color = Color.Yellow 21 | ), 22 | playground: Rect = Rect(10, 10, 100, 100), 23 | vx: Double = 1.0, 24 | vy: Double = 1.0, 25 | var dir_x: Boolean = true, 26 | var dir_y: Boolean = true 27 | ) { 28 | 29 | def on_tick(): Unit = { 30 | if (this.ball.x < this.playground.left.toDouble || this.ball.x + this.ball.width > this.playground.right.toDouble) { 31 | this.dir_x = !this.dir_x 32 | } 33 | if (this.ball.y < this.playground.top.toDouble || this.ball.y + this.ball.height > this.playground.bottom.toDouble) { 34 | this.dir_y = !this.dir_y 35 | } 36 | 37 | if (this.dir_x) { 38 | ball = ball.copy(x = ball.x + this.vx) 39 | } else { 40 | ball = ball.copy(x = ball.x - this.vx) 41 | } 42 | 43 | if (this.dir_y) { 44 | ball = ball.copy(y = ball.x + this.vy) 45 | } else { 46 | ball = ball.copy(y = ball.x - this.vy) 47 | } 48 | } 49 | } 50 | 51 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 52 | // create app and run it 53 | val tick_rate = Duration.ofMillis(250) 54 | val app = App() 55 | 56 | run_app(terminal, app, tick_rate, jni) 57 | } 58 | 59 | def run_app(terminal: Terminal, app: App, tick_rate: java.time.Duration, jni: CrosstermJni): Unit = { 60 | var last_tick = Instant.now() 61 | 62 | def elapsed = java.time.Duration.between(last_tick, java.time.Instant.now()) 63 | 64 | def timeout = { 65 | val timeout = tick_rate.minus(elapsed) 66 | new tui.crossterm.Duration(timeout.toSeconds, timeout.getNano) 67 | } 68 | 69 | while (true) { 70 | terminal.draw(f => ui(f, app)) 71 | 72 | if (jni.poll(timeout)) { 73 | jni.read() match { 74 | case key: tui.crossterm.Event.Key => 75 | key.keyEvent.code match { 76 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 77 | case _: tui.crossterm.KeyCode.Down => app.y += 1.0 78 | case _: tui.crossterm.KeyCode.Up => app.y -= 1.0 79 | case _: tui.crossterm.KeyCode.Right => app.x += 1.0 80 | case _: tui.crossterm.KeyCode.Left => app.x -= 1.0 81 | case _ => () 82 | } 83 | case _ => () 84 | } 85 | } 86 | if (elapsed >= tick_rate) { 87 | app.on_tick() 88 | last_tick = Instant.now() 89 | } 90 | } 91 | } 92 | 93 | def ui(f: Frame, app: App): Unit = { 94 | val chunks = Layout( 95 | direction = Direction.Horizontal, 96 | constraints = Array(Constraint.Percentage(50), Constraint.Percentage(50)) 97 | ) 98 | .split(f.size) 99 | 100 | val canvas0 = CanvasWidget( 101 | block = Some(BlockWidget(borders = Borders.ALL, title = Some(Spans.nostyle("World")))), 102 | yBounds = Point(-90.0, 90.0), 103 | xBounds = Point(-180.0, 180.0) 104 | ) { ctx => 105 | ctx.draw(WorldMap(color = Color.White, resolution = MapResolution.High)) 106 | ctx.print(app.x, -app.y, Spans.from(Span.styled("You are here", Style(fg = Some(Color.Yellow))))) 107 | } 108 | f.renderWidget(canvas0, chunks(0)) 109 | 110 | val canvas1 = CanvasWidget( 111 | block = Some(BlockWidget(borders = Borders.ALL, title = Some(Spans.nostyle("Pong")))), 112 | yBounds = Point(10.0, 110.0), 113 | xBounds = Point(10.0, 110.0) 114 | ) { ctx => 115 | ctx.draw(app.ball) 116 | } 117 | f.renderWidget(canvas1, chunks(1)) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/ParagraphExample.scala: -------------------------------------------------------------------------------- 1 | package tuiexamples 2 | 3 | import tui._ 4 | import tui.crossterm.CrosstermJni 5 | import tui.widgets.{BlockWidget, ParagraphWidget} 6 | 7 | import java.time.{Duration, Instant} 8 | import scala.math.Ordering.Implicits._ 9 | 10 | object ParagraphExample { 11 | case class App(var scroll: Int = 0) { 12 | def on_tick(): Unit = { 13 | scroll += 1 14 | scroll %= 10 15 | } 16 | } 17 | 18 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 19 | // create app and run it 20 | val tick_rate = Duration.ofMillis(250) 21 | val app = App() 22 | 23 | run_app(terminal, app, tick_rate, jni) 24 | } 25 | 26 | def run_app( 27 | terminal: Terminal, 28 | app: App, 29 | tick_rate: Duration, 30 | jni: CrosstermJni 31 | ): Unit = { 32 | var last_tick = Instant.now() 33 | 34 | def elapsed = java.time.Duration.between(last_tick, java.time.Instant.now()) 35 | 36 | def timeout = { 37 | val timeout = tick_rate.minus(elapsed) 38 | new tui.crossterm.Duration(timeout.toSeconds, timeout.getNano) 39 | } 40 | 41 | while (true) { 42 | terminal.draw(f => ui(f, app)) 43 | 44 | if (jni.poll(timeout)) { 45 | jni.read() match { 46 | case key: tui.crossterm.Event.Key => 47 | key.keyEvent.code match { 48 | case char: tui.crossterm.KeyCode.Char if char.c() == 'q' => return 49 | case _ => () 50 | } 51 | case _ => () 52 | } 53 | } 54 | if (elapsed >= tick_rate) { 55 | app.on_tick() 56 | last_tick = Instant.now() 57 | } 58 | } 59 | } 60 | 61 | def ui(f: Frame, app: App): Unit = { 62 | // Words made "loooong" to demonstrate line breaking. 63 | val s = "Veeeeeeeeeeeeeeeery loooooooooooooooooong striiiiiiiiiiiiiiiiiiiiiiiiiing. " 64 | val long_line = s.repeat(f.size.width / s.length + 4) + "\n" 65 | 66 | val block = BlockWidget(style = Style(bg = Some(Color.White), fg = Some(Color.Black))) 67 | f.renderWidget(block, f.size) 68 | 69 | val chunks = Layout( 70 | direction = Direction.Vertical, 71 | margin = Margin(5), 72 | constraints = Array(Constraint.Percentage(25), Constraint.Percentage(25), Constraint.Percentage(25), Constraint.Percentage(25)) 73 | ).split(f.size) 74 | 75 | val text = Text.fromSpans( 76 | Spans.nostyle("This is a line "), 77 | Spans.styled("This is a line ", Style.DEFAULT.fg(Color.Red)), 78 | Spans.styled("This is a line", Style.DEFAULT.bg(Color.Blue)), 79 | Spans.styled("This is a longer line", Style.DEFAULT.addModifier(Modifier.CROSSED_OUT)), 80 | Spans.styled(long_line, Style.DEFAULT.bg(Color.Green)), 81 | Spans.styled("This is a line", Style.DEFAULT.fg(Color.Green).addModifier(Modifier.ITALIC)) 82 | ) 83 | 84 | def create_block(title: String): BlockWidget = 85 | BlockWidget( 86 | borders = Borders.ALL, 87 | style = Style(bg = Some(Color.White), fg = Some(Color.Black)), 88 | title = Some(Spans.from(Span.styled(title, Style.DEFAULT.addModifier(Modifier.BOLD)))) 89 | ) 90 | 91 | val paragraph0 = ParagraphWidget( 92 | text = text, 93 | style = Style(bg = Some(Color.White), fg = Some(Color.Black)), 94 | block = Some(create_block("Left, no wrap")), 95 | alignment = Alignment.Left 96 | ) 97 | f.renderWidget(paragraph0, chunks(0)) 98 | val paragraph1 = ParagraphWidget( 99 | text = text, 100 | style = Style(bg = Some(Color.White), fg = Some(Color.Black)), 101 | block = Some(create_block("Left, wrap")), 102 | alignment = Alignment.Left, 103 | wrap = Some(ParagraphWidget.Wrap(trim = true)) 104 | ) 105 | f.renderWidget(paragraph1, chunks(1)) 106 | 107 | val paragraph2 = ParagraphWidget( 108 | text = text, 109 | style = Style(bg = Some(Color.White), fg = Some(Color.Black)), 110 | block = Some(create_block("Center, wrap")), 111 | alignment = Alignment.Center, 112 | wrap = Some(ParagraphWidget.Wrap(trim = true)), 113 | scroll = (app.scroll, 0) 114 | ) 115 | f.renderWidget(paragraph2, chunks(2)) 116 | 117 | val paragraph3 = ParagraphWidget( 118 | text = text, 119 | style = Style(bg = Some(Color.White), fg = Some(Color.Black)), 120 | block = Some(create_block("Right, wrap")), 121 | alignment = Alignment.Right, 122 | wrap = Some(ParagraphWidget.Wrap(trim = true)) 123 | ) 124 | f.renderWidget(paragraph3, chunks(3)) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/src/scala/tui/widgets/BlockTests.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | class BlockTests extends TuiTest { 5 | test("inner_takes_into_account_the_borders") { 6 | // No borders 7 | assertEq( 8 | BlockWidget().inner(Rect.default), 9 | Rect.default, 10 | "no borders, width=0, height=0" 11 | ) 12 | assertEq( 13 | BlockWidget().inner(Rect(x = 0, y = 0, width = 1, height = 1)), 14 | Rect(x = 0, y = 0, width = 1, height = 1), 15 | "no borders, width=1, height=1" 16 | ) 17 | 18 | // Left border 19 | assertEq( 20 | BlockWidget(borders = Borders.LEFT).inner(Rect(x = 0, y = 0, width = 0, height = 1)), 21 | Rect(x = 0, y = 0, width = 0, height = 1), 22 | "left, width=0" 23 | ) 24 | assertEq( 25 | BlockWidget(borders = Borders.LEFT).inner(Rect(x = 0, y = 0, width = 1, height = 1)), 26 | Rect(x = 1, y = 0, width = 0, height = 1), 27 | "left, width=1" 28 | ) 29 | assertEq( 30 | BlockWidget(borders = Borders.LEFT).inner(Rect(x = 0, y = 0, width = 2, height = 1)), 31 | Rect(x = 1, y = 0, width = 1, height = 1), 32 | "left, width=2" 33 | ) 34 | 35 | // Top border 36 | assertEq( 37 | BlockWidget(borders = Borders.TOP).inner(Rect(x = 0, y = 0, width = 1, height = 0)), 38 | Rect(x = 0, y = 0, width = 1, height = 0), 39 | "top, height=0" 40 | ) 41 | assertEq( 42 | BlockWidget(borders = Borders.TOP).inner(Rect(x = 0, y = 0, width = 1, height = 1)), 43 | Rect(x = 0, y = 1, width = 1, height = 0), 44 | "top, height=1" 45 | ) 46 | assertEq( 47 | BlockWidget(borders = Borders.TOP).inner(Rect(x = 0, y = 0, width = 1, height = 2)), 48 | Rect(x = 0, y = 1, width = 1, height = 1), 49 | "top, height=2" 50 | ) 51 | 52 | // Right border 53 | assertEq( 54 | BlockWidget(borders = Borders.RIGHT).inner(Rect(x = 0, y = 0, width = 0, height = 1)), 55 | Rect(x = 0, y = 0, width = 0, height = 1), 56 | "right, width=0" 57 | ) 58 | assertEq( 59 | BlockWidget(borders = Borders.RIGHT).inner(Rect(x = 0, y = 0, width = 1, height = 1)), 60 | Rect(x = 0, y = 0, width = 0, height = 1), 61 | "right, width=1" 62 | ) 63 | assertEq( 64 | BlockWidget(borders = Borders.RIGHT).inner(Rect(x = 0, y = 0, width = 2, height = 1)), 65 | Rect(x = 0, y = 0, width = 1, height = 1), 66 | "right, width=2" 67 | ) 68 | 69 | // Bottom border 70 | assertEq( 71 | BlockWidget(borders = Borders.BOTTOM).inner(Rect(x = 0, y = 0, width = 1, height = 0)), 72 | Rect(x = 0, y = 0, width = 1, height = 0), 73 | "bottom, height=0" 74 | ) 75 | assertEq( 76 | BlockWidget(borders = Borders.BOTTOM).inner(Rect(x = 0, y = 0, width = 1, height = 1)), 77 | Rect(x = 0, y = 0, width = 1, height = 0), 78 | "bottom, height=1" 79 | ) 80 | assertEq( 81 | BlockWidget(borders = Borders.BOTTOM).inner(Rect(x = 0, y = 0, width = 1, height = 2)), 82 | Rect(x = 0, y = 0, width = 1, height = 1), 83 | "bottom, height=2" 84 | ) 85 | 86 | // All borders 87 | assertEq( 88 | BlockWidget(borders = Borders.ALL).inner(Rect.default), 89 | Rect.default, 90 | "all borders, width=0, height=0" 91 | ) 92 | assertEq( 93 | BlockWidget(borders = Borders.ALL).inner(Rect(x = 0, y = 0, width = 1, height = 1)), 94 | Rect(x = 1, y = 1, width = 0, height = 0), 95 | "all borders, width=1, height=1" 96 | ) 97 | assertEq( 98 | BlockWidget(borders = Borders.ALL).inner(Rect(x = 0, y = 0, width = 2, height = 2)), 99 | Rect(x = 1, y = 1, width = 0, height = 0), 100 | "all borders, width=2, height=2" 101 | ) 102 | assertEq( 103 | BlockWidget(borders = Borders.ALL).inner(Rect(x = 0, y = 0, width = 3, height = 3)), 104 | Rect(x = 1, y = 1, width = 1, height = 1), 105 | "all borders, width=3, height=3" 106 | ) 107 | } 108 | 109 | test("inner_takes_into_account_the_title") { 110 | assertEq( 111 | BlockWidget(title = Some(Spans.nostyle("Test"))).inner(Rect(x = 0, y = 0, width = 0, height = 1)), 112 | Rect(x = 0, y = 1, width = 0, height = 0) 113 | ) 114 | assertEq( 115 | BlockWidget(title = Some(Spans.nostyle("Test")), titleAlignment = Alignment.Center).inner(Rect(x = 0, y = 0, width = 0, height = 1)), 116 | Rect(x = 0, y = 1, width = 0, height = 0) 117 | ) 118 | assertEq( 119 | BlockWidget(title = Some(Spans.nostyle("Test")), titleAlignment = Alignment.Right).inner(Rect(x = 0, y = 0, width = 0, height = 1)), 120 | Rect(x = 0, y = 1, width = 0, height = 0) 121 | ) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/src/scala/tui/cassowary/QuadrilateralTest.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package cassowary 3 | 4 | import tui.cassowary.WeightedRelation._ 5 | import tui.cassowary.operators._ 6 | import tui.internal.ranges 7 | 8 | class QuadrilateralTest extends TuiTest { 9 | test("test_quadrilateral") { 10 | 11 | case class Point( 12 | x: Variable = Variable(), 13 | y: Variable = Variable() 14 | ) 15 | val values = Values() 16 | 17 | val points = Array(Point(), Point(), Point(), Point()) 18 | val point_starts = Array((10.0, 10.0), (10.0, 200.0), (200.0, 200.0), (200.0, 10.0)) 19 | val midpoints = Array(Point(), Point(), Point(), Point()) 20 | val solver = Solver() 21 | var weight = 1.0 22 | val multiplier = 2.0 23 | 24 | ranges.range(0, 4) { i => 25 | val cs = Array(points(i).x | EQ(Strength.WEAK * weight) | point_starts(i)._1, points(i).y | EQ(Strength.WEAK * weight) | point_starts(i)._2) 26 | 27 | // check that constraint DSL creates the correct thing 28 | if (i == 0) { 29 | val expectedCs = Seq( 30 | Constraint( 31 | expression = Expression(terms = Array(Term(variable = Variable.force(0), coefficient = 1.0)), constant = -10.0), 32 | strength = Strength(1.0), 33 | op = RelationalOperator.Equal 34 | ), 35 | Constraint( 36 | expression = Expression(terms = Array(Term(variable = Variable.force(1), coefficient = 1.0)), constant = -10.0), 37 | strength = Strength(1.0), 38 | op = RelationalOperator.Equal 39 | ) 40 | ) 41 | 42 | assertEq(expected = expectedCs.toList.toString(), actual = cs.toList.toString()) 43 | } 44 | 45 | solver.add_constraints(cs).unwrap() 46 | 47 | weight *= multiplier; 48 | } 49 | 50 | Array((0, 1), (1, 2), (2, 3), (3, 0)).foreach { case (start, end) => 51 | val cs = Array( 52 | midpoints(start).x | EQ(Strength.REQUIRED) | (points(start).x + points(end).x) / 2.0, 53 | midpoints(start).y | EQ(Strength.REQUIRED) | (points(start).y + points(end).y) / 2.0 54 | ) 55 | solver.add_constraints(cs).unwrap(); 56 | } 57 | 58 | solver 59 | .add_constraints( 60 | Array( 61 | points(0).x + 20.0 | LE(Strength.STRONG) | points(2).x, 62 | points(0).x + 20.0 | LE(Strength.STRONG) | points(3).x, 63 | points(1).x + 20.0 | LE(Strength.STRONG) | points(2).x, 64 | points(1).x + 20.0 | LE(Strength.STRONG) | points(3).x, 65 | points(0).y + 20.0 | LE(Strength.STRONG) | points(1).y, 66 | points(0).y + 20.0 | LE(Strength.STRONG) | points(2).y, 67 | points(3).y + 20.0 | LE(Strength.STRONG) | points(1).y, 68 | points(3).y + 20.0 | LE(Strength.STRONG) | points(2).y 69 | ) 70 | ) 71 | .unwrap() 72 | 73 | points.foreach { point => 74 | solver 75 | .add_constraints( 76 | Array( 77 | point.x | GE(Strength.REQUIRED) | 0.0, 78 | point.y | GE(Strength.REQUIRED) | 0.0, 79 | point.x | LE(Strength.REQUIRED) | 500.0, 80 | point.y | LE(Strength.REQUIRED) | 500.0 81 | ) 82 | ) 83 | .unwrap() 84 | } 85 | 86 | values.update_values(solver.fetch_changes()) 87 | 88 | assertEq( 89 | Array( 90 | (values.value_of(midpoints(0).x), values.value_of(midpoints(0).y)), 91 | (values.value_of(midpoints(1).x), values.value_of(midpoints(1).y)), 92 | (values.value_of(midpoints(2).x), values.value_of(midpoints(2).y)), 93 | (values.value_of(midpoints(3).x), values.value_of(midpoints(3).y)) 94 | ), 95 | Array((10.0, 105.0), (105.0, 200.0), (200.0, 105.0), (105.0, 10.0)) 96 | ) 97 | 98 | solver.add_edit_variable(points(2).x, Strength.STRONG).unwrap() 99 | solver.add_edit_variable(points(2).y, Strength.STRONG).unwrap() 100 | solver.suggest_value(points(2).x, 300.0).unwrap() 101 | solver.suggest_value(points(2).y, 400.0).unwrap() 102 | 103 | values.update_values(solver.fetch_changes()) 104 | 105 | assertEq( 106 | Array( 107 | (values.value_of(points(0).x), values.value_of(points(0).y)), 108 | (values.value_of(points(1).x), values.value_of(points(1).y)), 109 | (values.value_of(points(2).x), values.value_of(points(2).y)), 110 | (values.value_of(points(3).x), values.value_of(points(3).y)) 111 | ), 112 | Array( 113 | (10.0, 10.0), 114 | (10.0, 200.0), 115 | (300.0, 400.0), 116 | (200.0, 10.0) 117 | ) 118 | ) 119 | 120 | assertEq( 121 | Array( 122 | (values.value_of(midpoints(0).x), values.value_of(midpoints(0).y)), 123 | (values.value_of(midpoints(1).x), values.value_of(midpoints(1).y)), 124 | (values.value_of(midpoints(2).x), values.value_of(midpoints(2).y)), 125 | (values.value_of(midpoints(3).x), values.value_of(midpoints(3).y)) 126 | ), 127 | Array( 128 | (10.0, 105.0), 129 | (155.0, 300.0), 130 | (250.0, 205.0), 131 | (105.0, 10.0) 132 | ) 133 | ) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /crossterm/src/java/tui/crossterm/KeyCode.java: -------------------------------------------------------------------------------- 1 | package tui.crossterm; 2 | 3 | /// Represents a key. 4 | public sealed interface KeyCode 5 | permits KeyCode.Backspace, 6 | KeyCode.Enter, 7 | KeyCode.Left, 8 | KeyCode.Right, 9 | KeyCode.Up, 10 | KeyCode.Down, 11 | KeyCode.Home, 12 | KeyCode.End, 13 | KeyCode.PageUp, 14 | KeyCode.PageDown, 15 | KeyCode.Tab, 16 | KeyCode.BackTab, 17 | KeyCode.Delete, 18 | KeyCode.Insert, 19 | KeyCode.F, 20 | KeyCode.Char, 21 | KeyCode.Null, 22 | KeyCode.Esc, 23 | KeyCode.CapsLock, 24 | KeyCode.ScrollLock, 25 | KeyCode.NumLock, 26 | KeyCode.PrintScreen, 27 | KeyCode.Pause, 28 | KeyCode.Menu, 29 | KeyCode.KeypadBegin, 30 | KeyCode.Media, 31 | KeyCode.Modifier { 32 | /// Backspace key. 33 | record Backspace() implements KeyCode { 34 | } 35 | 36 | /// Enter key. 37 | record Enter() implements KeyCode { 38 | } 39 | 40 | /// Left arrow key. 41 | record Left() implements KeyCode { 42 | } 43 | 44 | /// Right arrow key. 45 | record Right() implements KeyCode { 46 | } 47 | 48 | /// Up arrow key. 49 | record Up() implements KeyCode { 50 | } 51 | 52 | /// Down arrow key. 53 | record Down() implements KeyCode { 54 | } 55 | 56 | /// Home key. 57 | record Home() implements KeyCode { 58 | } 59 | 60 | /// End key. 61 | record End() implements KeyCode { 62 | } 63 | 64 | /// Page up key. 65 | record PageUp() implements KeyCode { 66 | } 67 | 68 | /// Page down key. 69 | record PageDown() implements KeyCode { 70 | } 71 | 72 | /// Tab key. 73 | record Tab() implements KeyCode { 74 | } 75 | 76 | /// Shift + Tab key. 77 | record BackTab() implements KeyCode { 78 | } 79 | 80 | /// Delete key. 81 | record Delete() implements KeyCode { 82 | } 83 | 84 | /// Insert key. 85 | record Insert() implements KeyCode { 86 | } 87 | 88 | /// F key. 89 | /// 90 | /// `KeyCode::F(1)` represents F1 key, etc. 91 | record F(int num) implements KeyCode { 92 | } 93 | 94 | /// A character. 95 | /// 96 | /// `KeyCode::Char('c')` represents `c` character, etc. 97 | record Char(char c) implements KeyCode { 98 | } 99 | 100 | /// Null. 101 | record Null() implements KeyCode { 102 | } 103 | 104 | /// Escape key. 105 | record Esc() implements KeyCode { 106 | } 107 | 108 | /// Caps Lock key. 109 | /// 110 | /// **Note:** this key can only be read if 111 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 112 | /// [`PushKeyboardEnhancementFlags`]. 113 | record CapsLock() implements KeyCode { 114 | } 115 | 116 | /// Scroll Lock key. 117 | /// 118 | /// **Note:** this key can only be read if 119 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 120 | /// [`PushKeyboardEnhancementFlags`]. 121 | record ScrollLock() implements KeyCode { 122 | } 123 | 124 | /// Num Lock key. 125 | /// 126 | /// **Note:** this key can only be read if 127 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 128 | /// [`PushKeyboardEnhancementFlags`]. 129 | record NumLock() implements KeyCode { 130 | } 131 | 132 | /// Print Screen key. 133 | /// 134 | /// **Note:** this key can only be read if 135 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 136 | /// [`PushKeyboardEnhancementFlags`]. 137 | record PrintScreen() implements KeyCode { 138 | } 139 | 140 | /// Pause key. 141 | /// 142 | /// **Note:** this key can only be read if 143 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 144 | /// [`PushKeyboardEnhancementFlags`]. 145 | record Pause() implements KeyCode { 146 | } 147 | 148 | /// Menu key. 149 | /// 150 | /// **Note:** this key can only be read if 151 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 152 | /// [`PushKeyboardEnhancementFlags`]. 153 | record Menu() implements KeyCode { 154 | } 155 | 156 | /// The "Begin" key (often mapped to the 5 key when Num Lock is turned on). 157 | /// 158 | /// **Note:** this key can only be read if 159 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 160 | /// [`PushKeyboardEnhancementFlags`]. 161 | record KeypadBegin() implements KeyCode { 162 | } 163 | 164 | /// A media key. 165 | /// 166 | /// **Note:** these keys can only be read if 167 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] has been enabled with 168 | /// [`PushKeyboardEnhancementFlags`]. 169 | record Media(MediaKeyCode mediaKeyCode) implements KeyCode { 170 | } 171 | 172 | /// A modifier key. 173 | /// 174 | /// **Note:** these keys can only be read if **both** 175 | /// [`KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES`] and 176 | /// [`KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES`] have been enabled with 177 | /// [`PushKeyboardEnhancementFlags`]. 178 | record Modifier(ModifierKeyCode modifierKeyCode) implements KeyCode { 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tui/src/scala/tui/Terminal.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import scala.util.control.NonFatal 4 | 5 | /** Interface to the terminal backed by Termion 6 | * 7 | * @param backend 8 | * @param buffers 9 | * Holds the results of the current and previous draw calls. The two are compared at the end of each draw pass to output the necessary updates to the 10 | * terminal 11 | * @param current 12 | * Index of the current buffer in the previous array 13 | * @param hiddenCursor 14 | * Whether the cursor is currently hidden 15 | * @param viewport 16 | */ 17 | case class Terminal private ( 18 | backend: Backend, 19 | buffers: Array[Buffer], 20 | var current: Int, 21 | var hiddenCursor: Boolean, 22 | viewport: Viewport 23 | ) { 24 | require(buffers.length == 2) 25 | 26 | def drop(): Unit = 27 | // Attempt to restore the cursor state 28 | if (hiddenCursor) { 29 | try showCursor() 30 | catch { 31 | case NonFatal(e) => System.err.println(s"Failed to show the cursor: ${e.getMessage}") 32 | } 33 | } 34 | 35 | /** Get a Frame object which provides a consistent view into the terminal state for rendering. 36 | */ 37 | def getFrame(): Frame = 38 | Frame( 39 | buffer = currentBufferMut(), 40 | size = viewport.area, 41 | cursorPosition = None 42 | ) 43 | 44 | def currentBufferMut(): Buffer = 45 | buffers(current) 46 | 47 | /** Obtains a difference between the previous and the current buffer and passes it to the current backend for drawing. 48 | */ 49 | def flush(): Unit = { 50 | val previous_buffer = buffers(1 - current) 51 | val current_buffer = buffers(current) 52 | val updates = previous_buffer.diff(current_buffer) 53 | backend.draw(updates) 54 | } 55 | 56 | /** Updates the Terminal so that internal buffers match the requested size. Requested size will be saved so the size can remain consistent when rendering. 57 | * This leads to a full clear of the screen. 58 | */ 59 | def resize(area: Rect): Unit = { 60 | buffers(current).resize(area) 61 | buffers(1 - current).resize(area) 62 | viewport.area = area 63 | clear() 64 | } 65 | 66 | /** Queries the backend for size and resizes if it doesn't match the previous size. 67 | */ 68 | def autoresize(): Unit = 69 | if (viewport.resizeBehavior == ResizeBehavior.Auto) { 70 | val size_ = size() 71 | if (size_ != viewport.area) { 72 | resize(size_) 73 | } 74 | } 75 | 76 | /** Synchronizes terminal size, calls the rendering closure, flushes the current internal state and prepares for the next draw call. 77 | */ 78 | def draw(f: Frame => Unit): CompletedFrame = { 79 | // Autoresize - otherwise we get glitches if shrinking or potential desync between widgets 80 | // and the terminal (if growing), which may OOB. 81 | autoresize() 82 | 83 | val frame = getFrame() 84 | f(frame) 85 | // We can't change the cursor position right away because we have to flush the frame to 86 | // stdout first. But we also can't keep the frame around, since it holds a &mut to 87 | // Terminal. Thus, we're taking the important data out of the Frame and dropping it. 88 | val cursor_position = frame.cursorPosition 89 | 90 | // Draw to stdout 91 | flush() 92 | 93 | cursor_position match { 94 | case None => hideCursor() 95 | case Some((x, y)) => 96 | showCursor() 97 | setCursor(x, y) 98 | } 99 | 100 | // Swap buffers 101 | buffers(1 - current).reset() 102 | current = 1 - current 103 | 104 | // Flush 105 | backend.flush() 106 | CompletedFrame( 107 | buffer = buffers(1 - current), 108 | area = viewport.area 109 | ) 110 | } 111 | 112 | def hideCursor(): Unit = { 113 | backend.hideCursor() 114 | hiddenCursor = true 115 | } 116 | 117 | def showCursor(): Unit = { 118 | backend.showCursor() 119 | hiddenCursor = false 120 | } 121 | 122 | def getCursor(): (Int, Int) = 123 | backend.getCursor() 124 | 125 | def setCursor(x: Int, y: Int): Unit = 126 | backend.setCursor(x, y) 127 | 128 | /** Clear the terminal and force a full redraw on the next draw call. 129 | */ 130 | def clear(): Unit = { 131 | backend.clear() 132 | // Reset the back buffer to make sure the next update will redraw everything. 133 | buffers(1 - current).reset() 134 | } 135 | 136 | /** Queries the real size of the backend. 137 | */ 138 | def size(): Rect = 139 | backend.size() 140 | } 141 | 142 | object Terminal { 143 | 144 | /** Wrapper around Terminal initialization. Each buffer is initialized with a blank string and default colors for the foreground and the background 145 | */ 146 | def init(backend: Backend): Terminal = { 147 | 148 | val size = backend.size() 149 | 150 | Terminal.withOptions( 151 | backend, 152 | TerminalOptions( 153 | viewport = Viewport( 154 | area = size, 155 | resizeBehavior = ResizeBehavior.Auto 156 | ) 157 | ) 158 | ) 159 | } 160 | 161 | // UNSTABLE 162 | def withOptions(backend: Backend, options: TerminalOptions): Terminal = 163 | Terminal( 164 | backend, 165 | buffers = Array( 166 | Buffer.empty(options.viewport.area), 167 | Buffer.empty(options.viewport.area) 168 | ), 169 | current = 0, 170 | hiddenCursor = false, 171 | viewport = options.viewport 172 | ) 173 | } 174 | -------------------------------------------------------------------------------- /demo/src/scala/tuiexamples/UserInputExample.scala: -------------------------------------------------------------------------------- 1 | /// A simple example demonstrating how to handle user input. This is 2 | /// a bit out of the scope of the library as it does not provide any 3 | /// input handling out of the box. However, it may helps some to get 4 | /// started. 5 | /// 6 | /// This is a very simple example: 7 | /// * A input box always focused. Every character you type is registered 8 | /// here 9 | /// * Pressing Backspace erases a character 10 | /// * Pressing Enter pushes the current input in the history of previous 11 | /// messages 12 | 13 | package tuiexamples 14 | 15 | import tui._ 16 | import tui.crossterm.{CrosstermJni, KeyCode} 17 | import tui.widgets.{BlockWidget, ParagraphWidget} 18 | import tui.widgets.ListWidget 19 | 20 | object UserInputExample { 21 | sealed trait InputMode 22 | object InputMode { 23 | case object Normal extends InputMode 24 | case object Editing extends InputMode 25 | } 26 | 27 | /// App holds the state of the application 28 | case class App( 29 | /// Current value of the input box 30 | var input: String = "", 31 | /// Current input mode 32 | var input_mode: InputMode = InputMode.Normal, 33 | /// History of recorded messages 34 | var messages: Array[String] = Array.empty 35 | ) 36 | 37 | def main(args: Array[String]): Unit = withTerminal { (jni, terminal) => 38 | // create app and run it 39 | val app = App() 40 | run_app(terminal, app, jni) 41 | } 42 | 43 | def run_app(terminal: Terminal, app: App, jni: CrosstermJni): Unit = 44 | while (true) { 45 | terminal.draw(f => ui(f, app)) 46 | 47 | jni.read() match { 48 | case key: tui.crossterm.Event.Key => 49 | app.input_mode match { 50 | case InputMode.Normal => 51 | key.keyEvent().code() match { 52 | case c: KeyCode.Char if c.c == 'e' => app.input_mode = InputMode.Editing; 53 | case c: KeyCode.Char if c.c == 'q' => return 54 | case _ => () 55 | } 56 | case InputMode.Editing => 57 | key.keyEvent().code() match { 58 | case _: KeyCode.Enter => 59 | app.messages = app.messages :+ app.input 60 | app.input = "" 61 | case c: KeyCode.Char => app.input = app.input + c.c(); 62 | case _: KeyCode.Backspace => app.input = app.input.substring(0, app.input.length - 1) 63 | case _: KeyCode.Esc => app.input_mode = InputMode.Normal; 64 | case _ => () 65 | } 66 | } 67 | case _ => () 68 | } 69 | } 70 | 71 | def ui(f: Frame, app: App): Unit = { 72 | val chunks = Layout( 73 | direction = Direction.Vertical, 74 | margin = Margin(2), 75 | constraints = Array(Constraint.Length(1), Constraint.Length(3), Constraint.Min(5)) 76 | ).split(f.size) 77 | 78 | val (msg, style) = app.input_mode match { 79 | case InputMode.Normal => 80 | ( 81 | Text.from( 82 | Span.nostyle("Press "), 83 | Span.styled("q", Style.DEFAULT.addModifier(Modifier.BOLD)), 84 | Span.nostyle(" to exit, "), 85 | Span.styled("e", Style.DEFAULT.addModifier(Modifier.BOLD)), 86 | Span.nostyle(" to start editing.") 87 | ), 88 | Style.DEFAULT.addModifier(Modifier.RAPID_BLINK) 89 | ) 90 | case InputMode.Editing => 91 | ( 92 | Text.from( 93 | Span.nostyle("Press "), 94 | Span.styled("Esc", Style.DEFAULT.addModifier(Modifier.BOLD)), 95 | Span.nostyle(" to stop editing, "), 96 | Span.styled("Enter", Style.DEFAULT.addModifier(Modifier.BOLD)), 97 | Span.nostyle(" to record the message") 98 | ), 99 | Style.DEFAULT 100 | ) 101 | } 102 | val text = msg.overwrittenStyle(style) 103 | 104 | val help_message = ParagraphWidget(text = text) 105 | f.renderWidget(help_message, chunks(0)) 106 | 107 | val input = ParagraphWidget( 108 | text = Text.nostyle(app.input), 109 | block = Some(BlockWidget(borders = Borders.ALL, title = Some(Spans.nostyle("Input")))), 110 | style = app.input_mode match { 111 | case InputMode.Normal => Style.DEFAULT 112 | case InputMode.Editing => Style.DEFAULT.fg(Color.Yellow) 113 | } 114 | ) 115 | f.renderWidget(input, chunks(1)) 116 | 117 | app.input_mode match { 118 | case InputMode.Normal => 119 | // Hide the cursor. `Frame` does this by default, so we don't need to do anything here 120 | () 121 | 122 | case InputMode.Editing => 123 | // Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering 124 | f.setCursor( 125 | // Put cursor past the end of the input text 126 | x = chunks(1).x + Grapheme(app.input).width + 1, 127 | // Move one line down, from the border to the input line 128 | y = chunks(1).y + 1 129 | ) 130 | } 131 | 132 | val items: Array[ListWidget.Item] = 133 | app.messages.zipWithIndex.map { case (m, i) => ListWidget.Item(content = Text.nostyle(s"$i: $m")) } 134 | 135 | val messages = 136 | ListWidget( 137 | items = items, 138 | block = Some(BlockWidget(borders = Borders.ALL, title = Some(Spans.nostyle("Messages")))) 139 | ) 140 | f.renderWidget(messages, chunks(2)) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/ListWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | import tui.internal.ranges 5 | import tui.internal.saturating._ 6 | 7 | /** A widget to display several items among which one can be selected (optional) 8 | * 9 | * @param block 10 | * @param items 11 | * @param style 12 | * Style used as a base style for the widget 13 | * @param startCorner 14 | * @param highlightStyle 15 | * Style used to render selected item 16 | * @param highlightSymbol 17 | * Symbol in front of the selected item (Shift all items to the right) 18 | * @param repeatHighlightSymbol 19 | * Whether to repeat the highlight symbol for each line of the selected item 20 | */ 21 | case class ListWidget( 22 | block: Option[BlockWidget] = None, 23 | items: Array[ListWidget.Item], 24 | style: Style = Style.DEFAULT, 25 | startCorner: Corner = Corner.TopLeft, 26 | highlightStyle: Style = Style.DEFAULT, 27 | highlightSymbol: Option[String] = None, 28 | repeatHightlightSymbol: Boolean = false 29 | ) extends Widget 30 | with StatefulWidget { 31 | 32 | def getItemsBounds( 33 | selected0: Option[Int], 34 | offset0: Int, 35 | maxHeight: Int 36 | ): (Int, Int) = { 37 | val offset = math.min(offset0, items.length.saturating_sub_unsigned(1)) 38 | var start = offset 39 | var end = offset 40 | var height = 0 41 | val it = items.iterator.drop(offset) 42 | var continue = true 43 | while (continue && it.hasNext) { 44 | val item = it.next() 45 | if (height + item.height > maxHeight) { 46 | continue = false 47 | } else { 48 | height += item.height 49 | end += 1 50 | } 51 | } 52 | 53 | val selected = math.min(selected0.getOrElse(0), items.length - 1) 54 | while (selected >= end) { 55 | height = height.saturating_add(items(end).height) 56 | end += 1 57 | while (height > maxHeight) { 58 | height = height.saturating_sub_unsigned(items(start).height) 59 | start += 1 60 | } 61 | } 62 | while (selected < start) { 63 | start -= 1 64 | height = height.saturating_add(items(start).height) 65 | while (height > maxHeight) { 66 | end -= 1 67 | height = height.saturating_sub_unsigned(items(end).height) 68 | } 69 | } 70 | (start, end) 71 | } 72 | 73 | type State = ListWidget.State 74 | 75 | def render(area: Rect, buf: Buffer, state: State): Unit = { 76 | buf.setStyle(area, style) 77 | val list_area = block match { 78 | case Some(b) => 79 | val inner_area = b.inner(area) 80 | b.render(area, buf) 81 | inner_area 82 | case None => area 83 | } 84 | 85 | if (list_area.width < 1 || list_area.height < 1) { 86 | return 87 | } 88 | 89 | if (items.isEmpty) { 90 | return 91 | } 92 | val list_height = list_area.height 93 | 94 | val (start, end) = getItemsBounds(state.selected, state.offset, list_height) 95 | state.offset = start 96 | 97 | val highlight_symbol1 = highlightSymbol.getOrElse("") 98 | val blank_symbol = " ".repeat(Grapheme(highlight_symbol1).width) 99 | 100 | var current_height = 0 101 | val has_selection = state.selected.isDefined 102 | ranges.range(state.offset, state.offset + end - start) { i => 103 | val item = items(i) 104 | val (x, y) = startCorner match { 105 | case Corner.BottomLeft => 106 | current_height += item.height 107 | (list_area.left, list_area.bottom - current_height) 108 | case _ => 109 | val pos = (list_area.left, list_area.top + current_height) 110 | current_height += item.height 111 | pos 112 | } 113 | val area = Rect(x, y, width = list_area.width, height = item.height) 114 | 115 | val item_style = style.patch(item.style) 116 | buf.setStyle(area, item_style) 117 | 118 | val is_selected = state.selected.contains(i) 119 | item.content.lines.zipWithIndex.foreach { case (line, j) => 120 | // if the item is selected, we need to display the hightlight symbol: 121 | // - either for the first line of the item only, 122 | // - or for each line of the item if the appropriate option is set 123 | val symbol = if (is_selected && (j == 0 || repeatHightlightSymbol)) { 124 | highlight_symbol1 125 | } else { 126 | blank_symbol 127 | } 128 | val (elem_x, max_element_width) = if (has_selection) { 129 | val (elem_x, _) = buf.setStringn( 130 | x, 131 | y + j, 132 | symbol, 133 | list_area.width, 134 | item_style 135 | ) 136 | (elem_x, list_area.width - (elem_x - x)) 137 | } else { 138 | (x, list_area.width) 139 | } 140 | buf.setSpans(elem_x, y + j, line, max_element_width); 141 | } 142 | if (is_selected) { 143 | buf.setStyle(area, highlightStyle) 144 | } 145 | } 146 | } 147 | 148 | def render(area: Rect, buf: Buffer): Unit = { 149 | val state = ListWidget.State() 150 | render(area, buf, state) 151 | } 152 | } 153 | 154 | object ListWidget { 155 | case class State( 156 | var offset: Int = 0, 157 | var selected: Option[Int] = None 158 | ) { 159 | def select(index: Option[Int]): Unit = { 160 | selected = index 161 | if (index.isEmpty) { 162 | offset = 0 163 | } 164 | } 165 | } 166 | 167 | case class Item(content: Text, style: Style = Style.DEFAULT) { 168 | def height: Int = content.height 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tui/src/scala/tui/widgets/BlockWidget.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | import tui.Style 5 | import tui.internal.ranges._ 6 | import tui.internal.saturating._ 7 | 8 | /** Base widget to be used with all upper level ones. It may be used to display a box border around the widget and/or add a title. 9 | * 10 | * @param title 11 | * Optional title place on the upper left of the block 12 | * @param titleAlignment 13 | * Title alignment. The default is top left of the block, but one can choose to place title in the top middle, or top right of the block 14 | * @param borders 15 | * Visible borders 16 | * @param borderStyle 17 | * Border style 18 | * @param borderType 19 | * Type of the border. The default is plain lines but one can choose to have rounded corners or doubled lines instead. 20 | * @param style 21 | * Widget style 22 | */ 23 | case class BlockWidget( 24 | title: Option[Spans] = None, 25 | titleAlignment: Alignment = Alignment.Left, 26 | borders: Borders = Borders.NONE, 27 | borderStyle: Style = Style.DEFAULT, 28 | borderType: BlockWidget.BorderType = BlockWidget.BorderType.Plain, 29 | style: Style = Style.DEFAULT 30 | ) extends Widget { 31 | 32 | /** Compute the inner area of a block based on its border visibility rules. 33 | */ 34 | def inner(area: Rect): Rect = { 35 | var inner = area 36 | if (borders.intersects(Borders.LEFT)) { 37 | inner = inner.copy( 38 | x = inner.x.saturating_add(1).min(inner.right), 39 | width = inner.width.saturating_sub_unsigned(1) 40 | ) 41 | } 42 | if (borders.intersects(Borders.TOP) || title.isDefined) { 43 | inner = inner.copy( 44 | y = inner.y.saturating_add(1).min(inner.bottom), 45 | height = inner.height.saturating_sub_unsigned(1) 46 | ) 47 | } 48 | if (borders.intersects(Borders.RIGHT)) { 49 | inner = inner.copy(width = inner.width.saturating_sub_unsigned(1)) 50 | } 51 | if (borders.intersects(Borders.BOTTOM)) { 52 | inner = inner.copy(height = inner.height.saturating_sub_unsigned(1)) 53 | } 54 | inner 55 | } 56 | 57 | def render(area: Rect, buf: Buffer): Unit = { 58 | if (area.area == 0) { 59 | return 60 | } 61 | buf.setStyle(area, style) 62 | val symbols = BlockWidget.BorderType.lineSymbols(borderType) 63 | 64 | // Sides 65 | if (borders.intersects(Borders.LEFT)) { 66 | range(area.top, area.bottom) { y => 67 | buf 68 | .get(area.left, y) 69 | .setSymbol(symbols.vertical) 70 | .setStyle(borderStyle) 71 | () 72 | } 73 | } 74 | 75 | if (borders.intersects(Borders.TOP)) { 76 | range(area.left, area.right) { x => 77 | buf 78 | .get(x, area.top) 79 | .setSymbol(symbols.horizontal) 80 | .setStyle(borderStyle) 81 | () 82 | } 83 | } 84 | if (borders.intersects(Borders.RIGHT)) { 85 | val x = area.right - 1 86 | range(area.top, area.bottom) { y => 87 | buf 88 | .get(x, y) 89 | .setSymbol(symbols.vertical) 90 | .setStyle(borderStyle) 91 | () 92 | } 93 | } 94 | if (borders.intersects(Borders.BOTTOM)) { 95 | val y = area.bottom - 1 96 | range(area.left, area.right) { x => 97 | buf 98 | .get(x, y) 99 | .setSymbol(symbols.horizontal) 100 | .setStyle(borderStyle) 101 | () 102 | } 103 | } 104 | 105 | // Corners 106 | if (borders.contains(Borders.RIGHT | Borders.BOTTOM)) { 107 | buf 108 | .get(area.right - 1, area.bottom - 1) 109 | .setSymbol(symbols.bottomRight) 110 | .setStyle(borderStyle) 111 | } 112 | if (borders.contains(Borders.RIGHT | Borders.TOP)) { 113 | buf 114 | .get(area.right - 1, area.top) 115 | .setSymbol(symbols.topRight) 116 | .setStyle(borderStyle) 117 | } 118 | if (borders.contains(Borders.LEFT | Borders.BOTTOM)) { 119 | buf 120 | .get(area.left, area.bottom - 1) 121 | .setSymbol(symbols.bottomLeft) 122 | .setStyle(borderStyle) 123 | } 124 | if (borders.contains(Borders.LEFT | Borders.TOP)) { 125 | buf 126 | .get(area.left, area.top) 127 | .setSymbol(symbols.topLeft) 128 | .setStyle(borderStyle) 129 | } 130 | 131 | // Title 132 | title.foreach { title => 133 | val left_border_dx = if (borders.intersects(Borders.LEFT)) 1 else 0 134 | 135 | val right_border_dx = if (borders.intersects(Borders.RIGHT)) { 1 } 136 | else { 0 } 137 | 138 | val title_area_width = area.width 139 | .saturating_sub_unsigned(left_border_dx) 140 | .saturating_sub_unsigned(right_border_dx) 141 | 142 | val title_dx = titleAlignment match { 143 | case Alignment.Left => left_border_dx 144 | case Alignment.Center => area.width.saturating_sub_unsigned(title.width) / 2 145 | case Alignment.Right => 146 | area.width 147 | .saturating_sub_unsigned(title.width) 148 | .saturating_sub_unsigned(right_border_dx) 149 | } 150 | 151 | val title_x = area.left + title_dx 152 | val title_y = area.top 153 | 154 | buf.setSpans(title_x, title_y, title, title_area_width); 155 | } 156 | } 157 | } 158 | 159 | object BlockWidget { 160 | sealed trait BorderType 161 | 162 | object BorderType { 163 | case object Plain extends BorderType 164 | 165 | case object Rounded extends BorderType 166 | 167 | case object Double extends BorderType 168 | 169 | case object Thick extends BorderType 170 | 171 | def lineSymbols(borderType: BorderType): symbols.line.Set = 172 | borderType match { 173 | case BorderType.Plain => symbols.line.NORMAL 174 | case BorderType.Rounded => symbols.line.ROUNDED 175 | case BorderType.Double => symbols.line.DOUBLE 176 | case BorderType.Thick => symbols.line.THICK 177 | } 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /tui/src/scala/tui/CrosstermBackend.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import tui.crossterm.{Attribute, Command, CrosstermJni} 4 | 5 | import java.util 6 | 7 | class CrosstermBackend(buffer: CrosstermJni) extends Backend { 8 | override def flush(): Unit = 9 | buffer.flush() 10 | 11 | override def draw(content: Array[(Int, Int, Cell)]): Unit = { 12 | var fg: Color = Color.Reset 13 | var bg: Color = Color.Reset 14 | var modifier = Modifier.EMPTY 15 | var last_pos: Option[(Int, Int)] = None 16 | val commands = new util.ArrayList[Command]() 17 | 18 | content.foreach { case (x, y, cell) => 19 | // Move the cursor if the previous location was not (x - 1, y) 20 | def shouldMove = last_pos match { 21 | case Some((lastX, lastY)) if x == lastX + 1 && y == lastY => false 22 | case _ => true 23 | } 24 | if (shouldMove) { commands.add(new Command.MoveTo(x, y)) } 25 | last_pos = Some((x, y)) 26 | 27 | if (cell.modifier != modifier) { 28 | val diff = CrosstermBackend.ModifierDiff(from = modifier, to = cell.modifier) 29 | diff.queue(commands) 30 | modifier = cell.modifier 31 | } 32 | if (cell.fg != fg) { 33 | val color = CrosstermBackend.from(cell.fg) 34 | commands.add(new Command.SetForegroundColor(color)) 35 | fg = cell.fg 36 | } 37 | if (cell.bg != bg) { 38 | val color = CrosstermBackend.from(cell.bg) 39 | commands.add(new Command.SetBackgroundColor(color)) 40 | bg = cell.bg 41 | } 42 | commands.add(new Command.Print(cell.symbol.str)) 43 | } 44 | commands.add(new Command.SetForegroundColor(new crossterm.Color.Reset())) 45 | commands.add(new Command.SetBackgroundColor(new crossterm.Color.Reset())) 46 | commands.add(new Command.SetAttribute(crossterm.Attribute.Reset)) 47 | 48 | buffer.enqueue(commands) 49 | } 50 | 51 | override def hideCursor(): Unit = 52 | buffer.execute(new Command.Hide()) 53 | 54 | override def showCursor(): Unit = 55 | buffer.execute(new Command.Show()) 56 | 57 | override def getCursor(): (Int, Int) = { 58 | val xy = buffer.cursorPosition() 59 | (xy.x(), xy.y()) 60 | } 61 | 62 | override def setCursor(x: Int, y: Int): Unit = 63 | buffer.execute(new Command.MoveTo(x, y)) 64 | 65 | override def clear(): Unit = 66 | buffer.execute(new Command.Clear(crossterm.ClearType.All)) 67 | 68 | override def size(): Rect = { 69 | val xy = buffer.terminalSize() 70 | Rect(0, 0, xy.x(), xy.y()) 71 | } 72 | } 73 | 74 | object CrosstermBackend { 75 | def apply(buffer: CrosstermJni) = new CrosstermBackend(buffer) 76 | 77 | def from(color: Color): crossterm.Color = 78 | color match { 79 | case Color.Reset => new crossterm.Color.Reset() 80 | case Color.Black => new crossterm.Color.Black() 81 | case Color.Red => new crossterm.Color.DarkRed() 82 | case Color.Green => new crossterm.Color.DarkGreen() 83 | case Color.Yellow => new crossterm.Color.DarkYellow() 84 | case Color.Blue => new crossterm.Color.DarkBlue() 85 | case Color.Magenta => new crossterm.Color.DarkMagenta() 86 | case Color.Cyan => new crossterm.Color.DarkCyan() 87 | case Color.Gray => new crossterm.Color.Grey() 88 | case Color.DarkGray => new crossterm.Color.DarkGrey() 89 | case Color.LightRed => new crossterm.Color.Red() 90 | case Color.LightGreen => new crossterm.Color.Green() 91 | case Color.LightBlue => new crossterm.Color.Blue() 92 | case Color.LightYellow => new crossterm.Color.Yellow() 93 | case Color.LightMagenta => new crossterm.Color.Magenta() 94 | case Color.LightCyan => new crossterm.Color.Cyan() 95 | case Color.White => new crossterm.Color.White() 96 | case Color.Indexed(i) => new crossterm.Color.AnsiValue(i) 97 | case Color.Rgb(r, g, b) => new crossterm.Color.Rgb(r, g, b) 98 | } 99 | 100 | case class ModifierDiff(from: Modifier, to: Modifier) { 101 | def queue(commands: util.ArrayList[Command]): Unit = { 102 | val removed = from - to 103 | if (removed.contains(Modifier.REVERSED)) { 104 | commands.add(new Command.SetAttribute(Attribute.NoReverse)) 105 | } 106 | if (removed.contains(Modifier.BOLD)) { 107 | commands.add(new Command.SetAttribute(Attribute.NormalIntensity)) 108 | if (to.contains(Modifier.DIM)) { 109 | commands.add(new Command.SetAttribute(Attribute.Dim)) 110 | } 111 | } 112 | if (removed.contains(Modifier.ITALIC)) { 113 | commands.add(new Command.SetAttribute(Attribute.NoItalic)) 114 | } 115 | if (removed.contains(Modifier.UNDERLINED)) { 116 | commands.add(new Command.SetAttribute(Attribute.NoUnderline)) 117 | } 118 | if (removed.contains(Modifier.DIM)) { 119 | commands.add(new Command.SetAttribute(Attribute.NormalIntensity)) 120 | } 121 | if (removed.contains(Modifier.CROSSED_OUT)) { 122 | commands.add(new Command.SetAttribute(Attribute.NotCrossedOut)) 123 | } 124 | if (removed.contains(Modifier.SLOW_BLINK) || removed.contains(Modifier.RAPID_BLINK)) { 125 | commands.add(new Command.SetAttribute(Attribute.NoBlink)) 126 | } 127 | 128 | val added = to - from 129 | if (added.contains(Modifier.REVERSED)) { 130 | commands.add(new Command.SetAttribute(Attribute.Reverse)) 131 | } 132 | if (added.contains(Modifier.BOLD)) { 133 | commands.add(new Command.SetAttribute(Attribute.Bold)) 134 | } 135 | if (added.contains(Modifier.ITALIC)) { 136 | commands.add(new Command.SetAttribute(Attribute.Italic)) 137 | } 138 | if (added.contains(Modifier.UNDERLINED)) { 139 | commands.add(new Command.SetAttribute(Attribute.Underlined)) 140 | } 141 | if (added.contains(Modifier.DIM)) { 142 | commands.add(new Command.SetAttribute(Attribute.Dim)) 143 | } 144 | if (added.contains(Modifier.CROSSED_OUT)) { 145 | commands.add(new Command.SetAttribute(Attribute.CrossedOut)) 146 | } 147 | if (added.contains(Modifier.SLOW_BLINK)) { 148 | commands.add(new Command.SetAttribute(Attribute.SlowBlink)) 149 | } 150 | if (added.contains(Modifier.RAPID_BLINK)) { 151 | commands.add(new Command.SetAttribute(Attribute.RapidBlink)) 152 | } 153 | () 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /cassowary/src/scala/tui/cassowary/operators.scala: -------------------------------------------------------------------------------- 1 | package tui.cassowary 2 | 3 | import scala.collection.mutable 4 | 5 | object operators { 6 | implicit class DoubleOps(private val self: Double) extends AnyVal { 7 | def |(rhs: WeightedRelation): PartialConstraint = PartialConstraint(Expression.from_constant(self), rhs) 8 | 9 | def +(rhs: Variable) = new Expression(mutable.ArrayBuffer(Term(rhs, 1.0)), self) 10 | 11 | def +(rhs: Term): Expression = Expression(mutable.ArrayBuffer(rhs), self) 12 | 13 | def +(rhs: Expression): Expression = { 14 | rhs.constant = rhs.constant + self 15 | rhs 16 | } 17 | 18 | def -(rhs: Variable): Expression = Expression(mutable.ArrayBuffer(Term(rhs, -1.0)), self) 19 | 20 | def -(rhs: Term): Expression = Expression(mutable.ArrayBuffer(!rhs), self) 21 | 22 | def -(rhs: Expression): Expression = { 23 | rhs.negate() 24 | rhs.constant += self 25 | rhs 26 | } 27 | 28 | def *(rhs: Variable): Term = Term(rhs, self) 29 | 30 | def *(rhs: Term): Term = { 31 | rhs.coefficient = rhs.coefficient * self 32 | rhs 33 | } 34 | 35 | def *(rhs: Expression): Expression = { 36 | rhs.constant = rhs.constant * self 37 | var i = 0 38 | while (i < rhs.terms.length) { 39 | rhs.terms(i) = rhs.terms(i) * self 40 | i += 1 41 | } 42 | rhs 43 | } 44 | } 45 | 46 | implicit class VariableOps(private val self: Variable) extends AnyVal { 47 | def |(rhs: WeightedRelation): PartialConstraint = PartialConstraint(Expression.from_term(Term(self, coefficient = 1.0)), rhs) 48 | 49 | def +(rhs: Double) = new Expression(mutable.ArrayBuffer(Term(self, 1.0)), rhs) 50 | 51 | def +(rhs: Variable): Expression = Expression(Array(Term(self, 1.0), Term(rhs, 1.0)), 0.0) 52 | 53 | def +(rhs: Term): Expression = Expression(Array(Term(self, 1.0), rhs), 0.0) 54 | 55 | def +(rhs: Expression): Expression = { 56 | rhs.terms += Term(self, 1.0) 57 | rhs 58 | } 59 | 60 | def unary_! : Term = Term(self, -1.0) 61 | 62 | def -(rhs: Double): Expression = Expression(mutable.ArrayBuffer(Term(self, 1.0)), -rhs) 63 | 64 | def -(rhs: Variable): Expression = Expression(mutable.ArrayBuffer(Term(self, 1.0), Term(rhs, -1.0)), 0.0) 65 | 66 | def -(rhs: Term): Expression = Expression(mutable.ArrayBuffer(Term(self, 1.0), !rhs), 0.0) 67 | 68 | def -(rhs: Expression): Expression = { 69 | rhs.negate() 70 | rhs.terms.addOne(Term(self, 1.0)) 71 | rhs 72 | } 73 | def *(rhs: Double): Term = Term(self, rhs) 74 | def /(rhs: Double): Term = Term(self, 1.0 / rhs) 75 | } 76 | 77 | implicit class TermOps(private val self: Term) extends AnyVal { 78 | def |(rhs: WeightedRelation): PartialConstraint = PartialConstraint(Expression.from_term(self), rhs) 79 | 80 | def +(rhs: Variable): Expression = Expression(Array(self, Term(rhs, 1.0)), 0.0) 81 | 82 | def +(rhs: Double): Expression = Expression(mutable.ArrayBuffer(self), rhs) 83 | 84 | def +(rhs: Term): Expression = Expression(mutable.ArrayBuffer(self, rhs), 0.0) 85 | 86 | def +(rhs: Expression): Expression = { 87 | rhs.terms.addOne(self) 88 | rhs 89 | } 90 | 91 | def unary_! : Term = { 92 | self.coefficient = -self.coefficient 93 | self 94 | } 95 | 96 | def -(rhs: Variable): Expression = Expression(mutable.ArrayBuffer(self, Term(rhs, -1.0)), 0.0) 97 | 98 | def -(rhs: Double): Expression = Expression(mutable.ArrayBuffer(self), -rhs) 99 | 100 | def -(rhs: Term): Expression = Expression(mutable.ArrayBuffer(self, !rhs), 0.0) 101 | 102 | def -(rhs: Expression): Expression = { 103 | rhs.negate() 104 | rhs.terms.addOne(self) 105 | rhs 106 | } 107 | def *(rhs: Double): Term = { 108 | self.coefficient = self.coefficient * rhs 109 | self 110 | } 111 | def /(rhs: Double): Term = { 112 | self.coefficient = self.coefficient / rhs 113 | self 114 | } 115 | } 116 | 117 | implicit class ExpressionOps(private val self: Expression) extends AnyVal { 118 | def |(rhs: WeightedRelation): PartialConstraint = PartialConstraint(self, rhs) 119 | 120 | def +(rhs: Variable): Expression = { 121 | self.terms.addOne(Term(rhs, 1.0)) 122 | self 123 | } 124 | 125 | def +(self: Expression, rhs: Term): Expression = { 126 | self.terms.addOne(rhs) 127 | self 128 | } 129 | 130 | def +(rhs: Double): Expression = { 131 | self.constant = self.constant + rhs 132 | self 133 | } 134 | 135 | def +(rhs: Expression): Expression = { 136 | self.terms.addAll(rhs.terms) 137 | self.constant = rhs.constant 138 | self 139 | } 140 | 141 | def unary_! : Expression = { 142 | self.negate() 143 | self 144 | } 145 | 146 | def -(rhs: Double): Expression = { 147 | self.constant = self.constant - rhs 148 | self 149 | } 150 | 151 | def -(rhs: Variable): Expression = { 152 | self.terms.addOne(Term(rhs, -1.0)) 153 | self 154 | } 155 | 156 | def -(rhs: Term): Expression = { 157 | self.terms.addOne(!rhs) 158 | self 159 | } 160 | 161 | def -(rhs: Expression): Expression = { 162 | rhs.negate() 163 | self.terms.addAll(rhs.terms) 164 | self.constant += rhs.constant 165 | self 166 | } 167 | def *(rhs: Double): Expression = { 168 | self.constant = self.constant * rhs 169 | var i = 0 170 | while (i < self.terms.length) { 171 | self.terms(i) = self.terms(i) * rhs 172 | i += 1 173 | } 174 | self 175 | } 176 | def /(rhs: Double): Expression = { 177 | self.constant = self.constant / rhs 178 | var i = 0 179 | while (i < self.terms.length) { 180 | self.terms(i) = self.terms(i) / rhs 181 | i += 1 182 | } 183 | self 184 | } 185 | } 186 | 187 | implicit class PartialConstraintOps(private val self: PartialConstraint) extends AnyVal { 188 | def |(rhs: Double): Constraint = { 189 | val (op, s) = self.wr.into 190 | Constraint(self.e - rhs, s, op) 191 | } 192 | def |(rhs: Variable): Constraint = { 193 | val (op, s) = self.wr.into 194 | Constraint(self.e - rhs, s, op) 195 | } 196 | def |(rhs: Term): Constraint = { 197 | val (op, s) = self.wr.into 198 | Constraint(self.e - rhs, s, op) 199 | } 200 | def |(rhs: Expression): Constraint = { 201 | val (op, s) = self.wr.into 202 | Constraint(self.e - rhs, s, op) 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /tests/src/scala/tui/widgets/ParagraphTests.scala: -------------------------------------------------------------------------------- 1 | package tui 2 | package widgets 3 | 4 | class ParagraphTests extends TuiTest { 5 | test("it_does_not_panic_if_max_is_zero") { 6 | val widget = SparklineWidget(data = Array(0, 0, 0)) 7 | val area = Rect(0, 0, 3, 1) 8 | val buffer = Buffer.empty(area) 9 | widget.render(area, buffer) 10 | } 11 | 12 | val SAMPLE_STRING = 13 | "The library is based on the principle of immediate rendering with intermediate buffers. This means that at each new frame you should build all widgets that are supposed to be part of the UI. While providing a great flexibility for rich and interactive UI, this may introduce overhead for highly dynamic content." 14 | 15 | test("widgets_paragraph_can_wrap_its_content") { 16 | val test_case = (alignment: Alignment, expected: Buffer) => { 17 | val backend = TestBackend(20, 10) 18 | val terminal = Terminal.init(backend) 19 | 20 | terminal.draw { f => 21 | val text = Text.from(Spans.nostyle(SAMPLE_STRING)) 22 | val paragraph = ParagraphWidget( 23 | text = text, 24 | block = Some(BlockWidget(borders = Borders.ALL)), 25 | wrap = Some(ParagraphWidget.Wrap(trim = true)), 26 | alignment = alignment 27 | ) 28 | f.renderWidget(paragraph, f.size); 29 | } 30 | assertBuffer(backend, expected) 31 | } 32 | 33 | test_case( 34 | Alignment.Left, 35 | Buffer.withLines( 36 | "┌──────────────────┐", 37 | "│The library is │", 38 | "│based on the │", 39 | "│principle of │", 40 | "│immediate │", 41 | "│rendering with │", 42 | "│intermediate │", 43 | "│buffers. This │", 44 | "│means that at each│", 45 | "└──────────────────┘" 46 | ) 47 | ) 48 | test_case( 49 | Alignment.Right, 50 | Buffer.withLines( 51 | "┌──────────────────┐", 52 | "│ The library is│", 53 | "│ based on the│", 54 | "│ principle of│", 55 | "│ immediate│", 56 | "│ rendering with│", 57 | "│ intermediate│", 58 | "│ buffers. This│", 59 | "│means that at each│", 60 | "└──────────────────┘" 61 | ) 62 | ) 63 | test_case( 64 | Alignment.Center, 65 | Buffer.withLines( 66 | "┌──────────────────┐", 67 | "│ The library is │", 68 | "│ based on the │", 69 | "│ principle of │", 70 | "│ immediate │", 71 | "│ rendering with │", 72 | "│ intermediate │", 73 | "│ buffers. This │", 74 | "│means that at each│", 75 | "└──────────────────┘" 76 | ) 77 | ) 78 | } 79 | 80 | test("widgets_paragraph_renders_double_width_graphemes") { 81 | val backend = TestBackend(width = 10, height = 10) 82 | val terminal = Terminal.init(backend) 83 | 84 | val s = "コンピュータ上で文字を扱う場合、典型的には文字による通信を行う場合にその両端点では、" 85 | terminal.draw { f => 86 | val paragraph = ParagraphWidget(text = Text.nostyle(s), block = Some(BlockWidget(borders = Borders.ALL)), wrap = Some(ParagraphWidget.Wrap(trim = true))) 87 | f.renderWidget(paragraph, f.size); 88 | } 89 | 90 | val expected = Buffer.withLines( 91 | "┌────────┐", 92 | "│コンピュ│", 93 | "│ータ上で│", 94 | "│文字を扱│", 95 | "│う場合、│", 96 | "│典型的に│", 97 | "│は文字に│", 98 | "│よる通信│", 99 | "│を行う場│", 100 | "└────────┘" 101 | ) 102 | assertBuffer(backend, expected) 103 | } 104 | 105 | test("widgets_paragraph_renders_mixed_width_graphemes") { 106 | val backend = TestBackend(10, 7) 107 | val terminal = Terminal.init(backend) 108 | 109 | terminal.draw { f => 110 | val text = Text.nostyle("aコンピュータ上で文字を扱う場合、") 111 | val paragraph = ParagraphWidget(text = text, block = Some(BlockWidget(borders = Borders.ALL)), wrap = Some(ParagraphWidget.Wrap(trim = true))) 112 | f.renderWidget(paragraph, f.size); 113 | } 114 | 115 | val expected = Buffer.withLines( 116 | // The internal width is 8 so only 4 slots for double-width characters. 117 | "┌────────┐", 118 | "│aコンピ │", // Here we have 1 latin character so only 3 double-width ones can fit. 119 | "│ュータ上│", 120 | "│で文字を│", 121 | "│扱う場合│", 122 | "│、 │", 123 | "└────────┘" 124 | ) 125 | assertBuffer(backend, expected) 126 | } 127 | 128 | test("widgets_paragraph_can_wrap_with_a_trailing_nbsp") { 129 | val nbsp = "\u00a0" 130 | val line = Text.from(Span.nostyle("NBSP"), Span.nostyle(nbsp)) 131 | val backend = TestBackend(20, 3) 132 | val terminal = Terminal.init(backend) 133 | val expected = Buffer.withLines( 134 | "┌──────────────────┐", 135 | "│NBSP\u00a0 │", 136 | "└──────────────────┘" 137 | ) 138 | terminal.draw { f => 139 | val paragraph = ParagraphWidget(text = line, block = Some(BlockWidget(borders = Borders.ALL))) 140 | f.renderWidget(paragraph, f.size); 141 | } 142 | assertBuffer(backend, expected) 143 | } 144 | 145 | test("widgets_paragraph_can_scroll_horizontally") { 146 | val test_case = (alignment: Alignment, scroll: (Int, Int), expected: Buffer) => { 147 | val backend = TestBackend(20, 10) 148 | val terminal = Terminal.init(backend) 149 | 150 | terminal.draw { f => 151 | val text = Text.nostyle("段落现在可以水平滚动了!\nParagraph can scroll horizontally!\nShort line") 152 | val paragraph = ParagraphWidget(text = text, block = Some(BlockWidget(borders = Borders.ALL)), alignment = alignment, scroll = scroll) 153 | f.renderWidget(paragraph, f.size); 154 | } 155 | assertBuffer(backend, expected) 156 | } 157 | 158 | test_case( 159 | Alignment.Left, 160 | (0, 7), 161 | Buffer.withLines( 162 | "┌──────────────────┐", 163 | "│在可以水平滚动了!│", 164 | "│ph can scroll hori│", 165 | "│ine │", 166 | "│ │", 167 | "│ │", 168 | "│ │", 169 | "│ │", 170 | "│ │", 171 | "└──────────────────┘" 172 | ) 173 | ) 174 | // only support Alignment.Left 175 | test_case( 176 | Alignment.Right, 177 | (0, 7), 178 | Buffer.withLines( 179 | "┌──────────────────┐", 180 | "│段落现在可以水平滚│", 181 | "│Paragraph can scro│", 182 | "│ Short line│", 183 | "│ │", 184 | "│ │", 185 | "│ │", 186 | "│ │", 187 | "│ │", 188 | "└──────────────────┘" 189 | ) 190 | ) 191 | } 192 | } 193 | --------------------------------------------------------------------------------