diff --git a/bubbles.go b/bubbles.go new file mode 100644 index 0000000..9b9e8d4 --- /dev/null +++ b/bubbles.go @@ -0,0 +1 @@ +package bubbles diff --git a/examples/go.mod b/examples/go.mod deleted file mode 100644 index f90d5c1..0000000 --- a/examples/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module examples - -go 1.13 - -replace github.com/charmbracelet/teaparty => ../ - -require ( - github.com/charmbracelet/tea v0.3.0 - github.com/charmbracelet/teaparty v0.0.0-20200212224515-b4d35fd52906 - github.com/muesli/termenv v0.5.2 -) diff --git a/examples/go.sum b/examples/go.sum deleted file mode 100644 index dba7e54..0000000 --- a/examples/go.sum +++ /dev/null @@ -1,32 +0,0 @@ -github.com/charmbracelet/tea v0.0.0-20200220032354-23432f30dd46 h1:ZRxnZ9WA8+DX7IpIrUjaAzue12wCA8bHgDWSrqARvGY= -github.com/charmbracelet/tea v0.0.0-20200220032354-23432f30dd46/go.mod h1:96S8RyGdd7HfPcgDC8XqPKe+eH/1etFkw6lfhQCesgU= -github.com/charmbracelet/tea v0.2.0 h1:PvoNZa2N5HeHnUELfEmZxLO+gmDgjNcrDLNHUMsSuXM= -github.com/charmbracelet/tea v0.2.0/go.mod h1:lADjwO2mMub9qvXSCA9vAkabVWO0HeUrv4uO/lG3C+k= -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/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= -github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= -github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= -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.1-0.20200131124310-936567584c3e h1:6exaizu60WAeTP998SgBox6eaSs6rT36fR/0ebsrnOQ= -github.com/muesli/termenv v0.4.1-0.20200131124310-936567584c3e/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= -github.com/muesli/termenv v0.4.1-0.20200225030536-aeb2025df636 h1:E8Ak48QMa0KO1r/IaPrpUxD+mEcOXB+4Q1ySBx04P6k= -github.com/muesli/termenv v0.4.1-0.20200225030536-aeb2025df636/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= -github.com/muesli/termenv v0.5.0 h1:7uYkBUJ2EE+CdWlBoYzO70I/cq/r3bOm4a40/lvmiAU= -github.com/muesli/termenv v0.5.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/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200120151820-655fe14d7479 h1:LhLiKguPgZL+Tglay4GhVtfF0kb8cvOJ0dHTCBO8YNI= -golang.org/x/sys v0.0.0-20200120151820-655fe14d7479/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -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= diff --git a/examples/textinputs/main.go b/examples/textinputs/main.go deleted file mode 100644 index dae8594..0000000 --- a/examples/textinputs/main.go +++ /dev/null @@ -1,202 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/charmbracelet/tea" - input "github.com/charmbracelet/teaparty/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 := tea.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() (tea.Model, tea.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 tea.Msg, model tea.Model) (tea.Model, tea.Cmd) { - m, ok := model.(Model) - if !ok { - panic("could not perform assertion on model") - } - - switch msg := msg.(type) { - - case tea.KeyMsg: - switch msg.String() { - - case "ctrl+c": - return m, tea.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, tea.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 tea.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 tea.Model) tea.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 tea.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 tea.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/go.mod b/go.mod index 172938b..b8f9023 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,8 @@ -module github.com/charmbracelet/teaparty +module github.com/charmbracelet/bubbles go 1.13 require ( - github.com/charmbracelet/tea v0.3.0 + github.com/charmbracelet/bubbletea v0.6.4-0.20200525234836-3b8b011b5a26 github.com/muesli/termenv v0.5.2 - golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f // indirect ) - -replace github.com/charmbracelet/tea => ../tea diff --git a/go.sum b/go.sum index b584ead..70be335 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,22 @@ -github.com/charmbracelet/tea v0.2.0 h1:PvoNZa2N5HeHnUELfEmZxLO+gmDgjNcrDLNHUMsSuXM= -github.com/charmbracelet/tea v0.2.0/go.mod h1:lADjwO2mMub9qvXSCA9vAkabVWO0HeUrv4uO/lG3C+k= +github.com/charmbracelet/bubbletea v0.6.4-0.20200525234836-3b8b011b5a26 h1:CUqznYQaIkZzgsrBe7QKIudrr+Wvtbe8t+K6ygEKcPc= +github.com/charmbracelet/bubbletea v0.6.4-0.20200525234836-3b8b011b5a26/go.mod h1:BTzHOUvUlKecQz7ZB8NgPRWi2Z8NRCV04qwyFOfO1Kk= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= 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.5.0 h1:7uYkBUJ2EE+CdWlBoYzO70I/cq/r3bOm4a40/lvmiAU= -github.com/muesli/termenv v0.5.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= +github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 h1:pd4YKIqCB0U7O2I4gWHgEUA2mCEOENmco0l/bM957bU= +github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03/go.mod h1:Z9+Ul5bCbBKnbCvdOWbLqTHhJiYV414CURZJba6L8qA= +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-20200331124033-c3d80250170d h1:nc5K6ox/4lTFbMVSL9WRR81ixkcwXThoiF6yf+R9scA= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/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/sys v0.0.0-20200519105757-fe76b779f299 h1:DYfZAGf2WMFjMxbgTjaC+2HC7NkNAQs+6Q8b9WEB/F4= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pager/pager.go b/paginator/paginator.go similarity index 80% rename from pager/pager.go rename to paginator/paginator.go index 79a8222..ee6b9eb 100644 --- a/pager/pager.go +++ b/paginator/paginator.go @@ -1,27 +1,27 @@ -// package pager provides a Tea 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 pager +// package paginator provides a Bubble Tea 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/tea" + tea "github.com/charmbracelet/bubbletea" ) -// PagerType specifies the way we render pagination -type PagerType int +// Type specifies the way we render pagination. +type Type int // Pagination rendering options const ( - Arabic PagerType = iota + Arabic Type = iota Dots ) -// Model is the Tea model for this user interface +// Model is the Tea model for this user interface. type Model struct { - Type PagerType + Type Type Page int PerPage int TotalPages int @@ -44,13 +44,13 @@ func (m *Model) SetTotalPages(items int) int { } n := items / m.PerPage if items%m.PerPage > 0 { - n += 1 + n++ } m.TotalPages = n return n } -// ItemsOnPage is a helper function fro returning the numer of items on the +// ItemsOnPage is a helper function for 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) @@ -82,12 +82,17 @@ func (m *Model) PrevPage() { // 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 { + if !m.OnLastPage() { m.Page++ } } -// NewModel creates a new model with defaults +// LastPage returns whether or not we're on the last page. +func (m Model) OnLastPage() bool { + return m.Page == m.TotalPages-1 +} + +// NewModel creates a new model with defaults. func NewModel() Model { return Model{ Type: Arabic, @@ -104,7 +109,7 @@ func NewModel() Model { } } -// Update is the Tea update function which binds keystrokes to pagination +// Update is the Tea update function which binds keystrokes to pagination. func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: @@ -145,7 +150,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { return m, nil } -// View renders the pagination to a string +// View renders the pagination to a string. func View(model tea.Model) string { m, ok := model.(Model) if !ok { diff --git a/spinner/spinner.go b/spinner/spinner.go index 0bfd06c..2f7bb73 100644 --- a/spinner/spinner.go +++ b/spinner/spinner.go @@ -1,14 +1,13 @@ package spinner import ( - "errors" "time" - "github.com/charmbracelet/tea" + tea "github.com/charmbracelet/bubbletea" "github.com/muesli/termenv" ) -// Spinner denotes a type of spinner +// Spinner is a set of frames used in animating the spinner. type Spinner = int // Available types of spinners @@ -17,6 +16,10 @@ const ( Dot ) +const ( + defaultFPS = 9 +) + var ( // Spinner frames spinners = map[Spinner][]string{ @@ -24,49 +27,66 @@ var ( 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 + + // Type is the set of frames to use. See Spinner. + Type Spinner + + // FPS is the speed at which the ticker should tick + FPS int + + // ForegroundColor sets the background color of the spinner. It can be a + // hex code or one of the 256 ANSI colors. If the terminal emulator can't + // doesn't support the color specified it will automatically degrade + // (per github.com/muesli/termenv). ForegroundColor string + + // BackgroundColor sets the background color of the spinner. It can be a + // hex code or one of the 256 ANSI colors. If the terminal emulator can't + // doesn't support the color specified it will automatically degrade + // (per github.com/muesli/termenv). BackgroundColor string + // CustomMsgFunc can be used to a custom message on tick. This can be + // useful when you have spinners in different parts of your application and + // want to differentiate between the messages for clarity and simplicity. + // If nil, this setting is ignored. + CustomMsgFunc func() tea.Msg + frame int } -// NewModel returns a model with default values +// NewModel returns a model with default values. func NewModel() Model { return Model{ - Type: Line, - FPS: 9, - frame: 0, + Type: Line, + FPS: defaultFPS, } } -// TickMsg indicates that the timer has ticked and we should render a frame -type TickMsg time.Time +// TickMsg indicates that the timer has ticked and we should render a frame. +type TickMsg struct{} -// Update is the Tea update function +// Update is the Tea update function. This will advance the spinner one frame +// every time it's called, regardless the message passed, so be sure the logic +// is setup so as not to call this Update needlessly. func Update(msg tea.Msg, m Model) (Model, tea.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 + m.frame++ + if m.frame >= len(spinners[m.Type]) { + m.frame = 0 } + if m.CustomMsgFunc != nil { + return m, Tick(m) + } + return m, Tick(m) } -// View renders the model's view +// View renders the model's view. func View(model Model) string { s := spinners[model.Type] if model.frame >= len(s) { @@ -86,15 +106,13 @@ func View(model Model) 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 tea.Model) (tea.Sub, error) { - m, ok := model.(Model) - if !ok { - return nil, assertionErr +// Tick is the command used to advance the spinner one frame. +func Tick(model Model) tea.Cmd { + return func() tea.Msg { + time.Sleep(time.Second / time.Duration(model.FPS)) + if model.CustomMsgFunc != nil { + return model.CustomMsgFunc() + } + return TickMsg{} } - return tea.Tick(time.Second/time.Duration(m.FPS), func(t time.Time) tea.Msg { - return TickMsg(t) - }), nil } diff --git a/teaparty.go b/teaparty.go deleted file mode 100644 index e9e3cd5..0000000 --- a/teaparty.go +++ /dev/null @@ -1 +0,0 @@ -package teaparty diff --git a/textinput/textinput.go b/textinput/textinput.go index 0233b66..3d4a6c7 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -1,13 +1,18 @@ package textinput import ( - "errors" + "strings" "time" + "unicode" - "github.com/charmbracelet/tea" + tea "github.com/charmbracelet/bubbletea" "github.com/muesli/termenv" ) +const ( + defaultBlinkSpeed = time.Millisecond * 600 +) + var ( // color is a helper for returning colors color func(s string) termenv.Color = termenv.ColorProfile().Color @@ -18,15 +23,15 @@ var ( // this text input. type ErrMsg error -// Model is the Tea model for this text input element +// Model is the Tea model for this text input element. type Model struct { Err error Prompt string - Value string Cursor string BlinkSpeed time.Duration Placeholder string TextColor string + BackgroundColor string PlaceholderColor string CursorColor string @@ -34,71 +39,199 @@ type Model struct { // accept. If 0 or less, there's no limit. CharLimit int + // Width is the maximum number of characters that can be displayed at once. + // It essentially treats the text field like a horizontally scrolling + // viewport. If 0 or less this setting is ignored. + Width int + + // Underlying text value + value string + // Focus indicates whether user input focus should be on this input // component. When false, don't blink and ignore keyboard input. focus bool + // Cursor blink state blink bool - pos int + + // Cursor position + pos int + + // Used to emulate a viewport when width is set and the content is + // overflowing + offset int } -// Focused returns the focus state on the model +// SetValue sets the value of the text input. +func (m *Model) SetValue(s string) { + if m.CharLimit > 0 && len(s) > m.CharLimit { + m.value = s[:m.CharLimit] + } else { + m.value = s + } + if m.pos > len(m.value) { + m.pos = len(m.value) + } + m.handleOverflow() +} + +// Value returns the value of the text input. +func (m Model) Value() string { + return m.value +} + +// Cursor start moves the cursor to the given position. If the position is out +// of bounds the cursor will be moved to the start or end accordingly. +func (m *Model) SetCursor(pos int) { + m.pos = max(0, min(len(m.value), pos)) + m.handleOverflow() +} + +// CursorStart moves the cursor to the start of the field. +func (m *Model) CursorStart() { + m.pos = 0 + m.handleOverflow() +} + +// CursorEnd moves the cursor to the end of the field. +func (m *Model) CursorEnd() { + m.pos = len(m.value) + m.handleOverflow() +} + +// Focused returns the focus state on the model. func (m Model) Focused() bool { return m.focus } -// Focus sets the focus state on the model +// 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 +// Blur removes the focus state on the model. func (m *Model) Blur() { m.focus = false m.blink = true } +// Reset sets the input to its default state with no input. +func (m *Model) Reset() { + m.value = "" + m.offset = 0 + m.pos = 0 + m.blink = false +} + +// If a max width is defined, perform some logic to treat the visible area +// as a horizontally scrolling viewport. +func (m *Model) handleOverflow() { + if m.Width > 0 { + overflow := max(0, len(m.value)-m.Width) + + if overflow > 0 && m.pos < m.offset { + m.offset = max(0, min(len(m.value), m.pos)) + } else if overflow > 0 && m.pos >= m.offset+m.Width { + m.offset = max(0, m.pos-m.Width) + } + } +} + // colorText colorizes a given string according to the TextColor value of the -// model +// model. func (m *Model) colorText(s string) string { return termenv. String(s). Foreground(color(m.TextColor)). + Background(color(m.BackgroundColor)). String() } // colorPlaceholder colorizes a given string according to the TextColor value -// of the model +// of the model. func (m *Model) colorPlaceholder(s string) string { return termenv. String(s). Foreground(color(m.PlaceholderColor)). + Background(color(m.BackgroundColor)). String() } -// CursorBlinkMsg is sent when the cursor should alternate it's blinking state -type CursorBlinkMsg struct{} +func (m *Model) wordLeft() { + if m.pos == 0 || len(m.value) == 0 { + return + } -// NewModel creates a new model with default settings + i := m.pos - 1 + + for i >= 0 { + if unicode.IsSpace(rune(m.value[i])) { + m.pos-- + i-- + } else { + break + } + } + + for i >= 0 { + if !unicode.IsSpace(rune(m.value[i])) { + m.pos-- + i-- + } else { + break + } + } +} + +func (m *Model) wordRight() { + if m.pos >= len(m.value) || len(m.value) == 0 { + return + } + + i := m.pos + + for i < len(m.value) { + if unicode.IsSpace(rune(m.value[i])) { + m.pos++ + i++ + } else { + break + } + } + + for i < len(m.value) { + if !unicode.IsSpace(rune(m.value[i])) { + m.pos++ + i++ + } else { + break + } + } +} + +// BlinkMsg is sent when the cursor should alternate it's blinking state. +type BlinkMsg struct{} + +// NewModel creates a new model with default settings. func NewModel() Model { return Model{ Prompt: "> ", - Value: "", - BlinkSpeed: time.Millisecond * 600, + BlinkSpeed: defaultBlinkSpeed, Placeholder: "", TextColor: "", PlaceholderColor: "240", CursorColor: "", CharLimit: 0, + value: "", focus: false, blink: true, pos: 0, } } -// Update is the Tea update loop +// Update is the Tea update loop. func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { if !m.focus { m.blink = true @@ -106,74 +239,84 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { } switch msg := msg.(type) { - case tea.KeyMsg: switch msg.Type { case tea.KeyBackspace: fallthrough case tea.KeyDelete: - if len(m.Value) > 0 { - m.Value = m.Value[:m.pos-1] + m.Value[m.pos:] + if len(m.value) > 0 { + m.value = m.value[:m.pos-1] + m.value[m.pos:] m.pos-- } - return m, nil case tea.KeyLeft: + if msg.Alt { // alt+left arrow, back one word + m.wordLeft() + break + } if m.pos > 0 { m.pos-- } - return m, nil case tea.KeyRight: - if m.pos < len(m.Value) { + if msg.Alt { // alt+right arrow, forward one word + m.wordRight() + break + } + if m.pos < len(m.value) { m.pos++ } - return m, nil case tea.KeyCtrlF: // ^F, forward one character fallthrough case tea.KeyCtrlB: // ^B, back one charcter fallthrough case tea.KeyCtrlA: // ^A, go to beginning - m.pos = 0 - return m, nil + m.CursorStart() case tea.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:] + 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 tea.KeyCtrlE: // ^E, go to end - m.pos = len(m.Value) - return m, nil + m.CursorEnd() case tea.KeyCtrlK: // ^K, kill text after cursor - m.Value = m.Value[:m.pos] - m.pos = len(m.Value) - return m, nil + m.value = m.value[:m.pos] + m.pos = len(m.value) case tea.KeyCtrlU: // ^U, kill text before cursor - m.Value = m.Value[m.pos:] + m.value = m.value[m.pos:] m.pos = 0 - return m, nil + m.offset = 0 case tea.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:] + + if msg.Alt { + if msg.Rune == 'b' { // alt+b, back one word + m.wordLeft() + break + } + if msg.Rune == 'f' { // alt+f, forward one word + m.wordRight() + break + } + } + + // 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: + case BlinkMsg: m.blink = !m.blink - return m, nil - - default: - return m, nil + return m, Blink(m) } + + m.handleOverflow() + + return m, nil } -// View renders the textinput in its current state +// View renders the textinput in its current state. func View(model tea.Model) string { m, ok := model.(Model) if !ok { @@ -181,18 +324,42 @@ func View(model tea.Model) string { } // Placeholder text - if m.Value == "" && m.Placeholder != "" { + if m.value == "" && m.Placeholder != "" { return placeholderView(m) } - v := m.colorText(m.Value[:m.pos]) + left := m.offset + right := 0 + if m.Width > 0 { + right = min(len(m.value), m.offset+m.Width+1) + } else { + right = len(m.value) + } + value := m.value[left:right] + pos := m.pos - m.offset - if m.pos < len(m.Value) { - v += cursorView(string(m.Value[m.pos]), m) - v += m.colorText(m.Value[m.pos+1:]) + v := m.colorText(value[:pos]) + + if pos < len(value) { + v += cursorView(string(value[pos]), m) // cursor and text under it + v += m.colorText(value[pos+1:]) // text after cursor } else { v += cursorView(" ", m) } + + // If a max width and background color were set fill the empty spaces with + // the background color. + if m.Width > 0 && len(m.BackgroundColor) > 0 && len(value) <= m.Width { + padding := m.Width - len(value) + if len(value)+padding <= m.Width && pos < len(value) { + padding++ + } + v += strings.Repeat( + termenv.String(" ").Background(color(m.BackgroundColor)).String(), + padding, + ) + } + return m.Prompt + v } @@ -219,26 +386,42 @@ func placeholderView(m Model) string { return m.Prompt + v } -// cursorView style the cursor +// cursorView styles the cursor. func cursorView(s string, m Model) string { if m.blink { + if m.TextColor != "" || m.BackgroundColor != "" { + return termenv.String(s). + Foreground(color(m.TextColor)). + Background(color(m.BackgroundColor)). + String() + } return s } return termenv.String(s). Foreground(color(m.CursorColor)). + Background(color(m.BackgroundColor)). Reverse(). String() } -// MakeSub return a subscription that lets us know when to alternate the -// blinking of the cursor. -func MakeSub(model tea.Model) (tea.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") - } +// Blink is a command used to time the cursor blinking. +func Blink(model Model) tea.Cmd { return func() tea.Msg { - time.Sleep(m.BlinkSpeed) - return CursorBlinkMsg{} - }, nil + time.Sleep(model.BlinkSpeed) + return BlinkMsg{} + } +} + +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/viewport/viewport.go b/viewport/viewport.go new file mode 100644 index 0000000..b56c251 --- /dev/null +++ b/viewport/viewport.go @@ -0,0 +1,169 @@ +package viewport + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// MODEL + +type Model struct { + Err error + Width int + Height int + Y int + + lines []string +} + +// Scrollpercent returns the amount scrolled as a float between 0 and 1. +func (m Model) ScrollPercent() float64 { + if m.Height >= len(m.lines) { + return 1.0 + } + y := float64(m.Y) + h := float64(m.Height) + t := float64(len(m.lines)) + return y / (t - h) +} + +// SetContent set the pager's text content. +func (m *Model) SetContent(s string) { + s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings + m.lines = strings.Split(s, "\n") +} + +// NewModel creates a new pager model. Pass the dimensions of the pager. +func NewModel(width, height int) Model { + return Model{ + Width: width, + Height: height, + } +} + +// ViewDown moves the view down by the number of lines in the viewport. +// Basically, "page down". +func (m *Model) ViewDown() { + m.Y = min(len(m.lines)-m.Height, m.Y+m.Height) +} + +// ViewUp moves the view up by one height of the viewport. Basically, "page up". +func (m *Model) ViewUp() { + m.Y = max(0, m.Y-m.Height) +} + +// HalfViewUp moves the view up by half the height of the viewport. +func (m *Model) HalfViewUp() { + m.Y = max(0, m.Y-m.Height/2) +} + +// HalfViewDown moves the view down by half the height of the viewport. +func (m *Model) HalfViewDown() { + m.Y = min(len(m.lines)-m.Height, m.Y+m.Height/2) +} + +// LineDown moves the view up by the given number of lines. +func (m *Model) LineDown(n int) { + m.Y = min(len(m.lines)-m.Height, m.Y+n) +} + +// LineDown moves the view down by the given number of lines. +func (m *Model) LineUp(n int) { + m.Y = max(0, m.Y-n) +} + +// UPDATE + +// Update runs the update loop with default keybindings. To define your own +// keybindings use the methods on Model. +func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + switch msg.String() { + // Down one page + case "pgdown": + fallthrough + case " ": // spacebar + fallthrough + case "f": + m.ViewDown() + return m, nil + + // Up one page + case "pgup": + fallthrough + case "b": + m.ViewUp() + return m, nil + + // Down half page + case "d": + m.HalfViewDown() + return m, nil + + // Up half page + case "u": + m.HalfViewUp() + return m, nil + + // Down one line + case "down": + fallthrough + case "j": + m.LineDown(1) + return m, nil + + // Up one line + case "up": + fallthrough + case "k": + m.LineUp(1) + return m, nil + } + } + + return m, nil +} + +// VIEW + +// View renders the viewport into a string. +func View(m Model) string { + if m.Err != nil { + return m.Err.Error() + } + + var lines []string + + if len(m.lines) > 0 { + top := max(0, m.Y) + bottom := min(len(m.lines), m.Y+m.Height) + lines = m.lines[top:bottom] + } + + // Fill empty space with newlines + extraLines := "" + if len(lines) < m.Height { + extraLines = strings.Repeat("\n", m.Height-len(lines)) + } + + return strings.Join(lines, "\n") + extraLines +} + +// 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 +}