diff --git a/TODO.org b/TODO.org index d80962b..db2ab96 100644 --- a/TODO.org +++ b/TODO.org @@ -2,3 +2,4 @@ This is a running list of things that need to taken care of at some point: - [ ] Long subtract function too complex - [ ] Manifest requires registry key on Windows + - [ ] Investigate double width chars and substr (think emojis) diff --git a/cmd/cli/tui/booksview/books.go b/cmd/cli/tui/booksview/books.go index abd7d39..6791c16 100644 --- a/cmd/cli/tui/booksview/books.go +++ b/cmd/cli/tui/booksview/books.go @@ -13,17 +13,22 @@ import ( "github.com/jonathanhope/armaria/cmd/cli/tui/help" "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" ) -const HeaderHeight = 3 // height of the header -const HeaderSpacerHeight = 1 // height of the spacer between the header and table -const FooterHeight = 4 // height of the footer -const HeaderName = "BooksHeader" // name of the header -const FooterName = "BooksFooter" // name of the footer -const TableName = "BooksTable" // name of the table -const HelpName = "BooksHelp" // name of the help screen +const HeaderHeight = 3 // height of the header +const HeaderSpacerHeight = 1 // height of the spacer between the header and table +const FooterHeight = 4 // height of the footer +const HeaderName = "BooksHeader" // name of the header +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 RemoveTagOperation = "RemoveTag" // operation to remove tag // InputType is which type of input is being collected. type inputType int @@ -50,7 +55,7 @@ type model struct { 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. @@ -61,6 +66,7 @@ func InitialModel() tea.Model { footer: footer.InitialModel(FooterName), table: scrolltable.InitialModel[armaria.Book]( TableName, + false, []scrolltable.ColumnDefinition[armaria.Book]{ { Mode: scrolltable.StaticColumn, @@ -105,6 +111,8 @@ func InitialModel() tea.Model { {Context: "Listing", Key: "n", Help: "Edit name"}, {Context: "Listing", Key: "+", Help: "Add folder"}, {Context: "Listing", Key: "b", Help: "Add bookmark"}, + {Context: "Listing", Key: "t", Help: "Add tag"}, + {Context: "Listing", Key: "T", Help: "Remove tag"}, {Context: "Listing", Key: "q", Help: "Quit"}, {Context: "Input", Key: "left", Help: "Move to previous char"}, {Context: "Input", Key: "right", Help: "Move to next char"}, @@ -112,6 +120,7 @@ func InitialModel() tea.Model { {Context: "Input", Key: "esc", Help: "Cancel input"}, }, ), + typeahead: typeahead.InitialModel(TypeaheadName), } } @@ -192,6 +201,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, footerCmd } + // 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 + } + var footerCmd tea.Cmd m.footer, footerCmd = m.footer.Update(msg) @@ -204,7 +220,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var helpCmd tea.Cmd m.help, helpCmd = m.help.Update(msg) - cmds := []tea.Cmd{tableCmd, headerCmd, footerCmd, helpCmd} + var typeaheadCmd tea.Cmd + m.typeahead, typeaheadCmd = m.typeahead.Update(msg) + + cmds := []tea.Cmd{tableCmd, headerCmd, footerCmd, helpCmd, typeaheadCmd} switch msg := msg.(type) { @@ -331,6 +350,44 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func() tea.Msg { return msgs.BusyMsg{} }) } } + + case "t": + if !m.header.Busy() && !m.table.Empty() && !m.table.Selection().IsFolder { + cmds = append(cmds, m.typeaheadStartCmd( + "Add Tag: ", + "", + 128, + AddTagOperation, + true, + func() ([]string, error) { + options := armariaapi.DefaultListTagsOptions() + return armariaapi.ListTags(options) + }, + func(query string) ([]string, error) { + options := armariaapi.DefaultListTagsOptions().WithQuery(query) + return armariaapi.ListTags(options) + }, + )) + } + + case "T": + cmds = append(cmds, m.typeaheadStartCmd( + "Remove Tag: ", + "", + 128, + RemoveTagOperation, + false, + func() ([]string, error) { + return m.table.Selection().Tags, nil + }, + func(query string) ([]string, error) { + tags := lo.Filter(m.table.Selection().Tags, func(tag string, index int) bool { + return strings.Contains(tag, query) + }) + + return tags, nil + }, + )) } case msgs.SelectionChangedMsg[armaria.Book]: @@ -353,31 +410,47 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case msgs.InputCancelledMsg: - m.query = "" - m.inputType = inputNone - cmds = append(cmds, m.inputEndCmd(), m.updateFiltersCmd()) + if msg.Name == FooterName { + m.query = "" + m.inputType = inputNone + cmds = append(cmds, m.inputEndCmd()) + } case msgs.InputConfirmedMsg: - 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{} }) + 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{} }) + } + + m.inputType = inputNone } - m.inputType = inputNone + case msgs.TypeaheadCancelledMsg: + if msg.Name == TypeaheadName { + cmds = append(cmds, m.typeaheadEndCmd()) + } + + case msgs.TypeaheadConfirmedMsg: + if msg.Name == TypeaheadName && msg.Operation == AddTagOperation { + cmds = append(cmds, m.typeaheadEndCmd(), m.addTag(msg.Value)) + } else if msg.Name == TypeaheadName && msg.Operation == RemoveTagOperation { + cmds = append(cmds, m.typeaheadEndCmd(), m.removeTag(msg.Value)) + } case msgs.InputChangedMsg: if m.inputType == inputSearch { @@ -399,6 +472,10 @@ func (m model) View() string { return m.header.View() + "\n\n" + m.help.View() } + if m.typeahead.TypeaheadMode() { + return m.header.View() + "\n\n" + m.typeahead.View() + } + header := m.header.View() _, headerHeight := lipgloss.Size(header) @@ -504,7 +581,7 @@ func (m model) getBreadcrumbsCmd() tea.Cmd { return msgs.ErrorMsg{Err: err} } - return msgs.NavMsg(strings.Join(parents, " > ")) + return msgs.BreadcrumbsMsg(strings.Join(parents, " > ")) } } @@ -619,17 +696,15 @@ func (m model) updateFiltersCmd() tea.Cmd { // inputStartCmd makes the necessary state updates when the input mode starts. func (m model) inputStartCmd(prompt string, text string, maxChars int) tea.Cmd { - return tea.Batch( - func() tea.Msg { - return msgs.InputModeMsg{ - Name: FooterName, - InputMode: true, - Prompt: prompt, - Text: text, - MaxChars: maxChars, - } - }, - ) + 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. @@ -649,14 +724,52 @@ func (m model) inputEndCmd() tea.Cmd { 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 func() ([]string, error), filteredQuery func(query string) ([]string, error)) 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 { - height := m.height - + tableHeight := m.height - HeaderHeight - HeaderSpacerHeight - FooterHeight + typeaheadHeight := m.height - + HeaderHeight - + HeaderSpacerHeight + headerSizeMsg := msgs.SizeMsg{ Name: HeaderName, Width: m.width, @@ -670,13 +783,20 @@ func (m model) recalculateSizeCmd() tea.Cmd { tableSizeMsg := msgs.SizeMsg{ Name: TableName, Width: m.width, - Height: height, + 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 }, ) } @@ -745,7 +865,6 @@ func (m model) moveBetweenCmd(previous string, next string, move msgs.Direction) _, err := armariaapi.UpdateFolder(m.table.Selection().ID, options) if err != nil { - fmt.Println(err) return msgs.ErrorMsg{Err: err} } } else { @@ -756,7 +875,6 @@ func (m model) moveBetweenCmd(previous string, next string, move msgs.Direction) _, err := armariaapi.UpdateBook(m.table.Selection().ID, options) if err != nil { - fmt.Println(err) return msgs.ErrorMsg{Err: err} } } @@ -764,3 +882,27 @@ func (m model) moveBetweenCmd(previous string, next string, move msgs.Direction) return m.getBooksCmd(move)() } } + +// addTag adds a tag to a bookmark. +func (m model) addTag(tag string) tea.Cmd { + return func() tea.Msg { + options := armariaapi.DefaultAddTagsOptions() + _, err := armariaapi.AddTags(m.table.Selection().ID, []string{tag}, options) + if err != nil { + return msgs.ErrorMsg{Err: err} + } + return m.getBooksCmd(msgs.DirectionNone)() + } +} + +// removeTag removes a tag from a bookmark. +func (m model) removeTag(tag string) tea.Cmd { + return func() tea.Msg { + options := armariaapi.DefaultRemoveTagsOptions() + _, err := armariaapi.RemoveTags(m.table.Selection().ID, []string{tag}, options) + if err != nil { + return msgs.ErrorMsg{Err: err} + } + return m.getBooksCmd(msgs.DirectionNone)() + } +} diff --git a/cmd/cli/tui/footer/footer.go b/cmd/cli/tui/footer/footer.go index d7e021c..6298475 100644 --- a/cmd/cli/tui/footer/footer.go +++ b/cmd/cli/tui/footer/footer.go @@ -53,7 +53,7 @@ func (m FooterModel) Update(msg tea.Msg) (FooterModel, tea.Cmd) { case msgs.SizeMsg: if msg.Name == m.name { m.width = msg.Width - } else { + var inputCmd tea.Cmd m.input, inputCmd = m.input.Update(msgs.SizeMsg{ Name: m.inputName, @@ -67,21 +67,9 @@ func (m FooterModel) Update(msg tea.Msg) (FooterModel, tea.Cmd) { m.inputMode = msg.InputMode if m.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} - }) + cmds = append(cmds, m.startInputCmd(msg.Prompt, msg.Text, msg.MaxChars)) } 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: ""} - }) + cmds = append(cmds, m.endInputCmd()) } } @@ -100,13 +88,15 @@ func (m FooterModel) Update(msg tea.Msg) (FooterModel, tea.Cmd) { case "esc": cmds = append(cmds, func() tea.Msg { - return msgs.InputCancelledMsg{} + return msgs.InputCancelledMsg{Name: m.name} }) case "enter": - cmds = append(cmds, func() tea.Msg { - return msgs.InputConfirmedMsg{} - }) + if m.input.Text() != "" { + cmds = append(cmds, func() tea.Msg { + return msgs.InputConfirmedMsg{Name: m.name} + }) + } default: var inputCmd tea.Cmd @@ -148,7 +138,7 @@ func (m FooterModel) View() string { filters = "No filters applied" } if m.width > 0 { - filters = utils.Substr(filters, 0, m.width-4) + filters = utils.Substr(filters, m.width-4) } filtersStyle := lipgloss.NewStyle(). @@ -165,3 +155,25 @@ 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/footer/footer_test.go b/cmd/cli/tui/footer/footer_test.go index 0de1795..820cc53 100644 --- a/cmd/cli/tui/footer/footer_test.go +++ b/cmd/cli/tui/footer/footer_test.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -115,7 +116,7 @@ func TestCanCancelInput(t *testing.T) { wantCmd := func() tea.Msg { return tea.BatchMsg{ - func() tea.Msg { return msgs.InputCancelledMsg{} }, + func() tea.Msg { return msgs.InputCancelledMsg{Name: Name} }, } } @@ -127,7 +128,19 @@ func TestCanConfirmInput(t *testing.T) { 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{ @@ -138,7 +151,7 @@ func TestCanConfirmInput(t *testing.T) { wantCmd := func() tea.Msg { return tea.BatchMsg{ - func() tea.Msg { return msgs.InputConfirmedMsg{} }, + func() tea.Msg { return msgs.InputConfirmedMsg{Name: Name} }, } } diff --git a/cmd/cli/tui/header/header.go b/cmd/cli/tui/header/header.go index 1550dfa..e7a0299 100644 --- a/cmd/cli/tui/header/header.go +++ b/cmd/cli/tui/header/header.go @@ -40,7 +40,7 @@ func (m HeaderModel) Update(msg tea.Msg) (HeaderModel, tea.Cmd) { m.width = msg.Width } - case msgs.NavMsg: + case msgs.BreadcrumbsMsg: m.nav = string(msg) case msgs.BusyMsg: @@ -66,7 +66,7 @@ func (m HeaderModel) View() string { } rows := [][]string{ - {title, utils.Substr(m.nav, 0, cellTextWidth)}, + {title, utils.Substr(m.nav, cellTextWidth)}, } titleNavStyle := lipgloss. diff --git a/cmd/cli/tui/header/header_test.go b/cmd/cli/tui/header/header_test.go index bb49238..18b4629 100644 --- a/cmd/cli/tui/header/header_test.go +++ b/cmd/cli/tui/header/header_test.go @@ -27,7 +27,7 @@ func TestCanUpdateWidth(t *testing.T) { func TestCanUpdateNav(t *testing.T) { gotModel := HeaderModel{} - gotModel, gotCmd := gotModel.Update(msgs.NavMsg("nav")) + gotModel, gotCmd := gotModel.Update(msgs.BreadcrumbsMsg("nav")) wantModel := HeaderModel{ nav: "nav", diff --git a/cmd/cli/tui/msgs/books.go b/cmd/cli/tui/msgs/books.go deleted file mode 100644 index bf6b553..0000000 --- a/cmd/cli/tui/msgs/books.go +++ /dev/null @@ -1,4 +0,0 @@ -package msgs - -// FolderMsg is a message that changes the current folder. -type FolderMsg string diff --git a/cmd/cli/tui/msgs/footer.go b/cmd/cli/tui/msgs/footer.go deleted file mode 100644 index 2c19ef9..0000000 --- a/cmd/cli/tui/msgs/footer.go +++ /dev/null @@ -1,22 +0,0 @@ -package msgs - -// FocusMsg is used to set whether the footer is in input mode or not. -type InputModeMsg struct { - Name string // name of the target footer - 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{} - -// InputConfirmedMsg is published when the user cancels input. -type InputConfirmedMsg struct{} - -// FiltersMsg is published when the filters change. -type FiltersMsg struct { - Name string // name of the target footer - Filters []string // current set of filters -} diff --git a/cmd/cli/tui/msgs/header.go b/cmd/cli/tui/msgs/header.go deleted file mode 100644 index 6127a55..0000000 --- a/cmd/cli/tui/msgs/header.go +++ /dev/null @@ -1,4 +0,0 @@ -package msgs - -// NavMsg is a message that changes the nav display in the header. -type NavMsg string diff --git a/cmd/cli/tui/msgs/help.go b/cmd/cli/tui/msgs/help.go deleted file mode 100644 index 3ba425a..0000000 --- a/cmd/cli/tui/msgs/help.go +++ /dev/null @@ -1,6 +0,0 @@ -package msgs - -// ShowHelpMsg is used to bring up a help screen. -type ShowHelpMsg struct { - Name string // name of the target help screen -} diff --git a/cmd/cli/tui/msgs/msgs.go b/cmd/cli/tui/msgs/msgs.go new file mode 100644 index 0000000..9787fd1 --- /dev/null +++ b/cmd/cli/tui/msgs/msgs.go @@ -0,0 +1,150 @@ +package msgs + +// Direction is a direction movement can occur in. +type Direction int + +const ( + DirectionNone Direction = iota // don't move + DirectionUp // move up + DirectionDown // move down + DirectionStart // move to the start +) + +// View is which View to show. +type View string + +const ( + ViewBooks View = "books" // show a listing of books + ViewError View = "error" // show an error +) + +// ErrorMsg is a message that contains an error. +type ErrorMsg struct{ Err error } + +// Error stringifies ErrorMsg. +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 +} + +// 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 func() ([]string, error) // returns results when there isn't enough input + FilteredQuery func(query string) ([]string, error) // 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 string // 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 + 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 + Data []T //the data to show in the scrolltable + Move Direction // optionally adjust the cursor +} diff --git a/cmd/cli/tui/msgs/scrolltable.go b/cmd/cli/tui/msgs/scrolltable.go deleted file mode 100644 index b709d03..0000000 --- a/cmd/cli/tui/msgs/scrolltable.go +++ /dev/null @@ -1,25 +0,0 @@ -package msgs - -// SelectionChangedMsg is published when the scrolltable selection changes. -type SelectionChangedMsg[T any] struct { - Name string // name of the target srolltable - 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 scrolltable. -type DataMsg[T any] struct { - Name string // name of the target scrolltable - Data []T //the data to show in the scrolltable - Move Direction // optionally adjust the cursor -} - -// Direction is a direction the cursor can move on the scrolltable.. -type Direction int - -const ( - DirectionNone Direction = iota // don't move - DirectionUp // move up the table - DirectionDown // move down the table - DirectionStart // move to the start of the table -) diff --git a/cmd/cli/tui/msgs/shared.go b/cmd/cli/tui/msgs/shared.go deleted file mode 100644 index cda35dc..0000000 --- a/cmd/cli/tui/msgs/shared.go +++ /dev/null @@ -1,31 +0,0 @@ -package msgs - -// ErrorMsg is a message that contains an error. -type ErrorMsg struct{ Err error } - -// Error stringifies ErrorMsg. -func (e ErrorMsg) Error() string { return e.Err.Error() } - -// View is which View to show. -type View string - -const ( - ViewBooks View = "books" // show a listing of books - ViewError View = "error" // show an 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{} diff --git a/cmd/cli/tui/msgs/textinput.go b/cmd/cli/tui/msgs/textinput.go deleted file mode 100644 index fc7cd66..0000000 --- a/cmd/cli/tui/msgs/textinput.go +++ /dev/null @@ -1,34 +0,0 @@ -package msgs - -// FocusMsg is used to focus a textinput. -type FocusMsg struct { - Name string // name of the target textinput - MaxChars int // maximum number of chars to allow -} - -// InputChangedMsg is published when the value in a textinput changes. -type InputChangedMsg struct { - Name string // name of the target textinput -} - -// BlinkMsg is a message that causes the cursor to blink. -type BlinkMsg struct { - Name string // name of the target textinput -} - -// BlurMsg is published when the textinput blurs. -type BlurMsg struct { - Name string // name of the target textinput -} - -// TextMsg sets the text of a textinput. -type TextMsg struct { - Name string // name of the target textinput - Text string // text to set on the textinput -} - -// PromptMsg sets the prompt of a textinput. -type PromptMsg struct { - Name string // name of the target textinput - Prompt string // text to set on the textinput -} diff --git a/cmd/cli/tui/scrolltable/scrolltable.go b/cmd/cli/tui/scrolltable/scrolltable.go index 6dc57b7..c767336 100644 --- a/cmd/cli/tui/scrolltable/scrolltable.go +++ b/cmd/cli/tui/scrolltable/scrolltable.go @@ -44,13 +44,15 @@ type ScrolltableModel[T any] struct { height int // max height the scrolltable can occupy cursor int // index of selected datum in the visible frame frameStart int // start of the visible frame + hideHeader bool // if true hide the header } // InitialModel builds a scrolltable model. -func InitialModel[T any](name string, columnDefinitions []ColumnDefinition[T]) ScrolltableModel[T] { +func InitialModel[T any](name string, hideHeader bool, columnDefinitions []ColumnDefinition[T]) ScrolltableModel[T] { return ScrolltableModel[T]{ name: name, columnDefinitions: columnDefinitions, + hideHeader: hideHeader, } } @@ -185,7 +187,10 @@ func (m ScrolltableModel[T]) Update(msg tea.Msg) (ScrolltableModel[T], tea.Cmd) m.data = msg.Data m.resetFrame(msg.Move) - return m, tea.Batch(m.selectionChangedCmd(), func() tea.Msg { return msgs.FreeMsg{} }) + return m, tea.Batch( + m.selectionChangedCmd(), + func() tea.Msg { return msgs.FreeMsg{} }, + ) } case tea.KeyMsg: @@ -235,7 +240,7 @@ func (m ScrolltableModel[T]) View() string { cell := def.RenderCell(datum) if def.Mode == DynamicColumn { // Dynamic columns need to have their string truncated if it's too long. - cell = utils.Substr(cell, 0, dynamicColumnTextWidth) + cell = utils.Substr(cell, dynamicColumnTextWidth) } return cell }) @@ -243,9 +248,12 @@ func (m ScrolltableModel[T]) View() string { rows = append(rows, row) }) - headers := lo.Map(m.columnDefinitions, func(def ColumnDefinition[T], _ int) string { - return def.Header - }) + var headers []string + if !m.hideHeader { + headers = lo.Map(m.columnDefinitions, func(def ColumnDefinition[T], _ int) string { + return def.Header + }) + } booksTable := table.New(). Border(lipgloss.NormalBorder()). diff --git a/cmd/cli/tui/textinput/textinput.go b/cmd/cli/tui/textinput/textinput.go index 3df4c85..1f46060 100644 --- a/cmd/cli/tui/textinput/textinput.go +++ b/cmd/cli/tui/textinput/textinput.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" + "github.com/muesli/reflow/ansi" ) const BlinkSpeed = 600 // how quickly to blink the cursor @@ -15,16 +16,18 @@ 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 text input - focus bool // whether the text input is focused or not - blink bool // flag that alternates in order to make the cursor blink - frameStart int // where the viewable frame of text starts - maxChars int // maximum number of chars to allow - sleeper sleeper // used to sleep + 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 } // InitialModel builds the model. @@ -36,18 +39,209 @@ func InitialModel(name string, prompt string) TextInputModel { } } -// Text returns the curent text in the input. +// Text returns the current text in the input. func (m *TextInputModel) Text() string { return m.text } -// toEnd moves the cursor to the end of the textinput. -func (m *TextInputModel) toEnd() { - m.cursor = len(m.text) - if m.cursor > m.available()-1 { - diff := m.cursor - (m.available() - 1) - m.frameStart += diff - m.cursor = m.available() - 1 +// 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 { + return m.text + " " +} + +// windowWidth returns the available width for text. +// The avaialble width is the overall width less the measured prompt width. +func (m TextInputModel) windowWidth() int { + width := m.width - ansi.PrintableRuneWidth(m.prompt) - Padding*2 + if width < 0 { + width = 0 + } + + return width +} + +// window returns the currently visible part of the text. +func (m *TextInputModel) window() string { + if m.textWithSpace() == " " || m.width == 0 { + return " " + } + + textRunes := strings.Split(m.textWithSpace(), "") + start := m.windowStart + end := m.windowEnd + if end > len(textRunes) { + end = len(textRunes) + } + + windowRunes := textRunes[start:end] + return strings.Join(windowRunes, "") +} + +// initWindow intializes the window after the text changes. +func (m *TextInputModel) initWindow() { + m.windowStart = 0 + m.windowEnd = m.windowWidth() + m.cursor = 0 + m.index = 0 + m.moveEnd() + m.chopLeft() +} + +// cursorAtStart returns true if the cursor is at the start of the window. +func (m *TextInputModel) cursorAtStart() bool { + return m.cursor == 0 +} + +// cursorAtEnd returns true if the cursor is at the end of the window. +func (m *TextInputModel) cursorAtEnd() bool { + windowRunes := strings.Split(m.window(), "") + return m.cursor == len(windowRunes)-1 +} + +// indexAtStart returns true if the cursor is at the start of the text. +func (m *TextInputModel) indexAtStart() bool { + return m.index == 0 +} + +// indexAtEnd returns true if the cursor is at the end of the text. +func (m *TextInputModel) indexAtEnd() bool { + textRunes := strings.Split(m.textWithSpace(), "") + return m.index == len(textRunes)-1 +} + +// chopRight chops runes off the right of the window to make it fit. +func (m *TextInputModel) chopRight() { + textRunes := strings.Split(m.textWithSpace(), "") + for ansi.PrintableRuneWidth(m.window()) < m.windowWidth() && m.windowEnd < len(textRunes) { + m.windowEnd += 1 + } + + for ansi.PrintableRuneWidth(m.window()) > m.windowWidth() { + m.windowEnd -= 1 + } +} + +// chopLeft chops runes off the left of the window to make it fit. +func (m *TextInputModel) chopLeft() { + for ansi.PrintableRuneWidth(m.window()) < m.windowWidth() && m.windowStart > 0 { + m.windowStart -= 1 + } + + for ansi.PrintableRuneWidth(m.window()) > m.windowWidth() { + m.windowStart += 1 + } +} + +// 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() } } @@ -59,19 +253,16 @@ func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { if m.name == msg.Name { m.focus = true m.blink = true - m.cursor = 0 - m.frameStart = 0 m.maxChars = msg.MaxChars - m.toEnd() + m.initWindow() return m, m.blinkCmd() } case msgs.BlurMsg: m.focus = false m.blink = false - m.cursor = 0 - m.frameStart = 0 m.maxChars = 0 + m.initWindow() case msgs.BlinkMsg: if m.focus && msg.Name == m.name { @@ -82,17 +273,19 @@ func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { case msgs.TextMsg: if m.name == msg.Name { m.text = msg.Text - m.toEnd() + 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: @@ -100,83 +293,17 @@ func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { switch msg.String() { case "backspace": - // No need to delete a char if the text is empty or the cursor isn't behind a char. - if m.text != "" && m.cursor != 0 { - text := strings.Split(m.text, "") - - if m.cursor == 1 { - // Delete a char at the start of the text. - m.text = strings.Join(text[1:], "") - } else if m.cursor+m.frameStart == len(m.text) { - // Delete a char at the end of the text. - m.text = strings.Join(text[:len(text)-1], "") - } else { - // Delete a char in the middle of the text. - first := strings.Join(text[:m.cursor+m.frameStart-1], "") - rest := strings.Join(text[m.cursor+m.frameStart:], "") - m.text = first + rest - } - - // Move the frame if needed to keep it full. - // Otherwise move the cursor back a position. - if m.cursor+m.frameStart > m.available()-1 && m.frameStart > 0 { - m.frameStart -= 1 - } else { - m.cursor -= 1 - } - - return m, m.inputChangedCmd() - } + m.delete() + return m, m.inputChangedCmd() case "left": - if m.cursor > 0 { - // Move the cursor back if it's not at the start of the frame. - m.cursor -= 1 - } else if m.cursor == 0 && m.frameStart > 0 { - // Move the frame back if the cursor is at the start of the frame and it's possible. - m.frameStart -= 1 - } + m.moveLeft() case "right": - if m.cursor < m.available()-1 && m.cursor < len(m.text) { - // Move cursor forward if it's not at the end of the frame. - m.cursor += 1 - } else if m.cursor == m.available()-1 && m.frameStart+m.cursor < len(m.text) { - // Move the frame forward if the cursor is at the end of the frame and it's possible. - m.frameStart += 1 - } + m.moveRight() default: - textLength := len(strings.Split(m.text, "")) - runesLength := len(strings.Split(string(msg.Runes), "")) - - if m.maxChars > 0 && textLength+runesLength > m.maxChars { - return m, nil - } - - if m.cursor == 0 { - // Insert the char at start of the text. - m.text = string(msg.Runes) + m.text - } else if m.cursor+m.frameStart == len(m.text) { - // Insert the char at the end of the text. - m.text += string(msg.Runes) - } else { - // Insert the char in the middle of the text. - text := strings.Split(m.text, "") - first := strings.Join(text[:m.cursor+m.frameStart], "") - rest := strings.Join(text[m.cursor+m.frameStart:], "") - m.text = first + string(msg.Runes) + rest - } - - // Move the cursor forward. - // If the cursor would move past the end of the frame move the frame forward instead. - m.cursor += len(msg.Runes) - if m.cursor > m.available()-1 { - diff := m.cursor - (m.available() - 1) - m.frameStart += diff - m.cursor = m.available() - 1 - } - + m.insert(msg.Runes) return m, m.inputChangedCmd() } } @@ -185,27 +312,19 @@ func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { return m, nil } -// available returns the available space for text. -func (m TextInputModel) available() int { - return m.width - len(m.prompt) -} - // View renders the model. func (m TextInputModel) View() string { - if m.width-len(m.prompt) <= 0 { - return "" - } - promptStyle := lipgloss. NewStyle(). Bold(true). + Inline(true). Foreground(lipgloss.Color("1")) - cursor := lipgloss. + cursorStyle := lipgloss. NewStyle(). Inline(true) if m.blink { - cursor = cursor.Reverse(true) + cursorStyle = cursorStyle.Reverse(true) } s := lipgloss. @@ -213,35 +332,30 @@ func (m TextInputModel) View() string { Width(m.width). Padding(0, Padding) - if m.width == 0 { + window := m.window() + windowRunes := strings.Split(window, "") + if window == "" { return s.Render(promptStyle.Render(m.prompt)) } - available := m.width - len(m.prompt) - text := strings.Split(m.text+" ", "") - if len(text) > available { - text = text[m.frameStart : m.frameStart+available] + if m.cursorAtStart() { // Render the view with the cursor at the start. + under := strings.Join(windowRunes[0:1], "") + rest := strings.Join(windowRunes[1:], "") + return s.Render(promptStyle.Render(m.prompt) + cursorStyle.Render(under) + rest) } - if m.cursor == 0 { - // Render the view with the cursor at the start. - under := strings.Join(text[0:1], "") - rest := strings.Join(text[1:], "") - return s.Render(promptStyle.Render(m.prompt) + cursor.Render(under) + rest) - } - - if m.cursor+m.frameStart == len(m.text) { - // Render the view with the cursor at the end. - rest := strings.Join(text[0:len(text)-1], "") - under := strings.Join(text[len(text)-1:], "") - return s.Render(promptStyle.Render(m.prompt) + rest + cursor.Render(under)) + if m.cursorAtEnd() { // Render the view with the cursor at the end. + rest := strings.Join(windowRunes[0:len(windowRunes)-1], "") + under := strings.Join(windowRunes[len(windowRunes)-1:], "") + return s.Render(promptStyle.Render(m.prompt) + rest + cursorStyle.Render(under)) } // Render the view with the cursor in the middle. - first := strings.Join(text[0:m.cursor], "") - under := strings.Join(text[m.cursor:m.cursor+1], "") - rest := strings.Join(text[m.cursor+1:], "") - return s.Render(promptStyle.Render(m.prompt) + first + cursor.Render(under) + rest) + first := strings.Join(windowRunes[0:m.cursor], "") + under := strings.Join(windowRunes[m.cursor:m.cursor+1], "") + rest := strings.Join(windowRunes[m.cursor+1:], "") + return s.Render(promptStyle.Render(m.prompt) + first + cursorStyle.Render(under) + rest) + } // Init initializes the model. @@ -273,6 +387,7 @@ type sleeper interface { // 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/textinput/textinput_test.go index f7459b0..bad78d9 100644 --- a/cmd/cli/tui/textinput/textinput_test.go +++ b/cmd/cli/tui/textinput/textinput_test.go @@ -1,558 +1,429 @@ package textinput import ( - "testing" - "time" - 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" + "testing" ) const name = "TestInput" const prompt = ">" -const width = 4 - -func TestTextScrollsAsInputAdded(t *testing.T) { - // enter 1 - gotModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - cursor: 0, - focus: true, +func TestText(t *testing.T) { + model := TextInputModel{ + text: "test", } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) - - wantModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "1", - cursor: 1, - focus: true, + diff := cmp.Diff(model.Text(), "test") + if diff != "" { + t.Errorf("Expected and actual texts different") } +} - wantCmd := func() tea.Msg { return msgs.InputChangedMsg{Name: name} } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) - - // enter 2 +func TestInsert(t *testing.T) { + validate := func(model TextInputModel, window string) { + windowDiff := cmp.Diff(model.window(), window) + if windowDiff != "" { + t.Errorf("Expected and actual windows different:\n%s", windowDiff) + } + } - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + // start - wantModel = TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "12", - cursor: 2, - focus: true, + model := TextInputModel{ + name: name, + text: "🐂🐜", + width: 12, } - wantCmd = func() tea.Msg { return msgs.InputChangedMsg{Name: name} } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) + 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{'🦊'}}) - // enter 3 + validate(model, "🦊🐂🐜 ") - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}) + // end - wantModel = TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 2, - frameStart: 1, - focus: true, + model = TextInputModel{ + name: name, + text: "🦊🐂", + width: 12, } - wantCmd = func() tea.Msg { return msgs.InputChangedMsg{Name: name} } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} + model, _ = model.Update(msgs.FocusMsg{Name: name}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'🐜'}}) -func TestTextScrollsAsInputRemoved(t *testing.T) { - // delete 3 - - gotModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 2, - frameStart: 1, - focus: true, - } + validate(model, "🦊🐂🐜 ") - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + // middle - wantModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "12", - cursor: 2, - focus: true, + model = TextInputModel{ + name: name, + text: "🦊🐜", + width: 12, } - wantCmd := func() tea.Msg { return msgs.InputChangedMsg{Name: name} } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) - - // delete 2 - - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + 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{'🐂'}}) - wantModel = TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "1", - cursor: 1, - focus: true, - } + validate(model, "🦊🐂🐜 ") +} - wantCmd = func() tea.Msg { return msgs.InputChangedMsg{Name: name} } +func TestInsertMovesWindow(t *testing.T) { + validate := func(model TextInputModel, index int, window string) { + atEndDiff := cmp.Diff(model.cursorAtEnd(), true) + if atEndDiff != "" { + t.Errorf("Expected and actual cursor at ends different:\n%s", atEndDiff) + } - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) + cursorDiff := cmp.Diff(model.cursor, 1) + if cursorDiff != "" { + t.Errorf("Expected and actual cursors different:\n%s", cursorDiff) + } - // delete 1 + indexDiff := cmp.Diff(model.index, index) + if indexDiff != "" { + t.Errorf("Expected and actual indexes different:\n%s", indexDiff) + } - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + windowDiff := cmp.Diff(model.window(), window) + if windowDiff != "" { + t.Errorf("Expected and actual windows different:\n%s", windowDiff) + } + } - wantModel = TextInputModel{ + model := TextInputModel{ name: name, + width: 6, prompt: prompt, - width: width, - cursor: 0, - focus: true, + text: "🦊", } - wantCmd = func() tea.Msg { return msgs.InputChangedMsg{Name: name} } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) + model, _ = model.Update(msgs.FocusMsg{Name: name}) + validate(model, 1, "🦊 ") - // noop + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + validate(model, 2, "c ") - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyBackspace}) - - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'🦊'}}) + validate(model, 3, "🦊 ") } -func TestCanScrollToStartOfText(t *testing.T) { - // move to 3 - - gotModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 2, - frameStart: 1, - focus: true, +func TestDelete(t *testing.T) { + validate := func(model TextInputModel, window string) { + windowDiff := cmp.Diff(model.window(), window) + if windowDiff != "" { + t.Errorf("Expected and actual windows different:\n%s", windowDiff) + } } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyLeft}) + // start - wantModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 1, - frameStart: 1, - focus: true, + model := TextInputModel{ + name: name, + text: "🦊🐂🐜", + width: 12, } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + 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}) - // move to 2 + validate(model, "🐂🐜 ") - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyLeft}) + // end - wantModel = TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 0, - frameStart: 1, - focus: true, + model = TextInputModel{ + name: name, + text: "🦊🐂🐜", + width: 12, } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + model, _ = model.Update(msgs.FocusMsg{Name: name}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) - // move to 1 + validate(model, "🦊🐂 ") - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyLeft}) + // middle - wantModel = TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 0, - frameStart: 0, - focus: true, + model = TextInputModel{ + name: name, + text: "🦊🐂🐜", + width: 12, } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) - - // noop - - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model, _ = model.Update(msgs.FocusMsg{Name: name}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + validate(model, "🦊🐜 ") } -func TestCanScrollToEndOfText(t *testing.T) { - // move to 2 - - gotModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 0, - frameStart: 0, - focus: true, - } - - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyRight}) - - wantModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 1, - frameStart: 0, - focus: true, +func TestDeleteMovesWindow(t *testing.T) { + validate := func(model TextInputModel, window string) { + windowDiff := cmp.Diff(model.window(), window) + if windowDiff != "" { + t.Errorf("Expected and actual windows different:\n%s", windowDiff) + } } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) - - // move to 3 - - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyRight}) - - wantModel = TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 2, - frameStart: 0, - focus: true, + model := TextInputModel{ + name: name, + text: "🦊c🦊c🦊c", + width: 6, } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + model, _ = model.Update(msgs.FocusMsg{Name: name}) + validate(model, "🦊c ") - // move to end + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + validate(model, "c🦊 ") - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyRight}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + validate(model, "🦊c ") - wantModel = TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 2, - frameStart: 1, - focus: true, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + validate(model, "c🦊 ") - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + validate(model, "🦊c ") - // noop + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + validate(model, "🦊 ") - gotModel, gotCmd = gotModel.Update(tea.KeyMsg{Type: tea.KeyRight}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + validate(model, " ") - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + validate(model, " ") } -func TestCanInsertAtStart(t *testing.T) { - gotModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 0, - frameStart: 0, - focus: true, - } +func TestMoveRight(t *testing.T) { + validate := func(model TextInputModel, window string, cursor int, index int) { + windowDiff := cmp.Diff(model.window(), window) + if windowDiff != "" { + t.Errorf("Expected and actual windows different:\n%s", windowDiff) + } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'0'}}) + cursorDiff := cmp.Diff(model.cursor, cursor) + if cursorDiff != "" { + t.Errorf("Expected and actual cursors different:\n%s", cursorDiff) + } - wantModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "0123", - cursor: 1, - frameStart: 0, - focus: true, + indexDiff := cmp.Diff(model.index, index) + if indexDiff != "" { + t.Errorf("Expected and actual indexes different:\n%s", indexDiff) + } } - wantCmd := func() tea.Msg { return msgs.InputChangedMsg{Name: name} } - - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} - -func TestCanDeleteAtStart(t *testing.T) { - gotModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "0123", - cursor: 1, - frameStart: 0, - focus: true, + model := TextInputModel{ + name: name, + width: 6, + prompt: prompt, + text: "a🦊b🐂c🐜", } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + 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}) + validate(model, "a🦊", 0, 0) - wantModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 0, - frameStart: 0, - focus: true, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "a🦊", 1, 1) - wantCmd := func() tea.Msg { return msgs.InputChangedMsg{Name: name} } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🦊b", 1, 2) - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "b🐂", 1, 3) -func TestCanInsertInMiddle(t *testing.T) { - gotModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "13", - cursor: 1, - frameStart: 0, - focus: true, - } - - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🐂c", 1, 4) - wantModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 2, - frameStart: 0, - focus: true, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "c🐜", 1, 5) - wantCmd := func() tea.Msg { return msgs.InputChangedMsg{Name: name} } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🐜 ", 1, 6) - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🐜 ", 1, 6) } -func TestCanDeleteInMiddle(t *testing.T) { - gotModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "123", - cursor: 2, - frameStart: 0, - focus: true, - } +func TestMoveRightVariation2(t *testing.T) { + validate := func(model TextInputModel, window string, cursor int, index int) { + windowDiff := cmp.Diff(model.window(), window) + if windowDiff != "" { + t.Errorf("Expected and actual at windows different:\n%s", windowDiff) + } - gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + cursorDiff := cmp.Diff(model.cursor, cursor) + if cursorDiff != "" { + t.Errorf("Expected and actual at cursors different:\n%s", cursorDiff) + } - wantModel := TextInputModel{ - name: name, - prompt: prompt, - width: width, - text: "13", - cursor: 1, - frameStart: 0, - focus: true, + indexDiff := cmp.Diff(model.index, index) + if indexDiff != "" { + t.Errorf("Expected and actual at indexes different:\n%s", indexDiff) + } } - wantCmd := func() tea.Msg { return msgs.InputChangedMsg{Name: name} } + model := TextInputModel{ + name: name, + width: 7, + prompt: prompt, + text: "🦊🐂abcd🐜🐕", + } - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} + 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}) + validate(model, "🦊🐂", 0, 0) -func TestCanChangePrompt(t *testing.T) { - gotModel := TextInputModel{ - name: name, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🦊🐂", 1, 1) - gotModel, gotCmd := gotModel.Update(msgs.PromptMsg{Name: name, Prompt: prompt}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🐂a", 1, 2) - wantModel := TextInputModel{ - name: name, - prompt: prompt, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🐂ab", 2, 3) - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) -} + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "abc", 2, 4) -func TestCanChangeText(t *testing.T) { - const text = "123" + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "abcd", 3, 5) - gotModel := TextInputModel{ - name: name, - width: 4, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "cd🐜", 2, 6) - gotModel, gotCmd := gotModel.Update(msgs.TextMsg{Name: name, Text: text}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🐜🐕", 1, 7) - wantModel := TextInputModel{ - name: name, - text: text, - width: 4, - cursor: 3, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🐕 ", 1, 8) - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyRight}) + validate(model, "🐕 ", 1, 8) } -func TestCanChangeSize(t *testing.T) { - const width = 3 - - gotModel := TextInputModel{ - name: name, - } +func TestMoveLeft(t *testing.T) { + validate := func(model TextInputModel, window string, cursor int, index int) { + windowDiff := cmp.Diff(model.window(), window) + if windowDiff != "" { + t.Errorf("Expected and actual windows different:\n%s", windowDiff) + } - gotModel, gotCmd := gotModel.Update(msgs.SizeMsg{Name: name, Width: width}) + cursorDiff := cmp.Diff(model.cursor, cursor) + if cursorDiff != "" { + t.Errorf("Expected and actual cursors different:\n%s", cursorDiff) + } - wantModel := TextInputModel{ - name: name, - width: width - Padding*2, + indexDiff := cmp.Diff(model.index, index) + if indexDiff != "" { + t.Errorf("Expected and actual indexes different:\n%s", indexDiff) + } } - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) -} - -func TestCanFocus(t *testing.T) { - gotModel := TextInputModel{ - name: name, - focus: false, - blink: false, - width: 1, - sleeper: noopSleeper{}, + model := TextInputModel{ + name: name, + width: 6, + prompt: prompt, + text: "a🦊b🐂c🐜", } - gotModel, gotCmd := gotModel.Update(msgs.FocusMsg{Name: name}) + model, _ = model.Update(msgs.FocusMsg{Name: name}) + validate(model, "🐜 ", 1, 6) - wantModel := TextInputModel{ - name: name, - focus: true, - blink: true, - width: 1, - sleeper: noopSleeper{}, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "🐜 ", 0, 5) - wantCmd := func() tea.Msg { return msgs.BlinkMsg{Name: name} } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "c🐜", 0, 4) - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "🐂c", 0, 3) -func TestCanBlur(t *testing.T) { - gotModel := TextInputModel{ - name: name, - focus: true, - blink: true, - sleeper: noopSleeper{}, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "b🐂", 0, 2) - gotModel, gotCmd := gotModel.Update(msgs.BlurMsg{Name: name}) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "🦊b", 0, 1) - wantModel := TextInputModel{ - name: name, - focus: false, - blink: false, - sleeper: noopSleeper{}, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "a🦊", 0, 0) - verifyUpdate(t, gotModel, wantModel, gotCmd, nil) + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "a🦊", 0, 0) } -func TestCanBlink(t *testing.T) { - gotModel := TextInputModel{ - name: name, - focus: true, - blink: true, - sleeper: noopSleeper{}, - } +func TestMoveLeftVariation2(t *testing.T) { + validate := func(model TextInputModel, window string, cursor int, index int) { + windowDiff := cmp.Diff(model.window(), window) + if windowDiff != "" { + t.Errorf("Expected and actual windows different:\n%s", windowDiff) + } - gotModel, gotCmd := gotModel.Update(msgs.BlinkMsg{Name: name}) + cursorDiff := cmp.Diff(model.cursor, cursor) + if cursorDiff != "" { + t.Errorf("Expected and actual cursors different:\n%s", cursorDiff) + } - wantModel := TextInputModel{ - name: name, - focus: true, - blink: false, - sleeper: noopSleeper{}, + indexDiff := cmp.Diff(model.index, index) + if indexDiff != "" { + t.Errorf("Expected and actual indexes different:\n%s", indexDiff) + } } - wantCmd := func() tea.Msg { return msgs.BlinkMsg{Name: name} } + model := TextInputModel{ + name: name, + width: 7, + prompt: prompt, + text: "🦊🐂abcd🐜🐕", + } - verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) -} + model, _ = model.Update(msgs.FocusMsg{Name: name}) + validate(model, "🐕 ", 1, 8) -func TestCanLimitMaxChars(t *testing.T) { - gotModel := TextInputModel{ - name: name, - sleeper: noopSleeper{}, - width: 2, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "🐕 ", 0, 7) - gotModel, _ = gotModel.Update(msgs.FocusMsg{Name: name, MaxChars: 1}) - gotModel, _ = gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) - gotModel, _ = gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) - - wantModel := TextInputModel{ - name: name, - sleeper: noopSleeper{}, - width: 2, - maxChars: 1, - text: "1", - cursor: 1, - focus: true, - blink: true, - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "🐜🐕", 0, 6) - verifyUpdate(t, gotModel, wantModel, nil, nil) -} + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "d🐜", 0, 5) -func TestText(t *testing.T) { - gotModel := TextInputModel{ - text: "123", - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "cd🐜", 0, 4) - diff := cmp.Diff(gotModel.Text(), "123") - if diff != "" { - t.Errorf("Expected and actual text different:\n%s", diff) - } -} + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "bcd", 0, 3) -func verifyUpdate(t *testing.T, gotModel TextInputModel, wantModel TextInputModel, gotCmd tea.Cmd, wantCmd tea.Cmd) { - unexported := cmp.AllowUnexported(TextInputModel{}) - modelDiff := cmp.Diff(gotModel, wantModel, unexported) - if modelDiff != "" { - t.Errorf("Expected and actual models different:\n%s", modelDiff) - } + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "abcd", 0, 2) - utils.CompareCommands(t, gotCmd, wantCmd) -} + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "🐂ab", 0, 1) -type noopSleeper struct{} + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "🦊🐂", 0, 0) -func (s noopSleeper) sleep(d time.Duration) { + model, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + validate(model, "🦊🐂", 0, 0) } diff --git a/cmd/cli/tui/typeahead/typeahead.go b/cmd/cli/tui/typeahead/typeahead.go new file mode 100644 index 0000000..b89373b --- /dev/null +++ b/cmd/cli/tui/typeahead/typeahead.go @@ -0,0 +1,214 @@ +package typeahead + +import ( + "slices" + "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" +) + +// 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 func() ([]string, error) // returns results when there isn't enough input + filteredQuery func(query string) ([]string, error) // 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 scrolltable.ScrolltableModel[string] // shows the options to select from +} + +// TypeaheadMode returns whether the typeahead is accepting input or not. +func (m TypeaheadModel) TypeaheadMode() bool { + return m.typeaheadMode +} + +// 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(tableName, true, []scrolltable.ColumnDefinition[string]{ + { + Mode: scrolltable.DynamicColumn, + Header: "", + RenderCell: func(item string) string { + return item + }, + Style: func(item string, 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.loadOptions()) + } + + 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.loadOptions()) + } 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 +} + +// loadOptions loads the available options. +func (m TypeaheadModel) loadOptions() 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} + } + + if m.includeInput && !slices.Contains(items, m.input.Text()) { + items = append([]string{m.input.Text()}, items...) + } + + return msgs.DataMsg[string]{Name: m.tableName, Data: items, Move: msgs.DirectionStart} + } else { + items, err := m.unfilteredQuery() + if err != nil { + return msgs.ErrorMsg{Err: err} + } + + if m.includeInput && m.input.Text() != "" && !slices.Contains(items, m.input.Text()) { + items = append([]string{m.input.Text()}, items...) + } + + return msgs.DataMsg[string]{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 new file mode 100644 index 0000000..4b94bd8 --- /dev/null +++ b/cmd/cli/tui/typeahead/typeahead_test.go @@ -0,0 +1,198 @@ +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, + IncludeInput: true, + Prompt: prompt, + Text: "text", + MaxChars: 5, + UnfilteredQuery: func() ([]string, error) { + return []string{}, nil + }, + FilteredQuery: func(query string) ([]string, error) { + return []string{}, nil + }, + }) + + wantModel := TypeaheadModel{ + name: name, + inputName: inputName, + tableName: tableName, + typeaheadMode: true, + minFilterChars: 3, + operation: operation, + includeInput: true, + } + + 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[string]{ + Name: tableName, + Data: []string{}, + 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, + includeInput: true, + } + + 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, + includeInput: true, + } + + 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, + includeInput: true, + } + + 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, + includeInput: true, + } + + 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, + includeInput: true, + } + + 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/utils/substr.go b/cmd/cli/tui/utils/substr.go index ab6d1ee..e2d38d4 100644 --- a/cmd/cli/tui/utils/substr.go +++ b/cmd/cli/tui/utils/substr.go @@ -2,38 +2,45 @@ package utils import ( "strings" + + "github.com/muesli/reflow/ansi" ) // Substr cuts a string down to substring. // if the strings length is reduced ellipsis are added. -func Substr(s string, from, length int) string { - if s == "" { +func Substr(s string, length int) string { + // If the input string is empty or the target length is too short return an empty string. + if s == "" || length <= 0 { return s } - wb := strings.Split(s, "") + // Measure the actual width of the string. + width := ansi.PrintableRuneWidth(s) - to := from + length - if to > len(wb) { - to = len(wb) + // If the width of the string fits in the max length then return it unchanged. + if length >= width { + return s } - if from > len(wb) { - from = len(wb) + // Account for the ellipsis in the max length. + if length < width { + length -= 1 } - if to < len(wb) { - to -= 1 + // Trim the the string down to the desired length. + // Start with the obvious case of every char being single width. + // If that doesn't work remove one char at a time until the width is reached. + runes := strings.Split(s, "") + if length > len(runes) { + length = len(runes) } - out := strings.Join(wb[from:to], "") - if s == out { - return s + out := strings.Join(runes[0:length], "") + additional := 0 + for ansi.PrintableRuneWidth(out) > length { + additional += 1 + out = strings.Join(runes[0:length-additional], "") } - if out != "" { - return out + "…" - } else { - return out - } + return out + "…" } diff --git a/cmd/cli/tui/utils/substr_test.go b/cmd/cli/tui/utils/substr_test.go index 85cc152..847a920 100644 --- a/cmd/cli/tui/utils/substr_test.go +++ b/cmd/cli/tui/utils/substr_test.go @@ -9,22 +9,20 @@ import ( func TestSubstr(t *testing.T) { type test struct { input string - from int length int want string } tests := []test{ - {input: "", from: 0, length: 0, want: ""}, - {input: "a", from: 2, length: 0, want: ""}, - {input: "a", from: 0, length: 2, want: "a"}, - {input: "a", from: 0, length: 1, want: "a"}, - {input: "aaa", from: 0, length: 2, want: "a…"}, + {input: "", length: 0, want: ""}, + {input: "a", length: 2, want: "a"}, + {input: "a", length: 1, want: "a"}, + {input: "aaa", length: 2, want: "a…"}, } for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { - got := Substr(tc.input, tc.from, tc.length) + got := Substr(tc.input, tc.length) diff := cmp.Diff(got, tc.want) if diff != "" { t.Errorf("Expected and actual strings different:\n%s", diff)