Teact is a React-like abstraction built on top of Charm's Bubbletea system that will make your TUIs easier to build, and responsive to terminal size. It's like HTML + CSS + your browser's layout engine, all-in-one for the terminal.
Every Teact app starts with a call to teact.Run
in its main.go
, to the component that will be the root of your application. For example, this runs a Hello World application (source code here):
func main() {
myApp := greeter.New()
if _, err := teact.Run(myApp, tea.WithAltScreen()); err != nil {
fmt.Printf("An error occurred running the program:\n%v", err)
os.Exit(1)
}
}
Teact apps can be quit by default with ctrl-c
or ctrl-d
(and this can be changed).
A Teact component is just an implementation of the Component
interface, and is analogous to an HTML element. It provides size & display information to Teact's layout/rendering system.
However, 98% of the time you won't need to deal with any sizing because your custom components can be formed from the default Teact components. You can think of the default Teact components like inbuilt HTML tags - <p>
, <div>
, <li>
, etc. Your components should mostly be compositions of other components (just like in React).
For example, here's a HelloWorldApp
component (source code here) that's a composition of a flexbox containing styled text:
// A custom component
type Greeter interface {
components.Component
}
// Implementation of the custom component
type greeterImpl struct {
// So long as we assign a component to this then our component will call down to it (via Go struct embedding)
components.Component
}
func New() Greeter {
// This is a tree, just like HTML, with leaf nodes indented the most
root := flexbox.NewWithOpts(
[]flexbox_item.FlexboxItem{
flexbox_item.New(
stylebox.New(
text.New("Hello, world!"),
stylebox.WithStyle(
style.WithForeground(lipgloss.Color("#B6DCFE")),
),
),
),
},
flexbox.WithVerticalAlignment(flexbox.AlignCenter),
flexbox.WithHorizontalAlignment(flexbox.AlignCenter),
)
return &greeterImpl{
Component: root,
}
}
Because the component has a Component
struct embedded inside of it, HelloWorldApp
fulfills the Component
interface and Teact will know to use the embedded struct (which in this case is the flexbox) for rendering the HelloWorldApp
component.
Interactivity is accomplished by making a component implement the InteractiveComponent
interface, which in turn uses the Bubbletea Update
function. For example, this component keeps track of the number of keypresses it's seen and displays it (source code here:
type KeypressCounter interface {
components.InteractiveComponent
}
type keypressCounterImpl struct {
components.Component
keysPressed int
output text.Text
}
func New() KeypressCounter {
output := text.New()
result := &keypressCounterImpl{
Component: output,
keysPressed: 0,
output: output,
}
result.updateOutputText()
return result
}
func (k *keypressCounterImpl) Update(msg tea.Msg) tea.Cmd {
if utilities.GetMaybeKeyMsgStr(msg) != "" {
k.keysPressed += 1
k.updateOutputText()
}
return nil
}
func (k keypressCounterImpl) SetFocus(isFocused bool) tea.Cmd {
return nil
}
func (k keypressCounterImpl) IsFocused() bool {
return true
}
func (b *keypressCounterImpl) updateOutputText() {
b.output.SetContents(fmt.Sprintf("You've pressed %v keys", b.keysPressed))
}
You can see that each time the component receives a message, it checks if it's a keyboard message (since there are non-keyboard messages) and counts it.
Teact includes several utility functions (under the utilities
directory) to make writing your components easier. Of note:
NewStyle
, which allows building alipgloss.Style
object with the Go options pattern. For example:NewStyle( WithBold(true), WithUnderline(true), WithBorder(lipgloss.Border()), )
GetMaybeKeyMsgStr
, which is shorthand for testing if atea.Msg
is atea.KeyMsg
, and getting its string value if so.
Teact includes rudimentary component testing tools. These come in the form of assertions that are applied at various times in the component render loop (see below for more information on how this works). These are especially good if you're writing a new component from scratch (i.e. not embedding a Component
in your impl struct
).
Bubbletea is a great foundation to build on, but it has several shortcomings that I hit when trying to build with it:
In vanilla Bubbletea, parent components receive a simple string
from child components via tea.Model.View
. This means that the parent has no idea how to resize a given child's string - only the child knows how to render their View
at the right size.
The logical next step is a Resize
method that cascades from child to parents, so that children are aware of the size they ought to be rendering at. However, this prevents a layout that responds to content: when a child grows of its own accord (say, it intercepted a keypress and added something to its width), a parent flexbox would need to resize the child's siblings. How does the child signify to the parent that it's wider and a recalculation needs to occur?
The way to do it in vanilla Bubbletea would be to have the child return a wider string. However, the parent might have preferences on how wide the child should be (e.g. to avoid overflowing the parent), so the parent might want to compress (perhaps by word-wrapping) the child text. But we know from earlier that a parent doesn't know how to resize a child's text - only a child knows that - so truly responsive layouts are impossible with vanilla Bubbletea.
Teact fixes this in the same way as your browser: doing a two-pass approach, where item preferred sizes are calculated first and then actual sizes are settled on using that information.
tea.Model.Update
returns a tea.Model
. This means that a child Bubbletea component can either:
- Implement
tea.Model
, but then itsUpdate
will return atea.Model
(thereby requiring the parent to cast it before storing theUpdate
result) - Not actually implement
tea.Model
(which is what most of the components in the Bubbles repo do)
The by-value Update
is also problematic when trying to create a generic component. For example, I was writing FilterableList[T].Update
, with T
being the element component that the list would contain. No matter how I tried, I couldn't get implementations of the FilterableList[T]
interface to conform to the Update(msg tea.Msg) T
function on the interface.
The by-value state transitioning is nice in theory (very Redux-y), but in practice I found it to be cumbersome so Teact only supports by-reference components.
The concept of "focusable component" is very useful and showed up in nearly all the example Bubbles, but it's not encoded in the BubbleTea framework in any way (all the example Bubbles recreate Focus
, Blur
, and Focused
by hand).
A resize of my terminal window should have each parent resizing their children (because the parent knows what size the children should be), but there was no out-of-the-box way for components to do this.
-
98% of the time, you should simply be assembling the default Teact components into a new component rather than writing the
View
,GetContentMinMax
, etc. methods. -
Put each of your components in its own directory. This will help you stay organized.
-
Give each component a public interface that implements either
Component
orInteractiveComponent
. This will make it clear which type your component implements. -
Give each component a private implementation, built by a
New()
constructor. For example:In
my_component/my_component.go
type Greeter interface { component.Component // We know this isn't an interactive component }
In
my_component/my_component_impl.go
type greeterImpl struct { component.Component } func New() Greeter { root := text.New("Hello, world!") return &greeterImpl{ Component: root, } }
-
Embed a
Component
inside each private component implementation. This will transparently cause thestruct
to implement theComponent
interface, so that the rendering system will render however the embeddedComponent
instance wants. -
To track subcomponents that your component needs to modify, store them as properties on your component (NOT replacing the embedded
Component
) and use them as needed. For example:type greeterImpl struct { components.Component toUpdate text.Text } func New() Greeter { toUpdate := text.New("Hello, World!") root := flexbox.NewWithOpts( []flexbox_item.FlexboxItem{ flexbox_item.New( stylebox.New( toUpdate, stylebox.WithStyle( style.WithForeground(lipgloss.Color("#B6DCFE")), ), ), ), }, flexbox.WithVerticalAlignment(flexbox.AlignCenter), flexbox.WithHorizontalAlignment(flexbox.AlignCenter), ) return &greeterImpl{ Component: root, } } func (impl *greeterImpl) UpdateGreeting(greeting string) Greeter { impl.toUpdate.SetContents(greeting) }
-
When your component is configurable, use the Go options pattern with a constructor like
New(opts ...MyComponentOpt)
. This will make it much easier to do the initial instantiation of your component, as all configuration for a component can be aligned visually. For comparison:// If Teact components didn't have the Go optional pattern root := flexbox.New().SetChildren([]flexbox_item.FlexboxItem{ flexbox_item.New().WithContent( stylebox.New( text.New().SetContent("Hello, world") ).SetStyle(someStyle) ).WithMaxWidth(flexbox_item.FixedSize(20)).WithVerticalGrowthFactor(1) }).SetHorizontalAlignment(flexbox.Center).SetVerticalAlignment(flexbox.Center) // With Go options pattern (notice how each component is an indentation level) root := flexbox.New( WithChildren(flexbox_item.FlexboxItem{ flexbox_item.New( WithContent( stylebox.New( text.New( WithContent("Hello, world") ) WithStyle(someStyle), ) ), WithMaxWidth(flexbox_item.FixedSize(20)), WithVerticalGrowthFactor(1), ), }), WithHorizontalAlignment(flexbox.Center), WithVerticalAlignment(flexbox.Center), )
-
Pass
tea.Msg
events solely fromInteractiveComponent
toInteractiveComponent
. I.e., when anInteractiveComponent
needs to pass atea.Msg
event downwards, have the parent'sUpdate
method pass thetea.Msg
directly to the descendant that should receive it. Don't try to pass the event through a bunch of non-InteractiveComponent
s (of which you will have many -Flexbox
,Stylebox
,Text
, etc.).
Teact rendering is a rudimentary version of what happens in your browser. Basically:
- X-Pass: The minimum & desired widths & heights of each component in the graph is calculated (the equivalent of
min-content
andmax-content
in CSS), from bottom-to-top.- For those not familiar with CSS, components can have different sizes because word-wrapping can reduce the width of text (at a corresponding increase in height). The max width of a block of text is the length of its longest line without wrapping, and the min width is the length of the shortest word.
- Y-Pass: Incorporating each component's desired width and the actual viewport width of your terminal, go top-to-bottom giving actual sizes to each component and calculating the components desired height given that width.
- Render: Using all information, give an actual width to each component and render each component into a string to be displayed.
These three phases correspond to the three functions on the Component
interface:
GetContentMinMax()
SetWidthAndGetDesiredHeight(actualWidth)
View(actualWidth, actualHeight)
- Add Grid layout!!
- Make every component styleable, so we don't need styleboxes everywhere???
- Add some sort of inline/span thing
- Create a single "position" enum (so that we don't have different ones between flexbox and text, etc.)
- Make flexbox alignments purely "MainAxis" and "CrossAxis", so that when flipping the box things will be nicer