Skip to content

Latest commit

 

History

History
655 lines (538 loc) · 19.8 KB

File metadata and controls

655 lines (538 loc) · 19.8 KB

Getting started with gala-tui

Build three small apps from scratch — counter, input form, async fetcher. Each one introduces one new concept: Update, Sub, and Cmd.

If you already know Bubble Tea or Elm: same architecture, GALA syntax, sealed-types-everywhere. Skim part 1, then jump to part 3 (async).

Prerequisites

  • A working gala binary on $PATH. Build from martianoff/gala master:
    bazel build //cmd/gala:gala
    cp bazel-bin/cmd/gala/gala_/gala ~/.local/bin/
  • A terminal that supports the ANSI alt screen (any modern macOS / Linux terminal; Windows Terminal works).

1. Hello, counter

Create a directory counter/ with two files:

counter/gala.mod:

module example.com/counter

gala dev

counter/main.gala:

package main

import (
    . "github.com/martianoff/gala-tui"
    . "martianoff/gala/std"
)

// ----- Model + Msg -----------------------------------------------------------

struct Model(N int)

sealed type Msg {
    case Inc()
    case Dec()
    case Quit()
}

// ----- Update ----------------------------------------------------------------

func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] {
    return msg match {
        case Inc()  => (m.Copy(N = m.N + 1), NoCmd[Msg]())
        case Dec()  => (m.Copy(N = m.N - 1), NoCmd[Msg]())
        case Quit() => (m, QuitCmd[Msg]())
    }
}

// ----- View ------------------------------------------------------------------

func view(m Model) Widget {
    val title = TextStyled(s"  Counter: ${m.N}  ",
        DefaultStyle().WithBold().WithFg(BrightCyan()))
    val hint = TextStyled("  +/- to change  ·  q to quit  ",
        DefaultStyle().WithDim())
    return Column(ArrayOf[LayoutChild](
        Fixed(1, title),
        Fixed(1, hint),
    ))
}

// ----- main ------------------------------------------------------------------

func main() {
    val keyToMsg = DispatchKeys[Msg](
        ArrayOf[KeyBinding[Msg]](
            KeyBind[Msg]("ctrl+c", Quit()),
            KeyBind[Msg]("q",      Quit()),
            KeyBind[Msg]("esc",    Quit()),
            KeyBind[Msg]("+",      Inc()),
            KeyBind[Msg]("=",      Inc()),
            KeyBind[Msg]("-",      Dec()),
        ),
        Inc(),   // any other key just bumps the count
    )
    val _ = RunSimple[Model, Msg](Model(N = 0), update, view, keyToMsg)
}

The pair of DispatchKeys (spec-string keymap) + RunSimple (Program + Run in one call) collapses what used to be ~25 lines of keyToMsg / charToMsg / Program(...) / Run(...) boilerplate into a single block.

Run it:

gala build ./counter
./counter

+ and - change the number. q or Ctrl-C quits.

What's going on

  • Program[M, T] is the Elm Triad: Initial, Update, View. The runtime owns the loop; you write three pure functions.
  • Update takes the current model and a message, returns the next model and a Cmd. Always pure.
  • Cmd[T] is data — NoCmd, QuitCmd, MsgCmd(t), BatchCmd(...), FutureCmd(...). The runtime interprets it.
  • Run is the simple keyboard-only entry point. We'll graduate to RunWithMouse (mouse + resize) and RunWithSub (futures + timers) later.

2. Add an input field

Replace counter/main.gala with this expanded version that lets the user type a name and renders a greeting.

package main

import (
    . "github.com/martianoff/gala-tui"
    . "martianoff/gala/std"
)

struct Model(Name string)

sealed type Msg {
    case TypeChar(C rune)
    case TypeBackspace()
    case Quit()
}

func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] {
    return msg match {
        case TypeChar(c) =>
            (m.Copy(Name = m.Name + string(c)), NoCmd[Msg]())
        case TypeBackspace() => {
            val n = RuneCount(m.Name)
            val next = if (n == 0) "" else stringDropLast(m.Name)
            (m.Copy(Name = next), NoCmd[Msg]())
        }
        case Quit() => (m, QuitCmd[Msg]())
    }
}

func view(m Model) Widget {
    val prompt = TextStyled("  Name: ", DefaultStyle().WithBold())
    val typed = TextStyled(m.Name + "▎", DefaultStyle().WithFg(BrightCyan()))
    val hello = if (m.Name == "")
        Text("  (type to set the name)")
    else
        TextStyled("  Hello, " + m.Name + "!", DefaultStyle().WithBold())
    return Column(ArrayOf[LayoutChild](
        Fixed(1, Row(ArrayOf[LayoutChild](
            Fixed(8, prompt),
            Flex(1, typed),
        ))),
        Fixed(1, Text("")),
        Fixed(1, hello),
        Fixed(1, TextStyled("  Esc / Ctrl-C to quit", DefaultStyle().WithDim())),
    ))
}

func keyToMsg(ev KeyEvent) Msg {
    if KeyMatches(ev, "ctrl+c") { return Quit() }
    return ev.Key match {
        case Esc()       => Quit()
        case Backspace() => TypeBackspace()
        case Char(c)     => TypeChar(C = c)
        case _           => TypeChar(C = ' ')
    }
}

// stringDropLast trims one rune off the end of s.
func stringDropLast(s string) string {
    var out = ""
    var keep = RuneCount(s) - 1
    var i = 0
    for _, c := range s {
        if i < keep { out = out + string(c) }
        i = i + 1
    }
    return out
}

func main() {
    val program = Program[Model, Msg](
        Initial = Model(Name = ""),
        Update  = (m, msg) => update(m, msg),
        View    = (m) => view(m),
    )
    val _ = Run[Model, Msg](program, (ev) => keyToMsg(ev))
}

Notice RuneCount — gala-tui ships grapheme-aware text helpers so Unicode input behaves correctly. string(c) round-trips a rune into a single-char string. The cursor is just a rendered character — no special cursor API needed.

3. Async — fetch a delayed result

The third app simulates a slow API call. While the request is "in flight", a spinner pulses; when it returns, we render the result.

This needs three new pieces:

  • AfterDelay(d, () => msg) — a Cmd that emits msg after d elapses (the callback shape lets the runtime invoke it lazily)
  • TickSub(Interval = d, Make = () => msg) — a Sub that fires a message every d so the spinner animates
  • RunWithSub — the async-aware runtime that polls futures and tickers
package main

import (
    . "github.com/martianoff/gala-tui"
    . "martianoff/gala/collection_immutable"
    . "martianoff/gala/std"
    . "martianoff/gala/time_utils"
)

struct Model(
    Tick    int,
    Loading bool,
    Result  string,
)

sealed type Msg {
    case Tick()
    case StartFetch()
    case FetchDone(Body string)
    case Quit()
}

func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] {
    return msg match {
        case Tick() => (m.Copy(Tick = m.Tick + 1), NoCmd[Msg]())
        case StartFetch() =>
            (m.Copy(Loading = true, Result = ""),
             AfterDelay[Msg](Seconds(int64(2)),
                 () => FetchDone(Body = "  hello from /api ")))
        case FetchDone(body) =>
            (m.Copy(Loading = false, Result = body), NoCmd[Msg]())
        case Quit() => (m, QuitCmd[Msg]())
    }
}

func view(m Model) Widget {
    val title = TextStyled("  Async demo  ",
        DefaultStyle().WithBold().WithFg(BrightCyan()))
    val status = if (m.Loading)
        Row(ArrayOf[LayoutChild](
            Fixed(2, Spinner(BrailleSpinner(), m.Tick)),
            Flex(1, Text(" loading...")),
        ))
    else if (m.Result == "")
        Text("  press SPACE to fetch")
    else
        TextStyled("  result: " + m.Result, DefaultStyle().WithFg(BrightGreen()))
    return Column(ArrayOf[LayoutChild](
        Fixed(1, title),
        Fixed(1, Text("")),
        Fixed(1, status),
        Fixed(1, Text("")),
        Fixed(1, TextStyled("  q quits", DefaultStyle().WithDim())),
    ))
}

func keyToMsg(ev KeyEvent) Msg {
    if KeyMatches(ev, "ctrl+c") { return Quit() }
    return ev.Key match {
        case Char('q') => Quit()
        case Char(' ') => StartFetch()
        case _         => Tick()       // any key just bumps the tick
    }
}

func main() {
    val program = Program[Model, Msg](
        Initial = Model(Tick = 0, Loading = false, Result = ""),
        Update  = (m, msg) => update(m, msg),
        View    = (m) => view(m),
    )
    // Tick every 100 ms so the spinner animates while we wait for fetch.
    val sub = TickSub[Msg](
        Interval = Milliseconds(int64(100)),
        Make = () => Tick(),
    )
    val _ = RunWithSub[Model, Msg](program, (ev) => keyToMsg(ev), sub)
}

AfterDelay returns a Cmd[Msg] carrying a FutureCmd. The runtime polls it every loop iteration; when the delay elapses, the future resolves to FetchDone(Body = ...) and the runtime dispatches it through update() like any other message.

TickSub returns a Sub[Msg] — same idea, but recurring. The runtime tracks each ticker's next-due time and fires Make() on the clock.

4. Mouse + resize

For mouse and window-resize support, swap RunWithSub for RunWithMouse. The makeKeyMsg parameter becomes makeInputMsg and receives an InputEvent (a sealed sum of key/mouse/resize/unknown):

func inputToMsg(ev InputEvent) Msg = ev match {
    case KeyInput(k)       => keyToMsg(k)
    case MouseInput(m)     => m.Btn match {
        case MouseScrollUp()   => Tick()       // scroll up = bump
        case MouseScrollDown() => StartFetch() // scroll down = fetch
        case _                  => Tick()
    }
    case ResizeInput(_, _) => Tick()
    case UnknownInput()    => Tick()
}

// in main:
val _ = RunWithMouse[Model, Msg](program, (ev) => inputToMsg(ev), sub)

That's it — same program, same model, same view; the runtime now also delivers mouse packets and terminal resize events.

Click on list rows

Most interactive widgets ship with a click-aware sibling constructor that bakes the click contract into the widget itself. There is no hand-rolled click map, no inputToMsg mouse-handling code: the framework registers each row's actually-rendered rectangle with its internal HitRegistry during paint, dispatches the matching message when a click lands, and falls through to your inputToMsg only for clicks that miss every registered widget.

val list = SelectListOfPick[Msg](
    items, sel, focused,
    (i) => SelectRow(Idx = i),
)

The inputToMsg adapter only sees scroll-wheel and unregistered clicks:

case MouseInput(ev) => ev.Btn match {
    case MouseScrollUp()   => ScrollUp()
    case MouseScrollDown() => ScrollDown()
    case _                 => NoOp()       // clicks already routed by the framework
}

Visibility is automatic: when a widget isn't part of the current view tree, it doesn't register hits — clicking those coordinates is inert without any guard code.

Same pattern for the rest of the catalog: ButtonClick(label, focused, msg), TabsClick(titles, bodies, sel, focused, onTabClick), DataTableViewClick(dt, focused, onRowClick), TreeFocusedClick(root, cursor, focused, onNodeClick), MenuViewClick(m, focused, onPick), DropdownViewClick(d, focused, onSelect). See the Cookbook for how to extend this to user-defined widgets with their own event vocabulary.

5. Testing

You can drive your Update with StepAll — no terminal involved, no input parsing — by feeding a list of messages and asserting on the final model.

package main

import (
    . "github.com/martianoff/gala-tui"
    . "martianoff/gala/collection_immutable"
    . "martianoff/gala/test"
)

func TestCounterIncrementsTwice(t T) T {
    val program = Program[Model, Msg](
        Initial = Model(N = 0),
        Update  = (m, msg) => update(m, msg),
        View    = (m) => view(m),
    )
    val (final, _) = StepAll(program, ArrayOf[Msg](Inc(), Inc(), Inc()))
    return Eq(t, final.N, 3)
}

For visual regressions, use the Snapshot helpers — render to a buffer and compare against a fixture string:

val out = Snapshot(view(Model(N = 7)), 40, 4)
// out is a plain text dump, one line per row, no ANSI noise.
val want = "  Counter: 7" + "\n" + "  +/- to change  ·  q to quit"
return IsTrue(t, SnapshotsEqual(out, want))

Run all the tests with gala test ./your-app.

6. Two-pane app with focus + navigation

The previous parts each had one focusable widget. Real TUIs usually have several panes the user Tab-cycles between, with arrow keys driving the focused pane and visible cursor highlighting on whichever one owns the keyboard. The framework provides three primitives so you don't hand-roll any of that:

Primitive What it owns
state.FocusManager The focus ring + Tab/Shift-Tab cycling
m.Focus.Route[T](...) "What does an arrow key mean for the focused pane?"
NewFocusBuilder(fm) Per-widget focus visualization (ui.DataTable("table", dt))

We'll build a tiny contact browser — sidebar lists names, main pane shows details of the highlighted contact. Tab cycles focus, arrows move within the focused pane, Enter on the sidebar pins the selection.

contacts/gala.mod:

module example.com/contacts

gala dev

contacts/main.gala:

package main

import (
    . "github.com/martianoff/gala-tui"
    "github.com/martianoff/gala-tui/state"
    . "martianoff/gala/collection_immutable"
    . "martianoff/gala/std"
)

// ----- Model ----------------------------------------------------------------

struct Contact(Name string, Email string, Phone string)

struct Model(
    Focus    state.FocusManager,
    Contacts Array[Contact],
    Sel      int,                  // selected contact index
    DetailLn int,                  // detail-pane cursor (0..2 for the 3 fields)
)

func initialModel() Model {
    val contacts = ArrayOf[Contact](
        Contact(Name = "Ada Lovelace",     Email = "ada@example.com",  Phone = "+44 20 7123 4567"),
        Contact(Name = "Grace Hopper",     Email = "grace@example.com", Phone = "+1 555 010 0001"),
        Contact(Name = "Alan Turing",      Email = "alan@example.com",  Phone = "+44 20 7123 4568"),
        Contact(Name = "Margaret Hamilton", Email = "marg@example.com", Phone = "+1 555 010 0002"),
    )
    return Model(
        Focus    = state.NewFocusManager(ArrayOf[string]("sidebar", "details")),
        Contacts = contacts,
        Sel      = 0,
        DetailLn = 0,
    )
}

// ----- Messages -------------------------------------------------------------

sealed type Msg {
    case TabKey()
    case ArrowUp()
    case ArrowDown()
    case Enter()
    case Quit()
    case NoOp()
}

// ----- Update ---------------------------------------------------------------

func update(m Model, msg Msg) Tuple[Model, Cmd[Msg]] {
    return msg match {
        case Quit()      => (m, QuitCmd[Msg]())
        case TabKey()    => (m.Copy(Focus = m.Focus.Next()), NoCmd[Msg]())
        case ArrowDown() => (arrowByFocus(m, +1), NoCmd[Msg]())
        case ArrowUp()   => (arrowByFocus(m, -1), NoCmd[Msg]())
        case Enter()     => (m, NoCmd[Msg]())
        case NoOp()      => (m, NoCmd[Msg]())
    }
}

// m.Focus.Route dispatches the arrow to whichever pane is focused.
// One declarative call replaces an `if focus == "sidebar" else if ...` chain.
func arrowByFocus(m Model, delta int) Model =
    m.Focus.Route[Model](
        ArrayOf[state.FocusedCase[Model]](
            state.OnPane[Model]("sidebar", () => moveSidebar(m, delta)),
            state.OnPane[Model]("details", () => moveDetails(m, delta)),
        ),
        m,   // fallback: nothing focused → unchanged
    )

func moveSidebar(m Model, delta int) Model {
    val n = m.Contacts.Length()
    val next = clampInt(m.Sel + delta, 0, n - 1)
    return m.Copy(Sel = next, DetailLn = 0)  // reset detail cursor on switch
}

func moveDetails(m Model, delta int) Model =
    m.Copy(DetailLn = clampInt(m.DetailLn + delta, 0, 2))

// ----- View -----------------------------------------------------------------

func view(m Model) Widget {
    val ui = NewFocusBuilder(m.Focus)
    val sidebar = sidebarPane(m, ui)
    val details = detailsPane(m, ui)
    return Column(ArrayOf[LayoutChild](
        Fixed(1, header(m)),
        Flex(1, Row(ArrayOf[LayoutChild](
            Fixed(28, sidebar),
            Flex(1, details),
        ))),
        Fixed(1, footer()),
    ))
}

func header(m Model) Widget =
    TextStyled(" Contacts — Tab to switch panes ",
        DefaultStyle().WithBold().WithFg(BrightCyan()))

func sidebarPane(m Model, ui FocusBuilder) Widget {
    val labels = m.Contacts.Map((c) => c.Name)
    val list = ui.SelectListOf("sidebar", labels, m.Sel)
    return Border(list)
}

func detailsPane(m Model, ui FocusBuilder) Widget {
    val c = m.Contacts.Get(m.Sel)
    val lines = ArrayOf[string](
        "Name:  " + c.Name,
        "Email: " + c.Email,
        "Phone: " + c.Phone,
    )
    val list = ui.SelectListOf("details", lines, m.DetailLn)
    return Border(list)
}

func footer() Widget =
    TextStyled(" ↑↓ move · Tab switch panes · q quit ",
        DefaultStyle().WithDim())

// ----- Key bindings ---------------------------------------------------------

func keyToMsg(ev KeyEvent) Msg {
    if KeyMatches(ev, "ctrl+c") { return Quit() }
    return ev.Key match {
        case Char(c) => charToMsg(c)
        case Tab()   => TabKey()
        case Up()    => ArrowUp()
        case Down()  => ArrowDown()
        case Enter() => Enter()
        case Esc()   => Quit()
        case _       => NoOp()
    }
}

func charToMsg(c rune) Msg = c match {
    case 'q' => Quit()
    case _   => NoOp()
}

// ----- main -----------------------------------------------------------------

func main() {
    val program = Program[Model, Msg](
        Initial = initialModel(),
        Update  = (m, msg) => update(m, msg),
        View    = (m) => view(m),
    )
    val _ = Run[Model, Msg](program, (ev) => keyToMsg(ev))
}

Run it:

gala build ./contacts
./contacts

Press Tab — the focus border moves between sidebar and details. Press ↓ — the cursor advances inside the focused pane only. Same code, zero hand-rolled focus state.

What you didn't have to write

  • Per-widget IsFocused(...) lookups in view. ui.SelectListOf("sidebar", ...) looks up focus internally.
  • A style branch per widget. When sidebar has focus, the SelectListOf cursor row paints BrightYellow + Bold + Reverse. Default false keeps the unfocused list calm.
  • An if focus == "X" else if focus == "Y" chain in update. m.Focus.Route(...) does the dispatch declaratively.
  • A border swap based on focus. (You can add one with if (m.Focus.IsFocused("sidebar")) ThickBorder() else SingleBorder() if you want — but the cursor highlight alone is usually enough.)

Add a third pane

Want a log drawer that's also focusable? Three steps:

// 1. Name the new pane in the focus ring:
Focus = state.NewFocusManager(ArrayOf[string]("sidebar", "details", "drawer"))

// 2. Add a Route case:
state.OnPane[Model]("drawer", () => scrollDrawer(m, delta)),

// 3. Render it focus-aware:
val drawer = ui.SelectListOf("drawer", logLines, m.LogCursor)

That's the full diff for adding a focusable pane.

Testing this app

Use m.Focus.Route(...) directly in a unit test — no terminal needed:

import . "github.com/martianoff/gala-tui"
import "github.com/martianoff/gala-tui/state"
import . "martianoff/gala/test"

func TestSidebarArrowMovesSel(t T) T {
    val m0 = initialModel()
    val (m1, _) = update(m0, ArrowDown())
    return Eq(t, m1.Sel, m0.Sel + 1)
}

func TestTabSwitchesFocus(t T) T {
    val m0 = initialModel()
    val (m1, _) = update(m0, TabKey())
    val (m2, _) = update(m1, ArrowDown())
    val t1 = Eq(t, m2.Sel, m0.Sel)               // sidebar didn't move
    return Eq(t1, m2.DetailLn, m0.DetailLn + 1)  // details cursor did
}

For the full picture (key decoding + render + buffer assertions), use the harness — see TESTING.md § "Layer 2".

Where to go next

  • Browse demo/megademo.gala — exercises every widget on screen at once (palette, datatable, tree, line chart, log drawer, themes, modals, toasts).
  • Read STRUCTURE.md for a map of where each piece lives in this repo.
  • Read the source for the widget you need — every public function has a docstring with a usage example.