sorcery-go/pkg/ui/tui.go

110 lines
4.0 KiB
Go
Executable File

// Package ui implements the bubbletea TUI — the "Focused Forge" interface
// used by admins who want deep y/n configuration without leaving the
// terminal.
//
// Layout (tmux-style):
//
// ┌─────────────────────────────────────────┐
// │ 🔮 SORCERY-Go · Casting: wget, openssl │ header
// ├─────────────────────────────────────────┤
// │ [scrollable build log] │ viewport
// │ ... │
// ├─────────────────────────────────────────┤
// │ [LOAD 2.41] [JOBS 3/12] ▓▓▓▓▓░░░ 42% │ status bar
// └─────────────────────────────────────────┘
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Model holds the entire TUI state.
type Model struct {
casting []string
completed int
total int
logs viewport.Model
progress progress.Model
width int
height int
logLines []string
}
var (
statusStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#353535")).
Bold(true)
logStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false).
BorderForeground(lipgloss.Color("#555555"))
)
// NewModel returns the initial TUI state.
func NewModel(total int) Model {
vp := viewport.New(80, 20)
p := progress.New(progress.WithDefaultGradient())
return Model{
total: total,
logs: vp,
progress: p,
casting: []string{},
}
}
// Init satisfies tea.Model.
func (m Model) Init() tea.Cmd { return nil }
// Update handles messages from the engine.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
case LogMsg:
m.logLines = append(m.logLines, string(msg))
m.logs.SetContent(strings.Join(m.logLines, "\n"))
case CastCompleteMsg:
m.completed++
pct := float64(m.completed) / float64(m.total)
return m, m.progress.SetPercent(pct)
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.logs.Width = msg.Width
m.logs.Height = msg.Height - 4
}
var cmd tea.Cmd
m.logs, cmd = m.logs.Update(msg)
return m, cmd
}
// View renders the screen.
func (m Model) View() string {
header := fmt.Sprintf(" 🔮 SORCERY-Go · Casting: %s", strings.Join(m.casting, ", "))
statusText := fmt.Sprintf(" [JOBS: %d/%d] [ROOT: /]", m.completed, m.total)
spacer := strings.Repeat(" ", max(0, m.width-len(statusText)-20))
bar := lipgloss.JoinHorizontal(lipgloss.Top,
statusStyle.Render(statusText),
spacer,
m.progress.View(),
)
return lipgloss.JoinVertical(lipgloss.Left,
header,
logStyle.Width(m.width).Height(m.height-4).Render(m.logs.View()),
bar,
)
}
// LogMsg is one line of build output streamed into the viewport.
type LogMsg string
// CastCompleteMsg is fired when a worker finishes a spell.
type CastCompleteMsg struct{ Name string }