Skip to content

mattlianje/layoutz

Repository files navigation

layoutz

Simple, beautiful CLI output 🪶

Build declarative and composable sections, trees, tables, dashboards, and interactive Elm-style TUIs. Easily create new primitives (no component-library limitations).

Part of d4 • Also in: JavaScript, Haskell

Supported by

Features

  • Use Layoutz.scala like a header-file
  • Effortless composition of elements
  • Rich text formatting: alignment, wrapping, justification, underlines, padding, truncation
  • ANSI colors and wide character support
  • Lists, trees, tables, charts, progress bars, spinners...
  • Easy creation of custom elements
  • Thread-safe, purely functional rendering
  • LayoutzApp for Elm-style TUIs


interactive task listsimple game

Table of Contents

Installation

layoutz is on MavenCentral and cross-built for Scala, 2.12, 2.13, 3.x

"xyz.matthieucourt" %% "layoutz" % "0.5.0"

Or try in REPL:

scala-cli repl --scala 3 --dep xyz.matthieucourt:layoutz_3:0.5.0

All you need:

import layoutz._

Quickstart

There are two usage paths with this little package:

(1/2) Static rendering

Beautiful + compositional strings

import layoutz._

val demo = layout(
  underline("ˆ")("Test Dashboard").center(),
  row(
    statusCard("API", "LIVE").border(Border.Double),
    statusCard("DB", "99.9%"),
    statusCard("Cache", "READY").border(Border.Thick)
  ),
  box("Services")(
    ul("Production", "Staging", 
       ul("test-api", 
          ul("more nest")
         )
      ),
    inlineBar("Health", 0.94)
  ).border(Border.Round)
)

println(demo.render)
            Test Dashboard
            ˆˆˆˆˆˆˆˆˆˆˆˆˆˆ
╔════════╗ ┌─────────┐ ┏━━━━━━━━━┓
║ API    ║ │ DB      │ ┃ Cache   ┃
║ LIVE   ║ │ 99.9%   │ ┃ READY   ┃
╚════════╝ └─────────┘ ┗━━━━━━━━━┛
╭─────────────Services──────────────╮
│ • Production                      │
│ • Staging                         │
│   ◦ test-api                      │
│     ▪ more nest                   │
│ Health [██████████████████──] 94% │
╰───────────────────────────────────╯

(2/2) Interactive apps

Build Elm-style TUIs

import layoutz._

object CounterApp extends LayoutzApp[Int, String] {
  def init = 0

  def update(msg: String, count: Int) = msg match {
    case "inc" => count + 1
    case "dec" => count - 1
    case _     => count
  }

  def subscriptions(count: Int) = 
    Sub.onKeyPress {
      case CharKey('+') => Some("inc")
      case CharKey('-') => Some("dec")
      case _            => None
    }

  def view(count: Int) = layout(
    section("Counter")(s"Count: $count"),
    br,
    ul("Press `+` or `-`")
  )
}

CounterApp.run() /* call .run to start your app */

Why layoutz?

  • We have s"...", and full-blown TUI libraries - but there is a gap in-between.
  • With LLM's, boilerplate code that formats & "pretty-prints" is cheaper than ever...
  • Thus, more than ever, "string formatting code" is spawning, and polluting domain logic
  • Ultimately, layoutz is just a tiny, declarative DSL to combat this
  • One the side, layoutz also has a Elm-style runtime to bring these arbitrary "Elements" to life: much like a flipbook.
    • The runtime has some little niceties built-in like common cmd's like file I/O, HTTP-requests, and a key input handler
  • But at the end of the day, you can use layoutz merely to structure Strings (without any of the TUI stuff)

Core concepts

  • Every piece of content is an Element
  • Elements are immutable and composable - you build complex layouts by combining simple elements.
  • A layout is just a special element that arranges other elements vertically with consistent spacing:
layout(elem1, elem2, elem3)  /* Joins with "\n" */
  • Call .render on an element to get a String or .putStrLn to render and print in one call.
  • The power comes from uniform composition, since everything is an Element, everything can be combined with everything else.
  • Since you can extend this Element interface, you can create any Elements you can imagine... and they will compose with all the other layoutz built-in Elements ... and don't need to rely on a side-car component library.

Fluent API

Some typesetting elements work as both nouns ("an underline") and verbs ("to underline something").

For these, layoutz offers a so-called "fluent" syntax with transformations avaible in infix position via dot-completion (They boil down to the same case classes and render the same thing under the hood... it is just a matter of taste and how your brain works).

Nested style:

margin(">>")(underline()("Hello\nWorld!"))

Fluent style:

"Hello\nWorld!".underline.margin(">>")

Both fluent and nested will render:

>> Hello
>> World!
>> ──────

Available: .center(), .pad(), .wrap(), .truncate(), .underline(), .margin()

Elements

All the building blocks you can use in your layouts:

Text: Text

layoutz implicitly converts Strings to Text elements:

"Simple text"
Text("Simple text") /* <- you don't need to do this */

Line Break: br

Add extra line-break "\n" with br:

layout("Line 1", br, "Line 2")

Section: section

section("Config")(kv("env" -> "prod"))
section("Status", "-")(kv("health" -> "ok"))
section("Report", "#", 5)(kv("items" -> "42"))
=== Config ===
env : prod

--- Status ---
health : ok

##### Report #####
items : 42

Layout (vertical): layout

layout("First", "Second", "Third")
First
Second
Third

Row (horizontal): row

row("Left", "Middle", "Right")
Left Middle Right

Columns: columns

columns(
  layout("Tasks", ul("Setup", "Code", ul("more stuff"))),
  layout("Status", "foo", "bar", "baz")
)
Tasks           Status
• Setup         foo
• Code          bar
  ◦ more stuff  baz

Horizontal rule: hr

hr
hr.width(10).char("~")
──────────────────────────────────────────────────
~~~~~~~~~~

Key-value pairs: kv

kv("name" -> "Alice", "role" -> "admin")
name : Alice
role : admin

Table: table

Tables automatically normalize row lengths - truncating long rows and padding short ones:

table(
  headers = Seq("Name", "Age", "City"),
  rows = Seq(
    Seq("Alice", "30", "New York"),
    Seq("Bob", "25"),                          /* Short row - auto-padded */
    Seq("Charlie", "35", "London", "Extra")    /* Long row - auto-truncated */
  )
)
┌─────────┬─────┬─────────┐
│ Name    │ Age │ City    │
├─────────┼─────┼─────────┤
│ Alice   │ 30  │ New York│
│ Bob     │ 25  │         │
│ Charlie │ 35  │ London  │
└─────────┴─────┴─────────┘

Ordered Lists: ol

Automatically numbered lists

ol("First step", "Second step", "Third step")
1. First step
2. Second step
3. Third step

Hierarchical nested numbering

ol(
  "Setup",
  ol("Install tools", "Configure IDE"),
  "Development",
  ol("Write code", ol("Unit tests", "Integration tests")),
  "Deploy"
)
1. Setup
  a. Install tools
  b. Configure IDE
2. Development
  a. Write code
    i. Unit tests
    ii. Integration tests
3. Deploy

Mix with other elements

ol(
  "Initialize project",
  ul("Create repo", "Setup CI/CD"),
  inlineBar("Progress", 0.6)
)
1. Initialize project
2. • Create repo
   • Setup CI/CD
3. Progress [████████████────────] 60%

Unordered Lists: ul

Clean unordered lists with custom bullets

ul("Feature A", "Feature B", "Feature C")
ul("")("Item 1", "Item 2")
• Feature A
• Feature B
• Feature C

→ Item 1
→ Item 2

Nested lists with auto-styling

ul(
  "Backend",
  ul("API", "Database"),
  "Frontend",
  ul("Components", ul("Header", ul("Footer")))
)
• Backend
  ◦ API
  ◦ Database
• Frontend
  ◦ Components
    ▪ Header
      • Footer

Underline: underline

Add underlines to any element

/* Fluent syntax */
"Important Title".underline()
"Custom".underline("=")

underline()("Important Title")
underline("=")("Custom")
Important Title
───────────────

Custom
══════

Colors: Color

Just add ANSI coloring with .color and Color.<...> to see what is available

Color.Red("The quick brown fox...")
"The quick brown fox...".color(Color.BrightCyan)
"The quick brown fox...".underlineColored("~", Color.Red)
"The quick brown fox...".marginColored("[INFO]", Color.Cyan)

Colors:

  • Black Red Green Yellow Blue Magenta Cyan White
  • BrightBlack BrightRed BrightGreen BrightYellow BrightBlue BrightMagenta BrightCyan BrightWhite
  • NoColor (for conditional formatting)

Extended colors:

  • Color.Full(n) - 256-color palette (0-255)
  • Color.True(r, g, b) - 24-bit RGB true color
import layoutz._

/* 256-color palette gradient */
val palette = tightRow((16 to 231 by 7).map(i => "".color(Color.Full(i))): _*)

/* RGB gradients */
val redToBlue = tightRow((0 to 255 by 8).map(i => "".color(Color.True(i, 100, 255 - i))): _*)
val greenFade = tightRow((0 to 255 by 8).map(i => "".color(Color.True(0, 255 - i, i))): _*)
val rainbow = tightRow((0 to 255 by 8).map { i =>
  val r = if (i < 128) i * 2 else 255
  val g = if (i < 128) 255 else (255 - i) * 2
  val b = if (i > 128) (i - 128) * 2 else 0
  "".color(Color.True(r, g, b))
}: _*)

layout(palette, redToBlue, greenFade, rainbow).putStrLn

Styles: Style

ANSI styles are added the same way with .style and Style.<...>

"The quick brown fox...".style(Style.Bold)
"The quick brown fox...".color(Color.Red).style(Style.Bold)
"The quick brown fox...".style(Style.Reverse).style(Style.Italic)

Styles:

  • Bold Dim Italic Underline
  • Blink Reverse Hidden Strikethrough
  • NoStyle (for conditional formatting)

Combining styles: Use ++ to combine multiple styles at once:

"Fancy!".style(Style.Bold ++ Style.Italic ++ Style.Underline)
table(<???>).border(Border.Thick).style(Style.Bold ++ Style.Reverse)

Create your Custom Elements

Create your own components by implementing the Element trait

For example lets create a square we can re-use:

case class Square(size: Int) extends Element {
  def render: String = {
    if (size < 2) return ""
    val width = size * 2 - 2
    val top = "" + ("" * width) + ""
    val middle = (1 to size - 2).map(_ => "" + (" " * width) + "")
    val bottom = "" + ("" * width) + ""
    (top +: middle :+ bottom).mkString("\n")
  }
}

Then re-use it like any element:

row(Square(2), Square(4), Square(6))
┌──┐ ┌──────┐ ┌──────────┐
└──┘ │      │ │          │
     │      │ │          │
     └──────┘ │          │
              │          │
              └──────────┘

Box: box

With title:

box("Summary")(kv("total" -> "42"))
┌──Summary───┐
│ total : 42 │
└────────────┘

Without title:

box(kv("total" -> "42"))
┌────────────┐
│ total : 42 │
└────────────┘

Status card: statusCard

statusCard("CPU", "45%")
┌───────┐
│ CPU   │
│ 45%   │
└───────┘

Progress bar: inlineBar

inlineBar("Download", 0.75)
Download [███████████████─────] 75%

Spinner: spinner

spinner("Loading...", frame = 3)
spinner("Processing", frame = 0, SpinnerStyle.Line)
⠸ Loading...
|| Processing

Styles: Dots (default), Line, Clock, Bounce

Tree: tree

tree("Project")(
  tree("src")(
    tree("main")(tree("App.scala")),
    tree("test")(tree("AppSpec.scala"))
  )
)
Project
└── src/
    ├── main/
    │   └── App.scala
    └── test/
        └── AppSpec.scala

Banner: banner

banner("System Dashboard").border(Border.Double)
╔═══════════════════╗
║ System Dashboard  ║
╚═══════════════════╝

Chart: chart

chart(
  "Web" -> 10,
  "Mobile" -> 20,
  "API" -> 15
)
Web            │████████████████ 10.0
Mobile         │████████████████████████████████ 20.0
API            │███████████████████████████ 15.0

Text Input: textInput

textInput("Username", "alice", "Enter your username", active = true)
textInput("Password", "", "Enter password", active = false)
> Username: alice_
  Password: Enter password

Single Choice: SingleChoice

Pick one option from a list:

SingleChoice(
  label = "How was your day?",
  options = Seq("great", "okay", "meh"),
  selected = 0,
  active = true
)
> How was your day?
  ► ● great
    ○ okay
    ○ meh

Multi Choice: MultiChoice

Pick multiple options from a list:

MultiChoice(
  label = "Favorite colors?",
  options = Seq("Red", "Blue", "Green"),
  selected = Set(0, 2),
  cursor = 1,
  active = true
)
> Favorite colors? (space to toggle, enter to confirm)
    ☑ Red
  ► ☐ Blue
    ☑ Green

Space: space

Add horizontal spacing

layout("Left", space(10), "Right")
Left          Right

Padding: pad

Add uniform padding around any element

/* Fluent */
"content".pad(2)
box(kv("cpu" -> "45%")).pad(1)

/* Nested */
pad(2)("content")
pad(1)(box(kv("cpu" -> "45%")))
      
  content  
      

Truncation: truncate

Truncate long text with ellipsis

/* Fluent */
"This is a very long text that will be cut off".truncate(15)
"Custom ellipsis example text here".truncate(20, "")

/* Nested */
truncate(15)("This is a very long text that will be cut off")
truncate(20, "")("Custom ellipsis example text here")
This is a ve...
Custom ellipsis ex…

Empty Element: empty

Useful for conditional rendering

layout(
  "Always shown",
  if (hasError) "Something failed!".margin("[error]") else empty,
  "Also always shown"
)

Vertical Rule: vr

Vertical separators to complement horizontal rules

vr(3)           // 3-line vertical separator
vr(5, "")      // Custom character
│
│
│

Margin: margin

Add prefix margins to elements for compiler-style error messages:

layout(
  "Ooops!",
  row("val result: Int = ", underline("^")("getString()")),
  "Expected Int, found String"
).margin("[error]")
[error] Ooops!
[error] val result: Int =  getString()
[error]                    ^^^^^^^^^^^
[error] Expected Int, found String

Available in both fluent (.margin()) and nested syntax (margin("prefix")()).

Text Formatting & Layout

Alignment: center,leftAlign,rightAlign

Align text within a specified width

/* Fluent */
"TITLE".center(20)
"Left side".leftAlign(20)
"Right side".rightAlign(20)

/* Nested */
center("TITLE", 20)
leftAlign("Left side", 20)
rightAlign("Right side", 20)
        TITLE  
Left side           
          Right side

Works with multiline text:

"Line 1\nLine 2".center(15)
   Line 1   
   Line 2   

Text Wrapping: wrap

Wrap long text at word boundaries

/* Fluent */
"This is a very long line that should be wrapped at word boundaries".wrap(20)

/* Nested */
wrap("This is a very long line that should be wrapped at word boundaries", 20)
This is a very long
line that should be
wrapped at word
boundaries

Text Justification: justify,justifyAll

Distribute spaces to fit exact width

"All the lines\nmaybe the last".justify(20).render
"All the lines\nmaybe the last".justifyAll(20).render

/* Nested syntax normal */
justify("All the lines\nmaybe the last", 20).render
justifyAll("All the lines\nmaybe the last", 20).render
All     the    lines
maybe the last

All     the    lines
maybe    the    last

Border Styles

Elements like box, table, statusCard, and banner support different Border options using the typeclass-based .border() method:

Single (default):

box("Title")("").border(Border.Single)
/* default style is Border.Single, so same as: box("Title")("") */
┌─Title─┐
│       │
└───────┘

Double:

banner("Welcome").border(Border.Double)
╔═════════╗
║ Welcome ║
╚═════════╝

Thick:

table(headers, rows).border(Border.Thick)
┏━━━━━━━┳━━━━━━━━┓
┃ Name  ┃ Status ┃
┣━━━━━━━╋━━━━━━━━┫
┃ Alice ┃ Online ┃
┗━━━━━━━┻━━━━━━━━┛

Round:

box("Info")("").border(Border.Round)
╭─Info─╮
│      │
╰──────╯

Custom:

box("Hello hello")("World!").border(
  Border.Custom(
    corner = "+",
    horizontal = "=",
    vertical = "|"
  )
)
+==Hello hello==+
| World!        |
+===============+

Border.None

You can also disable borders entirely, which can be quite nice especially for tables:

val t = table(
    Seq("Name", "Role", "Status"),
    Seq(
      Seq("Alice", "Engineer", "Online"),
      Seq("Eve", "QA", "Away"),
      Seq(
        ul("Gegard", ul("Mousasi", ul("was a BAD man"))),
        "Fighter",
        "Nasty"
      )
    )
  ).border(Border.Round)
  Name                  Role       Status

  Alice                 Engineer   Online
  Eve                   QA         Away
  • Gegard              Fighter    Nasty
    ◦ Mousasi
      ▪ was a BAD man

HasBorder Typeclass

All border styling is done via the HasBorder typeclass, which allows you to write generic code that works with any bordered element:

Knowing about this typeclass can be of use as you extend the Element interface to make your own elements. For example this function that would work with any implementer of HasBorder

def makeThick[T: HasBorder](element: T): T = element.border(Border.Thick)

Working with collections

The full power of Scala functional collections is at your fingertips to render your strings with layoutz

case class User(name: String, role: String)
val users = Seq(User("Alice", "Admin"), User("Bob", "User"), User("Tom", "User"))

val usersByRole = users.groupBy(_.role)
section("Users by Role")(
  layout(
    usersByRole.map { case (role, roleUsers) =>
      box(role)(
        ul(roleUsers.map(_.name): _*)
      )
    }.toSeq: _*
  )
)
=== Users by Role ===
┌──Admin──┐
│ • Alice │
└─────────┘

┌──User──┐
│ • Bob  │
│ • Tom  │
└────────┘

Interactive Apps

Build Elm-style terminal applications with the LayoutzApp architecture. The Elm Architecture creates unidirectional data flow from inputs to view (re)rendering

LayoutzApp[State, Message]

Implement this trait:

trait LayoutzApp[State, Message] {
  def init: (State, Cmd[Message])                                   /* Initial state and startup commands */
  def update(message: Message, state: State): (State, Cmd[Message]) /* Apply message to state */
  def subscriptions(state: State): Sub[Message]                     /* Declare event listeners */
  def view(state: State): Element                                   /* Render state to UI */
}

The .run() method handles the event loop, terminal management, and threading automatically.

The layoutz runtime spawns three daemon threads:

  • Render thread - Continuously renders your view to the terminal (~50ms intervals)
  • Tick thread - Handles time-based subscriptions and file/HTTP polling (~10ms intervals)
  • Input thread - Blocks on terminal input, converts keys to messages via subscriptions

All state updates happen synchronously through update, keeping your app logic simple and predictable.

Key Types

layoutz comes with a built-in little ADT to handle keyboard input

CharKey(c: Char)           /* 'a', '1', ' ', etc. */
EnterKey, BackspaceKey, TabKey, EscapeKey, DeleteKey
ArrowUpKey, ArrowDownKey, ArrowLeftKey, ArrowRightKey
SpecialKey(name: String)   /* Ctrl+Q, Ctrl+S, etc. */

Subscriptions

Listen to ongoing events or create timers:

Subscription Description
Sub.none No subscriptions
Sub.onKeyPress(handler) Keyboard input
Sub.time.every(intervalMs, msg) Timers, animations, periodic ticks
Sub.file.watch(path, onChange) File changes
Sub.http.poll(url, intervalMs, onResponse, headers) HTTP polling
Sub.batch(sub1, sub2, ...) Multiple subscriptions

Example:

import layoutz._

sealed trait Msg
case object Tick extends Msg
case class ConfigChanged(content: Either[String, String]) extends Msg
case object Quit extends Msg

def subscriptions(state: State) = Sub.batch(
  Sub.time.every(100, Tick),
  Sub.file.watch("config.json", cfg => ConfigChanged(cfg)),
  Sub.onKeyPress { case CharKey('q') => Some(Quit); case _ => None }
)

Commands

Layoutz comes with some helpers to make common one-shot side effects like http requests and file I/O. Use Cmd.perform as your escape hatch for custom side effects:

Command Result Type Description
Cmd.none - No command to execute (default)
Cmd.batch(cmd1, cmd2, ...) - Execute multiple commands
Cmd.file.read(path, onResult) Either[String, String] Read file contents
Cmd.file.write(path, content, onResult) Either[String, Unit] Write to file
Cmd.file.ls(path, onResult) Either[String, List[String]] List directory contents
Cmd.file.cwd(onResult) Either[String, String] Get current working directory
Cmd.http.get(url, onResult, headers) Either[String, String] HTTP GET request
Cmd.http.post(url, body, onResult, headers) Either[String, String] HTTP POST request
Cmd.http.bearerAuth(token) Map[String, String] Create Bearer auth header
Cmd.perform(task, onResult) Either[String, String] Custom async command

Note: With the implicit conversion, you can return just the state instead of (state, Cmd.none):

def update(msg: Msg, state: State) = msg match {
  case Increment => state.copy(count = state.count + 1)  // Automatically becomes (state, Cmd.none)
  case LoadData => (state.copy(loading = true), Cmd.file.read("data.txt", DataLoaded))
}

Examples

Small interactive TUI apps using built-in Cmd and Sub features.

File viewer

Watch and display file contents
import layoutz._

case class FileState(content: String, error: Option[String])
sealed trait Msg
case class FileLoaded(result: Either[String, String]) extends Msg

object FileViewer extends LayoutzApp[FileState, Msg] {
  val filename = "README.md"
  
  def init = (FileState("Loading...", None), Cmd.file.read(filename, FileLoaded))
  
  def update(msg: Msg, state: FileState) = msg match {
    case FileLoaded(Right(content)) => 
      (state.copy(content = content.take(500), error = None), Cmd.none)
    case FileLoaded(Left(err)) => 
      (state.copy(error = Some(err)), Cmd.none)
  }
  
  def subscriptions(state: FileState) = 
    Sub.file.watch(filename, FileLoaded)
  
  def view(state: FileState) = {
    val display = state.error match {
      case Some(err) => Color.BrightRed(s"Error: $err")
      case None => wrap(state.content, 60)
    }
    
    layout(
      underlineColored("=", Color.BrightMagenta)("File Viewer").style(Style.Bold),
      kv("File" -> filename).color(Color.BrightBlue),
      box("Content")(display).border(Border.Round),
      "Auto-reloads on file change".color(Color.BrightBlack)
    )
  }
}

FileViewer.run()

Stopwatch timer

Custom timer using `Sub.time.every`
import layoutz._

case class TimerState(seconds: Int, running: Boolean)
sealed trait Msg
case object Tick extends Msg
case object ToggleTimer extends Msg
case object ResetTimer extends Msg

object StopwatchApp extends LayoutzApp[TimerState, Msg] {
  def init = (TimerState(0, false), Cmd.none)
  
  def update(msg: Msg, state: TimerState) = msg match {
    case Tick => 
      (state.copy(seconds = state.seconds + 1), Cmd.none)
    case ToggleTimer => 
      (state.copy(running = !state.running), Cmd.none)
    case ResetTimer => 
      (TimerState(0, running = false), Cmd.none)
  }
  
  def subscriptions(state: TimerState) = Sub.batch(
    if (state.running) Sub.time.every(1000, Tick) else Sub.none,
    Sub.onKeyPress {
      case CharKey(' ') => Some(ToggleTimer)
      case CharKey('r') => Some(ResetTimer)
      case _ => None
    }
  )
  
  def view(state: TimerState) = {
    val minutes = state.seconds / 60
    val secs = state.seconds % 60
    val timeDisplay = f"$minutes%02d:$secs%02d"
    
    val statusColor = if (state.running) Color.BrightGreen else Color.BrightYellow
    val statusText = if (state.running) "RUNNING" else "PAUSED"
    
    layout(
      underlineColored("=", Color.BrightCyan)("Stopwatch").style(Style.Bold),
      "",
      box("Time")(
        timeDisplay.style(Style.Bold).center(20)
      ).color(statusColor).border(Border.Double),
      "",
      kv(
        "Status" -> statusText,
        "Elapsed" -> s"${state.seconds}s"
      ).color(Color.BrightBlue),
      "",
      ul(
        "space: start/pause",
        "r: reset"
      ).color(Color.BrightBlack)
    )
  }
}

StopwatchApp.run()

Custom side effects

Using `Cmd.perform` for side effects
import layoutz._

case class TaskState(status: String = "idle", count: Int = 0)

sealed trait Msg
case object RunTask extends Msg
case class TaskDone(result: Either[String, String]) extends Msg

object SideEffectApp extends LayoutzApp[TaskState, Msg] {
  def init = (TaskState(), Cmd.none)
  
  def update(msg: Msg, state: TaskState) = msg match {
    case RunTask =>
      (state.copy(status = "running..."),
       Cmd.perform(
         () => {
           println("Firing missile...")
           Thread.sleep(500)
           if (scala.util.Random.nextDouble() < 0.3) {
             println("Missile failed to launch")
             Left("Launch failure")
           } else {
             println("Impact confirmed")
             Right("completed")
           }
         },
         TaskDone
       ))
    
    case TaskDone(Right(_)) => 
      state.copy(status = "success", count = state.count + 1)
    
    case TaskDone(Left(err)) =>
      state.copy(status = s"error: $err")
  }
  
  def subscriptions(state: TaskState) = Sub.onKeyPress {
    case CharKey('r') => Some(RunTask)
    case _ => None
  }
  
  def view(state: TaskState) = layout(
    section("Side Effect Demo")(
      kv("Status" -> state.status, "Count" -> state.count.toString)
    ),
    "r: run task".color(Color.BrightBlack)
  )
}

SideEffectApp.run()

API poller

Poll API endpoint and display JSON
import layoutz._

case class ApiState(response: String, lastUpdate: String, error: Option[String])
sealed trait Msg
case class ApiResponse(result: Either[String, String]) extends Msg

object ApiPoller extends LayoutzApp[ApiState, Msg] {
  val apiUrl = "https://api.github.com/zen"
  
  def init = (ApiState("Loading...", "Never", None), Cmd.none)
  
  def update(msg: Msg, state: ApiState) = msg match {
    case ApiResponse(Right(data)) =>
      val now = java.time.LocalTime.now().toString.take(8)
      (state.copy(response = data, lastUpdate = now, error = None), Cmd.none)
    case ApiResponse(Left(err)) =>
      (state.copy(error = Some(err)), Cmd.none)
  }
  
  def subscriptions(state: ApiState) =
    Sub.http.poll(apiUrl, 3000, ApiResponse)
  
  def view(state: ApiState) = {
    val display = state.error match {
      case Some(err) => Color.BrightRed(s"Error: $err")
      case None => wrap(state.response, 60).color(Color.BrightGreen)
    }
    
    layout(
      underlineColored("~", Color.BrightCyan)("API Poller").style(Style.Bold),
      kv("Endpoint" -> apiUrl, "Last Update" -> state.lastUpdate).color(Color.BrightBlue),
      box("Response")(display).border(Border.Round),
      "Polls every 3s".color(Color.BrightBlack)
    )
  }
}

ApiPoller.run()

Multi-endpoint monitor

Monitor multiple APIs with `Sub.batch`
import layoutz._

case class MonitorState(
  github: String = "...",
  httpbin: String = "...",
  placeholder: String = "..."
)

sealed trait Msg
case class GithubResp(result: Either[String, String]) extends Msg
case class HttpbinResp(result: Either[String, String]) extends Msg
case class PlaceholderResp(result: Either[String, String]) extends Msg

object MultiMonitor extends LayoutzApp[MonitorState, Msg] {
  def init = (MonitorState(), Cmd.none)
  
  def update(msg: Msg, state: MonitorState) = msg match {
    case GithubResp(Right(data)) => (state.copy(github = data.take(20)), Cmd.none)
    case GithubResp(Left(e)) => (state.copy(github = s"ERROR: $e"), Cmd.none)
    case HttpbinResp(Right(_)) => (state.copy(httpbin = "UP"), Cmd.none)
    case HttpbinResp(Left(e)) => (state.copy(httpbin = s"ERROR: $e"), Cmd.none)
    case PlaceholderResp(Right(_)) => (state.copy(placeholder = "UP"), Cmd.none)
    case PlaceholderResp(Left(e)) => (state.copy(placeholder = s"ERROR: $e"), Cmd.none)
  }
  
  def subscriptions(state: MonitorState) = Sub.batch(
    Sub.http.poll("https://api.github.com/zen", 4000, GithubResp),
    Sub.http.poll("https://httpbin.org/get", 5000, HttpbinResp),
    Sub.http.poll("https://jsonplaceholder.typicode.com/posts/1", 6000, PlaceholderResp)
  )
  
  def view(state: MonitorState) = layout(
    underlineColored("~", Color.BrightGreen)("Multi-API Monitor").style(Style.Bold),
    br,
    table(
      Seq("Service", "Status"),
      Seq(
        Seq("GitHub", state.github),
        Seq("HTTPBin", state.httpbin),
        Seq("JSONPlaceholder", state.placeholder)
      )
    ).border(Border.Round),
    br,
    "Auto-polls all endpoints".color(Color.BrightBlack)
  )
}

MultiMonitor.run()

HTTP fetch on demand

Fetch data with `Cmd.http.get`
import layoutz._

case class FetchState(data: String, loading: Boolean, count: Int)
sealed trait Msg
case object Fetch extends Msg
case class Response(result: Either[String, String]) extends Msg

object HttpFetcher extends LayoutzApp[FetchState, Msg] {
  def init = (FetchState("Press 'f' to fetch", false, 0), Cmd.none)
  
  def update(msg: Msg, state: FetchState) = msg match {
    case Fetch =>
      (state.copy(loading = true, count = state.count + 1), 
       Cmd.http.get("https://api.github.com/zen", Response))
    case Response(Right(data)) =>
      (state.copy(data = data, loading = false), Cmd.none)
    case Response(Left(err)) =>
      (state.copy(data = s"Error: $err", loading = false), Cmd.none)
  }
  
  def subscriptions(state: FetchState) = Sub.onKeyPress {
    case CharKey('f') => Some(Fetch)
    case _ => None
  }
  
  def view(state: FetchState) = {
    val status: Element = if (state.loading) spinner("Fetching", state.count % 10)
                          else Text(s"Fetched ${state.count} times")
    
    layout(
      underlineColored("=", Color.BrightCyan)("HTTP Fetcher").style(Style.Bold),
      box("Zen Quote")(wrap(state.data, 50)).border(Border.Round).color(Color.BrightGreen),
      status,
      "f: fetch".color(Color.BrightBlack)
    )
  }
}

HttpFetcher.run()

Complex task manager

Task manager with navigation, progress tracking, and stateful emojis

import layoutz._

case class TaskState(
    tasks: List[String],
    selected: Int,
    isLoading: Boolean,
    completed: Set[Int],
    progress: Double,
    startTime: Long,
    spinnerFrame: Int
)

sealed trait TaskMessage
case object MoveUp extends TaskMessage
case object MoveDown extends TaskMessage
case object StartTask extends TaskMessage
case object UpdateTick extends TaskMessage

object TaskApp extends LayoutzApp[TaskState, TaskMessage] {
  def init = (TaskState(
    tasks = List("Process data", "Generate reports", "Backup files"),
    selected = 0,
    isLoading = false,
    completed = Set.empty,
    progress = 0.0,
    startTime = 0,
    spinnerFrame = 0
  ), Cmd.none)

  def update(msg: TaskMessage, state: TaskState) = msg match {
    case MoveUp if !state.isLoading =>
      val newSelected =
        if (state.selected > 0) state.selected - 1 else state.tasks.length - 1
      (state.copy(selected = newSelected), Cmd.none)

    case MoveDown if !state.isLoading =>
      val newSelected =
        if (state.selected < state.tasks.length - 1) state.selected + 1 else 0
      (state.copy(selected = newSelected), Cmd.none)

    case StartTask if !state.isLoading =>
      (state.copy(
        isLoading = true,
        progress = 0.0,
        startTime = System.currentTimeMillis()
      ), Cmd.none)

    case UpdateTick if state.isLoading =>
      val elapsed = System.currentTimeMillis() - state.startTime
      val newProgress = math.min(1.0, elapsed / 3000.0)

      val newState = if (newProgress >= 1.0) {
        state.copy(
          isLoading = false,
          completed = state.completed + state.selected,
          progress = 1.0
        )
      } else {
        state.copy(progress = newProgress)
      }
      
      // Also update spinner frame
      (newState.copy(spinnerFrame = newState.spinnerFrame + 1), Cmd.none)

    case UpdateTick => (state.copy(spinnerFrame = state.spinnerFrame + 1), Cmd.none)
    case _           => (state, Cmd.none)
  }

  def subscriptions(state: TaskState) = Sub.batch(
    Sub.time.every(100, UpdateTick),
    Sub.onKeyPress {
      case CharKey('w') | ArrowUpKey   => Some(MoveUp)
      case CharKey('s') | ArrowDownKey => Some(MoveDown)
      case CharKey(' ') | EnterKey     => Some(StartTask)
      case _                           => None
    }
  )

  def view(state: TaskState) = {
    val taskList = state.tasks.zipWithIndex.map { case (task, index) =>
      val emoji =
        if (state.completed.contains(index)) ""
        else if (state.isLoading && index == state.selected) ""
        else "📋"
      val marker = if (index == state.selected) "" else " "
      s"$marker $emoji $task"
    }

    val status = if (state.isLoading) {
      layout(
        spinner("Processing", state.spinnerFrame),
        inlineBar("Progress", state.progress),
        f"${state.progress * 100}%.0f%% complete"
      )
    } else {
      layout("Press SPACE to start, W/S to navigate")
    }

    layout(
      section("Tasks")(Layout(taskList.map(Text))),
      section("Status")(status)
    )
  }
}

TaskApp.run()

Form input widgets

Build interactive forms with choice widgets
import layoutz._

case class FormState(
    name: String = "",
    mood: Int = 0,
    letters: Set[Int] = Set.empty,
    cursor: Int = 0,
    field: Int = 0
)

sealed trait Msg
case class TypeChar(c: Char) extends Msg
case object Backspace extends Msg
case object NextField extends Msg
case object MoveUp extends Msg
case object MoveDown extends Msg
case object Toggle extends Msg

object FormApp extends LayoutzApp[FormState, Msg] {
  val moods = Seq("great", "okay", "meh")
  val options = ('A' to 'F').map(_.toString).toSeq
  
  def init = (FormState(), Cmd.none)
  
  def update(msg: Msg, state: FormState) = msg match {
    case TypeChar(c) if state.field == 0 => 
      (state.copy(name = state.name + c), Cmd.none)
    case Backspace if state.field == 0 && state.name.nonEmpty =>
      (state.copy(name = state.name.dropRight(1)), Cmd.none)
    case MoveUp if state.field == 1 =>
      (state.copy(mood = (state.mood - 1 + moods.length) % moods.length), Cmd.none)
    case MoveDown if state.field == 1 =>
      (state.copy(mood = (state.mood + 1) % moods.length), Cmd.none)
    case MoveUp if state.field == 2 =>
      (state.copy(cursor = (state.cursor - 1 + options.length) % options.length), Cmd.none)
    case MoveDown if state.field == 2 =>
      (state.copy(cursor = (state.cursor + 1) % options.length), Cmd.none)
    case Toggle if state.field == 2 =>
      val newLetters = if (state.letters.contains(state.cursor))
        state.letters - state.cursor else state.letters + state.cursor
      (state.copy(letters = newLetters), Cmd.none)
    case NextField =>
      (state.copy(field = (state.field + 1) % 3), Cmd.none)
    case _ => (state, Cmd.none)
  }
  
  def subscriptions(state: FormState) = Sub.onKeyPress {
    case CharKey(' ') if state.field == 2 => Some(Toggle)
    case CharKey(c) if c.isLetterOrDigit || c == ' ' => Some(TypeChar(c))
    case BackspaceKey => Some(Backspace)
    case ArrowUpKey => Some(MoveUp)
    case ArrowDownKey => Some(MoveDown)
    case TabKey | EnterKey => Some(NextField)
    case _ => None
  }
  
  def view(state: FormState) = layout(
    textInput("Name", state.name, "Type here", state.field == 0),
    SingleChoice("How was your day?", moods, state.mood, state.field == 1),
    MultiChoice("Favorite letters?", options, state.letters, state.cursor, state.field == 2)
  )
}

See FormExample.scala for a complete working example.

Inspiration

About

Simple, beautiful CLI output

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •