charmbracelet/x

repository·main·Indexed 43 days ago

https://github.com/charmbracelet/x

A repository of experimental Go packages for terminal manipulation, color utilities, and developer tooling, featuring pony for declarative view rendering and integration with Bubble Tea.

Tokens
80.7K
Snippets
365
Records
422
Agent score
6%

What's inside charmbracelet-x

  1. Overview of the x repository

    main

    The x repository is a collection of experimental Go packages developed by Charm. These packages are intended for testing new ideas and may not guarantee backwards compatibility. Once a package matures, it may be moved to a different repository.

    Developers can use these packages for various terminal-related tasks, including ANSI parsing, terminal UI markup, color utilities, and cross-platform PTY interfaces.

  2. Use golden file testing for rendering output

    main

    To verify rendering output, pony uses golden file testing. This compares the actual output against an expected output stored in testdata/.

    How to implement

    When writing a test for rendering, use golden.RequireEqual(t, output). This will automatically look for a corresponding .golden file in the testdata/ directory.

    Updating golden files

    If you intentionally change the rendering output, the tests will fail. To accept the new output as the new 'truth', run:

    go test -update

    This regenerates all testdata/*.golden files with the current output.

    func TestMyRender(t *testing.T) {
        output := myTemplate.Render(data, 80, 24)
        golden.RequireEqual(t, output)  // Compares against testdata/TestMyRender.golden
    }
  3. Implement stateful components using slots

    main

    Stateful components use a slot system to allow dynamic content injection. In your template, use <slot name="slotname" />. When rendering, use RenderWithSlots to pass a map of elements to those slots.

    // Template with slots
    // <vstack>
    //    <text>Username:</text>
    //    <slot name="input" />
    // </vstack>
    
    // Rendering with slots
    slots := map[string]pony.Element{
        "input": m.inputComp.Render(),
    }
    output := m.template.RenderWithSlots(data, slots, m.width, m.height)
  4. How stateful components with slots work in pony

    main

    Pony uses a pattern where stateful components manage their own state and logic but render to stateless pony.Element structures. This allows components to be decoupled from layout concerns, as the pony layout engine handles sizing automatically.

    The Pattern

    1. Component manages state: The component handles Update logic and stores internal data.
    2. Component renders to Element: The component's Render method returns a declarative pony.Element (not a string or a view).
    3. Slots inject components: Templates use <slot name="..." /> placeholders to define where components should be injected.
    4. Bubble Tea routes events: The main Bubble Tea Update loop routes incoming messages to the currently focused component.

    Key Insight

    Components do not need to know their own size. They return Element structures, and pony's layout engine calculates the necessary dimensions during the template rendering phase.

    type Input struct {
        value   string
        cursor  int
        focused bool
    }
    
    // Update handles events (state management)
    func (i *Input) Update(msg tea.Msg) {
        switch msg := msg.(type) {
        case tea.KeyPressMsg:
            i.value += msg.String()
            i.cursor++
        }
    }
    
    // Render returns pony Element (no size needed!)
    func (i *Input) Render() pony.Element {
        style, _ := pony.ParseStyle("fg:white")
        
        return pony.NewBox(
            pony.NewText(i.value).WithStyle(style),
        ).WithBorder("rounded").WithPadding(1)
    }
  5. Use Go templates in pony markup

    main

    Pony supports standard Go template syntax for variables, conditionals, loops, and built-in functions.

    <!-- Variables -->
    <text>Hello, {{ .Username }}!</text>
    
    <!-- Conditionals -->
    {{ if .IsOnline }}
    <text style="fg:green">● Online</text>
    {{ else }}
    <text style="fg:red">○ Offline</text>
    {{ end }}
    
    <!-- Loops -->
    {{ range .Items }}
    <text>• {{ . }}</text>
    {{ end }}
    
    <!-- Functions: upper, lower, title, trim, join, printf, add, sub, mul, div, repeat -->
    <text>{{ upper .Title }}</text>
    <text>{{ printf "Count: %d" .Count }}</text>
  6. Set element IDs in Pony

    main

    You can assign IDs to elements to facilitate hit testing and interaction. IDs can be set via XML markup or programmatically in Go.

    In Markup:

    <button id="submit-btn" text="Submit" />

    Programmatically:

    btn := pony.NewButton("Submit")
    btn.SetID("submit-btn")

    For Custom Components: When building a component that renders a tree of elements, call SetID() on the top-level element (e.g., a VStack) to ensure that clicking anywhere within the component returns the component's ID instead of the IDs of its children.

    func (i *Input) Render() pony.Element {
        vstack := pony.NewVStack(
            pony.NewText(i.label),
            pony.NewBox(pony.NewText(i.value)).Border("rounded"),
        )
    
        // Set the input's ID on the VStack so clicks return "my-input", not child IDs
        vstack.SetID(i.ID())
    
        return vstack
    }
  7. Tips for pony testing

    main

    When working with the pony test suite, keep these best practices in mind:

    • Subtests: Use t.Run(name, func(t *testing.T) { ... }) for multiple test cases. Golden files for subtests are stored in testdata/TestName/subtest.golden.
    • File Naming: Keep test names descriptive, as they determine the name of the generated golden file.
    • Reviewing Changes: When using go test -update, review the diffs in your version control system carefully to ensure no unintended rendering changes were introduced.
  8. Implement mouse click handling in Pony

    main

    Pony provides stateless mouse click handling using RenderWithBounds and HitTest. This allows you to capture clicks without mutating your model inside the View() function.

    To implement this:

    1. Use tmpl.RenderWithBounds to get both the rendered screen and a BoundsMap.
    2. Set view.MouseMode = tea.MouseModeAllMotion in your Bubble Tea view.
    3. Use view.Callback to perform a HitTest on the mouse coordinates.
    4. Return a tea.Cmd containing the element's ID to be handled in your Update() function.

    Important: To ensure clicks on a custom component return the component's ID rather than its children's IDs, you must call SetID() on the root element returned by the component's Render() method.

    type buttonClickMsg string
    
    func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case buttonClickMsg:
            switch msg {
            case "submit-btn":
                return m, m.submitForm()
            case "cancel-btn":
                return m, tea.Quit
            }
        }
        return m, nil
    }
    
    func (m model) View() tea.View {
        data := ViewData{...}
    
        // Render and get bounds map
        scr, boundsMap := m.template.RenderWithBounds(data, nil, m.width, m.height)
    
        view := tea.NewView(scr.Render())
        view.MouseMode = tea.MouseModeAllMotion
    
        view.Callback = func(msg tea.Msg) tea.Cmd {
            if click, ok := msg.(tea.MouseClickMsg); ok {
                mouse := click.Mouse()
    
                if elem := boundsMap.HitTest(mouse.X, mouse.Y); elem != nil {
                    return func() tea.Msg {
                        return buttonClickMsg(elem.ID())
                    }
                }
            }
            return nil
        }
    
        return view
    }
  9. Integrate pony with Bubble Tea v2

    main

    To use pony with Bubble Tea, use pony for declarative view rendering while delegating application lifecycle, event handling, and commands to Bubble Tea.

    Follow these integration steps:

    1. Store the Template in your Model: Parse your markup during model initialization using pony.MustParse(markup).
    2. Render in the View() method: Prepare your data in a map[string]interface{} and call m.template.RenderToSize(data, m.width, m.height) to generate the output. Wrap the result in tea.NewView(output).
    3. Handle Window Resizing: Capture tea.WindowSizeMsg in your Update method to update the width and height stored in your model, ensuring the next RenderToSize call uses the correct dimensions.
    4. Request Initial Size: Return tea.RequestWindowSize from your Init() method to ensure the first render has valid dimensions.
    type model struct {
        template *pony.Template
        width    int
        height   int
        count    int
    }
    
    func initialModel() model {
        return model{
            template: pony.MustParse(markup),
        }
    }
    
    func (m model) View() tea.View {
        data := map[string]interface{}{
            "Count": m.count,
        }
        output := m.template.RenderToSize(data, m.width, m.height)
        return tea.NewView(output)
    }
    
    func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
        switch msg := msg.(type) {
        case tea.WindowSizeMsg:
            m.width = msg.Width
            m.height = msg.Height
        }
        return m, nil
    }
    
    func (m model) Init() tea.Cmd {
        return tea.RequestWindowSize
    }
  10. Implement a stateful component in pony

    main

    To create a stateful component, implement a struct that manages its own internal state and provides two primary methods:

    1. Update(msg tea.Msg): Handles state transitions based on incoming Bubble Tea messages.
    2. Render() pony.Element: Returns a declarative pony.Element representing the component's visual structure.

    Because components return pony.Element, they can be composed of any pony primitives (like NewBox, NewText, etc.) and benefit from automatic layout management.

    type Input struct {
        value   string
        cursor  int
        focused bool
    }
    
    func (i *Input) Update(msg tea.Msg) {
        switch msg := msg.(type) {
        case tea.KeyPressMsg:
            i.value += msg.String()
            i.cursor++
        }
    }
    
    func (i *Input) Render() pony.Element {
        style, _ := pony.ParseStyle("fg:white")
        
        return pony.NewBox(
            pony.NewText(i.value).WithStyle(style),
        ).WithBorder("rounded").WithPadding(1)
    }
  11. Run tests in pony

    main

    Pony uses the standard Go testing framework. You can run tests using the following commands:

    • Run all tests: go test ./...
    • Run a specific test: go test -run <TestName> (e.g., go test -run TestRender)
    • Run with verbose output: go test -v
    • Run with coverage: go test -cover
    # Run all tests
    go test ./...
    
    # Run specific test
    go test -run TestRender
    
    # Run with verbose output
    go test -v
    
    # Run with coverage
    go test -cover