From 8b17f06699aae429767784899bf180ce36d317f0 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Wed, 12 Jun 2024 17:37:45 -0400 Subject: [PATCH] with hidden, alive, and element expiration Signed-off-by: Alex Goodman --- bubbles/frame/frame.go | 80 +++++++++++-- bubbles/frame/frame_test.go | 224 ++++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 bubbles/frame/frame_test.go diff --git a/bubbles/frame/frame.go b/bubbles/frame/frame.go index 30b7d35..6bcf0fd 100644 --- a/bubbles/frame/frame.go +++ b/bubbles/frame/frame.go @@ -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{}, @@ -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() diff --git a/bubbles/frame/frame_test.go b/bubbles/frame/frame_test.go new file mode 100644 index 0000000..eeb0a37 --- /dev/null +++ b/bubbles/frame/frame_test.go @@ -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") +}