406 lines
9.8 KiB
Go
406 lines
9.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// Model is the interface for components that slot into Frame regions.
|
|
// View receives the allocated width and height and returns rendered text.
|
|
type Model interface {
|
|
View(width, height int) string
|
|
}
|
|
|
|
// ModelFunc is a convenience adapter for using a function as a Model.
|
|
type ModelFunc func(width, height int) string
|
|
|
|
// View implements Model.
|
|
func (f ModelFunc) View(width, height int) string { return f(width, height) }
|
|
|
|
// Frame is a live compositional AppShell for TUI.
|
|
// Uses HLCRF variant strings for region layout — same as the static Layout system,
|
|
// but with live-updating Model components instead of static strings.
|
|
//
|
|
// frame := cli.NewFrame("HCF")
|
|
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
|
|
// frame.Content(myTableModel)
|
|
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
|
|
// frame.Run()
|
|
type Frame struct {
|
|
variant string
|
|
layout *Composite
|
|
models map[Region]Model
|
|
history []Model // content region stack for Navigate/Back
|
|
out io.Writer
|
|
done chan struct{}
|
|
mu sync.Mutex
|
|
|
|
// Focus management (bubbletea upgrade)
|
|
focused Region
|
|
keyMap KeyMap
|
|
width int
|
|
height int
|
|
program *tea.Program
|
|
}
|
|
|
|
// NewFrame creates a new Frame with the given HLCRF variant string.
|
|
//
|
|
// frame := cli.NewFrame("HCF") // header, content, footer
|
|
// frame := cli.NewFrame("H[LC]F") // header, [left + content], footer
|
|
func NewFrame(variant string) *Frame {
|
|
return &Frame{
|
|
variant: variant,
|
|
layout: Layout(variant),
|
|
models: make(map[Region]Model),
|
|
out: os.Stdout,
|
|
done: make(chan struct{}),
|
|
focused: RegionContent,
|
|
keyMap: DefaultKeyMap(),
|
|
width: 80,
|
|
height: 24,
|
|
}
|
|
}
|
|
|
|
// Header sets the Header region model.
|
|
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
|
|
|
|
// Left sets the Left sidebar region model.
|
|
func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f }
|
|
|
|
// Content sets the Content region model.
|
|
func (f *Frame) Content(m Model) *Frame { f.setModel(RegionContent, m); return f }
|
|
|
|
// Right sets the Right sidebar region model.
|
|
func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f }
|
|
|
|
// Footer sets the Footer region model.
|
|
func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f }
|
|
|
|
func (f *Frame) setModel(r Region, m Model) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.models[r] = m
|
|
}
|
|
|
|
// Navigate replaces the Content region with a new model, pushing the current one
|
|
// onto the history stack for Back().
|
|
func (f *Frame) Navigate(m Model) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if current, ok := f.models[RegionContent]; ok {
|
|
f.history = append(f.history, current)
|
|
}
|
|
f.models[RegionContent] = m
|
|
}
|
|
|
|
// Back pops the content history stack, restoring the previous Content model.
|
|
// Returns false if the history is empty.
|
|
func (f *Frame) Back() bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if len(f.history) == 0 {
|
|
return false
|
|
}
|
|
f.models[RegionContent] = f.history[len(f.history)-1]
|
|
f.history = f.history[:len(f.history)-1]
|
|
return true
|
|
}
|
|
|
|
// Stop signals the Frame to exit its Run loop.
|
|
func (f *Frame) Stop() {
|
|
select {
|
|
case <-f.done:
|
|
default:
|
|
close(f.done)
|
|
}
|
|
}
|
|
|
|
// WithKeyMap sets custom key bindings for Frame navigation.
|
|
func (f *Frame) WithKeyMap(km KeyMap) *Frame {
|
|
f.keyMap = km
|
|
return f
|
|
}
|
|
|
|
// Focused returns the currently focused region.
|
|
func (f *Frame) Focused() Region {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.focused
|
|
}
|
|
|
|
// Focus sets focus to a specific region.
|
|
// Ignores the request if the region is not in this Frame's variant.
|
|
func (f *Frame) Focus(r Region) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if _, exists := f.layout.regions[r]; exists {
|
|
f.focused = r
|
|
}
|
|
}
|
|
|
|
// buildFocusRing returns the ordered list of regions in this Frame's variant.
|
|
// Order follows HLCRF convention.
|
|
func (f *Frame) buildFocusRing() []Region {
|
|
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
|
var ring []Region
|
|
for _, r := range order {
|
|
if _, exists := f.layout.regions[r]; exists {
|
|
ring = append(ring, r)
|
|
}
|
|
}
|
|
return ring
|
|
}
|
|
|
|
// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps.
|
|
// In non-TTY mode, it renders once and returns immediately.
|
|
func (f *Frame) Run() {
|
|
if !f.isTTY() {
|
|
fmt.Fprint(f.out, f.String())
|
|
return
|
|
}
|
|
f.runLive()
|
|
}
|
|
|
|
// RunFor runs the frame for a fixed duration, then stops.
|
|
// Useful for dashboards that refresh periodically.
|
|
func (f *Frame) RunFor(d time.Duration) {
|
|
go func() {
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-timer.C:
|
|
f.Stop()
|
|
case <-f.done:
|
|
}
|
|
}()
|
|
f.Run()
|
|
}
|
|
|
|
// String renders the frame as a static string (no ANSI, no live updates).
|
|
// This is the non-TTY fallback path.
|
|
func (f *Frame) String() string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
w, h := f.termSize()
|
|
var sb strings.Builder
|
|
|
|
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
|
for _, r := range order {
|
|
if _, exists := f.layout.regions[r]; !exists {
|
|
continue
|
|
}
|
|
m, ok := f.models[r]
|
|
if !ok {
|
|
continue
|
|
}
|
|
rw, rh := f.regionSize(r, w, h)
|
|
view := m.View(rw, rh)
|
|
if view != "" {
|
|
sb.WriteString(view)
|
|
if !strings.HasSuffix(view, "\n") {
|
|
sb.WriteByte('\n')
|
|
}
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func (f *Frame) isTTY() bool {
|
|
if file, ok := f.out.(*os.File); ok {
|
|
return term.IsTerminal(int(file.Fd()))
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (f *Frame) termSize() (int, int) {
|
|
if file, ok := f.out.(*os.File); ok {
|
|
w, h, err := term.GetSize(int(file.Fd()))
|
|
if err == nil {
|
|
return w, h
|
|
}
|
|
}
|
|
return 80, 24 // sensible default
|
|
}
|
|
|
|
func (f *Frame) regionSize(r Region, totalW, totalH int) (int, int) {
|
|
// Simple allocation: Header/Footer get 1 line, sidebars get 1/4 width,
|
|
// Content gets the rest.
|
|
switch r {
|
|
case RegionHeader, RegionFooter:
|
|
return totalW, 1
|
|
case RegionLeft, RegionRight:
|
|
return totalW / 4, totalH - 2 // minus header + footer
|
|
case RegionContent:
|
|
sideW := 0
|
|
if _, ok := f.models[RegionLeft]; ok {
|
|
sideW += totalW / 4
|
|
}
|
|
if _, ok := f.models[RegionRight]; ok {
|
|
sideW += totalW / 4
|
|
}
|
|
return totalW - sideW, totalH - 2
|
|
}
|
|
return totalW, totalH
|
|
}
|
|
|
|
func (f *Frame) runLive() {
|
|
// Enter alt-screen.
|
|
fmt.Fprint(f.out, "\033[?1049h")
|
|
// Hide cursor.
|
|
fmt.Fprint(f.out, "\033[?25l")
|
|
|
|
defer func() {
|
|
// Show cursor.
|
|
fmt.Fprint(f.out, "\033[?25h")
|
|
// Leave alt-screen.
|
|
fmt.Fprint(f.out, "\033[?1049l")
|
|
}()
|
|
|
|
ticker := time.NewTicker(80 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
f.renderFrame()
|
|
|
|
select {
|
|
case <-f.done:
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *Frame) renderFrame() {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
w, h := f.termSize()
|
|
|
|
// Move to top-left.
|
|
fmt.Fprint(f.out, "\033[H")
|
|
// Clear screen.
|
|
fmt.Fprint(f.out, "\033[2J")
|
|
|
|
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
|
for _, r := range order {
|
|
if _, exists := f.layout.regions[r]; !exists {
|
|
continue
|
|
}
|
|
m, ok := f.models[r]
|
|
if !ok {
|
|
continue
|
|
}
|
|
rw, rh := f.regionSize(r, w, h)
|
|
view := m.View(rw, rh)
|
|
if view != "" {
|
|
fmt.Fprint(f.out, view)
|
|
if !strings.HasSuffix(view, "\n") {
|
|
fmt.Fprintln(f.out)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Built-in Region Components
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// statusLineModel renders a "title key:value key:value" bar.
|
|
type statusLineModel struct {
|
|
title string
|
|
pairs []string
|
|
}
|
|
|
|
// StatusLine creates a header/footer bar with a title and key:value pairs.
|
|
//
|
|
// frame.Header(cli.StatusLine("core dev", "18 repos", "main"))
|
|
func StatusLine(title string, pairs ...string) Model {
|
|
return &statusLineModel{title: title, pairs: pairs}
|
|
}
|
|
|
|
func (s *statusLineModel) View(width, _ int) string {
|
|
parts := []string{BoldStyle.Render(s.title)}
|
|
for _, p := range s.pairs {
|
|
parts = append(parts, DimStyle.Render(p))
|
|
}
|
|
line := strings.Join(parts, " ")
|
|
if width > 0 {
|
|
line = Truncate(line, width)
|
|
}
|
|
return line
|
|
}
|
|
|
|
// keyHintsModel renders keyboard shortcut hints.
|
|
type keyHintsModel struct {
|
|
hints []string
|
|
}
|
|
|
|
// KeyHints creates a footer showing keyboard shortcuts.
|
|
//
|
|
// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit"))
|
|
func KeyHints(hints ...string) Model {
|
|
return &keyHintsModel{hints: hints}
|
|
}
|
|
|
|
func (k *keyHintsModel) View(width, _ int) string {
|
|
parts := make([]string, len(k.hints))
|
|
for i, h := range k.hints {
|
|
parts[i] = DimStyle.Render(h)
|
|
}
|
|
line := strings.Join(parts, " ")
|
|
if width > 0 {
|
|
line = Truncate(line, width)
|
|
}
|
|
return line
|
|
}
|
|
|
|
// breadcrumbModel renders a navigation path.
|
|
type breadcrumbModel struct {
|
|
parts []string
|
|
}
|
|
|
|
// Breadcrumb creates a navigation breadcrumb bar.
|
|
//
|
|
// frame.Header(cli.Breadcrumb("core", "dev", "health"))
|
|
func Breadcrumb(parts ...string) Model {
|
|
return &breadcrumbModel{parts: parts}
|
|
}
|
|
|
|
func (b *breadcrumbModel) View(width, _ int) string {
|
|
styled := make([]string, len(b.parts))
|
|
for i, p := range b.parts {
|
|
if i == len(b.parts)-1 {
|
|
styled[i] = BoldStyle.Render(p)
|
|
} else {
|
|
styled[i] = DimStyle.Render(p)
|
|
}
|
|
}
|
|
line := strings.Join(styled, DimStyle.Render(" > "))
|
|
if width > 0 {
|
|
line = Truncate(line, width)
|
|
}
|
|
return line
|
|
}
|
|
|
|
// staticModel wraps a plain string as a Model.
|
|
type staticModel struct {
|
|
text string
|
|
}
|
|
|
|
// StaticModel wraps a static string as a Model, for use in Frame regions.
|
|
func StaticModel(text string) Model {
|
|
return &staticModel{text: text}
|
|
}
|
|
|
|
func (s *staticModel) View(_, _ int) string {
|
|
return s.text
|
|
}
|