Skip to content

CRBroughton/alloy

Repository files navigation

Alloy

A TUI framework for Odin;

  • alloy - full-screen TUI apps using the Elm Architecture (init / update / view)
  • forge - inline CLI wizards; step-by-step prompts that stay in the terminal scroll history

forge v1.0.0 — forge is stable and production ready. alloy is still in early development; APIs will change and features are incomplete.

Install alloy

curl -fsSL https://raw.githubusercontent.com/CRBroughton/alloy/master/install-alloy.sh | sh

Install forge

curl -fsSL https://raw.githubusercontent.com/CRBroughton/alloy/master/install-forge.sh | sh

alloy

Inspired by Bubble Tea. Full-screen, alternate-screen buffer, event loop driven.

Quick start

package main

import "core:fmt"
import alloy "vendor/alloy"

Model :: struct { count: int }

my_init :: proc() -> (^Model, alloy.Cmd) {
    return new(Model), nil
}

my_update :: proc(m: ^Model, msg: alloy.Msg) -> (^Model, alloy.Cmd) {
    if km, ok := msg.(alloy.KeyMsg); ok {
        if km.key == .CtrlC do return m, alloy.quit
        if km.key == .Rune && km.rune == '+' do m.count += 1
    }
    return m, nil
}

my_view :: proc(m: ^Model) -> string {
    return fmt.tprintf("Count: %d  (+ to increment, Ctrl+C to quit)\r\n", m.count)
}

main :: proc() {
    alloy.run(&alloy.Program(Model){
        init = my_init,
        update = my_update,
        view = my_view,
    })
}

Components

TextInput

Single-line text field with cursor, placeholder, and focus state.

alloy-text-input.webm

Select

Keyboard-navigable option list; returns SelectDoneMsg on confirm.

alloy-select.webm

Spinner

Animated indicator driven by a self-scheduling SleepCmd timer.

alloy-spinner.webm

Confirm

Yes/No prompt with configurable default; returns ConfirmMsg on answer.

alloy-confirm.webm

MultiSelect

Checkbox list; Space to toggle, Enter to confirm; returns MultiSelectDoneMsg.

alloy-multiselect.webm

Box

Bordered container with title and four border styles (Rounded, Single, Double, Heavy).

alloy-box.webm

Batch

Dispatch multiple commands from a single init or update call.

alloy-batch.webm

ProgressBar

Known-duration progress indicator driven by a 0.0–1.0 float in the model.

alloy-progress-bar.webm

Textarea

Multi-line text editor with (row, col) cursor, Enter to split lines, Backspace to merge.

alloy-textarea.webm

Grid

Column-based layout with fr and Fixed track types and configurable gap.

Timer-based commands

Use sleep to schedule any delayed message, not just spinners:

import "core:time"

// Deliver a custom message after 2 seconds:
return m, alloy.sleep(2 * time.Second, MyTimeoutMsg{})

sleep accepts any value that satisfies the Msg union; define your own message types in your app.

Batching commands

update returns a single Cmd. Use batch to return multiple at once:

app_init :: proc() -> (^Model, alloy.Cmd) {
    m := new(Model)
    alloy.spinner_init(&m.spinner, 1)
    return m, alloy.batch(
        alloy.spinner_start(&m.spinner),
        proc() -> alloy.Msg { return fetch_data() },
    )
}

nil entries are silently dropped; safe to pass conditional commands without pre-checking:

return m, alloy.batch(
    alloy.spinner_start(&m.spinner),
    needs_fetch ? fetch_cmd : nil,
)

forge

Inspired by Clack. Inline wizard prompts; no alternate screen. Each completed step stays visible in the terminal.

forge-multi-select.webm

Quick start

Chain prompts sequentially; check .status after each step to handle cancellation:

package main

import "core:fmt"
import forge "vendor/forge"

main :: proc() {
    name := forge.text("Project name?", "my-app")
    if name.status == .Cancelled do return

    framework := forge.pick("Framework?", []forge.SelectOption{
        {label = "React",  value = "react"},
        {label = "Vue",    value = "vue"},
        {label = "Svelte", value = "svelte"},
    })
    if framework.status == .Cancelled do return

    install := forge.confirm("Install dependencies?")
    if install.status == .Cancelled do return

    forge.wizard_end()

    fmt.printf("Creating %s with %s...\n", name.value, framework.value)
}

Prompts

  • text: single-line text input with optional placeholder; supports mask = true for passwords
  • pick: arrow-key navigable option list; returns the selected value
  • confirm: Yes/No toggle with Left/Right arrows and y/n shortcuts
  • multi_select: checkbox list; Space to toggle, Enter to confirm; supports default and description per option

Each prompt returns a StepResult:

StepResult :: struct {
    value:  string,    // display string (single value, or comma-joined for multi-select)
    values: []string,  // selected values (multi-select only; caller owns; call delete())
    status: StepStatus, // .Done, .Cancelled, .Error
}

Multi-select options

MultiSelectOption extends SelectOption with per-option defaults and descriptions:

extras := forge.multi_select("Extras?", []forge.MultiSelectOption{
    {label = "ESLint",   value = "eslint",   description = "Fast linting",    default = true},
    {label = "Prettier", value = "prettier", description = "Code formatter"},
    {label = "Husky",    value = "husky",    description = "Git hooks"},
})
if extras.status == .Cancelled do return
defer delete(extras.values)

for v in extras.values {
    fmt.printf("  installing %s\n", v)
}

Task runner

Run sequential tasks with a live spinner after your prompts:

SetupCtx :: struct { name: string }
ctx := SetupCtx{name = name.value}

result := forge.tasks(
    label         = "Setting up project",
    ctx           = &ctx,
    stop_on_error = true,
    tasks         = []forge.Task(SetupCtx){
        {
            label = "Create directory",
            run   = proc(c: ^SetupCtx) -> bool {
                return os.make_directory(c.name) == nil
            },
        },
        {
            label = "Init git repository",
            run   = proc(c: ^SetupCtx) -> bool {
                res := forge.exec({"git", "init", c.name})
                defer forge.exec_result_destroy(&res)
                return res.success
            },
        },
    },
)
if result.status == .Error do return

forge.wizard_end()

exec

Run shell commands from inside task procs.

res := forge.exec({"pnpm", "install"})
defer forge.exec_result_destroy(&res)
return res.success

Pass an optional working directory as the second argument:

res := forge.exec({"git", "init"}, project_name)
defer forge.exec_result_destroy(&res)
return res.success

ExecResult fields:

ExecResult :: struct {
    exit_code: int,
    success:   bool,
    stdout:    string,
    stderr:    string,
}

Always call exec_result_destroy when done; it frees the captured stdout and stderr strings.

copy_embedded

Write compile-time-embedded template files into a project directory, mirroring the path structure. Subdirectories are created as needed:

ok := forge.copy_embedded(project_name, embedded_paths(), embedded_read)
if !ok do return false

copy_embedded is decoupled from any specific embed implementation; it takes a paths slice and a read proc, so it works with any code-generated embed layer:

copy_embedded :: proc(
    project: string,
    paths:   []string,
    read:    proc(_: string) -> ([]byte, bool),
) -> bool

See examples/forge/installer/ for a full example using embed-gen (src/embed/) to generate the embed layer at build time.


Running tests

just test

About

A terminal UI framework for Odin

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors