From 331bcd564d3b96c93365c90f23c0b632765e3c6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:12:32 +0000 Subject: [PATCH] feat(frame): implement tea.Model (Init, Update, View) with lipgloss layout Co-Authored-By: Claude Opus 4.6 --- pkg/cli/frame.go | 229 ++++++++++++++++++++++++++++++++++++++++++ pkg/cli/frame_test.go | 127 +++++++++++++++++++++++ 2 files changed, 356 insertions(+) diff --git a/pkg/cli/frame.go b/pkg/cli/frame.go index dc13cfd2..283159be 100644 --- a/pkg/cli/frame.go +++ b/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() { diff --git a/pkg/cli/frame_test.go b/pkg/cli/frame_test.go index 3173ab4e..b867dde8 100644 --- a/pkg/cli/frame_test.go +++ b/pkg/cli/frame_test.go @@ -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 {