diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml deleted file mode 100644 index 2eb25262d..000000000 --- a/.github/workflows/lint-soft.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: lint-soft -on: - push: - pull_request: - -permissions: - contents: read - # Optional: allow read access to pull request. Use with `only-new-issues` option. - pull-requests: read - -jobs: - golangci: - name: lint-soft - runs-on: ubuntu-latest - steps: - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: ^1.18 - - - uses: actions/checkout@v4 - - name: golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - # Optional: golangci-lint command line arguments. - args: --config .golangci-soft.yml --issues-exit-code=0 - # Optional: show only new issues if it's a pull request. The default value is `false`. - only-new-issues: true diff --git a/.github/workflows/lint-sync.yml b/.github/workflows/lint-sync.yml new file mode 100644 index 000000000..ecf858024 --- /dev/null +++ b/.github/workflows/lint-sync.yml @@ -0,0 +1,14 @@ +name: lint-sync +on: + schedule: + # every Sunday at midnight + - cron: "0 0 * * 0" + workflow_dispatch: # allows manual triggering + +permissions: + contents: write + pull-requests: write + +jobs: + lint: + uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 86612f09e..a1d6d0e51 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,26 +3,6 @@ on: push: pull_request: -permissions: - contents: read - # Optional: allow read access to pull request. Use with `only-new-issues` option. - pull-requests: read - jobs: - golangci: - name: lint - runs-on: ubuntu-latest - steps: - - name: Install Go - uses: actions/setup-go@v5 - with: - go-version: ^1.18 - - - uses: actions/checkout@v4 - - name: golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - # Optional: golangci-lint command line arguments. - #args: - # Optional: show only new issues if it's a pull request. The default value is `false`. - only-new-issues: true + lint: + uses: charmbracelet/meta/.github/workflows/lint.yml@main diff --git a/.gitignore b/.gitignore index e43b0f988..1a074454e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +dist/ diff --git a/.golangci-soft.yml b/.golangci-soft.yml index 0170561d5..d325d4fcc 100644 --- a/.golangci-soft.yml +++ b/.golangci-soft.yml @@ -1,5 +1,6 @@ run: tests: false + issues-exit-code: 0 issues: include: @@ -14,16 +15,13 @@ issues: linters: enable: - # - dupl - exhaustive - # - exhaustivestruct - goconst - godot - godox - - gomnd + - mnd - gomoddirectives - goprintffuncname - # - lll - misspell - nakedret - nestif @@ -34,14 +32,9 @@ linters: # disable default linters, they are already enabled in .golangci.yml disable: - - deadcode - errcheck - gosimple - govet - ineffassign - staticcheck - - structcheck - - typecheck - unused - - varcheck - - predeclared diff --git a/.golangci.yml b/.golangci.yml index 43d348b65..d6789e014 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -15,12 +15,10 @@ issues: linters: enable: - bodyclose - - exportloopref - gofumpt - goimports - gosec - nilerr - - predeclared - revive - rowserrcheck - sqlclosecheck @@ -28,4 +26,3 @@ linters: - unconvert - unparam - whitespace - - predeclared diff --git a/.goreleaser.yml b/.goreleaser.yml index c61970e07..3353d0202 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,5 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json +version: 2 includes: - from_url: url: charmbracelet/meta/main/goreleaser-lib.yaml -# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json - diff --git a/README.md b/README.md index f6e4d21c2..66abbd785 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,12 @@ following requirements: Thank you! +## Contributing + +See [contributing][contribute]. + +[contribute]: https://github.com/charmbracelet/bubbles/contribute + ## Feedback We’d love to hear your thoughts on this project. Feel free to drop us a note! diff --git a/cursor/cursor.go b/cursor/cursor.go index a4f479385..1297422d4 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -101,6 +101,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmd := m.BlinkCmd() return m, cmd + case tea.FocusMsg: + return m, m.Focus() + + case tea.BlurMsg: + m.Blur() + return m, nil + case BlinkMsg: // We're choosy about whether to accept blinkMsgs so that our cursor // only exactly when it should. diff --git a/filepicker/filepicker.go b/filepicker/filepicker.go index 9a1235a7b..113c6c963 100644 --- a/filepicker/filepicker.go +++ b/filepicker/filepicker.go @@ -7,7 +7,7 @@ import ( "sort" "strconv" "strings" - "sync" + "sync/atomic" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" @@ -15,17 +15,10 @@ import ( "github.com/dustin/go-humanize" ) -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 -// Return the next ID we should use on the Model. func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } // New returns a new filepicker model with default styling and key bindings. @@ -377,7 +370,7 @@ func (m Model) View() string { var symlinkPath string info, _ := f.Info() isSymlink := info.Mode()&os.ModeSymlink != 0 - size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) + size := strings.Replace(humanize.Bytes(uint64(info.Size())), " ", "", 1) //nolint:gosec name := f.Name() if isSymlink { @@ -386,7 +379,7 @@ func (m Model) View() string { disabled := !m.canSelect(name) && !f.IsDir() - if m.selected == i { + if m.selected == i { //nolint:nestif selected := "" if m.ShowPermissions { selected += " " + info.Mode().String() diff --git a/go.mod b/go.mod index 57141d99d..575d4f5b3 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.18 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/atotto/clipboard v0.1.4 - github.com/charmbracelet/bubbletea v0.27.0 + github.com/charmbracelet/bubbletea v1.1.2 github.com/charmbracelet/harmonica v0.2.0 - github.com/charmbracelet/lipgloss v0.12.1 - github.com/charmbracelet/x/ansi v0.1.4 - github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b + github.com/charmbracelet/lipgloss v1.0.0 + github.com/charmbracelet/x/ansi v0.4.2 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 github.com/dustin/go-humanize v1.0.1 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.16 @@ -21,17 +21,14 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.2.0 // indirect - github.com/charmbracelet/x/input v0.1.0 // indirect - github.com/charmbracelet/x/term v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.1.0 // indirect + github.com/charmbracelet/x/term v0.2.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.24.0 // indirect + golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index a6bd9e942..bd41a6f3e 100644 --- a/go.sum +++ b/go.sum @@ -6,22 +6,18 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU= -github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y= +github.com/charmbracelet/bubbletea v1.1.2 h1:naQXF2laRxyLyil/i7fxdpiz1/k06IKquhm4vBfHsIc= +github.com/charmbracelet/bubbletea v1.1.2/go.mod h1:9HIU/hBV24qKjlehyj8z1r/tR9TYTQEag+cWZnuXo8E= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs= -github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8= -github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= -github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= -github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= -github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= -github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= -github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wph7Ika2k4= -github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= +github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= +github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -47,14 +43,11 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/help/help.go b/help/help.go index f4e1c9713..85afbee13 100644 --- a/help/help.go +++ b/help/help.go @@ -124,28 +124,23 @@ func (m Model) ShortHelpView(bindings []key.Binding) string { continue } + // Sep var sep string if totalWidth > 0 && i < len(bindings) { sep = separator } + // Item str := sep + m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " + m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc) - w := lipgloss.Width(str) - // If adding this help item would go over the available width, stop - // drawing. - if m.Width > 0 && totalWidth+w > m.Width { - // Although if there's room for an ellipsis, print that. - tail := " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis) - tailWidth := lipgloss.Width(tail) - - if totalWidth+tailWidth < m.Width { + // Tail + if tail, ok := m.shouldAddItem(totalWidth, w); !ok { + if tail != "" { b.WriteString(tail) } - break } @@ -170,8 +165,7 @@ func (m Model) FullHelpView(groups [][]key.Binding) string { out []string totalWidth int - sep = m.Styles.FullSeparator.Render(m.FullSeparator) - sepWidth = lipgloss.Width(sep) + separator = m.Styles.FullSeparator.Inline(true).Render(m.FullSeparator) ) // Iterate over groups to build columns @@ -179,12 +173,17 @@ func (m Model) FullHelpView(groups [][]key.Binding) string { if group == nil || !shouldRenderColumn(group) { continue } - var ( + sep string keys []string descriptions []string ) + // Sep + if totalWidth > 0 && i < len(groups) { + sep = separator + } + // Separate keys and descriptions into different slices for _, kb := range group { if !kb.Enabled() { @@ -194,33 +193,42 @@ func (m Model) FullHelpView(groups [][]key.Binding) string { descriptions = append(descriptions, kb.Help().Desc) } + // Column col := lipgloss.JoinHorizontal(lipgloss.Top, + sep, m.Styles.FullKey.Render(strings.Join(keys, "\n")), - m.Styles.FullKey.Render(" "), + " ", m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")), ) + w := lipgloss.Width(col) - // Column - totalWidth += lipgloss.Width(col) - if m.Width > 0 && totalWidth > m.Width { + // Tail + if tail, ok := m.shouldAddItem(totalWidth, w); !ok { + if tail != "" { + out = append(out, tail) + } break } + totalWidth += w out = append(out, col) - - // Separator - if i < len(group)-1 { - totalWidth += sepWidth - if m.Width > 0 && totalWidth > m.Width { - break - } - out = append(out, sep) - } } return lipgloss.JoinHorizontal(lipgloss.Top, out...) } +func (m Model) shouldAddItem(totalWidth, width int) (tail string, ok bool) { + // If there's room for an ellipsis, print that. + if m.Width > 0 && totalWidth+width > m.Width { + tail = " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis) + + if totalWidth+lipgloss.Width(tail) < m.Width { + return tail, false + } + } + return "", true +} + func shouldRenderColumn(b []key.Binding) (ok bool) { for _, v := range b { if v.Enabled() { diff --git a/help/help_test.go b/help/help_test.go new file mode 100644 index 000000000..79601d789 --- /dev/null +++ b/help/help_test.go @@ -0,0 +1,38 @@ +package help + +import ( + "fmt" + "testing" + + "github.com/charmbracelet/x/exp/golden" + + "github.com/charmbracelet/bubbles/key" +) + +func TestFullHelp(t *testing.T) { + m := New() + m.FullSeparator = " | " + k := key.WithKeys("x") + kb := [][]key.Binding{ + { + key.NewBinding(k, key.WithHelp("enter", "continue")), + }, + { + key.NewBinding(k, key.WithHelp("esc", "back")), + key.NewBinding(k, key.WithHelp("?", "help")), + }, + { + key.NewBinding(k, key.WithHelp("H", "home")), + key.NewBinding(k, key.WithHelp("ctrl+c", "quit")), + key.NewBinding(k, key.WithHelp("ctrl+l", "log")), + }, + } + + for _, w := range []int{20, 30, 40} { + t.Run(fmt.Sprintf("full help %d width", w), func(t *testing.T) { + m.Width = w + s := m.FullHelpView(kb) + golden.RequireEqual(t, []byte(s)) + }) + } +} diff --git a/help/testdata/TestFullHelp/full_help_20_width.golden b/help/testdata/TestFullHelp/full_help_20_width.golden new file mode 100644 index 000000000..e8c569b13 --- /dev/null +++ b/help/testdata/TestFullHelp/full_help_20_width.golden @@ -0,0 +1 @@ +enter continue … \ No newline at end of file diff --git a/help/testdata/TestFullHelp/full_help_30_width.golden b/help/testdata/TestFullHelp/full_help_30_width.golden new file mode 100644 index 000000000..1183a10ec --- /dev/null +++ b/help/testdata/TestFullHelp/full_help_30_width.golden @@ -0,0 +1,2 @@ +enter continue | esc back … + ? help \ No newline at end of file diff --git a/help/testdata/TestFullHelp/full_help_40_width.golden b/help/testdata/TestFullHelp/full_help_40_width.golden new file mode 100644 index 000000000..e0227d09c --- /dev/null +++ b/help/testdata/TestFullHelp/full_help_40_width.golden @@ -0,0 +1,3 @@ +enter continue | esc back | H home + ? help ctrl+c quit + ctrl+l log \ No newline at end of file diff --git a/list/README.md b/list/README.md index 60e802c2d..ad073edf1 100644 --- a/list/README.md +++ b/list/README.md @@ -17,7 +17,7 @@ type Item interface { ``` ```go -// DefaultItem describes an items designed to work with DefaultDelegate. +// DefaultItem describes an item designed to work with DefaultDelegate. type DefaultItem interface { Item Title() string diff --git a/list/defaultitem.go b/list/defaultitem.go index 3f07cef6f..4affe342a 100644 --- a/list/defaultitem.go +++ b/list/defaultitem.go @@ -35,7 +35,7 @@ type DefaultItemStyles struct { func NewDefaultItemStyles() (s DefaultItemStyles) { s.NormalTitle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}). - Padding(0, 0, 0, 2) + Padding(0, 0, 0, 2) //nolint:mnd s.NormalDesc = s.NormalTitle. Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}) @@ -51,7 +51,7 @@ func NewDefaultItemStyles() (s DefaultItemStyles) { s.DimmedTitle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). - Padding(0, 0, 0, 2) + Padding(0, 0, 0, 2) //nolint:mnd s.DimmedDesc = s.DimmedTitle. Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}) @@ -61,7 +61,7 @@ func NewDefaultItemStyles() (s DefaultItemStyles) { return s } -// DefaultItem describes an items designed to work with DefaultDelegate. +// DefaultItem describes an item designed to work with DefaultDelegate. type DefaultItem interface { Item Title() string @@ -93,11 +93,13 @@ type DefaultDelegate struct { // NewDefaultDelegate creates a new delegate with default styles. func NewDefaultDelegate() DefaultDelegate { + const defaultHeight = 2 + const defaultSpacing = 1 return DefaultDelegate{ ShowDescription: true, Styles: NewDefaultItemStyles(), - height: 2, - spacing: 1, + height: defaultHeight, + spacing: defaultSpacing, } } diff --git a/list/list.go b/list/list.go index 17e6e1559..6808142b3 100644 --- a/list/list.go +++ b/list/list.go @@ -10,15 +10,16 @@ import ( "strings" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/sahilm/fuzzy" + "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/paginator" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/sahilm/fuzzy" ) // Item is an item that appears in the list. @@ -52,6 +53,7 @@ type ItemDelegate interface { } type filteredItem struct { + index int // index in the unfiltered list item Item // item matched matches []int // rune indices of matched items } @@ -269,6 +271,34 @@ func (m *Model) SetShowTitle(v bool) { m.updatePagination() } +// SetFilterText explicitly sets the filter text without relying on user input. +// It also sets the filterState to a sane default of FilterApplied, but this +// can be changed with SetFilterState. +func (m *Model) SetFilterText(filter string) { + m.filterState = Filtering + m.FilterInput.SetValue(filter) + cmd := filterItems(*m) + msg := cmd() + fmm, _ := msg.(FilterMatchesMsg) + m.filteredItems = filteredItems(fmm) + m.filterState = FilterApplied + m.Paginator.Page = 0 + m.cursor = 0 + m.FilterInput.CursorEnd() + m.updatePagination() + m.updateKeybindings() +} + +// Helper method for setting the filtering state manually. +func (m *Model) SetFilterState(state FilterState) { + m.Paginator.Page = 0 + m.cursor = 0 + m.filterState = state + m.FilterInput.CursorEnd() + m.FilterInput.Focus() + m.updateKeybindings() +} + // ShowTitle returns whether or not the title bar is set to be rendered. func (m Model) ShowTitle() bool { return m.showTitle @@ -455,12 +485,26 @@ func (m Model) MatchesForItem(index int) []int { return m.filteredItems[index].matches } -// Index returns the index of the currently selected item as it appears in the -// entire slice of items. +// Index returns the index of the currently selected item as it is stored in the +// filtered list of items. +// Using this value with SetItem() might be incorrect, consider using +// GlobalIndex() instead. func (m Model) Index() int { return m.Paginator.Page*m.Paginator.PerPage + m.cursor } +// GlobalIndex returns the index of the currently selected item as it is stored +// in the unfiltered list of items. This value can be used with SetItem(). +func (m Model) GlobalIndex() int { + index := m.Index() + + if m.filteredItems == nil || index >= len(m.filteredItems) { + return index + } + + return m.filteredItems[index].index +} + // Cursor returns the index of the cursor on the current page. func (m Model) Cursor() int { return m.cursor @@ -678,7 +722,7 @@ func (m Model) itemsAsFilterItems() filteredItems { // Set keybindings according to the filter state. func (m *Model) updateKeybindings() { - switch m.filterState { + switch m.filterState { //nolint:exhaustive case Filtering: m.KeyMap.CursorUp.SetEnabled(false) m.KeyMap.CursorDown.SetEnabled(false) @@ -1109,7 +1153,7 @@ func (m Model) statusView() string { itemsDisplay := fmt.Sprintf("%d %s", visibleItems, itemName) - if m.filterState == Filtering { + if m.filterState == Filtering { //nolint:nestif // Filter results if visibleItems == 0 { status = m.Styles.StatusEmpty.Render("Nothing matched") @@ -1125,7 +1169,7 @@ func (m Model) statusView() string { if filtered { f := strings.TrimSpace(m.FilterInput.Value()) - f = ansi.Truncate(f, 10, "…") + f = ansi.Truncate(f, 10, "…") //nolint:mnd status += fmt.Sprintf("“%s” ", f) } @@ -1142,7 +1186,7 @@ func (m Model) statusView() string { } func (m Model) paginationView() string { - if m.Paginator.TotalPages < 2 { //nolint:gomnd + if m.Paginator.TotalPages < 2 { //nolint:mnd return "" } @@ -1227,6 +1271,7 @@ func filterItems(m Model) tea.Cmd { filterMatches := []filteredItem{} for _, r := range m.Filter(m.FilterInput.Value(), targets) { filterMatches = append(filterMatches, filteredItem{ + index: r.Index, item: items[r.Index], matches: r.MatchedIndexes, }) diff --git a/list/list_test.go b/list/list_test.go index 2c925aab4..2627e5b1a 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -3,6 +3,7 @@ package list import ( "fmt" "io" + "reflect" "strings" "testing" @@ -11,7 +12,7 @@ import ( type item string -func (i item) FilterValue() string { return "" } +func (i item) FilterValue() string { return string(i) } type itemDelegate struct{} @@ -72,3 +73,65 @@ func TestCustomStatusBarItemName(t *testing.T) { t.Fatalf("Error: expected view to contain %s", expected) } } + +func TestSetFilterText(t *testing.T) { + tc := []Item{item("foo"), item("bar"), item("baz")} + + list := New(tc, itemDelegate{}, 10, 10) + list.SetFilterText("ba") + + list.SetFilterState(Unfiltered) + expected := tc + // TODO: replace with slices.Equal() when project move to go1.18 or later + if !reflect.DeepEqual(list.VisibleItems(), expected) { + t.Fatalf("Error: expected view to contain only %s", expected) + } + + list.SetFilterState(Filtering) + expected = []Item{item("bar"), item("baz")} + if !reflect.DeepEqual(list.VisibleItems(), expected) { + t.Fatalf("Error: expected view to contain only %s", expected) + } + + list.SetFilterState(FilterApplied) + if !reflect.DeepEqual(list.VisibleItems(), expected) { + t.Fatalf("Error: expected view to contain only %s", expected) + } +} + +func TestSetFilterState(t *testing.T) { + tc := []Item{item("foo"), item("bar"), item("baz")} + + list := New(tc, itemDelegate{}, 10, 10) + list.SetFilterText("ba") + + list.SetFilterState(Unfiltered) + expected, notExpected := "up", "clear filter" + + lines := strings.Split(list.View(), "\n") + footer := lines[len(lines)-1] + + if !strings.Contains(footer, expected) || strings.Contains(footer, notExpected) { + t.Fatalf("Error: expected view to contain '%s' not '%s'", expected, notExpected) + } + + list.SetFilterState(Filtering) + expected, notExpected = "filter", "more" + + lines = strings.Split(list.View(), "\n") + footer = lines[len(lines)-1] + + if !strings.Contains(footer, expected) || strings.Contains(footer, notExpected) { + t.Fatalf("Error: expected view to contain '%s' not '%s'", expected, notExpected) + } + + list.SetFilterState(FilterApplied) + expected = "clear" + + lines = strings.Split(list.View(), "\n") + footer = lines[len(lines)-1] + + if !strings.Contains(footer, expected) { + t.Fatalf("Error: expected view to contain '%s'", expected) + } +} diff --git a/list/style.go b/list/style.go index e4451f87b..e663c07b8 100644 --- a/list/style.go +++ b/list/style.go @@ -45,7 +45,7 @@ func DefaultStyles() (s Styles) { verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"} - s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2) + s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2) //nolint:mnd s.Title = lipgloss.NewStyle(). Background(lipgloss.Color("62")). @@ -65,7 +65,7 @@ func DefaultStyles() (s Styles) { s.StatusBar = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). - Padding(0, 0, 1, 2) + Padding(0, 0, 1, 2) //nolint:mnd s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor) @@ -79,9 +79,9 @@ func DefaultStyles() (s Styles) { s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor) - s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd + s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:mnd - s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) + s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) //nolint:mnd s.ActivePaginationDot = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}). diff --git a/paginator/paginator.go b/paginator/paginator.go index 82dc3ed3c..961b4e560 100644 --- a/paginator/paginator.go +++ b/paginator/paginator.go @@ -130,9 +130,12 @@ func (m Model) OnFirstPage() bool { return m.Page == 0 } +// Option is used to set options in New. +type Option func(*Model) + // New creates a new model with defaults. -func New() Model { - return Model{ +func New(opts ...Option) Model { + m := Model{ Type: Arabic, Page: 0, PerPage: 1, @@ -142,6 +145,12 @@ func New() Model { InactiveDot: "○", ArabicFormat: "%d/%d", } + + for _, opt := range opts { + opt(&m) + } + + return m } // NewModel creates a new model with defaults. @@ -149,6 +158,20 @@ func New() Model { // Deprecated: use [New] instead. var NewModel = New +// WithTotalPages sets the total pages. +func WithTotalPages(totalPages int) Option { + return func(m *Model) { + m.TotalPages = totalPages + } +} + +// WithPerPage sets the total pages. +func WithPerPage(perPage int) Option { + return func(m *Model) { + m.PerPage = perPage + } +} + // Update is the Tea update function which binds keystrokes to pagination. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { @@ -166,7 +189,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // View renders the pagination to a string. func (m Model) View() string { - switch m.Type { + switch m.Type { //nolint:exhaustive case Dots: return m.dotsView() default: diff --git a/paginator/paginator_test.go b/paginator/paginator_test.go index 57f326c66..679e68249 100644 --- a/paginator/paginator_test.go +++ b/paginator/paginator_test.go @@ -6,6 +6,32 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +func TestNew(t *testing.T) { + model := New() + + if model.PerPage != 1 { + t.Errorf("PerPage = %d, expected %d", model.PerPage, 1) + } + if model.TotalPages != 1 { + t.Errorf("TotalPages = %d, expected %d", model.TotalPages, 1) + } + + perPage := 42 + totalPages := 42 + + model = New( + WithPerPage(perPage), + WithTotalPages(totalPages), + ) + + if model.PerPage != perPage { + t.Errorf("PerPage = %d, expected %d", model.PerPage, perPage) + } + if model.TotalPages != totalPages { + t.Errorf("TotalPages = %d, expected %d", model.TotalPages, totalPages) + } +} + func TestSetTotalPages(t *testing.T) { tests := []struct { name string diff --git a/progress/progress.go b/progress/progress.go index defa98133..cfd18cb56 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -4,7 +4,7 @@ import ( "fmt" "math" "strings" - "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea" @@ -17,17 +17,10 @@ import ( // Internal ID management. Used during animating to assure that frame messages // can only be received by progress components that sent them. -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 -// Return the next ID we should use on the model. func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } const ( @@ -342,7 +335,7 @@ func (m Model) percentageView(percent float64) string { return "" } percent = math.Max(0, math.Min(1, percent)) - percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:gomnd + percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:mnd percentage = m.PercentageStyle.Inline(true).Render(percentage) return percentage } diff --git a/spinner/spinner.go b/spinner/spinner.go index bb53597fe..ac6e34aba 100644 --- a/spinner/spinner.go +++ b/spinner/spinner.go @@ -1,7 +1,7 @@ package spinner import ( - "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea" @@ -10,17 +10,10 @@ import ( // Internal ID management. Used during animating to ensure that frame messages // are received only by spinner components that sent them. -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 -// Return the next ID we should use on the Model. func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } // Spinner is a set of frames used in animating the spinner. @@ -33,39 +26,39 @@ type Spinner struct { var ( Line = Spinner{ Frames: []string{"|", "/", "-", "\\"}, - FPS: time.Second / 10, //nolint:gomnd + FPS: time.Second / 10, //nolint:mnd } Dot = Spinner{ Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "}, - FPS: time.Second / 10, //nolint:gomnd + FPS: time.Second / 10, //nolint:mnd } MiniDot = Spinner{ Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, - FPS: time.Second / 12, //nolint:gomnd + FPS: time.Second / 12, //nolint:mnd } Jump = Spinner{ Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"}, - FPS: time.Second / 10, //nolint:gomnd + FPS: time.Second / 10, //nolint:mnd } Pulse = Spinner{ Frames: []string{"█", "▓", "▒", "░"}, - FPS: time.Second / 8, //nolint:gomnd + FPS: time.Second / 8, //nolint:mnd } Points = Spinner{ Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"}, - FPS: time.Second / 7, //nolint:gomnd + FPS: time.Second / 7, //nolint:mnd } Globe = Spinner{ Frames: []string{"🌍", "🌎", "🌏"}, - FPS: time.Second / 4, //nolint:gomnd + FPS: time.Second / 4, //nolint:mnd } Moon = Spinner{ Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"}, - FPS: time.Second / 8, //nolint:gomnd + FPS: time.Second / 8, //nolint:mnd } Monkey = Spinner{ Frames: []string{"🙈", "🙉", "🙊"}, - FPS: time.Second / 3, //nolint:gomnd + FPS: time.Second / 3, //nolint:mnd } Meter = Spinner{ Frames: []string{ @@ -77,15 +70,15 @@ var ( "▰▱▱", "▱▱▱", }, - FPS: time.Second / 7, //nolint:gomnd + FPS: time.Second / 7, //nolint:mnd } Hamburger = Spinner{ Frames: []string{"☱", "☲", "☴", "☲"}, - FPS: time.Second / 3, //nolint:gomnd + FPS: time.Second / 3, //nolint:mnd } Ellipsis = Spinner{ Frames: []string{"", ".", "..", "..."}, - FPS: time.Second / 3, //nolint:gomnd + FPS: time.Second / 3, //nolint:mnd } ) diff --git a/stopwatch/stopwatch.go b/stopwatch/stopwatch.go index 6b298f791..e5d5f6333 100644 --- a/stopwatch/stopwatch.go +++ b/stopwatch/stopwatch.go @@ -2,22 +2,16 @@ package stopwatch import ( - "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea" ) -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } // TickMsg is a message that is sent on every timer tick. @@ -29,7 +23,8 @@ type TickMsg struct { // Note, however, that a stopwatch will reject ticks from other // stopwatches, so it's safe to flow all TickMsgs through all stopwatches // and have them still behave appropriately. - ID int + ID int + tag int } // StartStopMsg is sent when the stopwatch should start or stop. @@ -47,6 +42,7 @@ type ResetMsg struct { type Model struct { d time.Duration id int + tag int running bool // How long to wait before every tick. Defaults to 1 second. @@ -81,7 +77,7 @@ func (m Model) Init() tea.Cmd { func (m Model) Start() tea.Cmd { return tea.Batch(func() tea.Msg { return StartStopMsg{ID: m.id, running: true} - }, tick(m.id, m.Interval)) + }, tick(m.id, m.tag, m.Interval)) } // Stop stops the stopwatch. @@ -128,8 +124,17 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if !m.running || msg.ID != m.id { break } + + // If a tag is set, and it's not the one we expect, reject the message. + // This prevents the stopwatch from receiving too many messages and + // thus ticking too fast. + if msg.tag > 0 && msg.tag != m.tag { + return m, nil + } + m.d += m.Interval - return m, tick(m.id, m.Interval) + m.tag++ + return m, tick(m.id, m.tag, m.Interval) } return m, nil @@ -145,8 +150,8 @@ func (m Model) View() string { return m.d.String() } -func tick(id int, d time.Duration) tea.Cmd { +func tick(id int, tag int, d time.Duration) tea.Cmd { return tea.Tick(d, func(_ time.Time) tea.Msg { - return TickMsg{ID: id} + return TickMsg{ID: id, tag: tag} }) } diff --git a/table/table.go b/table/table.go index 6103c8361..d68b626ff 100644 --- a/table/table.go +++ b/table/table.go @@ -133,7 +133,7 @@ type Option func(*Model) func New(opts ...Option) Model { m := Model{ cursor: 0, - viewport: viewport.New(0, 20), + viewport: viewport.New(0, 20), //nolint:mnd KeyMap: DefaultKeyMap(), Help: help.New(), @@ -216,11 +216,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case key.Matches(msg, m.KeyMap.PageDown): m.MoveDown(m.viewport.Height) case key.Matches(msg, m.KeyMap.HalfPageUp): - m.MoveUp(m.viewport.Height / 2) + m.MoveUp(m.viewport.Height / 2) //nolint:mnd case key.Matches(msg, m.KeyMap.HalfPageDown): - m.MoveDown(m.viewport.Height / 2) - case key.Matches(msg, m.KeyMap.LineDown): - m.MoveDown(1) + m.MoveDown(m.viewport.Height / 2) //nolint:mnd case key.Matches(msg, m.KeyMap.GotoTop): m.GotoTop() case key.Matches(msg, m.KeyMap.GotoBottom): diff --git a/textarea/textarea.go b/textarea/textarea.go index 153f39ef3..8a2071f3f 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -24,9 +24,12 @@ const ( minHeight = 1 defaultHeight = 6 defaultWidth = 40 - defaultCharLimit = 400 + defaultCharLimit = 0 // no limit defaultMaxHeight = 99 defaultMaxWidth = 500 + + // XXX: in v2, make max lines dynamic and default max lines configurable. + maxLines = 10000 ) // Internal messages for clipboard operations. @@ -290,13 +293,13 @@ func New() Model { style: &blurredStyle, FocusedStyle: focusedStyle, BlurredStyle: blurredStyle, - cache: memoization.NewMemoCache[line, [][]rune](defaultMaxHeight), + cache: memoization.NewMemoCache[line, [][]rune](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, Cursor: cur, KeyMap: DefaultKeyMap, - value: make([][]rune, minHeight, defaultMaxHeight), + value: make([][]rune, minHeight, maxLines), focus: false, col: 0, row: 0, @@ -360,9 +363,8 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // whatnot. runes = m.san().Sanitize(runes) - var availSpace int if m.CharLimit > 0 { - availSpace = m.CharLimit - m.Length() + availSpace := m.CharLimit - m.Length() // If the char limit's been reached, cancel. if availSpace <= 0 { return @@ -393,9 +395,9 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { lines = append(lines, runes[lstart:]) } - // Obey the maximum height limit. - if m.MaxHeight > 0 && len(m.value)+len(lines)-1 > m.MaxHeight { - allowedHeight := max(0, m.MaxHeight-len(m.value)+1) + // Obey the maximum line limit. + if maxLines > 0 && len(m.value)+len(lines)-1 > maxLines { + allowedHeight := max(0, maxLines-len(m.value)+1) lines = lines[:allowedHeight] } @@ -492,7 +494,8 @@ func (m *Model) CursorDown() { // Move the cursor to the start of the next line so that we can get // the line information. We need to add 2 columns to account for the // trailing space wrapping. - m.col = min(li.StartColumn+li.Width+2, len(m.value[m.row])-1) + const trailingSpace = 2 + m.col = min(li.StartColumn+li.Width+trailingSpace, len(m.value[m.row])-1) } nli := m.LineInfo() @@ -526,7 +529,8 @@ func (m *Model) CursorUp() { // This can be done by moving the cursor to the start of the line and // then subtracting 2 to account for the trailing space we keep on // soft-wrapped lines. - m.col = li.StartColumn - 2 + const trailingSpace = 2 + m.col = li.StartColumn - trailingSpace } nli := m.LineInfo() @@ -588,11 +592,7 @@ func (m *Model) Blur() { // Reset sets the input to its default state with no input. func (m *Model) Reset() { - startCap := m.MaxHeight - if startCap <= 0 { - startCap = defaultMaxHeight - } - m.value = make([][]rune, minHeight, startCap) + m.value = make([][]rune, minHeight, maxLines) m.col = 0 m.row = 0 m.viewport.GotoTop() @@ -1119,7 +1119,7 @@ func (m Model) View() string { displayLine++ var ln string - if m.ShowLineNumbers { + if m.ShowLineNumbers { //nolint:nestif if wl == 0 { if m.row == l { ln = style.Render(m.style.computedCursorLineNumber().Render(m.formatLineNumber(l + 1))) @@ -1198,7 +1198,7 @@ func (m Model) View() string { } // formatLineNumber formats the line number for display dynamically based on -// the maximum number of lines +// the maximum number of lines. func (m Model) formatLineNumber(x any) string { // XXX: ultimately we should use a max buffer height, which has yet to be // implemented. @@ -1407,7 +1407,7 @@ func wrap(runes []rune, width int) [][]rune { word = append(word, r) } - if spaces > 0 { + if spaces > 0 { //nolint:nestif if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width { row++ lines = append(lines, []rune{}) diff --git a/textinput/textinput.go b/textinput/textinput.go index 93bc150bc..66e451859 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -565,7 +565,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // Let's remember where the position of the cursor currently is so that if // the cursor position changes, we can reset the blink. - oldPos := m.pos //nolint + oldPos := m.pos switch msg := msg.(type) { case tea.KeyMsg: @@ -658,7 +658,7 @@ func (m Model) View() string { pos := max(0, m.pos-m.offset) v := styleText(m.echoTransform(string(value[:pos]))) - if pos < len(value) { + if pos < len(value) { //nolint:nestif char := m.echoTransform(string(value[pos])) m.Cursor.SetChar(char) v += m.Cursor.View() // cursor and text under it @@ -700,10 +700,12 @@ func (m Model) View() string { func (m Model) placeholderView() string { var ( v string - p = []rune(m.Placeholder) style = m.PlaceholderStyle.Inline(true).Render ) + p := make([]rune, m.Width+1) + copy(p, []rune(m.Placeholder)) + m.Cursor.TextStyle = m.PlaceholderStyle m.Cursor.SetChar(string(p[:1])) v += m.Cursor.View() @@ -813,16 +815,29 @@ func (m Model) completionView(offset int) string { return "" } -// AvailableSuggestions returns the list of available suggestions. -func (m *Model) AvailableSuggestions() []string { - suggestions := make([]string, len(m.suggestions)) - for i, s := range m.suggestions { +func (m *Model) getSuggestions(sugs [][]rune) []string { + suggestions := make([]string, len(sugs)) + for i, s := range sugs { suggestions[i] = string(s) } - return suggestions } +// AvailableSuggestions returns the list of available suggestions. +func (m *Model) AvailableSuggestions() []string { + return m.getSuggestions(m.suggestions) +} + +// MatchedSuggestions returns the list of matched suggestions. +func (m *Model) MatchedSuggestions() []string { + return m.getSuggestions(m.matchedSuggestions) +} + +// CurrentSuggestion returns the currently selected suggestion index. +func (m *Model) CurrentSuggestionIndex() int { + return m.currentSuggestionIndex +} + // CurrentSuggestion returns the currently selected suggestion. func (m *Model) CurrentSuggestion() string { if m.currentSuggestionIndex >= len(m.matchedSuggestions) { diff --git a/textinput/textinput_test.go b/textinput/textinput_test.go index 27a7640a8..95ef0c616 100644 --- a/textinput/textinput_test.go +++ b/textinput/textinput_test.go @@ -30,3 +30,10 @@ func Test_CurrentSuggestion(t *testing.T) { t.Fatalf("Error: expected first suggestion but was %s", suggestion) } } + +func Test_SlicingOutsideCap(t *testing.T) { + textinput := New() + textinput.Placeholder = "作業ディレクトリを指定してください" + textinput.Width = 32 + textinput.View() +} diff --git a/timer/timer.go b/timer/timer.go index eb085e00d..d0b6da615 100644 --- a/timer/timer.go +++ b/timer/timer.go @@ -2,22 +2,16 @@ package timer import ( - "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea" ) -var ( - lastID int - idMtx sync.Mutex -) +var lastID int64 func nextID() int { - idMtx.Lock() - defer idMtx.Unlock() - lastID++ - return lastID + return int(atomic.AddInt64(&lastID, 1)) } // Authors note with regard to start and stop commands: @@ -67,6 +61,8 @@ type TickMsg struct { // Timeout returns whether or not this tick is a timeout tick. You can // alternatively listen for TimeoutMsg. Timeout bool + + tag int } // TimeoutMsg is a message that is sent once when the timer times out. @@ -86,6 +82,7 @@ type Model struct { Interval time.Duration id int + tag int running bool } @@ -143,6 +140,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { break } + // If a tag is set, and it's not the one we expect, reject the message. + // This prevents the ticker from receiving too many messages and + // thus ticking too fast. + if msg.tag > 0 && msg.tag != m.tag { + return m, nil + } + m.Timeout -= m.Interval return m, tea.Batch(m.tick(), m.timedout()) } @@ -172,7 +176,7 @@ func (m *Model) Toggle() tea.Cmd { func (m Model) tick() tea.Cmd { return tea.Tick(m.Interval, func(_ time.Time) tea.Msg { - return TickMsg{ID: m.id, Timeout: m.Timedout()} + return TickMsg{ID: m.id, tag: m.tag, Timeout: m.Timedout()} }) } diff --git a/viewport/viewport.go b/viewport/viewport.go index e0a4cc33f..f220d1eb6 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -50,6 +50,8 @@ type Model struct { // // This should only be used in program occupying the entire terminal, // which is usually via the alternate screen buffer. + // + // Deprecated: high performance rendering is now deprecated in Bubble Tea. HighPerformanceRendering bool initialized bool @@ -97,8 +99,7 @@ func (m Model) ScrollPercent() float64 { return math.Max(0.0, math.Min(1.0, v)) } -// SetContent set the pager's text content. For high performance rendering the -// Sync command should also be called. +// SetContent set the pager's text content. func (m *Model) SetContent(s string) { s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings m.lines = strings.Split(s, "\n") @@ -126,6 +127,8 @@ func (m Model) visibleLines() (lines []string) { } // scrollArea returns the scrollable boundaries for high performance rendering. +// +// XXX: high performance rendering is deprecated in Bubble Tea. func (m Model) scrollArea() (top, bottom int) { top = max(0, m.YPosition) bottom = max(top, top+m.Height) @@ -165,7 +168,7 @@ func (m *Model) HalfViewDown() (lines []string) { return nil } - return m.LineDown(m.Height / 2) + return m.LineDown(m.Height / 2) //nolint:mnd } // HalfViewUp moves the view up by half the height of the viewport. @@ -174,7 +177,7 @@ func (m *Model) HalfViewUp() (lines []string) { return nil } - return m.LineUp(m.Height / 2) + return m.LineUp(m.Height / 2) //nolint:mnd } // LineDown moves the view down by the given number of lines. @@ -189,6 +192,8 @@ func (m *Model) LineDown(n int) (lines []string) { m.SetYOffset(m.YOffset + n) // Gather lines to send off for performance scrolling. + // + // XXX: high performance rendering is deprecated in Bubble Tea. bottom := clamp(m.YOffset+m.Height, 0, len(m.lines)) top := clamp(m.YOffset+m.Height-n, 0, bottom) return m.lines[top:bottom] @@ -206,6 +211,8 @@ func (m *Model) LineUp(n int) (lines []string) { m.SetYOffset(m.YOffset - n) // Gather lines to send off for performance scrolling. + // + // XXX: high performance rendering is deprecated in Bubble Tea. top := max(0, m.YOffset) bottom := clamp(m.YOffset+n, 0, m.maxYOffset()) return m.lines[top:bottom] @@ -242,6 +249,8 @@ func (m *Model) GotoBottom() (lines []string) { // first render and after a window resize. // // For high performance rendering only. +// +// Deprecated: high performance rendering is deprecated in Bubble Tea. func Sync(m Model) tea.Cmd { if len(m.lines) == 0 { return nil @@ -261,6 +270,9 @@ func ViewDown(m Model, lines []string) tea.Cmd { return nil } top, bottom := m.scrollArea() + + // XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we + // won't need to return a command here. return tea.ScrollDown(lines, top, bottom) } @@ -272,6 +284,9 @@ func ViewUp(m Model, lines []string) tea.Cmd { return nil } top, bottom := m.scrollArea() + + // XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we + // won't need to return a command here. return tea.ScrollUp(lines, top, bottom) } @@ -335,7 +350,7 @@ func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) { if !m.MouseWheelEnabled || msg.Action != tea.MouseActionPress { break } - switch msg.Button { + switch msg.Button { //nolint:exhaustive case tea.MouseButtonWheelUp: lines := m.LineUp(m.MouseWheelDelta) if m.HighPerformanceRendering {