Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: tui #50

Merged
merged 1 commit into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 54 additions & 90 deletions cmd/cli/tui/footer/footer.go → cmd/cli/tui/controls/footer/footer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import (

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/jonathanhope/armaria/cmd/cli/tui/msgs"
"github.com/jonathanhope/armaria/cmd/cli/tui/textinput"
"github.com/jonathanhope/armaria/cmd/cli/tui/controls/textinput"
"github.com/jonathanhope/armaria/cmd/cli/tui/utils"
)

Expand All @@ -33,6 +32,58 @@ func (m FooterModel) InputMode() bool {
return m.inputMode
}

// InputName is the name of the underlying input.
func (m FooterModel) InputName() string {
return m.name + "Input"
}

// Resize changes the size of the footer.
func (m *FooterModel) Resize(width int) {
m.width = width
m.input.Resize(width - HelpInfoWidth)
}

// Insert inserts runes in front of the cursor.
func (m *FooterModel) Insert(runes []rune) tea.Cmd {
return m.input.Insert(runes)
}

// Delete deletes the rune in front of the cursor.
func (m *FooterModel) Delete() tea.Cmd {
return m.input.Delete()
}

// MoveLeft moves the cursor the left once space.
func (m *FooterModel) MoveLeft() {
m.input.MoveLeft()
}

// MoveRight moves the cursor to right once space.
func (m *FooterModel) MoveRight() {
m.input.MoveRight()
}

// StartInputMode switches the footer into accepting input.
func (m *FooterModel) StartInputMode(prompt string, text string, maxChars int) {
m.inputMode = true
m.input.SetPrompt(prompt)
m.input.SetText(text)
m.input.Focus(maxChars)
}

// StopInputMode switches the footer out of accepting input.
func (m *FooterModel) StopInputMode() {
m.inputMode = false
m.input.SetPrompt("")
m.input.SetText("")
m.input.Blur()
}

// SetFilters sets the curently applied filters.
func (m *FooterModel) SetFilters(filters []string) {
m.filters = filters
}

// InitialModel builds the model.
func InitialModel(name string) FooterModel {
inputName := name + "Input"
Expand All @@ -46,72 +97,7 @@ func InitialModel(name string) FooterModel {

// Update handles a message.
func (m FooterModel) Update(msg tea.Msg) (FooterModel, tea.Cmd) {
var cmds []tea.Cmd

switch msg := msg.(type) {

case msgs.SizeMsg:
if msg.Name == m.name {
m.width = msg.Width

var inputCmd tea.Cmd
m.input, inputCmd = m.input.Update(msgs.SizeMsg{
Name: m.inputName,
Width: m.width - HelpInfoWidth,
})
cmds = append(cmds, inputCmd)
}

case msgs.InputModeMsg:
if msg.Name == m.name {
m.inputMode = msg.InputMode

if m.inputMode {
cmds = append(cmds, m.startInputCmd(msg.Prompt, msg.Text, msg.MaxChars))
} else {
cmds = append(cmds, m.endInputCmd())
}
}

case msgs.FiltersMsg:
if msg.Name == m.name {
m.filters = msg.Filters
}

case tea.KeyMsg:
if m.inputMode {
switch msg.String() {
case "ctrl+c":
if m.inputMode {
return m, tea.Quit
}

case "esc":
cmds = append(cmds, func() tea.Msg {
return msgs.InputCancelledMsg{Name: m.name}
})

case "enter":
if m.input.Text() != "" {
cmds = append(cmds, func() tea.Msg {
return msgs.InputConfirmedMsg{Name: m.name}
})
}

default:
var inputCmd tea.Cmd
m.input, inputCmd = m.input.Update(msg)
cmds = append(cmds, inputCmd)
}
}

default:
var inputCmd tea.Cmd
m.input, inputCmd = m.input.Update(msg)
cmds = append(cmds, inputCmd)
}

return m, tea.Batch(cmds...)
return m, nil
}

// View renders the model.
Expand Down Expand Up @@ -155,25 +141,3 @@ func (m FooterModel) View() string {
func (m FooterModel) Init() tea.Cmd {
return nil
}

// startInputCmd is a command that switches the footer to input mode.
func (m FooterModel) startInputCmd(prompt string, text string, maxChars int) tea.Cmd {
return tea.Batch(func() tea.Msg {
return msgs.PromptMsg{Name: m.inputName, Prompt: prompt}
}, func() tea.Msg {
return msgs.TextMsg{Name: m.inputName, Text: text}
}, func() tea.Msg {
return msgs.FocusMsg{Name: m.inputName, MaxChars: maxChars}
})
}

// endInputCmd is a command that switches the footer out of input mode.
func (m FooterModel) endInputCmd() tea.Cmd {
return tea.Batch(func() tea.Msg {
return msgs.BlurMsg{Name: m.inputName}
}, func() tea.Msg {
return msgs.PromptMsg{Name: m.inputName, Prompt: ""}
}, func() tea.Msg {
return msgs.TextMsg{Name: m.inputName, Text: ""}
})
}
89 changes: 89 additions & 0 deletions cmd/cli/tui/controls/footer/footer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package footer

import (
"testing"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/jonathanhope/armaria/cmd/cli/tui/controls/textinput"
)

const Name = "footer"

func TestCanUpdateWidth(t *testing.T) {
gotModel := FooterModel{
name: Name,
inputName: Name + "Input",
input: textinput.TextInputModel{},
}
gotModel.Resize(15)

wantModel := FooterModel{
name: Name,
inputName: Name + "Input",
width: 15,
}

verifyUpdate(t, gotModel, wantModel)
}

func TestCanStartInputMode(t *testing.T) {
gotModel := FooterModel{
name: Name,
inputName: Name + "Input",
}
gotModel.Resize(100)
gotModel.StartInputMode("prompt", "text", 15)

wantModel := FooterModel{
name: Name,
inputName: Name + "Input",
inputMode: true,
width: 100,
}

verifyUpdate(t, gotModel, wantModel)
}

func TestCanEndInputMode(t *testing.T) {
gotModel := FooterModel{
name: Name,
inputName: Name + "Input",
inputMode: true,
}
gotModel.Resize(100)
gotModel.StopInputMode()

wantModel := FooterModel{
name: Name,
inputName: Name + "Input",
inputMode: false,
width: 100,
}

verifyUpdate(t, gotModel, wantModel)
}

func TestCanSetFilters(t *testing.T) {
gotModel := FooterModel{
name: Name,
inputName: Name + "Input",
}
gotModel.SetFilters([]string{"one"})

wantModel := FooterModel{
name: Name,
inputName: Name + "Input",
filters: []string{"one"},
}

verifyUpdate(t, gotModel, wantModel)
}

func verifyUpdate(t *testing.T, gotModel FooterModel, wantModel FooterModel) {
unexported := cmp.AllowUnexported(FooterModel{})
modelDiff := cmp.Diff(gotModel, wantModel, unexported, cmpopts.IgnoreFields(FooterModel{}, "input"))
if modelDiff != "" {
t.Errorf("Expected and actual models different:\n%s", modelDiff)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,44 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/jonathanhope/armaria/cmd/cli/tui/msgs"
"github.com/jonathanhope/armaria/cmd/cli/tui/utils"
)

// HeaderModel is the model for a header.
// The header displays state information such as breadcrumbs for the selected book.
type HeaderModel struct {
name string // name of the header
title string // title of the app
nav string // breadcrumbs for the currently selected book
busy bool // if true the writer is busy
width int // max width of the header
name string // name of the header
title string // title of the app
breadcrumbs string // breadcrumbs for the currently selected book
busy bool // if true the writer is busy
width int // max width of the header
}

// Busy returns whether the writer is busy or not.
func (m HeaderModel) Busy() bool {
return m.busy
}

// SetBusy denotes that the writer is busy.
func (m *HeaderModel) SetBusy() {
m.busy = true
}

// SetBusy denotes that the writer is free.
func (m *HeaderModel) SetFree() {
m.busy = false
}

// SetBreadcrumbs sets the bread crumbs displayed in the header.
func (m *HeaderModel) SetBreadcrumbs(breadcrumbs string) {
m.breadcrumbs = breadcrumbs
}

// Resize changes the size of the header.
func (m *HeaderModel) Resize(width int) {
m.width = width
}

// InitialModel builds the model.
func InitialModel(name string, title string) HeaderModel {
return HeaderModel{
Expand All @@ -33,23 +52,6 @@ func InitialModel(name string, title string) HeaderModel {

// Update handles a message.
func (m HeaderModel) Update(msg tea.Msg) (HeaderModel, tea.Cmd) {
switch msg := msg.(type) {

case msgs.SizeMsg:
if msg.Name == m.name {
m.width = msg.Width
}

case msgs.BreadcrumbsMsg:
m.nav = string(msg)

case msgs.BusyMsg:
m.busy = true

case msgs.FreeMsg:
m.busy = false
}

return m, nil
}

Expand All @@ -66,7 +68,7 @@ func (m HeaderModel) View() string {
}

rows := [][]string{
{title, utils.Substr(m.nav, cellTextWidth)},
{title, utils.Substr(m.breadcrumbs, cellTextWidth)},
}

titleNavStyle := lipgloss.
Expand Down
Loading