From c267762438a3b24e9138e4ec02dd43c3b99c71fd Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 24 May 2023 12:31:00 -0400 Subject: [PATCH 01/44] fix(regression): auto-open a TTY when stdin is not a TTY (#746) The regression was introduced in precisely this revision: fcc805f3da68f535f7c3c78b94b4b14cd22a047a Closes #745. --- tea.go | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/tea.go b/tea.go index ca24eacf3a..2e024ed604 100644 --- a/tea.go +++ b/tea.go @@ -392,20 +392,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 +412,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. From 26dc0b5b327b55a1deaece8cb57bda39564ca25f Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Wed, 31 May 2023 14:08:34 -0400 Subject: [PATCH 02/44] chore(deps): bump bubbles to v0.16.1 --- examples/go.mod | 4 ++-- examples/go.sum | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 057df64474..5dad68268c 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,8 +3,8 @@ 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.16.1 + github.com/charmbracelet/bubbletea v0.24.1 github.com/charmbracelet/glamour v0.6.0 github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss v0.7.1 diff --git a/examples/go.sum b/examples/go.sum index b112ab32dc..902315408e 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -11,13 +11,12 @@ 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.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 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/x/exp/teatest v0.0.0-20230508155401-2bd6fa14c46a h1:GpNt24LKE8sH5G0SZUpu4Tg15sP5XSt1mnfIqE7fW34= @@ -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,7 +45,6 @@ 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= @@ -58,10 +55,8 @@ github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTd 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= @@ -99,7 +94,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= From 5f7a07b03c73639d86343ac3df00cb698cf24832 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Wed, 31 May 2023 14:08:43 -0400 Subject: [PATCH 03/44] chore(deps): bump bubbles to v0.16.1 --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 86f90679aa..2e15427694 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.16.1 // indirect 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 diff --git a/go.sum b/go.sum index 77ace75615..98430888bc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 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/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 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= From 8254e0e4721c62d7b479d1103ad24be4f2bde032 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Wed, 31 May 2023 14:20:18 -0400 Subject: [PATCH 04/44] fix(examples/file-picker): use `CurrentDirectory` instead of Path --- examples/file-picker/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/file-picker/main.go b/examples/file-picker/main.go index 30ecf01af8..727b880e8a 100644 --- a/examples/file-picker/main.go +++ b/examples/file-picker/main.go @@ -58,7 +58,7 @@ func (m model) View() string { func main() { fp := filepicker.New() - fp.Path, _ = os.UserHomeDir() + fp.CurrentDirectory, _ = os.UserHomeDir() m := model{ filepicker: fp, From 444e04bbb30f700e3d5d2d7323b5e60b7a2b78c5 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Wed, 31 May 2023 16:18:24 -0500 Subject: [PATCH 05/44] docs(examples): filepicker AllowedTypes example (#713) --- examples/file-picker/main.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/examples/file-picker/main.go b/examples/file-picker/main.go index 727b880e8a..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,6 +82,7 @@ func (m model) View() string { func main() { fp := filepicker.New() + fp.AllowedTypes = []string{".mod", ".sum", ".go", ".txt", ".md"} fp.CurrentDirectory, _ = os.UserHomeDir() m := model{ From 44f17fa1c0d9bf4a20d5bc89640d2ebe7a7f3a8b Mon Sep 17 00:00:00 2001 From: Christian Muehlhaeuser Date: Fri, 2 Jun 2023 12:05:33 +0200 Subject: [PATCH 06/44] fix: stop renderer before acquiring renderer mutex --- standard_renderer.go | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/standard_renderer.go b/standard_renderer.go index e4fb61ca9e..0ab9473d80 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -88,6 +88,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 +100,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 +110,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. From f3e1b676056b478f3994ec894a8aafe761bae67b Mon Sep 17 00:00:00 2001 From: tomfeigin Date: Tue, 6 Jun 2023 18:49:11 +0300 Subject: [PATCH 07/44] (feat): Add option to set max FPS (#578) * (feat): add option for setting FPS on renderer Co-authored-by: Christian Rocha --- options.go | 9 +++++++++ standard_renderer.go | 12 +++++++++--- tea.go | 6 +++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/options.go b/options.go index 30f7e6c50b..249d3bffc9 100644 --- a/options.go +++ b/options.go @@ -200,3 +200,12 @@ func WithFilter(filter func(Model, Msg) Msg) ProgramOption { p.filter = filter } } + +// WithMaxFPS 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/standard_renderer.go b/standard_renderer.go index 0ab9473d80..c6a1b61017 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{}, } diff --git a/tea.go b/tea.go index 2e024ed604..c1d8cafd5d 100644 --- a/tea.go +++ b/tea.go @@ -144,6 +144,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. @@ -445,7 +449,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 From b2d2ac6504e595df2a895e043f243cfca4cd3fed Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jun 2023 09:17:15 -0300 Subject: [PATCH 08/44] chore: update example tests, test on ci (#735) * chore: update example tests, test on ci Signed-off-by: Carlos Alexandro Becker * fix: mark *.golden as binary Signed-off-by: Carlos Alexandro Becker --------- Signed-off-by: Carlos Alexandro Becker --- .gitattributes | 1 + .github/workflows/build.yml | 4 ++++ examples/simple/main_test.go | 12 +++++++++--- examples/simple/testdata/TestApp.golden | 4 ++-- 4 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 .gitattributes 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..2511296056 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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/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..b0b6c3dc97 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. +[?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 From b80eb8303bba8fe2a8ea89a3962a01a0ee5320b8 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 9 Jun 2023 13:33:53 -0300 Subject: [PATCH 09/44] docs: fix lint issues in examples and tutorials (#759) Signed-off-by: Carlos Alexandro Becker --- examples/cellbuffer/main.go | 30 ++++++++++++---------------- examples/composable-views/main.go | 2 +- examples/help/main.go | 6 +++--- examples/http/main.go | 2 +- examples/list-simple/main.go | 6 +++--- examples/package-manager/main.go | 4 +--- examples/package-manager/packages.go | 2 +- examples/pipe/main.go | 2 +- examples/prevent-quit/main.go | 2 +- examples/progress-download/main.go | 6 +++--- examples/realtime/main.go | 6 ++---- examples/send-msg/main.go | 8 +++----- examples/stopwatch/main.go | 2 +- examples/textinputs/main.go | 11 +++++----- examples/timer/main.go | 2 +- examples/tui-daemon-combo/main.go | 8 +++----- examples/views/main.go | 12 ++--------- tutorials/commands/main.go | 2 +- 18 files changed, 47 insertions(+), 66 deletions(-) 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/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/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/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/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) } From c0cc6aa1fb4f2e19554d1f75fd20e9266d200c90 Mon Sep 17 00:00:00 2001 From: Lukas Bloznelis <33397865+bloznelis@users.noreply.github.com> Date: Wed, 14 Jun 2023 17:25:09 +0300 Subject: [PATCH 10/44] chore(docs): add typioca to Bubble Tea in the Wild (#763) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0a88caa5fa..395b65c172 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,7 @@ 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 * [wander](https://github.com/robinovitch61/wander): a HashiCorp Nomad terminal client From d9c675138c8f2a48522d0f0f043609baae30ce4e Mon Sep 17 00:00:00 2001 From: Raphael 'kena' Poss Date: Fri, 21 Oct 2022 17:00:39 +0200 Subject: [PATCH 11/44] fix(key),test: simplify the input analysis code --- key.go | 352 +++++++++++++++++------------------- key_sequences.go | 71 ++++++++ key_test.go | 457 +++++++++++++++++++++++++++++++++++++++++++---- mouse.go | 143 +++++++-------- mouse_test.go | 302 +++++++++++-------------------- 5 files changed, 821 insertions(+), 504 deletions(-) create mode 100644 key_sequences.go diff --git a/key.go b/key.go index c2e5e3ab01..bbd533cd78 100644 --- a/key.go +++ b/key.go @@ -1,8 +1,9 @@ package tea import ( - "errors" + "fmt" "io" + "regexp" "unicode/utf8" "github.com/mattn/go-localereader" @@ -338,85 +339,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 +427,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 +460,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 +482,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 +491,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 +500,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 +510,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 +517,118 @@ var sequences = map[string]Key{ "\x1bOD": {Type: KeyLeft, Alt: false}, } +// 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 returns messages // containing information about the key or mouse events accordingly. func readInputs(input io.Reader) ([]Msg, error) { var buf [256]byte + input = localereader.NewReader(input) + // 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 - } - // 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)) - } - return m, nil + var msgs []Msg + for i, w := 0, 0; i < len(b); i += w { + var msg Msg + w, msg = detectOneMsg(b[i:]) + msgs = append(msgs, msg) } + return msgs, nil +} - var runeSets [][]rune - var runes []rune +var unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) - // 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) (w int, msg Msg) { + // Detect mouse events. + if len(b) >= 6 && b[0] == '\x1b' && b[1] == '[' && b[2] == 'M' { + return 6, MouseMsg(parseX10MouseEvent(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 } - - // 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..1af6a3e9ae 100644 --- a/key_test.go +++ b/key_test.go @@ -2,8 +2,15 @@ package tea import ( "bytes" + "flag" "fmt" + "math/rand" + "reflect" + "runtime" + "sort" + "strings" "testing" + "time" ) func TestKeyString(t *testing.T) { @@ -48,13 +55,165 @@ 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}, + }, + // 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) + 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 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 +232,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 +255,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 +277,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 +293,30 @@ func TestReadInput(t *testing.T) { []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, []Msg{ MouseMsg{ + X: 32, + Y: 16, Type: MouseWheelUp, }, }, }, + {"left 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, + }), + MouseMsg(MouseEvent{ + X: 64, + Y: 32, + Type: MouseRelease, + }), + }, + }, {"shift+tab", []byte{'\x1b', '[', 'Z'}, []Msg{ @@ -136,6 +325,10 @@ func TestReadInput(t *testing.T) { }, }, }, + {"enter", + []byte{'\r'}, + []Msg{KeyMsg{Type: KeyEnter}}, + }, {"alt+enter", []byte{'\x1b', '\r'}, []Msg{ @@ -162,9 +355,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 +384,236 @@ 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) } + + // Compute the title for the event sequence. + 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) } + }) + } +} - 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) +// 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/mouse.go b/mouse.go index f918d20695..fc66691cc6 100644 --- a/mouse.go +++ b/mouse.go @@ -1,15 +1,15 @@ package tea -import ( - "bytes" - "errors" -) - -// 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 { @@ -66,84 +66,67 @@ var mouseEventTypes = map[MouseEventType]string{ // ESC [M Cb Cx Cy // // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking -func parseX10MouseEvents(buf []byte) ([]MouseEvent, error) { - var r []MouseEvent - - seq := []byte("\x1b[M") - if !bytes.Contains(buf, seq) { - return r, errors.New("not an X10 mouse event") - } - - 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") +func parseX10MouseEvent(buf []byte) MouseEvent { + v := buf[3:6] + 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 } - - 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 - } + } 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 } } + } - if e&bitAlt != 0 { - m.Alt = true - } - if e&bitCtrl != 0 { - m.Ctrl = true - } - - // (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 - - r = append(r, m) + if e&bitAlt != 0 { + m.Alt = true } + if e&bitCtrl != 0 { + m.Ctrl = true + } + + // (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 - return r, nil + return m } diff --git a/mouse_test.go b/mouse_test.go index d9c108f2f7..a64a2e302d 100644 --- a/mouse_test.go +++ b/mouse_test.go @@ -122,268 +122,209 @@ 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, - }, + expected: MouseEvent{ + X: 0, + Y: 0, + Type: MouseLeft, }, }, { name: "max position", buf: encode(0b0010_0000, 222, 222), // Because 255 (max int8) - 32 - 1. - expected: []MouseEvent{ - { - X: 222, - Y: 222, - Type: MouseLeft, - }, + expected: MouseEvent{ + X: 222, + Y: 222, + Type: MouseLeft, }, }, // 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, }, }, { 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, }, }, { name: "middle", buf: encode(0b0000_0001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseMiddle, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMiddle, }, }, { 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, }, }, { name: "right", buf: encode(0b0000_0010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRight, }, }, { 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, }, }, { name: "motion", buf: encode(0b0010_0011, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseMotion, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseMotion, }, }, { 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, }, }, { 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, }, }, { name: "release", buf: encode(0b0000_0011, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRelease, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRelease, }, }, // Combinations. { name: "alt+right", buf: encode(0b0010_1010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - Alt: true, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRight, + Alt: true, }, }, { name: "ctrl+right", buf: encode(0b0011_0010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - Ctrl: true, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRight, + Ctrl: true, }, }, { name: "ctrl+alt+right", buf: encode(0b0011_1010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseRight, - Alt: true, - Ctrl: true, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseRight, + Alt: true, + Ctrl: true, }, }, { name: "alt+wheel down", buf: encode(0b0100_1001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseWheelDown, - Alt: true, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelDown, + Alt: true, }, }, { name: "ctrl+wheel down", buf: encode(0b0101_0001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseWheelDown, - Ctrl: true, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelDown, + Ctrl: true, }, }, { name: "ctrl+alt+wheel down", buf: encode(0b0101_1001, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseWheelDown, - Alt: true, - Ctrl: true, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseWheelDown, + Alt: true, + Ctrl: true, }, }, // Unknown. { name: "wheel with unknown bit", buf: encode(0b0100_0010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseUnknown, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseUnknown, }, }, { name: "unknown with modifier", buf: encode(0b0100_1010, 32, 16), - expected: []MouseEvent{ - { - X: 32, - Y: 16, - Type: MouseUnknown, - Alt: true, - }, + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseUnknown, + Alt: true, }, }, // 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, }, }, } @@ -392,56 +333,13 @@ 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, - ) - } - - for i := range tc.expected { - if tc.expected[i] != actual[i] { - t.Fatalf("expected %#v but got %#v", - tc.expected[i], - actual[i], - ) - } - } - }) - } -} - -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 := parseX10MouseEvents(tc.buf) + actual := parseX10MouseEvent(tc.buf) - if err == nil { - t.Fatalf("expected error but got nil") + if tc.expected != actual { + t.Fatalf("expected %#v but got %#v", + tc.expected, + actual, + ) } }) } From ed4f2ec1ca0b47156fece1b368ee1f68f2020478 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 19 Jun 2023 17:30:27 -0400 Subject: [PATCH 12/44] chore: go mod tidy to remove rogue bubbles dep --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index 2e15427694..86f90679aa 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v0.16.1 // indirect 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 diff --git a/go.sum b/go.sum index 98430888bc..77ace75615 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ 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/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= -github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 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= From b1e7f42ab0625c298c70f3dcd1f04da979a2e8be Mon Sep 17 00:00:00 2001 From: Raphael 'kena' Poss Date: Fri, 21 Oct 2022 18:18:56 +0200 Subject: [PATCH 13/44] fix(key): invert the control loop Instead of reading messages in an array and then sending them into a channel, this version of key.go writes to the channel directly. --- key.go | 40 ++++++++++++++++++++------------------ key_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++------ tty.go | 26 +++++++------------------ 3 files changed, 77 insertions(+), 44 deletions(-) diff --git a/key.go b/key.go index bbd533cd78..74f85ec405 100644 --- a/key.go +++ b/key.go @@ -1,12 +1,11 @@ package tea import ( + "context" "fmt" "io" "regexp" "unicode/utf8" - - "github.com/mattn/go-localereader" ) // KeyMsg contains information about a keypress. KeyMsgs are always sent to @@ -539,27 +538,30 @@ func (u unknownCSISequenceMsg) String() string { var spaceRunes = []rune{' '} -// readInputs reads keypress and mouse inputs from a TTY and returns messages +// 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 - input = localereader.NewReader(input) - - // Read and block - numBytes, err := input.Read(buf[:]) - if err != nil { - return nil, err - } - b := buf[:numBytes] - - var msgs []Msg - for i, w := 0, 0; i < len(b); i += w { - var msg Msg - w, msg = detectOneMsg(b[i:]) - msgs = append(msgs, msg) + for { + // Read and block. + numBytes, err := input.Read(buf[:]) + if err != nil { + return err + } + b := buf[:numBytes] + + var i, w int + for i, w = 0, 0; i < len(b); i += w { + var msg Msg + w, msg = detectOneMsg(b[i:]) + select { + case msgs <- msg: + case <-ctx.Done(): + return ctx.Err() + } + } } - return msgs, nil } var unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) diff --git a/key_test.go b/key_test.go index 1af6a3e9ae..d466b056e3 100644 --- a/key_test.go +++ b/key_test.go @@ -2,13 +2,17 @@ package tea import ( "bytes" + "context" + "errors" "flag" "fmt" + "io" "math/rand" "reflect" "runtime" "sort" "strings" + "sync" "testing" "time" ) @@ -442,12 +446,7 @@ func TestReadInput(t *testing.T) { 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) - } - - // Compute the title for the event sequence. + msgs := testReadInputs(t, bytes.NewReader(td.in)) var buf strings.Builder for i, msg := range msgs { if i > 0 { @@ -459,6 +458,7 @@ func TestReadInput(t *testing.T) { 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) @@ -475,6 +475,49 @@ func TestReadInput(t *testing.T) { } } +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) + } + }() + + // 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 { diff --git a/tty.go b/tty.go index 3ab6639b75..715d542356 100644 --- a/tty.go +++ b/tty.go @@ -7,6 +7,7 @@ import ( "time" isatty "github.com/mattn/go-isatty" + localereader "github.com/mattn/go-localereader" "github.com/muesli/cancelreader" "golang.org/x/term" ) @@ -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: } } } From f75684c9862611cfa7f5f5f862330d6769fee757 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 14 Jun 2023 14:42:04 -0400 Subject: [PATCH 14/44] chore: group handler type and methods together --- tea.go | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/tea.go b/tea.go index c1d8cafd5d..34fb883e59 100644 --- a/tea.go +++ b/tea.go @@ -58,8 +58,6 @@ type Model interface { // update function. type Cmd func() Msg -type handlers []chan struct{} - type inputType int const ( @@ -102,6 +100,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 @@ -678,22 +699,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() -} From cd63c32c730b5b847a97c333b9b8d8e049d9e2e1 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 28 Jun 2023 11:40:08 -0300 Subject: [PATCH 15/44] feat(deps): update termenv (#768) --- examples/go.mod | 4 ++-- examples/go.sum | 6 ++++-- go.mod | 4 ++-- go.sum | 9 ++++----- tutorials/go.mod | 4 ++-- tutorials/go.sum | 11 ++++------- 6 files changed, 18 insertions(+), 20 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 5dad68268c..9de58ea074 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -13,7 +13,7 @@ require ( 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 ( @@ -38,7 +38,7 @@ require ( github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/net v0.7.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.7.0 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index 902315408e..7b504cd64c 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -58,8 +58,9 @@ github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIf 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.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= @@ -101,8 +102,9 @@ 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 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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 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/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= From c1b0b19d64bad50197c2888ad4a72d429206985a Mon Sep 17 00:00:00 2001 From: Roman Leonenkov Date: Fri, 30 Jun 2023 00:37:48 +0100 Subject: [PATCH 16/44] fix: index out of range in examples/credit-card-form when ccn is empty (#770) --- examples/credit-card-form/main.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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, " ", "") From ea7ceb7f38f047c54490d077904884a7fae2ac96 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 6 Jul 2023 13:42:53 -0300 Subject: [PATCH 17/44] build: group dependabot updates (#773) --- .github/dependabot.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9065bc6290..45a12a0563 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,10 @@ updates: commit-message: prefix: "feat" include: "scope" + groups: + gomod: + patterns: + - "*" - package-ecosystem: "gomod" directory: "/examples" schedule: @@ -16,8 +20,12 @@ updates: labels: - "dependencies" commit-message: - prefix: "chore" + prefix: "docs" include: "scope" + groups: + gomod: + patterns: + - "*" - package-ecosystem: "gomod" directory: "/tutorials" schedule: @@ -25,8 +33,12 @@ updates: labels: - "dependencies" commit-message: - prefix: "chore" + prefix: "docs" include: "scope" + groups: + gomod: + patterns: + - "*" - package-ecosystem: "github-actions" directory: "/" schedule: @@ -36,3 +48,7 @@ updates: commit-message: prefix: "chore" include: "scope" + groups: + github-actions: + patterns: + - "*" From 5bc25046903fef4b35d95e01b6541293260770bc Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 6 Jul 2023 16:20:36 -0400 Subject: [PATCH 18/44] chore(lint): wrap various TTY-related errors --- tty.go | 7 ++++--- tty_unix.go | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tty.go b/tty.go index 715d542356..b804cf0bcb 100644 --- a/tty.go +++ b/tty.go @@ -2,6 +2,7 @@ package tea import ( "errors" + "fmt" "io" "os" "time" @@ -21,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) } } @@ -48,7 +49,7 @@ func (p *Program) restoreTerminalState() error { if p.console != nil { err := p.console.Reset() if err != nil { - return err + return fmt.Errorf("error restoring terminal state: %w", err) } } @@ -60,7 +61,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{}) 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 } From b639c9bab238b4be6925ae4766e88945786e84d9 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 7 Jul 2023 19:17:24 +0000 Subject: [PATCH 19/44] Revert "build: group dependabot updates (#773)" This reverts commit ea7ceb7f38f047c54490d077904884a7fae2ac96. --- .github/dependabot.yml | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 45a12a0563..9065bc6290 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,10 +9,6 @@ updates: commit-message: prefix: "feat" include: "scope" - groups: - gomod: - patterns: - - "*" - package-ecosystem: "gomod" directory: "/examples" schedule: @@ -20,12 +16,8 @@ updates: labels: - "dependencies" commit-message: - prefix: "docs" + prefix: "chore" include: "scope" - groups: - gomod: - patterns: - - "*" - package-ecosystem: "gomod" directory: "/tutorials" schedule: @@ -33,12 +25,8 @@ updates: labels: - "dependencies" commit-message: - prefix: "docs" + prefix: "chore" include: "scope" - groups: - gomod: - patterns: - - "*" - package-ecosystem: "github-actions" directory: "/" schedule: @@ -48,7 +36,3 @@ updates: commit-message: prefix: "chore" include: "scope" - groups: - github-actions: - patterns: - - "*" From ffad6555d58677ea95eeebdc65cfaee77ccd922b Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 7 Jul 2023 11:34:57 -0400 Subject: [PATCH 20/44] chore(lint): add various nolint directives, where appropriate --- commands.go | 2 +- tty.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/commands.go b/commands.go index 7c30a1216d..3a0520d479 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 diff --git a/tty.go b/tty.go index b804cf0bcb..bd9717efbf 100644 --- a/tty.go +++ b/tty.go @@ -42,7 +42,7 @@ func (p *Program) restoreTerminalState() error { p.renderer.exitAltScreen() // give the terminal a moment to catch up - time.Sleep(time.Millisecond * 10) + time.Sleep(time.Millisecond * 10) //nolint:gomnd } } @@ -87,7 +87,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. From 522659d79813db8953b1b34f59c8d2013222660c Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 7 Jul 2023 11:35:32 -0400 Subject: [PATCH 21/44] chore(lint): wrap various errors --- key.go | 8 ++++++-- logging.go | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/key.go b/key.go index 74f85ec405..a49152c3db 100644 --- a/key.go +++ b/key.go @@ -547,7 +547,7 @@ func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { // Read and block. numBytes, err := input.Read(buf[:]) if err != nil { - return err + return fmt.Errorf("error reading input: %w", err) } b := buf[:numBytes] @@ -558,7 +558,11 @@ func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { select { case msgs <- msg: case <-ctx.Done(): - return ctx.Err() + err := ctx.Err() + if err != nil { + err = fmt.Errorf("found context error while reading input: %w", err) + } + return err } } } diff --git a/logging.go b/logging.go index 59258d4c7c..e60626994a 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, 0o644) //nolint:gomnd if err != nil { - return nil, err + return nil, fmt.Errorf("error opening file for logging: %w", err) } log.SetOutput(f) From c284acad9f18f5563906f98bd4c5d6d27e709599 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 7 Jul 2023 11:35:46 -0400 Subject: [PATCH 22/44] chore(lint): extract a magic number when parsing X10 mouse events --- key.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/key.go b/key.go index a49152c3db..b71222857b 100644 --- a/key.go +++ b/key.go @@ -572,8 +572,9 @@ var unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e func detectOneMsg(b []byte) (w int, msg Msg) { // Detect mouse events. - if len(b) >= 6 && b[0] == '\x1b' && b[1] == '[' && b[2] == 'M' { - return 6, MouseMsg(parseX10MouseEvent(b)) + const mouseEventLen = 6 + if len(b) >= mouseEventLen && b[0] == '\x1b' && b[1] == '[' && b[2] == 'M' { + return mouseEventLen, MouseMsg(parseX10MouseEvent(b)) } // Detect escape sequence and control characters other than NUL, From c4c83ba757f891a8fbe513790b0176f9f26fc85b Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 10 Jul 2023 08:52:36 -0400 Subject: [PATCH 23/44] chore: restrict logfile permissions to owner-only --- logging.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logging.go b/logging.go index e60626994a..a53118193c 100644 --- a/logging.go +++ b/logging.go @@ -33,7 +33,7 @@ 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) //nolint:gomnd + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:gomnd if err != nil { return nil, fmt.Errorf("error opening file for logging: %w", err) } From 91dd1200733714c4fb7bc7ffb24af2c35cc2f111 Mon Sep 17 00:00:00 2001 From: naglis <827324+naglis@users.noreply.github.com> Date: Mon, 24 Jul 2023 16:07:57 +0300 Subject: [PATCH 24/44] docs: fix `WithFPS` godoc --- options.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/options.go b/options.go index 249d3bffc9..d1c04034b7 100644 --- a/options.go +++ b/options.go @@ -201,7 +201,7 @@ func WithFilter(filter func(Model, Msg) Msg) ProgramOption { } } -// WithMaxFPS sets a custom maximum FPS at which the renderer should run. If +// 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 { From f6f65aef201aa1b56be7d8c4445e739719274c43 Mon Sep 17 00:00:00 2001 From: Gabriel Lopes Date: Mon, 21 Aug 2023 11:28:06 -0400 Subject: [PATCH 25/44] Add storydb to 'Bubble Tea in the Wild' section (#804) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 395b65c172..f55bb5105c 100644 --- a/README.md +++ b/README.md @@ -360,6 +360,7 @@ For some Bubble Tea programs in production, see: * [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 From 1ad127782c2af4c772565c2892d472300a395d43 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Mon, 21 Aug 2023 11:41:38 -0400 Subject: [PATCH 26/44] feat(textinput): example for autocompletion with charmbracelet repositories (#803) * feat: autocompletion with charmbracelet repositories * chore(deps): bump bubbles --- examples/autocomplete/main.go | 104 ++++++++++++++++++++++++++++++++++ examples/go.mod | 6 +- examples/go.sum | 8 +-- 3 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 examples/autocomplete/main.go diff --git a/examples/autocomplete/main.go b/examples/autocomplete/main.go new file mode 100644 index 0000000000..55795883f9 --- /dev/null +++ b/examples/autocomplete/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +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.Placeholder = "Bubbletea" + 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(), + "(esc to quit)", + ) +} diff --git a/examples/go.mod b/examples/go.mod index 9de58ea074..ab5d792b53 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,8 +3,8 @@ module examples go 1.17 require ( - github.com/charmbracelet/bubbles v0.16.1 - github.com/charmbracelet/bubbletea v0.24.1 + github.com/charmbracelet/bubbles v0.16.2-0.20230821152602-eda891258c02 + 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 @@ -33,7 +33,7 @@ require ( 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 diff --git a/examples/go.sum b/examples/go.sum index 7b504cd64c..725fddf586 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -11,8 +11,8 @@ 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.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= -github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbles v0.16.2-0.20230821152602-eda891258c02 h1:MruS04uPbUJHF0MQzwOlh9yco1pHwPDLI7qGMu25IME= +github.com/charmbracelet/bubbles v0.16.2-0.20230821152602-eda891258c02/go.mod h1:XUdibuVUiMfcfKTRla58bmY3TWsdjgF+Rp8pvimQLck= 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= @@ -68,8 +68,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= From 5506e9a22e2e8c572ca2c53b85b71ef4b43bd7d2 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 21 Aug 2023 13:27:53 -0400 Subject: [PATCH 27/44] chore: minor UX edits to autocomplete example (#807) --- examples/autocomplete/main.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/autocomplete/main.go b/examples/autocomplete/main.go index 55795883f9..2aba5d8733 100644 --- a/examples/autocomplete/main.go +++ b/examples/autocomplete/main.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) func main() { @@ -63,7 +64,9 @@ type model struct { func initialModel() model { ti := textinput.New() - ti.Placeholder = "Bubbletea" + ti.Prompt = "charmbracelet/" + ti.Placeholder = "repo..." + ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) ti.Focus() ti.CharLimit = 50 ti.Width = 20 @@ -99,6 +102,6 @@ func (m model) View() string { return fmt.Sprintf( "What’s your favorite Charm repository?\n\n%s\n\n%s\n", m.textInput.View(), - "(esc to quit)", + "(tab to complete, ctrl+n/ctrl+p to cycle through suggestions, esc to quit)", ) } From b5e2519feaddf29998c4eecdecc54f7fa4c008ec Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 22 Aug 2023 10:21:49 -0400 Subject: [PATCH 28/44] chore(docs): add walk to Bubble Tea in the Wild --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f55bb5105c..092ca4ff75 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,7 @@ For some Bubble Tea programs in production, see: * [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 From 12f405723d7b7d2845fe716a8607834996be2a85 Mon Sep 17 00:00:00 2001 From: Eng Zer Jun Date: Fri, 25 Aug 2023 00:17:44 +0800 Subject: [PATCH 29/44] refactor: remove redundant nil check in `flush` (#812) From the Go docs: "If the map is nil, the number of iterations is 0." [1] Therefore, an additional nil check for before the loop is unnecessary. [1]: https://go.dev/ref/spec#For_range Signed-off-by: Eng Zer Jun --- standard_renderer.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/standard_renderer.go b/standard_renderer.go index c6a1b61017..0f282d8bb9 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -208,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 From bf2ffaf84002e0c45f2d85cc5571902b21264025 Mon Sep 17 00:00:00 2001 From: Maas Lalani Date: Mon, 28 Aug 2023 10:07:18 -0400 Subject: [PATCH 30/44] chore: remove examples/mouse/README.md --- examples/mouse/README.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 examples/mouse/README.md 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 - - From d55cfec13eae603b3905214eb854539f1331f46c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Sep 2023 17:24:42 +0000 Subject: [PATCH 31/44] chore(deps): bump actions/checkout from 3 to 4 (#820) --- .github/workflows/build.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/examples.yml | 2 +- .github/workflows/lint-soft.yml | 2 +- .github/workflows/lint.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2511296056..a14384cdda 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: go-version: ${{ matrix.go-version }} - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Download Go modules run: go mod download diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 845096e44c..f6985a684c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -17,7 +17,7 @@ jobs: 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..22051704ab 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -20,7 +20,7 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-go@v4 with: go-version: '^1' diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml index 6230528921..5ce0d37f87 100644 --- a/.github/workflows/lint-soft.yml +++ b/.github/workflows/lint-soft.yml @@ -18,7 +18,7 @@ jobs: 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..16f38b8a66 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: with: go-version: ^1 - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: From afcf4983362a4f15323998da44d1091a17285560 Mon Sep 17 00:00:00 2001 From: David Dworken Date: Sat, 23 Sep 2023 16:52:47 -0700 Subject: [PATCH 32/44] chore(docs): Add hiSHtory to Bubble Tea in the Wild --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 092ca4ff75..0e94e7bc0c 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ For some Bubble Tea programs in production, see: * [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash * [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 From 99d7d4bd17b92a6748d97cf116d0e5b88e420758 Mon Sep 17 00:00:00 2001 From: William Poussier Date: Sun, 1 Oct 2023 22:52:51 +0200 Subject: [PATCH 33/44] docs(README): add wI2L/scrabbler to Bubble Tea in the Wild --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0e94e7bc0c..685c1d31f5 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,7 @@ For some Bubble Tea programs in production, see: * [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 From 365895d05d55aa24e49b63fcf03f7bd72674fc5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Oct 2023 12:32:22 +0000 Subject: [PATCH 34/44] chore(deps): bump github.com/charmbracelet/lipgloss in /examples (#842) --- examples/go.mod | 6 +++--- examples/go.sum | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index ab5d792b53..8ed61bc820 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -7,7 +7,7 @@ require ( 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 @@ -27,7 +27,7 @@ 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 @@ -38,7 +38,7 @@ require ( github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/net v0.7.0 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.7.0 // indirect + golang.org/x/sys v0.12.0 // indirect golang.org/x/term v0.6.0 // indirect golang.org/x/text v0.7.0 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index 725fddf586..4abf481683 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -17,8 +17,9 @@ github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM2 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.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= @@ -47,8 +48,9 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 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= @@ -103,8 +105,9 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc 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/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/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.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= From 6d07f4a4105da34c8f890f17503ee17f5d7f79e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Oct 2023 12:37:21 +0000 Subject: [PATCH 35/44] chore(deps): bump golang.org/x/net from 0.7.0 to 0.17.0 in /examples (#840) --- examples/go.mod | 8 ++++---- examples/go.sum | 21 ++++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 8ed61bc820..32d4d75c6d 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -36,11 +36,11 @@ require ( 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.12.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 4abf481683..b08dc82e83 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -83,13 +83,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= @@ -106,22 +110,29 @@ 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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +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= From 5536bca34e218a1e17a55e5ba36c22fab5784f44 Mon Sep 17 00:00:00 2001 From: Raphael 'kena' Poss Date: Wed, 18 Oct 2023 12:59:26 +0200 Subject: [PATCH 36/44] fix(key): support very long buffered input (#570) --- key.go | 36 ++++++++++++++++++++++++++++++++++-- key_test.go | 21 ++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/key.go b/key.go index b71222857b..fe438a627b 100644 --- a/key.go +++ b/key.go @@ -543,6 +543,8 @@ var spaceRunes = []rune{' '} func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { var buf [256]byte + var leftOverFromPrevIteration []byte +loop: for { // Read and block. numBytes, err := input.Read(buf[:]) @@ -550,11 +552,31 @@ func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { return fmt.Errorf("error reading input: %w", err) } b := buf[:numBytes] + if leftOverFromPrevIteration != nil { + b = append(leftOverFromPrevIteration, b...) + } + + // 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, 0; i < len(b); i += w { var msg Msg - w, msg = detectOneMsg(b[i:]) + 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(): @@ -565,12 +587,13 @@ func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { return err } } + leftOverFromPrevIteration = nil } } var unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) -func detectOneMsg(b []byte) (w int, msg Msg) { +func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) { // Detect mouse events. const mouseEventLen = 6 if len(b) >= mouseEventLen && b[0] == '\x1b' && b[1] == '[' && b[2] == 'M' { @@ -618,6 +641,15 @@ func detectOneMsg(b []byte) (w int, msg Msg) { 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 + } + // If we found at least one rune, we report the bunch of them as // a single KeyRunes or KeySpace event. if len(runes) > 0 { diff --git a/key_test.go b/key_test.go index d466b056e3..ae8643b903 100644 --- a/key_test.go +++ b/key_test.go @@ -200,7 +200,7 @@ func TestDetectOneMsg(t *testing.T) { for _, tc := range td { t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) { - width, msg := detectOneMsg(tc.seq) + 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)) } @@ -211,6 +211,25 @@ func TestDetectOneMsg(t *testing.T) { } } +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 From 5984e69f093cec035a8e6480d7b977f3fcf3541b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 7 Nov 2023 13:49:00 -0300 Subject: [PATCH 37/44] fix: race changing ignoreSignals (#791) * fix: race changing ignoreSignals * fix: atomic.Uint32 --- options.go | 3 ++- options_test.go | 3 ++- tea.go | 9 +++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/options.go b/options.go index d1c04034b7..b9e8104dbc 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) } } 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/tea.go b/tea.go index 34fb883e59..74e83e35d3 100644 --- a/tea.go +++ b/tea.go @@ -18,6 +18,7 @@ import ( "os/signal" "runtime/debug" "sync" + "sync/atomic" "syscall" "github.com/containerd/console" @@ -153,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. @@ -238,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 } @@ -632,7 +633,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() @@ -648,7 +649,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 From a6f07b8ba6439fa65612a350bc1878d9d8c0447a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Nov 2023 14:02:25 -0300 Subject: [PATCH 38/44] chore(deps): bump stefanzweifel/git-auto-commit-action from 4 to 5 (#834) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 4 to 5. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v4...v5) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/examples.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 22051704ab..af6638ae27 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -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 From 185724ca96fee206026f6f28613b773165226082 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Nov 2023 10:31:31 -0500 Subject: [PATCH 39/44] docs: add go-sweep (Minesweeper) to Bubble Tea in the Wild --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 685c1d31f5..012f7f26d1 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,7 @@ 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 From bc1c475eb0263aba13ef430f191677e153dc0320 Mon Sep 17 00:00:00 2001 From: Guy Edwards Date: Sat, 11 Nov 2023 19:52:36 +0000 Subject: [PATCH 40/44] docs: add nom rss reader to bubble-tea-in-the-wild --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 012f7f26d1..0834246cdc 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,7 @@ 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 From 2bcb0af2e217f986b59b01fe0b255b619a546fc4 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 4 Dec 2023 08:50:27 -0800 Subject: [PATCH 41/44] feat: add set-window-title command (#611) Set the terminal window title using termenv. Fixes: https://github.com/charmbracelet/bubbletea/issues/610 --- commands.go | 17 +++++++++++++++ examples/set-window-title/main.go | 35 +++++++++++++++++++++++++++++++ screen.go | 5 +++++ tea.go | 3 +++ tutorials/basics/main.go | 2 +- 5 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 examples/set-window-title/main.go diff --git a/commands.go b/commands.go index 3a0520d479..7b139b8858 100644 --- a/commands.go +++ b/commands.go @@ -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/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/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/tea.go b/tea.go index 74e83e35d3..ddd1144660 100644 --- a/tea.go +++ b/tea.go @@ -388,6 +388,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. 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) { From a154847611ccd2bcd85df4e9c14d06eb67f6d60b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 4 Dec 2023 08:50:59 -0800 Subject: [PATCH 42/44] feat: extended Coordinates mouse reporting & additional buttons support (#594) * feat(mouse): add extended mouse & shift key support Support SGR(1006) mouse mode Support parsing shift key press Support additional mouse buttons Report which button was released Report button motion * fix: key.go sgr len missing calculation (#841) * chore(test): add sgr mouse msg detect test --------- Co-authored-by: robinsamuel <96998379+robin-samuel@users.noreply.github.com> --- examples/mouse/main.go | 14 +- examples/simple/testdata/TestApp.golden | 2 +- key.go | 23 +- key_test.go | 33 +- mouse.go | 290 ++++++-- mouse_test.go | 841 ++++++++++++++++++++---- nil_renderer.go | 2 + options.go | 6 + renderer.go | 12 +- screen_test.go | 16 +- standard_renderer.go | 14 + tea.go | 25 +- tty.go | 3 +- 13 files changed, 1052 insertions(+), 229 deletions(-) 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/simple/testdata/TestApp.golden b/examples/simple/testdata/TestApp.golden index b0b6c3dc97..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 +[?25h[?1002l[?1003l[?1006l \ No newline at end of file diff --git a/key.go b/key.go index fe438a627b..f851490a0d 100644 --- a/key.go +++ b/key.go @@ -566,7 +566,7 @@ loop: canHaveMoreData := numBytes == len(buf) var i, w int - for i, w = 0, 0; i < len(b); i += w { + for i, w = 0, 07; i < len(b); i += w { var msg Msg w, msg = detectOneMsg(b[i:], canHaveMoreData) if w == 0 { @@ -591,13 +591,26 @@ loop: } } -var unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) +var ( + unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) + mouseSGRRegex = regexp.MustCompile(`(\d+);(\d+);(\d+)([Mm])`) +) func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) { // Detect mouse events. - const mouseEventLen = 6 - if len(b) >= mouseEventLen && b[0] == '\x1b' && b[1] == '[' && b[2] == 'M' { - return mouseEventLen, MouseMsg(parseX10MouseEvent(b)) + // 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)) + } + } } // Detect escape sequence and control characters other than NUL, diff --git a/key_test.go b/key_test.go index ae8643b903..0b1112a222 100644 --- a/key_test.go +++ b/key_test.go @@ -137,7 +137,12 @@ func TestDetectOneMsg(t *testing.T) { // Mouse event. seqTest{ []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, - MouseMsg{X: 32, Y: 16, Type: MouseWheelUp}, + 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{ @@ -316,27 +321,33 @@ func TestReadInput(t *testing.T) { []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, []Msg{ MouseMsg{ - X: 32, - Y: 16, - Type: MouseWheelUp, + X: 32, + Y: 16, + Type: MouseWheelUp, + Button: MouseButtonWheelUp, + Action: MouseActionPress, }, }, }, - {"left release", + {"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, + X: 32, + Y: 16, + Type: MouseLeft, + Button: MouseButtonLeft, + Action: MouseActionMotion, }), MouseMsg(MouseEvent{ - X: 64, - Y: 32, - Type: MouseRelease, + X: 64, + Y: 32, + Type: MouseRelease, + Button: MouseButtonNone, + Action: MouseActionRelease, }), }, }, diff --git a/mouse.go b/mouse.go index fc66691cc6..add8d02931 100644 --- a/mouse.go +++ b/mouse.go @@ -1,5 +1,7 @@ package tea +import "strconv" + // 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. @@ -13,11 +15,22 @@ func (m MouseMsg) String() 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,38 +41,170 @@ 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: // @@ -68,9 +213,22 @@ var mouseEventTypes = map[MouseEventType]string{ // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking func parseX10MouseEvent(buf []byte) MouseEvent { v := buf[3:6] + m := parseMouseButton(int(v[0]), false) + + // (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 - const byteOffset = 32 - e := v[0] - byteOffset + e := b + if !isSGR { + e -= x10MouseByteOffset + } const ( bitShift = 0b0000_0100 @@ -78,55 +236,73 @@ func parseX10MouseEvent(buf []byte) MouseEvent { bitCtrl = 0b0001_0000 bitMotion = 0b0010_0000 bitWheel = 0b0100_0000 + bitAdd = 0b1000_0000 // additional buttons 8-11 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 - } + if e&bitAdd != 0 { + m.Button = MouseButtonBackward + MouseButton(e&bitsMask) + } else if e&bitWheel != 0 { + m.Button = MouseButtonWheelUp + MouseButton(e&bitsMask) } 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 - } + 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 } } - if e&bitAlt != 0 { - m.Alt = true - } - if e&bitCtrl != 0 { - m.Ctrl = true + // Motion bit doesn't get reported for wheel events. + if e&bitMotion != 0 && !m.IsWheel() { + m.Action = MouseActionMotion } - // (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 + // Modifiers + m.Alt = e&bitAlt != 0 + m.Ctrl = e&bitCtrl != 0 + m.Shift = e&bitShift != 0 + + // 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 m } diff --git a/mouse_test.go b/mouse_test.go index a64a2e302d..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: "", }, @@ -127,20 +233,24 @@ func TestParseX10MouseEvent(t *testing.T) { // Position. { name: "zero position", - buf: encode(0b0010_0000, 0, 0), + buf: encode(0b0000_0000, 0, 0), expected: MouseEvent{ - X: 0, - Y: 0, - Type: MouseLeft, + 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. + buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1. expected: MouseEvent{ - X: 222, - Y: 222, - Type: MouseLeft, + X: 222, + Y: 222, + Type: MouseLeft, + Action: MouseActionPress, + Button: MouseButtonLeft, }, }, // Simple. @@ -148,173 +258,287 @@ func TestParseX10MouseEvent(t *testing.T) { name: "left", buf: encode(0b0000_0000, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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), + buf: encode(0b0000_1010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Alt: true, + X: 32, + Y: 16, + Alt: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, { name: "ctrl+right", - buf: encode(0b0011_0010, 32, 16), + buf: encode(0b0001_0010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Ctrl: true, + X: 32, + Y: 16, + Ctrl: true, + Type: MouseRight, + Action: MouseActionPress, + Button: MouseButtonRight, }, }, { - name: "ctrl+alt+right", - buf: encode(0b0011_1010, 32, 16), + name: "left in motion", + buf: encode(0b0010_0000, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Alt: true, - Ctrl: true, + X: 32, + Y: 16, + Alt: false, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, }, }, { - name: "alt+wheel down", - buf: encode(0b0100_1001, 32, 16), + name: "alt+right in motion", + buf: encode(0b0010_1010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, - Alt: true, + X: 32, + Y: 16, + Alt: true, + Type: MouseRight, + Action: MouseActionMotion, + Button: MouseButtonRight, }, }, { - name: "ctrl+wheel down", - buf: encode(0b0101_0001, 32, 16), + name: "ctrl+right in motion", + buf: encode(0b0011_0010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, - Ctrl: true, + X: 32, + Y: 16, + Ctrl: true, + Type: MouseRight, + Action: MouseActionMotion, + Button: MouseButtonRight, }, }, { - name: "ctrl+alt+wheel down", - buf: encode(0b0101_1001, 32, 16), + name: "ctrl+alt+right", + buf: encode(0b0001_1010, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, - Alt: true, - Ctrl: true, + 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), + name: "ctrl+wheel up", + buf: encode(0b0101_0000, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseUnknown, + X: 32, + Y: 16, + Ctrl: true, + Type: MouseWheelUp, + Action: MouseActionPress, + Button: MouseButtonWheelUp, }, }, { - name: "unknown with modifier", - buf: encode(0b0100_1010, 32, 16), + name: "alt+wheel down", + buf: encode(0b0100_1001, 32, 16), expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseUnknown, - Alt: true, + 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. @@ -322,9 +546,11 @@ func TestParseX10MouseEvent(t *testing.T) { name: "overflow position", buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1. expected: MouseEvent{ - X: -6, - Y: -33, - Type: MouseLeft, + X: -6, + Y: -33, + Type: MouseLeft, + Action: MouseActionMotion, + Button: MouseButtonLeft, }, }, } @@ -344,3 +570,370 @@ func TestParseX10MouseEvent(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 + 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: "backward in motion", + buf: encode(160, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseBackward, + Action: MouseActionMotion, + Button: MouseButtonBackward, + }, + }, + { + name: "forward", + buf: encode(129, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseForward, + Action: MouseActionPress, + Button: MouseButtonForward, + }, + }, + { + name: "forward in motion", + buf: encode(161, 32, 16, false), + expected: MouseEvent{ + X: 32, + Y: 16, + Type: MouseForward, + Action: MouseActionMotion, + Button: MouseButtonForward, + }, + }, + // Combinations. + { + 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, + }, + }, + } + + for i := range tt { + tc := tt[i] + + t.Run(tc.name, func(t *testing.T) { + 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 b9e8104dbc..71e944939a 100644 --- a/options.go +++ b/options.go @@ -108,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. @@ -127,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. // 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_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 0f282d8bb9..1573a1c278 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -396,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 ddd1144660..f18cb87cf0 100644 --- a/tea.go +++ b/tea.go @@ -301,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) { @@ -335,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() @@ -489,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. diff --git a/tty.go b/tty.go index bd9717efbf..01f084d438 100644 --- a/tty.go +++ b/tty.go @@ -35,8 +35,7 @@ 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() From e671b840f205aa9fbc2fba9903290a1205390760 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Dec 2023 08:59:23 -0300 Subject: [PATCH 43/44] chore(deps): bump actions/setup-go from 4 to 5 (#881) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 5. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-go dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/examples.yml | 2 +- .github/workflows/lint-soft.yml | 2 +- .github/workflows/lint.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a14384cdda..edfae9866a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: GO111MODULE: "on" steps: - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f6985a684c..067d42c7b5 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -12,7 +12,7 @@ jobs: GO111MODULE: "on" steps: - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index af6638ae27..c1d9c31440 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: '^1' cache: true diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml index 5ce0d37f87..4f3fbc0cbc 100644 --- a/.github/workflows/lint-soft.yml +++ b/.github/workflows/lint-soft.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: ^1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 16f38b8a66..10df8cc9c3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Install Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: ^1 From b6695477b4a0b9191efb7c7aa7d64bb0292f5d9e Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Wed, 13 Dec 2023 11:58:15 -0500 Subject: [PATCH 44/44] chore(examples): bump the bubbles dep in the examples to v0.17.0 Closes #882 --- examples/go.mod | 2 +- examples/go.sum | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index 32d4d75c6d..48f1c3012e 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,7 +3,7 @@ module examples go 1.17 require ( - github.com/charmbracelet/bubbles v0.16.2-0.20230821152602-eda891258c02 + 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 diff --git a/examples/go.sum b/examples/go.sum index b08dc82e83..c1562876cc 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -11,13 +11,12 @@ 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.16.2-0.20230821152602-eda891258c02 h1:MruS04uPbUJHF0MQzwOlh9yco1pHwPDLI7qGMu25IME= -github.com/charmbracelet/bubbles v0.16.2-0.20230821152602-eda891258c02/go.mod h1:XUdibuVUiMfcfKTRla58bmY3TWsdjgF+Rp8pvimQLck= +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.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=