From 82ddbb8e12586f1fa64a46826d6c1fb53cd7d9c5 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 12 May 2020 17:05:16 -0400 Subject: [PATCH] Move components over and update examples --- examples/go.mod | 4 +- examples/go.sum | 19 ++- examples/http/main.go | 2 +- examples/input/main.go | 26 ++-- examples/pager/main.go | 2 +- examples/simple/main.go | 2 +- examples/spinner/main.go | 21 ++-- examples/textinputs/main.go | 202 +++++++++++++++++++++++++++++ examples/views/main.go | 44 +++---- go.mod | 1 + go.sum | 7 ++ pager/pager.go | 202 +++++++++++++++++++++++++++++ paginator/paginator.go | 183 +++++++++++++++++++++++++++ spinner/spinner.go | 100 +++++++++++++++ textinput/textinput.go | 244 ++++++++++++++++++++++++++++++++++++ 15 files changed, 1002 insertions(+), 57 deletions(-) create mode 100644 examples/textinputs/main.go create mode 100644 pager/pager.go create mode 100644 paginator/paginator.go create mode 100644 spinner/spinner.go create mode 100644 textinput/textinput.go diff --git a/examples/go.mod b/examples/go.mod index 07e3457..1a5dae9 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -6,8 +6,8 @@ replace github.com/charmbracelet/boba => ../ require ( github.com/charmbracelet/boba v0.0.0-00010101000000-000000000000 - github.com/charmbracelet/tea v0.3.0 - github.com/charmbracelet/teaparty v0.0.0-20200511213328-a72bf9128d83 github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 github.com/muesli/termenv v0.5.2 + golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 // indirect + golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f // indirect ) diff --git a/examples/go.sum b/examples/go.sum index 110b8d3..7b9d7b6 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,11 +1,3 @@ -github.com/charmbracelet/tea v0.0.0-20200130023737-bb06373836b4 h1:O8IGyYrKQuCwZZ98JP3DvzQCoPiXM5Y2zjwlDY7mOFM= -github.com/charmbracelet/tea v0.0.0-20200130023737-bb06373836b4/go.mod h1:UsFFdg04MNbcYi1r2FBtdDEFY07bObaYDKHhE1xZUaQ= -github.com/charmbracelet/tea v0.3.0 h1:W5F1x/IYeSCKpZl3/hM3Mn5v2KAagckabDFhhzh5sIE= -github.com/charmbracelet/tea v0.3.0/go.mod h1:uA/DUzCuyIZ1NFyAdCz6k+gF8lspujo6ZvoavcSsLCM= -github.com/charmbracelet/teaparty v0.0.0-20200212224515-b4d35fd52906 h1:kcvv+hjb0dJiqhtMXkql5tczxalWMPvKIqwWo7cyhiQ= -github.com/charmbracelet/teaparty v0.0.0-20200212224515-b4d35fd52906/go.mod h1:BG6oiwNZL9hB739ZOifRi3ePRGv0nT+kRTfxYLcZj/Y= -github.com/charmbracelet/teaparty v0.0.0-20200511213328-a72bf9128d83 h1:ivIS4ze0LLG9yl9L8cnYerh6dWnqhmBqh1ohmrA3I/I= -github.com/charmbracelet/teaparty v0.0.0-20200511213328-a72bf9128d83/go.mod h1:rlnGPwUokLHs2rQBiIYg6fpLM5m4DvtE2LwzdvS1wsk= github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 h1:VRIbnDWRmAh5yBdz+J6yFMF5vso1It6vn+WmM/5l7MA= github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOfxzWM9Cst40msBF1C2UdQgDv962oTxSuMs= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= @@ -14,16 +6,19 @@ github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tW github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/muesli/termenv v0.4.0/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= github.com/muesli/termenv v0.5.2 h1:N1Y1dHRtx6OizOgaIQXd8SkJl4T/cCOV+YyWXiuLUEA= github.com/muesli/termenv v0.5.2/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942 h1:A7GG7zcGjl3jqAqGPmcNjd/D9hzL95SuoOQAaFNdLU0= github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk= -golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200430202703-d923437fa56d h1:xmcims+WSpFuY56YEzkKF6IMDxYAVDRipkQRJfXUBZk= golang.org/x/sys v0.0.0-20200430202703-d923437fa56d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f h1:mOhmO9WsBaJCNmaZHPtHs9wOcdqdKCjF6OPJlmDM3KI= golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/examples/http/main.go b/examples/http/main.go index d993ef6..67c6970 100644 --- a/examples/http/main.go +++ b/examples/http/main.go @@ -74,7 +74,7 @@ func view(model boba.Model) string { } else if m.status != 0 { s += fmt.Sprintf("%d %s", m.status, http.StatusText(m.status)) } - return s + return s + "\n" } func checkServer() boba.Msg { diff --git a/examples/input/main.go b/examples/input/main.go index 9871a81..755ec02 100644 --- a/examples/input/main.go +++ b/examples/input/main.go @@ -1,14 +1,12 @@ package main -// A simple program that counts down from 5 and then exits. - import ( "errors" "fmt" "log" "github.com/charmbracelet/boba" - "github.com/charmbracelet/teaparty/input" + input "github.com/charmbracelet/boba/textinput" ) type Model struct { @@ -33,8 +31,9 @@ func main() { } func initialize() (boba.Model, boba.Cmd) { - inputModel := input.DefaultModel() + inputModel := input.NewModel() inputModel.Placeholder = "Pikachu" + inputModel.Focus() return Model{ textInput: inputModel, @@ -60,6 +59,8 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { case boba.KeyCtrlC: fallthrough case boba.KeyEsc: + fallthrough + case boba.KeyEnter: return m, boba.Quit } @@ -74,13 +75,16 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { } func subscriptions(model boba.Model) boba.Subs { + m, ok := model.(Model) + if !ok { + return nil + } + sub, err := input.MakeSub(m.textInput) + if err != nil { + return nil + } return boba.Subs{ - // We just hand off the subscription to the input component, giving - // it the model it expects. - "input": func(model boba.Model) boba.Msg { - m, _ := model.(Model) - return input.Blink(m.textInput) - }, + "input": sub, } } @@ -95,5 +99,5 @@ func view(model boba.Model) string { "What’s your favorite Pokémon?\n\n%s\n\n%s", input.View(m.textInput), "(esc to quit)", - ) + ) + "\n" } diff --git a/examples/pager/main.go b/examples/pager/main.go index 77fcf73..8d0affd 100644 --- a/examples/pager/main.go +++ b/examples/pager/main.go @@ -6,7 +6,7 @@ import ( "os" "github.com/charmbracelet/boba" - "github.com/charmbracelet/teaparty/pager" + "github.com/charmbracelet/boba/pager" ) func main() { diff --git a/examples/simple/main.go b/examples/simple/main.go index a751bd3..aa36169 100644 --- a/examples/simple/main.go +++ b/examples/simple/main.go @@ -54,7 +54,7 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { // to the terminal. func view(model boba.Model) string { m, _ := model.(Model) - return fmt.Sprintf("Hi. This program will exit in %d seconds. To quit sooner press any key.", m) + return fmt.Sprintf("Hi. This program will exit in %d seconds. To quit sooner press any key.\n", m) } // This is a subscription which we setup in NewProgram(). It waits for one diff --git a/examples/spinner/main.go b/examples/spinner/main.go index be37058..fbd8719 100644 --- a/examples/spinner/main.go +++ b/examples/spinner/main.go @@ -2,10 +2,10 @@ package main import ( "fmt" - "log" + "os" "github.com/charmbracelet/boba" - "github.com/charmbracelet/teaparty/spinner" + "github.com/charmbracelet/boba/spinner" "github.com/muesli/termenv" ) @@ -14,8 +14,9 @@ var ( ) type Model struct { - spinner spinner.Model - err error + spinner spinner.Model + quitting bool + err error } type errMsg error @@ -23,7 +24,8 @@ type errMsg error func main() { p := boba.NewProgram(initialize, update, view, subscriptions) if err := p.Start(); err != nil { - log.Fatal(err) + fmt.Println(err) + os.Exit(1) } } @@ -51,6 +53,7 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { case "esc": fallthrough case "ctrl+c": + m.quitting = true return m, boba.Quit default: return m, nil @@ -70,7 +73,7 @@ func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { func view(model boba.Model) string { m, ok := model.(Model) if !ok { - return "could not perform assertion on model in view" + return "could not perform assertion on model in view\n" } if m.err != nil { return m.err.Error() @@ -79,7 +82,11 @@ func view(model boba.Model) string { String(spinner.View(m.spinner)). Foreground(color.Color("205")). String() - return fmt.Sprintf("\n\n %s Loading forever...press q to quit\n\n", s) + str := fmt.Sprintf("\n\n %s Loading forever...press q to quit\n\n", s) + if m.quitting { + return str + "\n" + } + return str } func subscriptions(model boba.Model) boba.Subs { diff --git a/examples/textinputs/main.go b/examples/textinputs/main.go new file mode 100644 index 0000000..f99eebe --- /dev/null +++ b/examples/textinputs/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "fmt" + "os" + + "github.com/charmbracelet/boba" + input "github.com/charmbracelet/boba/textinput" + te "github.com/muesli/termenv" +) + +var ( + color = te.ColorProfile().Color + focusedText = "205" + focusedPrompt = te.String("> ").Foreground(color("205")).String() + blurredPrompt = "> " + focusedSubmitButton = "[ " + te.String("Submit").Foreground(color("205")).String() + " ]" + blurredSubmitButton = "[ " + te.String("Submit").Foreground(color("240")).String() + " ]" +) + +func main() { + if err := boba.NewProgram( + initialize, + update, + view, + subscriptions, + ).Start(); err != nil { + fmt.Printf("could not start program: %s\n", err) + os.Exit(1) + } +} + +type Model struct { + index int + nameInput input.Model + nickNameInput input.Model + emailInput input.Model + submitButton string +} + +func initialize() (boba.Model, boba.Cmd) { + name := input.NewModel() + name.Placeholder = "Name" + name.Focus() + name.Prompt = focusedPrompt + name.TextColor = focusedText + + nickName := input.NewModel() + nickName.Placeholder = "Nickname" + nickName.Prompt = blurredPrompt + + email := input.NewModel() + email.Placeholder = "Email" + email.Prompt = blurredPrompt + + return Model{0, name, nickName, email, blurredSubmitButton}, nil +} + +func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { + m, ok := model.(Model) + if !ok { + panic("could not perform assertion on model") + } + + switch msg := msg.(type) { + + case boba.KeyMsg: + switch msg.String() { + + case "ctrl+c": + return m, boba.Quit + + // Cycle between inputs + case "tab": + fallthrough + case "shift+tab": + fallthrough + case "enter": + fallthrough + case "up": + fallthrough + case "down": + inputs := []input.Model{ + m.nameInput, + m.nickNameInput, + m.emailInput, + } + + s := msg.String() + + // Did the user press enter while the submit button was focused? + // If so, exit. + if s == "enter" && m.index == len(inputs) { + return m, boba.Quit + } + + // Cycle indexes + if s == "up" || s == "shift+tab" { + m.index-- + } else { + m.index++ + } + + if m.index > len(inputs) { + m.index = 0 + } else if m.index < 0 { + m.index = len(inputs) + } + + for i := 0; i <= len(inputs)-1; i++ { + if i == m.index { + // Focused input + inputs[i].Focus() + inputs[i].Prompt = focusedPrompt + inputs[i].TextColor = focusedText + continue + } + // Blurred input + inputs[i].Blur() + inputs[i].Prompt = blurredPrompt + inputs[i].TextColor = "" + } + + m.nameInput = inputs[0] + m.nickNameInput = inputs[1] + m.emailInput = inputs[2] + + if m.index == len(inputs) { + m.submitButton = focusedSubmitButton + } else { + m.submitButton = blurredSubmitButton + } + + return m, nil + + default: + // Handle character input + m = updateInputs(msg, m) + return m, nil + } + + default: + // Handle blinks + m = updateInputs(msg, m) + return m, nil + } +} + +func updateInputs(msg boba.Msg, m Model) Model { + m.nameInput, _ = input.Update(msg, m.nameInput) + m.nickNameInput, _ = input.Update(msg, m.nickNameInput) + m.emailInput, _ = input.Update(msg, m.emailInput) + return m +} + +func subscriptions(model boba.Model) boba.Subs { + m, ok := model.(Model) + if !ok { + return nil + } + + // It's a little hacky, but we're using the subscription from one + // input element to handle the blinking for all elements. It doesn't + // have to be this way, we're just feeling a bit lazy at the moment. + inputSub, err := input.MakeSub(m.nameInput) + if err != nil { + return nil + } + + return boba.Subs{ + // It's a little hacky, but we're using the subscription from one + // input element to handle the blinking for all elements. It doesn't + // have to be this way, we're just feeling a bit lazy at the moment. + "blink": inputSub, + } +} + +func view(model boba.Model) string { + m, ok := model.(Model) + if !ok { + return "[error] could not perform assertion on model" + } + + s := "\n" + + inputs := []string{ + input.View(m.nameInput), + input.View(m.nickNameInput), + input.View(m.emailInput), + } + + for i := 0; i < len(inputs); i++ { + s += inputs[i] + if i < len(inputs)-1 { + s += "\n" + } + } + + s += "\n\n" + m.submitButton + "\n" + + return s +} diff --git a/examples/views/main.go b/examples/views/main.go index 1021bdf..90923bf 100644 --- a/examples/views/main.go +++ b/examples/views/main.go @@ -8,12 +8,12 @@ import ( "strings" "time" - "github.com/charmbracelet/tea" + "github.com/charmbracelet/boba" "github.com/fogleman/ease" ) func main() { - p := tea.NewProgram( + p := boba.NewProgram( initialize, update, view, @@ -28,13 +28,13 @@ func main() { type tickMsg time.Time -func newTickMsg(t time.Time) tea.Msg { +func newTickMsg(t time.Time) boba.Msg { return tickMsg(t) } type frameMsg time.Time -func newFrameMsg(t time.Time) tea.Msg { +func newFrameMsg(t time.Time) boba.Msg { return frameMsg(t) } @@ -52,27 +52,27 @@ type Model struct { // INIT -func initialize() (tea.Model, tea.Cmd) { +func initialize() (boba.Model, boba.Cmd) { return Model{0, false, 10, 0, 0, false}, nil } // SUBSCRIPTIONS -func subscriptions(model tea.Model) tea.Subs { +func subscriptions(model boba.Model) boba.Subs { m, _ := model.(Model) if !m.Chosen || m.Loaded { - return tea.Subs{ - "tick": tea.Every(time.Second, newTickMsg), + return boba.Subs{ + "tick": boba.Every(time.Second, newTickMsg), } } - return tea.Subs{ - "frame": tea.Every(time.Second/60, newFrameMsg), + return boba.Subs{ + "frame": boba.Every(time.Second/60, newFrameMsg), } } // UPDATE -func update(msg tea.Msg, model tea.Model) (tea.Model, tea.Cmd) { +func update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { m, _ := model.(Model) if !m.Chosen { @@ -81,10 +81,10 @@ func update(msg tea.Msg, model tea.Model) (tea.Model, tea.Cmd) { return updateChosen(msg, m) } -func updateChoices(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { +func updateChoices(msg boba.Msg, m Model) (boba.Model, boba.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case boba.KeyMsg: switch msg.String() { case "j": fallthrough @@ -108,12 +108,12 @@ func updateChoices(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { case "esc": fallthrough case "ctrl+c": - return m, tea.Quit + return m, boba.Quit } case tickMsg: if m.Ticks == 0 { - return m, tea.Quit + return m, boba.Quit } m.Ticks -= 1 } @@ -121,17 +121,17 @@ func updateChoices(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { return m, nil } -func updateChosen(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { +func updateChosen(msg boba.Msg, m Model) (boba.Model, boba.Cmd) { switch msg := msg.(type) { - case tea.KeyMsg: + case boba.KeyMsg: switch msg.String() { case "q": fallthrough case "esc": fallthrough case "ctrl+c": - return m, tea.Quit + return m, boba.Quit } case frameMsg: @@ -148,7 +148,7 @@ func updateChosen(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { case tickMsg: if m.Loaded { if m.Ticks == 0 { - return m, tea.Quit + return m, boba.Quit } m.Ticks -= 1 } @@ -159,12 +159,12 @@ func updateChosen(msg tea.Msg, m Model) (tea.Model, tea.Cmd) { // VIEW -func view(model tea.Model) string { +func view(model boba.Model) string { m, _ := model.(Model) if !m.Chosen { - return choicesView(m) + return choicesView(m) + "\n" } - return chosenView(m) + return chosenView(m) + "\n" } const choicesTpl = `What to do today? diff --git a/go.mod b/go.mod index ee006e3..bce6b81 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.13 require ( github.com/muesli/termenv v0.5.2 github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942 + golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 golang.org/x/sys v0.0.0-20200430202703-d923437fa56d // indirect ) diff --git a/go.sum b/go.sum index d11647c..50f2d5e 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,13 @@ github.com/muesli/termenv v0.5.2 h1:N1Y1dHRtx6OizOgaIQXd8SkJl4T/cCOV+YyWXiuLUEA= github.com/muesli/termenv v0.5.2/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942 h1:A7GG7zcGjl3jqAqGPmcNjd/D9hzL95SuoOQAaFNdLU0= github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200430202703-d923437fa56d h1:xmcims+WSpFuY56YEzkKF6IMDxYAVDRipkQRJfXUBZk= golang.org/x/sys v0.0.0-20200430202703-d923437fa56d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pager/pager.go b/pager/pager.go new file mode 100644 index 0000000..3a29bfe --- /dev/null +++ b/pager/pager.go @@ -0,0 +1,202 @@ +package pager + +import ( + "errors" + "os" + "strings" + + "github.com/charmbracelet/boba" + "golang.org/x/crypto/ssh/terminal" +) + +func NewProgram(initialContent string) *boba.Program { + return boba.NewProgram( + Init(initialContent), + Update, + View, + nil, + ) +} + +// MSG + +type terminalSizeMsg struct { + width int + height int +} + +type errMsg error + +// MODEL + +type State int + +const ( + StateInit State = iota + StateReady +) + +type Model struct { + Err error + Standalone bool + State State + Width int + Height int + Y int + + lines []string +} + +// Content adds text content to the model +func (m *Model) Content(s string) { + s = strings.TrimSpace(s) + s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings + m.lines = strings.Split(s, "\n") +} + +func NewModel() Model { + return Model{ + State: StateInit, + } +} + +// INIT + +func Init(initialContent string) func() (boba.Model, boba.Cmd) { + m := NewModel() + m.Standalone = true + m.Content(initialContent) + return func() (boba.Model, boba.Cmd) { + return m, getTerminalSize + } +} + +// UPDATE + +func Update(msg boba.Msg, model boba.Model) (boba.Model, boba.Cmd) { + m, ok := model.(Model) + if !ok { + return Model{ + Err: errors.New("could not perform assertion on model in update in pager; are you sure you passed the correct model?"), + }, nil + } + + switch msg := msg.(type) { + + case boba.KeyMsg: + switch msg.String() { + case "q": + fallthrough + case "ctrl+c": + if m.Standalone { + return m, boba.Quit + } + + // Up one page + case "b": + m.Y = max(0, m.Y-m.Height) + return m, nil + + // Down one page + case "space": + fallthrough + case "f": + m.Y = min(len(m.lines)-m.Height, m.Y+m.Height) + return m, nil + + // Up half page + case "u": + m.Y = max(0, m.Y-m.Height/2) + return m, nil + + // Down half page + case "d": + m.Y = min(len(m.lines)-m.Height, m.Y+m.Height/2) + return m, nil + + // Up one line + case "up": + fallthrough + case "k": + m.Y = max(0, m.Y-1) + return m, nil + + // Down one line + case "down": + fallthrough + case "j": + m.Y = min(len(m.lines)-m.Height, m.Y+1) + return m, nil + + // Re-render + case "ctrl+l": + return m, getTerminalSize + + } + + case errMsg: + m.Err = msg + return m, nil + + case terminalSizeMsg: + m.Width = msg.width + m.Height = msg.height + m.State = StateReady + return m, nil + } + + return model, nil +} + +// VIEW + +func View(model boba.Model) string { + m, ok := model.(Model) + if !ok { + return "could not perform assertion on model in view in pager; are you sure you passed the correct model?" + } + + if m.Err != nil { + return m.Err.Error() + } + + if len(m.lines) == 0 { + return "(Buffer empty)" + } + + if m.State == StateReady { + // Render viewport + top := max(0, m.Y) + bottom := min(len(m.lines), m.Y+m.Height) + lines := m.lines[top:bottom] + return "\n" + strings.Join(lines, "\n") + } + + return "" +} + +// CMD + +func getTerminalSize() boba.Msg { + w, h, err := terminal.GetSize(int(os.Stdout.Fd())) + if err != nil { + return errMsg(err) + } + return terminalSizeMsg{w, h} +} + +// ETC + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/paginator/paginator.go b/paginator/paginator.go new file mode 100644 index 0000000..9350937 --- /dev/null +++ b/paginator/paginator.go @@ -0,0 +1,183 @@ +// package paginator provides a Boba package for calulating pagination and +// rendering pagination info. Note that this package does not render actual +// pages: it's purely for handling keystrokes related to pagination, and +// rendering pagination status. +package paginator + +import ( + "fmt" + + "github.com/charmbracelet/boba" +) + +// PaginatorType specifies the way we render pagination +type PaginatorType int + +// Pagination rendering options +const ( + Arabic PaginatorType = iota + Dots +) + +// Model is the Boba model for this user interface +type Model struct { + Type PaginatorType + Page int + PerPage int + TotalPages int + ActiveDot string + InactiveDot string + ArabicFormat string + UseLeftRightKeys bool + UseUpDownKeys bool + UseHLKeys bool + UseJKKeys bool +} + +// SetTotalPages is a helper function for calculatng the total number of pages +// from a given number of items. It's use is optional since this pager can be +// used for other things beyond navigating sets. Note that it both returns the +// number of total pages and alters the model. +func (m *Model) SetTotalPages(items int) int { + if items == 0 { + return 0 + } + n := items / m.PerPage + if items%m.PerPage > 0 { + n += 1 + } + m.TotalPages = n + return n +} + +// ItemsOnPage is a helper function fro returning the numer of items on the +// current page given the total numer of items passed as an argument. +func (m Model) ItemsOnPage(totalItems int) int { + start, end := m.GetSliceBounds(totalItems) + return end - start +} + +// GetSliceBounds is a helper function for paginating slices. Pass the length +// of the slice you're rendering and you'll receive the start and end bounds +// corresponding the to pagination. For example: +// +// bunchOfStuff := []stuff{...} +// start, end := model.GetSliceBounds(len(bunchOfStuff)) +// sliceToRender := bunchOfStuff[start:end] +// +func (m *Model) GetSliceBounds(length int) (start int, end int) { + start = m.Page * m.PerPage + end = min(m.Page*m.PerPage+m.PerPage, length) + return start, end +} + +// PrevPage is a number function for navigating one page backward. It will not +// page beyond the first page (i.e. page 0). +func (m *Model) PrevPage() { + if m.Page > 0 { + m.Page-- + } +} + +// NextPage is a helper function for navigating one page forward. It will not +// page beyond the last page (i.e. totalPages - 1). +func (m *Model) NextPage() { + if m.Page < m.TotalPages-1 { + m.Page++ + } +} + +// NewModel creates a new model with defaults +func NewModel() Model { + return Model{ + Type: Arabic, + Page: 0, + PerPage: 1, + TotalPages: 1, + ActiveDot: "•", + InactiveDot: "○", + ArabicFormat: "%d/%d", + UseLeftRightKeys: true, + UseUpDownKeys: false, + UseHLKeys: true, + UseJKKeys: false, + } +} + +// Update is the Boba update function which binds keystrokes to pagination +func Update(msg boba.Msg, m Model) (Model, boba.Cmd) { + switch msg := msg.(type) { + case boba.KeyMsg: + if m.UseLeftRightKeys { + switch msg.String() { + case "left": + m.PrevPage() + case "right": + m.NextPage() + } + } + if m.UseUpDownKeys { + switch msg.String() { + case "up": + m.PrevPage() + case "down": + m.NextPage() + } + } + if m.UseHLKeys { + switch msg.String() { + case "h": + m.PrevPage() + case "l": + m.NextPage() + } + } + if m.UseJKKeys { + switch msg.String() { + case "j": + m.PrevPage() + case "k": + m.NextPage() + } + } + } + + return m, nil +} + +// View renders the pagination to a string +func View(model boba.Model) string { + m, ok := model.(Model) + if !ok { + return "could not perform assertion on model" + } + switch m.Type { + case Dots: + return dotsView(m) + default: + return arabicView(m) + } +} + +func dotsView(m Model) string { + var s string + for i := 0; i < m.TotalPages; i++ { + if i == m.Page { + s += m.ActiveDot + continue + } + s += m.InactiveDot + } + return s +} + +func arabicView(m Model) string { + return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/spinner/spinner.go b/spinner/spinner.go new file mode 100644 index 0000000..3071591 --- /dev/null +++ b/spinner/spinner.go @@ -0,0 +1,100 @@ +package spinner + +import ( + "errors" + "time" + + "github.com/charmbracelet/boba" + "github.com/muesli/termenv" +) + +// Spinner denotes a type of spinner +type Spinner = int + +// Available types of spinners +const ( + Line Spinner = iota + Dot +) + +var ( + // Spinner frames + spinners = map[Spinner][]string{ + Line: {"|", "/", "-", "\\"}, + Dot: {"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "}, + } + + assertionErr = errors.New("could not perform assertion on model to what the spinner expects. are you sure you passed the right value?") + + color = termenv.ColorProfile().Color +) + +// Model contains the state for the spinner. Use NewModel to create new models +// rather than using Model as a struct literal. +type Model struct { + Type Spinner + FPS int + ForegroundColor string + BackgroundColor string + + frame int +} + +// NewModel returns a model with default values +func NewModel() Model { + return Model{ + Type: Line, + FPS: 9, + frame: 0, + } +} + +// TickMsg indicates that the timer has ticked and we should render a frame +type TickMsg time.Time + +// Update is the Boba update function +func Update(msg boba.Msg, m Model) (Model, boba.Cmd) { + switch msg.(type) { + case TickMsg: + m.frame++ + if m.frame >= len(spinners[m.Type]) { + m.frame = 0 + } + return m, nil + default: + return m, nil + } +} + +// View renders the model's view +func View(model Model) string { + s := spinners[model.Type] + if model.frame >= len(s) { + return "[error]" + } + + str := s[model.frame] + + if model.ForegroundColor != "" || model.BackgroundColor != "" { + return termenv. + String(str). + Foreground(color(model.ForegroundColor)). + Background(color(model.BackgroundColor)). + String() + } + + return str +} + +// GetSub creates the subscription that allows the spinner to spin. Remember +// that you need to execute this function in order to get the subscription +// you'll need. +func MakeSub(model boba.Model) (boba.Sub, error) { + m, ok := model.(Model) + if !ok { + return nil, assertionErr + } + return boba.Tick(time.Second/time.Duration(m.FPS), func(t time.Time) boba.Msg { + return TickMsg(t) + }), nil +} diff --git a/textinput/textinput.go b/textinput/textinput.go new file mode 100644 index 0000000..440da99 --- /dev/null +++ b/textinput/textinput.go @@ -0,0 +1,244 @@ +package textinput + +import ( + "errors" + "time" + + "github.com/charmbracelet/boba" + "github.com/muesli/termenv" +) + +var ( + // color is a helper for returning colors + color func(s string) termenv.Color = termenv.ColorProfile().Color +) + +// ErrMsg indicates there's been an error. We don't handle errors in the this +// package; we're expecting errors to be handle in the program that implements +// this text input. +type ErrMsg error + +// Model is the Boba model for this text input element +type Model struct { + Err error + Prompt string + Value string + Cursor string + BlinkSpeed time.Duration + Placeholder string + TextColor string + PlaceholderColor string + CursorColor string + + // CharLimit is the maximum amount of characters this input element will + // accept. If 0 or less, there's no limit. + CharLimit int + + // Focus indicates whether user input focus should be on this input + // component. When false, don't blink and ignore keyboard input. + focus bool + + blink bool + pos int +} + +// Focused returns the focus state on the model +func (m Model) Focused() bool { + return m.focus +} + +// Focus sets the focus state on the model +func (m *Model) Focus() { + m.focus = true + m.blink = false +} + +// Blur removes the focus state on the model +func (m *Model) Blur() { + m.focus = false + m.blink = true +} + +// colorText colorizes a given string according to the TextColor value of the +// model +func (m *Model) colorText(s string) string { + return termenv. + String(s). + Foreground(color(m.TextColor)). + String() +} + +// colorPlaceholder colorizes a given string according to the TextColor value +// of the model +func (m *Model) colorPlaceholder(s string) string { + return termenv. + String(s). + Foreground(color(m.PlaceholderColor)). + String() +} + +// CursorBlinkMsg is sent when the cursor should alternate it's blinking state +type CursorBlinkMsg struct{} + +// NewModel creates a new model with default settings +func NewModel() Model { + return Model{ + Prompt: "> ", + Value: "", + BlinkSpeed: time.Millisecond * 600, + Placeholder: "", + TextColor: "", + PlaceholderColor: "240", + CursorColor: "", + CharLimit: 0, + + focus: false, + blink: true, + pos: 0, + } +} + +// Update is the Boba update loop +func Update(msg boba.Msg, m Model) (Model, boba.Cmd) { + if !m.focus { + m.blink = true + return m, nil + } + + switch msg := msg.(type) { + + case boba.KeyMsg: + switch msg.Type { + case boba.KeyBackspace: + fallthrough + case boba.KeyDelete: + if len(m.Value) > 0 { + m.Value = m.Value[:m.pos-1] + m.Value[m.pos:] + m.pos-- + } + return m, nil + case boba.KeyLeft: + if m.pos > 0 { + m.pos-- + } + return m, nil + case boba.KeyRight: + if m.pos < len(m.Value) { + m.pos++ + } + return m, nil + case boba.KeyCtrlF: // ^F, forward one character + fallthrough + case boba.KeyCtrlB: // ^B, back one charcter + fallthrough + case boba.KeyCtrlA: // ^A, go to beginning + m.pos = 0 + return m, nil + case boba.KeyCtrlD: // ^D, delete char under cursor + if len(m.Value) > 0 && m.pos < len(m.Value) { + m.Value = m.Value[:m.pos] + m.Value[m.pos+1:] + } + return m, nil + case boba.KeyCtrlE: // ^E, go to end + m.pos = len(m.Value) + return m, nil + case boba.KeyCtrlK: // ^K, kill text after cursor + m.Value = m.Value[:m.pos] + m.pos = len(m.Value) + return m, nil + case boba.KeyCtrlU: // ^U, kill text before cursor + m.Value = m.Value[m.pos:] + m.pos = 0 + return m, nil + case boba.KeyRune: // input a regular character + if m.CharLimit <= 0 || len(m.Value) < m.CharLimit { + m.Value = m.Value[:m.pos] + string(msg.Rune) + m.Value[m.pos:] + m.pos++ + } + return m, nil + default: + return m, nil + } + + case ErrMsg: + m.Err = msg + return m, nil + + case CursorBlinkMsg: + m.blink = !m.blink + return m, nil + + default: + return m, nil + } +} + +// View renders the textinput in its current state +func View(model boba.Model) string { + m, ok := model.(Model) + if !ok { + return "could not perform assertion on model" + } + + // Placeholder text + if m.Value == "" && m.Placeholder != "" { + return placeholderView(m) + } + + v := m.colorText(m.Value[:m.pos]) + + if m.pos < len(m.Value) { + v += cursorView(string(m.Value[m.pos]), m) + v += m.colorText(m.Value[m.pos+1:]) + } else { + v += cursorView(" ", m) + } + return m.Prompt + v +} + +// placeholderView +func placeholderView(m Model) string { + var ( + v string + p = m.Placeholder + ) + + // Cursor + if m.blink && m.PlaceholderColor != "" { + v += cursorView( + m.colorPlaceholder(p[:1]), + m, + ) + } else { + v += cursorView(p[:1], m) + } + + // The rest of the placeholder text + v += m.colorPlaceholder(p[1:]) + + return m.Prompt + v +} + +// cursorView style the cursor +func cursorView(s string, m Model) string { + if m.blink { + return s + } + return termenv.String(s). + Foreground(color(m.CursorColor)). + Reverse(). + String() +} + +// MakeSub return a subscription that lets us know when to alternate the +// blinking of the cursor. +func MakeSub(model boba.Model) (boba.Sub, error) { + m, ok := model.(Model) + if !ok { + return nil, errors.New("could not assert given model to the model we expected; make sure you're passing as input model") + } + return func() boba.Msg { + time.Sleep(m.BlinkSpeed) + return CursorBlinkMsg{} + }, nil +}