Theming

Theme is a small struct of named colour slots plus a BorderChars glyph set. Widgets and dialogs that take (using Theme) pick up the ambient theme without needing to know what your palette looks like.

import termflow.tui.Theme

The shape

final case class Theme(
  primary:    Color,  secondary:  Color,
  error:      Color,  warning:    Color,
  success:    Color,  info:       Color,
  border:     Color,  background: Color,  foreground: Color,
  chars:      BorderChars = BorderChars.sharp
)

object Theme:
  val dark:    Theme   // shipped — default
  val light:   Theme   // shipped — light palette
  val mono:    Theme   // shipped — single-colour terminal fallback
  val rounded: Theme   // shipped — rounded box-drawing chars

Pick one as your given at the top of your view code:

import termflow.tui.{Theme, Color}
import termflow.tui.Theme.themed

given Theme = Theme.dark

val tag = "PRIMARY".themed(_.primary)
val ok  = "✓".themed(_.success)

The .themed(slot) extension takes a Theme => Color and returns a styled Text. This keeps view code free of hard-coded colours and gives you palette-swap-by-import.

Slot semantics

The names are deliberately abstract — they mean roles, not literal colours:

SlotUsed for
primaryHeadings, focus chrome, active tab
secondarySubtle headings, placeholder text
errorValidation messages, exit codes
warningCaution states
successConfirmation messages, "✓ Submitted"
infoNeutral status (timestamps, counts)
borderBox outlines, separators
backgroundFills (rare — the terminal default usually wins)
foregroundDefault text colour

The four shipped themes pick concrete Color values for each slot that look good together. When you add your own theme, copy Theme.dark and override what you need:

val termflowBranded: Theme = Theme.dark.copy(
  primary  = Color.Rgb(0xff, 0x6f, 0x3d),  // brand orange
  border   = Color.Indexed(238)
)

Capability gating

Color.Rgb(...) only emits true-colour ANSI when the terminal supports it. The AnsiRenderer reads Capabilities.colorDepth (from the Capabilities.detect(env) pass at startup) and downgrades:

Truecolor → Indexed256 → Ansi16 → Ansi8 → Mono

So an Rgb(0xff, 0x6f, 0x3d) becomes the closest 256-colour approximation on terminals that don't speak true-colour, and a basic 8-colour ANSI on really old terminals.

NO_COLOR is honoured — set the env var and the renderer drops to Mono. For everything colour-blind-related, prefer the mono theme rather than rolling your own.

Box-drawing chars

object BorderChars:
  val sharp:   BorderChars  // ┌─┐│└┘
  val rounded: BorderChars  // ╭─╮│╰╯
  val double:  BorderChars  // ╔═╗║╚╝
  val ascii:   BorderChars  // +-+|-+

When Capabilities.unicode is false, the renderer auto-substitutes ascii regardless of which set the theme picked. This means you can ship Theme.dark (which uses sharp) and it still renders correctly on a vt100.

To switch glyphs in code:

val rounded = Theme.dark.copy(chars = BorderChars.rounded)

A theme-aware widget

Most widgets in the catalogue already take (using Theme) — that's all you have to know to use them. If you're building your own widget, the same pattern applies:

def MyBadge(label: String)(using theme: Theme): VNode =
  TextNode(1.x, 1.y, List(
    " ".text,
    label.text(fg = theme.background, bg = theme.primary, bold = true),
    " ".text
  ))

Switching at runtime

Because Theme is just a value, you can swap themes from update and pass it through to view via the model:

final case class Model(theme: Theme, /* … */)

def view(m: Model): RootNode =
  given Theme = m.theme
  // … render against the active theme

The apps.themes.ThemeDemoApp sample shows side-by-side previews of every shipped theme — useful both as a smoke test for new themes and as a starting point if you're designing your own palette.

Reference

File: modules/termflow-screen/src/main/scala/termflow/tui/Theme.scala.

For the full per-slot API, see the Scaladoc.