tui

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Mar 16, 2026 License: MIT Imports: 5 Imported by: 0

README

pkg/tui — Panel-Based TUI Framework

A reusable framework for building panel-based terminal UIs with Bubbletea. Extracted from Gitshelf.

What You Get

  • Panel rendering — bordered boxes with numbered titles, accent colors, and scrollbars
  • Prompt system — text input and confirmation dialogs with quick-select shortcuts
  • Navigation helpers — cursor clamping, visible-range windowing, tab cycling, panel state cycling (normal → maximized → hidden)
  • App orchestration — a Bubbletea model that handles window resize, focus, mouse clicks, prompt lifecycle, and delegates domain keys to your handler
  • Refresh flags — bitmask system so your loader only reloads what changed

Quick Start

package main

import (
    "fmt"
    "os"

    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"

    "github.com/ignaciotcrespo/gitshelf/pkg/tui"
)

// 1. Define your panel IDs and prompt modes as int constants.

const (
    PanelList tui.PanelID = iota
    PanelDetail
    PanelPreview
)

const (
    PromptNone   tui.PromptMode = iota
    PromptAdd
    PromptRename
    PromptConfirm // must match the confirmMode passed to NewPrompt
)

const (
    ConfirmNone   tui.ConfirmAction = iota
    ConfirmDelete
)

// 2. Implement the PromptLabeler interface.

type myLabeler struct{}

func (myLabeler) PromptLabel(mode tui.PromptMode) string {
    switch mode {
    case PromptAdd:
        return "New item name"
    case PromptRename:
        return "Rename"
    }
    return ""
}

func (myLabeler) ConfirmMessage(action tui.ConfirmAction, target string) string {
    switch action {
    case ConfirmDelete:
        return fmt.Sprintf("Delete '%s'", target)
    }
    return ""
}

// 3. Implement PanelRenderer.

type myRenderer struct {
    items []string
    sel   int
}

func (r *myRenderer) RenderPanel(id tui.PanelID, width, height int, focused bool) (string, *tui.ScrollInfo) {
    // Return rendered content and optional scroll info
    return "panel content here", nil
}

func (r *myRenderer) RenderHeader(width int) string {
    return lipgloss.NewStyle().Bold(true).Render(" My App")
}

func (r *myRenderer) RenderHelp(promptActive bool) string {
    if promptActive {
        return " enter confirm · esc cancel"
    }
    return " n new · d delete · q quit"
}

// 4. Implement DataLoader.

type myLoader struct{}

func (myLoader) Refresh(flag tui.RefreshFlag) {
    // Reload data based on flag
}

// 5. Implement Logger.

type myLogger struct{}

func (myLogger) SetStatus(msg string) { /* log status */ }
func (myLogger) SetError(msg string)  { /* log error */ }

// 6. Wire it all together.

func main() {
    renderer := &myRenderer{items: []string{"alpha", "beta", "gamma"}}
    logger := myLogger{}

    config := tui.AppConfig{
        Panels: []tui.PanelDef{
            {ID: PanelList, Title: "Items", Num: 1},
            {ID: PanelDetail, Title: "Detail", Num: 2},
            {ID: PanelPreview, Title: "Preview", Num: 3},
        },
        PivotIDs: []tui.PanelID{PanelList},
        KeyHandler: func(key string, state *tui.AppState) tui.KeyResult {
            switch key {
            case "q", "ctrl+c":
                return tui.KeyResult{Quit: true}
            case "n":
                return tui.KeyResult{
                    Prompt: &tui.PromptReq{Mode: PromptAdd},
                }
            case "d":
                return tui.KeyResult{
                    Prompt: &tui.PromptReq{
                        Confirm: ConfirmDelete,
                        Target:  "current item",
                    },
                }
            }
            return tui.KeyResult{}
        },
        Labeler:  myLabeler{},
        Renderer: renderer,
        Loader:   myLoader{},
    }

    app := tui.NewApp(config, logger)

    // Handle prompt completions
    app.OnPromptResult = func(result *tui.Result) bool {
        switch result.Mode {
        case PromptAdd:
            logger.SetStatus(fmt.Sprintf("Created: %s", result.Value))
        case PromptConfirm:
            if result.Confirmed {
                logger.SetStatus("Deleted")
            }
        }
        return false
    }

    p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion())
    if _, err := p.Run(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

Core Types

Type Aliases

The framework uses plain int aliases so consumers define their own constants without importing an enum package:

type PanelID      = int
type PanelState   = int   // PanelNormal (0), PanelMaximized (1), PanelHidden (2)
type PromptMode   = int
type ConfirmAction = int
RefreshFlag

Bitmask telling the loader what data to reload:

tui.RefreshNone       // nothing
tui.RefreshDiff       // reload diff/preview
tui.RefreshCLFiles    // reload file list (implies diff)
tui.RefreshShelfFiles // reload secondary file list (implies diff)
tui.RefreshAll        // reload everything

Return the appropriate flag from your KeyHandler so the loader does minimal work.

Panel Rendering

Box
content := tui.Box(num, title, body, width, height, focused, tui.BoxOpts{
    Scroll: tui.ScrollInfo{TotalLines: 100, VisibleLines: 20, ScrollPos: 5},
    Accent: lipgloss.Color("251"), // override border color
})
  • Truncates content to fit width × height (no overflow)
  • Renders a scrollbar on the right border when TotalLines > VisibleLines
  • Set Accent for in-context but unfocused panels (e.g. light gray vs bright white)

Before calling Box, initialize the package-level styles:

tui.ActiveBorderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("255"))
tui.InactiveBorderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240"))
tui.TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255"))
tui.StatusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
CyclePanelState
newState, shouldMoveFocus := tui.CyclePanelState(currentState, isFocused)
// normal → maximized → hidden → normal
// Returns shouldMoveFocus=true when hiding so you can move focus elsewhere
Region
r := tui.Region{X: 10, Y: 0, W: 40, H: 20}
if r.Contains(mouseX, mouseY) {
    // clicked inside this panel
}

Navigation Helpers

VisibleRange

Windowed scrolling that keeps the selected item visible:

start, end := tui.VisibleRange(selectedIndex, totalItems, maxVisibleLines, linesPerItem)
for i := start; i < end; i++ {
    // render item i
}
ClampCursor
cursor = tui.ClampCursor(cursor, len(items)) // keeps cursor in [0, len-1]
TabPanel

Cycle focus through a list of panel IDs:

flow := []tui.PanelID{PanelList, PanelDetail, PanelPreview}
nextFocus := tui.TabPanel(currentFocus, flow, +1) // forward
prevFocus := tui.TabPanel(currentFocus, flow, -1) // backward

Prompt System

Setup
prompt := tui.NewPrompt(myLabeler{}, PromptConfirm)

The second argument is the PromptMode value your app uses for confirmations. The prompt uses this to distinguish confirmation dialogs from text input.

Text Input
cmd := prompt.Start(PromptAdd, "default value")
// or with quick-select options:
cmd := prompt.StartWithOptions(PromptRename, "current", []string{"Option A", "Option B"})

Quick-select assigns a unique letter shortcut to each option (e.g. [O]ption A [p]tion B). Pressing the letter picks that option immediately.

Confirmation
prompt.StartConfirm(ConfirmDelete, "item name")
// Shows: "Delete 'item name'? (y/n)"
// Message text comes from your PromptLabeler.ConfirmMessage()
Handling Results
result, handled, cmd := prompt.HandleKey(keyMsg)
if result != nil {
    // result.Mode tells you which prompt completed
    // result.Value has the text (for input prompts)
    // result.Confirmed is true/false (for confirmations)
    // result.ConfirmAction and result.ConfirmTarget echo back what you passed
}
Prompt Styles

Initialize before first render:

tui.InputLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
tui.ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
tui.HelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))

App Model

tui.App is a ready-made Bubbletea model. It handles:

  • tea.WindowSizeMsg — stores width/height
  • tea.FocusMsg — triggers RefreshAll
  • tea.MouseMsg — hit-tests panel regions, updates focus/pivot
  • Prompt lifecycle — active prompt intercepts keys
  • Everything else → your KeyHandler
AppConfig
config := tui.AppConfig{
    Panels:     []tui.PanelDef{...},
    PivotIDs:   []tui.PanelID{...},     // panels that switch context (e.g. list vs tree)
    KeyHandler: func(key string, state *tui.AppState) tui.KeyResult { ... },
    Labeler:    myLabeler{},
    Renderer:   myRenderer{},
    Loader:     myLoader{},
}
AppState

The framework manages generic navigation state:

type AppState struct {
    Focus       PanelID
    Pivot       PanelID
    Cursors     map[PanelID]int
    Selections  map[string]bool
    PanelStates map[PanelID]PanelState
    Scrolls     map[PanelID]int
    HScrolls    map[PanelID]int
    Custom      any  // your domain state goes here
}

Put domain-specific state in Custom and type-assert in your KeyHandler/Renderer:

type myState struct {
    WrapMode bool
    Filter   string
}

// In KeyHandler:
custom := state.Custom.(*myState)
custom.WrapMode = !custom.WrapMode
Custom View Layout

The default App.View() is minimal (header + prompt + help). For custom layouts, embed tui.App in your own model and override View():

type MyModel struct {
    tui.App
    // your fields
}

func (m MyModel) View() string {
    // Use m.App.Width, m.App.Height for dimensions
    // Use m.App.Config.Renderer.RenderPanel(...) for each panel
    // Use tui.Box(...) for bordered panels
    // Use m.App.Prompt.Render() for the prompt bar
    // Use m.App.Config.Renderer.RenderHelp(...) for the help bar
    // Record m.App.PanelRegions for mouse click detection
    return lipgloss.JoinVertical(lipgloss.Left, header, panels, help)
}

Design Principles

  • No genericsint aliases + any for custom state keeps it simple
  • No circular deps — the framework has zero knowledge of your domain
  • Pure functionsVisibleRange, ClampCursor, TabPanel, CyclePanelState are all stateless
  • Styles are package vars — set them once at init, the framework uses them everywhere
  • Consumer owns layout — the framework provides building blocks, not a rigid grid

Documentation

Overview

Package tui provides a reusable framework for building panel-based terminal UIs with Bubbletea.

Index

Constants

This section is empty.

Variables

View Source
var (
	ActiveBorderStyle   lipgloss.Style
	InactiveBorderStyle lipgloss.Style
	TitleStyle          lipgloss.Style
	StatusBarStyle      lipgloss.Style
)

Styles used by panel rendering. Set these from the parent package.

View Source
var (
	InputLabelStyle lipgloss.Style
	ErrorStyle      lipgloss.Style
	HelpStyle       lipgloss.Style
)

Styles used by prompt rendering. Set these from the parent package.

Functions

func Box

func Box(num int, title, content string, width, height int, active bool, opts ...BoxOpts) string

Box renders a bordered panel with a numbered title in the top border. This is the single source of truth for panel rendering constraints. All content is truncated to fit within width×height, preventing overflow.

func ClampCursor

func ClampCursor(cursor, count int) int

ClampCursor ensures cursor is within [0, count-1]. Returns 0 if count is 0.

func RenderOption

func RenderOption(opt QuickOption) string

RenderOption renders a name with its shortcut key highlighted: [C]hanges

func VisibleRange

func VisibleRange(selected, count, maxLines, linesPerItem int) (int, int)

VisibleRange returns the start and end indices of items to display, ensuring the selected item is always visible within maxLines.

Types

type App

type App struct {
	Config AppConfig
	State  AppState
	Prompt Prompt

	Width  int
	Height int

	// PanelRegions stores screen coordinates for mouse hit-testing.
	PanelRegions map[PanelID]Region

	// OnPromptResult is called when a prompt completes.
	// Returns true if a follow-up confirm was started (caller should return early).
	OnPromptResult func(result *Result) bool
	// contains filtered or unexported fields
}

App is the generic Bubbletea model for a panel-based TUI. It can be used as a standalone tea.Model or as a helper embedded in a consumer model.

func NewApp

func NewApp(config AppConfig, logger Logger) App

NewApp creates a framework App with the given config.

func (*App) HandleMouse

func (a *App) HandleMouse(msg tea.MouseMsg) (bool, RefreshFlag)

HandleMouse processes a mouse event for panel focus switching. Returns (handled, refresh).

func (*App) HandleUniversalKey

func (a *App) HandleUniversalKey(key string) (handled bool, quit bool, refresh RefreshFlag)

HandleUniversalKey processes universal keys (quit, tab, panel numbers). Returns (handled, quit, refresh). If handled is true, the key was consumed.

func (App) Init

func (a App) Init() tea.Cmd

Init implements tea.Model.

func (App) Update

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

Update implements tea.Model.

func (App) View

func (a App) View() string

View implements tea.Model.

type AppConfig

type AppConfig struct {
	Panels     []PanelDef
	TabFlow    func(focus, pivot PanelID, panelStates map[PanelID]PanelState) []PanelID
	KeyHandler func(key string, state *AppState) KeyResult // domain key handling
	Labeler    PromptLabeler
	Renderer   PanelRenderer
	Loader     DataLoader
}

AppConfig wires consumer logic into the framework.

type AppState

type AppState struct {
	Focus       PanelID
	Pivot       PanelID
	Cursors     map[PanelID]int        // cursor position per panel
	Selections  map[string]bool        // selected items (generic keys)
	PanelStates map[PanelID]PanelState // Normal/Maximized/Hidden per panel
	Scrolls     map[PanelID]int        // vertical scroll per panel
	HScrolls    map[PanelID]int        // horizontal scroll per panel
	Custom      any                    // consumer-specific state
}

AppState is framework-owned generic state.

type BoxOpts

type BoxOpts struct {
	Scroll ScrollInfo
	Accent lipgloss.Color // override border/title color (empty = default)
}

BoxOpts holds optional rendering parameters for Box.

type ConfirmAction

type ConfirmAction = int

ConfirmAction identifies what dangerous action is pending confirmation.

type DataLoader

type DataLoader interface {
	Refresh(flag RefreshFlag)
}

DataLoader refreshes data based on refresh flags.

type KeyResult

type KeyResult struct {
	Refresh   RefreshFlag
	Prompt    *PromptReq
	StatusMsg string
	ErrorMsg  string
	Quit      bool
}

KeyResult is the output of the consumer's key handler.

type Logger

type Logger interface {
	SetStatus(msg string)
	SetError(msg string)
}

Logger provides status/error reporting.

type PanelDef

type PanelDef struct {
	ID     PanelID
	Title  string
	Num    int  // number key shortcut (1-9)
	Pivot  bool // pressing number also sets this as pivot
	Toggle bool // pressing number cycles Normal → Maximized → Hidden when focused
}

PanelDef defines a panel in the layout.

type PanelID

type PanelID = int

PanelID identifies a panel in the layout.

func TabPanel

func TabPanel(focus PanelID, flow []PanelID, dir int) PanelID

TabPanel cycles focus through the given flow of panels. dir should be 1 (forward) or -1 (backward).

type PanelRenderer

type PanelRenderer interface {
	RenderPanel(id PanelID, width, height int, focused bool) (content string, scroll *ScrollInfo)
	RenderHeader(width int) string
	RenderHelp(promptActive bool) string
}

PanelRenderer renders panel content and help bar.

type PanelState

type PanelState = int

PanelState represents the display state of a toggleable panel.

const (
	PanelNormal    PanelState = 0
	PanelMaximized PanelState = 1
	PanelHidden    PanelState = 2
	PanelMinimized PanelState = 3
)

func CyclePanelState

func CyclePanelState(current PanelState, focused bool) (PanelState, bool)

CyclePanelState cycles a toggleable panel through: normal → maximized → hidden → normal. Returns the new state and whether focus should move away (when hidden).

func CycleWorktreeState

func CycleWorktreeState(current PanelState, focused bool) (PanelState, bool)

CycleWorktreeState toggles the worktrees panel between normal and minimized. Returns the new state and whether focus should move away (when minimizing).

type Prompt

type Prompt struct {
	Mode          PromptMode
	Input         textinput.Model
	ConfirmAction ConfirmAction
	ConfirmTarget string
	Options       []QuickOption
	Labeler       PromptLabeler
	// contains filtered or unexported fields
}

Prompt holds the current input/confirmation state.

func NewPrompt

func NewPrompt(labeler PromptLabeler, confirmMode PromptMode) Prompt

NewPrompt creates a Prompt configured with a labeler and the consumer's confirm mode value.

func (*Prompt) Active

func (p *Prompt) Active() bool

Active returns true if a prompt is currently showing.

func (*Prompt) Cancel

func (p *Prompt) Cancel()

Cancel dismisses the current prompt.

func (*Prompt) HandleKey

func (p *Prompt) HandleKey(msg tea.KeyMsg) (*Result, bool, tea.Cmd)

HandleKey processes a key event for the active prompt. Returns (result, handled, cmd). If result is non-nil, the prompt completed.

func (*Prompt) Render

func (p *Prompt) Render() string

Render returns the prompt bar string.

func (*Prompt) RenderHelp

func (p *Prompt) RenderHelp() string

RenderHelp returns help text for the active prompt.

func (*Prompt) Start

func (p *Prompt) Start(mode PromptMode, defaultValue string) tea.Cmd

Start begins a new input prompt with the given mode and default value.

func (*Prompt) StartConfirm

func (p *Prompt) StartConfirm(action ConfirmAction, target string)

StartConfirm begins a confirmation prompt.

func (*Prompt) StartWithOptions

func (p *Prompt) StartWithOptions(mode PromptMode, defaultValue string, names []string) tea.Cmd

StartWithOptions begins a prompt with quick-select options. Each option gets a unique shortcut key assigned from its name.

func (*Prompt) Update

func (p *Prompt) Update(msg tea.Msg) tea.Cmd

Update processes non-key messages (e.g. cursor blink) for the textinput.

func (*Prompt) Value

func (p *Prompt) Value() string

Value returns the current text input value.

type PromptLabeler

type PromptLabeler interface {
	PromptLabel(mode PromptMode) string
	ConfirmMessage(action ConfirmAction, target string) string
}

PromptLabeler provides display text for prompt modes and confirm actions.

type PromptMode

type PromptMode = int

PromptMode identifies the current input prompt type.

type PromptReq

type PromptReq struct {
	Mode         PromptMode
	DefaultValue string
	Confirm      ConfirmAction
	Target       string
	Options      []string
}

PromptReq describes a prompt the coordinator should start.

type QuickOption

type QuickOption struct {
	Key  rune
	Name string
}

QuickOption is a named option with a shortcut key.

func AssignShortcuts

func AssignShortcuts(names []string) []QuickOption

AssignShortcuts assigns a unique shortcut key to each name. It picks the first unused uppercase letter from each name.

type RefreshFlag

type RefreshFlag int

RefreshFlag tells the coordinator what data to reload.

const (
	RefreshNone       RefreshFlag = 0
	RefreshDiff       RefreshFlag = 1 << iota
	RefreshCLFiles                // reload changelist files (implies diff)
	RefreshShelfFiles             // reload shelf files (implies diff)
	RefreshAll                    // reload everything
	RefreshWorktree               // debounced worktree switch + reload
)

type Region

type Region struct {
	X, Y, W, H int
}

Region stores the screen coordinates of a panel for mouse hit-testing.

func (Region) Contains

func (r Region) Contains(x, y int) bool

Contains returns true if the given screen coordinates fall within this region.

type Result

type Result struct {
	Mode          PromptMode
	Value         string
	Confirmed     bool // for Confirm mode: true=yes, false=cancelled
	ConfirmAction ConfirmAction
	ConfirmTarget string
}

Result represents the outcome of a completed prompt.

type ScrollInfo

type ScrollInfo struct {
	TotalLines   int
	VisibleLines int
	ScrollPos    int
}

ScrollInfo provides scroll position data for rendering a scrollbar on the right border.

Jump to

Keyboard shortcuts

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