From def0aba654a9eb2e2f45a958d1b0080cbeba1ac8 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Wed, 15 Nov 2023 08:49:15 -0500 Subject: [PATCH] Add tree-of-models bubble component (#29) * add a tree component (tree of models) Signed-off-by: Alex Goodman * update taskprogress model to implement VisibleModel Signed-off-by: Alex Goodman * fix test comment Signed-off-by: Alex Goodman --------- Signed-off-by: Alex Goodman --- bubbles/internal/testutil/run_model.go | 3 + bubbles/taskprogress/model.go | 24 +- bubbles/tree/__snapshots__/model_test.snap | 102 ++++++++ bubbles/tree/model.go | 262 +++++++++++++++++++ bubbles/tree/model_test.go | 285 +++++++++++++++++++++ go.mod | 1 + go.sum | 4 + visible_model.go | 8 + 8 files changed, 683 insertions(+), 6 deletions(-) create mode 100755 bubbles/tree/__snapshots__/model_test.snap create mode 100644 bubbles/tree/model.go create mode 100644 bubbles/tree/model_test.go create mode 100644 visible_model.go diff --git a/bubbles/internal/testutil/run_model.go b/bubbles/internal/testutil/run_model.go index 8753541..f01e705 100644 --- a/bubbles/internal/testutil/run_model.go +++ b/bubbles/internal/testutil/run_model.go @@ -33,6 +33,9 @@ func RunModel(_ testing.TB, m tea.Model, iterations int, message tea.Msg) string } func flatten(p tea.Msg) (msgs []tea.Msg) { + if p == nil { + return nil + } if reflect.TypeOf(p).Name() == "batchMsg" { partials := extractBatchMessages(p) for _, m := range partials { diff --git a/bubbles/taskprogress/model.go b/bubbles/taskprogress/model.go index 9567c17..a88f87a 100644 --- a/bubbles/taskprogress/model.go +++ b/bubbles/taskprogress/model.go @@ -13,6 +13,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/wagoodman/go-progress" + + "github.com/anchore/bubbly" ) const ( @@ -20,6 +22,8 @@ const ( xMark = "✘" ) +var _ bubbly.VisibleModel = (*Model)(nil) + type Model struct { // ui components (view models) Spinner spinner.Model @@ -147,10 +151,6 @@ func (m Model) Init() tea.Cmd { m.ProgressBar.Init(), } - // if m.progressor != nil { - // cmds = append(cmds, m.ProgressBar.Init()) - //} - return tea.Batch( cmds..., ) @@ -161,7 +161,7 @@ func (m Model) ID() int { return m.id } -// ID returns the spinner's unique ID. +// Sequence returns the spinner's current sequence number. func (m Model) Sequence() int { return m.sequence } @@ -234,9 +234,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } +func (m Model) IsVisible() bool { + isDoneAndHidden := m.completed && m.HideOnSuccess + if isDoneAndHidden { + // it might be that the consumer will not invoke View() again based on + // this response, in which case we need to ensure that the done() function + // in invoked to release resources + m.done() + } + + return !(isDoneAndHidden) +} + // View renders the model's view. func (m Model) View() string { - if m.completed && m.HideOnSuccess { + if !m.IsVisible() { m.done() return "" } diff --git a/bubbles/tree/__snapshots__/model_test.snap b/bubbles/tree/__snapshots__/model_test.snap new file mode 100755 index 0000000..46dc1ef --- /dev/null +++ b/bubbles/tree/__snapshots__/model_test.snap @@ -0,0 +1,102 @@ + +[TestModel_View/gocase - 1] +└──a + └──a-a +--- + +[TestModel_View/sibling_branches_(one_extra_level) - 1] +├──a +│ ├──a-a +│ └──a-b +└──b + └──b-a +--- + +[TestModel_View/sibling_branches_(lots_of_extra_levels) - 1] +├──a +│ ├──a-a +│ ├──a-b +│ │ ├──a-b-a +│ │ ├──a-b-b +│ │ └──a-b-c +│ ├──a-c +│ │ └──a-c-a +│ └──a-d +└──b + ├──b-a + │ ├──b-a-a + │ │ └──b-a-a-a + │ │ └──b-a-a-a-a + │ └──b-a-b + └──b-b +--- + +[TestModel_View/multiline_node - 1] +├──a +│ more a... +│ ├──a-a +│ │ a-a continued... +│ │ more a-a! +│ └──a-b +└──b + more b... + └──b-a +--- + +[TestModel_View/padded_multiline_node - 1] +├──a +│ more a... +│ │ +│ ├──a-a +│ │ a-a continued... +│ │ more a-a! +│ │ +│ ├──a-b +│ ├──a-c +│ └──a-d +└──b + more b... + │ + └──b-a +--- + +[TestModel_View/hidden_nodes - 1] +└──a + └──a-a +--- + + +[TestModel_View/margin - 1] + ├──a + │ ├──a-a + │ └──a-b + └──b + └──b-a +--- + +[TestModel_View/roots_without_prefix - 1] +a +├──a-a +└──a-b +b +└──b-a +--- + +[TestModel_View/horizontal_padding - 1] + ✔ a + ├── ✔ a-a + ├── ✔ a-b + │ ├── ✔ a-b-a + │ ├── ✔ a-b-b + │ └── ✔ a-b-c + ├── ⠼ a-c + │ └── ⠼ a-c-a + └── ⠼ a-d + ⠼ b + ├── ⠼ b-a + │ ├── ⠼ b-a-a + │ │ └── ⠼ b-a-a-a + │ │ └── ⠼ b-a-a-a-a + │ └── ⠼ b-a-b + └── ⠼ b-b +--- diff --git a/bubbles/tree/model.go b/bubbles/tree/model.go new file mode 100644 index 0000000..38059db --- /dev/null +++ b/bubbles/tree/model.go @@ -0,0 +1,262 @@ +package tree + +import ( + "errors" + "strings" + "sync" + + tea "github.com/charmbracelet/bubbletea" + "github.com/scylladb/go-set/strset" + + "github.com/anchore/bubbly" +) + +var _ tea.Model = (*Model)(nil) + +type Model struct { + roots []string + nodes map[string]bubbly.VisibleModel + children map[string][]string + parents map[string]string + lock *sync.RWMutex + + // formatting options + + Margin string + Indent string + Fork string + Branch string + Leaf string + Padding string + VerticalPadMultilineNodes bool + RootsWithoutPrefix bool +} + +func NewModel() Model { + return Model{ + nodes: make(map[string]bubbly.VisibleModel), + children: make(map[string][]string), + parents: make(map[string]string), + lock: &sync.RWMutex{}, + + // formatting options + + Margin: "", + Indent: " ", + Branch: "│ ", + Fork: "├──", + Leaf: "└──", + Padding: "", + VerticalPadMultilineNodes: false, + RootsWithoutPrefix: false, + } +} + +func (m *Model) Add(parent string, id string, model bubbly.VisibleModel) error { + m.lock.Lock() + defer m.lock.Unlock() + + if id == "" { + return errors.New("id cannot be empty") + } + + m.nodes[id] = model + if parent != "" { + m.children[parent] = append(m.children[parent], id) + m.parents[id] = parent + } else { + m.roots = append(m.roots, id) + } + + return nil +} + +func (m *Model) Remove(id string) { + m.lock.Lock() + defer m.lock.Unlock() + + delete(m.nodes, id) + delete(m.children, id) + delete(m.parents, id) + for _, children := range m.children { + for i, child := range children { + if child == id { + m.children[child] = append(children[:i], children[i+1:]...) + } + } + } + + for i, node := range m.roots { + if node == id { + m.roots = append(m.roots[:i], m.roots[i+1:]...) + } + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds tea.Cmd + for id := range m.nodes { + model, cmd := m.nodes[id].Update(msg) + if cmd != nil { + cmds = tea.Batch(cmds, cmd) + } + m.nodes[id] = model.(bubbly.VisibleModel) + } + + return m, cmds +} + +func (m Model) View() string { + sb := strings.Builder{} + + observed := strset.New() + + for i, id := range m.roots { + ret := m.renderNode(i, id, observed, 0, []bool{m.isLastElement(i, m.roots)}) + if len(ret) > 0 { + sb.WriteString(ret) + } + } + + // optionally add a margin to the left of the entire tree + if m.Margin != "" { + lines := strings.Split(strings.TrimRight(sb.String(), "\n"), "\n") + sb = strings.Builder{} + for i, line := range lines { + sb.WriteString(m.Margin) + sb.WriteString(line) + if i != len(lines)-1 { + sb.WriteString("\n") + } + } + } + + return strings.TrimRight(sb.String(), "\n") +} + +func (m Model) renderNode(siblingIdx int, id string, observed *strset.Set, depth int, path []bool) string { + if observed.Has(id) { + return "" + } + + observed.Add(id) + + node := m.nodes[id] + + if !node.IsVisible() { + return "" + } + + prefix := strings.Builder{} + + // handle indentation and prefixes for each level + + for i := 0; i < depth; i++ { + if m.RootsWithoutPrefix && i == 0 { + prefix.WriteString(m.Padding) + continue + } + if path[i] { + prefix.WriteString(m.Indent) + } else { + prefix.WriteString(m.Branch) + } + prefix.WriteString(m.Padding) + } + + // determine the correct prefix (fork or leaf) + if m.RootsWithoutPrefix && depth > 0 || !m.RootsWithoutPrefix { + prefix.WriteString(m.forkOrLeaf(siblingIdx, id)) + } + + sb := strings.Builder{} + + // add the node's view + current := node.View() + if len(current) > 0 { + sb.WriteString(m.prefixLines(current, prefix.String(), m.hasChildren(id))) + sb.WriteString("\n") + } + + // process all children + for i, childID := range m.children[id] { + _, ok := m.nodes[childID] + if ok && !observed.Has(childID) { + newPath := append([]bool(nil), path...) + newPath = append(newPath, m.isLastElement(i, m.children[id])) + sb.WriteString(m.renderNode(i, childID, observed, depth+1, newPath)) + } + } + + return sb.String() +} + +func (m Model) isLastElement(idx int, siblings []string) bool { + // check if this is the last visible element in the list of siblings + for i := idx + 1; i < len(siblings); i++ { + if m.nodes[siblings[i]].IsVisible() { + return false + } + } + return true +} + +func (m Model) hasChildren(id string) bool { + // check if there are any children that are visible + for _, childID := range m.children[id] { + if m.nodes[childID].IsVisible() { + return true + } + } + return false +} + +func (m Model) forkOrLeaf(siblingIdx int, id string) string { + if parent, exists := m.parents[id]; exists { + // index relative to the parent's "children" list + if m.isLastElement(siblingIdx, m.children[parent]) { + return m.Leaf + } + return m.Fork + } + + // index relative to the root nodes + if m.isLastElement(siblingIdx, m.roots) { + return m.Leaf + } + return m.Fork +} + +func (m Model) prefixLines(input, prefix string, hasChildren bool) string { + lines := strings.Split(strings.TrimRight(input, "\n"), "\n") + sb := strings.Builder{} + nextPrefix := strings.ReplaceAll(prefix, m.Fork, m.Branch) + nextPrefix = strings.ReplaceAll(nextPrefix, m.Leaf, m.Indent) + + doPadding := m.VerticalPadMultilineNodes && len(lines) > 1 + + for i, line := range lines { + if i == 0 { + sb.WriteString(prefix) + } else { + sb.WriteString(nextPrefix) + } + sb.WriteString(line) + if doPadding || i != len(lines)-1 { + sb.WriteString("\n") + } + } + + if doPadding { + sb.WriteString(nextPrefix) + if hasChildren { + sb.WriteString(m.Branch) + } + } + + return sb.String() +} diff --git a/bubbles/tree/model_test.go b/bubbles/tree/model_test.go new file mode 100644 index 0000000..91bcfb4 --- /dev/null +++ b/bubbles/tree/model_test.go @@ -0,0 +1,285 @@ +package tree + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" + + "github.com/anchore/bubbly" + "github.com/anchore/bubbly/bubbles/internal/testutil" +) + +var _ bubbly.VisibleModel = (*dummyViewer)(nil) + +type dummyViewer struct { + hidden bool + state string +} + +func (d dummyViewer) IsVisible() bool { + return !d.hidden +} + +func (d dummyViewer) Init() tea.Cmd { + return nil +} + +func (d dummyViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return d, nil +} + +func (d dummyViewer) View() string { + return d.state +} + +func TestModel_View(t *testing.T) { + + tests := []struct { + name string + taskGen func(testing.TB) Model + iterations int + }{ + { + name: "gocase", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + + // └─ a + // └─ a-a + + require.NoError(t, subject.Add("", "a", dummyViewer{state: "a"})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: "a-a"})) + + return subject + }, + }, + { + name: "sibling branches (one extra level)", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + + // ├─ a + // │ ├─ a-a + // │ └─ a-b + // └─ b + // └─ b-a + + require.NoError(t, subject.Add("", "a", dummyViewer{state: "a"})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: "a-a"})) + require.NoError(t, subject.Add("a", "a-b", dummyViewer{state: "a-b"})) + require.NoError(t, subject.Add("", "b", dummyViewer{state: "b"})) + require.NoError(t, subject.Add("b", "b-a", dummyViewer{state: "b-a"})) + + return subject + }, + }, + { + name: "sibling branches (lots of extra levels)", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + + // ├─ a + // │ ├─ a-a + // │ ├─ a-b + // │ │ ├─ a-b-a + // │ │ ├─ a-b-b + // │ │ └─ a-b-c + // │ ├─ a-c + // │ │ └─ a-c-a + // │ └─ a-d + // └─ b + // ├─ b-a + // │ ├─ b-a-a + // │ │ └─ b-a-a-a + // │ │ └─ b-a-a-a-a + // │ └─ b-a-b + // └─ b-b + + require.NoError(t, subject.Add("", "a", dummyViewer{state: "a"})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: "a-a"})) + require.NoError(t, subject.Add("a", "a-b", dummyViewer{state: "a-b"})) + require.NoError(t, subject.Add("a-b", "a-b-a", dummyViewer{state: "a-b-a"})) + require.NoError(t, subject.Add("a-b", "a-b-b", dummyViewer{state: "a-b-b"})) + require.NoError(t, subject.Add("a-b", "a-b-c", dummyViewer{state: "a-b-c"})) + require.NoError(t, subject.Add("a", "a-c", dummyViewer{state: "a-c"})) + require.NoError(t, subject.Add("a-c", "a-c-a", dummyViewer{state: "a-c-a"})) + require.NoError(t, subject.Add("a", "a-d", dummyViewer{state: "a-d"})) + require.NoError(t, subject.Add("", "b", dummyViewer{state: "b"})) + require.NoError(t, subject.Add("b", "b-a", dummyViewer{state: "b-a"})) + require.NoError(t, subject.Add("b-a", "b-a-a", dummyViewer{state: "b-a-a"})) + require.NoError(t, subject.Add("b-a-a", "b-a-a-a", dummyViewer{state: "b-a-a-a"})) + require.NoError(t, subject.Add("b-a-a-a", "b-a-a-a-a", dummyViewer{state: "b-a-a-a-a"})) + require.NoError(t, subject.Add("b-a", "b-a-b", dummyViewer{state: "b-a-b"})) + require.NoError(t, subject.Add("b", "b-b", dummyViewer{state: "b-b"})) + + return subject + }, + }, + { + name: "multiline node", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + + // ├─ a + // │ more a... + // │ ├─ a-a + // │ │ a-a continued... + // │ │ more a-a! + // │ └─ a-b + // └─ b + // more b... + // └─ b-a + + require.NoError(t, subject.Add("", "a", dummyViewer{state: "a\nmore a..."})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: "a-a\na-a continued...\nmore a-a!"})) + require.NoError(t, subject.Add("a", "a-b", dummyViewer{state: "a-b"})) + require.NoError(t, subject.Add("", "b", dummyViewer{state: "b\nmore b..."})) + require.NoError(t, subject.Add("b", "b-a", dummyViewer{state: "b-a"})) + + return subject + }, + }, + { + name: "padded multiline node", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + subject.VerticalPadMultilineNodes = true + + // ├─ a + // │ more a... + // │ ├─ a-a + // │ │ a-a continued... + // │ │ more a-a! + // │ └─ a-b + // └─ b + // more b... + // └─ b-a + + require.NoError(t, subject.Add("", "a", dummyViewer{state: "a\nmore a..."})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: "a-a\na-a continued...\nmore a-a!"})) + require.NoError(t, subject.Add("a", "a-b", dummyViewer{state: "a-b"})) + require.NoError(t, subject.Add("a", "a-c", dummyViewer{state: "a-c"})) + require.NoError(t, subject.Add("a", "a-d", dummyViewer{state: "a-d"})) + require.NoError(t, subject.Add("", "b", dummyViewer{state: "b\nmore b..."})) + require.NoError(t, subject.Add("b", "b-a", dummyViewer{state: "b-a"})) + + return subject + }, + }, + { + name: "hidden nodes", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + + // └─ a + // └─ a-a + + require.NoError(t, subject.Add("", "a", dummyViewer{state: "a"})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: "a-a"})) // shown as a leaf instead of a fork + require.NoError(t, subject.Add("a", "a-b", dummyViewer{state: "a-b", hidden: true})) + require.NoError(t, subject.Add("", "b", dummyViewer{state: "b", hidden: true})) + require.NoError(t, subject.Add("b", "b-a", dummyViewer{state: "b-a"})) // gets pruned entirely + + return subject + }, + }, + { + name: "margin", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + subject.Margin = " " + + // ├─ a + // │ ├─ a-a + // │ └─ a-b + // └─ b + // └─ b-a + + require.NoError(t, subject.Add("", "a", dummyViewer{state: "a"})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: "a-a"})) + require.NoError(t, subject.Add("a", "a-b", dummyViewer{state: "a-b"})) + require.NoError(t, subject.Add("", "b", dummyViewer{state: "b"})) + require.NoError(t, subject.Add("b", "b-a", dummyViewer{state: "b-a"})) + + return subject + }, + }, + { + name: "roots without prefix", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + subject.RootsWithoutPrefix = true + + // a + // ├──a-a + // └──a-b + // b + // └──b-a + + require.NoError(t, subject.Add("", "a", dummyViewer{state: "a"})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: "a-a"})) + require.NoError(t, subject.Add("a", "a-b", dummyViewer{state: "a-b"})) + require.NoError(t, subject.Add("", "b", dummyViewer{state: "b"})) + require.NoError(t, subject.Add("b", "b-a", dummyViewer{state: "b-a"})) + + return subject + }, + }, + { + name: "horizontal padding", + taskGen: func(tb testing.TB) Model { + subject := NewModel() + subject.Padding = " " + subject.RootsWithoutPrefix = true + + // ✔ a + // ├── ✔ a-a + // ├── ✔ a-b + // │ ├── ✔ a-b-a + // │ ├── ✔ a-b-b + // │ └── ✔ a-b-c + // ├── ⠼ a-c + // │ └── ⠼ a-c-a + // └── ⠼ a-d + // ⠼ b + // ├── ⠼ b-a + // │ ├── ⠼ b-a-a + // │ │ └── ⠼ b-a-a-a + // │ │ └── ⠼ b-a-a-a-a + // │ └── ⠼ b-a-b + // └── ⠼ b-b + + require.NoError(t, subject.Add("", "a", dummyViewer{state: " ✔ a"})) + require.NoError(t, subject.Add("a", "a-a", dummyViewer{state: " ✔ a-a"})) + require.NoError(t, subject.Add("a", "a-b", dummyViewer{state: " ✔ a-b"})) + require.NoError(t, subject.Add("a-b", "a-b-a", dummyViewer{state: " ✔ a-b-a"})) + require.NoError(t, subject.Add("a-b", "a-b-b", dummyViewer{state: " ✔ a-b-b"})) + require.NoError(t, subject.Add("a-b", "a-b-c", dummyViewer{state: " ✔ a-b-c"})) + require.NoError(t, subject.Add("a", "a-c", dummyViewer{state: " ⠼ a-c"})) + require.NoError(t, subject.Add("a-c", "a-c-a", dummyViewer{state: " ⠼ a-c-a"})) + require.NoError(t, subject.Add("a", "a-d", dummyViewer{state: " ⠼ a-d"})) + require.NoError(t, subject.Add("", "b", dummyViewer{state: " ⠼ b"})) + require.NoError(t, subject.Add("b", "b-a", dummyViewer{state: " ⠼ b-a"})) + require.NoError(t, subject.Add("b-a", "b-a-a", dummyViewer{state: " ⠼ b-a-a"})) + require.NoError(t, subject.Add("b-a-a", "b-a-a-a", dummyViewer{state: " ⠼ b-a-a-a"})) + require.NoError(t, subject.Add("b-a-a-a", "b-a-a-a-a", dummyViewer{state: " ⠼ b-a-a-a-a"})) + require.NoError(t, subject.Add("b-a", "b-a-b", dummyViewer{state: " ⠼ b-a-b"})) + require.NoError(t, subject.Add("b", "b-b", dummyViewer{state: " ⠼ b-b"})) + + return subject + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var m tea.Model = tt.taskGen(t) + tsk, ok := m.(Model) + require.True(t, ok) + got := testutil.RunModel(t, tsk, tt.iterations, nil) + t.Log(got) + snaps.MatchSnapshot(t, got) + }) + } +} diff --git a/go.mod b/go.mod index 2aa1881..626033b 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/charmbracelet/lipgloss v0.9.1 github.com/erikgeiser/promptkit v0.7.0 github.com/gkampitakis/go-snaps v0.4.11 + github.com/scylladb/go-set v1.0.2 github.com/stretchr/testify v1.8.4 github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c diff --git a/go.sum b/go.sum index 3675ecb..a697582 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/promptkit v0.7.0 h1:Yi28iN6JRs8/0x+wjQRPfWb+vWz1pFmZ5fu2uoFipD8= github.com/erikgeiser/promptkit v0.7.0/go.mod h1:Jj9bhN+N8RbMjB1jthkr9A4ydmczZ1WZJ8xTXnP12dg= +github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA= +github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI= github.com/gkampitakis/ciinfo v0.2.5 h1:K0mac90lGguc1conc46l0YEsB7/nioWCqSnJp/6z8Eo= github.com/gkampitakis/ciinfo v0.2.5/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -83,6 +85,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/scylladb/go-set v1.0.2 h1:SkvlMCKhP0wyyct6j+0IHJkBkSZL+TDzZ4E7f7BCcRE= +github.com/scylladb/go-set v1.0.2/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/visible_model.go b/visible_model.go new file mode 100644 index 0000000..e89f5c5 --- /dev/null +++ b/visible_model.go @@ -0,0 +1,8 @@ +package bubbly + +import tea "github.com/charmbracelet/bubbletea" + +type VisibleModel interface { + IsVisible() bool + tea.Model +}