Skip to content

Commit

Permalink
with hidden, alive, and element expiration
Browse files Browse the repository at this point in the history
Signed-off-by: Alex Goodman <[email protected]>
  • Loading branch information
wagoodman committed Sep 16, 2024
1 parent 6e423d6 commit 8b17f06
Show file tree
Hide file tree
Showing 2 changed files with 294 additions and 10 deletions.
80 changes: 70 additions & 10 deletions bubbles/frame/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,37 @@ import (
tea "github.com/charmbracelet/bubbletea"
)

// VisibleElement allows UI elements to be conditionally hidden, but still present in the model state
type VisibleElement interface {
IsHidden() bool
}

// TerminalElement allows UI elements to have a lifecycle, where at the end of the lifecycle the element is removed
// from the model state entirely
type TerminalElement interface {
IsAlive() bool
}

// ImprintableElement is a special case of a TerminalElement, where the element is removed from the model state after a
// printing the model state as a trail behind the current model and removing the element on the next update
type ImprintableElement interface {
ShouldImprint() bool
}

type Frame struct {
footer *bytes.Buffer
models []tea.Model
models []annotatedModel
windowSize tea.WindowSizeMsg
showFooter bool
truncateFooter bool
}

type annotatedModel struct {
model tea.Model
expired bool
hidden bool
}

func New() *Frame {
return &Frame{
footer: &bytes.Buffer{},
Expand All @@ -38,39 +61,76 @@ func (f *Frame) TruncateFooter(set bool) {
}

func (f *Frame) AppendModel(uiElement tea.Model) {
f.models = append(f.models, uiElement)
f.models = append(f.models, annotatedModel{model: uiElement})
}

func (f Frame) Init() tea.Cmd {
return nil
}

func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// note: we need a pointer receiver such that the same instance of UI used in Teardown is referenced (to keep finalize events)

func (f Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.WindowSizeMsg); ok {
f.windowSize = msg
}

var cmds []tea.Cmd

// 1. prune any models that are no longer alive
// 2. hide/show any models based on the latest state
// 3. trail any models that are expired, pruning them on the next update
for i := 0; i < len(f.models); i++ {
if p, ok := f.models[i].model.(TerminalElement); ok && !p.IsAlive() {
f.models = append(f.models[:i], f.models[i+1:]...)
i--
continue
}

if f.models[i].expired {
f.models = append(f.models[:i], f.models[i+1:]...)
i--
continue
}

if p, ok := f.models[i].model.(VisibleElement); ok && p.IsHidden() {
f.models[i].hidden = true
} else {
f.models[i].hidden = false
}

if p, ok := f.models[i].model.(ImprintableElement); ok && p.ShouldImprint() {
f.models[i].expired = true

cmd := tea.Printf("%s", f.models[i].model.View())
cmds = append(cmds, cmd)
}
}

for i, el := range f.models {
newEl, cmd := el.Update(msg)
if el.expired {
continue
}
newEl, cmd := el.model.Update(msg)
cmds = append(cmds, cmd)
f.models[i] = newEl
f.models[i].model = newEl
}
return f, tea.Batch(cmds...)
}

func (f Frame) View() string {
// all UI elements
str := ""
var strs []string
for _, p := range f.models {
rendered := p.View()
if p.hidden {
continue
}
rendered := p.model.View()
if len(rendered) > 0 {
str += rendered + "\n"
strs = append(strs, rendered)
}
}

str := strings.Join(strs, "\n")

// log events
if f.showFooter {
contents := f.footer.String()
Expand Down
224 changes: 224 additions & 0 deletions bubbles/frame/frame_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package frame

import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type mockModel struct {
updateCalled bool
view string
}

func (m mockModel) Init() tea.Cmd {
return nil
}

func (m mockModel) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
m.updateCalled = true
return m, nil
}

func (m mockModel) View() string {
return m.view
}

type mockTerminalElement struct {
mockModel
isAlive bool
}

func (m mockTerminalElement) IsAlive() bool {
return m.isAlive
}

type mockVisibleElement struct {
mockModel
isHidden bool
}

func (m mockVisibleElement) IsHidden() bool {
return m.isHidden
}

type mockImprintableElement struct {
mockModel
shouldImprint bool
}

func (m mockImprintableElement) ShouldImprint() bool {
return m.shouldImprint
}

func TestFrame_Update_PruneTerminalElement(t *testing.T) {
frame := New()
model := &mockTerminalElement{isAlive: false}

frame.AppendModel(model)

m, _ := frame.Update(nil)
actual := m.(Frame)

assert.Empty(t, actual.models)
}

func TestFrame_Update_HideVisibleElement(t *testing.T) {
frame := New()
model := mockVisibleElement{isHidden: true}

frame.AppendModel(model)

m, _ := frame.Update(nil)
actual := m.(Frame)

require.NotEmpty(t, actual.models)
assert.True(t, actual.models[0].hidden)
}

func TestFrame_Update_ImprintImprintableElement(t *testing.T) {
frame := New()
model := &mockImprintableElement{shouldImprint: true, mockModel: mockModel{view: "imprinted!"}}

frame.AppendModel(model)

m, cmds := frame.Update(nil)
actual := m.(Frame)

assert.True(t, actual.models[0].expired)
assert.NotNil(t, cmds)
}

func TestFrame_Update_UpdateElement(t *testing.T) {
frame := New()
model := &mockModel{}

frame.AppendModel(model)

m, cmds := frame.Update(nil)
actual := m.(Frame)

assert.Nil(t, cmds)
assert.True(t, actual.models[0].model.(mockModel).updateCalled)
}

// Mock model for VisibleElement and TerminalElement
type mockVisibleTerminalModel struct {
view string
isHidden bool
isAlive bool
shouldImprint bool
}

func (m mockVisibleTerminalModel) Init() tea.Cmd {
return nil
}

func (m mockVisibleTerminalModel) Update(_ tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

func (m mockVisibleTerminalModel) View() string {
return m.view
}

func (m mockVisibleTerminalModel) IsHidden() bool {
return m.isHidden
}

func (m mockVisibleTerminalModel) IsAlive() bool {
return m.isAlive
}

func (m mockVisibleTerminalModel) ShouldImprint() bool {
return m.shouldImprint
}

func TestFrame_View_HiddenElement(t *testing.T) {
frame := New()
model := mockVisibleTerminalModel{view: "visible", isHidden: true, isAlive: true}

frame.AppendModel(model)

m, _ := frame.Update(nil)

assert.Empty(t, m.View())
}

func TestFrame_View_VisibleElement(t *testing.T) {
frame := New()
model := mockVisibleTerminalModel{view: "visible", isHidden: false, isAlive: true}

frame.AppendModel(model)

m, _ := frame.Update(nil)

assert.Contains(t, m.View(), "visible")
}

func TestFrame_View_DeadElement(t *testing.T) {
frame := New()
model := mockVisibleTerminalModel{view: "should not be seen", isHidden: false, isAlive: false}

frame.AppendModel(model)

m, _ := frame.Update(nil)

assert.NotContains(t, m.View(), "should not be seen")
}

func TestFrame_View_WithFooter(t *testing.T) {
frame := New()
frame.ShowFooter(true)
frame.TruncateFooter(false)
model := mockVisibleTerminalModel{view: "visible", isHidden: false, isAlive: true}

frame.AppendModel(model)
frame.Footer().Write([]byte("log line 1\nlog line 2"))

m, _ := frame.Update(nil)

viewOutput := m.View()

assert.Contains(t, viewOutput, "visible")
assert.Contains(t, viewOutput, "log line 1")
assert.Contains(t, viewOutput, "log line 2")
}

func TestFrame_View_WithTruncatedFooter(t *testing.T) {
frame := New()
frame.ShowFooter(true)
frame.TruncateFooter(true)
frame.windowSize = tea.WindowSizeMsg{Height: 2} // but there are 3 lines!
model := mockVisibleTerminalModel{view: "visible", isHidden: false, isAlive: true}

frame.AppendModel(model)
frame.Footer().Write([]byte("log line 1\nlog line 2\nlog line 3"))

m, _ := frame.Update(nil)

viewOutput := m.View()

assert.Contains(t, viewOutput, "visible")
assert.Contains(t, viewOutput, "log line 3")
assert.NotContains(t, viewOutput, "log line 1")
}

func TestFrame_View_NoFooter(t *testing.T) {
frame := New()
frame.ShowFooter(false)
model := mockVisibleTerminalModel{view: "visible", isHidden: false, isAlive: true}

frame.AppendModel(model)
frame.Footer().Write([]byte("log line 1\nlog line 2"))

m, _ := frame.Update(nil)

viewOutput := m.View()

assert.Contains(t, viewOutput, "visible")
assert.NotContains(t, viewOutput, "log line 1")
assert.NotContains(t, viewOutput, "log line 2")
}

0 comments on commit 8b17f06

Please sign in to comment.