Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dynamic elements #70

Merged
merged 1 commit into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")
}
Loading