reactea

package module
v2.0.0-...-6805aa3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 3 Imported by: 0

README

reactea v2

A companion to Bubble Tea v2 that adds a component hierarchy, routing, layout and modals.

go get github.com/Hayao0819/reactea/v2

Quickstart

type App struct {
	router *router.Component
}

func (a *App) Init(ctx *reactea.Ctx) tea.Cmd {
	return tea.Batch(a.router.Init(ctx), reactea.EnterAltScreen)
}

func (a *App) Update(ctx *reactea.Ctx, msg tea.Msg) tea.Cmd {
	if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "q" {
		return tea.Quit
	}

	return a.router.Update(ctx, msg)
}

func (a *App) Render(ctx *reactea.Ctx) string {
	return a.router.Render(ctx)
}

func main() {
	app := reactea.New(&App{router: router.NewWithRoutes(routes)})

	if err := app.Run(); err != nil {
		log.Fatal(err)
	}
}

The component

type Component interface {
	Init(*Ctx) tea.Cmd
	Update(*Ctx, tea.Msg) tea.Cmd
	Render(*Ctx) string
}

Three methods, one argument in common. BasicComponent supplies no-op versions of Init and Update, so most components only write what they mean.

A component that owns children forwards Init and Update to them and Renders them into whatever boxes it decides on. layout does this for you in the common cases.

Render should be a function of the component's state. Terminal features are asked for with commands, not while drawing:

func (c *App) Init(ctx *reactea.Ctx) tea.Cmd {
	return tea.Batch(reactea.EnterAltScreen, reactea.SetWindowTitle("my-app"))
}

EnterAltScreen, ExitAltScreen, SetWindowTitle, SetMouseMode, SetReportFocus, SetBackgroundColor, SetForegroundColor and SetKeyboardEnhancements all work this way: the App holds what was asked for and puts it on every frame. The cursor is the exception — it depends on the layout, which only exists while rendering — so it is set through the Ctx.

Cleanup

There is no Destroy. A component that owns a resource says so where it acquires it:

func (c *Page) Init(ctx *reactea.Ctx) tea.Cmd {
	ticker := time.NewTicker(time.Second)
	ctx.OnDestroy(ticker.Stop)

	return c.poll(ticker.C)
}

Cleanups belong to a Scope. The app has a root scope that closes when the program ends — through tea.Quit, Ctrl+C or a signal alike — so nothing is stranded by a parent that forgot to forward a call. A parent that mounts and unmounts children gives each one ctx.Scope().Child() and closes it when the child goes; router and modal already do, so routing away from a page runs that page's cleanups and nothing else.

Ctx

Ctx is what a component is told about the frame it is taking part in.

Size(), Width(), Height() the box this component may draw into
Inset(dx, dy, w, h) the box for a child, in the parent's coordinates
Route(), PreviousRoute() where the app is
SetRoute(r), Navigate(r) commands that move it
SetCursor, CursorAt where the terminal cursor goes this frame
OnDestroy, Scope, WithScope cleanup, and which scope it belongs to

A cursor set through a Ctx is translated into screen coordinates automatically, however deep the component sits, so no parent does offset arithmetic. It is also per frame: a component that stops asking for the cursor gets a view without one, with no state to unwind.

Routing is a message, not a mutation. SetRoute and Navigate return commands, so they are safe to issue from a command goroutine, and the move arrives at the tree as a RouteChangedMsg where every other message arrives.

Quitting

tea.Quit, Ctrl+C and a SIGTERM all close the root scope before the program ends — App installs a tea.WithFilter to catch the quit before Bubble Tea's event loop returns on it.

Layout

layout.Column(
	layout.Fixed(1, header),
	layout.Grow(1, layout.Row(
		layout.Fixed(16, layout.Framed(paneStyle, sidebar)),
		layout.Grow(1, layout.Framed(paneStyle, pages)),
	)),
	layout.Fixed(1, footer),
)

Fixed takes exactly that many cells, Grow takes a share of what is left weighted against the other growing items, and Bounded is Grow with a floor and a ceiling. Every cell is handed out: the remainder from an uneven split goes to the items with the largest fractional share, so three Grow(1, …) items in a 10-cell box get 4, 3 and 3.

Framed draws a lipgloss style around a component. Lipgloss counts Width and Height as the outer size, so the child is rendered at the box minus the border, padding and margin, and its cursor shifted to match.

A Box recomputes its split in each phase rather than caching it from the last Render, so Update and Render can be called in any order.

Focus and input routing

Messages fall into two kinds. Input — keys, paste, mouse — has an addressee: the keyboard reaches whatever holds the focus, the mouse reaches whatever sits under the pointer. Everything else — ticks, async results, window size, route changes — reaches the whole tree. IsKeyboard, IsMouse and IsInput are exported so a custom container can route the same way.

Mark the items that can take the focus, and let the app decide which key moves it:

body := layout.Row(
    layout.Fixed(20, layout.Framed(paneStyle, cpu).WhenFocused(activeStyle)).Focusable(),
    layout.Grow(1, layout.Framed(paneStyle, procs).WhenFocused(activeStyle)).Focusable(),
)

func (r *root) Update(ctx *reactea.Ctx, msg tea.Msg) tea.Cmd {
    if reactea.Key(msg, "tab") {
        if !r.body.FocusNext() {
            r.body.FocusFirst()
        }

        return nil
    }

    return r.body.Update(ctx, msg)
}

FocusNext descends into a nested box before advancing, and reports false at the end so the caller decides how to wrap. A component reads ctx.Focused() to style itself, and only a focused component may set the cursor — one cursor per frame falls out of the focus rules instead of being a race between siblings.

Global keys are read above the tree, so they have to stand down while something below is typing. A component that takes the keys says so, and the root asks:

// entering filter mode
return reactea.CaptureInput

// leaving it
return reactea.ReleaseInput

func (r *root) Update(ctx *reactea.Ctx, msg tea.Msg) tea.Cmd {
    if ctx.InputCaptured() {
        return r.body.Update(ctx, msg)
    }
    ...
}

modal.Stack captures and releases on its own, so a modal needs nothing from the app. Calls nest, so a capture must be paired with a release.

Mouse events arrive with box-local coordinates, so a click is msg.Y rows into your own pane. A press also moves the focus to what was pressed; a wheel or a motion leaves it alone.

func (c *procs) Update(ctx *reactea.Ctx, msg tea.Msg) tea.Cmd {
    switch msg := msg.(type) {
    case tea.MouseClickMsg:
        c.selected = c.offset + msg.Y
    case tea.MouseWheelMsg:
        c.scroll(msg.Button)
    }

    return nil
}

Routing

router.NewWithRoutes(router.Routes{
	"/user/settings": func(router.Params) reactea.Component { return settings.New() },
	"/user/:id":      func(p router.Params) reactea.Component { return profile.New(p["id"]) },
	"default":        func(router.Params) reactea.Component { return home.New() },
})

Placeholders capture params (:id), may be optional (?:id) or a trailing catch-all (+?:rest). When more than one matches, the most specific wins — literal over param over optional over catch-all, with a string tie-break, so the choice never depends on Go's map iteration order. Set NotFound for a page of your own; otherwise an unmatched route renders a plain message.

Modals

func (p *Page) Update(ctx *reactea.Ctx, msg tea.Msg) tea.Cmd {
	switch msg := msg.(type) {
	case tea.KeyPressMsg:
		return p.stack.Push(&NameInput{})
	case modal.Result[string]:
		p.name = msg.Value
	}

	return nil
}

A modal is an ordinary component pushed onto a modal.Stack. While it is on top it takes the input, though the base keeps receiving its own ticks and async results so its work can finish; it finishes with modal.Return or modal.Fail, which pops it and delivers a modal.Result[T] to the tree. Nothing blocks — no extra goroutine, no channel handshake. Each modal gets its own scope, so dismissing one runs exactly its cleanups.

Wrapping Bubble Tea models and bubbles widgets

The two need different adapters, because a bubbles widget is not a tea.Model and never has been. A widget's Update returns its own concrete type (func (m Model) Update(tea.Msg) (Model, tea.Cmd)) so that you can write m.input, cmd = m.input.Update(msg) without a type assertion, and Go has no covariant returns. In v2 the View() string signature is a second mismatch.

Wraps Constraint
Reactify a self-contained Bubble Tea model tea.Model
ReactifyWidget a bubbles widget Widget[T]
input := textinput.New()
input.SetVirtualCursor(false)
input.Focus()

component := reactea.ReactifyWidget(input)

ReactifyWidget stores the widget value back after every Update, calls the widget's Init() when it has one, and reports its cursor through the Ctx. Widgets draw a virtual cursor into their string by default and report no real cursor in that mode; reactea leaves that choice to you.

An interface whose Update returns the interface itself also fits Widget[T] — name it as the type argument, as in ReactifyWidget[huh.Model](form).

Testing

An App runs without a terminal, which is all a component test needs.

app := reactea.New(page, reactea.WithSize(70, 20), reactea.WithRoute("/inbox"))

app.Init()
app.Update(tea.KeyPressMsg{Code: 'r', Text: "r"})

if !strings.Contains(app.View().Content, "Reloading") {
	t.Error(...)
}

App.View() returns the whole tea.View, so the cursor is assertable too. The alt-screen and the title arrive by command, so feed the batch Init returns back through Update before asserting on them. Two apps in one process share nothing, so tests can run in parallel.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CaptureInput

func CaptureInput() tea.Msg

CaptureInput declares that something below is taking the keys — a text field in filter mode, a modal. Global key handling at the root stands down until the matching ReleaseInput. Calls nest, so they must be paired.

func EnterAltScreen

func EnterAltScreen() tea.Msg

EnterAltScreen puts the program in the alternate screen buffer.

func ExitAltScreen

func ExitAltScreen() tea.Msg

ExitAltScreen returns to the normal screen buffer.

func IsInput

func IsInput(msg tea.Msg) bool

IsInput reports whether msg is addressed to one component rather than to all of them.

func IsKeyboard

func IsKeyboard(msg tea.Msg) bool

IsKeyboard reports whether msg is a key or paste event.

func IsMouse

func IsMouse(msg tea.Msg) bool

IsMouse reports whether msg is a mouse event.

func Key

func Key(msg tea.Msg, keys ...string) bool

Key reports whether msg is a press of one of keys, spelled the way tea.KeyPressMsg.String does.

func MatchRoute

func MatchRoute(route string, placeholder string) (map[string]string, bool)

MatchRoute reports whether route (e.g. /teams/123/12) matches placeholder (e.g. /teams/:teamId/:playerId) and returns the params it captured.

Params follow ^:.*$, where ^ is the start of a path level and $ its end.

  • The whole matched route is available under the key "$".
  • Placeholders can be optional: /foo/?:/?: matches /foo, /foo/bar and /foo/bar/baz.
  • A trailing placeholder can be an optional catch-all: /foo/+?: matches /foo and everything below it.
  • Wildcards are allowed: /foo/:/bar.
  • A repeated param name keeps the value of its last occurrence.

func Mouse

func Mouse(ctx *Ctx, msg tea.Msg) (x, y int, ok bool)

Mouse reports whether msg is a mouse event inside this component's box. Containers translate as they route, so the coordinates are already box-local.

func MouseAt

func MouseAt(msg tea.Msg) (x, y int, ok bool)

MouseAt returns where msg landed, if it is a mouse event at all.

func ReleaseInput

func ReleaseInput() tea.Msg

ReleaseInput undoes one CaptureInput.

func Resolve

func Resolve(base, target string) string

Resolve turns target into an absolute route, honouring "." and "..".

func SetBackgroundColor

func SetBackgroundColor(colour color.Color) tea.Cmd

SetBackgroundColor sets the terminal background. Pass nil to reset it.

func SetBracketedPaste

func SetBracketedPaste(on bool) tea.Cmd

SetBracketedPaste turns bracketed paste on or off. It is on by default.

func SetForegroundColor

func SetForegroundColor(colour color.Color) tea.Cmd

SetForegroundColor sets the terminal foreground. Pass nil to reset it.

func SetKeyboardEnhancements

func SetKeyboardEnhancements(enhancements tea.KeyboardEnhancements) tea.Cmd

SetKeyboardEnhancements asks the terminal for richer key reporting.

func SetMouseMode

func SetMouseMode(mode tea.MouseMode) tea.Cmd

SetMouseMode asks the terminal for mouse reporting.

func SetProgressBar

func SetProgressBar(bar *tea.ProgressBar) tea.Cmd

SetProgressBar shows a progress bar in the terminal's progress area. Pass nil to take it away.

func SetReportFocus

func SetReportFocus(on bool) tea.Cmd

SetReportFocus turns focus reporting on or off.

func SetWindowTitle

func SetWindowTitle(title string) tea.Cmd

SetWindowTitle sets the terminal window title.

func TranslateMouse

func TranslateMouse(msg tea.Msg, dx, dy int) tea.Msg

TranslateMouse moves a mouse event into a child's coordinate space. A container that insets a child translates as it routes, so a component always reads box-local coordinates.

Types

type App

type App struct {
	// contains filtered or unexported fields
}

App owns a running program's state. It lives here rather than in package variables, so two apps in one process never share a route. Commands go straight back to Bubbletea, which already runs them under a panic guard.

func New

func New(root Component, options ...Option) *App

New builds an App around root.

func (*App) Ctx

func (a *App) Ctx() *Ctx

Ctx is the root context: the whole screen, bound to the root scope.

func (*App) Init

func (a *App) Init() tea.Cmd

func (*App) InputCaptured

func (a *App) InputCaptured() bool

InputCaptured reports whether something below has claimed the keys.

func (*App) Program

func (a *App) Program(options ...tea.ProgramOption) *tea.Program

Program wraps the app in a Bubbletea program. Any quit closes the root scope first.

func (*App) Route

func (a *App) Route() string

Route is the app's current route.

func (*App) Run

func (a *App) Run(options ...tea.ProgramOption) error

Run builds a program and runs it.

func (*App) Scope

func (a *App) Scope() *Scope

Scope is the app's root scope. It closes when the program ends.

func (*App) Update

func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd)

func (*App) View

func (a *App) View() tea.View

type BasicComponent

type BasicComponent struct{}

BasicComponent implements Init and Update as no-ops.

func (*BasicComponent) Init

func (c *BasicComponent) Init(*Ctx) tea.Cmd

func (*BasicComponent) Update

func (c *BasicComponent) Update(*Ctx, tea.Msg) tea.Cmd

type Component

type Component interface {
	Init(*Ctx) tea.Cmd
	Update(*Ctx, tea.Msg) tea.Cmd

	// Render should be a function of the component's state: ask for terminal
	// features with commands, not while drawing. The cursor is the exception,
	// since it depends on the layout.
	Render(*Ctx) string
}

Component is a piece of UI. Update is not guaranteed to run before the first Render, so put anything critical in Init. There is no Destroy; register cleanups with Ctx.OnDestroy so a parent cannot leak a child by forgetting to forward one.

func Func

func Func(render RenderFunc) Component

Func turns a render function into a Component.

func Text

func Text(content string) Component

Text is a Component that always renders content.

type Ctx

type Ctx struct {
	// contains filtered or unexported fields
}

Ctx is the box a component may draw into, where the app is, and the scope its cleanups belong to. A Ctx knows its origin on screen, so a cursor set through it is translated for the component and no parent does offset arithmetic.

func (*Ctx) CursorAt

func (c *Ctx) CursorAt(x, y int)

CursorAt is SetCursor with a plain block cursor.

func (*Ctx) Focused

func (c *Ctx) Focused() bool

Focused reports whether this component holds the keyboard focus. Containers decide it; a component reads it to style itself and to know whether keys are meant for it.

func (*Ctx) Height

func (c *Ctx) Height() int

func (*Ctx) InputCaptured

func (c *Ctx) InputCaptured() bool

InputCaptured reports whether something below has claimed the keys, so a component reading global keys knows to stand down.

func (*Ctx) Inset

func (c *Ctx) Inset(dx, dy, width, height int) *Ctx

Inset carves a child box out of this one, clamped to what is left so a child can never start outside its parent.

func (*Ctx) Navigate

func (c *Ctx) Navigate(target string) tea.Cmd

Navigate accepts a route relative to the current one.

func (*Ctx) OnDestroy

func (c *Ctx) OnDestroy(cleanup func())

OnDestroy registers a cleanup with this Ctx's scope.

func (*Ctx) Origin

func (c *Ctx) Origin() (int, int)

Origin is where this box sits on screen.

func (*Ctx) PreviousRoute

func (c *Ctx) PreviousRoute() string

PreviousRoute is where the app was before the last route change.

func (*Ctx) Route

func (c *Ctx) Route() string

Route is the app's current route.

func (*Ctx) Scope

func (c *Ctx) Scope() *Scope

Scope is the scope cleanups registered here belong to.

func (*Ctx) SetCursor

func (c *Ctx) SetCursor(cursor *tea.Cursor)

SetCursor places the cursor inside this box for this frame. It stays a render concern because it depends on the layout; everything else the terminal can be asked for is a command. Pass nil to hide it.

A component without the focus is ignored, so one cursor per frame falls out of the focus rules instead of being a race between siblings.

func (*Ctx) SetRoute

func (c *Ctx) SetRoute(target string) tea.Cmd

SetRoute moves to an absolute route when the returned command runs, so it is safe to call from any goroutine.

func (*Ctx) Size

func (c *Ctx) Size() (int, int)

Size is the box this component may draw into.

func (*Ctx) Width

func (c *Ctx) Width() int

func (*Ctx) WithFocus

func (c *Ctx) WithFocus(focused bool) *Ctx

WithFocus marks the child branch as holding, or not holding, the focus. Containers call it as they route.

func (*Ctx) WithScope

func (c *Ctx) WithScope(scope *Scope) *Ctx

WithScope binds the same box to another scope, for a child that may later be unmounted on its own.

type InvisibleComponent

type InvisibleComponent struct{}

InvisibleComponent renders nothing.

func (*InvisibleComponent) Render

func (c *InvisibleComponent) Render(*Ctx) string

type Option

type Option func(*App)

Option configures an App.

func WithAltScreen

func WithAltScreen() Option

WithAltScreen starts in the alternate screen buffer.

func WithRoute

func WithRoute(route string) Option

WithRoute starts the app on route instead of "/".

func WithSize

func WithSize(width, height int) Option

WithSize sets the size the app assumes until the terminal reports its own.

func WithTerminal

func WithTerminal(apply func(*tea.View)) Option

WithTerminal seeds terminal state before the first frame. Bubbletea renders once before running Init's commands, so asking with a command alone flashes a frame onto the primary screen.

func WithWindowTitle

func WithWindowTitle(title string) Option

WithWindowTitle sets the terminal window title.

type Reactified

type Reactified[TModel tea.Model] struct {
	BasicComponent

	Model TModel
	// contains filtered or unexported fields
}

Reactified adapts a tea.Model. A bubbles widget returns its own concrete type from Update, so it never satisfies tea.Model; use ReactifyWidget for those.

func Reactify

func Reactify[TModel tea.Model](model TModel) *Reactified[TModel]

Reactify wraps a tea.Model as a Component.

func (*Reactified[TModel]) Init

func (c *Reactified[TModel]) Init(*Ctx) tea.Cmd

func (*Reactified[TModel]) Render

func (c *Reactified[TModel]) Render(ctx *Ctx) string

func (*Reactified[TModel]) Update

func (c *Reactified[TModel]) Update(_ *Ctx, msg tea.Msg) tea.Cmd

type ReactifiedWidget

type ReactifiedWidget[TWidget Widget[TWidget]] struct {
	BasicComponent

	Widget TWidget
}

ReactifiedWidget adapts a bubbles widget.

func ReactifyWidget

func ReactifyWidget[TWidget Widget[TWidget]](widget TWidget) *ReactifiedWidget[TWidget]

ReactifyWidget wraps a bubbles widget as a Component.

func (*ReactifiedWidget[TWidget]) Init

func (c *ReactifiedWidget[TWidget]) Init(*Ctx) tea.Cmd

Only some widgets (timer, stopwatch, filepicker, progress) have an Init, which is why it is not part of Widget.

func (*ReactifiedWidget[TWidget]) Render

func (c *ReactifiedWidget[TWidget]) Render(ctx *Ctx) string

A widget exposes Cursor() separately from View(), and only once its virtual cursor is off and it is focused. Reactea forces neither.

func (*ReactifiedWidget[TWidget]) Update

func (c *ReactifiedWidget[TWidget]) Update(_ *Ctx, msg tea.Msg) tea.Cmd

type RenderFunc

type RenderFunc = func(*Ctx) string

RenderFunc is a stateless component: props are whatever the closure captures.

type RouteChangedMsg

type RouteChangedMsg struct {
	From string
	To   string
}

RouteChangedMsg is delivered to the whole tree after the route moves.

type Scope

type Scope struct {
	// contains filtered or unexported fields
}

Scope owns the cleanups of everything mounted under it. It replaces a Destroy method on Component: a parent that mounts and unmounts children gives each a Child scope, and everything else falls back to the app's root scope, so a forgotten call cannot strand a cleanup.

func NewScope

func NewScope() *Scope

NewScope opens a scope with no parent.

func (*Scope) Child

func (s *Scope) Child() *Scope

Child opens a nested scope. Closing the parent closes it too; closing the child early unmounts one thing without touching the rest.

func (*Scope) Close

func (s *Scope) Close()

Close runs the cleanups newest first. Closing twice is a no-op.

func (*Scope) Closed

func (s *Scope) Closed() bool

Closed reports whether Close has run.

func (*Scope) OnDestroy

func (s *Scope) OnDestroy(cleanup func())

OnDestroy runs the cleanup at once if the scope has already closed.

type Widget

type Widget[T any] interface {
	Update(tea.Msg) (T, tea.Cmd)
	View() string
}

Widget is the shape every bubbles widget has. The self-referential type parameter is what lets one adapter cover textinput, textarea, viewport, list and friends. An interface whose Update returns itself also fits: name it as the type argument, as in ReactifyWidget[huh.Model](form).

type Wrapper

type Wrapper struct {
	Child Component
}

Wrapper forwards the whole lifecycle to a single child. Embed it and write only the methods that differ.

func Wrap

func Wrap(child Component) Wrapper

Wrap builds a Wrapper around child.

func (Wrapper) Init

func (w Wrapper) Init(ctx *Ctx) tea.Cmd

func (Wrapper) Render

func (w Wrapper) Render(ctx *Ctx) string

func (Wrapper) Update

func (w Wrapper) Update(ctx *Ctx, msg tea.Msg) tea.Cmd

Directories

Path Synopsis
examples
tour command
Command tour exercises every part of the reactea v2 API in one screen.
Command tour exercises every part of the reactea v2 API in one screen.
Package layout splits a box among child components along one axis.
Package layout splits a box among child components along one axis.
Package modal stacks blocking overlays on a base component.
Package modal stacks blocking overlays on a base component.
Package router picks a child component from the app's current route.
Package router picks a child component from the app's current route.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL