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:
parent
acfbc2aaee
commit
331bcd564d
2 changed files with 356 additions and 0 deletions
229
pkg/cli/frame.go
229
pkg/cli/frame.go
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue