spruce logo

spruce

Package Version Hex Docs

A terminal-UI kit for Gleam. spruce renders styled terminal output — colors, boxes, semantic message lines, icons, deterministic hash-colors, ANSI-aware alignment, and grouped/indented output — that automatically respects the terminal’s color support. It is logging-agnostic and runs on both Erlang and JavaScript.

spruce builds on gleam_community_ansi for styling and tty for color-support detection.

gleam add spruce
import spruce

pub fn main() {
  // Detect the terminal's color support once, then thread the context
  // through render functions. Use `spruce.no_color()` for deterministic
  // output (e.g. in tests or when piping).
  let sp = spruce.detect()
  echo spruce.supports_color(sp)
}

The Spruce context

Every render function takes an explicit Spruce value, which carries:

This keeps rendering pure and testable: spruce.no_color() produces escape-free, deterministic strings.

Modules

Example

import spruce
import spruce/box
import spruce/group
import spruce/message

pub fn main() {
  let sp = spruce.detect()
  box.print(sp, "spruce")
  group.group(sp, "Building", fn(sp) {
    message.print_start(sp, "compiling")
    message.print_success(sp, "done")
  })
}
import spruce
import spruce/details
import spruce/line
import spruce/message
import spruce/severity

pub fn compact_line_example() {
  let sp = spruce.detect()
  let meta =
    details.new()
    |> details.add("duration", "42ms")
    |> details.add("target", "javascript")

  line.new("Build complete")
  |> line.severity(severity.Info)
  |> line.scope("build")
  |> line.details(meta)
  |> line.render(sp, _)
  |> echo

  message.success_with(
    sp,
    "Build complete",
    message.default_options() |> message.with_formatter(message.badge()),
  )
  |> echo
}

Composability

Every spruce primitive is a plain value or a String-returning function, so they combine freely.

Styles are reusable values. Build one with style.new, extend it with more combinators, and apply it with style.render. Hash colors and adaptive light/dark colors compose the same way.

import spruce
import spruce/palette
import spruce/style

pub fn styles() {
  let sp = spruce.detect()

  // Build a style once, then derive variants from it.
  let heading = style.new() |> style.bold |> style.underline
  let accent = heading |> style.fg(style.Cyan)
  echo style.render(sp, accent, "spruce")

  // `palette.hash` returns a `Style`, so it pipes into more combinators.
  let service = palette.hash(sp, "api") |> style.bold
  echo style.render(sp, service, "api")

  // Adaptive colors resolve against the detected background at render time.
  let brand =
    style.new()
    |> style.fg(style.adaptive(
      light: style.Hex(0x0369a1),
      dark: style.Hex(0x7dd3fc),
    ))
  echo style.render(sp, brand, "brand")
}

Renderers nest, because they all return String. Render a table, then drop it straight into a box:

import spruce
import spruce/box
import spruce/style
import spruce/table

pub fn renderers_nest() {
  let sp = spruce.detect()

  let grid =
    table.new()
    |> table.headers(["package", "target"])
    |> table.rows([["spruce", "erlang"], ["spruce", "javascript"]])
    |> table.render(sp, _)

  box.render(sp, grid, box.options(title: "build", color: style.Cyan))
  |> echo
}

Containers nest their own structure. Lists and trees compose to any depth, and the parent’s kind and enumerator drive rendering throughout:

import spruce
import spruce/list

pub fn lists_nest() {
  let sp = spruce.detect()

  list.new()
  |> list.kind(list.Ordered)
  |> list.item("setup")
  |> list.nested(
    "build",
    list.new() |> list.item("erlang") |> list.item("javascript"),
  )
  |> list.render(sp, _)
  |> echo
}

Multi-line blocks compose side by side. spruce/layout joins blocks horizontally or vertically while staying ANSI-aware:

import gleam/string
import spruce/layout

pub fn columns() {
  let names = ["package", "spruce", "tty"] |> string.join("\n")
  let versions = ["version", "1.0.0", "1.1.0"] |> string.join("\n")

  // Each block is padded to its own width, so the columns line up.
  layout.join_horizontal(layout.Start, [names, "   ", versions])
  |> echo
}

Thread the context through a pipeline. spruce/output accumulates rendered blocks so several renderers — and nested groups — compose with |> and emit together. append works with any Spruce -> String renderer via a _ capture, and nothing prints until print:

import spruce
import spruce/message
import spruce/output

pub fn report() {
  let sp = spruce.detect()

  output.new(sp)
  |> output.append(message.start(_, "compiling"))
  |> output.group("Tests", fn(o) {
    o
    |> output.append(message.success(_, "erlang"))
    |> output.append(message.success(_, "javascript"))
  })
  |> output.append(message.ready(_, "release ready"))
  |> output.print
}

For eager, streaming grouping that prints as work happens and can return a value from the body, reach for spruce/group.group instead.

Development

just build   # compile
just test    # run tests on both targets
just lint    # format check + glinter
just ci      # full validation

Further documentation will be available at https://hexdocs.pm/spruce.

Search Document