gitstack

grindlemire/go-tui code browser

16.5 KB markdown 638 lines 2026-06-06 · d6d303e raw

GSX Syntax

Overview

.gsx files are Go files extended with a templ-like syntax for declaring UI. They look like regular Go with the same package declarations, imports, and type definitions, but they add a templ keyword for defining components that return element trees. The tui generate command reads .gsx files and produces standard _gsx.go files that call the tui package API. You never need to edit the generated files.

Package and Imports

A .gsx file starts with a package declaration and imports, just like any Go file:

package main

import (
    "fmt"
    "strings"

    tui "github.com/grindlemire/go-tui"
)

The examples alias the import as tui, but this is optional since the root package is already named tui. The alias just makes it explicit that option calls like tui.NewState use the tui name, even though the path ends in go-tui. Use whichever form you prefer.

Everything else in the file (type declarations, constants, variables, helper functions) follows normal Go syntax.

Pure Components

Pure components are stateless functions declared with the templ keyword. They take parameters, return an element tree, and have no lifecycle of their own.

templ Greeting(name string) {
    <span class="text-cyan font-bold">{"Hello, " + name}</span>
}

This generates a function called Greeting that accepts name string and returns a GreetingView containing the element tree. You call it from other templates using the @ prefix with positional arguments:

templ App() {
    <div class="flex-col gap-1">
        @Greeting("Alice")
        @Greeting("Bob")
    </div>
}

Children Slot

Both pure and struct components can accept nested content via the {children...} placeholder.

In a pure component, children arrive as a function parameter:

templ Card(title string) {
    <div class="border-rounded p-1 flex-col gap-1">
        <span class="text-gradient-cyan-magenta font-bold">{title}</span>
        <hr class="border-single" />
        {children...}
    </div>
}

The caller passes children by nesting elements inside the component call:

templ Dashboard() {
    @Card("System Info") {
        <span>Version: 1.2.0</span>
        <span>Uptime: 3d 14h</span>
    }
}

Struct components use the same {children...} syntax. Add a children []*tui.Element field to the struct and accept it in the constructor:

type panel struct {
    title    string
    children []*tui.Element
}

func NewPanel(title string, children []*tui.Element) *panel {
    return &panel{title: title, children: children}
}

templ (p *panel) Render() {
    <div class="border-rounded p-1 flex-col gap-1">
        <span class="font-bold">{p.title}</span>
        {children...}
    </div>
}

The caller syntax is the same as with pure components:

templ (a *myApp) Render() {
    @NewPanel("Items") {
        <span>First</span>
        <span>Second</span>
    }
}

The generated code passes children through the constructor. On re-renders, UpdateProps copies the fresh children to the cached instance automatically.

When to Use Pure Components

Use pure components for reusable visual elements that don't need their own state: cards, badges, headers, layout wrappers, styled containers. They're the go-tui equivalent of a React functional component with no hooks.

Struct Components

Struct components carry state, handle input, and support lifecycle hooks. They're defined in three parts: a struct, a constructor, and a templ render method.

package main

import (
    "fmt"

    tui "github.com/grindlemire/go-tui"
)

type counter struct {
    count *tui.State[int]
}

func Counter() *counter {
    return &counter{
        count: tui.NewState(0),
    }
}

func (c *counter) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.Rune('+'), func(ke tui.KeyEvent) {
            c.count.Update(func(v int) int { return v + 1 })
        }),
        tui.On(tui.Rune('-'), func(ke tui.KeyEvent) {
            c.count.Update(func(v int) int { return v - 1 })
        }),
    }
}

templ (c *counter) Render() {
    <div class="flex-col items-center justify-center h-full gap-1">
        <span class="font-bold text-cyan">{fmt.Sprintf("Count: %d", c.count.Get())}</span>
        <span class="font-dim">Press + / - to change, Esc to quit</span>
    </div>
}

The templ (c *counter) Render() syntax declares a method on the struct that returns a *tui.Element. The method name must be Render. In the .gsx file you write no parameters, but the generated Go code adds app *tui.App automatically.

The Component Interface

At minimum, a struct component must implement Render(app *App) *Element. The templ method syntax generates this for you. Beyond that, several optional interfaces add behavior:

Interface Method Purpose
KeyListener KeyMap() tui.KeyMap Keyboard bindings
MouseListener HandleMouse(MouseEvent) bool Mouse click/scroll handling
WatcherProvider Watchers() []tui.Watcher Timers and channel listeners
Initializer Init() func() Setup; returns a cleanup function
AppBinder BindApp(app *App) Receive the App instance
PropsUpdater UpdateProps(fresh Component) Handle prop changes on re-mount

You don't need to implement all of these. Use only what your component needs. The Components guide covers each in detail.

Elements

Elements are HTML-like tags that map to tui.Element instances. There are two kinds: container elements that can hold children, and self-closing (void) elements that cannot.

Container Elements

Element Description
<div> Block flex container (the workhorse for layout)
<span> Inline text container
<p> Paragraph with text wrapping
<ul> Unordered list container
<li> List item (renders with a bullet)
<button> Clickable element
<table> Table container

Self-Closing (Void) Elements

These must use the /> closing syntax and cannot have children:

Element Description
<input /> Text input field
<progress /> Progress bar
<hr /> Horizontal rule
<br /> Line break

Nesting

Elements nest naturally:

<div class="flex-col gap-1 p-1">
    <div class="flex justify-between">
        <span class="font-bold">Title</span>
        <span class="font-dim">Subtitle</span>
    </div>
    <hr />
    <ul class="flex-col">
        <li><span>First item</span></li>
        <li><span>Second item</span></li>
    </ul>
</div>

Attributes

Attributes set element properties. There are three forms:

String Attributes

Quoted strings for class, id, and other string-typed properties:

<div class="flex-col gap-2" id="main-panel">
    <span class="font-bold text-cyan">Title</span>
</div>

Go Expression Attributes

Curly braces for any Go expression:

<div width={42} height={10} flexGrow={1.5}>
    <span textStyle={tui.NewStyle().Bold().Foreground(tui.ANSIColor(tui.Cyan))}>
        Styled text
    </span>
</div>

This works for integers, floats, booleans, function calls, struct literals, or any other expression that produces a value of the right type.

Boolean Attributes

A bare attribute name is shorthand for true:

<button disabled>Can't click</button>
// equivalent to:
<button disabled={true}>Can't click</button>

Ref Attributes

Bind an element to a reference variable for later access (scroll control, click detection, etc.):

<div ref={myRef} class="flex-col">
    content
</div>

See the Event Handling guide for how refs work with click handling.

Attribute Reference

Here's the full set of supported attributes, grouped by purpose:

Identity: id, class, disabled, ref, deps

Dimensions: width, widthPercent, height, heightPercent, minWidth, minHeight, maxWidth, maxHeight

Flex Container: direction, justify, align, gap

Flex Item: flexGrow, flexShrink, alignSelf

Spacing: padding, margin

Visual: border, borderStyle, borderTitle, background, text, textStyle, textAlign

Focus: focusable, onFocus, onBlur

Scroll: scrollable, scrollOffset, scrollbarStyle, scrollbarThumbStyle

Input-specific: value, placeholder

Progress-specific: value, max

Go Expressions

Curly braces embed Go expressions as text content or attribute values.

Text Content

Any Go expression inside {...} is rendered as text:

<span>{fmt.Sprintf("Count: %d", c.count.Get())}</span>
<span>{"Hello, " + name}</span>
<span>{len(items)}</span>

Computed Classes

Classes can be built from Go expressions too:

<span class={statusClass(isOnline)}>Status</span>

Where statusClass is a regular Go function:

func statusClass(online bool) string {
    if online {
        return "text-green font-bold"
    }
    return "text-red font-dim"
}

Method Calls

Call methods on the receiver or on state variables:

<span textStyle={s.getHeaderStyle()}>{s.count.Get()}</span>

Control Flow

Three directives control rendering logic: if, for, and := bindings.

if / else

Conditionally render elements based on a Go boolean expression:

if s.count.Get() > 0 {
    <span class="text-green">Positive</span>
} else {
    <span class="text-red">Zero or negative</span>
}

You can chain conditions with else if:

if s.count.Get() > 10 {
    <span class="text-green font-bold">High</span>
} else if s.count.Get() > 0 {
    <span class="text-yellow">Low</span>
} else {
    <span class="text-red">Zero</span>
}

for

Loop over slices, maps, or any Go iterable with range:

for i, item := range items {
    <span>{fmt.Sprintf("%d. %s", i+1, item)}</span>
}

You can ignore the index with _:

for _, item := range items {
    <span>{item}</span>
}

Loops and conditionals nest freely:

for i, item := range items {
    if i == s.selected.Get() {
        <span class="text-cyan font-bold">{"> " + item}</span>
    } else {
        <span>{"  " + item}</span>
    }
}

Element Bindings (:=)

Bind an element to a local variable to avoid repeating complex expressions:

countText := <span class="font-bold">{fmt.Sprintf("%d", s.count.Get())}</span>
<div class="flex gap-1">
    <span>Count:</span>
    {countText}
</div>

This is useful when you want to compute an element once and reuse it. The variable is scoped to the rest of the component body after its declaration.

Helper Functions

Regular Go functions in .gsx files work exactly as they do in .go files. They're useful for formatting, style computation, and shared logic:

package main

import "fmt"

func keyLabel(name string, pressed bool) string {
    if pressed {
        return "* " + name
    }
    return "  " + name
}

func keyStyle(pressed bool) string {
    if pressed {
        return "text-green font-bold"
    }
    return "font-dim"
}

templ KeyIndicator(name string, pressed bool) {
    <span class={keyStyle(pressed)}>{keyLabel(name, pressed)}</span>
}

The distinction between a helper function and a component is the templ keyword. A templ declaration produces an element tree. A func declaration is plain Go.

Calling Components

Components are called with the @ prefix. Parameters are passed as positional arguments matching the component's parameter list.

Pure Components

templ Badge(label string) {
    <span class="bg-cyan text-black px-1 font-bold">{label}</span>
}

templ StatusLine(label string, value string) {
    <div class="flex gap-1">
        <span class="font-dim">{label}</span>
        <span>{value}</span>
    </div>
}

// Usage:
templ Header() {
    <div class="flex-col gap-1">
        @Badge("v1.0")
        @StatusLine("Status:", "Running")
    </div>
}

Struct Components

Struct components are instantiated through their constructor and passed to the template with @:

templ (a *app) Render() {
    <div class="flex h-full">
        @Sidebar(a.category)
        @Content(a.category, a.query)
    </div>
}

Where Sidebar and Content are constructors that return struct component instances.

Code Generation

After writing or editing .gsx files, run the code generator to produce the corresponding Go files:

tui generate ./...

This processes all .gsx files recursively and writes _gsx.go files alongside them. For example, hello.gsx produces hello_gsx.go. Hyphens in filenames become underscores (my-app.gsx becomes my_app_gsx.go).

The generated files should not be edited by hand. They're overwritten on every run of tui generate.

Related Commands

Command Purpose
tui generate [path...] Generate Go code from .gsx files
tui check [path...] Validate .gsx files without writing output
tui fmt [path...] Format .gsx files (like gofmt for .gsx)
tui fmt --check [path...] Check formatting without modifying files
tui fmt --stdout [path...] Write formatted output to stdout

Path arguments accept specific files (hello.gsx), directories (./examples/), or recursive patterns (./...).

See the CLI section above for the full command reference.

Putting It All Together

Here's a complete .gsx file that uses most of the syntax covered above: pure components, a struct component with state, control flow, helper functions, and children slots:

package main

import (
    "fmt"

    tui "github.com/grindlemire/go-tui"
)

// Helper function: formats an item label
func itemLabel(index int, name string, selected bool) string {
    prefix := "  "
    if selected {
        prefix = "> "
    }
    return fmt.Sprintf("%s%d. %s", prefix, index+1, name)
}

// Helper function: returns a style class based on selection
func itemClass(selected bool) string {
    if selected {
        return "text-cyan font-bold"
    }
    return ""
}

// Pure component with children slot
templ Panel(title string) {
    <div class="border-rounded p-1 flex-col gap-1" width={32}>
        <span class="font-bold text-gradient-cyan-magenta">{title}</span>
        <hr />
        {children...}
    </div>
}

// Struct component with state and key handling
type listApp struct {
    items    []string
    selected *tui.State[int]
}

func ListApp() *listApp {
    return &listApp{
        items:    []string{"Alpha", "Bravo", "Charlie", "Delta"},
        selected: tui.NewState(0),
    }
}

func (l *listApp) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.KeyUp, func(ke tui.KeyEvent) {
            l.selected.Update(func(v int) int {
                if v > 0 {
                    return v - 1
                }
                return v
            })
        }),
        tui.On(tui.KeyDown, func(ke tui.KeyEvent) {
            l.selected.Update(func(v int) int {
                if v < len(l.items)-1 {
                    return v + 1
                }
                return v
            })
        }),
    }
}

templ (l *listApp) Render() {
    <div class="flex-col items-center justify-center h-full">
        @Panel("Select an Item") {
            for i, item := range l.items {
                <span class={itemClass(i == l.selected.Get())}>
                    {itemLabel(i, item, i == l.selected.Get())}
                </span>
            }

            <br />

            if l.selected.Get() >= 0 {
                <span class="font-dim">{fmt.Sprintf("Selected: %s", l.items[l.selected.Get()])}</span>
            }
        }
    </div>
}

With a corresponding main.go:

package main

import (
    "fmt"
    "os"

    tui "github.com/grindlemire/go-tui"
)

func main() {
    app, err := tui.NewApp(
        tui.WithRootComponent(ListApp()),
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
    defer app.Close()

    if err := app.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

Generate and run:

tui generate ./...
go run .

Here's what the finished list looks like:

GSX Syntax screenshot

Next Steps

  • Styling and Colors — Text styles, colors, borders, and gradients
  • Layout — Flexbox layout: direction, alignment, spacing, and sizing
  • Components — Component patterns, composition, and lifecycle interfaces