diff --git a/cmd/cli/tui/booksview/books.go b/cmd/cli/tui/booksview/books.go index 5f8930c..15df0ac 100644 --- a/cmd/cli/tui/booksview/books.go +++ b/cmd/cli/tui/booksview/books.go @@ -8,22 +8,22 @@ 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/msgs" "github.com/jonathanhope/armaria/cmd/cli/tui/scrolltable" - "github.com/jonathanhope/armaria/cmd/cli/tui/textinput" "github.com/jonathanhope/armaria/pkg/api" "github.com/jonathanhope/armaria/pkg/model" ) -const HeaderHeight = 3 // height of the header -const HeaderSpacerHeight = 1 // height of the spacer between the header and table -const FooterHeight = 3 // height of the footer -const HelpInfoWidth = 7 // width of the help info in the footer -const HeaderName = "BooksHeader" // name of the header -const TextInputName = "BooksInput" // name of the textinput -const TableName = "BooksTable" // name of the table +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 // InputType is which type of input is being collected. type inputType int @@ -40,8 +40,6 @@ const ( // The book listing displays the bookmarks in the bookmarks DB. type model struct { activeView msgs.View // which view is currently being shown - helpMode bool // whether to show the help or not - inputMode bool // if true then the view is collecting input 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 @@ -49,9 +47,10 @@ type model struct { query string // current search query busy bool // used to limit writers 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 - input textinput.TextInputModel // allows text input + } // InitialModel builds the model. @@ -59,6 +58,7 @@ func InitialModel() tea.Model { return model{ activeView: msgs.ViewBooks, header: header.InitialModel(HeaderName, "📜 Armaria"), + footer: footer.InitialModel(FooterName), table: scrolltable.InitialModel[armaria.Book]( TableName, []scrolltable.ColumnDefinition[armaria.Book]{ @@ -88,6 +88,7 @@ func InitialModel() tea.Model { }, }), help: help.InitialModel( + HelpName, []string{"Listing", "Input"}, []help.Binding{ {Context: "Listing", Key: "up", Help: "Previous book"}, @@ -110,7 +111,6 @@ func InitialModel() tea.Model { {Context: "Input", Key: "esc", Help: "Cancel input"}, }, ), - input: textinput.InitialModel(TextInputName, ""), } } @@ -170,141 +170,29 @@ func formatTags(book armaria.Book) string { return strings.Join(book.Tags, ", ") } -// filtersDisplayHeight returns the height of the filters display in the header. -func (m model) filtersDisplayHeight() int { - if len(m.query) > 0 { - return 1 - } - - return 0 -} - // Update handles a message. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // There are some cases where this view is in a substate that handles events differently. - - // Substate: a different view is active. - if m.activeView != msgs.ViewBooks { - switch msg := msg.(type) { - - case msgs.ViewMsg: - m.activeView = msgs.View(msg) - return m, nil - - case tea.WindowSizeMsg: - m.height = msg.Height - m.width = msg.Width - return m, m.recalculateSizeCmd() - - default: - return m, nil - } + // If another view is active ignore all keypresses. + if _, ok := msg.(tea.KeyMsg); ok && m.activeView != msgs.ViewBooks { + return m, nil } - // Substate: the help screen is active. - if m.helpMode { - switch msg := msg.(type) { - - case tea.KeyMsg: - switch msg.String() { - - case "ctrl+c": - return m, tea.Quit - - case "q", "esc": - m.helpMode = false - return m, nil - - default: - return m, nil - } - - case msgs.ViewMsg: - m.activeView = msgs.View(msg) - return m, nil - - case tea.WindowSizeMsg: - m.height = msg.Height - m.width = msg.Width - return m, m.recalculateSizeCmd() - - default: - return m, nil - } + // 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 } - // Substate: the input is active. - if m.inputMode { - switch msg := msg.(type) { - - case tea.KeyMsg: - switch msg.String() { - - case "ctrl+c": - return m, tea.Quit - - case "esc": - m.inputMode = false - m.query = "" - m.inputType = inputNone - return m, tea.Batch(m.inputEndCmd(), m.updateFiltersCmd()) - - case "enter": - cmds := []tea.Cmd{m.inputEndCmd()} - if m.inputType == inputName { - m.busy = true - cmds = append(cmds, m.updateNameCmd(m.input.Text())) - } else if m.inputType == inputURL { - m.busy = true - cmds = append(cmds, m.updateURLCmd(m.input.Text())) - } else if m.inputType == inputFolder { - m.busy = true - cmds = append(cmds, m.addFolderCmd(m.input.Text())) - } - - m.inputMode = false - m.inputType = inputNone - - return m, tea.Batch(cmds...) - - default: - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msg) - return m, inputCmd - } - - case tea.WindowSizeMsg: - m.height = msg.Height - m.width = msg.Width - return m, m.recalculateSizeCmd() - - case msgs.InputChangedMsg: - if m.inputType == inputSearch { - m.query = m.input.Text() - return m, m.getBooksCmd(msgs.DirectionStart) - } - - case msgs.DataMsg[armaria.Book]: - var tableCmd tea.Cmd - m.table, tableCmd = m.table.Update(msg) - return m, tableCmd - - case msgs.ViewMsg: - m.activeView = msgs.View(msg) - return m, nil - - default: - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msg) - return m, inputCmd - } + // 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 } - // Otherwise we fall into the main event loop. - // The first step is forward the message to the underlying components. - - var inputCmd tea.Cmd - m.input, inputCmd = m.input.Update(msg) + var footerCmd tea.Cmd + m.footer, footerCmd = m.footer.Update(msg) var tableCmd tea.Cmd m.table, tableCmd = m.table.Update(msg) @@ -312,7 +200,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var headerCmd tea.Cmd m.header, headerCmd = m.header.Update(msg) - cmds := []tea.Cmd{tableCmd, headerCmd, inputCmd} + var helpCmd tea.Cmd + m.help, helpCmd = m.help.Update(msg) + + cmds := []tea.Cmd{tableCmd, headerCmd, footerCmd, helpCmd} switch msg := msg.(type) { @@ -323,7 +214,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "?": - m.helpMode = true + cmds = append(cmds, func() tea.Msg { return msgs.ShowHelpMsg{Name: HelpName} }) case "enter": if !m.table.Empty() { @@ -365,28 +256,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.getBooksCmd(msgs.DirectionNone)) case "s": - m.inputMode = true m.inputType = inputSearch - cmds = append(cmds, m.inputStartCmd("Query: ", "")) + cmds = append(cmds, m.inputStartCmd("Query: ", "", 0)) case "u": if !m.table.Empty() && !m.table.Selection().IsFolder { - m.inputMode = true m.inputType = inputURL - cmds = append(cmds, m.inputStartCmd("URL: ", *m.table.Selection().URL)) + cmds = append(cmds, m.inputStartCmd("URL: ", *m.table.Selection().URL, 2048)) } case "n": if !m.table.Empty() { - m.inputMode = true m.inputType = inputName - cmds = append(cmds, m.inputStartCmd("Name: ", m.table.Selection().Name)) + cmds = append(cmds, m.inputStartCmd("Name: ", m.table.Selection().Name, 2048)) } case "+": - m.inputMode = true m.inputType = inputFolder - cmds = append(cmds, m.inputStartCmd("Folder: ", "")) + cmds = append(cmds, m.inputStartCmd("Folder: ", "", 2048)) case "ctrl+up": if m.query == "" && !m.table.Empty() && m.table.Index() > 0 && !m.busy { @@ -438,6 +325,32 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case msgs.FreeMsg: m.busy = false + + case msgs.InputCancelledMsg: + m.query = "" + m.inputType = inputNone + cmds = append(cmds, m.inputEndCmd(), m.updateFiltersCmd()) + + case msgs.InputConfirmedMsg: + cmds = append(cmds, m.inputEndCmd()) + if m.inputType == inputName { + m.busy = true + cmds = append(cmds, m.updateNameCmd(m.footer.Text())) + } else if m.inputType == inputURL { + m.busy = true + cmds = append(cmds, m.updateURLCmd(m.footer.Text())) + } else if m.inputType == inputFolder { + m.busy = true + cmds = append(cmds, m.addFolderCmd(m.footer.Text())) + } + + m.inputType = inputNone + + case msgs.InputChangedMsg: + if m.inputType == inputSearch { + m.query = m.footer.Text() + cmds = append(cmds, m.getBooksCmd(msgs.DirectionStart)) + } } return m, tea.Batch(cmds...) @@ -449,7 +362,7 @@ func (m model) View() string { return "" } - if m.helpMode { + if m.help.HelpMode() { return m.header.View() + "\n\n" + m.help.View() } @@ -474,27 +387,7 @@ func (m model) View() string { "\n\n" + table + spacer + - m.footerView() -} - -// footerView renders the footer view. -func (m model) footerView() string { - help := lipgloss. - NewStyle(). - Foreground(lipgloss.Color("3")). - SetString("Help: ?") - - footerStyle := lipgloss. - NewStyle(). - Border(lipgloss.ThickBorder()). - BorderTop(true). - BorderBottom(false). - BorderLeft(false). - BorderRight(false). - Width(m.width). - BorderForeground(lipgloss.Color("5")) - - return footerStyle.Render(m.input.View() + help.Render()) + m.footer.View() } // Init initializes the model. @@ -663,27 +556,40 @@ func (m model) addFolderCmd(name 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([]string{fmt.Sprintf("Query: %s", m.query)}) } + return func() tea.Msg { + return msgs.FiltersMsg{Name: FooterName, Filters: []string{fmt.Sprintf("Query: %s", m.query)}} + } } - return func() tea.Msg { return msgs.FiltersMsg([]string{}) } + 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) tea.Cmd { +func (m model) inputStartCmd(prompt string, text string, maxChars int) tea.Cmd { return tea.Batch( - func() tea.Msg { return msgs.PromptMsg{Name: TextInputName, Prompt: prompt} }, - func() tea.Msg { return msgs.TextMsg{Name: TextInputName, Text: text} }, - func() tea.Msg { return msgs.FocusMsg{Name: TextInputName} }, + 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.BlurMsg{Name: TextInputName} }, - func() tea.Msg { return msgs.PromptMsg{Name: TextInputName, Prompt: ""} }, - func() tea.Msg { return msgs.TextMsg{Name: TextInputName, Text: ""} }, + func() tea.Msg { + return msgs.InputModeMsg{ + Name: FooterName, + InputMode: false, + } + }, m.getBooksCmd(msgs.DirectionNone), m.updateFiltersCmd(), m.recalculateSizeCmd(), @@ -697,7 +603,6 @@ func (m model) inputEndCmd() tea.Cmd { func (m model) recalculateSizeCmd() tea.Cmd { height := m.height - HeaderHeight - - m.filtersDisplayHeight() - HeaderSpacerHeight - FooterHeight @@ -706,21 +611,21 @@ func (m model) recalculateSizeCmd() tea.Cmd { Width: m.width, } + footerSizeMsg := msgs.SizeMsg{ + Name: FooterName, + Width: m.width, + } + tableSizeMsg := msgs.SizeMsg{ Name: TableName, Width: m.width, Height: height, } - inputSizeMsg := msgs.SizeMsg{ - Name: TextInputName, - Width: m.width - HelpInfoWidth, - } - return tea.Batch( func() tea.Msg { return headerSizeMsg }, + func() tea.Msg { return footerSizeMsg }, func() tea.Msg { return tableSizeMsg }, - func() tea.Msg { return inputSizeMsg }, ) } diff --git a/cmd/cli/tui/errorview/error_test.go b/cmd/cli/tui/errorview/error_test.go index 61420bc..9295215 100644 --- a/cmd/cli/tui/errorview/error_test.go +++ b/cmd/cli/tui/errorview/error_test.go @@ -8,6 +8,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/utils" ) func TestHandlesViewMessage(t *testing.T) { @@ -47,16 +48,5 @@ func verifyUpdate(t *testing.T, gotModel tea.Model, wantModel tea.Model, gotCmd t.Errorf("Expected and actual models different:\n%s", modelDiff) } - if gotCmd == nil || wantCmd == nil { - if gotCmd != nil || wantCmd != nil { - t.Errorf("Expected and actual cmds different: one is nil and one is non-nil") - } - - return - } - - cmdDiff := cmp.Diff(gotCmd(), wantCmd()) - if modelDiff != "" { - t.Errorf("Expected and actual cmds different:\n%s", cmdDiff) - } + utils.CompareCommands(t, gotCmd, wantCmd) } diff --git a/cmd/cli/tui/footer/footer.go b/cmd/cli/tui/footer/footer.go new file mode 100644 index 0000000..98869a9 --- /dev/null +++ b/cmd/cli/tui/footer/footer.go @@ -0,0 +1,164 @@ +package footer + +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/textinput" + "github.com/jonathanhope/armaria/cmd/cli/tui/utils" +) + +const HelpInfoWidth = 7 // width of the help info in the footer +const TextInputName = "BooksInput" // name of the textinput + +// FooterModel is the model for a header. +// The footer can collect input, and displays informationa about the apps state. +type FooterModel struct { + name string // name of the footer + width int // max width of the footer + inputMode bool // if true footer is accepting input + filters []string // currently applied filters + input textinput.TextInputModel // allows text input +} + +// Text returns the text in the footers input. +func (m FooterModel) Text() string { + return m.input.Text() +} + +// InputMode returns whether the footer is accepting input or not. +func (m FooterModel) InputMode() bool { + return m.inputMode +} + +// InitialModel builds the model. +func InitialModel(name string) FooterModel { + return FooterModel{ + name: name, + input: textinput.InitialModel(TextInputName, ""), + } +} + +// 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 + } else { + var inputCmd tea.Cmd + m.input, inputCmd = m.input.Update(msgs.SizeMsg{ + Name: TextInputName, + 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, func() tea.Msg { + return msgs.PromptMsg{Name: TextInputName, Prompt: msg.Prompt} + }, func() tea.Msg { + return msgs.TextMsg{Name: TextInputName, Text: msg.Text} + }, func() tea.Msg { + return msgs.FocusMsg{Name: TextInputName, MaxChars: msg.MaxChars} + }) + } else { + cmds = append(cmds, func() tea.Msg { + return msgs.BlurMsg{Name: TextInputName} + }, func() tea.Msg { + return msgs.PromptMsg{Name: TextInputName, Prompt: ""} + }, func() tea.Msg { + return msgs.TextMsg{Name: TextInputName, Text: ""} + }) + } + } + + 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{} + }) + + case "enter": + cmds = append(cmds, func() tea.Msg { + return msgs.InputConfirmedMsg{} + }) + + 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...) +} + +// View renders the model. +func (m FooterModel) View() string { + help := lipgloss. + NewStyle(). + Foreground(lipgloss.Color("3")). + SetString("Help: ?") + + footerStyle := lipgloss. + NewStyle(). + Border(lipgloss.ThickBorder()). + BorderTop(true). + BorderBottom(false). + BorderLeft(false). + BorderRight(false). + Width(m.width). + BorderForeground(lipgloss.Color("5")) + + var filters string + if len(m.filters) > 0 { + filters = strings.Join(m.filters, ", ") + } else { + filters = "No filters applied" + } + if m.width > 0 { + filters = utils.Substr(filters, 0, m.width-4) + } + + filtersStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("2")). + SetString(filters). + Width(m.width). + Align(lipgloss.Right). + Padding(0, 2) + + return footerStyle.Render(m.input.View() + help.Render() + "\n" + filtersStyle.Render()) +} + +// Init initializes the model. +func (m FooterModel) Init() tea.Cmd { + return nil +} diff --git a/cmd/cli/tui/footer/footer_test.go b/cmd/cli/tui/footer/footer_test.go new file mode 100644 index 0000000..06aef20 --- /dev/null +++ b/cmd/cli/tui/footer/footer_test.go @@ -0,0 +1,144 @@ +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/utils" +) + +const Name = "footer" + +func TestCanUpdateWidth(t *testing.T) { + gotModel := FooterModel{ + name: Name, + } + gotModel, gotCmd := gotModel.Update(msgs.SizeMsg{Name: Name, Width: 1}) + + wantModel := FooterModel{ + name: Name, + width: 1, + } + + verifyUpdate(t, gotModel, wantModel, gotCmd, nil) +} + +func TestCanStartInputMode(t *testing.T) { + gotModel := FooterModel{ + name: Name, + } + gotModel, gotCmd := gotModel.Update(msgs.InputModeMsg{ + Name: Name, + InputMode: true, + Prompt: "prompt", + Text: "text", + MaxChars: 5, + }) + + wantModel := FooterModel{ + name: Name, + inputMode: true, + } + + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { return msgs.PromptMsg{Name: TextInputName, Prompt: "prompt"} }, + func() tea.Msg { return msgs.TextMsg{Name: TextInputName, Text: "text"} }, + func() tea.Msg { return msgs.FocusMsg{Name: TextInputName, MaxChars: 5} }, + } + } + + verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) +} + +func TestCanEndInputMode(t *testing.T) { + gotModel := FooterModel{ + name: Name, + inputMode: true, + } + gotModel, gotCmd := gotModel.Update(msgs.InputModeMsg{Name: Name, InputMode: false}) + + wantModel := FooterModel{ + name: Name, + inputMode: false, + } + + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { return msgs.BlurMsg{Name: TextInputName} }, + func() tea.Msg { return msgs.PromptMsg{Name: TextInputName} }, + func() tea.Msg { return msgs.TextMsg{Name: TextInputName} }, + } + } + + verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) +} + +func TestCanSetFilters(t *testing.T) { + gotModel := FooterModel{ + name: Name, + } + gotModel, gotCmd := gotModel.Update(msgs.FiltersMsg{Name: Name, Filters: []string{"one"}}) + + wantModel := FooterModel{ + name: Name, + filters: []string{"one"}, + } + + verifyUpdate(t, gotModel, wantModel, gotCmd, nil) +} + +func TestCanCancelInput(t *testing.T) { + gotModel := FooterModel{ + name: Name, + inputMode: true, + } + gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + wantModel := FooterModel{ + name: Name, + inputMode: true, + } + + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { return msgs.InputCancelledMsg{} }, + } + } + + verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) +} + +func TestCanConfirmInput(t *testing.T) { + gotModel := FooterModel{ + name: Name, + inputMode: true, + } + gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + wantModel := FooterModel{ + name: Name, + inputMode: true, + } + + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { return msgs.InputConfirmedMsg{} }, + } + } + + 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/header/header.go b/cmd/cli/tui/header/header.go index 6994821..42e2bf1 100644 --- a/cmd/cli/tui/header/header.go +++ b/cmd/cli/tui/header/header.go @@ -6,17 +6,15 @@ import ( "github.com/charmbracelet/lipgloss/table" "github.com/jonathanhope/armaria/cmd/cli/tui/msgs" "github.com/jonathanhope/armaria/cmd/cli/tui/utils" - "github.com/samber/lo" ) -// HeaderModel is the HeaderModel for a header. +// 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 - width int // max width of the header - filters []string // currently active filters + name string // name of the header + title string // title of the app + nav string // breadcrumbs for the currently selected book + width int // max width of the header } // InitialModel builds the model. @@ -38,10 +36,6 @@ func (m HeaderModel) Update(msg tea.Msg) (HeaderModel, tea.Cmd) { case msgs.NavMsg: m.nav = string(msg) - - case msgs.FiltersMsg: - m.filters = msg - } return m, nil @@ -58,20 +52,6 @@ func (m HeaderModel) View() string { {m.title, utils.Substr(m.nav, 0, cellTextWidth)}, } - for _, filtersChunk := range lo.Chunk(m.filters, 2) { - row := make([]string, 2) - - if len(filtersChunk) > 0 { - row[0] = utils.Substr(m.filters[0], 0, cellTextWidth) - } - - if len(filtersChunk) > 1 { - row[1] = utils.Substr(m.filters[1], 0, cellTextWidth) - } - - rows = append(rows, row) - } - titleNavStyle := lipgloss. NewStyle(). Bold(true). @@ -79,11 +59,6 @@ func (m HeaderModel) View() string { Width(cellWidth). Padding(0, cellPadding) - filterStyle := lipgloss. - NewStyle(). - Foreground(lipgloss.Color("2")). - Padding(0, cellPadding) - headingTable := table. New(). Border(lipgloss.ThickBorder()). @@ -101,10 +76,6 @@ func (m HeaderModel) View() string { return titleNavStyle.Align(lipgloss.Left) case row == 1 && col == 1: return titleNavStyle.Align(lipgloss.Right) - case col == 0: - return filterStyle.Align(lipgloss.Left) - case col == 1: - return filterStyle.Align(lipgloss.Right) } return lipgloss.NewStyle() diff --git a/cmd/cli/tui/header/header_test.go b/cmd/cli/tui/header/header_test.go index a7c94c8..df1d857 100644 --- a/cmd/cli/tui/header/header_test.go +++ b/cmd/cli/tui/header/header_test.go @@ -6,6 +6,7 @@ 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" ) const Name = "header" @@ -42,16 +43,5 @@ func verifyUpdate(t *testing.T, gotModel HeaderModel, wantModel HeaderModel, got t.Errorf("Expected and actual models different:\n%s", modelDiff) } - if gotCmd == nil || wantCmd == nil { - if gotCmd != nil || wantCmd != nil { - t.Errorf("Expected and actual cmds different: one is nil and one is non-nil") - } - - return - } - - cmdDiff := cmp.Diff(gotCmd(), wantCmd()) - if modelDiff != "" { - t.Errorf("Expected and actual cmds different:\n%s", cmdDiff) - } + utils.CompareCommands(t, gotCmd, wantCmd) } diff --git a/cmd/cli/tui/help/help.go b/cmd/cli/tui/help/help.go index 52dc28b..1fe5421 100644 --- a/cmd/cli/tui/help/help.go +++ b/cmd/cli/tui/help/help.go @@ -4,6 +4,7 @@ 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" ) @@ -17,13 +18,21 @@ type Binding struct { // HelpModel is the HelpModel for help. // The help screen shows keybindings. type HelpModel struct { + name string // name of the help screen contexts []string // the different context to show keybindings for bindings []Binding // the different keybindings + helpMode bool // if true the help screen is active +} + +// HelpMode returns true if the hlep mode is active. +func (m HelpModel) HelpMode() bool { + return m.helpMode } // InitialModel builds the model. -func InitialModel(contexts []string, bindings []Binding) HelpModel { +func InitialModel(name string, contexts []string, bindings []Binding) HelpModel { return HelpModel{ + name: name, contexts: contexts, bindings: bindings, } @@ -31,6 +40,21 @@ func InitialModel(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 } diff --git a/cmd/cli/tui/help/help_test.go b/cmd/cli/tui/help/help_test.go new file mode 100644 index 0000000..6b92b43 --- /dev/null +++ b/cmd/cli/tui/help/help_test.go @@ -0,0 +1,67 @@ +package help + +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 = "help" + +func TestCanShowHelp(t *testing.T) { + gotModel := HelpModel{ + name: Name, + } + gotModel, gotCmd := gotModel.Update(msgs.ShowHelpMsg{Name: Name}) + + wantModel := HelpModel{ + name: Name, + helpMode: true, + } + + verifyUpdate(t, gotModel, wantModel, gotCmd, nil) +} + +func TestCanHideHelp(t *testing.T) { + gotModel := HelpModel{ + name: Name, + helpMode: true, + } + gotModel, gotCmd := gotModel.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + + 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) +} + +func TestHelpMode(t *testing.T) { + gotModel := HelpModel{ + name: Name, + helpMode: true, + } + + modelDiff := cmp.Diff(gotModel.HelpMode(), true) + if modelDiff != "" { + t.Errorf("Expected and actual help modes different:\n%s", modelDiff) + } +} + +func verifyUpdate(t *testing.T, gotModel HelpModel, wantModel HelpModel, gotCmd tea.Cmd, wantCmd tea.Cmd) { + unexported := cmp.AllowUnexported(HelpModel{}) + modelDiff := cmp.Diff(gotModel, wantModel, unexported) + if modelDiff != "" { + t.Errorf("Expected and actual models different:\n%s", modelDiff) + } + + utils.CompareCommands(t, gotCmd, wantCmd) +} diff --git a/cmd/cli/tui/msgs/footer.go b/cmd/cli/tui/msgs/footer.go new file mode 100644 index 0000000..2c19ef9 --- /dev/null +++ b/cmd/cli/tui/msgs/footer.go @@ -0,0 +1,22 @@ +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 index ad85b62..6127a55 100644 --- a/cmd/cli/tui/msgs/header.go +++ b/cmd/cli/tui/msgs/header.go @@ -2,6 +2,3 @@ package msgs // NavMsg is a message that changes the nav display in the header. type NavMsg string - -// FiltersMsg is a message that changes the display of the currently active filters. -type FiltersMsg []string diff --git a/cmd/cli/tui/msgs/help.go b/cmd/cli/tui/msgs/help.go new file mode 100644 index 0000000..3ba425a --- /dev/null +++ b/cmd/cli/tui/msgs/help.go @@ -0,0 +1,6 @@ +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/textinput.go b/cmd/cli/tui/msgs/textinput.go index a478886..fc7cd66 100644 --- a/cmd/cli/tui/msgs/textinput.go +++ b/cmd/cli/tui/msgs/textinput.go @@ -2,7 +2,8 @@ package msgs // FocusMsg is used to focus a textinput. type FocusMsg struct { - Name string // name of the target textinput + 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. diff --git a/cmd/cli/tui/scrolltable/scrolltable_test.go b/cmd/cli/tui/scrolltable/scrolltable_test.go index 12239a7..5298e8f 100644 --- a/cmd/cli/tui/scrolltable/scrolltable_test.go +++ b/cmd/cli/tui/scrolltable/scrolltable_test.go @@ -6,6 +6,7 @@ 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" ) func TestCanUpdateData(t *testing.T) { @@ -28,7 +29,16 @@ func TestCanUpdateData(t *testing.T) { frameStart: 0, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "1"}, + } + }, + func() tea.Msg { return msgs.FreeMsg{} }, + } + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) } @@ -54,7 +64,16 @@ func TestCanUpdateDataMoveUp(t *testing.T) { frameStart: 0, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "1"}, + } + }, + func() tea.Msg { return msgs.FreeMsg{} }, + } + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) } @@ -79,7 +98,16 @@ func TestCanUpdateDataMoveDown(t *testing.T) { frameStart: 1, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "2"}, + } + }, + func() tea.Msg { return msgs.FreeMsg{} }, + } + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) } @@ -105,7 +133,16 @@ func TestCanUpdateDataMoveStart(t *testing.T) { frameStart: 0, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "1"}, + } + }, + func() tea.Msg { return msgs.FreeMsg{} }, + } + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) } @@ -132,7 +169,9 @@ func TestCanScrollDown(t *testing.T) { frameStart: 1, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{Selection: TestDatum{ID: "2"}} + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) @@ -162,7 +201,9 @@ func TestCanScrollUp(t *testing.T) { data: data, frameStart: 0, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{Selection: TestDatum{ID: "1"}} + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) @@ -192,7 +233,9 @@ func TestCanMoveDown(t *testing.T) { data: data, frameStart: 0, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{Selection: TestDatum{ID: "2"}} + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) @@ -222,7 +265,9 @@ func TestCanMoveUp(t *testing.T) { data: data, frameStart: 0, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{Selection: TestDatum{ID: "1"}} + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) @@ -299,7 +344,16 @@ func TestFrameCannotBeLargerThanData(t *testing.T) { data: data, frameStart: 0, } - wantCmd := func() tea.Msg { return msgs.SelectionChangedMsg[TestDatum]{} } + wantCmd := func() tea.Msg { + return tea.BatchMsg{ + func() tea.Msg { + return msgs.SelectionChangedMsg[TestDatum]{ + Selection: TestDatum{ID: "1"}, + } + }, + func() tea.Msg { return msgs.FreeMsg{} }, + } + } verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) } @@ -401,16 +455,5 @@ func verifyUpdate(t *testing.T, gotModel ScrolltableModel[TestDatum], wantModel t.Errorf("Expected and actual models different:\n%s", modelDiff) } - if gotCmd == nil || wantCmd == nil { - if gotCmd != nil || wantCmd != nil { - t.Errorf("Expected and actual cmds different: one is nil and one is non-nil") - } - - return - } - - cmdDiff := cmp.Diff(gotCmd(), wantCmd()) - if modelDiff != "" { - t.Errorf("Expected and actual cmds different:\n%s", cmdDiff) - } + utils.CompareCommands(t, gotCmd, wantCmd) } diff --git a/cmd/cli/tui/textinput/textinput.go b/cmd/cli/tui/textinput/textinput.go index e8b349f..3df4c85 100644 --- a/cmd/cli/tui/textinput/textinput.go +++ b/cmd/cli/tui/textinput/textinput.go @@ -23,6 +23,7 @@ type TextInputModel struct { 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 } @@ -60,6 +61,7 @@ func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { m.blink = true m.cursor = 0 m.frameStart = 0 + m.maxChars = msg.MaxChars m.toEnd() return m, m.blinkCmd() } @@ -69,6 +71,7 @@ func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { m.blink = false m.cursor = 0 m.frameStart = 0 + m.maxChars = 0 case msgs.BlinkMsg: if m.focus && msg.Name == m.name { @@ -144,6 +147,13 @@ func (m TextInputModel) Update(msg tea.Msg) (TextInputModel, tea.Cmd) { } 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 @@ -182,6 +192,10 @@ func (m TextInputModel) available() int { // View renders the model. func (m TextInputModel) View() string { + if m.width-len(m.prompt) <= 0 { + return "" + } + promptStyle := lipgloss. NewStyle(). Bold(true). diff --git a/cmd/cli/tui/textinput/textinput_test.go b/cmd/cli/tui/textinput/textinput_test.go index b3b756b..f7459b0 100644 --- a/cmd/cli/tui/textinput/textinput_test.go +++ b/cmd/cli/tui/textinput/textinput_test.go @@ -7,6 +7,7 @@ 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" ) const name = "TestInput" @@ -505,6 +506,31 @@ func TestCanBlink(t *testing.T) { verifyUpdate(t, gotModel, wantModel, gotCmd, wantCmd) } +func TestCanLimitMaxChars(t *testing.T) { + gotModel := TextInputModel{ + name: name, + sleeper: noopSleeper{}, + width: 2, + } + + 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, + } + + verifyUpdate(t, gotModel, wantModel, nil, nil) +} + func TestText(t *testing.T) { gotModel := TextInputModel{ text: "123", @@ -523,18 +549,7 @@ func verifyUpdate(t *testing.T, gotModel TextInputModel, wantModel TextInputMode t.Errorf("Expected and actual models different:\n%s", modelDiff) } - if gotCmd == nil || wantCmd == nil { - if gotCmd != nil || wantCmd != nil { - t.Errorf("Expected and actual cmds different: one is nil and one is non-nil") - } - - return - } - - cmdDiff := cmp.Diff(gotCmd(), wantCmd()) - if modelDiff != "" { - t.Errorf("Expected and actual cmds different:\n%s", cmdDiff) - } + utils.CompareCommands(t, gotCmd, wantCmd) } type noopSleeper struct{} diff --git a/cmd/cli/tui/utils/cmp_cmd.go b/cmd/cli/tui/utils/cmp_cmd.go new file mode 100644 index 0000000..f07d494 --- /dev/null +++ b/cmd/cli/tui/utils/cmp_cmd.go @@ -0,0 +1,66 @@ +package utils + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/google/go-cmp/cmp" +) + +func CompareCommands(t *testing.T, gotCmd tea.Cmd, wantCmd tea.Cmd) { + if gotCmd == nil || wantCmd == nil { + if gotCmd != nil || wantCmd != nil { + t.Errorf("Expected and actual cmds different: one is nil and one is non-nil") + } + } else if gotBatchCmd, ok := gotCmd().(tea.BatchMsg); ok { + if wantBatchCmd, ok := wantCmd().(tea.BatchMsg); ok { + gotBatches := []tea.BatchMsg{gotBatchCmd} + wantBatches := []tea.BatchMsg{wantBatchCmd} + var gotResults []tea.Msg + var wantResults []tea.Msg + + for len(gotBatches) > 0 || len(wantBatches) > 0 { + if len(gotBatches) > 0 { + var gotBatch tea.BatchMsg + gotBatch, gotBatches = gotBatches[0], gotBatches[1:] + + for _, got := range gotBatch { + result := got() + + if batchResult, ok := result.(tea.BatchMsg); ok { + gotBatches = append(gotBatches, batchResult) + } else { + gotResults = append(gotResults, result) + } + } + } + if len(wantBatches) > 0 { + var wantBatch tea.BatchMsg + wantBatch, wantBatches = wantBatches[0], wantBatches[1:] + + for _, want := range wantBatch { + result := want() + + if batchResult, ok := result.(tea.BatchMsg); ok { + wantBatches = append(wantBatches, batchResult) + } else { + wantResults = append(wantResults, result) + } + } + } + } + + cmdDiff := cmp.Diff(gotResults, wantResults) + if cmdDiff != "" { + t.Errorf("Expected and actual cmds different:\n%s", cmdDiff) + } + } else { + t.Errorf("Expected and actual cmds different: one was batch one wasn't") + } + } else { + cmdDiff := cmp.Diff(gotCmd(), wantCmd()) + if cmdDiff != "" { + t.Errorf("Expected and actual cmds different:\n%s", cmdDiff) + } + } +}