From 7bddd4404d9d2a7485e9f756ee7f51b19cd1561a Mon Sep 17 00:00:00 2001 From: Jonathan Hope Date: Fri, 24 May 2024 17:17:28 -0700 Subject: [PATCH] refactor: tui --- cmd/cli/tui/{ => controls}/footer/footer.go | 144 +-- cmd/cli/tui/controls/footer/footer_test.go | 89 ++ cmd/cli/tui/{ => controls}/header/header.go | 50 +- .../tui/{ => controls}/header/header_test.go | 25 +- cmd/cli/tui/{ => controls}/help/help.go | 25 +- cmd/cli/tui/{ => controls}/help/help_test.go | 13 +- .../{ => controls}/scrolltable/scrolltable.go | 117 +-- .../scrolltable/scrolltable_test.go | 79 +- .../tui/{ => controls}/textinput/textinput.go | 369 +++---- .../textinput/textinput_test.go | 163 ++-- cmd/cli/tui/controls/typeahead/typeahead.go | 232 +++++ .../tui/controls/typeahead/typeahead_test.go | 111 +++ cmd/cli/tui/footer/footer_test.go | 169 ---- cmd/cli/tui/msgs/msgs.go | 143 +-- cmd/cli/tui/program.go | 50 +- cmd/cli/tui/typeahead/typeahead.go | 239 ----- cmd/cli/tui/typeahead/typeahead_test.go | 191 ---- cmd/cli/tui/{ => views}/booksview/books.go | 919 ++++++++---------- cmd/cli/tui/{ => views}/errorview/error.go | 20 +- .../tui/{ => views}/errorview/error_test.go | 14 - go.mod | 51 +- go.sum | 248 ++--- 22 files changed, 1435 insertions(+), 2026 deletions(-) rename cmd/cli/tui/{ => controls}/footer/footer.go (51%) create mode 100644 cmd/cli/tui/controls/footer/footer_test.go rename cmd/cli/tui/{ => controls}/header/header.go (70%) rename cmd/cli/tui/{ => controls}/header/header_test.go (61%) rename cmd/cli/tui/{ => controls}/help/help.go (89%) rename cmd/cli/tui/{ => controls}/help/help_test.go (71%) rename cmd/cli/tui/{ => controls}/scrolltable/scrolltable.go (87%) rename cmd/cli/tui/{ => controls}/scrolltable/scrolltable_test.go (77%) rename cmd/cli/tui/{ => controls}/textinput/textinput.go (68%) rename cmd/cli/tui/{ => controls}/textinput/textinput_test.go (59%) create mode 100644 cmd/cli/tui/controls/typeahead/typeahead.go create mode 100644 cmd/cli/tui/controls/typeahead/typeahead_test.go delete mode 100644 cmd/cli/tui/footer/footer_test.go delete mode 100644 cmd/cli/tui/typeahead/typeahead.go delete mode 100644 cmd/cli/tui/typeahead/typeahead_test.go rename cmd/cli/tui/{ => views}/booksview/books.go (51%) rename cmd/cli/tui/{ => views}/errorview/error.go (71%) rename cmd/cli/tui/{ => views}/errorview/error_test.go (77%) diff --git a/cmd/cli/tui/footer/footer.go b/cmd/cli/tui/controls/footer/footer.go similarity index 51% rename from cmd/cli/tui/footer/footer.go rename to cmd/cli/tui/controls/footer/footer.go index 6298475..bc41e40 100644 --- a/cmd/cli/tui/footer/footer.go +++ b/cmd/cli/tui/controls/footer/footer.go @@ -5,8 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" - "github.com/jonathanhope/armaria/cmd/cli/tui/textinput" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/textinput" "github.com/jonathanhope/armaria/cmd/cli/tui/utils" ) @@ -33,6 +32,58 @@ func (m FooterModel) InputMode() bool { return m.inputMode } +// InputName is the name of the underlying input. +func (m FooterModel) InputName() string { + return m.name + "Input" +} + +// Resize changes the size of the footer. +func (m *FooterModel) Resize(width int) { + m.width = width + m.input.Resize(width - HelpInfoWidth) +} + +// Insert inserts runes in front of the cursor. +func (m *FooterModel) Insert(runes []rune) tea.Cmd { + return m.input.Insert(runes) +} + +// Delete deletes the rune in front of the cursor. +func (m *FooterModel) Delete() tea.Cmd { + return m.input.Delete() +} + +// MoveLeft moves the cursor the left once space. +func (m *FooterModel) MoveLeft() { + m.input.MoveLeft() +} + +// MoveRight moves the cursor to right once space. +func (m *FooterModel) MoveRight() { + m.input.MoveRight() +} + +// StartInputMode switches the footer into accepting input. +func (m *FooterModel) StartInputMode(prompt string, text string, maxChars int) { + m.inputMode = true + m.input.SetPrompt(prompt) + m.input.SetText(text) + m.input.Focus(maxChars) +} + +// StopInputMode switches the footer out of accepting input. +func (m *FooterModel) StopInputMode() { + m.inputMode = false + m.input.SetPrompt("") + m.input.SetText("") + m.input.Blur() +} + +// SetFilters sets the curently applied filters. +func (m *FooterModel) SetFilters(filters []string) { + m.filters = filters +} + // InitialModel builds the model. func InitialModel(name string) FooterModel { inputName := name + "Input" @@ -46,72 +97,7 @@ func InitialModel(name string) FooterModel { // Update handles a message. func (m FooterModel) Update(msg tea.Msg) (FooterModel, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - - case msgs.SizeMsg: - if msg.Name == m.name { - m.width = msg.Width - - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msgs.SizeMsg{ - Name: m.inputName, - Width: m.width - HelpInfoWidth, - }) - cmds = append(cmds, inputCmd) - } - - case msgs.InputModeMsg: - if msg.Name == m.name { - m.inputMode = msg.InputMode - - if m.inputMode { - cmds = append(cmds, m.startInputCmd(msg.Prompt, msg.Text, msg.MaxChars)) - } else { - cmds = append(cmds, m.endInputCmd()) - } - } - - case msgs.FiltersMsg: - if msg.Name == m.name { - m.filters = msg.Filters - } - - case tea.KeyMsg: - if m.inputMode { - switch msg.String() { - case "ctrl+c": - if m.inputMode { - return m, tea.Quit - } - - case "esc": - cmds = append(cmds, func() tea.Msg { - return msgs.InputCancelledMsg{Name: m.name} - }) - - case "enter": - if m.input.Text() != "" { - cmds = append(cmds, func() tea.Msg { - return msgs.InputConfirmedMsg{Name: m.name} - }) - } - - default: - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msg) - cmds = append(cmds, inputCmd) - } - } - - default: - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msg) - cmds = append(cmds, inputCmd) - } - - return m, tea.Batch(cmds...) + return m, nil } // View renders the model. @@ -155,25 +141,3 @@ func (m FooterModel) View() string { func (m FooterModel) Init() tea.Cmd { return nil } - -// startInputCmd is a command that switches the footer to input mode. -func (m FooterModel) startInputCmd(prompt string, text string, maxChars int) tea.Cmd { - return tea.Batch(func() tea.Msg { - return msgs.PromptMsg{Name: m.inputName, Prompt: prompt} - }, func() tea.Msg { - return msgs.TextMsg{Name: m.inputName, Text: text} - }, func() tea.Msg { - return msgs.FocusMsg{Name: m.inputName, MaxChars: maxChars} - }) -} - -// endInputCmd is a command that switches the footer out of input mode. -func (m FooterModel) endInputCmd() tea.Cmd { - return tea.Batch(func() tea.Msg { - return msgs.BlurMsg{Name: m.inputName} - }, func() tea.Msg { - return msgs.PromptMsg{Name: m.inputName, Prompt: ""} - }, func() tea.Msg { - return msgs.TextMsg{Name: m.inputName, Text: ""} - }) -} diff --git a/cmd/cli/tui/controls/footer/footer_test.go b/cmd/cli/tui/controls/footer/footer_test.go new file mode 100644 index 0000000..558317b --- /dev/null +++ b/cmd/cli/tui/controls/footer/footer_test.go @@ -0,0 +1,89 @@ +package footer + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/textinput" +) + +const Name = "footer" + +func TestCanUpdateWidth(t *testing.T) { + gotModel := FooterModel{ + name: Name, + inputName: Name + "Input", + input: textinput.TextInputModel{}, + } + gotModel.Resize(15) + + wantModel := FooterModel{ + name: Name, + inputName: Name + "Input", + width: 15, + } + + verifyUpdate(t, gotModel, wantModel) +} + +func TestCanStartInputMode(t *testing.T) { + gotModel := FooterModel{ + name: Name, + inputName: Name + "Input", + } + gotModel.Resize(100) + gotModel.StartInputMode("prompt", "text", 15) + + wantModel := FooterModel{ + name: Name, + inputName: Name + "Input", + inputMode: true, + width: 100, + } + + verifyUpdate(t, gotModel, wantModel) +} + +func TestCanEndInputMode(t *testing.T) { + gotModel := FooterModel{ + name: Name, + inputName: Name + "Input", + inputMode: true, + } + gotModel.Resize(100) + gotModel.StopInputMode() + + wantModel := FooterModel{ + name: Name, + inputName: Name + "Input", + inputMode: false, + width: 100, + } + + verifyUpdate(t, gotModel, wantModel) +} + +func TestCanSetFilters(t *testing.T) { + gotModel := FooterModel{ + name: Name, + inputName: Name + "Input", + } + gotModel.SetFilters([]string{"one"}) + + wantModel := FooterModel{ + name: Name, + inputName: Name + "Input", + filters: []string{"one"}, + } + + verifyUpdate(t, gotModel, wantModel) +} + +func verifyUpdate(t *testing.T, gotModel FooterModel, wantModel FooterModel) { + unexported := cmp.AllowUnexported(FooterModel{}) + modelDiff := cmp.Diff(gotModel, wantModel, unexported, cmpopts.IgnoreFields(FooterModel{}, "input")) + if modelDiff != "" { + t.Errorf("Expected and actual models different:\n%s", modelDiff) + } +} diff --git a/cmd/cli/tui/header/header.go b/cmd/cli/tui/controls/header/header.go similarity index 70% rename from cmd/cli/tui/header/header.go rename to cmd/cli/tui/controls/header/header.go index e7a0299..8d00c42 100644 --- a/cmd/cli/tui/header/header.go +++ b/cmd/cli/tui/controls/header/header.go @@ -4,18 +4,17 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" "github.com/jonathanhope/armaria/cmd/cli/tui/utils" ) // HeaderModel is the model for a header. // The header displays state information such as breadcrumbs for the selected book. type HeaderModel struct { - name string // name of the header - title string // title of the app - nav string // breadcrumbs for the currently selected book - busy bool // if true the writer is busy - width int // max width of the header + name string // name of the header + title string // title of the app + breadcrumbs string // breadcrumbs for the currently selected book + busy bool // if true the writer is busy + width int // max width of the header } // Busy returns whether the writer is busy or not. @@ -23,6 +22,26 @@ func (m HeaderModel) Busy() bool { return m.busy } +// SetBusy denotes that the writer is busy. +func (m *HeaderModel) SetBusy() { + m.busy = true +} + +// SetBusy denotes that the writer is free. +func (m *HeaderModel) SetFree() { + m.busy = false +} + +// SetBreadcrumbs sets the bread crumbs displayed in the header. +func (m *HeaderModel) SetBreadcrumbs(breadcrumbs string) { + m.breadcrumbs = breadcrumbs +} + +// Resize changes the size of the header. +func (m *HeaderModel) Resize(width int) { + m.width = width +} + // InitialModel builds the model. func InitialModel(name string, title string) HeaderModel { return HeaderModel{ @@ -33,23 +52,6 @@ func InitialModel(name string, title string) HeaderModel { // Update handles a message. func (m HeaderModel) Update(msg tea.Msg) (HeaderModel, tea.Cmd) { - switch msg := msg.(type) { - - case msgs.SizeMsg: - if msg.Name == m.name { - m.width = msg.Width - } - - case msgs.BreadcrumbsMsg: - m.nav = string(msg) - - case msgs.BusyMsg: - m.busy = true - - case msgs.FreeMsg: - m.busy = false - } - return m, nil } @@ -66,7 +68,7 @@ func (m HeaderModel) View() string { } rows := [][]string{ - {title, utils.Substr(m.nav, cellTextWidth)}, + {title, utils.Substr(m.breadcrumbs, cellTextWidth)}, } titleNavStyle := lipgloss. diff --git a/cmd/cli/tui/header/header_test.go b/cmd/cli/tui/controls/header/header_test.go similarity index 61% rename from cmd/cli/tui/header/header_test.go rename to cmd/cli/tui/controls/header/header_test.go index 18b4629..415fe12 100644 --- a/cmd/cli/tui/header/header_test.go +++ b/cmd/cli/tui/controls/header/header_test.go @@ -3,10 +3,7 @@ package header import ( "testing" - tea "github.com/charmbracelet/bubbletea" "github.com/google/go-cmp/cmp" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" - "github.com/jonathanhope/armaria/cmd/cli/tui/utils" ) const Name = "header" @@ -15,49 +12,49 @@ func TestCanUpdateWidth(t *testing.T) { gotModel := HeaderModel{ name: Name, } - gotModel, gotCmd := gotModel.Update(msgs.SizeMsg{Name: Name, Width: 1}) + gotModel.Resize(1) wantModel := HeaderModel{ name: Name, width: 1, } - verifyUpdate(t, gotModel, wantModel, gotCmd) + verifyUpdate(t, gotModel, wantModel) } func TestCanUpdateNav(t *testing.T) { gotModel := HeaderModel{} - gotModel, gotCmd := gotModel.Update(msgs.BreadcrumbsMsg("nav")) + gotModel.SetBreadcrumbs("breadcrumbs") wantModel := HeaderModel{ - nav: "nav", + breadcrumbs: "breadcrumbs", } - verifyUpdate(t, gotModel, wantModel, gotCmd) + verifyUpdate(t, gotModel, wantModel) } func TestCanMarkBusy(t *testing.T) { gotModel := HeaderModel{} - gotModel, gotCmd := gotModel.Update(msgs.BusyMsg{}) + gotModel.SetBusy() wantModel := HeaderModel{ busy: true, } - verifyUpdate(t, gotModel, wantModel, gotCmd) + verifyUpdate(t, gotModel, wantModel) } func TestCanMarkFree(t *testing.T) { gotModel := HeaderModel{ busy: true, } - gotModel, gotCmd := gotModel.Update(msgs.FreeMsg{}) + gotModel.SetFree() wantModel := HeaderModel{ busy: false, } - verifyUpdate(t, gotModel, wantModel, gotCmd) + verifyUpdate(t, gotModel, wantModel) } func TestBusy(t *testing.T) { @@ -71,12 +68,10 @@ func TestBusy(t *testing.T) { } } -func verifyUpdate(t *testing.T, gotModel HeaderModel, wantModel HeaderModel, gotCmd tea.Cmd) { +func verifyUpdate(t *testing.T, gotModel HeaderModel, wantModel HeaderModel) { unexported := cmp.AllowUnexported(HeaderModel{}) modelDiff := cmp.Diff(gotModel, wantModel, unexported) if modelDiff != "" { t.Errorf("Expected and actual models different:\n%s", modelDiff) } - - utils.CompareCommands(t, gotCmd, nil) } diff --git a/cmd/cli/tui/help/help.go b/cmd/cli/tui/controls/help/help.go similarity index 89% rename from cmd/cli/tui/help/help.go rename to cmd/cli/tui/controls/help/help.go index 1fe5421..e6853cf 100644 --- a/cmd/cli/tui/help/help.go +++ b/cmd/cli/tui/controls/help/help.go @@ -4,7 +4,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss/table" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" "github.com/samber/lo" ) @@ -29,6 +28,14 @@ func (m HelpModel) HelpMode() bool { return m.helpMode } +func (m *HelpModel) ShowHelp() { + m.helpMode = true +} + +func (m *HelpModel) HideHelp() { + m.helpMode = false +} + // InitialModel builds the model. func InitialModel(name string, contexts []string, bindings []Binding) HelpModel { return HelpModel{ @@ -40,21 +47,6 @@ func InitialModel(name string, contexts []string, bindings []Binding) HelpModel // Update handles a message. func (m HelpModel) Update(msg tea.Msg) (HelpModel, tea.Cmd) { - switch msg := msg.(type) { - case msgs.ShowHelpMsg: - m.helpMode = true - - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c": - return m, tea.Quit - - case "q", "esc": - m.helpMode = false - return m, nil - } - } - return m, nil } @@ -95,7 +87,6 @@ func (m HelpModel) View() string { Padding(0, cellPadding) headerStyle := baseStyle. - Copy(). Bold(true). Foreground(lipgloss.Color("3")) diff --git a/cmd/cli/tui/help/help_test.go b/cmd/cli/tui/controls/help/help_test.go similarity index 71% rename from cmd/cli/tui/help/help_test.go rename to cmd/cli/tui/controls/help/help_test.go index 6b92b43..5e36b7e 100644 --- a/cmd/cli/tui/help/help_test.go +++ b/cmd/cli/tui/controls/help/help_test.go @@ -5,7 +5,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/google/go-cmp/cmp" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" "github.com/jonathanhope/armaria/cmd/cli/tui/utils" ) @@ -15,14 +14,14 @@ func TestCanShowHelp(t *testing.T) { gotModel := HelpModel{ name: Name, } - gotModel, gotCmd := gotModel.Update(msgs.ShowHelpMsg{Name: Name}) + gotModel.ShowHelp() wantModel := HelpModel{ name: Name, helpMode: true, } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + verifyUpdate(t, gotModel, wantModel, nil, nil) } func TestCanHideHelp(t *testing.T) { @@ -30,18 +29,14 @@ func TestCanHideHelp(t *testing.T) { name: Name, helpMode: true, } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + gotModel.HideHelp() wantModel := HelpModel{ name: Name, helpMode: false, } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) - - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyEsc}) - - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + verifyUpdate(t, gotModel, wantModel, nil, nil) } func TestHelpMode(t *testing.T) { diff --git a/cmd/cli/tui/scrolltable/scrolltable.go b/cmd/cli/tui/controls/scrolltable/scrolltable.go similarity index 87% rename from cmd/cli/tui/scrolltable/scrolltable.go rename to cmd/cli/tui/controls/scrolltable/scrolltable.go index c767336..a8366da 100644 --- a/cmd/cli/tui/scrolltable/scrolltable.go +++ b/cmd/cli/tui/controls/scrolltable/scrolltable.go @@ -90,37 +90,10 @@ func (m ScrolltableModel[T]) Data() []T { return m.data } -// frameSize returns the current size of the visible frame of data. -func (m ScrolltableModel[T]) frameSize() int { - frameSize := m.height - Reserved - if len(m.data) < frameSize { - frameSize = len(m.data) - } - - return frameSize -} - -// resetFrame resets the frame after the size or data has changed. -func (m *ScrolltableModel[T]) resetFrame(move msgs.Direction) { - if move == msgs.DirectionUp { - m.moveUp() - } else if move == msgs.DirectionDown { - m.moveDown() - } else if move == msgs.DirectionStart { - m.cursor = 0 - m.frameStart = 0 - } - - if m.frameStart+m.cursor >= len(m.data) { - m.cursor = 0 - m.frameStart = 0 - } -} - -// moveDown moves the cursor down the table. -func (m *ScrolltableModel[T]) moveDown() (ScrolltableModel[T], tea.Cmd) { +// MoveDown moves the cursor down the table. +func (m *ScrolltableModel[T]) MoveDown() tea.Cmd { if m.Empty() { - return *m, nil + return nil } move := false @@ -136,16 +109,16 @@ func (m *ScrolltableModel[T]) moveDown() (ScrolltableModel[T], tea.Cmd) { } if scroll || move { - return *m, m.selectionChangedCmd() + return m.selectionChangedCmd() } - return *m, nil + return nil } -// moveUp moves the cursor up the table. -func (m *ScrolltableModel[T]) moveUp() (ScrolltableModel[T], tea.Cmd) { +// MoveUp moves the cursor up the table. +func (m *ScrolltableModel[T]) MoveUp() tea.Cmd { if m.Empty() { - return *m, nil + return nil } move := false @@ -161,49 +134,60 @@ func (m *ScrolltableModel[T]) moveUp() (ScrolltableModel[T], tea.Cmd) { } if scroll || move { - return *m, m.selectionChangedCmd() + return m.selectionChangedCmd() } - return *m, nil + return nil } -// Update updates the scrolltable model from a message. -func (m ScrolltableModel[T]) Update(msg tea.Msg) (ScrolltableModel[T], tea.Cmd) { - switch msg := msg.(type) { +// Resize changes the size of the table. +func (m *ScrolltableModel[T]) Resize(width int, height int) { + m.width = width + m.height = height + m.resetFrame(msgs.DirectionStart) +} - case msgs.SizeMsg: - if msg.Name == m.name { - m.width = msg.Width - m.height = msg.Height - m.resetFrame(msgs.DirectionNone) - } +// Reload reloads that data in the table. +func (m *ScrolltableModel[T]) Reload(data []T, move msgs.Direction) tea.Cmd { + // This allows the cursor to stick in the right place when an item is removed. + if len(m.data)-1 == len(data) && m.frameStart > 0 { + m.frameStart -= 1 + } - case msgs.DataMsg[T]: - if msg.Name == m.name { - // This allows the cursor to stick in the right place when an item is removed. - if len(m.data)-1 == len(msg.Data) && m.frameStart > 0 { - m.frameStart -= 1 - } + m.data = data + m.resetFrame(move) + return m.selectionChangedCmd() +} - m.data = msg.Data - m.resetFrame(msg.Move) - return m, tea.Batch( - m.selectionChangedCmd(), - func() tea.Msg { return msgs.FreeMsg{} }, - ) - } +// frameSize returns the current size of the visible frame of data. +func (m ScrolltableModel[T]) frameSize() int { + frameSize := m.height - Reserved + if len(m.data) < frameSize { + frameSize = len(m.data) + } - case tea.KeyMsg: - switch msg.String() { + return frameSize +} - case "down": - return m.moveDown() +// resetFrame resets the frame after the size or data has changed. +func (m *ScrolltableModel[T]) resetFrame(move msgs.Direction) { + if move == msgs.DirectionUp { + m.MoveUp() + } else if move == msgs.DirectionDown { + m.MoveDown() + } else if move == msgs.DirectionStart { + m.cursor = 0 + m.frameStart = 0 + } - case "up": - return m.moveUp() - } + if m.frameStart+m.cursor >= len(m.data) { + m.cursor = 0 + m.frameStart = 0 } +} +// Update updates the scrolltable model from a message. +func (m ScrolltableModel[T]) Update(msg tea.Msg) (ScrolltableModel[T], tea.Cmd) { return m, nil } @@ -293,6 +277,7 @@ func (m ScrolltableModel[T]) Init() tea.Cmd { func (m ScrolltableModel[T]) selectionChangedCmd() tea.Cmd { return func() tea.Msg { return msgs.SelectionChangedMsg[T]{ + Name: m.name, Empty: m.Empty(), Selection: m.Selection(), } diff --git a/cmd/cli/tui/scrolltable/scrolltable_test.go b/cmd/cli/tui/controls/scrolltable/scrolltable_test.go similarity index 77% rename from cmd/cli/tui/scrolltable/scrolltable_test.go rename to cmd/cli/tui/controls/scrolltable/scrolltable_test.go index 5298e8f..042fbe2 100644 --- a/cmd/cli/tui/scrolltable/scrolltable_test.go +++ b/cmd/cli/tui/controls/scrolltable/scrolltable_test.go @@ -20,7 +20,7 @@ func TestCanUpdateData(t *testing.T) { cursor: 0, height: height, } - gotModel, gotCmd := gotModel.Update(msgs.DataMsg[TestDatum]{Data: data}) + gotCmd := gotModel.Reload(data, msgs.DirectionNone) wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -30,13 +30,8 @@ func TestCanUpdateData(t *testing.T) { } wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { - return msgs.SelectionChangedMsg[TestDatum]{ - Selection: TestDatum{ID: "1"}, - } - }, - func() tea.Msg { return msgs.FreeMsg{} }, + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "1"}, } } @@ -55,7 +50,7 @@ func TestCanUpdateDataMoveUp(t *testing.T) { height: height, frameStart: 1, } - gotModel, gotCmd := gotModel.Update(msgs.DataMsg[TestDatum]{Data: data, Move: msgs.DirectionUp}) + gotCmd := gotModel.Reload(data, msgs.DirectionUp) wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -65,13 +60,8 @@ func TestCanUpdateDataMoveUp(t *testing.T) { } wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { - return msgs.SelectionChangedMsg[TestDatum]{ - Selection: TestDatum{ID: "1"}, - } - }, - func() tea.Msg { return msgs.FreeMsg{} }, + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "1"}, } } @@ -89,7 +79,7 @@ func TestCanUpdateDataMoveDown(t *testing.T) { cursor: 0, height: height, } - gotModel, gotCmd := gotModel.Update(msgs.DataMsg[TestDatum]{Data: data, Move: msgs.DirectionDown}) + gotCmd := gotModel.Reload(data, msgs.DirectionDown) wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -99,13 +89,8 @@ func TestCanUpdateDataMoveDown(t *testing.T) { } wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { - return msgs.SelectionChangedMsg[TestDatum]{ - Selection: TestDatum{ID: "2"}, - } - }, - func() tea.Msg { return msgs.FreeMsg{} }, + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "2"}, } } @@ -124,7 +109,7 @@ func TestCanUpdateDataMoveStart(t *testing.T) { height: height, frameStart: 1, } - gotModel, gotCmd := gotModel.Update(msgs.DataMsg[TestDatum]{Data: data, Move: msgs.DirectionStart}) + gotCmd := gotModel.Reload(data, msgs.DirectionStart) wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -134,13 +119,8 @@ func TestCanUpdateDataMoveStart(t *testing.T) { } wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { - return msgs.SelectionChangedMsg[TestDatum]{ - Selection: TestDatum{ID: "1"}, - } - }, - func() tea.Msg { return msgs.FreeMsg{} }, + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "1"}, } } @@ -160,7 +140,7 @@ func TestCanScrollDown(t *testing.T) { data: data, frameStart: 0, } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyDown})) + gotCmd := gotModel.MoveDown() wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -175,7 +155,7 @@ func TestCanScrollDown(t *testing.T) { verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) - gotModel, gotCmd = gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyDown})) + gotCmd = gotModel.MoveDown() verifyUpdate(t, gotModel, wantModel, gotCmd, nil) } @@ -193,7 +173,7 @@ func TestCanScrollUp(t *testing.T) { data: data, frameStart: 1, } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyUp})) + gotCmd := gotModel.MoveUp() wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -207,7 +187,7 @@ func TestCanScrollUp(t *testing.T) { verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) - gotModel, gotCmd = gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyUp})) + gotCmd = gotModel.MoveUp() verifyUpdate(t, gotModel, wantModel, gotCmd, nil) } @@ -225,7 +205,7 @@ func TestCanMoveDown(t *testing.T) { data: data, frameStart: 0, } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyDown})) + gotCmd := gotModel.MoveDown() wantModel := ScrolltableModel[TestDatum]{ cursor: 1, @@ -239,7 +219,7 @@ func TestCanMoveDown(t *testing.T) { verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) - gotModel, gotCmd = gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyDown})) + gotCmd = gotModel.MoveDown() verifyUpdate(t, gotModel, wantModel, gotCmd, nil) } @@ -257,7 +237,7 @@ func TestCanMoveUp(t *testing.T) { data: data, frameStart: 0, } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyUp})) + gotCmd := gotModel.MoveUp() wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -271,7 +251,7 @@ func TestCanMoveUp(t *testing.T) { verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) - gotModel, gotCmd = gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyUp})) + gotCmd = gotModel.MoveUp() verifyUpdate(t, gotModel, wantModel, gotCmd, nil) } @@ -286,7 +266,7 @@ func TestCanScrollIfFrameEmpty(t *testing.T) { data: data, frameStart: 0, } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyDown})) + gotCmd := gotModel.MoveDown() wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -297,7 +277,7 @@ func TestCanScrollIfFrameEmpty(t *testing.T) { verifyUpdate(t, gotModel, wantModel, gotCmd, nil) - gotModel, gotCmd = gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyUp})) + gotCmd = gotModel.MoveUp() verifyUpdate(t, gotModel, wantModel, gotCmd, nil) } @@ -314,7 +294,7 @@ func TestFrameSizeChangesWithHeight(t *testing.T) { data: data, frameStart: 0, } - gotModel, gotCmd := gotModel.Update(msgs.SizeMsg{Height: Reserved + 2}) + gotModel.Resize(0, Reserved+2) wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -323,7 +303,7 @@ func TestFrameSizeChangesWithHeight(t *testing.T) { frameStart: 0, } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + verifyUpdate(t, gotModel, wantModel, nil, nil) } func TestFrameCannotBeLargerThanData(t *testing.T) { @@ -336,7 +316,7 @@ func TestFrameCannotBeLargerThanData(t *testing.T) { cursor: 0, height: height, } - gotModel, gotCmd := gotModel.Update(msgs.DataMsg[TestDatum]{Data: data}) + gotCmd := gotModel.Reload(data, msgs.DirectionNone) wantModel := ScrolltableModel[TestDatum]{ cursor: 0, @@ -345,13 +325,8 @@ func TestFrameCannotBeLargerThanData(t *testing.T) { frameStart: 0, } wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { - return msgs.SelectionChangedMsg[TestDatum]{ - Selection: TestDatum{ID: "1"}, - } - }, - func() tea.Msg { return msgs.FreeMsg{} }, + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "1"}, } } diff --git a/cmd/cli/tui/textinput/textinput.go b/cmd/cli/tui/controls/textinput/textinput.go similarity index 68% rename from cmd/cli/tui/textinput/textinput.go rename to cmd/cli/tui/controls/textinput/textinput.go index 1f46060..888339f 100644 --- a/cmd/cli/tui/textinput/textinput.go +++ b/cmd/cli/tui/controls/textinput/textinput.go @@ -2,7 +2,6 @@ package textinput import ( "strings" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -10,40 +9,178 @@ import ( "github.com/muesli/reflow/ansi" ) -const BlinkSpeed = 600 // how quickly to blink the cursor -const Padding = 1 // how much left and right padding to add +const Padding = 1 // how much left and right padding to add // TextInputModel is the TextInputModel for a textinput. // The textinput allows users to enter and modify text. type TextInputModel struct { - name string // name of the text input - prompt string // prompt for the input - text string // the current text being inputted - width int // the width of the text input - cursor int // location of the cursor in the window - index int // which character is selected in the text - focus bool // whether the text input is focused or not - maxChars int // maximum number of chars to allow - blink bool // flag that alternates in order to make the cursor blink - windowStart int // index the window starts at - windowEnd int // index the window ends at - sleeper sleeper // used to sleep + name string // name of the input + prompt string // prompt for the input + text string // the current text being inputted + width int // the width of the text input + cursor int // location of the cursor in the window + index int // which character is selected in the text + focus bool // whether the input is focused or not + maxChars int // maximum number of chars to allow + blink bool // flag that alternates in order to make the cursor blink + windowStart int // index the window starts at + windowEnd int // index the window ends at } // InitialModel builds the model. func InitialModel(name string, prompt string) TextInputModel { return TextInputModel{ - name: name, - prompt: prompt, - sleeper: timeSleeper{}, + name: name, + prompt: prompt, } } // Text returns the current text in the input. -func (m *TextInputModel) Text() string { +func (m TextInputModel) Text() string { return m.text } +// Focus returns true if this input is focused. +func (m TextInputModel) Focused() bool { + return m.focus +} + +// Focus focuses the input. +// If it is focused it will be accepting input. +func (m *TextInputModel) Focus(maxChars int) { + m.focus = true + m.maxChars = maxChars + m.initWindow() +} + +// Blur blurs the input. +// Once it is no longer focused it will no longer accept input. +func (m *TextInputModel) Blur() { + m.focus = false + m.maxChars = 0 + m.initWindow() +} + +// SetText sets the text inside the input. +func (m *TextInputModel) SetText(text string) { + m.text = text + m.initWindow() +} + +// SetPrompt sets the prompt for the input. +func (m *TextInputModel) SetPrompt(prompt string) { + m.prompt = prompt + m.initWindow() +} + +// Resize changes the size of the input. +func (m *TextInputModel) Resize(width int) { + m.width = width - Padding*2 + m.initWindow() +} + +// Insert inserts runes in front of the cursor. +func (m *TextInputModel) Insert(runes []rune) tea.Cmd { + textRunes := strings.Split(m.text, "") + cursorAtEnd := m.cursorAtEnd() + + if m.maxChars > 0 && len(textRunes)+len(runes) > m.maxChars { + return nil + } + + if m.indexAtStart() { // Insert the char at start of the text. + m.text = string(runes) + m.text + } else if m.indexAtEnd() { // Insert the char at the end of the text. + m.text += string(runes) + } else { // Insert the char in the middle of the text. + first := strings.Join(textRunes[:m.index], "") + rest := strings.Join(textRunes[m.index:], "") + m.text = first + string(runes) + rest + } + + if cursorAtEnd { + m.moveEnd() + } else { + m.cursor += len(runes) + m.index += len(runes) + m.chopRight() + } + + return m.inputChangedCmd() +} + +// Delete deletes the rune in front of the cursor. +func (m *TextInputModel) Delete() tea.Cmd { + if m.text == "" || m.index == 0 { + return nil + } + + textRunes := strings.Split(m.text, "") + + if m.index == 1 { // Delete a char at the start of the text. + m.text = strings.Join(textRunes[1:], "") + } else if m.indexAtEnd() { // Delete a char at the end of the text. + m.text = strings.Join(textRunes[:len(textRunes)-1], "") + } else { // Delete a char in the middle of the text. + first := strings.Join(textRunes[:m.index-1], "") + rest := strings.Join(textRunes[m.index:], "") + m.text = first + rest + } + + m.index -= 1 + + if m.windowStart > 0 { + m.windowStart -= 1 + m.chopRight() + } else { + m.cursor -= 1 + } + + return m.inputChangedCmd() +} + +// MoveLeft moves the cursor the left once space. +func (m *TextInputModel) MoveLeft() { + shift := !m.indexAtStart() && m.cursorAtStart() + + if !m.indexAtStart() { + m.index -= 1 + } + + if !m.cursorAtStart() { + m.cursor -= 1 + } + + if shift { + m.windowStart -= 1 + } + + m.chopRight() +} + +// MoveRight moves the cursor to right once space. +func (m *TextInputModel) MoveRight() { + shift := !m.indexAtEnd() && m.cursorAtEnd() + previousLength := m.windowEnd - m.windowStart + + if !m.indexAtEnd() { + m.index += 1 + } + + if !m.cursorAtEnd() { + m.cursor += 1 + } + + if shift { + m.windowEnd += 1 + } + + m.chopLeft() + + newLength := m.windowEnd - m.windowStart + m.cursor += newLength - previousLength +} + // textWithSpace returns the current text with a space at the end. // This input uses a block cursor so the extra space is needed. func (m *TextInputModel) textWithSpace() string { @@ -133,182 +270,15 @@ func (m *TextInputModel) chopLeft() { } } -// moveRight moves the cursor to right once space. -func (m *TextInputModel) moveRight() { - shift := !m.indexAtEnd() && m.cursorAtEnd() - previousLength := m.windowEnd - m.windowStart - - if !m.indexAtEnd() { - m.index += 1 - } - - if !m.cursorAtEnd() { - m.cursor += 1 - } - - if shift { - m.windowEnd += 1 - } - - m.chopLeft() - - newLength := m.windowEnd - m.windowStart - m.cursor += newLength - previousLength -} - -// moveLeft moves the cursor the left once space. -func (m *TextInputModel) moveLeft() { - shift := !m.indexAtStart() && m.cursorAtStart() - - if !m.indexAtStart() { - m.index -= 1 - } - - if !m.cursorAtStart() { - m.cursor -= 1 - } - - if shift { - m.windowStart -= 1 - } - - m.chopRight() -} - // moveEnd moves to the end of the text. func (m *TextInputModel) moveEnd() { for !m.cursorAtEnd() || !m.indexAtEnd() { - m.moveRight() - } -} - -// moveEnd moves to the start of the text. -func (m *TextInputModel) moveStart() { - for !m.cursorAtStart() || !m.indexAtStart() { - m.moveLeft() - } -} - -// delete deletes the rune in front of the cursor. -func (m *TextInputModel) delete() { - if m.text == "" || m.index == 0 { - return - } - - textRunes := strings.Split(m.text, "") - - if m.index == 1 { // Delete a char at the start of the text. - m.text = strings.Join(textRunes[1:], "") - } else if m.indexAtEnd() { // Delete a char at the end of the text. - m.text = strings.Join(textRunes[:len(textRunes)-1], "") - } else { // Delete a char in the middle of the text. - first := strings.Join(textRunes[:m.index-1], "") - rest := strings.Join(textRunes[m.index:], "") - m.text = first + rest - } - - m.index -= 1 - - if m.windowStart > 0 { - m.windowStart -= 1 - m.chopRight() - } else { - m.cursor -= 1 - } -} - -// insert inserts runes in front of the cursor. -func (m *TextInputModel) insert(runes []rune) { - textRunes := strings.Split(m.text, "") - cursorAtEnd := m.cursorAtEnd() - - if m.maxChars > 0 && len(textRunes)+len(runes) > m.maxChars { - return - } - - if m.indexAtStart() { // Insert the char at start of the text. - m.text = string(runes) + m.text - } else if m.indexAtEnd() { // Insert the char at the end of the text. - m.text += string(runes) - } else { // Insert the char in the middle of the text. - first := strings.Join(textRunes[:m.index], "") - rest := strings.Join(textRunes[m.index:], "") - m.text = first + string(runes) + rest - } - - if cursorAtEnd { - m.moveEnd() - } else { - m.cursor += len(runes) - m.index += len(runes) - m.chopRight() + m.MoveRight() } } // Update handles a message. func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { - switch msg := msg.(type) { - - case msgs.FocusMsg: - if m.name == msg.Name { - m.focus = true - m.blink = true - m.maxChars = msg.MaxChars - m.initWindow() - return m, m.blinkCmd() - } - - case msgs.BlurMsg: - m.focus = false - m.blink = false - m.maxChars = 0 - m.initWindow() - - case msgs.BlinkMsg: - if m.focus && msg.Name == m.name { - m.blink = !m.blink - return m, m.blinkCmd() - } - - case msgs.TextMsg: - if m.name == msg.Name { - m.text = msg.Text - m.initWindow() - } - - case msgs.PromptMsg: - if m.name == msg.Name { - m.prompt = msg.Prompt - m.initWindow() - } - - case msgs.SizeMsg: - if m.name == msg.Name { - m.width = msg.Width - Padding*2 - m.initWindow() - } - - case tea.KeyMsg: - if m.focus { - switch msg.String() { - - case "backspace": - m.delete() - return m, m.inputChangedCmd() - - case "left": - m.moveLeft() - - case "right": - m.moveRight() - - default: - m.insert(msg.Runes) - return m, m.inputChangedCmd() - } - } - } - return m, nil } @@ -323,7 +293,8 @@ func (m TextInputModel) View() string { cursorStyle := lipgloss. NewStyle(). Inline(true) - if m.blink { + + if m.focus { cursorStyle = cursorStyle.Reverse(true) } @@ -363,31 +334,9 @@ func (m TextInputModel) Init() tea.Cmd { return nil } -// blinkCmd makes the cursor blink. -func (m *TextInputModel) blinkCmd() tea.Cmd { - return func() tea.Msg { - // By sleeping and then returning another BlinkMsg we can make the cursor blink. - m.sleeper.sleep(BlinkSpeed * time.Millisecond) - return msgs.BlinkMsg{Name: m.name} - } -} - // inputChangedCmd publishes a message with the current text. func (m TextInputModel) inputChangedCmd() tea.Cmd { return func() tea.Msg { return msgs.InputChangedMsg{Name: m.name} } } - -type sleeper interface { - // sleep pauses execution for the requested duration. - sleep(time.Duration) -} - -// timeSleeper implements sleeper with the time package. -type timeSleeper struct{} - -// sleep pauses execution of the calling thread for the requested duration. -func (s timeSleeper) sleep(d time.Duration) { - time.Sleep(d) -} diff --git a/cmd/cli/tui/textinput/textinput_test.go b/cmd/cli/tui/controls/textinput/textinput_test.go similarity index 59% rename from cmd/cli/tui/textinput/textinput_test.go rename to cmd/cli/tui/controls/textinput/textinput_test.go index bad78d9..0346e32 100644 --- a/cmd/cli/tui/textinput/textinput_test.go +++ b/cmd/cli/tui/controls/textinput/textinput_test.go @@ -1,10 +1,9 @@ package textinput import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/google/go-cmp/cmp" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" "testing" + + "github.com/google/go-cmp/cmp" ) const name = "TestInput" @@ -37,10 +36,10 @@ func TestInsert(t *testing.T) { width: 12, } - model, _ = model.Update(msgs.FocusMsg{Name: name}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'🦊'}}) + model.Focus(0) + model.MoveLeft() + model.MoveLeft() + model.Insert([]rune{'🦊'}) validate(model, "🦊🐂🐜 ") @@ -52,8 +51,8 @@ func TestInsert(t *testing.T) { width: 12, } - model, _ = model.Update(msgs.FocusMsg{Name: name}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'🐜'}}) + model.Focus(0) + model.Insert([]rune{'🐜'}) validate(model, "🦊🐂🐜 ") @@ -65,9 +64,9 @@ func TestInsert(t *testing.T) { width: 12, } - model, _ = model.Update(msgs.FocusMsg{Name: name}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'🐂'}}) + model.Focus(0) + model.MoveLeft() + model.Insert([]rune{'🐂'}) validate(model, "🦊🐂🐜 ") } @@ -102,13 +101,13 @@ func TestInsertMovesWindow(t *testing.T) { text: "🦊", } - model, _ = model.Update(msgs.FocusMsg{Name: name}) + model.Focus(0) validate(model, 1, "🦊 ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + model.Insert([]rune{'c'}) validate(model, 2, "c ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'🦊'}}) + model.Insert([]rune{'🦊'}) validate(model, 3, "🦊 ") } @@ -128,10 +127,10 @@ func TestDelete(t *testing.T) { width: 12, } - model, _ = model.Update(msgs.FocusMsg{Name: name}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Focus(0) + model.MoveLeft() + model.MoveLeft() + model.Delete() validate(model, "🐂🐜 ") @@ -143,8 +142,8 @@ func TestDelete(t *testing.T) { width: 12, } - model, _ = model.Update(msgs.FocusMsg{Name: name}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Focus(0) + model.Delete() validate(model, "🦊🐂 ") @@ -156,9 +155,9 @@ func TestDelete(t *testing.T) { width: 12, } - model, _ = model.Update(msgs.FocusMsg{Name: name}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Focus(0) + model.MoveLeft() + model.Delete() validate(model, "🦊🐜 ") } @@ -177,28 +176,28 @@ func TestDeleteMovesWindow(t *testing.T) { width: 6, } - model, _ = model.Update(msgs.FocusMsg{Name: name}) + model.Focus(0) validate(model, "🦊c ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Delete() validate(model, "c🦊 ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Delete() validate(model, "🦊c ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Delete() validate(model, "c🦊 ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Delete() validate(model, "🦊c ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Delete() validate(model, "🦊 ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Delete() validate(model, " ") - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + model.Delete() validate(model, " ") } @@ -227,34 +226,34 @@ func TestMoveRight(t *testing.T) { text: "a🦊b🐂c🐜", } - model, _ = model.Update(msgs.FocusMsg{Name: name}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.Focus(0) + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() validate(model, "a🦊", 0, 0) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "a🦊", 1, 1) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🦊b", 1, 2) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "b🐂", 1, 3) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🐂c", 1, 4) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "c🐜", 1, 5) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🐜 ", 1, 6) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🐜 ", 1, 6) } @@ -283,42 +282,42 @@ func TestMoveRightVariation2(t *testing.T) { text: "🦊🐂abcd🐜🐕", } - model, _ = model.Update(msgs.FocusMsg{Name: name}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.Focus(0) + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() + model.MoveLeft() validate(model, "🦊🐂", 0, 0) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🦊🐂", 1, 1) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🐂a", 1, 2) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🐂ab", 2, 3) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "abc", 2, 4) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "abcd", 3, 5) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "cd🐜", 2, 6) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🐜🐕", 1, 7) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🐕 ", 1, 8) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + model.MoveRight() validate(model, "🐕 ", 1, 8) } @@ -347,28 +346,28 @@ func TestMoveLeft(t *testing.T) { text: "a🦊b🐂c🐜", } - model, _ = model.Update(msgs.FocusMsg{Name: name}) + model.Focus(0) validate(model, "🐜 ", 1, 6) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "🐜 ", 0, 5) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "c🐜", 0, 4) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "🐂c", 0, 3) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "b🐂", 0, 2) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "🦊b", 0, 1) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "a🦊", 0, 0) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "a🦊", 0, 0) } @@ -397,33 +396,33 @@ func TestMoveLeftVariation2(t *testing.T) { text: "🦊🐂abcd🐜🐕", } - model, _ = model.Update(msgs.FocusMsg{Name: name}) + model.Focus(0) validate(model, "🐕 ", 1, 8) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "🐕 ", 0, 7) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "🐜🐕", 0, 6) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "d🐜", 0, 5) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "cd🐜", 0, 4) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "bcd", 0, 3) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "abcd", 0, 2) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "🐂ab", 0, 1) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "🦊🐂", 0, 0) - model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model.MoveLeft() validate(model, "🦊🐂", 0, 0) } diff --git a/cmd/cli/tui/controls/typeahead/typeahead.go b/cmd/cli/tui/controls/typeahead/typeahead.go new file mode 100644 index 0000000..c866d2e --- /dev/null +++ b/cmd/cli/tui/controls/typeahead/typeahead.go @@ -0,0 +1,232 @@ +package typeahead + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/scrolltable" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/textinput" + "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" + "github.com/samber/lo" +) + +// TypeaheadItem is the model for an item in the typeahead. +type TypeaheadItem struct { + Value string // hidden identifier + Label string // visible text + New bool // if true this is a new item that didn't exist before +} + +// UnfilteredQueryFn is a function that returns typeahead items when no filter is active. +type UnfilteredQueryFn func() ([]TypeaheadItem, error) + +// FilteredQueryFn is a function that returns the typeahead items when a filter is active. +type FilteredQueryFn func(query string) ([]TypeaheadItem, error) + +// typeaheadTable is the type of the underlying table in the typeahead. +type typeaheadTable = scrolltable.ScrolltableModel[TypeaheadItem] + +// TypeaheadTogglePayload has the data needed to start or stop the typeahead. +type StartTypeaheadPayload struct { + UnfilteredQuery UnfilteredQueryFn // returns results when there isn't enough input + FilteredQuery FilteredQueryFn // returns results when there's enough input + MinFilterChars int // the minumum number of chars needed to filter + IncludeInput bool // if true include the current input as an option + Prompt string // the prompt to show + Text string // the text to start the input with + MaxChars int // the maximum number of chars to allow +} + +// TypeaheadModel is the model for a typeahead. +// The typpeahead allows the user to filter a list of things to select from by typing. +type TypeaheadModel struct { + name string // name of the typeahead + width int // max width of the typeahead + typeaheadMode bool // if true typeahead is accepting input + inputName string // the name of the input in the typeahead + tableName string // the name of the table in the typeahead + unfilteredQuery UnfilteredQueryFn // returns results when there isn't enough input + filteredQuery FilteredQueryFn // returns results when there's enough input + minFilterChars int // the minumum number of chars needed to filter + includeInput bool // if true include the current input as an option + input textinput.TextInputModel // allows text input + table typeaheadTable // shows the options to select from +} + +// InitialModel builds the model. +func InitialModel(name string) TypeaheadModel { + inputName := name + "Input" + tableName := name + "Table" + + return TypeaheadModel{ + name: name, + inputName: inputName, + tableName: tableName, + input: textinput.InitialModel(inputName, ""), + table: scrolltable.InitialModel[TypeaheadItem]( + tableName, + true, + []scrolltable.ColumnDefinition[TypeaheadItem]{ + { + Mode: scrolltable.DynamicColumn, + Header: "", + RenderCell: func(item TypeaheadItem) string { + return item.Label + }, + Style: func(item TypeaheadItem, isSelected bool, isHeader bool) lipgloss.Style { + style := lipgloss. + NewStyle() + + if isSelected { + style = style.Bold(true).Underline(true) + } + + return style + }, + }, + }, + ), + } +} + +// TypeaheadMode returns whether the typeahead is accepting input or not. +func (m TypeaheadModel) TypeaheadMode() bool { + return m.typeaheadMode +} + +// TableName returns the name of the underlying table. +func (m TypeaheadModel) TableName() string { + return m.name + "Table" +} + +// TableName returns the name of the underlying table. +func (m TypeaheadModel) InputName() string { + return m.name + "Input" +} + +// Selection returns the currently selected item. +func (m TypeaheadModel) Selection() TypeaheadItem { + return m.table.Selection() +} + +// StartTypeahead will have the typeahead start collecting input +func (m *TypeaheadModel) StartTypeahead(payload StartTypeaheadPayload) tea.Cmd { + m.typeaheadMode = true + m.unfilteredQuery = payload.UnfilteredQuery + m.filteredQuery = payload.FilteredQuery + m.minFilterChars = payload.MinFilterChars + m.includeInput = payload.IncludeInput + + m.input.SetPrompt(payload.Prompt) + m.input.SetText(payload.Text) + m.input.Focus(payload.MaxChars) + + return m.LoadItemsCmd() +} + +// StartTypeahead will have the typeahead stop collecting input +func (m *TypeaheadModel) StopTypeahead() { + m.typeaheadMode = false + m.input.SetPrompt("") + m.input.SetText("") + m.input.Blur() +} + +// Insert inserts runes in front of the cursor. +func (m *TypeaheadModel) Insert(runes []rune) tea.Cmd { + return m.input.Insert(runes) +} + +// Delete deletes the rune in front of the cursor. +func (m *TypeaheadModel) Delete() tea.Cmd { + return m.input.Delete() +} + +// Resize changes the size of the typeahead. +func (m *TypeaheadModel) Resize(width int, height int) { + m.width = width + m.table.Resize(width, height) + m.input.Resize(width) +} + +// MoveUp moves the cursor up the table. +func (m *TypeaheadModel) MoveUp() tea.Cmd { + return m.table.MoveUp() +} + +// MoveDown moves the cursor down the table. +func (m *TypeaheadModel) MoveDown() tea.Cmd { + return m.table.MoveDown() +} + +// MoveLeft moves the cursor the left once space. +func (m *TypeaheadModel) MoveLeft() { + m.input.MoveLeft() +} + +// MoveRight moves the cursor to right once space. +func (m *TypeaheadModel) MoveRight() { + m.input.MoveLeft() +} + +// Reload reloads that data in the table. +func (m *TypeaheadModel) Reload(data []TypeaheadItem, move msgs.Direction) tea.Cmd { + return m.table.Reload(data, move) +} + +// Update handles a message. +func (m TypeaheadModel) Update(msg tea.Msg) (TypeaheadModel, tea.Cmd) { + return m, nil +} + +// View renders the model. +func (m TypeaheadModel) View() string { + return m.input.View() + "\n\n" + m.table.View() +} + +// Init initializes the model. +func (m TypeaheadModel) Init() tea.Cmd { + return nil +} + +// LoadItemsCmd loads the available option in the typeahead. +func (m TypeaheadModel) LoadItemsCmd() tea.Cmd { + return func() tea.Msg { + if len(strings.Split(m.input.Text(), "")) >= m.minFilterChars { + items, err := m.filteredQuery(m.input.Text()) + if err != nil { + return msgs.ErrorMsg{Err: err} + } + + numMatch := len(lo.Filter(items, func(item TypeaheadItem, index int) bool { + return item.Label == m.input.Text() + })) + + if m.includeInput && numMatch == 0 { + items = append( + []TypeaheadItem{{Label: m.input.Text(), Value: m.input.Text(), New: true}}, + items...) + } + + return msgs.DataMsg[TypeaheadItem]{Name: m.tableName, Data: items, Move: msgs.DirectionStart} + } else { + items, err := m.unfilteredQuery() + if err != nil { + return msgs.ErrorMsg{Err: err} + } + + numMatch := len(lo.Filter(items, func(item TypeaheadItem, index int) bool { + return item.Label == m.input.Text() + })) + + if m.includeInput && m.input.Text() != "" && numMatch == 0 { + items = append( + []TypeaheadItem{{Label: m.input.Text(), Value: m.input.Text(), New: true}}, + items...) + } + + return msgs.DataMsg[TypeaheadItem]{Name: m.tableName, Data: items, Move: msgs.DirectionStart} + } + } +} diff --git a/cmd/cli/tui/controls/typeahead/typeahead_test.go b/cmd/cli/tui/controls/typeahead/typeahead_test.go new file mode 100644 index 0000000..8b9d358 --- /dev/null +++ b/cmd/cli/tui/controls/typeahead/typeahead_test.go @@ -0,0 +1,111 @@ +package typeahead + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" + "github.com/jonathanhope/armaria/cmd/cli/tui/utils" +) + +const name = "typeaheaed" +const inputName = "input" +const tableName = "table" +const operation = "operation" +const prompt = ">" +const text = "text" +const maxChars = 5 + +func TestTypeaheadMode(t *testing.T) { + model := TypeaheadModel{ + typeaheadMode: true, + } + + diff := cmp.Diff(model.TypeaheadMode(), true) + if diff != "" { + t.Errorf("Expected and actual typeaheadmode different") + } +} + +func TestCanSwitchToTypeaheadMode(t *testing.T) { + gotModel := TypeaheadModel{ + name: name, + inputName: inputName, + tableName: tableName, + } + gotModel.input.Resize(15) + + gotCmd := gotModel.StartTypeahead(StartTypeaheadPayload{ + MinFilterChars: 3, + Prompt: prompt, + Text: "text", + MaxChars: 5, + UnfilteredQuery: func() ([]TypeaheadItem, error) { + return []TypeaheadItem{}, nil + }, + FilteredQuery: func(query string) ([]TypeaheadItem, error) { + return []TypeaheadItem{}, nil + }, + }) + + wantModel := TypeaheadModel{ + name: name, + inputName: inputName, + tableName: tableName, + typeaheadMode: true, + minFilterChars: 3, + } + + wantCmd := func() tea.Msg { + return msgs.DataMsg[TypeaheadItem]{ + Name: tableName, + Data: []TypeaheadItem{}, + Move: msgs.DirectionStart, + } + } + + verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) +} + +func TestCanSwitchFromTypeaheadMode(t *testing.T) { + gotModel := TypeaheadModel{ + name: name, + inputName: inputName, + tableName: tableName, + typeaheadMode: true, + minFilterChars: 3, + } + gotModel.input.Resize(15) + + gotModel.StopTypeahead() + + wantModel := TypeaheadModel{ + name: name, + inputName: inputName, + tableName: tableName, + typeaheadMode: false, + minFilterChars: 3, + } + + verifyUpdate(t, gotModel, wantModel, nil, nil) +} + +func verifyUpdate(t *testing.T, gotModel TypeaheadModel, wantModel TypeaheadModel, gotCmd tea.Cmd, wantCmd tea.Cmd) { + unexported := cmp.AllowUnexported(TypeaheadModel{}) + modelDiff := cmp.Diff( + gotModel, + wantModel, + unexported, + cmpopts.IgnoreFields(TypeaheadModel{}, "input"), + cmpopts.IgnoreFields(TypeaheadModel{}, "table"), + cmpopts.IgnoreFields(TypeaheadModel{}, "filteredQuery"), + cmpopts.IgnoreFields(TypeaheadModel{}, "unfilteredQuery"), + ) + if modelDiff != "" { + t.Errorf("Expected and actual models different:\n%s", modelDiff) + } + + utils.CompareCommands(t, gotCmd, wantCmd) +} diff --git a/cmd/cli/tui/footer/footer_test.go b/cmd/cli/tui/footer/footer_test.go deleted file mode 100644 index 820cc53..0000000 --- a/cmd/cli/tui/footer/footer_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package footer - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" - "github.com/jonathanhope/armaria/cmd/cli/tui/textinput" - "github.com/jonathanhope/armaria/cmd/cli/tui/utils" -) - -const Name = "footer" - -func TestCanUpdateWidth(t *testing.T) { - gotModel := FooterModel{ - name: Name, - inputName: Name + "Input", - } - gotModel, gotCmd := gotModel.Update(msgs.SizeMsg{Name: Name, Width: 1}) - - wantModel := FooterModel{ - name: Name, - inputName: Name + "Input", - width: 1, - } - - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) -} - -func TestCanStartInputMode(t *testing.T) { - gotModel := FooterModel{ - name: Name, - inputName: Name + "Input", - } - gotModel, gotCmd := gotModel.Update(msgs.InputModeMsg{ - Name: Name, - InputMode: true, - Prompt: "prompt", - Text: "text", - MaxChars: 5, - }) - - wantModel := FooterModel{ - name: Name, - inputName: Name + "Input", - inputMode: true, - } - - wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { return msgs.PromptMsg{Name: Name + "Input", Prompt: "prompt"} }, - func() tea.Msg { return msgs.TextMsg{Name: Name + "Input", Text: "text"} }, - func() tea.Msg { return msgs.FocusMsg{Name: Name + "Input", MaxChars: 5} }, - } - } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} - -func TestCanEndInputMode(t *testing.T) { - gotModel := FooterModel{ - name: Name, - inputName: Name + "Input", - inputMode: true, - } - gotModel, gotCmd := gotModel.Update(msgs.InputModeMsg{Name: Name, InputMode: false}) - - wantModel := FooterModel{ - name: Name, - inputName: Name + "Input", - inputMode: false, - } - - wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { return msgs.BlurMsg{Name: Name + "Input"} }, - func() tea.Msg { return msgs.PromptMsg{Name: Name + "Input"} }, - func() tea.Msg { return msgs.TextMsg{Name: Name + "Input"} }, - } - } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} - -func TestCanSetFilters(t *testing.T) { - gotModel := FooterModel{ - name: Name, - inputName: Name + "Input", - } - gotModel, gotCmd := gotModel.Update(msgs.FiltersMsg{Name: Name, Filters: []string{"one"}}) - - wantModel := FooterModel{ - name: Name, - inputName: Name + "Input", - filters: []string{"one"}, - } - - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) -} - -func TestCanCancelInput(t *testing.T) { - gotModel := FooterModel{ - name: Name, - inputName: Name + "Input", - inputMode: true, - } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyEsc}) - - wantModel := FooterModel{ - name: Name, - inputName: Name + "Input", - inputMode: true, - } - - wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { return msgs.InputCancelledMsg{Name: Name} }, - } - } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} - -func TestCanConfirmInput(t *testing.T) { - gotModel := FooterModel{ - name: Name, - inputName: Name + "Input", - inputMode: true, - input: textinput.InitialModel(Name+"Input", "> "), - } - - gotModel.input, _ = gotModel.input.Update(msgs.SizeMsg{ - Name: Name + "Input", - Width: 12, - }) - - gotModel.input, _ = gotModel.input.Update(msgs.TextMsg{ - Name: Name + "Input", - Text: "text", - }) - - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyEnter}) - - wantModel := FooterModel{ - name: Name, - inputName: Name + "Input", - inputMode: true, - } - - wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { return msgs.InputConfirmedMsg{Name: Name} }, - } - } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} - -func verifyUpdate(t *testing.T, gotModel FooterModel, wantModel FooterModel, gotCmd tea.Cmd, wantCmd tea.Cmd) { - unexported := cmp.AllowUnexported(FooterModel{}) - modelDiff := cmp.Diff(gotModel, wantModel, unexported, cmpopts.IgnoreFields(FooterModel{}, "input")) - if modelDiff != "" { - t.Errorf("Expected and actual models different:\n%s", modelDiff) - } - - utils.CompareCommands(t, gotCmd, wantCmd) -} diff --git a/cmd/cli/tui/msgs/msgs.go b/cmd/cli/tui/msgs/msgs.go index 98284ba..e655c05 100644 --- a/cmd/cli/tui/msgs/msgs.go +++ b/cmd/cli/tui/msgs/msgs.go @@ -11,11 +11,12 @@ const ( ) // View is which View to show. -type View string +type View int const ( - ViewBooks View = "books" // show a listing of books - ViewError View = "error" // show an error + ViewNone View = iota // no view + ViewBooks // show a listing of books + ViewError // show an error ) // ErrorMsg is a message that contains an error. @@ -27,137 +28,27 @@ func (e ErrorMsg) Error() string { return e.Err.Error() } // ViewMsg is a message that contains which view to show. type ViewMsg View -// SizeMsg is a message to inform the component it needs to resize. -type SizeMsg struct { - Name string // name of the target component - Width int // the max width the component can occupy - Height int // the max height the component can occupy -} - -// BusyMsg is a message that denotes the writer is busy. -type BusyMsg struct{} - -// FreeMsg is a message that denotes the writer is free again. -type FreeMsg struct{} - -// FolderMsg is a message that changes the current folder. -type FolderMsg string - -// BreadcrumbsMsg is a message that changes the current breadcrumbs being displayed. -type BreadcrumbsMsg string - -// FiltersMsg is published when the filters change. -type FiltersMsg struct { - Name string // name of the target component - Filters []string // current set of filters -} - -// InputModeMsg is used to change to input mode. -type InputModeMsg struct { - Name string // name of the target component - InputMode bool // if true the footer will be in input mode - Prompt string // the prompt to show - Text string // the text to start the input with - MaxChars int // the maximum number of chars to allow -} - -// InputCancelledMsg is published when the user cancels input. -type InputCancelledMsg struct { - Name string // name of the component which is collecting input. -} - -// InputConfirmedMsg is published when the user confirms input. -type InputConfirmedMsg struct { - Name string // name of the component which is collecting input. -} - -// InputChangedMsg is published when the value in an input changes. -type InputChangedMsg struct { - Name string // name of the target component -} - -// FocusMsg is used to focus an input. -type FocusMsg struct { - Name string // name of the target component - MaxChars int // maximum number of chars to allow -} - -// BlurMsg is published when the input blurs. -type BlurMsg struct { - Name string // name of the target component -} - -// TextMsg sets the text of an input. -type TextMsg struct { - Name string // name of the target component - Text string // text to set -} - -// PromptMsg sets the prompt of an input. -type PromptMsg struct { - Name string // name of the target component - Prompt string // prompt to use -} - -// BlinkMsg is a message that causes the cursor to blink. -type BlinkMsg struct { - Name string // name of the target component -} - -// TypeaheadItem is the model for an item in the typeahead. -type TypeaheadItem struct { - Value string // hidden identifier - Label string // visible text - New bool // if true this is a new item that didn't exist before -} - -// UnfilteredQueryFn is a function that returns typeahead items when no filter is active. -type UnfilteredQueryFn func() ([]TypeaheadItem, error) - -// FilteredQueryFn is a function that returns the typeahead items when a filter is active. -type FilteredQueryFn func(query string) ([]TypeaheadItem, error) - -// TypeaheadModeMsg is used to change to typeahead mode. -type TypeaheadModeMsg struct { - Name string // name of the target component - InputMode bool // if true the typeahead will be in input mode - Prompt string // the prompt to show - Text string // the text to start the input with - MaxChars int // the maximum number of chars to allow - UnfilteredQuery UnfilteredQueryFn // returns results when there isn't enough input - FilteredQuery FilteredQueryFn // returns results when there's enough input - MinFilterChars int // the minumum number of chars needed to filter - Operation string // the operation the typeahead is for - IncludeInput bool // if true include the current input as an option -} - -// TypeaheadConfirmedMsg is published when an option is selected.. -type TypeaheadConfirmedMsg struct { - Name string // name of the target typeahead - Value TypeaheadItem // the value that was selected - Operation string // the operation the typeahead is for -} - -// SelectionConfirmedMsg is published when an option selection is cancelled. -type TypeaheadCancelledMsg struct { - Name string // name of the target typeahead -} - -// ShowHelpMsg is used to bring up a help screen. -type ShowHelpMsg struct { - Name string // name of the target help screen -} - // SelectionChangedMsg is published when the table selection changes. type SelectionChangedMsg[T any] struct { - Name string // name of the target component + Name string // name of the target control Empty bool // whether the frame is empty Selection T // the selected item (if any) in the frame } // DataMsg is a message to update the data in the table. type DataMsg[T any] struct { - Name string // name of the target component + Name string // name of the target control Data []T //the data to show in the scrolltable Move Direction // optionally adjust the cursor } + +// InputChangedMsg is published when the value in an input changes. +type InputChangedMsg struct { + Name string // name of the target control +} + +// BreadcrumbsMsg is a message that changes the current breadcrumbs being displayed. +type BreadcrumbsMsg string + +// FolderMsg is a message that changes the current folder. +type FolderMsg string diff --git a/cmd/cli/tui/program.go b/cmd/cli/tui/program.go index 9601a13..de5bdf0 100644 --- a/cmd/cli/tui/program.go +++ b/cmd/cli/tui/program.go @@ -2,33 +2,58 @@ package tui import ( tea "github.com/charmbracelet/bubbletea" - "github.com/jonathanhope/armaria/cmd/cli/tui/booksview" - "github.com/jonathanhope/armaria/cmd/cli/tui/errorview" + "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" + "github.com/jonathanhope/armaria/cmd/cli/tui/views/booksview" + "github.com/jonathanhope/armaria/cmd/cli/tui/views/errorview" ) // model is an app level model that has multiple view models. // Only one view model is ever active at a time. type model struct { - books tea.Model // view to list books - error tea.Model // view to show an error + activeView msgs.View // which view is currently active + books tea.Model // view to list books + error tea.Model // view to show an error } // Update handles a message. // The message is passed down to the underlying view models. +// The exception to this is keypresses which are only passed to the current view. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var booksCmd tea.Cmd - m.books, booksCmd = m.books.Update(msg) - var errorCmd tea.Cmd - m.error, errorCmd = m.error.Update(msg) - return m, tea.Batch([]tea.Cmd{booksCmd, errorCmd}...) + if _, ok := msg.(tea.KeyMsg); ok { + switch m.activeView { + case msgs.ViewBooks: + m.books, booksCmd = m.books.Update(msg) + case msgs.ViewError: + m.error, errorCmd = m.error.Update(msg) + } + } else { + m.books, booksCmd = m.books.Update(msg) + m.error, errorCmd = m.error.Update(msg) + } + + switch msg := msg.(type) { + + case msgs.ViewMsg: + m.activeView = msgs.View(msg) + } + + return m, tea.Batch(booksCmd, errorCmd) } // View renders the model. // This renders and every underlying view model. -// However only one underlying view model will actually have content at a time. +// Only the currently active view is rendered. func (m model) View() string { + switch m.activeView { + case msgs.ViewBooks: + return m.books.View() + case msgs.ViewError: + return m.error.View() + } + return m.books.View() + m.error.View() } @@ -38,14 +63,15 @@ func (m model) Init() tea.Cmd { booksCmd := m.books.Init() errorCmd := m.error.Init() - return tea.Batch([]tea.Cmd{booksCmd, errorCmd}...) + return tea.Batch(booksCmd, errorCmd) } // Program is the top level TUI program. // This is how the TUI is started. func Program() *tea.Program { return tea.NewProgram(model{ - books: booksview.InitialModel(), - error: errorview.InitialModel(), + activeView: msgs.ViewBooks, + books: booksview.InitialModel(), + error: errorview.InitialModel(), }, tea.WithAltScreen()) } diff --git a/cmd/cli/tui/typeahead/typeahead.go b/cmd/cli/tui/typeahead/typeahead.go deleted file mode 100644 index abbdf5b..0000000 --- a/cmd/cli/tui/typeahead/typeahead.go +++ /dev/null @@ -1,239 +0,0 @@ -package typeahead - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" - "github.com/jonathanhope/armaria/cmd/cli/tui/scrolltable" - "github.com/jonathanhope/armaria/cmd/cli/tui/textinput" - "github.com/samber/lo" -) - -// typeaheadTable is the type of the underlying table in the typeahead. -type typeaheadTable = scrolltable.ScrolltableModel[msgs.TypeaheadItem] - -// TypeaheadModel is the model for a typeahead. -// The typpeahead allows the user to filter a list of things to select from by typing. -type TypeaheadModel struct { - name string // name of the typeahead - width int // max width of the typeahead - typeaheadMode bool // if true typeahead is accepting input - inputName string // the name of the input in the typeahead - tableName string // the name of the table in the typeahead - unfilteredQuery msgs.UnfilteredQueryFn // returns results when there isn't enough input - filteredQuery msgs.FilteredQueryFn // returns results when there's enough input - minFilterChars int // the minumum number of chars needed to filter - operation string // the operation the typeahead is for - includeInput bool // if true include the current input as an option - input textinput.TextInputModel // allows text input - table typeaheadTable // shows the options to select from -} - -// TypeaheadMode returns whether the typeahead is accepting input or not. -func (m TypeaheadModel) TypeaheadMode() bool { - return m.typeaheadMode -} - -// Text returns the text currently in the typeahead. -func (m TypeaheadModel) Text() string { - return m.input.Text() -} - -// InitialModel builds the model. -func InitialModel(name string) TypeaheadModel { - inputName := name + "Input" - tableName := name + "Table" - - return TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - input: textinput.InitialModel(inputName, ""), - table: scrolltable.InitialModel[msgs.TypeaheadItem]( - tableName, - true, - []scrolltable.ColumnDefinition[msgs.TypeaheadItem]{ - { - Mode: scrolltable.DynamicColumn, - Header: "", - RenderCell: func(item msgs.TypeaheadItem) string { - return item.Label - }, - Style: func(item msgs.TypeaheadItem, isSelected bool, isHeader bool) lipgloss.Style { - style := lipgloss. - NewStyle() - - if isSelected { - style = style.Bold(true).Underline(true) - } - - return style - }, - }, - }, - ), - } -} - -// Update handles a message. -func (m TypeaheadModel) Update(msg tea.Msg) (TypeaheadModel, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - - case msgs.InputChangedMsg: - if msg.Name == m.inputName { - cmds = append(cmds, m.loadItemsCmd()) - } - - case msgs.SizeMsg: - if msg.Name == m.name { - m.width = msg.Width - - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msgs.SizeMsg{ - Name: m.inputName, - Width: m.width, - }) - var tableCmd tea.Cmd - m.table, tableCmd = m.table.Update(msgs.SizeMsg{ - Name: m.tableName, - Width: m.width, - Height: msg.Height, - }) - cmds = append(cmds, inputCmd, tableCmd) - } - - case msgs.TypeaheadModeMsg: - if msg.Name == m.name { - m.typeaheadMode = msg.InputMode - m.unfilteredQuery = msg.UnfilteredQuery - m.filteredQuery = msg.FilteredQuery - m.minFilterChars = msg.MinFilterChars - m.operation = msg.Operation - m.includeInput = msg.IncludeInput - - if msg.InputMode { - cmds = append(cmds, func() tea.Msg { - return msgs.PromptMsg{Name: m.inputName, Prompt: msg.Prompt} - }, func() tea.Msg { - return msgs.TextMsg{Name: m.inputName, Text: msg.Text} - }, func() tea.Msg { - return msgs.FocusMsg{Name: m.inputName, MaxChars: msg.MaxChars} - }, m.loadItemsCmd()) - } else { - cmds = append(cmds, func() tea.Msg { - return msgs.BlurMsg{Name: m.inputName} - }, func() tea.Msg { - return msgs.PromptMsg{Name: m.inputName, Prompt: ""} - }, func() tea.Msg { - return msgs.TextMsg{Name: m.inputName, Text: ""} - }) - } - } - - case tea.KeyMsg: - if m.typeaheadMode { - switch msg.String() { - case "ctrl+c": - if m.typeaheadMode { - return m, tea.Quit - } - - case "esc": - cmds = append(cmds, func() tea.Msg { - return msgs.TypeaheadCancelledMsg{Name: m.name} - }) - - case "enter": - cmds = append(cmds, func() tea.Msg { - return msgs.TypeaheadConfirmedMsg{ - Name: m.name, - Value: m.table.Selection(), - Operation: m.operation, - } - }) - - case "up": - var tableCmd tea.Cmd - m.table, tableCmd = m.table.Update(msg) - cmds = append(cmds, tableCmd) - - case "down": - var tableCmd tea.Cmd - m.table, tableCmd = m.table.Update(msg) - cmds = append(cmds, tableCmd) - - default: - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msg) - cmds = append(cmds, inputCmd) - } - } - - default: - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msg) - - var tableCmd tea.Cmd - m.table, tableCmd = m.table.Update(msg) - - cmds = append(cmds, inputCmd, tableCmd) - } - - return m, tea.Batch(cmds...) -} - -// View renders the model. -func (m TypeaheadModel) View() string { - return m.input.View() + "\n\n" + m.table.View() -} - -// Init initializes the model. -func (m TypeaheadModel) Init() tea.Cmd { - return nil -} - -// loadItemsCmd loads the available option in the typeahead. -func (m TypeaheadModel) loadItemsCmd() tea.Cmd { - return func() tea.Msg { - if len(strings.Split(m.input.Text(), "")) >= m.minFilterChars { - items, err := m.filteredQuery(m.input.Text()) - if err != nil { - return msgs.ErrorMsg{Err: err} - } - - numMatch := len(lo.Filter(items, func(item msgs.TypeaheadItem, index int) bool { - return item.Label == m.input.Text() - })) - - if m.includeInput && numMatch == 0 { - items = append( - []msgs.TypeaheadItem{{Label: m.input.Text(), Value: m.input.Text(), New: true}}, - items...) - } - - return msgs.DataMsg[msgs.TypeaheadItem]{Name: m.tableName, Data: items, Move: msgs.DirectionStart} - - } else { - items, err := m.unfilteredQuery() - if err != nil { - return msgs.ErrorMsg{Err: err} - } - - numMatch := len(lo.Filter(items, func(item msgs.TypeaheadItem, index int) bool { - return item.Label == m.input.Text() - })) - - if m.includeInput && m.input.Text() != "" && numMatch == 0 { - items = append( - []msgs.TypeaheadItem{{Label: m.input.Text(), Value: m.input.Text(), New: true}}, - items...) - } - - return msgs.DataMsg[msgs.TypeaheadItem]{Name: m.tableName, Data: items, Move: msgs.DirectionStart} - } - } -} diff --git a/cmd/cli/tui/typeahead/typeahead_test.go b/cmd/cli/tui/typeahead/typeahead_test.go deleted file mode 100644 index 1299de9..0000000 --- a/cmd/cli/tui/typeahead/typeahead_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package typeahead - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" - "github.com/jonathanhope/armaria/cmd/cli/tui/utils" -) - -const name = "typeaheaed" -const inputName = "input" -const tableName = "table" -const operation = "operation" -const prompt = ">" -const text = "text" -const maxChars = 5 - -func TestTypeaheadMode(t *testing.T) { - model := TypeaheadModel{ - typeaheadMode: true, - } - - diff := cmp.Diff(model.TypeaheadMode(), true) - if diff != "" { - t.Errorf("Expected and actual typeaheadmode different") - } -} - -func TestCanSwitchToTypeaheadMode(t *testing.T) { - gotModel := TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - } - - gotModel, cmd := gotModel.Update(msgs.TypeaheadModeMsg{ - Name: name, - InputMode: true, - MinFilterChars: 3, - Operation: operation, - Prompt: prompt, - Text: "text", - MaxChars: 5, - UnfilteredQuery: func() ([]msgs.TypeaheadItem, error) { - return []msgs.TypeaheadItem{}, nil - }, - FilteredQuery: func(query string) ([]msgs.TypeaheadItem, error) { - return []msgs.TypeaheadItem{}, nil - }, - }) - - wantModel := TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - typeaheadMode: true, - minFilterChars: 3, - operation: operation, - } - - wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { return msgs.PromptMsg{Name: inputName, Prompt: prompt} }, - func() tea.Msg { return msgs.TextMsg{Name: inputName, Text: text} }, - func() tea.Msg { return msgs.FocusMsg{Name: inputName, MaxChars: maxChars} }, - func() tea.Msg { - return msgs.DataMsg[msgs.TypeaheadItem]{ - Name: tableName, - Data: []msgs.TypeaheadItem{}, - Move: msgs.DirectionStart, - } - }, - } - } - - verifyUpdate(t, gotModel, wantModel, cmd, wantCmd) -} - -func TestCanSwitchFromTypeaheadMode(t *testing.T) { - gotModel := TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - typeaheadMode: true, - minFilterChars: 3, - operation: operation, - } - - gotModel, cmd := gotModel.Update(msgs.TypeaheadModeMsg{ - Name: name, - InputMode: false, - }) - - wantModel := TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - typeaheadMode: false, - } - - wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { return msgs.BlurMsg{Name: inputName} }, - func() tea.Msg { return msgs.PromptMsg{Name: inputName, Prompt: ""} }, - func() tea.Msg { return msgs.TextMsg{Name: inputName, Text: ""} }, - } - } - - verifyUpdate(t, gotModel, wantModel, cmd, wantCmd) -} - -func TestCanCancelTypeahead(t *testing.T) { - gotModel := TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - typeaheadMode: true, - minFilterChars: 3, - operation: operation, - } - - gotModel, cmd := gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyEsc})) - - wantModel := TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - typeaheadMode: true, - minFilterChars: 3, - operation: operation, - } - - wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { return msgs.TypeaheadCancelledMsg{Name: name} }, - } - } - - verifyUpdate(t, gotModel, wantModel, cmd, wantCmd) -} - -func TestCanConfirmTypeahead(t *testing.T) { - gotModel := TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - typeaheadMode: true, - minFilterChars: 3, - operation: operation, - } - - gotModel, cmd := gotModel.Update(tea.KeyMsg(tea.Key{Type: tea.KeyEnter})) - - wantModel := TypeaheadModel{ - name: name, - inputName: inputName, - tableName: tableName, - typeaheadMode: true, - minFilterChars: 3, - operation: operation, - } - - wantCmd := func() tea.Msg { - return tea.BatchMsg{ - func() tea.Msg { return msgs.TypeaheadConfirmedMsg{Name: name, Operation: operation} }, - } - } - - verifyUpdate(t, gotModel, wantModel, cmd, wantCmd) -} - -func verifyUpdate(t *testing.T, gotModel TypeaheadModel, wantModel TypeaheadModel, gotCmd tea.Cmd, wantCmd tea.Cmd) { - unexported := cmp.AllowUnexported(TypeaheadModel{}) - modelDiff := cmp.Diff( - gotModel, - wantModel, - unexported, - cmpopts.IgnoreFields(TypeaheadModel{}, "input"), - cmpopts.IgnoreFields(TypeaheadModel{}, "table"), - cmpopts.IgnoreFields(TypeaheadModel{}, "filteredQuery"), - cmpopts.IgnoreFields(TypeaheadModel{}, "unfilteredQuery"), - ) - if modelDiff != "" { - t.Errorf("Expected and actual models different:\n%s", modelDiff) - } - - utils.CompareCommands(t, gotCmd, wantCmd) -} diff --git a/cmd/cli/tui/booksview/books.go b/cmd/cli/tui/views/booksview/books.go similarity index 51% rename from cmd/cli/tui/booksview/books.go rename to cmd/cli/tui/views/booksview/books.go index a2a3cee..ededbc3 100644 --- a/cmd/cli/tui/booksview/books.go +++ b/cmd/cli/tui/views/booksview/books.go @@ -8,12 +8,12 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/jonathanhope/armaria/cmd/cli/tui/footer" - "github.com/jonathanhope/armaria/cmd/cli/tui/header" - "github.com/jonathanhope/armaria/cmd/cli/tui/help" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/footer" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/header" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/help" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/scrolltable" + "github.com/jonathanhope/armaria/cmd/cli/tui/controls/typeahead" "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" - "github.com/jonathanhope/armaria/cmd/cli/tui/scrolltable" - "github.com/jonathanhope/armaria/cmd/cli/tui/typeahead" "github.com/jonathanhope/armaria/pkg/api" "github.com/jonathanhope/armaria/pkg/model" "github.com/samber/lo" @@ -27,11 +27,8 @@ const FooterName = "BooksFooter" // name of the footer const TableName = "BooksTable" // name of the table const HelpName = "BooksHelp" // name of the help screen const TypeaheadName = "BooksTypeahead" // name of the typeahead -const AddTagOperation = "AddTag" // operation to add a tag -const RemoveTagOp = "RemoveTag" // operation to remove tag -const ChangeParentOp = "ChangeParent" // operation to change parent -// InputType is which type of input is being collected. +// inputType is which type of input is being collected. type inputType int const ( @@ -43,28 +40,37 @@ const ( inputBookmark // collecting input to add a bookmark ) +// operation is which typeahead operation is being executed. +type operation int + +const ( + noOperation operation = iota // not currently in a typeahead + addTagOperation // using a typeahead to add a tag + removeTagOperation // using a typeahead to remove a tag + changeParentOperation // using a typeahead to change a books parent +) + // model is the model for the book listing. // The book listing displays the bookmarks in the bookmarks DB. type model struct { - activeView msgs.View // which view is currently being shown - inputType inputType // which type of input is being collected - width int // the current width of the screen - height int // the current height of the screen - folder string // the current folder - query string // current search query - header header.HeaderModel // header for app - footer footer.FooterModel // footer for app - table scrolltable.ScrolltableModel[armaria.Book] // table of books - help help.HelpModel // help for the app - typeahead typeahead.TypeaheadModel // typeahead for the app + inputType inputType // which type of input is being collected + operation operation // which typeahead operation is currently being executed + width int // the current width of the screen + height int // the current height of the screen + folder string // the current folder + query string // current search query + header header.HeaderModel // header for app + footer footer.FooterModel // footer for app + table scrolltable.ScrolltableModel[armaria.Book] // table of books + help help.HelpModel // help for the app + typeahead typeahead.TypeaheadModel // typeahead for the app } // InitialModel builds the model. func InitialModel() tea.Model { return model{ - activeView: msgs.ViewBooks, - header: header.InitialModel(HeaderName, "📜 Armaria"), - footer: footer.InitialModel(FooterName), + header: header.InitialModel(HeaderName, " Armaria"), + footer: footer.InitialModel(FooterName), table: scrolltable.InitialModel[armaria.Book]( TableName, false, @@ -183,374 +189,422 @@ func formatTags(book armaria.Book) string { return strings.Join(book.Tags, ", ") } -// Update handles a message. -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // If another view is active ignore all keypresses. - if _, ok := msg.(tea.KeyMsg); ok && m.activeView != msgs.ViewBooks { - return m, nil - } +// resize changes the size of the books view. +func (m *model) resize() { + tableHeight := m.height - + HeaderHeight - + HeaderSpacerHeight - + FooterHeight - // If the help screen is active direct all keypresses to it. - if _, ok := msg.(tea.KeyMsg); ok && m.help.HelpMode() { - var helpCmd tea.Cmd - m.help, helpCmd = m.help.Update(msg) - return m, helpCmd - } + typeaheadHeight := m.height - + HeaderHeight - + HeaderSpacerHeight - // If the footer is in input mode direct all keypresses to it. - if _, ok := msg.(tea.KeyMsg); ok && m.footer.InputMode() { - var footerCmd tea.Cmd - m.footer, footerCmd = m.footer.Update(msg) - return m, footerCmd - } + m.header.Resize(m.width) + m.footer.Resize(m.width) + m.table.Resize(m.width, tableHeight) + m.typeahead.Resize(m.width, typeaheadHeight) +} - // If the typeahead is in input mode direct all keypresses to it. - if _, ok := msg.(tea.KeyMsg); ok && m.typeahead.TypeaheadMode() { - var typeaheadCmd tea.Cmd - m.typeahead, typeaheadCmd = m.typeahead.Update(msg) - return m, typeaheadCmd +// updateFilters will upate the filters display in the header based on the current filters. +func (m *model) updateFilters() { + if len(m.query) > 0 { + m.footer.SetFilters([]string{fmt.Sprintf("Query: %s", m.query)}) + } else { + m.footer.SetFilters([]string{}) } +} - var footerCmd tea.Cmd - m.footer, footerCmd = m.footer.Update(msg) +// Update handles a message. +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { - var tableCmd tea.Cmd - m.table, tableCmd = m.table.Update(msg) + case tea.WindowSizeMsg: + m.height = msg.Height + m.width = msg.Width + m.resize() - var headerCmd tea.Cmd - m.header, headerCmd = m.header.Update(msg) + case msgs.DataMsg[armaria.Book]: + if msg.Name == TableName { + m.header.SetFree() + return m, m.table.Reload(msg.Data, msg.Move) + } - var helpCmd tea.Cmd - m.help, helpCmd = m.help.Update(msg) + case msgs.DataMsg[typeahead.TypeaheadItem]: + if msg.Name == m.typeahead.TableName() { + return m, m.typeahead.Reload(msg.Data, msg.Move) + } - var typeaheadCmd tea.Cmd - m.typeahead, typeaheadCmd = m.typeahead.Update(msg) + case msgs.SelectionChangedMsg[armaria.Book]: + if msg.Name == TableName && !m.table.Empty() { + return m, m.getBreadcrumbsCmd() + } - cmds := []tea.Cmd{tableCmd, headerCmd, footerCmd, helpCmd, typeaheadCmd} + case msgs.BreadcrumbsMsg: + m.header.SetBreadcrumbs(string(msg)) - switch msg := msg.(type) { + case msgs.FolderMsg: + m.folder = string(msg) + return m, m.getBooksCmd(msgs.DirectionStart) + + case msgs.InputChangedMsg: + if msg.Name == m.footer.InputName() && m.inputType == inputSearch { + m.query = m.footer.Text() + return m, m.getBooksCmd(msgs.DirectionStart) + } else if msg.Name == m.typeahead.InputName() { + return m, m.typeahead.LoadItemsCmd() + } case tea.KeyMsg: - switch msg.String() { + if m.footer.InputMode() { + switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit + case "ctrl+c": + return m, tea.Quit - case "?": - cmds = append(cmds, func() tea.Msg { return msgs.ShowHelpMsg{Name: HelpName} }) + case "backspace": + return m, m.footer.Delete() - case "enter": - if !m.table.Empty() && !m.header.Busy() { - if m.table.Selection().IsFolder { - m.folder = m.table.Selection().ID - cmds = append(cmds, m.getBooksCmd(msgs.DirectionStart)) - } else { - cmds = append(cmds, m.openURLCmd()) + case "left": + m.footer.MoveLeft() + + case "right": + m.footer.MoveRight() + + case "esc": + var cmd tea.Cmd + m.query = "" + m.footer.StopInputMode() + + if m.inputType == inputSearch { + cmd = m.getBooksCmd(msgs.DirectionStart) } - } - case "left": - if m.folder != "" && !m.header.Busy() { - cmds = append(cmds, m.getParentCmd()) - } + m.inputType = inputNone + return m, cmd + + case "enter": + var cmd tea.Cmd + + switch m.inputType { + case inputName: + m.header.SetBusy() + cmd = m.updateNameCmd(m.footer.Text()) + case inputURL: + m.header.SetBusy() + cmd = m.updateURLCmd(m.footer.Text()) + case inputFolder: + m.header.SetBusy() + cmd = m.addFolderCmd(m.footer.Text()) + case inputBookmark: + m.header.SetBusy() + cmd = m.addBookmarkCmd(m.footer.Text()) + } - case "right": - if !m.table.Empty() && m.table.Selection().IsFolder && !m.header.Busy() { - m.folder = m.table.Selection().ID - cmds = append(cmds, m.getBooksCmd(msgs.DirectionStart)) - } + m.footer.StopInputMode() + m.updateFilters() + m.inputType = inputNone - case "D": - if !m.table.Empty() && !m.header.Busy() { - cmds = append(cmds, m.deleteBookCmd(), func() tea.Msg { return msgs.BusyMsg{} }) - } + return m, cmd - case "c": - if m.query != "" && !m.header.Busy() { - m.query = "" - cmds = append( - cmds, - m.getBooksCmd(msgs.DirectionStart), - m.updateFiltersCmd(), - m.recalculateSizeCmd(), - ) + default: + return m, m.footer.Insert(msg.Runes) } + } else if m.typeahead.TypeaheadMode() { + switch msg.String() { - case "r": - if !m.header.Busy() { - cmds = append(cmds, m.getBooksCmd(msgs.DirectionNone)) - } + case "ctrl+c": + return m, tea.Quit - case "s": - if !m.header.Busy() { - m.inputType = inputSearch - cmds = append(cmds, m.inputStartCmd("Query: ", "", 0)) - } + case "left": + m.typeahead.MoveLeft() - case "u": - if !m.table.Empty() && !m.table.Selection().IsFolder && !m.header.Busy() { - m.inputType = inputURL - cmds = append(cmds, - m.inputStartCmd("URL: ", *m.table.Selection().URL, 2048), - func() tea.Msg { return msgs.BusyMsg{} }) - } + case "right": + m.typeahead.MoveRight() - case "n": - if !m.table.Empty() && !m.header.Busy() { - m.inputType = inputName - cmds = append(cmds, - m.inputStartCmd("Name: ", m.table.Selection().Name, 2048), - func() tea.Msg { return msgs.BusyMsg{} }) - } + case "up": + return m, m.typeahead.MoveUp() + + case "down": + return m, m.typeahead.MoveDown() + + case "backspace": + return m, m.typeahead.Delete() + + case "esc": + m.header.SetFree() + m.typeahead.StopTypeahead() + + case "enter": + value := m.typeahead.Selection() + m.typeahead.StopTypeahead() + + switch m.operation { + case addTagOperation: + return m, m.addTagCmd(value.Value) + case removeTagOperation: + return m, m.removeTagCmd(value.Value) + case changeParentOperation: + return m, m.changeParentCmd(value.Value) + } - case "+": - if !m.header.Busy() { - m.inputType = inputFolder - cmds = append(cmds, - m.inputStartCmd("Folder: ", "", 2048), - func() tea.Msg { return msgs.BusyMsg{} }) + default: + return m, m.typeahead.Insert(msg.Runes) } - case "b": - if !m.header.Busy() { - m.inputType = inputBookmark - cmds = append(cmds, - m.inputStartCmd("Bookmark: ", "", 2048), - func() tea.Msg { return msgs.BusyMsg{} }) + } else if m.help.HelpMode() { + switch msg.String() { + + case "q", "esc": + m.help.HideHelp() + + case "ctrl+c": + return m, tea.Quit } + } else { + switch msg.String() { + + case "ctrl+c", "q": + return m, tea.Quit + + case "?": + m.help.ShowHelp() + + case "up": + return m, m.table.MoveUp() + + case "down": + return m, m.table.MoveDown() - case "ctrl+up": - if m.query == "" && !m.table.Empty() && m.table.Index() > 0 && !m.header.Busy() { - if m.table.Index() == 1 { - next := m.table.Data()[0].ID - cmds = append(cmds, - m.moveToStartCmd(next, msgs.DirectionUp), - func() tea.Msg { return msgs.BusyMsg{} }) - } else { - previous := m.table.Data()[m.table.Index()-2].ID - next := m.table.Data()[m.table.Index()-1].ID - cmds = append(cmds, - m.moveBetweenCmd(previous, next, msgs.DirectionUp), - func() tea.Msg { return msgs.BusyMsg{} }) + case "left": + if m.folder != "" && !m.header.Busy() { + return m, m.getParentCmd() } - } - case "ctrl+down": - if m.query == "" && - !m.table.Empty() && - m.table.Index() < len(m.table.Data())-1 && - !m.header.Busy() { - if m.table.Index() == len(m.table.Data())-2 { - previous := m.table.Data()[len(m.table.Data())-1].ID - cmds = append(cmds, - m.moveToEndCmd(previous, msgs.DirectionDown), - func() tea.Msg { return msgs.BusyMsg{} }) - } else { - previous := m.table.Data()[m.table.Index()+1].ID - next := m.table.Data()[m.table.Index()+2].ID - cmds = append(cmds, - m.moveBetweenCmd(previous, next, msgs.DirectionDown), - func() tea.Msg { return msgs.BusyMsg{} }) + case "right": + if !m.table.Empty() && m.table.Selection().IsFolder && !m.header.Busy() { + m.folder = m.table.Selection().ID + return m, m.getBooksCmd(msgs.DirectionStart) } - } - case "t": - if !m.header.Busy() && !m.table.Empty() && !m.table.Selection().IsFolder { - cmds = append(cmds, m.typeaheadStartCmd( - "Add Tag: ", - "", - 128, - AddTagOperation, - true, - func() ([]msgs.TypeaheadItem, error) { - options := armariaapi.DefaultListTagsOptions() - tags, err := armariaapi.ListTags(options) - - if err != nil { - return nil, err - } - - items := lo.Map(tags, func(tag string, index int) msgs.TypeaheadItem { - return msgs.TypeaheadItem{Label: tag, Value: tag} - }) - - return items, nil - }, - func(query string) ([]msgs.TypeaheadItem, error) { - options := armariaapi.DefaultListTagsOptions().WithQuery(query) - tags, err := armariaapi.ListTags(options) - - if err != nil { - return nil, err - } - - items := lo.Map(tags, func(tag string, index int) msgs.TypeaheadItem { - return msgs.TypeaheadItem{Label: tag, Value: tag} - }) - - return items, nil - }, - )) - } + case "enter": + if !m.table.Empty() && !m.header.Busy() { + if m.table.Selection().IsFolder { + m.folder = m.table.Selection().ID + return m, m.getBooksCmd(msgs.DirectionStart) + } else { + return m, m.openURLCmd() + } + } - case "T": - if !m.header.Busy() && !m.table.Empty() && !m.table.Selection().IsFolder { - cmds = append(cmds, m.typeaheadStartCmd( - "Remove Tag: ", - "", - 128, - RemoveTagOp, - false, - func() ([]msgs.TypeaheadItem, error) { - items := lo.Map(m.table.Selection().Tags, func(tag string, index int) msgs.TypeaheadItem { - return msgs.TypeaheadItem{Label: tag, Value: tag} - }) - - return items, nil - }, - func(query string) ([]msgs.TypeaheadItem, error) { - tags := lo.Filter(m.table.Selection().Tags, func(tag string, index int) bool { - return strings.Contains(tag, query) - }) - - items := lo.Map(tags, func(tag string, index int) msgs.TypeaheadItem { - return msgs.TypeaheadItem{Label: tag, Value: tag} - }) - - return items, nil - }, - )) - } + case "r": + if !m.header.Busy() { + return m, m.getBooksCmd(msgs.DirectionNone) + } - case "p": - if !m.header.Busy() && !m.table.Empty() { - cmds = append(cmds, m.typeaheadStartCmd( - "Change Parent: ", - "", - 2048, - ChangeParentOp, - false, - func() ([]msgs.TypeaheadItem, error) { - options := armariaapi.DefaultListBooksOptions().WithFolders(true).WithBooks(false) - books, err := armariaapi.ListBooks(options) - - if err != nil { - return nil, err - } - - items := lo.Map(books, func(book armaria.Book, index int) msgs.TypeaheadItem { - return msgs.TypeaheadItem{Label: book.Name, Value: book.ID} - }) - - return items, nil - }, - func(query string) ([]msgs.TypeaheadItem, error) { - options := armariaapi. - DefaultListBooksOptions(). - WithFolders(true). - WithBooks(false). - WithQuery(query) - books, err := armariaapi.ListBooks(options) - - if err != nil { - return nil, err - } - - items := lo.Map(books, func(book armaria.Book, index int) msgs.TypeaheadItem { - return msgs.TypeaheadItem{Label: book.Name, Value: book.ID} - }) - - return items, nil - }, - )) - } + case "D": + if !m.table.Empty() && !m.header.Busy() { + m.header.SetBusy() + return m, m.deleteBookCmd() + } - case "P": - if !m.header.Busy() && !m.table.Empty() && m.table.Selection().ParentID != nil { - cmds = append(cmds, m.removeParentCmd()) - } - } + case "s": + if !m.header.Busy() { + m.inputType = inputSearch + m.footer.StartInputMode("Query: ", "", 0) + } - case msgs.SelectionChangedMsg[armaria.Book]: - if !m.table.Empty() { - cmds = append(cmds, m.getBreadcrumbsCmd()) - } + case "c": + if m.query != "" && !m.header.Busy() { + m.query = "" + m.updateFilters() + return m, m.getBooksCmd(msgs.DirectionNone) + } - case tea.WindowSizeMsg: - m.height = msg.Height - m.width = msg.Width + case "u": + if !m.table.Empty() && !m.table.Selection().IsFolder && !m.header.Busy() { + m.inputType = inputURL + m.footer.StartInputMode("URL: ", *m.table.Selection().URL, 2048) + m.header.SetBusy() + } - cmds = append(cmds, m.recalculateSizeCmd()) + case "n": + if !m.table.Empty() && !m.header.Busy() { + m.inputType = inputName + m.footer.StartInputMode("Name: ", m.table.Selection().Name, 2048) + m.header.SetBusy() + } - case msgs.FolderMsg: - m.folder = string(msg) - cmds = append(cmds, m.getBooksCmd(msgs.DirectionStart)) + case "+": + if !m.table.Empty() && !m.header.Busy() { + m.inputType = inputFolder + m.footer.StartInputMode("Folder: ", "", 2048) + m.header.SetBusy() + } - case msgs.ViewMsg: - m.activeView = msgs.View(msg) - return m, nil + case "b": + if !m.table.Empty() && !m.header.Busy() { + m.inputType = inputBookmark + m.footer.StartInputMode("Bookmark: ", "", 2048) + m.header.SetBusy() + } - case msgs.InputCancelledMsg: - if msg.Name == FooterName { - m.query = "" - m.inputType = inputNone - cmds = append(cmds, m.inputEndCmd()) - } + case "ctrl+up": + if m.query == "" && !m.table.Empty() && m.table.Index() > 0 && !m.header.Busy() { + m.header.SetBusy() + if m.table.Index() == 1 { + next := m.table.Data()[0].ID + return m, m.moveToStartCmd(next, msgs.DirectionUp) + } else { + previous := m.table.Data()[m.table.Index()-2].ID + next := m.table.Data()[m.table.Index()-1].ID + return m, m.moveBetweenCmd(previous, next, msgs.DirectionUp) + } + } - case msgs.InputConfirmedMsg: - if msg.Name == FooterName { - cmds = append(cmds, m.inputEndCmd()) - if m.inputType == inputName { - cmds = append(cmds, m. - updateNameCmd(m.footer.Text()), - func() tea.Msg { return msgs.BusyMsg{} }) - } else if m.inputType == inputURL { - cmds = append(cmds, - m.updateURLCmd(m.footer.Text()), - func() tea.Msg { return msgs.BusyMsg{} }) - } else if m.inputType == inputFolder { - cmds = append(cmds, - m.addFolderCmd(m.footer.Text()), - func() tea.Msg { return msgs.BusyMsg{} }) - } else if m.inputType == inputBookmark { - cmds = append(cmds, - m.addBookmarkCmd(m.footer.Text()), - func() tea.Msg { return msgs.BusyMsg{} }) - } + case "ctrl+down": + if m.query == "" && !m.table.Empty() && m.table.Index() < len(m.table.Data())-1 && !m.header.Busy() { + m.header.SetBusy() + if m.table.Index() == len(m.table.Data())-2 { + previous := m.table.Data()[len(m.table.Data())-1].ID + return m, m.moveToEndCmd(previous, msgs.DirectionDown) + } else { + previous := m.table.Data()[m.table.Index()+1].ID + next := m.table.Data()[m.table.Index()+2].ID + return m, m.moveBetweenCmd(previous, next, msgs.DirectionDown) + } + } - m.inputType = inputNone - } + case "t": + if !m.header.Busy() && !m.table.Empty() && !m.table.Selection().IsFolder { + m.operation = addTagOperation + m.header.SetBusy() + return m, m.typeahead.StartTypeahead(typeahead.StartTypeaheadPayload{ + Prompt: "Add Tag: ", + Text: "", + MaxChars: 128, + IncludeInput: true, + MinFilterChars: 3, + UnfilteredQuery: func() ([]typeahead.TypeaheadItem, error) { + options := armariaapi.DefaultListTagsOptions() + tags, err := armariaapi.ListTags(options) + + if err != nil { + return nil, err + } + + items := lo.Map(tags, func(tag string, index int) typeahead.TypeaheadItem { + return typeahead.TypeaheadItem{Label: tag, Value: tag} + }) + + return items, nil + }, + FilteredQuery: func(query string) ([]typeahead.TypeaheadItem, error) { + options := armariaapi.DefaultListTagsOptions().WithQuery(query) + tags, err := armariaapi.ListTags(options) + + if err != nil { + return nil, err + } + + items := lo.Map(tags, func(tag string, index int) typeahead.TypeaheadItem { + return typeahead.TypeaheadItem{Label: tag, Value: tag} + }) + + return items, nil + }, + }) + } - case msgs.TypeaheadCancelledMsg: - if msg.Name == TypeaheadName { - cmds = append(cmds, m.typeaheadEndCmd()) - } + case "T": + if !m.header.Busy() && !m.table.Empty() && !m.table.Selection().IsFolder { + m.operation = removeTagOperation + m.header.SetBusy() + return m, m.typeahead.StartTypeahead(typeahead.StartTypeaheadPayload{ + Prompt: "Remove Tag: ", + Text: "", + MaxChars: 128, + IncludeInput: false, + MinFilterChars: 3, + UnfilteredQuery: func() ([]typeahead.TypeaheadItem, error) { + items := lo.Map(m.table.Selection().Tags, func(tag string, index int) typeahead.TypeaheadItem { + return typeahead.TypeaheadItem{Label: tag, Value: tag} + }) + + return items, nil + }, + FilteredQuery: func(query string) ([]typeahead.TypeaheadItem, error) { + tags := lo.Filter(m.table.Selection().Tags, func(tag string, index int) bool { + return strings.Contains(tag, query) + }) + + items := lo.Map(tags, func(tag string, index int) typeahead.TypeaheadItem { + return typeahead.TypeaheadItem{Label: tag, Value: tag} + }) + + return items, nil + }, + }) + } - case msgs.TypeaheadConfirmedMsg: - if msg.Name == TypeaheadName && msg.Operation == AddTagOperation { - cmds = append(cmds, m.typeaheadEndCmd(), m.addTagCmd(msg.Value.Value)) - } else if msg.Name == TypeaheadName && msg.Operation == RemoveTagOp { - cmds = append(cmds, m.typeaheadEndCmd(), m.removeTagCmd(msg.Value.Value)) - } else if msg.Name == TypeaheadName && msg.Operation == ChangeParentOp { - cmds = append(cmds, m.typeaheadEndCmd(), m.changeParentCmd(msg.Value.Value)) - } + case "p": + if !m.header.Busy() && !m.table.Empty() { + m.operation = changeParentOperation + m.header.SetFree() + return m, m.typeahead.StartTypeahead(typeahead.StartTypeaheadPayload{ + Prompt: "Change Parent: ", + Text: "", + MaxChars: 2048, + IncludeInput: false, + MinFilterChars: 3, + UnfilteredQuery: func() ([]typeahead.TypeaheadItem, error) { + options := armariaapi.DefaultListBooksOptions().WithFolders(true).WithBooks(false) + books, err := armariaapi.ListBooks(options) + + if err != nil { + return nil, err + } + + items := lo.Map(books, func(book armaria.Book, index int) typeahead.TypeaheadItem { + return typeahead.TypeaheadItem{Label: book.Name, Value: book.ID} + }) + + return items, nil + }, + FilteredQuery: func(query string) ([]typeahead.TypeaheadItem, error) { + options := armariaapi. + DefaultListBooksOptions(). + WithFolders(true). + WithBooks(false). + WithQuery(query) + books, err := armariaapi.ListBooks(options) + + if err != nil { + return nil, err + } + + items := lo.Map(books, func(book armaria.Book, index int) typeahead.TypeaheadItem { + return typeahead.TypeaheadItem{Label: book.Name, Value: book.ID} + }) + + return items, nil + }, + }) + } - case msgs.InputChangedMsg: - if m.inputType == inputSearch { - m.query = m.footer.Text() - cmds = append(cmds, m.getBooksCmd(msgs.DirectionStart)) + case "P": + if !m.header.Busy() && !m.table.Empty() && m.table.Selection().ParentID != nil { + return m, m.removeParentCmd() + } + } } } - return m, tea.Batch(cmds...) + return m, nil } // View renders the model. func (m model) View() string { - if m.activeView != msgs.ViewBooks { - return "" - } - if m.help.HelpMode() { return m.header.View() + "\n\n" + m.help.View() } @@ -589,7 +643,7 @@ func (m model) Init() tea.Cmd { } // getBooksCmd is a command to get books from the bookmarks database. -func (m model) getBooksCmd(move msgs.Direction) tea.Cmd { +func (m *model) getBooksCmd(move msgs.Direction) tea.Cmd { return func() tea.Msg { options := armariaapi. DefaultListBooksOptions(). @@ -613,22 +667,6 @@ func (m model) getBooksCmd(move msgs.Direction) tea.Cmd { } } -// getParentCmd is a command to go one level up in the folder structure. -func (m model) getParentCmd() tea.Cmd { - return func() tea.Msg { - book, err := armariaapi.GetBook(m.folder, armariaapi.DefaultGetBookOptions()) - if err != nil { - return msgs.ErrorMsg{Err: err} - } - - if book.ParentID == nil { - return msgs.FolderMsg("") - } - - return msgs.FolderMsg(*book.ParentID) - } -} - // openURLCmd opens a bookmarks URL in the browser. func (m model) openURLCmd() tea.Cmd { return func() tea.Msg { @@ -668,6 +706,22 @@ func (m model) getBreadcrumbsCmd() tea.Cmd { } } +// getParentCmd is a command to go one level up in the folder structure. +func (m model) getParentCmd() tea.Cmd { + return func() tea.Msg { + book, err := armariaapi.GetBook(m.folder, armariaapi.DefaultGetBookOptions()) + if err != nil { + return msgs.ErrorMsg{Err: err} + } + + if book.ParentID == nil { + return msgs.FolderMsg("") + } + + return msgs.FolderMsg(*book.ParentID) + } +} + // deleteBookCmd deletes a bookmark or folder. func (m model) deleteBookCmd() tea.Cmd { return func() tea.Msg { @@ -764,125 +818,6 @@ func (m model) addBookmarkCmd(url string) tea.Cmd { } } -// updateFiltersCmd will upate the filters display in the header based on the current filters. -func (m model) updateFiltersCmd() tea.Cmd { - if len(m.query) > 0 { - return func() tea.Msg { - return msgs.FiltersMsg{Name: FooterName, Filters: []string{fmt.Sprintf("Query: %s", m.query)}} - } - } - - return func() tea.Msg { - return msgs.FiltersMsg{Name: FooterName, Filters: []string{}} - } -} - -// inputStartCmd makes the necessary state updates when the input mode starts. -func (m model) inputStartCmd(prompt string, text string, maxChars int) tea.Cmd { - return func() tea.Msg { - return msgs.InputModeMsg{ - Name: FooterName, - InputMode: true, - Prompt: prompt, - Text: text, - MaxChars: maxChars, - } - } -} - -// inputEndCmd makes the necessary state updates when the input mode ends. -func (m model) inputEndCmd() tea.Cmd { - cmds := []tea.Cmd{ - func() tea.Msg { - return msgs.InputModeMsg{ - Name: FooterName, - InputMode: false, - } - }, - m.getBooksCmd(msgs.DirectionNone), - m.updateFiltersCmd(), - m.recalculateSizeCmd(), - } - - return tea.Batch(cmds...) -} - -// typeaheadStartCmd makes the necessary state updates when the typeahead mode starts. -func (m model) typeaheadStartCmd(prompt string, text string, maxChars int, operation string, includeInput bool, unfilteredQuery msgs.UnfilteredQueryFn, filteredQuery msgs.FilteredQueryFn) tea.Cmd { - return func() tea.Msg { - return msgs.TypeaheadModeMsg{ - Name: TypeaheadName, - InputMode: true, - Prompt: prompt, - Text: text, - MaxChars: maxChars, - MinFilterChars: 3, - Operation: operation, - IncludeInput: includeInput, - UnfilteredQuery: unfilteredQuery, - FilteredQuery: filteredQuery, - } - } -} - -// typeaheadEndCmd makes the necessary state updates when the typeahead mode ends. -func (m model) typeaheadEndCmd() tea.Cmd { - cmds := []tea.Cmd{ - func() tea.Msg { - return msgs.TypeaheadModeMsg{ - Name: TypeaheadName, - InputMode: false, - } - }, - m.getBooksCmd(msgs.DirectionNone), - m.recalculateSizeCmd(), - } - - return tea.Batch(cmds...) -} - -// recalculateSizeCmd recalculates the size of the components. -// This needs to happen when the filters or the window size changes. -func (m model) recalculateSizeCmd() tea.Cmd { - tableHeight := m.height - - HeaderHeight - - HeaderSpacerHeight - - FooterHeight - - typeaheadHeight := m.height - - HeaderHeight - - HeaderSpacerHeight - - headerSizeMsg := msgs.SizeMsg{ - Name: HeaderName, - Width: m.width, - } - - footerSizeMsg := msgs.SizeMsg{ - Name: FooterName, - Width: m.width, - } - - tableSizeMsg := msgs.SizeMsg{ - Name: TableName, - Width: m.width, - Height: tableHeight, - } - - typeaheadSizeMsg := msgs.SizeMsg{ - Name: TypeaheadName, - Width: m.width, - Height: typeaheadHeight, - } - - return tea.Batch( - func() tea.Msg { return headerSizeMsg }, - func() tea.Msg { return footerSizeMsg }, - func() tea.Msg { return tableSizeMsg }, - func() tea.Msg { return typeaheadSizeMsg }, - ) -} - // moveToEndCmd moves a bookmark or folder to the end of the list. func (m model) moveToEndCmd(previous string, move msgs.Direction) tea.Cmd { return func() tea.Msg { @@ -966,6 +901,27 @@ func (m model) moveBetweenCmd(previous string, next string, move msgs.Direction) } } +// removeParentCmd removes the parent of a bookmark or folder. +func (m model) removeParentCmd() tea.Cmd { + return func() tea.Msg { + if m.table.Selection().IsFolder { + options := armariaapi.DefaultUpdateFolderOptions().WithoutParentID() + _, err := armariaapi.UpdateFolder(m.table.Selection().ID, options) + if err != nil { + return msgs.ErrorMsg{Err: err} + } + } else { + options := armariaapi.DefaultUpdateBookOptions().WithoutParentID() + _, err := armariaapi.UpdateBook(m.table.Selection().ID, options) + if err != nil { + return msgs.ErrorMsg{Err: err} + } + } + + return m.getBooksCmd(msgs.DirectionNone)() + } +} + // addTagCmd adds a tag to a bookmark. func (m model) addTagCmd(tag string) tea.Cmd { return func() tea.Msg { @@ -1010,24 +966,3 @@ func (m model) changeParentCmd(parentID string) tea.Cmd { return m.getBooksCmd(msgs.DirectionNone)() } } - -// removeParentCmd removes the parent of a bookmark or folder. -func (m model) removeParentCmd() tea.Cmd { - return func() tea.Msg { - if m.table.Selection().IsFolder { - options := armariaapi.DefaultUpdateFolderOptions().WithoutParentID() - _, err := armariaapi.UpdateFolder(m.table.Selection().ID, options) - if err != nil { - return msgs.ErrorMsg{Err: err} - } - } else { - options := armariaapi.DefaultUpdateBookOptions().WithoutParentID() - _, err := armariaapi.UpdateBook(m.table.Selection().ID, options) - if err != nil { - return msgs.ErrorMsg{Err: err} - } - } - - return m.getBooksCmd(msgs.DirectionNone)() - } -} diff --git a/cmd/cli/tui/errorview/error.go b/cmd/cli/tui/views/errorview/error.go similarity index 71% rename from cmd/cli/tui/errorview/error.go rename to cmd/cli/tui/views/errorview/error.go index 993decd..397e01a 100644 --- a/cmd/cli/tui/errorview/error.go +++ b/cmd/cli/tui/views/errorview/error.go @@ -11,16 +11,13 @@ import ( // model is the model for an error view. // The error view is used to display an error if one occurs. type model struct { - activeView msgs.View // which view is currently being shown - err error // the error that occurred - width int // the current width of the screen + err error // the error that occurred + width int // the current width of the screen } // InitialModel builds the model. func InitialModel() tea.Model { - return model{ - activeView: msgs.ViewBooks, - } + return model{} } // Update handles a message. @@ -30,9 +27,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width - case msgs.ViewMsg: - m.activeView = msgs.View(msg) - case msgs.ErrorMsg: m.err = msg.Err return m, func() tea.Msg { return msgs.ViewMsg(msgs.ViewError) } @@ -40,9 +34,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": - if m.activeView == msgs.ViewError { - return m, tea.Quit - } + return m, tea.Quit } } @@ -51,10 +43,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View renders the model. func (m model) View() string { - if m.activeView != msgs.ViewError { - return "" - } - return lipgloss. NewStyle(). Width(m.width). diff --git a/cmd/cli/tui/errorview/error_test.go b/cmd/cli/tui/views/errorview/error_test.go similarity index 77% rename from cmd/cli/tui/errorview/error_test.go rename to cmd/cli/tui/views/errorview/error_test.go index 9295215..2f58e06 100644 --- a/cmd/cli/tui/errorview/error_test.go +++ b/cmd/cli/tui/views/errorview/error_test.go @@ -11,20 +11,6 @@ import ( "github.com/jonathanhope/armaria/cmd/cli/tui/utils" ) -func TestHandlesViewMessage(t *testing.T) { - var gotModel tea.Model = model{ - activeView: msgs.ViewBooks, - } - - gotModel, gotCmd := gotModel.Update(msgs.ViewMsg(msgs.ViewError)) - - wantModel := model{ - activeView: msgs.ViewError, - } - - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) -} - func TestHandlesErrorMessage(t *testing.T) { err := errors.New("test error") var gotModel tea.Model = model{} diff --git a/go.mod b/go.mod index c9f3c47..602d28c 100644 --- a/go.mod +++ b/go.mod @@ -3,58 +3,63 @@ module github.com/jonathanhope/armaria go 1.21 require ( - github.com/alecthomas/kong v0.8.1 + github.com/alecthomas/kong v0.9.0 github.com/blockloop/scan/v2 v2.5.0 - github.com/charmbracelet/lipgloss v0.9.1 - github.com/cucumber/godog v0.13.0 + github.com/brianvoe/gofakeit/v6 v6.28.0 + github.com/charmbracelet/bubbletea v0.26.3 + github.com/charmbracelet/lipgloss v0.11.0 + github.com/cucumber/godog v0.14.1 github.com/google/go-cmp v0.6.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 - github.com/google/uuid v1.4.0 + github.com/google/uuid v1.6.0 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/file v0.1.0 github.com/knadh/koanf/providers/structs v0.1.0 - github.com/knadh/koanf/v2 v2.0.1 - github.com/mattn/go-sqlite3 v1.14.18 + github.com/knadh/koanf/v2 v2.1.1 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/muesli/reflow v0.3.0 github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d - github.com/nullism/bqb v1.7.1 - github.com/pressly/goose/v3 v3.16.0 - github.com/samber/lo v1.38.1 + github.com/nullism/bqb v1.7.2 + github.com/pressly/goose/v3 v3.20.0 + github.com/samber/lo v1.39.0 gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/brianvoe/gofakeit/v6 v6.26.0 // indirect - github.com/charmbracelet/bubbletea v0.24.2 // indirect - github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/charmbracelet/x/input v0.1.1 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.2 // indirect github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect github.com/cucumber/messages/go/v21 v21.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/structs v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/gofrs/uuid v4.3.1+incompatible // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-memdb v1.3.4 // indirect - github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/rivo/uniseg v0.4.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sethvargo/go-retry v0.2.4 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect ) diff --git a/go.sum b/go.sum index e3f5bbc..1c8d0d8 100644 --- a/go.sum +++ b/go.sum @@ -1,87 +1,58 @@ -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/ClickHouse/ch-go v0.58.2 h1:jSm2szHbT9MCAB1rJ3WuCJqmGLi5UTjlNu+f530UTS0= -github.com/ClickHouse/ch-go v0.58.2/go.mod h1:Ap/0bEmiLa14gYjCiRkYGbXvbe8vwdrfTYWhsuQ99aw= -github.com/ClickHouse/clickhouse-go/v2 v2.15.0 h1:G0hTKyO8fXXR1bGnZ0DY3vTG01xYfOGW76zgjg5tmC4= -github.com/ClickHouse/clickhouse-go/v2 v2.15.0/go.mod h1:kXt1SRq0PIRa6aKZD7TnFnY9PQKmc2b13sHtOYcK6cQ= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= -github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/alecthomas/assert/v2 v2.1.0 h1:tbredtNcQnoSd3QBhQWI7QZ3XHOVkw1Moklp2ojoH/0= -github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA= -github.com/alecthomas/kong v0.8.1 h1:acZdn3m4lLRobeh3Zi2S2EpnXTd1mOL6U7xVml+vfkY= -github.com/alecthomas/kong v0.8.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U= -github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE= -github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= +github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= +github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/blockloop/scan/v2 v2.5.0 h1:/yNcCwftYn3wf5BJsJFO9E9P48l45wThdUnM3WcDF+o= github.com/blockloop/scan/v2 v2.5.0/go.mod h1:OFYyMocUdRW3DUWehPI/fSsnpNMUNiyUaYXRMY5NMIY= -github.com/brianvoe/gofakeit/v6 v6.26.0 h1:DzJHo4K6RrAbglU6cReh+XqoaunuUMZ8OAQGXrYsXt8= -github.com/brianvoe/gofakeit/v6 v6.26.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= -github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= -github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= -github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= -github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/charmbracelet/bubbletea v0.26.3 h1:iXyGvI+FfOWqkB2V07m1DF3xxQijxjY2j8PqiXYqasg= +github.com/charmbracelet/bubbletea v0.26.3/go.mod h1:bpZHfDHTYJC5g+FBK+ptJRCQotRC+Dhh3AoMxa/2+3Q= +github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= +github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= +github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4= +github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= +github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= -github.com/cucumber/godog v0.13.0 h1:KvX9kNWmAJwp882HmObGOyBbNUP5SXQ+SDLNajsuV7A= -github.com/cucumber/godog v0.13.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces= +github.com/cucumber/godog v0.14.1 h1:HGZhcOyyfaKclHjJ+r/q93iaTJZLKYW6Tv3HkmUE6+M= +github.com/cucumber/godog v0.14.1/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces= github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/docker/cli v24.0.7+incompatible h1:wa/nIwYFW7BVTGa7SWPVyyXU9lgORqUb1xfI36MSkFg= -github.com/docker/cli v24.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM= -github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= -github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= -github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/elastic/go-sysinfo v1.11.1 h1:g9mwl05njS4r69TisC+vwHWTSKywZFYYUu3so3T/Lao= -github.com/elastic/go-sysinfo v1.11.1/go.mod h1:6KQb31j0QeWBDF88jIdWSxE8cwoOB9tO4Y4osN7Q70E= -github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0= -github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= -github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= -github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= -github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI= -github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= -github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= -github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c= +github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -91,29 +62,14 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= -github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.5.0 h1:NxstgwndsTRy7eq9/kqYc/BZh5w2hHJV86wjvO+1xPw= -github.com/jackc/pgx/v5 v5.5.0/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4= -github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak= -github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= -github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= -github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= @@ -122,8 +78,8 @@ github.com/knadh/koanf/providers/file v0.1.0 h1:fs6U7nrV58d3CFAFh8VTde8TM262ObYf github.com/knadh/koanf/providers/file v0.1.0/go.mod h1:rjJ/nHQl64iYCtAW2QQnF0eSmDEX/YZ/eNFj5yR6BvA= github.com/knadh/koanf/providers/structs v0.1.0 h1:wJRteCNn1qvLtE5h8KQBvLJovidSdntfdyIbbCzEyE0= github.com/knadh/koanf/providers/structs v0.1.0/go.mod h1:sw2YZ3txUcqA3Z27gPlmmBzWn1h8Nt9O6EP/91MkcWE= -github.com/knadh/koanf/v2 v2.0.1 h1:1dYGITt1I23x8cfx8ZnldtezdyaZtfAuRtIFOiRzK7g= -github.com/knadh/koanf/v2 v2.0.1/go.mod h1:ZeiIlIDXTE7w1lMT6UVcNiRAS2/rCeLn/GdLNvY1Dus= +github.com/knadh/koanf/v2 v2.1.1 h1:/R8eXqasSTsmDCsAyYj+81Wteg8AqrV9CP6gvsTsOmM= +github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -136,16 +92,14 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= -github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -156,50 +110,29 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d h1:NqRhLdNVlozULwM1B3VaHhcXYSgrOAv8V5BE65om+1Q= github.com/nathan-fiscaletti/consolesize-go v0.0.0-20220204101620-317176b6684d/go.mod h1:cxIIfNMTwff8f/ZvRouvWYF6wOoO7nj99neWSx2q/Es= -github.com/nullism/bqb v1.7.1 h1:n2BOwqJ3qggm/Z2CrNpzy9lWUqhyq7gy50xGVh+rdBw= -github.com/nullism/bqb v1.7.1/go.mod h1:4Z4vvPss9ms9dtLHpI4tUPtysmCAZfbm44lbsP3VDBY= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= -github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= -github.com/opencontainers/runc v1.1.10 h1:EaL5WeO9lv9wmS6SASjszOeQdSctvpbu0DdBQBizE40= -github.com/opencontainers/runc v1.1.10/go.mod h1:+/R6+KmDlh+hOO8NkjmgkG9Qzvypzk0yXxAPYYR65+M= -github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4= -github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg= -github.com/paulmach/orb v0.10.0 h1:guVYVqzxHE/CQ1KpfGO077TR0ATHSNjp4s6XGLn3W9s= -github.com/paulmach/orb v0.10.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nullism/bqb v1.7.2 h1:hsMZHqLDHXO64slhemqNWBa9Zy/fLiVNvi1orruZm/I= +github.com/nullism/bqb v1.7.2/go.mod h1:4Z4vvPss9ms9dtLHpI4tUPtysmCAZfbm44lbsP3VDBY= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= -github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pressly/goose/v3 v3.16.0 h1:xMJUsZdHLqSnCqESyKSqEfcYVYsUuup1nrOhaEFftQg= -github.com/pressly/goose/v3 v3.16.0/go.mod h1:JwdKVnmCRhnF6XLQs2mHEQtucFD49cQBdRM4UiwkxsM= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/pressly/goose/v3 v3.20.0 h1:uPJdOxF/Ipj7ABVNOAMJXSxwFXZGwMGHNqjC8e61VA0= +github.com/pressly/goose/v3 v3.20.0/go.mod h1:BRfF2GcG4FTG12QfdBVy3q1yveaf4ckL9vWwEcIO3lA= github.com/proullon/ramsql v0.0.1 h1:tI7qN48Oj1LTmgdo4aWlvI9z45a4QlWaXlmdJ+IIfbU= github.com/proullon/ramsql v0.0.1/go.mod h1:jG8oAQG0ZPHPyxg5QlMERS31airDC+ZuqiAe8DUvFVo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= -github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= -github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= -github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= -github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -211,80 +144,37 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/vertica/vertica-sql-go v1.3.3 h1:fL+FKEAEy5ONmsvya2WH5T8bhkvY27y/Ik3ReR2T+Qw= -github.com/vertica/vertica-sql-go v1.3.3/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= -github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd h1:dzWP1Lu+A40W883dK/Mr3xyDSM/2MggS8GtHT0qgAnE= -github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= -github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2 h1:E0yUuuX7UmPxXm92+yQCjMveLFO3zfvYFIJVuAqsVRA= -github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2/go.mod h1:fjBLQ2TdQNl4bMjuWl9adoTGBypwUTPoGC+EqYqiIcU= -go.opentelemetry.io/otel v1.20.0 h1:vsb/ggIY+hUjD/zCAQHpzTmndPqv/ml2ArbsbfBYTAc= -go.opentelemetry.io/otel v1.20.0/go.mod h1:oUIGj3D77RwJdM6PPZImDpSZGDvkD9fhesHny69JFrs= -go.opentelemetry.io/otel/trace v1.20.0 h1:+yxVAPZPbQhbC3OfAkeIVTky6iTFpcr4SiY9om7mXSQ= -go.opentelemetry.io/otel/trace v1.20.0/go.mod h1:HJSK7F/hA5RlzpZ0zKDCHCDHm556LCDtKaAo6JmBFUU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= -golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= -golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= -golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= -golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4= +golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= -golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= -golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14= -google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA= -google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= -google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 h1:FVCohIoYO7IJoDDVpV2pdq7SgrMH6wHnuTyrdrxJNoY= gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= -howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo= -lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= -modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= -modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= -modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= -modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= -modernc.org/libc v1.32.0 h1:yXatHTrACp3WaKNRCoZwUK7qj5V8ep1XyY0ka4oYcNc= -modernc.org/libc v1.32.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8= -modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= +modernc.org/sqlite v1.29.6 h1:0lOXGrycJPptfHDuohfYgNqoe4hu+gYuN/pKgY5XjS4= +modernc.org/sqlite v1.29.6/go.mod h1:S02dvcmm7TnTRvGhv8IGYyLnIt7AS2KPaB1F/71p75U= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=