diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..6c929d4803 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.golden -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cef3f37e1e..edfae9866a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,12 +12,12 @@ jobs: GO111MODULE: "on" steps: - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Download Go modules run: go mod download @@ -32,6 +32,10 @@ jobs: run: go build -v ./... working-directory: ./examples + - name: Test examples + run: go test -v ./... + working-directory: ./examples + - name: Build tutorials run: go build -v ./... working-directory: ./tutorials diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 845096e44c..067d42c7b5 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -12,12 +12,12 @@ jobs: GO111MODULE: "on" steps: - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Coverage env: diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index f027cf2182..c1d9c31440 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -20,8 +20,8 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: '^1' cache: true @@ -29,7 +29,7 @@ jobs: run: | (cd ./examples && go mod tidy) (cd ./tutorials && go mod tidy) - - uses: stefanzweifel/git-auto-commit-action@v4 + - uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: "chore: go mod tidy tutorials and examples" branch: master diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml index 6230528921..4f3fbc0cbc 100644 --- a/.github/workflows/lint-soft.yml +++ b/.github/workflows/lint-soft.yml @@ -14,11 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: ^1 - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2f26b2ac2a..10df8cc9c3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,11 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: ^1 - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: diff --git a/README.md b/README.md index 0a88caa5fa..0834246cdc 100644 --- a/README.md +++ b/README.md @@ -341,8 +341,10 @@ For some Bubble Tea programs in production, see: * [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues * [gitflow-toolkit](https://github.com/mritd/gitflow-toolkit): a GitFlow submission tool * [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash +* [go-sweep](https://github.com/maxpaulus43/go-sweep): Minesweeper in the terminal * [gocovsh](https://github.com/orlangure/gocovsh): explore Go coverage reports from the CLI * [got](https://github.com/fedeztk/got): a simple translator and text-to-speech app build on top of simplytranslate's APIs +* [hiSHtory](https://github.com/ddworken/hishtory): your shell history in context, synced, and queryable * [httpit](https://github.com/gonetx/httpit): a rapid http(s) benchmark tool * [IDNT](https://github.com/r-darwish/idnt): a batch software uninstaller * [kboard](https://github.com/CamiloGarciaLaRotta/kboard): a typing game @@ -351,15 +353,18 @@ For some Bubble Tea programs in production, see: * [mergestat](https://github.com/mergestat/mergestat): run SQL queries on git repositories * [Neon Modem Overdrive](https://github.com/mrusme/neonmodem): a BBS-style TUI client for Discourse, Lemmy, Lobste.rs and Hacker News * [Noted](https://github.com/torbratsberg/noted): a note viewer and manager +* [nom](https://github.com/guyfedwards/nom): RSS reader and manager * [pathos](https://github.com/chip/pathos): a PATH env variable editor * [portal](https://github.com/ZinoKader/portal): secure transfers between computers * [redis-viewer](https://github.com/SaltFishPr/redis-viewer): a Redis databases browser +* [scrabbler](https://github.com/wI2L/scrabbler): Automatic draw TUI for your duplicate Scrabble games * [sku](https://github.com/fedeztk/sku): Sudoku on the CLI * [Slides](https://github.com/maaslalani/slides): a markdown-based presentation tool * [SlurmCommander](https://github.com/CLIP-HPC/SlurmCommander): a Slurm workload manager TUI * [Soft Serve](https://github.com/charmbracelet/soft-serve): a command-line-first Git server that runs a TUI over SSH * [solitaire-tui](https://github.com/brianstrauch/solitaire-tui): Klondike Solitaire for the terminal * [StormForge Optimize Controller](https://github.com/thestormforge/optimize-controller): a tool for experimenting with application configurations in Kubernetes +* [Storydb](https://github.com/grrlopes/storydb): a bash/zsh ctrl+r improved command history finder. * [STTG](https://github.com/wille1101/sttg): a teletext client for SVT, Swedenโ€™s national public television station * [sttr](https://github.com/abhimanyu003/sttr): a general-purpose text transformer * [tasktimer](https://github.com/caarlos0/tasktimer): a dead-simple task timer @@ -367,8 +372,10 @@ For some Bubble Tea programs in production, see: * [ticker](https://github.com/achannarasappa/ticker): a terminal stock viewer and stock position tracker * [tran](https://github.com/abdfnx/tran): securely transfer stuff between computers (based on [portal](https://github.com/ZinoKader/portal)) * [Typer](https://github.com/maaslalani/typer): a typing test +* [typioca](https://github.com/bloznelis/typioca): Cozy typing speed tester in terminal * [tz](https://github.com/oz/tz): an aid for scheduling across multiple time zones * [ugm](https://github.com/ariasmn/ugm): a unix user and group browser +* [walk](https://github.com/antonmedv/walk): a terminal navigator * [wander](https://github.com/robinovitch61/wander): a HashiCorp Nomad terminal client * [WG Commander](https://github.com/AndrianBdn/wg-cmd): a TUI for a simple WireGuard VPN setup * [wishlist](https://github.com/charmbracelet/wishlist): an SSH directory diff --git a/commands.go b/commands.go index 7c30a1216d..7b139b8858 100644 --- a/commands.go +++ b/commands.go @@ -13,7 +13,7 @@ import ( // return tea.Batch(someCommand, someOtherCommand) // } func Batch(cmds ...Cmd) Cmd { - var validCmds []Cmd + var validCmds []Cmd //nolint:prealloc for _, c := range cmds { if c == nil { continue @@ -170,3 +170,20 @@ func Sequentially(cmds ...Cmd) Cmd { return nil } } + +// setWindowTitleMsg is an internal message used to set the window title. +type setWindowTitleMsg string + +// SetWindowTitle produces a command that sets the terminal title. +// +// For example: +// +// func (m model) Init() Cmd { +// // Set title. +// return tea.SetWindowTitle("My App") +// } +func SetWindowTitle(title string) Cmd { + return func() Msg { + return setWindowTitleMsg(title) + } +} diff --git a/examples/autocomplete/main.go b/examples/autocomplete/main.go new file mode 100644 index 0000000000..2aba5d8733 --- /dev/null +++ b/examples/autocomplete/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func main() { + p := tea.NewProgram(initialModel()) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +} + +type gotReposSuccessMsg []repo +type gotReposErrMsg error + +type repo struct { + Name string `json:"name"` +} + +const reposURL = "https://api.github.com/orgs/charmbracelet/repos" + +func getRepos() tea.Msg { + req, err := http.NewRequest(http.MethodGet, reposURL, nil) + if err != nil { + return gotReposErrMsg(err) + } + + req.Header.Add("Accept", "application/vnd.github+json") + req.Header.Add("X-GitHub-Api-Version", "2022-11-28") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return gotReposErrMsg(err) + } + defer resp.Body.Close() // nolint: errcheck + + data, err := io.ReadAll(resp.Body) + if err != nil { + return gotReposErrMsg(err) + } + + var repos []repo + + err = json.Unmarshal(data, &repos) + if err != nil { + return gotReposErrMsg(err) + } + + return gotReposSuccessMsg(repos) +} + +type model struct { + textInput textinput.Model +} + +func initialModel() model { + ti := textinput.New() + ti.Prompt = "charmbracelet/" + ti.Placeholder = "repo..." + ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + ti.Focus() + ti.CharLimit = 50 + ti.Width = 20 + ti.ShowSuggestions = true + return model{textInput: ti} +} + +func (m model) Init() tea.Cmd { + return tea.Batch(getRepos, textinput.Blink) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + case gotReposSuccessMsg: + var suggestions []string + for _, r := range msg { + suggestions = append(suggestions, r.Name) + } + m.textInput.SetSuggestions(suggestions) + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +func (m model) View() string { + return fmt.Sprintf( + "Whatโ€™s your favorite Charm repository?\n\n%s\n\n%s\n", + m.textInput.View(), + "(tab to complete, ctrl+n/ctrl+p to cycle through suggestions, esc to quit)", + ) +} diff --git a/examples/cellbuffer/main.go b/examples/cellbuffer/main.go index 00de39ce3b..456089e3e4 100644 --- a/examples/cellbuffer/main.go +++ b/examples/cellbuffer/main.go @@ -18,14 +18,14 @@ const ( fps = 60 frequency = 7.5 damping = 0.15 + asterisk = "*" ) func drawEllipse(cb *cellbuffer, xc, yc, rx, ry float64) { - const c = "*" var ( dx, dy, d1, d2 float64 - x float64 = 0 - y = ry + x float64 + y = ry ) d1 = ry*ry - rx*rx*ry + 0.25*rx*rx @@ -33,10 +33,10 @@ func drawEllipse(cb *cellbuffer, xc, yc, rx, ry float64) { dy = 2 * rx * rx * y for dx < dy { - cb.set(c, int(x+xc), int(y+yc)) - cb.set(c, int(-x+xc), int(y+yc)) - cb.set(c, int(x+xc), int(-y+yc)) - cb.set(c, int(-x+xc), int(-y+yc)) + cb.set(int(x+xc), int(y+yc)) + cb.set(int(-x+xc), int(y+yc)) + cb.set(int(x+xc), int(-y+yc)) + cb.set(int(-x+xc), int(-y+yc)) if d1 < 0 { x++ dx = dx + (2 * ry * ry) @@ -53,10 +53,10 @@ func drawEllipse(cb *cellbuffer, xc, yc, rx, ry float64) { d2 = ((ry * ry) * ((x + 0.5) * (x + 0.5))) + ((rx * rx) * ((y - 1) * (y - 1))) - (rx * rx * ry * ry) for y >= 0 { - cb.set(c, int(x+xc), int(y+yc)) - cb.set(c, int(-x+xc), int(y+yc)) - cb.set(c, int(x+xc), int(-y+yc)) - cb.set(c, int(-x+xc), int(-y+yc)) + cb.set(int(x+xc), int(y+yc)) + cb.set(int(-x+xc), int(y+yc)) + cb.set(int(x+xc), int(-y+yc)) + cb.set(int(-x+xc), int(-y+yc)) if d2 > 0 { y-- dy = dy - (2 * rx * rx) @@ -85,16 +85,12 @@ func (c *cellbuffer) init(w, h int) { c.wipe() } -func (c cellbuffer) set(v string, x, y int) { +func (c cellbuffer) set(x, y int) { i := y*c.stride + x if i > len(c.cells)-1 || x < 0 || y < 0 || x >= c.width() || y >= c.height() { return } - c.cells[i] = v -} - -func (c *cellbuffer) clear(x, y int) { - c.set(" ", x, y) + c.cells[i] = asterisk } func (c *cellbuffer) wipe() { diff --git a/examples/composable-views/main.go b/examples/composable-views/main.go index f81ce6ac8e..4352a7c5ad 100644 --- a/examples/composable-views/main.go +++ b/examples/composable-views/main.go @@ -98,7 +98,7 @@ func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.Next() m.resetSpinner() - cmds = append(cmds, spinner.Tick) + cmds = append(cmds, m.spinner.Tick) } } switch m.state { diff --git a/examples/credit-card-form/main.go b/examples/credit-card-form/main.go index ba5b2b3363..71b3bf58bf 100644 --- a/examples/credit-card-form/main.go +++ b/examples/credit-card-form/main.go @@ -53,14 +53,15 @@ func ccnValidator(s string) error { return fmt.Errorf("CCN is too long") } + if len(s) == 0 || len(s)%5 != 0 && (s[len(s)-1] < '0' || s[len(s)-1] > '9') { + return fmt.Errorf("CCN is invalid") + } + // The last digit should be a number unless it is a multiple of 4 in which // case it should be a space if len(s)%5 == 0 && s[len(s)-1] != ' ' { return fmt.Errorf("CCN must separate groups with spaces") } - if len(s)%5 != 0 && (s[len(s)-1] < '0' || s[len(s)-1] > '9') { - return fmt.Errorf("CCN is invalid") - } // The remaining digits should be integers c := strings.ReplaceAll(s, " ", "") diff --git a/examples/file-picker/main.go b/examples/file-picker/main.go index 30ecf01af8..f0470cd338 100644 --- a/examples/file-picker/main.go +++ b/examples/file-picker/main.go @@ -1,9 +1,11 @@ package main import ( + "errors" "fmt" "os" "strings" + "time" "github.com/charmbracelet/bubbles/filepicker" tea "github.com/charmbracelet/bubbletea" @@ -13,6 +15,15 @@ type model struct { filepicker filepicker.Model selectedFile string quitting bool + err error +} + +type clearErrorMsg struct{} + +func clearErrorAfter(t time.Duration) tea.Cmd { + return tea.Tick(t, func(_ time.Time) tea.Msg { + return clearErrorMsg{} + }) } func (m model) Init() tea.Cmd { @@ -27,6 +38,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit } + case clearErrorMsg: + m.err = nil } var cmd tea.Cmd @@ -38,6 +51,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectedFile = path } + // Did the user select a disabled file? + // This is only necessary to display an error to the user. + if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { + // Let's clear the selectedFile and display an error. + m.err = errors.New(path + " is not valid.") + m.selectedFile = "" + return m, tea.Batch(cmd, clearErrorAfter(2*time.Second)) + } + return m, cmd } @@ -47,7 +69,9 @@ func (m model) View() string { } var s strings.Builder s.WriteString("\n ") - if m.selectedFile == "" { + if m.err != nil { + s.WriteString(m.filepicker.Styles.DisabledFile.Render(m.err.Error())) + } else if m.selectedFile == "" { s.WriteString("Pick a file:") } else { s.WriteString("Selected file: " + m.filepicker.Styles.Selected.Render(m.selectedFile)) @@ -58,7 +82,8 @@ func (m model) View() string { func main() { fp := filepicker.New() - fp.Path, _ = os.UserHomeDir() + fp.AllowedTypes = []string{".mod", ".sum", ".go", ".txt", ".md"} + fp.CurrentDirectory, _ = os.UserHomeDir() m := model{ filepicker: fp, diff --git a/examples/go.mod b/examples/go.mod index 057df64474..48f1c3012e 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,17 +3,17 @@ module examples go 1.17 require ( - github.com/charmbracelet/bubbles v0.15.1-0.20230306155959-3372cf1aea2b - github.com/charmbracelet/bubbletea v0.24.0 + github.com/charmbracelet/bubbles v0.17.0 + github.com/charmbracelet/bubbletea v0.24.2 github.com/charmbracelet/glamour v0.6.0 github.com/charmbracelet/harmonica v0.2.0 - github.com/charmbracelet/lipgloss v0.7.1 + github.com/charmbracelet/lipgloss v0.9.1 github.com/charmbracelet/x/exp/teatest v0.0.0-20230508155401-2bd6fa14c46a github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-isatty v0.0.18 github.com/muesli/reflow v0.3.0 - github.com/muesli/termenv v0.15.1 + github.com/muesli/termenv v0.15.2 ) require ( @@ -27,20 +27,20 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/microcosm-cc/bluemonday v1.0.21 // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/sahilm/fuzzy v0.1.0 // indirect + github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect github.com/yuin/goldmark v1.5.2 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect - golang.org/x/net v0.7.0 // indirect + golang.org/x/net v0.17.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.6.0 // indirect - golang.org/x/term v0.6.0 // indirect - golang.org/x/text v0.7.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect ) replace github.com/charmbracelet/bubbletea => ../ diff --git a/examples/go.sum b/examples/go.sum index b112ab32dc..c1562876cc 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -11,15 +11,14 @@ github.com/aymanbagabas/go-udiff v0.1.0 h1:9Dpklm2oBBhMxIFbMffmPvDaF7vOYfv9B5HXV github.com/aymanbagabas/go-udiff v0.1.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/charmbracelet/bubbles v0.15.1-0.20230306155959-3372cf1aea2b h1:K9dWJ2spDhDhIrqnchjG867djPxWWe3mwdk6RdLMfhg= -github.com/charmbracelet/bubbles v0.15.1-0.20230306155959-3372cf1aea2b/go.mod h1:39HL8bnL0foloiENA/KvD+3mNg5SqWQV2Qh3eY/4ey4= +github.com/charmbracelet/bubbles v0.17.0 h1:VfyQMZMWr5TW1SqIJzpZaOcbeApXPpoy/1ZwIO0lMAI= +github.com/charmbracelet/bubbles v0.17.0/go.mod h1:0B5SDVyyRXMteAgJRkYRJQ6bvsKtWdzeepp8rN+RhXQ= github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= 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.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= -github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= -github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/charmbracelet/x/exp/teatest v0.0.0-20230508155401-2bd6fa14c46a h1:GpNt24LKE8sH5G0SZUpu4Tg15sP5XSt1mnfIqE7fW34= github.com/charmbracelet/x/exp/teatest v0.0.0-20230508155401-2bd6fa14c46a/go.mod h1:dpMo6PfGlhavn+ofggWhfFQmK9sZB2Ewljiz9bZtKVI= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= @@ -39,7 +38,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= @@ -47,24 +45,23 @@ github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= -github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -72,8 +69,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= -github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= +github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -85,13 +82,17 @@ github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18W github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= @@ -99,7 +100,6 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -107,22 +107,31 @@ golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= diff --git a/examples/help/main.go b/examples/help/main.go index fb382ce26c..67e5a4f480 100644 --- a/examples/help/main.go +++ b/examples/help/main.go @@ -132,12 +132,12 @@ func (m model) View() string { func main() { if os.Getenv("HELP_DEBUG") != "" { - if f, err := tea.LogToFile("debug.log", "help"); err != nil { + f, err := tea.LogToFile("debug.log", "help") + if err != nil { fmt.Println("Couldn't open a file for logging:", err) os.Exit(1) - } else { - defer f.Close() } + defer f.Close() // nolint:errcheck } if _, err := tea.NewProgram(newModel()).Run(); err != nil { diff --git a/examples/http/main.go b/examples/http/main.go index 1b6a1e6228..dc2842de0e 100644 --- a/examples/http/main.go +++ b/examples/http/main.go @@ -76,7 +76,7 @@ func checkServer() tea.Msg { if err != nil { return errMsg{err} } - defer res.Body.Close() + defer res.Body.Close() // nolint:errcheck return statusMsg(res.StatusCode) } diff --git a/examples/list-simple/main.go b/examples/list-simple/main.go index 8cc42909af..f8f85ed100 100644 --- a/examples/list-simple/main.go +++ b/examples/list-simple/main.go @@ -28,9 +28,9 @@ func (i item) FilterValue() string { return "" } type itemDelegate struct{} -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { i, ok := listItem.(item) if !ok { diff --git a/examples/mouse/README.md b/examples/mouse/README.md deleted file mode 100644 index 396219d7d5..0000000000 --- a/examples/mouse/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Mouse - - diff --git a/examples/mouse/main.go b/examples/mouse/main.go index ea5c11d40a..da923ad863 100644 --- a/examples/mouse/main.go +++ b/examples/mouse/main.go @@ -4,21 +4,19 @@ package main // coordinates and events. import ( - "fmt" "log" tea "github.com/charmbracelet/bubbletea" ) func main() { - p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseAllMotion()) + p := tea.NewProgram(model{}, tea.WithMouseAllMotion()) if _, err := p.Run(); err != nil { log.Fatal(err) } } type model struct { - init bool mouseEvent tea.MouseEvent } @@ -34,20 +32,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.MouseMsg: - m.init = true - m.mouseEvent = tea.MouseEvent(msg) + return m, tea.Printf("(X: %d, Y: %d) %s", msg.X, msg.Y, tea.MouseEvent(msg)) } return m, nil } func (m model) View() string { - s := "Do mouse stuff. When you're done press q to quit.\n\n" - - if m.init { - e := m.mouseEvent - s += fmt.Sprintf("(X: %d, Y: %d) %s", e.X, e.Y, e) - } + s := "Do mouse stuff. When you're done press q to quit.\n" return s } diff --git a/examples/package-manager/main.go b/examples/package-manager/main.go index ced7c773dc..eb6f69ad2d 100644 --- a/examples/package-manager/main.go +++ b/examples/package-manager/main.go @@ -115,7 +115,7 @@ type installedPkgMsg string func downloadAndInstall(pkg string) tea.Cmd { // This is where you'd do i/o stuff to download and install packages. In // our case we're just pausing for a moment to simulate the process. - d := time.Millisecond * time.Duration(rand.Intn(500)) + d := time.Millisecond * time.Duration(rand.Intn(500)) //nolint:gosec return tea.Tick(d, func(t time.Time) tea.Msg { return installedPkgMsg(pkg) }) @@ -129,8 +129,6 @@ func max(a, b int) int { } func main() { - rand.Seed(time.Now().Unix()) - if _, err := tea.NewProgram(newModel()).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) diff --git a/examples/package-manager/packages.go b/examples/package-manager/packages.go index 7ed8478a3c..40425fca2b 100644 --- a/examples/package-manager/packages.go +++ b/examples/package-manager/packages.go @@ -46,7 +46,7 @@ func getPackages() []string { }) for k := range pkgs { - pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10)) + pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10)) //nolint:gosec } return pkgs } diff --git a/examples/pipe/main.go b/examples/pipe/main.go index 1fce2fce84..a309566327 100644 --- a/examples/pipe/main.go +++ b/examples/pipe/main.go @@ -58,7 +58,7 @@ type model struct { func newModel(initialValue string) (m model) { i := textinput.New() i.Prompt = "" - i.CursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) + i.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) i.Width = 48 i.SetValue(initialValue) i.CursorEnd() diff --git a/examples/prevent-quit/main.go b/examples/prevent-quit/main.go index 1339393937..7399d3088b 100644 --- a/examples/prevent-quit/main.go +++ b/examples/prevent-quit/main.go @@ -61,7 +61,7 @@ func initialModel() model { return model{ textarea: ti, - help: help.NewModel(), + help: help.New(), keymap: keymap{ save: key.NewBinding( key.WithKeys("ctrl+s"), diff --git a/examples/progress-download/main.go b/examples/progress-download/main.go index 4639aa1b20..018b29f14f 100644 --- a/examples/progress-download/main.go +++ b/examples/progress-download/main.go @@ -40,7 +40,7 @@ func (pw *progressWriter) Write(p []byte) (int, error) { } func getResponse(url string) (*http.Response, error) { - resp, err := http.Get(url) + resp, err := http.Get(url) // nolint:gosec if err != nil { log.Fatal(err) } @@ -64,7 +64,7 @@ func main() { fmt.Println("could not get response", err) os.Exit(1) } - defer resp.Body.Close() + defer resp.Body.Close() // nolint:errcheck // Don't add TUI if the header doesn't include content size // it's impossible see progress without total @@ -79,7 +79,7 @@ func main() { fmt.Println("could not create file:", err) os.Exit(1) } - defer file.Close() + defer file.Close() // nolint:errcheck pw := &progressWriter{ total: int(resp.ContentLength), diff --git a/examples/realtime/main.go b/examples/realtime/main.go index 516fcd662e..4abddd3b6f 100644 --- a/examples/realtime/main.go +++ b/examples/realtime/main.go @@ -24,7 +24,7 @@ type responseMsg struct{} func listenForActivity(sub chan struct{}) tea.Cmd { return func() tea.Msg { for { - time.Sleep(time.Millisecond * time.Duration(rand.Int63n(900)+100)) + time.Sleep(time.Millisecond * time.Duration(rand.Int63n(900)+100)) // nolint:gosec sub <- struct{}{} } } @@ -46,7 +46,7 @@ type model struct { func (m model) Init() tea.Cmd { return tea.Batch( - spinner.Tick, + m.spinner.Tick, listenForActivity(m.sub), // generate activity waitForActivity(m.sub), // wait for activity ) @@ -78,8 +78,6 @@ func (m model) View() string { } func main() { - rand.Seed(time.Now().UTC().UnixNano()) - p := tea.NewProgram(model{ sub: make(chan struct{}), spinner: spinner.New(), diff --git a/examples/send-msg/main.go b/examples/send-msg/main.go index 7badf6a485..5ace43bcb7 100644 --- a/examples/send-msg/main.go +++ b/examples/send-msg/main.go @@ -53,7 +53,7 @@ func newModel() model { } func (m model) Init() tea.Cmd { - return spinner.Tick + return m.spinner.Tick } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -100,14 +100,12 @@ func (m model) View() string { } func main() { - rand.Seed(time.Now().UTC().UnixNano()) - p := tea.NewProgram(newModel()) // Simulate activity go func() { for { - pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond + pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond // nolint:gosec time.Sleep(pause) // Send the Bubble Tea program a message from outside the @@ -129,5 +127,5 @@ func randomFood() string { "a kohlrabi", "some spaghetti", "tacos", "a currywurst", "some curry", "a sandwich", "some peanut butter", "some cashews", "some ramen", } - return string(food[rand.Intn(len(food))]) + return food[rand.Intn(len(food))] // nolint:gosec } diff --git a/examples/set-window-title/main.go b/examples/set-window-title/main.go new file mode 100644 index 0000000000..2e14609e28 --- /dev/null +++ b/examples/set-window-title/main.go @@ -0,0 +1,35 @@ +package main + +// A simple example illustrating how to set a window title. + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +type model struct{} + +func (m model) Init() tea.Cmd { + return tea.SetWindowTitle("Bubble Tea Example") +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case tea.KeyMsg: + return m, tea.Quit + } + return m, nil +} + +func (m model) View() string { + return "\nPress any key to quit." +} + +func main() { + if _, err := tea.NewProgram(model{}).Run(); err != nil { + fmt.Println("Uh oh:", err) + os.Exit(1) + } +} diff --git a/examples/simple/main_test.go b/examples/simple/main_test.go index f736de754b..a2e747f6af 100644 --- a/examples/simple/main_test.go +++ b/examples/simple/main_test.go @@ -8,9 +8,15 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/exp/teatest" + "github.com/muesli/termenv" ) +func init() { + lipgloss.SetColorProfile(termenv.Ascii) +} + func TestApp(t *testing.T) { m := model(10) tm := teatest.NewTestModel( @@ -34,13 +40,13 @@ func TestApp(t *testing.T) { t.Fatal(err) } - out := readBts(t, tm.FinalOutput()) + out := readBts(t, tm.FinalOutput(t)) if !regexp.MustCompile(`This program will exit in \d+ seconds`).Match(out) { t.Fatalf("output does not match the given regular expression: %s", string(out)) } teatest.RequireEqualOutput(t, out) - if tm.FinalModel().(model) != 9 { + if tm.FinalModel(t).(model) != 9 { t.Errorf("expected model to be 10, was %d", m) } } @@ -71,7 +77,7 @@ func TestAppInteractive(t *testing.T) { t.Fatal(err) } - if tm.FinalModel().(model) != 7 { + if tm.FinalModel(t).(model) != 7 { t.Errorf("expected model to be 7, was %d", m) } } diff --git a/examples/simple/testdata/TestApp.golden b/examples/simple/testdata/TestApp.golden index 0a973b283e..6b886768a6 100644 --- a/examples/simple/testdata/TestApp.golden +++ b/examples/simple/testdata/TestApp.golden @@ -1,3 +1,3 @@ -[?25lHi. This program will exit in 10 seconds. To quit sooner press any key -Hi. This program will exit in 9 seconds. To quit sooner press any key. -[?25h[?1002l[?1003l \ No newline at end of file +[?25lHi. This program will exit in 10 seconds. To quit sooner press any key +Hi. This program will exit in 9 seconds. To quit sooner press any key. +[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/examples/stopwatch/main.go b/examples/stopwatch/main.go index 69884b1e11..2b1a4d4586 100644 --- a/examples/stopwatch/main.go +++ b/examples/stopwatch/main.go @@ -91,7 +91,7 @@ func main() { key.WithHelp("q", "quit"), ), }, - help: help.NewModel(), + help: help.New(), } m.keymap.start.SetEnabled(false) diff --git a/examples/textinputs/main.go b/examples/textinputs/main.go index 2620bfc3de..5882858aad 100644 --- a/examples/textinputs/main.go +++ b/examples/textinputs/main.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -28,7 +29,7 @@ var ( type model struct { focusIndex int inputs []textinput.Model - cursorMode textinput.CursorMode + cursorMode cursor.Mode } func initialModel() model { @@ -39,7 +40,7 @@ func initialModel() model { var t textinput.Model for i := range m.inputs { t = textinput.New() - t.CursorStyle = cursorStyle + t.Cursor.Style = cursorStyle t.CharLimit = 32 switch i { @@ -77,12 +78,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Change cursor mode case "ctrl+r": m.cursorMode++ - if m.cursorMode > textinput.CursorHide { - m.cursorMode = textinput.CursorBlink + if m.cursorMode > cursor.CursorHide { + m.cursorMode = cursor.CursorBlink } cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { - cmds[i] = m.inputs[i].SetCursorMode(m.cursorMode) + cmds[i] = m.inputs[i].Cursor.SetMode(m.cursorMode) } return m, tea.Batch(cmds...) diff --git a/examples/timer/main.go b/examples/timer/main.go index 7ee4f7ddac..42d980c7bd 100644 --- a/examples/timer/main.go +++ b/examples/timer/main.go @@ -111,7 +111,7 @@ func main() { key.WithHelp("q", "quit"), ), }, - help: help.NewModel(), + help: help.New(), } m.keymap.start.SetEnabled(false) diff --git a/examples/tui-daemon-combo/main.go b/examples/tui-daemon-combo/main.go index 962da810de..07364065b9 100644 --- a/examples/tui-daemon-combo/main.go +++ b/examples/tui-daemon-combo/main.go @@ -19,8 +19,6 @@ import ( var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render func main() { - rand.Seed(time.Now().UTC().UnixNano()) - var ( daemonMode bool showHelp bool @@ -77,7 +75,7 @@ func newModel() model { func (m model) Init() tea.Cmd { log.Println("Starting work...") return tea.Batch( - spinner.Tick, + m.spinner.Tick, runPretendProcess, ) } @@ -128,12 +126,12 @@ type processFinishedMsg time.Duration // pretendProcess simulates a long-running process. func runPretendProcess() tea.Msg { - pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond + pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond // nolint:gosec time.Sleep(pause) return processFinishedMsg(pause) } func randomEmoji() string { emojis := []rune("๐Ÿฆ๐Ÿง‹๐Ÿก๐Ÿค ๐Ÿ‘พ๐Ÿ˜ญ๐ŸฆŠ๐Ÿฏ๐Ÿฆ†๐Ÿฅจ๐ŸŽ๐Ÿ”๐Ÿ’๐Ÿฅ๐ŸŽฎ๐Ÿ“ฆ๐Ÿฆ๐Ÿถ๐Ÿธ๐Ÿ•๐Ÿฅ๐Ÿงฒ๐Ÿš’๐Ÿฅ‡๐Ÿ†๐ŸŒฝ") - return string(emojis[rand.Intn(len(emojis))]) + return string(emojis[rand.Intn(len(emojis))]) // nolint:gosec } diff --git a/examples/views/main.go b/examples/views/main.go index a06b59248f..da3c4ec943 100644 --- a/examples/views/main.go +++ b/examples/views/main.go @@ -216,7 +216,7 @@ func chosenView(m model) string { label = fmt.Sprintf("Downloaded. Exiting in %s seconds...", colorFg(strconv.Itoa(m.Ticks), "79")) } - return msg + "\n\n" + label + "\n" + progressbar(80, m.Progress) + "%" + return msg + "\n\n" + label + "\n" + progressbar(m.Progress) + "%" } func checkbox(label string, checked bool) string { @@ -226,7 +226,7 @@ func checkbox(label string, checked bool) string { return fmt.Sprintf("[ ] %s", label) } -func progressbar(width int, percent float64) string { +func progressbar(percent float64) string { w := float64(progressBarWidth) fullSize := int(math.Round(w * percent)) @@ -253,14 +253,6 @@ func makeFgStyle(color string) func(string) string { return termenv.Style{}.Foreground(term.Color(color)).Styled } -// Color a string's foreground and background with the given value. -func makeFgBgStyle(fg, bg string) func(string) string { - return termenv.Style{}. - Foreground(term.Color(fg)). - Background(term.Color(bg)). - Styled -} - // Generate a blend of colors. func makeRamp(colorA, colorB string, steps float64) (s []string) { cA, _ := colorful.Hex(colorA) diff --git a/go.mod b/go.mod index 86f90679aa..b5e9e98cd9 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b github.com/muesli/cancelreader v0.2.2 github.com/muesli/reflow v0.3.0 - github.com/muesli/termenv v0.15.1 + github.com/muesli/termenv v0.15.2 golang.org/x/sync v0.1.0 golang.org/x/term v0.6.0 ) @@ -19,6 +19,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/rivo/uniseg v0.2.0 // indirect - golang.org/x/sys v0.6.0 // indirect + golang.org/x/sys v0.7.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 77ace75615..9bc66d6357 100644 --- a/go.sum +++ b/go.sum @@ -4,7 +4,6 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -19,8 +18,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= -github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -41,10 +40,10 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= diff --git a/key.go b/key.go index c2e5e3ab01..f851490a0d 100644 --- a/key.go +++ b/key.go @@ -1,11 +1,11 @@ package tea import ( - "errors" + "context" + "fmt" "io" + "regexp" "unicode/utf8" - - "github.com/mattn/go-localereader" ) // KeyMsg contains information about a keypress. KeyMsgs are always sent to @@ -338,85 +338,73 @@ var keyNames = map[KeyType]string{ // Sequence mappings. var sequences = map[string]Key{ // Arrow keys - "\x1b[A": {Type: KeyUp}, - "\x1b[B": {Type: KeyDown}, - "\x1b[C": {Type: KeyRight}, - "\x1b[D": {Type: KeyLeft}, - "\x1b[1;2A": {Type: KeyShiftUp}, - "\x1b[1;2B": {Type: KeyShiftDown}, - "\x1b[1;2C": {Type: KeyShiftRight}, - "\x1b[1;2D": {Type: KeyShiftLeft}, - "\x1b[OA": {Type: KeyShiftUp}, // DECCKM - "\x1b[OB": {Type: KeyShiftDown}, // DECCKM - "\x1b[OC": {Type: KeyShiftRight}, // DECCKM - "\x1b[OD": {Type: KeyShiftLeft}, // DECCKM - "\x1b[a": {Type: KeyShiftUp}, // urxvt - "\x1b[b": {Type: KeyShiftDown}, // urxvt - "\x1b[c": {Type: KeyShiftRight}, // urxvt - "\x1b[d": {Type: KeyShiftLeft}, // urxvt - "\x1b[1;3A": {Type: KeyUp, Alt: true}, - "\x1b[1;3B": {Type: KeyDown, Alt: true}, - "\x1b[1;3C": {Type: KeyRight, Alt: true}, - "\x1b[1;3D": {Type: KeyLeft, Alt: true}, - "\x1b\x1b[A": {Type: KeyUp, Alt: true}, // urxvt - "\x1b\x1b[B": {Type: KeyDown, Alt: true}, // urxvt - "\x1b\x1b[C": {Type: KeyRight, Alt: true}, // urxvt - "\x1b\x1b[D": {Type: KeyLeft, Alt: true}, // urxvt - "\x1b[1;4A": {Type: KeyShiftUp, Alt: true}, - "\x1b[1;4B": {Type: KeyShiftDown, Alt: true}, - "\x1b[1;4C": {Type: KeyShiftRight, Alt: true}, - "\x1b[1;4D": {Type: KeyShiftLeft, Alt: true}, - "\x1b\x1b[a": {Type: KeyShiftUp, Alt: true}, // urxvt - "\x1b\x1b[b": {Type: KeyShiftDown, Alt: true}, // urxvt - "\x1b\x1b[c": {Type: KeyShiftRight, Alt: true}, // urxvt - "\x1b\x1b[d": {Type: KeyShiftLeft, Alt: true}, // urxvt - "\x1b[1;5A": {Type: KeyCtrlUp}, - "\x1b[1;5B": {Type: KeyCtrlDown}, - "\x1b[1;5C": {Type: KeyCtrlRight}, - "\x1b[1;5D": {Type: KeyCtrlLeft}, - "\x1b[Oa": {Type: KeyCtrlUp, Alt: true}, // urxvt - "\x1b[Ob": {Type: KeyCtrlDown, Alt: true}, // urxvt - "\x1b[Oc": {Type: KeyCtrlRight, Alt: true}, // urxvt - "\x1b[Od": {Type: KeyCtrlLeft, Alt: true}, // urxvt - "\x1b[1;6A": {Type: KeyCtrlShiftUp}, - "\x1b[1;6B": {Type: KeyCtrlShiftDown}, - "\x1b[1;6C": {Type: KeyCtrlShiftRight}, - "\x1b[1;6D": {Type: KeyCtrlShiftLeft}, - "\x1b[1;7A": {Type: KeyCtrlUp, Alt: true}, - "\x1b[1;7B": {Type: KeyCtrlDown, Alt: true}, - "\x1b[1;7C": {Type: KeyCtrlRight, Alt: true}, - "\x1b[1;7D": {Type: KeyCtrlLeft, Alt: true}, - "\x1b[1;8A": {Type: KeyCtrlShiftUp, Alt: true}, - "\x1b[1;8B": {Type: KeyCtrlShiftDown, Alt: true}, - "\x1b[1;8C": {Type: KeyCtrlShiftRight, Alt: true}, - "\x1b[1;8D": {Type: KeyCtrlShiftLeft, Alt: true}, + "\x1b[A": {Type: KeyUp}, + "\x1b[B": {Type: KeyDown}, + "\x1b[C": {Type: KeyRight}, + "\x1b[D": {Type: KeyLeft}, + "\x1b[1;2A": {Type: KeyShiftUp}, + "\x1b[1;2B": {Type: KeyShiftDown}, + "\x1b[1;2C": {Type: KeyShiftRight}, + "\x1b[1;2D": {Type: KeyShiftLeft}, + "\x1b[OA": {Type: KeyShiftUp}, // DECCKM + "\x1b[OB": {Type: KeyShiftDown}, // DECCKM + "\x1b[OC": {Type: KeyShiftRight}, // DECCKM + "\x1b[OD": {Type: KeyShiftLeft}, // DECCKM + "\x1b[a": {Type: KeyShiftUp}, // urxvt + "\x1b[b": {Type: KeyShiftDown}, // urxvt + "\x1b[c": {Type: KeyShiftRight}, // urxvt + "\x1b[d": {Type: KeyShiftLeft}, // urxvt + "\x1b[1;3A": {Type: KeyUp, Alt: true}, + "\x1b[1;3B": {Type: KeyDown, Alt: true}, + "\x1b[1;3C": {Type: KeyRight, Alt: true}, + "\x1b[1;3D": {Type: KeyLeft, Alt: true}, + + "\x1b[1;4A": {Type: KeyShiftUp, Alt: true}, + "\x1b[1;4B": {Type: KeyShiftDown, Alt: true}, + "\x1b[1;4C": {Type: KeyShiftRight, Alt: true}, + "\x1b[1;4D": {Type: KeyShiftLeft, Alt: true}, + + "\x1b[1;5A": {Type: KeyCtrlUp}, + "\x1b[1;5B": {Type: KeyCtrlDown}, + "\x1b[1;5C": {Type: KeyCtrlRight}, + "\x1b[1;5D": {Type: KeyCtrlLeft}, + "\x1b[Oa": {Type: KeyCtrlUp, Alt: true}, // urxvt + "\x1b[Ob": {Type: KeyCtrlDown, Alt: true}, // urxvt + "\x1b[Oc": {Type: KeyCtrlRight, Alt: true}, // urxvt + "\x1b[Od": {Type: KeyCtrlLeft, Alt: true}, // urxvt + "\x1b[1;6A": {Type: KeyCtrlShiftUp}, + "\x1b[1;6B": {Type: KeyCtrlShiftDown}, + "\x1b[1;6C": {Type: KeyCtrlShiftRight}, + "\x1b[1;6D": {Type: KeyCtrlShiftLeft}, + "\x1b[1;7A": {Type: KeyCtrlUp, Alt: true}, + "\x1b[1;7B": {Type: KeyCtrlDown, Alt: true}, + "\x1b[1;7C": {Type: KeyCtrlRight, Alt: true}, + "\x1b[1;7D": {Type: KeyCtrlLeft, Alt: true}, + "\x1b[1;8A": {Type: KeyCtrlShiftUp, Alt: true}, + "\x1b[1;8B": {Type: KeyCtrlShiftDown, Alt: true}, + "\x1b[1;8C": {Type: KeyCtrlShiftRight, Alt: true}, + "\x1b[1;8D": {Type: KeyCtrlShiftLeft, Alt: true}, // Miscellaneous keys "\x1b[Z": {Type: KeyShiftTab}, - "\x1b[2~": {Type: KeyInsert}, - "\x1b[3;2~": {Type: KeyInsert, Alt: true}, - "\x1b\x1b[2~": {Type: KeyInsert, Alt: true}, // urxvt - - "\x1b[3~": {Type: KeyDelete}, - "\x1b[3;3~": {Type: KeyDelete, Alt: true}, - "\x1b\x1b[3~": {Type: KeyDelete, Alt: true}, // urxvt - - "\x1b[5~": {Type: KeyPgUp}, - "\x1b[5;3~": {Type: KeyPgUp, Alt: true}, - "\x1b\x1b[5~": {Type: KeyPgUp, Alt: true}, // urxvt - "\x1b[5;5~": {Type: KeyCtrlPgUp}, - "\x1b[5^": {Type: KeyCtrlPgUp}, // urxvt - "\x1b[5;7~": {Type: KeyCtrlPgUp, Alt: true}, - "\x1b\x1b[5^": {Type: KeyCtrlPgUp, Alt: true}, // urxvt - - "\x1b[6~": {Type: KeyPgDown}, - "\x1b[6;3~": {Type: KeyPgDown, Alt: true}, - "\x1b\x1b[6~": {Type: KeyPgDown, Alt: true}, // urxvt - "\x1b[6;5~": {Type: KeyCtrlPgDown}, - "\x1b[6^": {Type: KeyCtrlPgDown}, // urxvt - "\x1b[6;7~": {Type: KeyCtrlPgDown, Alt: true}, - "\x1b\x1b[6^": {Type: KeyCtrlPgDown, Alt: true}, // urxvt + "\x1b[2~": {Type: KeyInsert}, + "\x1b[3;2~": {Type: KeyInsert, Alt: true}, + + "\x1b[3~": {Type: KeyDelete}, + "\x1b[3;3~": {Type: KeyDelete, Alt: true}, + + "\x1b[5~": {Type: KeyPgUp}, + "\x1b[5;3~": {Type: KeyPgUp, Alt: true}, + "\x1b[5;5~": {Type: KeyCtrlPgUp}, + "\x1b[5^": {Type: KeyCtrlPgUp}, // urxvt + "\x1b[5;7~": {Type: KeyCtrlPgUp, Alt: true}, + + "\x1b[6~": {Type: KeyPgDown}, + "\x1b[6;3~": {Type: KeyPgDown, Alt: true}, + "\x1b[6;5~": {Type: KeyCtrlPgDown}, + "\x1b[6^": {Type: KeyCtrlPgDown}, // urxvt + "\x1b[6;7~": {Type: KeyCtrlPgDown, Alt: true}, "\x1b[1~": {Type: KeyHome}, "\x1b[H": {Type: KeyHome}, // xterm, lxterm @@ -438,23 +426,15 @@ var sequences = map[string]Key{ "\x1b[1;6F": {Type: KeyCtrlShiftEnd}, // xterm, lxterm "\x1b[1;8F": {Type: KeyCtrlShiftEnd, Alt: true}, // xterm, lxterm - "\x1b[7~": {Type: KeyHome}, // urxvt - "\x1b\x1b[7~": {Type: KeyHome, Alt: true}, // urxvt - "\x1b[7^": {Type: KeyCtrlHome}, // urxvt - "\x1b\x1b[7^": {Type: KeyCtrlHome, Alt: true}, // urxvt - "\x1b[7$": {Type: KeyShiftHome}, // urxvt - "\x1b\x1b[7$": {Type: KeyShiftHome, Alt: true}, // urxvt - "\x1b[7@": {Type: KeyCtrlShiftHome}, // urxvt - "\x1b\x1b[7@": {Type: KeyCtrlShiftHome, Alt: true}, // urxvt - - "\x1b[8~": {Type: KeyEnd}, // urxvt - "\x1b\x1b[8~": {Type: KeyEnd, Alt: true}, // urxvt - "\x1b[8^": {Type: KeyCtrlEnd}, // urxvt - "\x1b\x1b[8^": {Type: KeyCtrlEnd, Alt: true}, // urxvt - "\x1b[8$": {Type: KeyShiftEnd}, // urxvt - "\x1b\x1b[8$": {Type: KeyShiftEnd, Alt: true}, // urxvt - "\x1b[8@": {Type: KeyCtrlShiftEnd}, // urxvt - "\x1b\x1b[8@": {Type: KeyCtrlShiftEnd, Alt: true}, // urxvt + "\x1b[7~": {Type: KeyHome}, // urxvt + "\x1b[7^": {Type: KeyCtrlHome}, // urxvt + "\x1b[7$": {Type: KeyShiftHome}, // urxvt + "\x1b[7@": {Type: KeyCtrlShiftHome}, // urxvt + + "\x1b[8~": {Type: KeyEnd}, // urxvt + "\x1b[8^": {Type: KeyCtrlEnd}, // urxvt + "\x1b[8$": {Type: KeyShiftEnd}, // urxvt + "\x1b[8@": {Type: KeyCtrlShiftEnd}, // urxvt // Function keys, Linux console "\x1b[[A": {Type: KeyF1}, // linux console @@ -479,29 +459,16 @@ var sequences = map[string]Key{ "\x1b[13~": {Type: KeyF3}, // urxvt "\x1b[14~": {Type: KeyF4}, // urxvt - "\x1b\x1b[11~": {Type: KeyF1, Alt: true}, // urxvt - "\x1b\x1b[12~": {Type: KeyF2, Alt: true}, // urxvt - "\x1b\x1b[13~": {Type: KeyF3, Alt: true}, // urxvt - "\x1b\x1b[14~": {Type: KeyF4, Alt: true}, // urxvt - "\x1b[15~": {Type: KeyF5}, // vt100, xterm, also urxvt "\x1b[15;3~": {Type: KeyF5, Alt: true}, // vt100, xterm, also urxvt - "\x1b\x1b[15~": {Type: KeyF5, Alt: true}, // urxvt - "\x1b[17~": {Type: KeyF6}, // vt100, xterm, also urxvt "\x1b[18~": {Type: KeyF7}, // vt100, xterm, also urxvt "\x1b[19~": {Type: KeyF8}, // vt100, xterm, also urxvt "\x1b[20~": {Type: KeyF9}, // vt100, xterm, also urxvt "\x1b[21~": {Type: KeyF10}, // vt100, xterm, also urxvt - "\x1b\x1b[17~": {Type: KeyF6, Alt: true}, // urxvt - "\x1b\x1b[18~": {Type: KeyF7, Alt: true}, // urxvt - "\x1b\x1b[19~": {Type: KeyF8, Alt: true}, // urxvt - "\x1b\x1b[20~": {Type: KeyF9, Alt: true}, // urxvt - "\x1b\x1b[21~": {Type: KeyF10, Alt: true}, // urxvt - "\x1b[17;3~": {Type: KeyF6, Alt: true}, // vt100, xterm "\x1b[18;3~": {Type: KeyF7, Alt: true}, // vt100, xterm "\x1b[19;3~": {Type: KeyF8, Alt: true}, // vt100, xterm @@ -514,9 +481,6 @@ var sequences = map[string]Key{ "\x1b[23;3~": {Type: KeyF11, Alt: true}, // vt100, xterm "\x1b[24;3~": {Type: KeyF12, Alt: true}, // vt100, xterm - "\x1b\x1b[23~": {Type: KeyF11, Alt: true}, // urxvt - "\x1b\x1b[24~": {Type: KeyF12, Alt: true}, // urxvt - "\x1b[1;2P": {Type: KeyF13}, "\x1b[1;2Q": {Type: KeyF14}, @@ -526,9 +490,6 @@ var sequences = map[string]Key{ "\x1b[25;3~": {Type: KeyF13, Alt: true}, // vt100, xterm "\x1b[26;3~": {Type: KeyF14, Alt: true}, // vt100, xterm - "\x1b\x1b[25~": {Type: KeyF13, Alt: true}, // urxvt - "\x1b\x1b[26~": {Type: KeyF14, Alt: true}, // urxvt - "\x1b[1;2R": {Type: KeyF15}, "\x1b[1;2S": {Type: KeyF16}, @@ -538,9 +499,6 @@ var sequences = map[string]Key{ "\x1b[28;3~": {Type: KeyF15, Alt: true}, // vt100, xterm "\x1b[29;3~": {Type: KeyF16, Alt: true}, // vt100, xterm - "\x1b\x1b[28~": {Type: KeyF15, Alt: true}, // urxvt - "\x1b\x1b[29~": {Type: KeyF16, Alt: true}, // urxvt - "\x1b[15;2~": {Type: KeyF17}, "\x1b[17;2~": {Type: KeyF18}, "\x1b[18;2~": {Type: KeyF19}, @@ -551,11 +509,6 @@ var sequences = map[string]Key{ "\x1b[33~": {Type: KeyF19}, "\x1b[34~": {Type: KeyF20}, - "\x1b\x1b[31~": {Type: KeyF17, Alt: true}, // urxvt - "\x1b\x1b[32~": {Type: KeyF18, Alt: true}, // urxvt - "\x1b\x1b[33~": {Type: KeyF19, Alt: true}, // urxvt - "\x1b\x1b[34~": {Type: KeyF20, Alt: true}, // urxvt - // Powershell sequences. "\x1bOA": {Type: KeyUp, Alt: false}, "\x1bOB": {Type: KeyDown, Alt: false}, @@ -563,102 +516,171 @@ var sequences = map[string]Key{ "\x1bOD": {Type: KeyLeft, Alt: false}, } -// readInputs reads keypress and mouse inputs from a TTY and returns messages +// unknownInputByteMsg is reported by the input reader when an invalid +// utf-8 byte is detected on the input. Currently, it is not handled +// further by bubbletea. However, having this event makes it possible +// to troubleshoot invalid inputs. +type unknownInputByteMsg byte + +func (u unknownInputByteMsg) String() string { + return fmt.Sprintf("?%#02x?", int(u)) +} + +// unknownCSISequenceMsg is reported by the input reader when an +// unrecognized CSI sequence is detected on the input. Currently, it +// is not handled further by bubbletea. However, having this event +// makes it possible to troubleshoot invalid inputs. +type unknownCSISequenceMsg []byte + +func (u unknownCSISequenceMsg) String() string { + return fmt.Sprintf("?CSI%+v?", []byte(u)[2:]) +} + +var spaceRunes = []rune{' '} + +// readInputs reads keypress and mouse inputs from a TTY and produces messages // containing information about the key or mouse events accordingly. -func readInputs(input io.Reader) ([]Msg, error) { +func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { var buf [256]byte - // Read and block - numBytes, err := input.Read(buf[:]) - if err != nil { - return nil, err - } - b := buf[:numBytes] - b, err = localereader.UTF8(b) - if err != nil { - return nil, err - } + var leftOverFromPrevIteration []byte +loop: + for { + // Read and block. + numBytes, err := input.Read(buf[:]) + if err != nil { + return fmt.Errorf("error reading input: %w", err) + } + b := buf[:numBytes] + if leftOverFromPrevIteration != nil { + b = append(leftOverFromPrevIteration, b...) + } - // Check if it's a mouse event. For now we're parsing X10-type mouse events - // only. - mouseEvent, err := parseX10MouseEvents(b) - if err == nil { - var m []Msg - for _, v := range mouseEvent { - m = append(m, MouseMsg(v)) + // If we had a short read (numBytes < len(buf)), we're sure that + // the end of this read is an event boundary, so there is no doubt + // if we are encountering the end of the buffer while parsing a message. + // However, if we've succeeded in filling up the buffer, there may + // be more data in the OS buffer ready to be read in, to complete + // the last message in the input. In that case, we will retry with + // the left over data in the next iteration. + canHaveMoreData := numBytes == len(buf) + + var i, w int + for i, w = 0, 07; i < len(b); i += w { + var msg Msg + w, msg = detectOneMsg(b[i:], canHaveMoreData) + if w == 0 { + // Expecting more bytes beyond the current buffer. Try waiting + // for more input. + leftOverFromPrevIteration = make([]byte, 0, len(b[i:])+len(buf)) + leftOverFromPrevIteration = append(leftOverFromPrevIteration, b[i:]...) + continue loop + } + + select { + case msgs <- msg: + case <-ctx.Done(): + err := ctx.Err() + if err != nil { + err = fmt.Errorf("found context error while reading input: %w", err) + } + return err + } } - return m, nil + leftOverFromPrevIteration = nil } +} - var runeSets [][]rune - var runes []rune +var ( + unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) + mouseSGRRegex = regexp.MustCompile(`(\d+);(\d+);(\d+)([Mm])`) +) - // Translate input into runes. In most cases we'll receive exactly one - // rune, but there are cases, particularly when an input method editor is - // used, where we can receive multiple runes at once. - for i, w := 0, 0; i < len(b); i += w { - r, width := utf8.DecodeRune(b[i:]) - if r == utf8.RuneError { - return nil, errors.New("could not decode rune") +func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) { + // Detect mouse events. + // X10 mouse events have a length of 6 bytes + const mouseEventX10Len = 6 + if len(b) >= mouseEventX10Len && b[0] == '\x1b' && b[1] == '[' { + switch b[2] { + case 'M': + return mouseEventX10Len, MouseMsg(parseX10MouseEvent(b)) + case '<': + if matchIndices := mouseSGRRegex.FindSubmatchIndex(b[3:]); matchIndices != nil { + // SGR mouse events length is the length of the match plus the length of the escape sequence + mouseEventSGRLen := matchIndices[1] + 3 + return mouseEventSGRLen, MouseMsg(parseSGRMouseEvent(b)) + } } + } - if r == '\x1b' && len(runes) > 1 { - // a new key sequence has started - runeSets = append(runeSets, runes) - runes = []rune{} - } + // Detect escape sequence and control characters other than NUL, + // possibly with an escape character in front to mark the Alt + // modifier. + var foundSeq bool + foundSeq, w, msg = detectSequence(b) + if foundSeq { + return + } - runes = append(runes, r) - w = width + // No non-NUL control character or escape sequence. + // If we are seeing at least an escape character, remember it for later below. + alt := false + i := 0 + if b[0] == '\x1b' { + alt = true + i++ } - // add the final set of runes we decoded - runeSets = append(runeSets, runes) - if len(runeSets) == 0 { - return nil, errors.New("received 0 runes from input") + // Are we seeing a standalone NUL? This is not handled by detectSequence(). + if i < len(b) && b[i] == 0 { + return i + 1, KeyMsg{Type: keyNUL, Alt: alt} } - var msgs []Msg - for _, runes := range runeSets { - // Is it a sequence, like an arrow key? - if k, ok := sequences[string(runes)]; ok { - msgs = append(msgs, KeyMsg(k)) - continue + // Find the longest sequence of runes that are not control + // characters from this point. + var runes []rune + for rw := 0; i < len(b); i += rw { + var r rune + r, rw = utf8.DecodeRune(b[i:]) + if r == utf8.RuneError || r <= rune(keyUS) || r == rune(keyDEL) || r == ' ' { + // Rune errors are handled below; control characters and spaces will + // be handled by detectSequence in the next call to detectOneMsg. + break } - - // Is this an unrecognized CSI sequence? If so, ignore it. - if len(runes) > 2 && runes[0] == 0x1b && (runes[1] == '[' || - (len(runes) > 3 && runes[1] == 0x1b && runes[2] == '[')) { - continue + runes = append(runes, r) + if alt { + // We only support a single rune after an escape alt modifier. + i += rw + break } + } + if i >= len(b) && canHaveMoreData { + // We have encountered the end of the input buffer. Alas, we can't + // be sure whether the data in the remainder of the buffer is + // complete (maybe there was a short read). Instead of sending anything + // dumb to the message channel, do a short read. The outer loop will + // handle this case by extending the buffer as necessary. + return 0, nil + } - // Is the alt key pressed? If so, the buffer will be prefixed with an - // escape. - alt := false - if len(runes) > 1 && runes[0] == 0x1b { - alt = true - runes = runes[1:] + // If we found at least one rune, we report the bunch of them as + // a single KeyRunes or KeySpace event. + if len(runes) > 0 { + k := Key{Type: KeyRunes, Runes: runes, Alt: alt} + if len(runes) == 1 && runes[0] == ' ' { + k.Type = KeySpace } + return i, KeyMsg(k) + } - for _, v := range runes { - // Is the first rune a control character? - r := KeyType(v) - if r <= keyUS || r == keyDEL { - msgs = append(msgs, KeyMsg(Key{Type: r, Alt: alt})) - continue - } - - // If it's a space, override the type with KeySpace (but still include - // the rune). - if r == ' ' { - msgs = append(msgs, KeyMsg(Key{Type: KeySpace, Runes: []rune{v}, Alt: alt})) - continue - } - - // Welp, just regular, ol' runes. - msgs = append(msgs, KeyMsg(Key{Type: KeyRunes, Runes: []rune{v}, Alt: alt})) - } + // We didn't find an escape sequence, nor a valid rune. Was this a + // lone escape character at the end of the input? + if alt && len(b) == 1 { + return 1, KeyMsg(Key{Type: KeyEscape}) } - return msgs, nil + // The character at the current position is neither an escape + // sequence, a valid rune start or a sole escape character. Report + // it as an invalid byte. + return 1, unknownInputByteMsg(b[0]) } diff --git a/key_sequences.go b/key_sequences.go new file mode 100644 index 0000000000..cc200f8d02 --- /dev/null +++ b/key_sequences.go @@ -0,0 +1,71 @@ +package tea + +import "sort" + +// extSequences is used by the map-based algorithm below. It contains +// the sequences plus their alternatives with an escape character +// prefixed, plus the control chars, plus the space. +// It does not contain the NUL character, which is handled specially +// by detectOneMsg. +var extSequences = func() map[string]Key { + s := map[string]Key{} + for seq, key := range sequences { + key := key + s[seq] = key + if !key.Alt { + key.Alt = true + s["\x1b"+seq] = key + } + } + for i := keyNUL + 1; i <= keyDEL; i++ { + if i == keyESC { + continue + } + s[string([]byte{byte(i)})] = Key{Type: i} + s[string([]byte{'\x1b', byte(i)})] = Key{Type: i, Alt: true} + if i == keyUS { + i = keyDEL - 1 + } + } + s[" "] = Key{Type: KeySpace, Runes: spaceRunes} + s["\x1b "] = Key{Type: KeySpace, Alt: true, Runes: spaceRunes} + s["\x1b\x1b"] = Key{Type: KeyEscape, Alt: true} + return s +}() + +// seqLengths is the sizes of valid sequences, starting with the +// largest size. +var seqLengths = func() []int { + sizes := map[int]struct{}{} + for seq := range extSequences { + sizes[len(seq)] = struct{}{} + } + lsizes := make([]int, 0, len(sizes)) + for sz := range sizes { + lsizes = append(lsizes, sz) + } + sort.Slice(lsizes, func(i, j int) bool { return lsizes[i] > lsizes[j] }) + return lsizes +}() + +// detectSequence uses a longest prefix match over the input +// sequence and a hash map. +func detectSequence(input []byte) (hasSeq bool, width int, msg Msg) { + seqs := extSequences + for _, sz := range seqLengths { + if sz > len(input) { + continue + } + prefix := input[:sz] + key, ok := seqs[string(prefix)] + if ok { + return true, sz, KeyMsg(key) + } + } + // Is this an unknown CSI sequence? + if loc := unknownCSIRe.FindIndex(input); loc != nil { + return true, loc[1], unknownCSISequenceMsg(input[:loc[1]]) + } + + return false, 0, nil +} diff --git a/key_test.go b/key_test.go index 07c2743c65..0b1112a222 100644 --- a/key_test.go +++ b/key_test.go @@ -2,8 +2,19 @@ package tea import ( "bytes" + "context" + "errors" + "flag" "fmt" + "io" + "math/rand" + "reflect" + "runtime" + "sort" + "strings" + "sync" "testing" + "time" ) func TestKeyString(t *testing.T) { @@ -48,13 +59,189 @@ func TestKeyTypeString(t *testing.T) { }) } +type seqTest struct { + seq []byte + msg Msg +} + +// buildBaseSeqTests returns sequence tests that are valid for the +// detectSequence() function. +func buildBaseSeqTests() []seqTest { + td := []seqTest{} + for seq, key := range sequences { + key := key + td = append(td, seqTest{[]byte(seq), KeyMsg(key)}) + if !key.Alt { + key.Alt = true + td = append(td, seqTest{[]byte("\x1b" + seq), KeyMsg(key)}) + } + } + // Add all the control characters. + for i := keyNUL + 1; i <= keyDEL; i++ { + if i == keyESC { + // Not handled in detectSequence(), so not part of the base test + // suite. + continue + } + td = append(td, seqTest{[]byte{byte(i)}, KeyMsg{Type: i}}) + td = append(td, seqTest{[]byte{'\x1b', byte(i)}, KeyMsg{Type: i, Alt: true}}) + if i == keyUS { + i = keyDEL - 1 + } + } + + // Additional special cases. + td = append(td, + // Unrecognized CSI sequence. + seqTest{ + []byte{'\x1b', '[', '-', '-', '-', '-', 'X'}, + unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}), + }, + // A lone space character. + seqTest{ + []byte{' '}, + KeyMsg{Type: KeySpace, Runes: []rune(" ")}, + }, + // An escape character with the alt modifier. + seqTest{ + []byte{'\x1b', ' '}, + KeyMsg{Type: KeySpace, Runes: []rune(" "), Alt: true}, + }, + ) + return td +} + +func TestDetectSequence(t *testing.T) { + td := buildBaseSeqTests() + for _, tc := range td { + t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) { + hasSeq, width, msg := detectSequence(tc.seq) + if !hasSeq { + t.Fatalf("no sequence found") + } + if width != len(tc.seq) { + t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq)) + } + if !reflect.DeepEqual(tc.msg, msg) { + t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg) + } + }) + } +} + +func TestDetectOneMsg(t *testing.T) { + td := buildBaseSeqTests() + // Add tests for the inputs that detectOneMsg() can parse, but + // detectSequence() cannot. + td = append(td, + // Mouse event. + seqTest{ + []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, + MouseMsg{X: 32, Y: 16, Type: MouseWheelUp, Button: MouseButtonWheelUp, Action: MouseActionPress}, + }, + // SGR Mouse event. + seqTest{ + []byte("\x1b[<0;33;17M"), + MouseMsg{X: 32, Y: 16, Type: MouseLeft, Button: MouseButtonLeft, Action: MouseActionPress}, + }, + // Runes. + seqTest{ + []byte{'a'}, + KeyMsg{Type: KeyRunes, Runes: []rune("a")}, + }, + seqTest{ + []byte{'\x1b', 'a'}, + KeyMsg{Type: KeyRunes, Runes: []rune("a"), Alt: true}, + }, + seqTest{ + []byte{'a', 'a', 'a'}, + KeyMsg{Type: KeyRunes, Runes: []rune("aaa")}, + }, + // Multi-byte rune. + seqTest{ + []byte("โ˜ƒ"), + KeyMsg{Type: KeyRunes, Runes: []rune("โ˜ƒ")}, + }, + seqTest{ + []byte("\x1bโ˜ƒ"), + KeyMsg{Type: KeyRunes, Runes: []rune("โ˜ƒ"), Alt: true}, + }, + // Standalone control chacters. + seqTest{ + []byte{'\x1b'}, + KeyMsg{Type: KeyEscape}, + }, + seqTest{ + []byte{byte(keySOH)}, + KeyMsg{Type: KeyCtrlA}, + }, + seqTest{ + []byte{'\x1b', byte(keySOH)}, + KeyMsg{Type: KeyCtrlA, Alt: true}, + }, + seqTest{ + []byte{byte(keyNUL)}, + KeyMsg{Type: KeyCtrlAt}, + }, + seqTest{ + []byte{'\x1b', byte(keyNUL)}, + KeyMsg{Type: KeyCtrlAt, Alt: true}, + }, + // Invalid characters. + seqTest{ + []byte{'\x80'}, + unknownInputByteMsg(0x80), + }, + ) + + if runtime.GOOS != "windows" { + // Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows. + // This is incorrect, but it makes our test fail if we try it out. + td = append(td, seqTest{ + []byte{'\xfe'}, + unknownInputByteMsg(0xfe), + }) + } + + for _, tc := range td { + t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) { + width, msg := detectOneMsg(tc.seq, false /* canHaveMoreData */) + if width != len(tc.seq) { + t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq)) + } + if !reflect.DeepEqual(tc.msg, msg) { + t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg) + } + }) + } +} + +func TestReadLongInput(t *testing.T) { + input := strings.Repeat("a", 1000) + msgs := testReadInputs(t, bytes.NewReader([]byte(input))) + if len(msgs) != 1 { + t.Errorf("expected 1 messages, got %d", len(msgs)) + } + km := msgs[0] + k := Key(km.(KeyMsg)) + if k.Type != KeyRunes { + t.Errorf("expected key runes, got %d", k.Type) + } + if len(k.Runes) != 1000 || !reflect.DeepEqual(k.Runes, []rune(input)) { + t.Errorf("unexpected runes: %+v", k) + } + if k.Alt { + t.Errorf("unexpected alt") + } +} + func TestReadInput(t *testing.T) { type test struct { keyname string in []byte out []Msg } - for i, td := range []test{ + testData := []test{ {"a", []byte{'a'}, []Msg{ @@ -73,6 +260,21 @@ func TestReadInput(t *testing.T) { }, }, }, + {"a alt+a", + []byte{'a', '\x1b', 'a'}, + []Msg{ + KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, + KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}, + }, + }, + {"a alt+a a", + []byte{'a', '\x1b', 'a', 'a'}, + []Msg{ + KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, + KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}, + KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, + }, + }, {"ctrl+a", []byte{byte(keySOH)}, []Msg{ @@ -81,6 +283,13 @@ func TestReadInput(t *testing.T) { }, }, }, + {"ctrl+a ctrl+b", + []byte{byte(keySOH), byte(keySTX)}, + []Msg{ + KeyMsg{Type: KeyCtrlA}, + KeyMsg{Type: KeyCtrlB}, + }, + }, {"alt+a", []byte{byte(0x1b), 'a'}, []Msg{ @@ -96,19 +305,7 @@ func TestReadInput(t *testing.T) { []Msg{ KeyMsg{ Type: KeyRunes, - Runes: []rune{'a'}, - }, - KeyMsg{ - Type: KeyRunes, - Runes: []rune{'b'}, - }, - KeyMsg{ - Type: KeyRunes, - Runes: []rune{'c'}, - }, - KeyMsg{ - Type: KeyRunes, - Runes: []rune{'d'}, + Runes: []rune{'a', 'b', 'c', 'd'}, }, }, }, @@ -124,10 +321,36 @@ func TestReadInput(t *testing.T) { []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, []Msg{ MouseMsg{ - Type: MouseWheelUp, + X: 32, + Y: 16, + Type: MouseWheelUp, + Button: MouseButtonWheelUp, + Action: MouseActionPress, }, }, }, + {"left motion release", + []byte{ + '\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33), + '\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33), + }, + []Msg{ + MouseMsg(MouseEvent{ + X: 32, + Y: 16, + Type: MouseLeft, + Button: MouseButtonLeft, + Action: MouseActionMotion, + }), + MouseMsg(MouseEvent{ + X: 64, + Y: 32, + Type: MouseRelease, + Button: MouseButtonNone, + Action: MouseActionRelease, + }), + }, + }, {"shift+tab", []byte{'\x1b', '[', 'Z'}, []Msg{ @@ -136,6 +359,10 @@ func TestReadInput(t *testing.T) { }, }, }, + {"enter", + []byte{'\r'}, + []Msg{KeyMsg{Type: KeyEnter}}, + }, {"alt+enter", []byte{'\x1b', '\r'}, []Msg{ @@ -162,9 +389,9 @@ func TestReadInput(t *testing.T) { }, }, }, - {"unrecognized CSI", + {"?CSI[45 45 45 45 88]?", []byte{'\x1b', '[', '-', '-', '-', '-', 'X'}, - []Msg{}, + []Msg{unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})}, }, // Powershell sequences. {"up", @@ -191,34 +418,275 @@ func TestReadInput(t *testing.T) { []byte{'\x1b', '\x7f'}, []Msg{KeyMsg{Type: KeyBackspace, Alt: true}}, }, - } { + {"ctrl+@", + []byte{'\x00'}, + []Msg{KeyMsg{Type: KeyCtrlAt}}, + }, + {"alt+ctrl+@", + []byte{'\x1b', '\x00'}, + []Msg{KeyMsg{Type: KeyCtrlAt, Alt: true}}, + }, + {"esc", + []byte{'\x1b'}, + []Msg{KeyMsg{Type: KeyEsc}}, + }, + {"alt+esc", + []byte{'\x1b', '\x1b'}, + []Msg{KeyMsg{Type: KeyEsc, Alt: true}}, + }, + // Bracketed paste does not work yet. + {"?CSI[50 48 48 126]? a b ?CSI[50 48 49 126]?", + []byte{ + '\x1b', '[', '2', '0', '0', '~', + 'a', ' ', 'b', + '\x1b', '[', '2', '0', '1', '~'}, + []Msg{ + // What we expect once bracketed paste is recognized properly: + // + // KeyMsg{Type: KeyRunes, Runes: []rune("a b")}, + // + // What we get instead (for now): + unknownCSISequenceMsg{0x1b, 0x5b, 0x32, 0x30, 0x30, 0x7e}, + KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, + KeyMsg{Type: KeySpace, Runes: []rune{' '}}, + KeyMsg{Type: KeyRunes, Runes: []rune{'b'}}, + unknownCSISequenceMsg{0x1b, 0x5b, 0x32, 0x30, 0x31, 0x7e}, + }, + }, + } + if runtime.GOOS != "windows" { + // Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows. + // This is incorrect, but it makes our test fail if we try it out. + testData = append(testData, + test{"?0xfe?", + []byte{'\xfe'}, + []Msg{unknownInputByteMsg(0xfe)}, + }, + test{"a ?0xfe? b", + []byte{'a', '\xfe', ' ', 'b'}, + []Msg{ + KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, + unknownInputByteMsg(0xfe), + KeyMsg{Type: KeySpace, Runes: []rune{' '}}, + KeyMsg{Type: KeyRunes, Runes: []rune{'b'}}, + }, + }, + ) + } + + for i, td := range testData { t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) { - msgs, err := readInputs(bytes.NewReader(td.in)) - if err != nil { - t.Fatalf("unexpected error: %v", err) + msgs := testReadInputs(t, bytes.NewReader(td.in)) + var buf strings.Builder + for i, msg := range msgs { + if i > 0 { + buf.WriteByte(' ') + } + if s, ok := msg.(fmt.Stringer); ok { + buf.WriteString(s.String()) + } else { + fmt.Fprintf(&buf, "%#v:%T", msg, msg) + } } + + title := buf.String() + if title != td.keyname { + t.Errorf("expected message titles:\n %s\ngot:\n %s", td.keyname, title) + } + if len(msgs) != len(td.out) { - t.Fatalf("unexpected message list length") + t.Fatalf("unexpected message list length: got %d, expected %d\n%#v", len(msgs), len(td.out), msgs) } - if len(msgs) == 1 { - if m, ok := msgs[0].(KeyMsg); ok && m.String() != td.keyname { - t.Fatalf(`expected a keymsg %q, got %q`, td.keyname, m) - } + if !reflect.DeepEqual(td.out, msgs) { + t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, msgs) } + }) + } +} + +func testReadInputs(t *testing.T, input io.Reader) []Msg { + // We'll check that the input reader finishes at the end + // without error. + var wg sync.WaitGroup + var inputErr error + ctx, cancel := context.WithCancel(context.Background()) + defer func() { + cancel() + wg.Wait() + if inputErr != nil && !errors.Is(inputErr, io.EOF) { + t.Fatalf("unexpected input error: %v", inputErr) + } + }() - for i, v := range msgs { - if m, ok := v.(KeyMsg); ok && - m.String() != td.out[i].(KeyMsg).String() { - t.Fatalf(`expected a keymsg %q, got %q`, td.out[i].(KeyMsg), m) + // The messages we're consuming. + msgsC := make(chan Msg) + + // Start the reader in the background. + wg.Add(1) + go func() { + defer wg.Done() + inputErr = readInputs(ctx, msgsC, input) + msgsC <- nil + }() + + var msgs []Msg +loop: + for { + select { + case msg := <-msgsC: + if msg == nil { + // end of input marker for the test. + break loop + } + msgs = append(msgs, msg) + case <-time.After(2 * time.Second): + t.Errorf("timeout waiting for input event") + break loop + } + } + return msgs +} + +// randTest defines the test input and expected output for a sequence +// of interleaved control sequences and control characters. +type randTest struct { + data []byte + lengths []int + names []string +} + +// seed is the random seed to randomize the input. This helps check +// that all the sequences get ultimately exercised. +var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)") + +// genRandomData generates a randomized test, with a random seed unless +// the seed flag was set. +func genRandomData(logfn func(int64), length int) randTest { + // We'll use a random source. However, we give the user the option + // to override it to a specific value for reproduceability. + s := *seed + if s == 0 { + s = time.Now().UnixNano() + } + // Inform the user so they know what to reuse to get the same data. + logfn(s) + return genRandomDataWithSeed(s, length) +} + +// genRandomDataWithSeed generates a randomized test with a fixed seed. +func genRandomDataWithSeed(s int64, length int) randTest { + src := rand.NewSource(s) + r := rand.New(src) + + // allseqs contains all the sequences, in sorted order. We sort + // to make the test deterministic (when the seed is also fixed). + type seqpair struct { + seq string + name string + } + var allseqs []seqpair + for seq, key := range sequences { + allseqs = append(allseqs, seqpair{seq, key.String()}) + } + sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq }) + + // res contains the computed test. + var res randTest + + for len(res.data) < length { + alt := r.Intn(2) + prefix := "" + esclen := 0 + if alt == 1 { + prefix = "alt+" + esclen = 1 + } + kind := r.Intn(3) + switch kind { + case 0: + // A control character. + if alt == 1 { + res.data = append(res.data, '\x1b') + } + res.data = append(res.data, 1) + res.names = append(res.names, prefix+"ctrl+a") + res.lengths = append(res.lengths, 1+esclen) + + case 1, 2: + // A sequence. + seqi := r.Intn(len(allseqs)) + s := allseqs[seqi] + if strings.HasPrefix(s.name, "alt+") { + esclen = 0 + prefix = "" + alt = 0 + } + if alt == 1 { + res.data = append(res.data, '\x1b') + } + res.data = append(res.data, s.seq...) + res.names = append(res.names, prefix+s.name) + res.lengths = append(res.lengths, len(s.seq)+esclen) + } + } + return res +} + +// TestDetectRandomSequencesLex checks that the lex-generated sequence +// detector works over concatenations of random sequences. +func TestDetectRandomSequencesLex(t *testing.T) { + runTestDetectSequence(t, detectSequence) +} + +func runTestDetectSequence( + t *testing.T, detectSequence func(input []byte) (hasSeq bool, width int, msg Msg), +) { + for i := 0; i < 10; i++ { + t.Run("", func(t *testing.T) { + td := genRandomData(func(s int64) { t.Logf("using random seed: %d", s) }, 1000) + + t.Logf("%#v", td) + + // tn is the event number in td. + // i is the cursor in the input data. + // w is the length of the last sequence detected. + for tn, i, w := 0, 0, 0; i < len(td.data); tn, i = tn+1, i+w { + hasSequence, width, msg := detectSequence(td.data[i:]) + if !hasSequence { + t.Fatalf("at %d (ev %d): failed to find sequence", i, tn) } - if m, ok := v.(MouseMsg); ok && - (mouseEventTypes[m.Type] != td.keyname || m.Type != td.out[i].(MouseMsg).Type) { - t.Fatalf(`expected a mousemsg %q, got %q`, - td.keyname, - mouseEventTypes[td.out[i].(MouseMsg).Type]) + if width != td.lengths[tn] { + t.Errorf("at %d (ev %d): expected width %d, got %d", i, tn, td.lengths[tn], width) + } + w = width + + s, ok := msg.(fmt.Stringer) + if !ok { + t.Errorf("at %d (ev %d): expected stringer event, got %T", i, tn, msg) + } else { + if td.names[tn] != s.String() { + t.Errorf("at %d (ev %d): expected event %q, got %q", i, tn, td.names[tn], s.String()) + } } } }) } } + +// TestDetectRandomSequencesLex checks that the map-based sequence +// detector works over concatenations of random sequences. +func TestDetectRandomSequencesMap(t *testing.T) { + runTestDetectSequence(t, detectSequence) +} + +// BenchmarkDetectSequenceMap benchmarks the map-based sequence +// detector. +func BenchmarkDetectSequenceMap(b *testing.B) { + td := genRandomDataWithSeed(123, 10000) + for i := 0; i < b.N; i++ { + for j, w := 0, 0; j < len(td.data); j += w { + _, w, _ = detectSequence(td.data[j:]) + } + } +} diff --git a/logging.go b/logging.go index 59258d4c7c..a53118193c 100644 --- a/logging.go +++ b/logging.go @@ -1,6 +1,7 @@ package tea import ( + "fmt" "io" "log" "os" @@ -32,9 +33,9 @@ type LogOptionsSetter interface { // LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter. func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) { - f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:gomnd if err != nil { - return nil, err + return nil, fmt.Errorf("error opening file for logging: %w", err) } log.SetOutput(f) diff --git a/mouse.go b/mouse.go index f918d20695..add8d02931 100644 --- a/mouse.go +++ b/mouse.go @@ -1,23 +1,36 @@ package tea -import ( - "bytes" - "errors" -) +import "strconv" -// MouseMsg contains information about a mouse event and is sent to a program's +// MouseMsg contains information about a mouse event and are sent to a programs // update function when mouse activity occurs. Note that the mouse must first // be enabled in order for the mouse events to be received. type MouseMsg MouseEvent +// String returns a string representation of a mouse event. +func (m MouseMsg) String() string { + return MouseEvent(m).String() +} + // MouseEvent represents a mouse event, which could be a click, a scroll wheel // movement, a cursor movement, or a combination. type MouseEvent struct { - X int - Y int + X int + Y int + Shift bool + Alt bool + Ctrl bool + Action MouseAction + Button MouseButton + + // Deprecated: Use MouseAction & MouseButton instead. Type MouseEventType - Alt bool - Ctrl bool +} + +// IsWheel returns true if the mouse event is a wheel event. +func (m MouseEvent) IsWheel() bool { + return m.Button == MouseButtonWheelUp || m.Button == MouseButtonWheelDown || + m.Button == MouseButtonWheelLeft || m.Button == MouseButtonWheelRight } // String returns a string representation of a mouse event. @@ -28,122 +41,268 @@ func (m MouseEvent) String() (s string) { if m.Alt { s += "alt+" } - s += mouseEventTypes[m.Type] + if m.Shift { + s += "shift+" + } + + if m.Button == MouseButtonNone { + if m.Action == MouseActionMotion || m.Action == MouseActionRelease { + s += mouseActions[m.Action] + } else { + s += "unknown" + } + } else if m.IsWheel() { + s += mouseButtons[m.Button] + } else { + btn := mouseButtons[m.Button] + if btn != "" { + s += btn + } + act := mouseActions[m.Action] + if act != "" { + s += " " + act + } + } + return s } +// MouseAction represents the action that occurred during a mouse event. +type MouseAction int + +// Mouse event actions. +const ( + MouseActionPress MouseAction = iota + MouseActionRelease + MouseActionMotion +) + +var mouseActions = map[MouseAction]string{ + MouseActionPress: "press", + MouseActionRelease: "release", + MouseActionMotion: "motion", +} + +// MouseButton represents the button that was pressed during a mouse event. +type MouseButton int + +// Mouse event buttons +// +// This is based on X11 mouse button codes. +// +// 1 = left button +// 2 = middle button (pressing the scroll wheel) +// 3 = right button +// 4 = turn scroll wheel up +// 5 = turn scroll wheel down +// 6 = push scroll wheel left +// 7 = push scroll wheel right +// 8 = 4th button (aka browser backward button) +// 9 = 5th button (aka browser forward button) +// 10 +// 11 +// +// Other buttons are not supported. +const ( + MouseButtonNone MouseButton = iota + MouseButtonLeft + MouseButtonMiddle + MouseButtonRight + MouseButtonWheelUp + MouseButtonWheelDown + MouseButtonWheelLeft + MouseButtonWheelRight + MouseButtonBackward + MouseButtonForward + MouseButton10 + MouseButton11 +) + +var mouseButtons = map[MouseButton]string{ + MouseButtonNone: "none", + MouseButtonLeft: "left", + MouseButtonMiddle: "middle", + MouseButtonRight: "right", + MouseButtonWheelUp: "wheel up", + MouseButtonWheelDown: "wheel down", + MouseButtonWheelLeft: "wheel left", + MouseButtonWheelRight: "wheel right", + MouseButtonBackward: "backward", + MouseButtonForward: "forward", + MouseButton10: "button 10", + MouseButton11: "button 11", +} + // MouseEventType indicates the type of mouse event occurring. +// +// Deprecated: Use MouseAction & MouseButton instead. type MouseEventType int // Mouse event types. +// +// Deprecated: Use MouseAction & MouseButton instead. const ( MouseUnknown MouseEventType = iota MouseLeft MouseRight MouseMiddle - MouseRelease + MouseRelease // mouse button release (X10 only) MouseWheelUp MouseWheelDown + MouseWheelLeft + MouseWheelRight + MouseBackward + MouseForward MouseMotion ) -var mouseEventTypes = map[MouseEventType]string{ - MouseUnknown: "unknown", - MouseLeft: "left", - MouseRight: "right", - MouseMiddle: "middle", - MouseRelease: "release", - MouseWheelUp: "wheel up", - MouseWheelDown: "wheel down", - MouseMotion: "motion", +// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events +// look like: +// +// ESC [ < Cb ; Cx ; Cy (M or m) +// +// where: +// +// Cb is the encoded button code +// Cx is the x-coordinate of the mouse +// Cy is the y-coordinate of the mouse +// M is for button press, m is for button release +// +// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates +func parseSGRMouseEvent(buf []byte) MouseEvent { + str := string(buf[3:]) + matches := mouseSGRRegex.FindStringSubmatch(str) + if len(matches) != 5 { + // Unreachable, we already checked the regex in `detectOneMsg`. + panic("invalid mouse event") + } + + b, _ := strconv.Atoi(matches[1]) + px := matches[2] + py := matches[3] + release := matches[4] == "m" + m := parseMouseButton(b, true) + + // Wheel buttons don't have release events + // Motion can be reported as a release event in some terminals (Windows Terminal) + if m.Action != MouseActionMotion && !m.IsWheel() && release { + m.Action = MouseActionRelease + m.Type = MouseRelease + } + + x, _ := strconv.Atoi(px) + y, _ := strconv.Atoi(py) + + // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). + m.X = x - 1 + m.Y = y - 1 + + return m } +const x10MouseByteOffset = 32 + // Parse X10-encoded mouse events; the simplest kind. The last release of X10 -// was December 1986, by the way. +// was December 1986, by the way. The original X10 mouse protocol limits the Cx +// and Cy coordinates to 223 (=255-032). // // X10 mouse events look like: // // ESC [M Cb Cx Cy // // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking -func parseX10MouseEvents(buf []byte) ([]MouseEvent, error) { - var r []MouseEvent +func parseX10MouseEvent(buf []byte) MouseEvent { + v := buf[3:6] + m := parseMouseButton(int(v[0]), false) - seq := []byte("\x1b[M") - if !bytes.Contains(buf, seq) { - return r, errors.New("not an X10 mouse event") + // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). + m.X = int(v[1]) - x10MouseByteOffset - 1 + m.Y = int(v[2]) - x10MouseByteOffset - 1 + + return m +} + +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates +func parseMouseButton(b int, isSGR bool) MouseEvent { + var m MouseEvent + e := b + if !isSGR { + e -= x10MouseByteOffset } - for _, v := range bytes.Split(buf, seq) { - if len(v) == 0 { - continue - } - if len(v) != 3 { - return r, errors.New("not an X10 mouse event") - } + const ( + bitShift = 0b0000_0100 + bitAlt = 0b0000_1000 + bitCtrl = 0b0001_0000 + bitMotion = 0b0010_0000 + bitWheel = 0b0100_0000 + bitAdd = 0b1000_0000 // additional buttons 8-11 - var m MouseEvent - const byteOffset = 32 - e := v[0] - byteOffset - - const ( - bitShift = 0b0000_0100 - bitAlt = 0b0000_1000 - bitCtrl = 0b0001_0000 - bitMotion = 0b0010_0000 - bitWheel = 0b0100_0000 - - bitsMask = 0b0000_0011 - - bitsLeft = 0b0000_0000 - bitsMiddle = 0b0000_0001 - bitsRight = 0b0000_0010 - bitsRelease = 0b0000_0011 - - bitsWheelUp = 0b0000_0000 - bitsWheelDown = 0b0000_0001 - ) - - if e&bitWheel != 0 { - // Check the low two bits. - switch e & bitsMask { - case bitsWheelUp: - m.Type = MouseWheelUp - case bitsWheelDown: - m.Type = MouseWheelDown - } - } else { - // Check the low two bits. - // We do not separate clicking and dragging. - switch e & bitsMask { - case bitsLeft: - m.Type = MouseLeft - case bitsMiddle: - m.Type = MouseMiddle - case bitsRight: - m.Type = MouseRight - case bitsRelease: - if e&bitMotion != 0 { - m.Type = MouseMotion - } else { - m.Type = MouseRelease - } - } - } + bitsMask = 0b0000_0011 + ) - if e&bitAlt != 0 { - m.Alt = true - } - if e&bitCtrl != 0 { - m.Ctrl = true + if e&bitAdd != 0 { + m.Button = MouseButtonBackward + MouseButton(e&bitsMask) + } else if e&bitWheel != 0 { + m.Button = MouseButtonWheelUp + MouseButton(e&bitsMask) + } else { + m.Button = MouseButtonLeft + MouseButton(e&bitsMask) + // X10 reports a button release as 0b0000_0011 (3) + if e&bitsMask == bitsMask { + m.Action = MouseActionRelease + m.Button = MouseButtonNone } + } - // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). - m.X = int(v[1]) - byteOffset - 1 - m.Y = int(v[2]) - byteOffset - 1 + // Motion bit doesn't get reported for wheel events. + if e&bitMotion != 0 && !m.IsWheel() { + m.Action = MouseActionMotion + } + + // Modifiers + m.Alt = e&bitAlt != 0 + m.Ctrl = e&bitCtrl != 0 + m.Shift = e&bitShift != 0 - r = append(r, m) + // backward compatibility + switch { + case m.Button == MouseButtonLeft && m.Action == MouseActionPress: + m.Type = MouseLeft + case m.Button == MouseButtonMiddle && m.Action == MouseActionPress: + m.Type = MouseMiddle + case m.Button == MouseButtonRight && m.Action == MouseActionPress: + m.Type = MouseRight + case m.Button == MouseButtonNone && m.Action == MouseActionRelease: + m.Type = MouseRelease + case m.Button == MouseButtonWheelUp && m.Action == MouseActionPress: + m.Type = MouseWheelUp + case m.Button == MouseButtonWheelDown && m.Action == MouseActionPress: + m.Type = MouseWheelDown + case m.Button == MouseButtonWheelLeft && m.Action == MouseActionPress: + m.Type = MouseWheelLeft + case m.Button == MouseButtonWheelRight && m.Action == MouseActionPress: + m.Type = MouseWheelRight + case m.Button == MouseButtonBackward && m.Action == MouseActionPress: + m.Type = MouseBackward + case m.Button == MouseButtonForward && m.Action == MouseActionPress: + m.Type = MouseForward + case m.Action == MouseActionMotion: + m.Type = MouseMotion + switch m.Button { + case MouseButtonLeft: + m.Type = MouseLeft + case MouseButtonMiddle: + m.Type = MouseMiddle + case MouseButtonRight: + m.Type = MouseRight + case MouseButtonBackward: + m.Type = MouseBackward + case MouseButtonForward: + m.Type = MouseForward + } + default: + m.Type = MouseUnknown } - return r, nil + return m } diff --git a/mouse_test.go b/mouse_test.go index d9c108f2f7..30f6ee364b 100644 --- a/mouse_test.go +++ b/mouse_test.go @@ -1,6 +1,9 @@ package tea -import "testing" +import ( + "fmt" + "testing" +) func TestMouseEvent_String(t *testing.T) { tt := []struct { @@ -9,83 +12,186 @@ func TestMouseEvent_String(t *testing.T) { expected string }{ { - name: "unknown", - event: MouseEvent{Type: MouseUnknown}, + name: "unknown", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonNone, + Type: MouseUnknown, + }, expected: "unknown", }, { - name: "left", - event: MouseEvent{Type: MouseLeft}, - expected: "left", + name: "left", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonLeft, + Type: MouseLeft, + }, + expected: "left press", }, { - name: "right", - event: MouseEvent{Type: MouseRight}, - expected: "right", + name: "right", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonRight, + Type: MouseRight, + }, + expected: "right press", }, { - name: "middle", - event: MouseEvent{Type: MouseMiddle}, - expected: "middle", + name: "middle", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonMiddle, + Type: MouseMiddle, + }, + expected: "middle press", }, { - name: "release", - event: MouseEvent{Type: MouseRelease}, + name: "release", + event: MouseEvent{ + Action: MouseActionRelease, + Button: MouseButtonNone, + Type: MouseRelease, + }, expected: "release", }, { - name: "wheel up", - event: MouseEvent{Type: MouseWheelUp}, + name: "wheel up", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonWheelUp, + Type: MouseWheelUp, + }, expected: "wheel up", }, { - name: "wheel down", - event: MouseEvent{Type: MouseWheelDown}, + name: "wheel down", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonWheelDown, + Type: MouseWheelDown, + }, expected: "wheel down", }, { - name: "motion", - event: MouseEvent{Type: MouseMotion}, + name: "wheel left", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonWheelLeft, + Type: MouseWheelLeft, + }, + expected: "wheel left", + }, + { + name: "wheel right", + event: MouseEvent{ + Action: MouseActionPress, + Button: MouseButtonWheelRight, + Type: MouseWheelRight, + }, + expected: "wheel right", + }, + { + name: "motion", + event: MouseEvent{ + Action: MouseActionMotion, + Button: MouseButtonNone, + Type: MouseMotion, + }, expected: "motion", }, + { + name: "shift+left release", + event: MouseEvent{ + Type: MouseLeft, + Action: MouseActionRelease, + Button: MouseButtonLeft, + Shift: true, + }, + expected: "shift+left release", + }, + { + name: "shift+left", + event: MouseEvent{ + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Shift: true, + }, + expected: "shift+left press", + }, + { + name: "ctrl+shift+left", + event: MouseEvent{ + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Shift: true, + Ctrl: true, + }, + expected: "ctrl+shift+left press", + }, { name: "alt+left", event: MouseEvent{ - Type: MouseLeft, - Alt: true, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Alt: true, }, - expected: "alt+left", + expected: "alt+left press", }, { name: "ctrl+left", event: MouseEvent{ - Type: MouseLeft, - Ctrl: true, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Ctrl: true, }, - expected: "ctrl+left", + expected: "ctrl+left press", }, { name: "ctrl+alt+left", event: MouseEvent{ - Type: MouseLeft, - Alt: true, - Ctrl: true, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Alt: true, + Ctrl: true, + }, + expected: "ctrl+alt+left press", + }, + { + name: "ctrl+alt+shift+left", + event: MouseEvent{ + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + Alt: true, + Ctrl: true, + Shift: true, }, - expected: "ctrl+alt+left", + expected: "ctrl+alt+shift+left press", }, { name: "ignore coordinates", event: MouseEvent{ - X: 100, - Y: 200, - Type: MouseLeft, + X: 100, + Y: 200, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, - expected: "left", + expected: "left press", }, { name: "broken type", event: MouseEvent{ - Type: MouseEventType(-1000), + Type: MouseEventType(-100), + Action: MouseAction(-110), + Button: MouseButton(-120), }, expected: "", }, @@ -122,268 +228,329 @@ func TestParseX10MouseEvent(t *testing.T) { tt := []struct { name string buf []byte - expected []MouseEvent + expected MouseEvent }{ // Position. { name: "zero position", - buf: encode(0b0010_0000, 0, 0), - expected: []MouseEvent{ - { - X: 0, - Y: 0, - Type: MouseLeft, - }, + buf: encode(0b0000_0000, 0, 0), + expected: MouseEvent{ + X: 0, + Y: 0, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, }, { name: "max position", - buf: encode(0b0010_0000, 222, 222), // Because 255 (max int8) - 32 - 1. - expected: []MouseEvent{ - { - X: 222, - Y: 222, - Type: MouseLeft, - }, + buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1. + expected: MouseEvent{ + X: 222, + Y: 222, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, }, // Simple. { name: "left", buf: encode(0b0000_0000, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseLeft, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, }, { name: "left in motion", buf: encode(0b0010_0000, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseLeft, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, }, }, { name: "middle", buf: encode(0b0000_0001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseMiddle, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMiddle, + Action: MouseActionPress, + Button: MouseButtonMiddle, }, }, { name: "middle in motion", buf: encode(0b0010_0001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseMiddle, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMiddle, + Action: MouseActionMotion, + Button: MouseButtonMiddle, }, }, { name: "right", buf: encode(0b0000_0010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, { name: "right in motion", buf: encode(0b0010_0010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRight, + Action: MouseActionMotion, + Button: MouseButtonRight, }, }, { name: "motion", buf: encode(0b0010_0011, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseMotion, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMotion, + Action: MouseActionMotion, + Button: MouseButtonNone, }, }, { name: "wheel up", buf: encode(0b0100_0000, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseWheelUp, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelUp, + Action: MouseActionPress, + Button: MouseButtonWheelUp, }, }, { name: "wheel down", buf: encode(0b0100_0001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseWheelDown, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "wheel left", + buf: encode(0b0100_0010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelLeft, + Action: MouseActionPress, + Button: MouseButtonWheelLeft, + }, + }, + { + name: "wheel right", + buf: encode(0b0100_0011, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelRight, + Action: MouseActionPress, + Button: MouseButtonWheelRight, }, }, { name: "release", buf: encode(0b0000_0011, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRelease, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRelease, + Action: MouseActionRelease, + Button: MouseButtonNone, + }, + }, + { + name: "backward", + buf: encode(0b1000_0000, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseBackward, + Action: MouseActionPress, + Button: MouseButtonBackward, + }, + }, + { + name: "forward", + buf: encode(0b1000_0001, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseForward, + Action: MouseActionPress, + Button: MouseButtonForward, + }, + }, + { + name: "button 10", + buf: encode(0b1000_0010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseUnknown, + Action: MouseActionPress, + Button: MouseButton10, + }, + }, + { + name: "button 11", + buf: encode(0b1000_0011, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseUnknown, + Action: MouseActionPress, + Button: MouseButton11, }, }, // Combinations. { name: "alt+right", - buf: encode(0b0010_1010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - Alt: true, - }, + buf: encode(0b0000_1010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, { name: "ctrl+right", - buf: encode(0b0011_0010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - Ctrl: true, - }, + buf: encode(0b0001_0010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, { - name: "ctrl+alt+right", - buf: encode(0b0011_1010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - Alt: true, - Ctrl: true, - }, + name: "left in motion", + buf: encode(0b0010_0000, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: false, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, }, }, { - name: "alt+wheel down", - buf: encode(0b0100_1001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseWheelDown, - Alt: true, - }, + name: "alt+right in motion", + buf: encode(0b0010_1010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Type: MouseRight, + Action: MouseActionMotion, + Button: MouseButtonRight, }, }, { - name: "ctrl+wheel down", - buf: encode(0b0101_0001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseWheelDown, - Ctrl: true, - }, + name: "ctrl+right in motion", + buf: encode(0b0011_0010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Ctrl: true, + Type: MouseRight, + Action: MouseActionMotion, + Button: MouseButtonRight, }, }, { - name: "ctrl+alt+wheel down", - buf: encode(0b0101_1001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseWheelDown, - Alt: true, - Ctrl: true, - }, + name: "ctrl+alt+right", + buf: encode(0b0001_1010, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, - // Unknown. { - name: "wheel with unknown bit", - buf: encode(0b0100_0010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseUnknown, - }, + name: "ctrl+wheel up", + buf: encode(0b0101_0000, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Ctrl: true, + Type: MouseWheelUp, + Action: MouseActionPress, + Button: MouseButtonWheelUp, }, }, { - name: "unknown with modifier", - buf: encode(0b0100_1010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseUnknown, - Alt: true, - }, + name: "alt+wheel down", + buf: encode(0b0100_1001, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "ctrl+alt+wheel down", + buf: encode(0b0101_1001, 32, 16), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Ctrl: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, }, }, // Overflow position. { name: "overflow position", buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1. - expected: []MouseEvent{ - { - X: -6, - Y: -33, - Type: MouseLeft, - }, - }, - }, - // Batched events. - { - name: "batched events", - buf: append(encode(0b0010_0000, 32, 16), encode(0b0000_0011, 64, 32)...), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseLeft, - }, - { - X: 64, - Y: 32, - Type: MouseRelease, - }, + expected: MouseEvent{ + X: -6, + Y: -33, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, }, }, } @@ -392,45 +559,367 @@ func TestParseX10MouseEvent(t *testing.T) { tc := tt[i] t.Run(tc.name, func(t *testing.T) { - actual, err := parseX10MouseEvents(tc.buf) - if err != nil { - t.Fatalf("unexpected error for test: %v", - err, - ) - } + actual := parseX10MouseEvent(tc.buf) - for i := range tc.expected { - if tc.expected[i] != actual[i] { - t.Fatalf("expected %#v but got %#v", - tc.expected[i], - actual[i], - ) - } + if tc.expected != actual { + t.Fatalf("expected %#v but got %#v", + tc.expected, + actual, + ) } }) } } -func TestParseX10MouseEvent_error(t *testing.T) { +// func TestParseX10MouseEvent_error(t *testing.T) { +// tt := []struct { +// name string +// buf []byte +// }{ +// { +// name: "empty buf", +// buf: nil, +// }, +// { +// name: "wrong high bit", +// buf: []byte("\x1a[M@A1"), +// }, +// { +// name: "short buf", +// buf: []byte("\x1b[M@A"), +// }, +// { +// name: "long buf", +// buf: []byte("\x1b[M@A11"), +// }, +// } +// +// for i := range tt { +// tc := tt[i] +// +// t.Run(tc.name, func(t *testing.T) { +// _, err := parseX10MouseEvent(tc.buf) +// +// if err == nil { +// t.Fatalf("expected error but got nil") +// } +// }) +// } +// } + +func TestParseSGRMouseEvent(t *testing.T) { + encode := func(b, x, y int, r bool) []byte { + re := 'M' + if r { + re = 'm' + } + return []byte(fmt.Sprintf("\x1b[<%d;%d;%d%c", b, x+1, y+1, re)) + } + tt := []struct { - name string - buf []byte + name string + buf []byte + expected MouseEvent }{ + // Position. + { + name: "zero position", + buf: encode(0, 0, 0, false), + expected: MouseEvent{ + X: 0, + Y: 0, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + }, + }, + { + name: "225 position", + buf: encode(0, 225, 225, false), + expected: MouseEvent{ + X: 225, + Y: 225, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + }, + }, + // Simple. + { + name: "left", + buf: encode(0, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, + }, + }, + { + name: "left in motion", + buf: encode(32, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, + }, + }, + { + name: "left release", + buf: encode(0, 32, 16, true), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRelease, + Action: MouseActionRelease, + Button: MouseButtonLeft, + }, + }, + { + name: "middle", + buf: encode(1, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMiddle, + Action: MouseActionPress, + Button: MouseButtonMiddle, + }, + }, + { + name: "middle in motion", + buf: encode(33, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMiddle, + Action: MouseActionMotion, + Button: MouseButtonMiddle, + }, + }, + { + name: "middle release", + buf: encode(1, 32, 16, true), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRelease, + Action: MouseActionRelease, + Button: MouseButtonMiddle, + }, + }, + { + name: "right", + buf: encode(2, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, + }, + }, + { + name: "right release", + buf: encode(2, 32, 16, true), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRelease, + Action: MouseActionRelease, + Button: MouseButtonRight, + }, + }, + { + name: "motion", + buf: encode(35, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMotion, + Action: MouseActionMotion, + Button: MouseButtonNone, + }, + }, + { + name: "wheel up", + buf: encode(64, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelUp, + Action: MouseActionPress, + Button: MouseButtonWheelUp, + }, + }, + { + name: "wheel down", + buf: encode(65, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "wheel left", + buf: encode(66, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelLeft, + Action: MouseActionPress, + Button: MouseButtonWheelLeft, + }, + }, + { + name: "wheel right", + buf: encode(67, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelRight, + Action: MouseActionPress, + Button: MouseButtonWheelRight, + }, + }, + { + name: "backward", + buf: encode(128, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseBackward, + Action: MouseActionPress, + Button: MouseButtonBackward, + }, + }, { - name: "empty buf", - buf: nil, + name: "backward in motion", + buf: encode(160, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseBackward, + Action: MouseActionMotion, + Button: MouseButtonBackward, + }, }, { - name: "wrong high bit", - buf: []byte("\x1a[M@A1"), + name: "forward", + buf: encode(129, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseForward, + Action: MouseActionPress, + Button: MouseButtonForward, + }, }, { - name: "short buf", - buf: []byte("\x1b[M@A"), + name: "forward in motion", + buf: encode(161, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseForward, + Action: MouseActionMotion, + Button: MouseButtonForward, + }, }, + // Combinations. { - name: "long buf", - buf: []byte("\x1b[M@A11"), + name: "alt+right", + buf: encode(10, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, + }, + }, + { + name: "ctrl+right", + buf: encode(18, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, + }, + }, + { + name: "ctrl+alt+right", + buf: encode(26, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, + }, + }, + { + name: "alt+wheel press", + buf: encode(73, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "ctrl+wheel press", + buf: encode(81, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Ctrl: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "ctrl+alt+wheel press", + buf: encode(89, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Alt: true, + Ctrl: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, + }, + { + name: "ctrl+alt+shift+wheel press", + buf: encode(93, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Shift: true, + Alt: true, + Ctrl: true, + Type: MouseWheelDown, + Action: MouseActionPress, + Button: MouseButtonWheelDown, + }, }, } @@ -438,10 +927,12 @@ func TestParseX10MouseEvent_error(t *testing.T) { tc := tt[i] t.Run(tc.name, func(t *testing.T) { - _, err := parseX10MouseEvents(tc.buf) - - if err == nil { - t.Fatalf("expected error but got nil") + actual := parseSGRMouseEvent(tc.buf) + if tc.expected != actual { + t.Fatalf("expected %#v but got %#v", + tc.expected, + actual, + ) } }) } diff --git a/nil_renderer.go b/nil_renderer.go index f5637aa47b..1b1d4409a3 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -17,3 +17,5 @@ func (n nilRenderer) enableMouseCellMotion() {} func (n nilRenderer) disableMouseCellMotion() {} func (n nilRenderer) enableMouseAllMotion() {} func (n nilRenderer) disableMouseAllMotion() {} +func (n nilRenderer) enableMouseSGRMode() {} +func (n nilRenderer) disableMouseSGRMode() {} diff --git a/options.go b/options.go index 30f7e6c50b..71e944939a 100644 --- a/options.go +++ b/options.go @@ -3,6 +3,7 @@ package tea import ( "context" "io" + "sync/atomic" "github.com/muesli/termenv" ) @@ -76,7 +77,7 @@ func WithoutCatchPanics() ProgramOption { // This is mainly useful for testing. func WithoutSignals() ProgramOption { return func(p *Program) { - p.ignoreSignals = true + atomic.StoreUint32(&p.ignoreSignals, 1) } } @@ -107,6 +108,9 @@ func WithAltScreen() ProgramOption { // movement events are also captured if a mouse button is pressed (i.e., drag // events). Cell motion mode is better supported than all motion mode. // +// This will try to enable the mouse in extended mode (SGR), if that is not +// supported by the terminal it will fall back to normal mode (X10). +// // To enable mouse cell motion once the program has already started running use // the EnableMouseCellMotion command. To disable the mouse when the program is // running use the DisableMouse command. @@ -126,6 +130,9 @@ func WithMouseCellMotion() ProgramOption { // wheel, and motion events, which are delivered regardless of whether a mouse // button is pressed, effectively enabling support for hover interactions. // +// This will try to enable the mouse in extended mode (SGR), if that is not +// supported by the terminal it will fall back to normal mode (X10). +// // Many modern terminals support this, but not all. If in doubt, use // EnableMouseCellMotion instead. // @@ -200,3 +207,12 @@ func WithFilter(filter func(Model, Msg) Msg) ProgramOption { p.filter = filter } } + +// WithFPS sets a custom maximum FPS at which the renderer should run. If +// less than 1, the default value of 60 will be used. If over 120, the FPS +// will be capped at 120. +func WithFPS(fps int) ProgramOption { + return func(p *Program) { + p.fps = fps + } +} diff --git a/options_test.go b/options_test.go index fdbc5b2cc2..74e75fe68b 100644 --- a/options_test.go +++ b/options_test.go @@ -2,6 +2,7 @@ package tea import ( "bytes" + "sync/atomic" "testing" ) @@ -37,7 +38,7 @@ func TestOptions(t *testing.T) { t.Run("without signals", func(t *testing.T) { p := NewProgram(nil, WithoutSignals()) - if !p.ignoreSignals { + if atomic.LoadUint32(&p.ignoreSignals) == 0 { t.Errorf("ignore signals should have been set") } }) diff --git a/renderer.go b/renderer.go index a6f416277f..5a3ee3c48d 100644 --- a/renderer.go +++ b/renderer.go @@ -40,16 +40,22 @@ type renderer interface { // events if a mouse button is pressed (i.e., drag events). enableMouseCellMotion() - // DisableMouseCellMotion disables Mouse Cell Motion tracking. + // disableMouseCellMotion disables Mouse Cell Motion tracking. disableMouseCellMotion() - // EnableMouseAllMotion enables mouse click, release, wheel and motion + // enableMouseAllMotion enables mouse click, release, wheel and motion // events, regardless of whether a mouse button is pressed. Many modern // terminals support this, but not all. enableMouseAllMotion() - // DisableMouseAllMotion disables All Motion mouse tracking. + // disableMouseAllMotion disables All Motion mouse tracking. disableMouseAllMotion() + + // enableMouseSGRMode enables mouse extended mode (SGR). + enableMouseSGRMode() + + // disableMouseSGRMode disables mouse extended mode (SGR). + disableMouseSGRMode() } // repaintMsg forces a full repaint. diff --git a/screen.go b/screen.go index 899db3d257..d064222fa0 100644 --- a/screen.go +++ b/screen.go @@ -167,3 +167,8 @@ func (p *Program) EnableMouseAllMotion() { func (p *Program) DisableMouseAllMotion() { p.renderer.disableMouseAllMotion() } + +// SetWindowTitle sets the terminal window title. +func (p *Program) SetWindowTitle(title string) { + p.output.SetWindowTitle(title) +} diff --git a/screen_test.go b/screen_test.go index 2f305e3d15..a6610a647d 100644 --- a/screen_test.go +++ b/screen_test.go @@ -14,42 +14,42 @@ func TestClearMsg(t *testing.T) { { name: "clear_screen", cmds: []Cmd{ClearScreen}, - expected: "\x1b[?25l\x1b[2J\x1b[1;1H\x1b[1;1Hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[2J\x1b[1;1H\x1b[1;1Hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "altscreen", cmds: []Cmd{EnterAltScreen, ExitAltScreen}, - expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25l\x1b[?1049l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "altscreen_autoexit", cmds: []Cmd{EnterAltScreen}, - expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25lsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1049l\x1b[?25h", + expected: "\x1b[?25l\x1b[?1049h\x1b[2J\x1b[1;1H\x1b[1;1H\x1b[?25lsuccess\r\n\x1b[2;0H\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h", }, { name: "mouse_cellmotion", cmds: []Cmd{EnableMouseCellMotion}, - expected: "\x1b[?25l\x1b[?1002hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?1002h\x1b[?1006hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "mouse_allmotion", cmds: []Cmd{EnableMouseAllMotion}, - expected: "\x1b[?25l\x1b[?1003hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?1003h\x1b[?1006hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "mouse_disable", cmds: []Cmd{EnableMouseAllMotion, DisableMouse}, - expected: "\x1b[?25l\x1b[?1003h\x1b[?1002l\x1b[?1003lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "cursor_hide", cmds: []Cmd{HideCursor}, - expected: "\x1b[?25l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?25lsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, { name: "cursor_hideshow", cmds: []Cmd{HideCursor, ShowCursor}, - expected: "\x1b[?25l\x1b[?25l\x1b[?25hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l", + expected: "\x1b[?25l\x1b[?25l\x1b[?25hsuccess\r\n\x1b[0D\x1b[2K\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", }, } diff --git a/standard_renderer.go b/standard_renderer.go index e4fb61ca9e..1573a1c278 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -16,7 +16,8 @@ import ( const ( // defaultFramerate specifies the maximum interval at which we should // update the view. - defaultFramerate = time.Second / 60 + defaultFPS = 60 + maxFPS = 120 ) // standardRenderer is a framerate-based terminal renderer, updating the view @@ -54,12 +55,17 @@ type standardRenderer struct { // newRenderer creates a new renderer. Normally you'll want to initialize it // with os.Stdout as the first argument. -func newRenderer(out *termenv.Output, useANSICompressor bool) renderer { +func newRenderer(out *termenv.Output, useANSICompressor bool, fps int) renderer { + if fps < 1 { + fps = defaultFPS + } else if fps > maxFPS { + fps = maxFPS + } r := &standardRenderer{ out: out, mtx: &sync.Mutex{}, done: make(chan struct{}), - framerate: defaultFramerate, + framerate: time.Second / time.Duration(fps), useANSICompressor: useANSICompressor, queuedMessageLines: []string{}, } @@ -88,6 +94,11 @@ func (r *standardRenderer) start() { // stop permanently halts the renderer, rendering the final frame. func (r *standardRenderer) stop() { + // Stop the renderer before acquiring the mutex to avoid a deadlock. + r.once.Do(func() { + r.done <- struct{}{} + }) + // flush locks the mutex r.flush() @@ -95,9 +106,6 @@ func (r *standardRenderer) stop() { defer r.mtx.Unlock() r.out.ClearLine() - r.once.Do(func() { - r.done <- struct{}{} - }) if r.useANSICompressor { if w, ok := r.out.TTY().(io.WriteCloser); ok { @@ -108,13 +116,15 @@ func (r *standardRenderer) stop() { // kill halts the renderer. The final frame will not be rendered. func (r *standardRenderer) kill() { + // Stop the renderer before acquiring the mutex to avoid a deadlock. + r.once.Do(func() { + r.done <- struct{}{} + }) + r.mtx.Lock() defer r.mtx.Unlock() r.out.ClearLine() - r.once.Do(func() { - r.done <- struct{}{} - }) } // listen waits for ticks on the ticker, or a signal to stop the renderer. @@ -198,10 +208,8 @@ func (r *standardRenderer) flush() { // Merge the set of lines we're skipping as a rendering optimization with // the set of lines we've explicitly asked the renderer to ignore. - if r.ignoreLines != nil { - for k, v := range r.ignoreLines { - skipLines[k] = v - } + for k, v := range r.ignoreLines { + skipLines[k] = v } // Paint new lines @@ -388,6 +396,20 @@ func (r *standardRenderer) disableMouseAllMotion() { r.out.DisableMouseAllMotion() } +func (r *standardRenderer) enableMouseSGRMode() { + r.mtx.Lock() + defer r.mtx.Unlock() + + r.out.EnableMouseExtendedMode() +} + +func (r *standardRenderer) disableMouseSGRMode() { + r.mtx.Lock() + defer r.mtx.Unlock() + + r.out.DisableMouseExtendedMode() +} + // setIgnoredLines specifies lines not to be touched by the standard Bubble Tea // renderer. func (r *standardRenderer) setIgnoredLines(from int, to int) { diff --git a/tea.go b/tea.go index ca24eacf3a..f18cb87cf0 100644 --- a/tea.go +++ b/tea.go @@ -18,6 +18,7 @@ import ( "os/signal" "runtime/debug" "sync" + "sync/atomic" "syscall" "github.com/containerd/console" @@ -58,8 +59,6 @@ type Model interface { // update function. type Cmd func() Msg -type handlers []chan struct{} - type inputType int const ( @@ -102,6 +101,29 @@ const ( withoutCatchPanics ) +// handlers manages series of channels returned by various processes. It allows +// us to wait for those processes to terminate before exiting the program. +type handlers []chan struct{} + +// Adds a channel to the list of handlers. We wait for all handlers to terminate +// gracefully on shutdown. +func (h *handlers) add(ch chan struct{}) { + *h = append(*h, ch) +} + +// shutdown waits for all handlers to terminate. +func (h handlers) shutdown() { + var wg sync.WaitGroup + for _, ch := range h { + wg.Add(1) + go func(ch chan struct{}) { + <-ch + wg.Done() + }(ch) + } + wg.Wait() +} + // Program is a terminal user interface. type Program struct { initialModel Model @@ -132,7 +154,7 @@ type Program struct { // was the altscreen active before releasing the terminal? altScreenWasActive bool - ignoreSignals bool + ignoreSignals uint32 // Stores the original reference to stdin for cases where input is not a // TTY on windows and we've automatically opened CONIN$ to receive input. @@ -144,6 +166,10 @@ type Program struct { windowsStdin *os.File //nolint:golint,structcheck,unused filter func(Model, Msg) Msg + + // fps is the frames per second we should set on the renderer, if + // applicable, + fps int } // Quit is a special command that tells the Bubble Tea program to exit. @@ -213,7 +239,7 @@ func (p *Program) handleSignals() chan struct{} { return case <-sig: - if !p.ignoreSignals { + if atomic.LoadUint32(&p.ignoreSignals) == 0 { p.msgs <- QuitMsg{} return } @@ -275,6 +301,12 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { return ch } +func (p *Program) disableMouse() { + p.renderer.disableMouseCellMotion() + p.renderer.disableMouseAllMotion() + p.renderer.disableMouseSGRMode() +} + // eventLoop is the central message loop. It receives and handles the default // Bubble Tea messages, update the model and triggers redraws. func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { @@ -309,15 +341,18 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { case exitAltScreenMsg: p.renderer.exitAltScreen() - case enableMouseCellMotionMsg: - p.renderer.enableMouseCellMotion() - - case enableMouseAllMotionMsg: - p.renderer.enableMouseAllMotion() + case enableMouseCellMotionMsg, enableMouseAllMotionMsg: + switch msg.(type) { + case enableMouseCellMotionMsg: + p.renderer.enableMouseCellMotion() + case enableMouseAllMotionMsg: + p.renderer.enableMouseAllMotion() + } + // mouse mode (1006) is a no-op if the terminal doesn't support it. + p.renderer.enableMouseSGRMode() case disableMouseMsg: - p.renderer.disableMouseCellMotion() - p.renderer.disableMouseAllMotion() + p.disableMouse() case showCursorMsg: p.renderer.showCursor() @@ -362,6 +397,9 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.Send(msg) } }() + + case setWindowTitleMsg: + p.SetWindowTitle(string(msg)) } // Process internal messages for the renderer. @@ -392,20 +430,12 @@ func (p *Program) Run() (Model, error) { case defaultInput: p.input = os.Stdin - case ttyInput: - // Open a new TTY, by request - f, err := openInputTTY() - if err != nil { - return p.initialModel, err - } - defer f.Close() //nolint:errcheck - p.input = f - - case customInput: - // If the user hasn't set a custom input, and input's not a terminal, - // open a TTY so we can capture input as normal. This will allow things - // to "just work" in cases where data was piped or redirected into this - // application. + // The user has not set a custom input, so we need to check whether or + // not standard input is a terminal. If it's not, we open a new TTY for + // input. This will allow things to "just work" in cases where data was + // piped in or redirected to the application. + // + // To disable input entirely pass nil to the [WithInput] program option. f, isFile := p.input.(*os.File) if !isFile { break @@ -420,6 +450,18 @@ func (p *Program) Run() (Model, error) { } defer f.Close() //nolint:errcheck p.input = f + + case ttyInput: + // Open a new TTY, by request + f, err := openInputTTY() + if err != nil { + return p.initialModel, err + } + defer f.Close() //nolint:errcheck + p.input = f + + case customInput: + // (There is nothing extra to do.) } // Handle signals. @@ -441,7 +483,7 @@ func (p *Program) Run() (Model, error) { // If no renderer is set use the standard one. if p.renderer == nil { - p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor)) + p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor), p.fps) } // Check if output is a TTY before entering raw mode, hiding the cursor and @@ -456,8 +498,10 @@ func (p *Program) Run() (Model, error) { } if p.startupOptions&withMouseCellMotion != 0 { p.renderer.enableMouseCellMotion() + p.renderer.enableMouseSGRMode() } else if p.startupOptions&withMouseAllMotion != 0 { p.renderer.enableMouseAllMotion() + p.renderer.enableMouseSGRMode() } // Initialize the program. @@ -603,7 +647,7 @@ func (p *Program) shutdown(kill bool) { // ReleaseTerminal restores the original terminal state and cancels the input // reader. You can return control to the Program with RestoreTerminal. func (p *Program) ReleaseTerminal() error { - p.ignoreSignals = true + atomic.StoreUint32(&p.ignoreSignals, 1) p.cancelReader.Cancel() p.waitForReadLoop() @@ -619,7 +663,7 @@ func (p *Program) ReleaseTerminal() error { // terminal to the former state when the program was running, and repaints. // Use it to reinitialize a Program after running ReleaseTerminal. func (p *Program) RestoreTerminal() error { - p.ignoreSignals = false + atomic.StoreUint32(&p.ignoreSignals, 0) if err := p.initTerminal(); err != nil { return err @@ -670,22 +714,3 @@ func (p *Program) Printf(template string, args ...interface{}) { messageBody: fmt.Sprintf(template, args...), } } - -// Adds a handler to the list of handlers. We wait for all handlers to terminate -// gracefully on shutdown. -func (h *handlers) add(ch chan struct{}) { - *h = append(*h, ch) -} - -// Shutdown waits for all handlers to terminate. -func (h handlers) shutdown() { - var wg sync.WaitGroup - for _, ch := range h { - wg.Add(1) - go func(ch chan struct{}) { - <-ch - wg.Done() - }(ch) - } - wg.Wait() -} diff --git a/tty.go b/tty.go index 3ab6639b75..01f084d438 100644 --- a/tty.go +++ b/tty.go @@ -2,11 +2,13 @@ package tea import ( "errors" + "fmt" "io" "os" "time" isatty "github.com/mattn/go-isatty" + localereader "github.com/mattn/go-localereader" "github.com/muesli/cancelreader" "golang.org/x/term" ) @@ -20,7 +22,7 @@ func (p *Program) initTerminal() error { if p.console != nil { err = p.console.SetRaw() if err != nil { - return err + return fmt.Errorf("error entering raw mode: %w", err) } } @@ -33,21 +35,20 @@ func (p *Program) initTerminal() error { func (p *Program) restoreTerminalState() error { if p.renderer != nil { p.renderer.showCursor() - p.renderer.disableMouseCellMotion() - p.renderer.disableMouseAllMotion() + p.disableMouse() if p.renderer.altScreen() { p.renderer.exitAltScreen() // give the terminal a moment to catch up - time.Sleep(time.Millisecond * 10) + time.Sleep(time.Millisecond * 10) //nolint:gomnd } } if p.console != nil { err := p.console.Reset() if err != nil { - return err + return fmt.Errorf("error restoring terminal state: %w", err) } } @@ -59,7 +60,7 @@ func (p *Program) initCancelReader() error { var err error p.cancelReader, err = cancelreader.NewReader(p.input) if err != nil { - return err + return fmt.Errorf("error creating cancelreader: %w", err) } p.readLoopDone = make(chan struct{}) @@ -71,25 +72,12 @@ func (p *Program) initCancelReader() error { func (p *Program) readLoop() { defer close(p.readLoopDone) - for { - if p.ctx.Err() != nil { - return - } - - msgs, err := readInputs(p.cancelReader) - if err != nil { - if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) { - select { - case <-p.ctx.Done(): - case p.errs <- err: - } - } - - return - } - - for _, msg := range msgs { - p.msgs <- msg + input := localereader.NewReader(p.cancelReader) + err := readInputs(p.ctx, p.msgs, input) + if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) { + select { + case <-p.ctx.Done(): + case p.errs <- err: } } } @@ -98,7 +86,7 @@ func (p *Program) readLoop() { func (p *Program) waitForReadLoop() { select { case <-p.readLoopDone: - case <-time.After(500 * time.Millisecond): + case <-time.After(500 * time.Millisecond): //nolint:gomnd // The read loop hangs, which means the input // cancelReader's cancel function has returned true even // though it was not able to cancel the read. diff --git a/tty_unix.go b/tty_unix.go index b6f5ffdf43..a3a25b8fa6 100644 --- a/tty_unix.go +++ b/tty_unix.go @@ -4,6 +4,7 @@ package tea import ( + "fmt" "os" "github.com/containerd/console" @@ -28,7 +29,9 @@ func (p *Program) initInput() error { // program exits. func (p *Program) restoreInput() error { if p.console != nil { - return p.console.Reset() + if err := p.console.Reset(); err != nil { + return fmt.Errorf("error restoring console: %w", err) + } } return nil } @@ -36,7 +39,7 @@ func (p *Program) restoreInput() error { func openInputTTY() (*os.File, error) { f, err := os.Open("/dev/tty") if err != nil { - return nil, err + return nil, fmt.Errorf("could not open a new TTY: %w", err) } return f, nil } diff --git a/tutorials/basics/main.go b/tutorials/basics/main.go index 4970290d24..71cef0a828 100644 --- a/tutorials/basics/main.go +++ b/tutorials/basics/main.go @@ -25,7 +25,7 @@ func initialModel() model { } func (m model) Init() tea.Cmd { - return nil + return tea.SetWindowTitle("Grocery List") } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { diff --git a/tutorials/commands/main.go b/tutorials/commands/main.go index 8ed68b7f5a..37a62f0b4c 100644 --- a/tutorials/commands/main.go +++ b/tutorials/commands/main.go @@ -22,7 +22,7 @@ func checkServer() tea.Msg { if err != nil { return errMsg{err} } - defer res.Body.Close() + defer res.Body.Close() // nolint:errcheck return statusMsg(res.StatusCode) } diff --git a/tutorials/go.mod b/tutorials/go.mod index 5933d123ca..085b9d4560 100644 --- a/tutorials/go.mod +++ b/tutorials/go.mod @@ -14,10 +14,10 @@ require ( github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.1 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/rivo/uniseg v0.2.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.6.0 // indirect + golang.org/x/sys v0.7.0 // indirect golang.org/x/term v0.6.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/tutorials/go.sum b/tutorials/go.sum index 484e442e86..9bc66d6357 100644 --- a/tutorials/go.sum +++ b/tutorials/go.sum @@ -1,12 +1,9 @@ -github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E= -github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -21,8 +18,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= -github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -43,10 +40,10 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=