feat(frame): implement tea.Model (Init, Update, View) with lipgloss layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-22 21:12:32 +00:00
parent acfbc2aaee
commit 331bcd564d
No known key found for this signature in database
GPG key ID: AF404715446AEB41
2 changed files with 356 additions and 0 deletions

View file

@ -9,6 +9,7 @@ import (
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)
@ -158,6 +159,234 @@ func (f *Frame) buildFocusRing() []Region {
return ring
}
// Init implements tea.Model. Collects Init() from all FrameModel regions.
func (f *Frame) Init() tea.Cmd {
f.mu.Lock()
defer f.mu.Unlock()
var cmds []tea.Cmd
for _, m := range f.models {
fm := adaptModel(m)
if cmd := fm.Init(); cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// Update implements tea.Model. Routes messages based on type and focus.
func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
f.mu.Lock()
defer f.mu.Unlock()
switch msg := msg.(type) {
case tea.WindowSizeMsg:
f.width = msg.Width
f.height = msg.Height
return f, f.broadcastLocked(msg)
case tea.KeyMsg:
switch msg.Type {
case f.keyMap.Quit:
return f, tea.Quit
case f.keyMap.Back:
f.backLocked()
return f, nil
case f.keyMap.FocusNext:
f.cycleFocusLocked(1)
return f, nil
case f.keyMap.FocusPrev:
f.cycleFocusLocked(-1)
return f, nil
case f.keyMap.FocusUp:
f.spatialFocusLocked(RegionHeader)
return f, nil
case f.keyMap.FocusDown:
f.spatialFocusLocked(RegionFooter)
return f, nil
case f.keyMap.FocusLeft:
f.spatialFocusLocked(RegionLeft)
return f, nil
case f.keyMap.FocusRight:
f.spatialFocusLocked(RegionRight)
return f, nil
default:
// Forward to focused region
return f, f.updateFocusedLocked(msg)
}
default:
// Broadcast non-key messages to all regions
return f, f.broadcastLocked(msg)
}
}
// View implements tea.Model. Composes region views using lipgloss.
func (f *Frame) View() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.viewLocked()
}
func (f *Frame) viewLocked() string {
w, h := f.width, f.height
if w == 0 || h == 0 {
w, h = f.termSize()
}
// Calculate region dimensions
headerH, footerH := 0, 0
if _, ok := f.layout.regions[RegionHeader]; ok {
if _, ok := f.models[RegionHeader]; ok {
headerH = 1
}
}
if _, ok := f.layout.regions[RegionFooter]; ok {
if _, ok := f.models[RegionFooter]; ok {
footerH = 1
}
}
middleH := h - headerH - footerH
if middleH < 1 {
middleH = 1
}
// Render each region
header := f.renderRegionLocked(RegionHeader, w, headerH)
footer := f.renderRegionLocked(RegionFooter, w, footerH)
// Calculate sidebar widths
leftW, rightW := 0, 0
if _, ok := f.layout.regions[RegionLeft]; ok {
if _, ok := f.models[RegionLeft]; ok {
leftW = w / 4
}
}
if _, ok := f.layout.regions[RegionRight]; ok {
if _, ok := f.models[RegionRight]; ok {
rightW = w / 4
}
}
contentW := w - leftW - rightW
if contentW < 1 {
contentW = 1
}
left := f.renderRegionLocked(RegionLeft, leftW, middleH)
right := f.renderRegionLocked(RegionRight, rightW, middleH)
content := f.renderRegionLocked(RegionContent, contentW, middleH)
// Compose middle row
var middleParts []string
if leftW > 0 {
middleParts = append(middleParts, left)
}
middleParts = append(middleParts, content)
if rightW > 0 {
middleParts = append(middleParts, right)
}
middle := content
if len(middleParts) > 1 {
middle = lipgloss.JoinHorizontal(lipgloss.Top, middleParts...)
}
// Compose full layout
var verticalParts []string
if headerH > 0 {
verticalParts = append(verticalParts, header)
}
verticalParts = append(verticalParts, middle)
if footerH > 0 {
verticalParts = append(verticalParts, footer)
}
return lipgloss.JoinVertical(lipgloss.Left, verticalParts...)
}
func (f *Frame) renderRegionLocked(r Region, w, h int) string {
if w <= 0 || h <= 0 {
return ""
}
m, ok := f.models[r]
if !ok {
return ""
}
fm := adaptModel(m)
return fm.View(w, h)
}
// cycleFocusLocked moves focus forward (+1) or backward (-1) in the focus ring.
// Must be called with f.mu held.
func (f *Frame) cycleFocusLocked(dir int) {
ring := f.buildFocusRing()
if len(ring) == 0 {
return
}
idx := 0
for i, r := range ring {
if r == f.focused {
idx = i
break
}
}
idx = (idx + dir + len(ring)) % len(ring)
f.focused = ring[idx]
}
// spatialFocusLocked moves focus to a specific region if it exists in the layout.
// Must be called with f.mu held.
func (f *Frame) spatialFocusLocked(target Region) {
if _, exists := f.layout.regions[target]; exists {
f.focused = target
}
}
// backLocked pops the content history. Must be called with f.mu held.
func (f *Frame) backLocked() {
if len(f.history) == 0 {
return
}
f.models[RegionContent] = f.history[len(f.history)-1]
f.history = f.history[:len(f.history)-1]
}
// broadcastLocked sends a message to all FrameModel regions.
// Must be called with f.mu held.
func (f *Frame) broadcastLocked(msg tea.Msg) tea.Cmd {
var cmds []tea.Cmd
for r, m := range f.models {
fm := adaptModel(m)
updated, cmd := fm.Update(msg)
f.models[r] = updated
if cmd != nil {
cmds = append(cmds, cmd)
}
}
return tea.Batch(cmds...)
}
// updateFocusedLocked sends a message to only the focused region.
// Must be called with f.mu held.
func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd {
m, ok := f.models[f.focused]
if !ok {
return nil
}
fm := adaptModel(m)
updated, cmd := fm.Update(msg)
f.models[f.focused] = updated
return cmd
}
// 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() {

View file

@ -299,6 +299,133 @@ func TestFrameFocus_Good(t *testing.T) {
})
}
func TestFrameTeaModel_Good(t *testing.T) {
t.Run("Init collects FrameModel inits", func(t *testing.T) {
f := NewFrame("HCF")
fm := &testFrameModel{viewText: "x"}
f.Content(fm)
cmd := f.Init()
// Should produce a batch command (non-nil if any FrameModel has Init)
// fm.Init returns nil, so batch of nils = nil
_ = cmd // no panic = success
assert.True(t, fm.initCalled)
})
t.Run("Update routes key to focused region", func(t *testing.T) {
f := NewFrame("HCF")
header := &testFrameModel{viewText: "h"}
content := &testFrameModel{viewText: "c"}
f.Header(header)
f.Content(content)
// Focus is Content by default
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}
f.Update(keyMsg)
assert.True(t, content.updateCalled, "focused region should receive key")
assert.False(t, header.updateCalled, "unfocused region should not receive key")
})
t.Run("Update broadcasts WindowSizeMsg to all", func(t *testing.T) {
f := NewFrame("HCF")
header := &testFrameModel{viewText: "h"}
content := &testFrameModel{viewText: "c"}
footer := &testFrameModel{viewText: "f"}
f.Header(header)
f.Content(content)
f.Footer(footer)
sizeMsg := tea.WindowSizeMsg{Width: 120, Height: 40}
f.Update(sizeMsg)
assert.True(t, header.updateCalled, "header should get resize")
assert.True(t, content.updateCalled, "content should get resize")
assert.True(t, footer.updateCalled, "footer should get resize")
assert.Equal(t, 120, f.width)
assert.Equal(t, 40, f.height)
})
t.Run("Update handles quit key", func(t *testing.T) {
f := NewFrame("HCF")
f.Content(StaticModel("c"))
quitMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
_, cmd := f.Update(quitMsg)
// cmd should be tea.Quit
assert.NotNil(t, cmd)
})
t.Run("Update handles back key", func(t *testing.T) {
f := NewFrame("HCF")
f.Content(StaticModel("page-1"))
f.Navigate(StaticModel("page-2"))
escMsg := tea.KeyMsg{Type: tea.KeyEsc}
f.Update(escMsg)
assert.Contains(t, f.String(), "page-1")
})
t.Run("Update cycles focus with Tab", func(t *testing.T) {
f := NewFrame("HCF")
f.Header(StaticModel("h"))
f.Content(StaticModel("c"))
f.Footer(StaticModel("f"))
assert.Equal(t, RegionContent, f.Focused())
tabMsg := tea.KeyMsg{Type: tea.KeyTab}
f.Update(tabMsg)
assert.Equal(t, RegionFooter, f.Focused())
f.Update(tabMsg)
assert.Equal(t, RegionHeader, f.Focused()) // wraps around
shiftTabMsg := tea.KeyMsg{Type: tea.KeyShiftTab}
f.Update(shiftTabMsg)
assert.Equal(t, RegionFooter, f.Focused()) // back
})
t.Run("View produces non-empty output", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
f := NewFrame("HCF")
f.Header(StaticModel("HEAD"))
f.Content(StaticModel("BODY"))
f.Footer(StaticModel("FOOT"))
view := f.View()
assert.Contains(t, view, "HEAD")
assert.Contains(t, view, "BODY")
assert.Contains(t, view, "FOOT")
})
t.Run("View lipgloss layout: header before content before footer", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
f := NewFrame("HCF")
f.Header(StaticModel("AAA"))
f.Content(StaticModel("BBB"))
f.Footer(StaticModel("CCC"))
f.width = 80
f.height = 24
view := f.View()
posA := indexOf(view, "AAA")
posB := indexOf(view, "BBB")
posC := indexOf(view, "CCC")
assert.Greater(t, posA, -1, "header should be present")
assert.Greater(t, posB, -1, "content should be present")
assert.Greater(t, posC, -1, "footer should be present")
assert.Less(t, posA, posB, "header before content")
assert.Less(t, posB, posC, "content before footer")
})
}
// indexOf returns the position of substr in s, or -1 if not found.
func indexOf(s, substr string) int {
for i := range len(s) - len(substr) + 1 {