feat(frame): add FrameModel interface and modelAdapter
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
762eadd736
commit
aa5cfc312d
2 changed files with 82 additions and 0 deletions
31
pkg/cli/frame_model.go
Normal file
31
pkg/cli/frame_model.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package cli
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
// FrameModel extends Model with bubbletea lifecycle methods.
|
||||
// Use this for interactive components that handle input.
|
||||
// Plain Model components work unchanged — Frame wraps them automatically.
|
||||
type FrameModel interface {
|
||||
Model
|
||||
Init() tea.Cmd
|
||||
Update(tea.Msg) (FrameModel, tea.Cmd)
|
||||
}
|
||||
|
||||
// adaptModel wraps a plain Model as a FrameModel via modelAdapter.
|
||||
// If the model already implements FrameModel, it is returned as-is.
|
||||
func adaptModel(m Model) FrameModel {
|
||||
if fm, ok := m.(FrameModel); ok {
|
||||
return fm
|
||||
}
|
||||
return &modelAdapter{m: m}
|
||||
}
|
||||
|
||||
// modelAdapter wraps a plain Model to satisfy FrameModel.
|
||||
// Init returns nil, Update is a no-op, View delegates to the wrapped Model.
|
||||
type modelAdapter struct {
|
||||
m Model
|
||||
}
|
||||
|
||||
func (a *modelAdapter) View(w, h int) string { return a.m.View(w, h) }
|
||||
func (a *modelAdapter) Init() tea.Cmd { return nil }
|
||||
func (a *modelAdapter) Update(tea.Msg) (FrameModel, tea.Cmd) { return a, nil }
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -196,6 +197,56 @@ func TestStaticModel_Good(t *testing.T) {
|
|||
assert.Equal(t, "hello", m.View(80, 24))
|
||||
}
|
||||
|
||||
func TestFrameModel_Good(t *testing.T) {
|
||||
t.Run("modelAdapter wraps plain Model", func(t *testing.T) {
|
||||
m := StaticModel("hello")
|
||||
adapted := adaptModel(m)
|
||||
|
||||
// Should return nil cmd from Init
|
||||
cmd := adapted.Init()
|
||||
assert.Nil(t, cmd)
|
||||
|
||||
// Should return itself from Update
|
||||
updated, cmd := adapted.Update(nil)
|
||||
assert.Equal(t, adapted, updated)
|
||||
assert.Nil(t, cmd)
|
||||
|
||||
// Should delegate View to wrapped model
|
||||
assert.Equal(t, "hello", adapted.View(80, 24))
|
||||
})
|
||||
|
||||
t.Run("FrameModel passes through without wrapping", func(t *testing.T) {
|
||||
fm := &testFrameModel{viewText: "interactive"}
|
||||
adapted := adaptModel(fm)
|
||||
|
||||
// Should be the same object, not wrapped
|
||||
_, ok := adapted.(*testFrameModel)
|
||||
assert.True(t, ok, "FrameModel should not be wrapped")
|
||||
assert.Equal(t, "interactive", adapted.View(80, 24))
|
||||
})
|
||||
}
|
||||
|
||||
// testFrameModel is a mock FrameModel for testing.
|
||||
type testFrameModel struct {
|
||||
viewText string
|
||||
initCalled bool
|
||||
updateCalled bool
|
||||
lastMsg tea.Msg
|
||||
}
|
||||
|
||||
func (m *testFrameModel) View(w, h int) string { return m.viewText }
|
||||
|
||||
func (m *testFrameModel) Init() tea.Cmd {
|
||||
m.initCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *testFrameModel) Update(msg tea.Msg) (FrameModel, tea.Cmd) {
|
||||
m.updateCalled = true
|
||||
m.lastMsg = msg
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// 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