diff --git a/environ.go b/environ.go index fe444a126f..cb75a966c2 100644 --- a/environ.go +++ b/environ.go @@ -13,8 +13,8 @@ type environ []string // returned will be the empty string. // This function traverses the environment variables in reverse order, so that // the last value set for the key is the one returned. -func (p *Program) getenv(key string) (v string) { - return p.environ.Getenv(key) +func (p *Program[T]) getenv(key string) (v string) { + return environ(p.Env).Getenv(key) } // Getenv returns the value of the environment variable named by the key. If diff --git a/examples/altscreen-toggle/main.go b/examples/altscreen-toggle/main.go index 041e57f440..bb60e0610e 100644 --- a/examples/altscreen-toggle/main.go +++ b/examples/altscreen-toggle/main.go @@ -19,11 +19,11 @@ type model struct { suspending bool } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.ResumeMsg: m.suspending = false @@ -79,7 +79,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(model{}).Run(); err != nil { + if err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/autocomplete/main.go b/examples/autocomplete/main.go index 161efa7801..bd6fc9356f 100644 --- a/examples/autocomplete/main.go +++ b/examples/autocomplete/main.go @@ -16,7 +16,7 @@ import ( func main() { p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } @@ -101,11 +101,11 @@ func initialModel() model { return model{textInput: ti, help: h, keymap: km} } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tea.Batch(getRepos, textinput.Blink) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { diff --git a/examples/capability/main.go b/examples/capability/main.go index 56476293e6..321f78a669 100644 --- a/examples/capability/main.go +++ b/examples/capability/main.go @@ -14,17 +14,15 @@ type model struct { width int } -var _ tea.Model = model{} - // Init implements tea.Model. -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { m.input = textinput.New() m.input.Placeholder = "Enter capability name to request" return m, m.input.Focus() } // Update implements tea.Model. -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -59,7 +57,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(model{}).Run(); err != nil { + if err := tea.NewProgram(model{}).Run(); err != nil { log.Fatal(err) } } diff --git a/examples/cellbuffer/main.go b/examples/cellbuffer/main.go index d361f00ccb..5c2514950a 100644 --- a/examples/cellbuffer/main.go +++ b/examples/cellbuffer/main.go @@ -142,11 +142,15 @@ type model struct { xVelocity, yVelocity float64 } -func (m model) Init() (tea.Model, tea.Cmd) { - return m, animate() +func (m model) Init() (model, tea.Cmd) { + return m, tea.Batch( + animate(), + tea.EnableMouseCellMotion, + tea.EnterAltScreen, + ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit @@ -193,8 +197,8 @@ func main() { spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping), } - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) - if _, err := p.Run(); err != nil { + p := tea.NewProgram(m) + if err := p.Run(); err != nil { fmt.Println("Uh oh:", err) os.Exit(1) } diff --git a/examples/chat/main.go b/examples/chat/main.go index 6377c46421..83c432a448 100644 --- a/examples/chat/main.go +++ b/examples/chat/main.go @@ -19,7 +19,7 @@ const gap = "\n\n" func main() { p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Oof: %v\n", err) } } @@ -63,11 +63,11 @@ Type a message and press Enter to send.`) } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, textarea.Blink } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.viewport.SetWidth(msg.Width) diff --git a/examples/colorprofile/main.go b/examples/colorprofile/main.go index 68a6ac507d..08af1e2989 100644 --- a/examples/colorprofile/main.go +++ b/examples/colorprofile/main.go @@ -15,10 +15,8 @@ var myFancyColor color.Color type model struct{} -var _ tea.Model = model{} - // Init implements tea.Model. -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tea.Batch( tea.RequestCapability("RGB"), tea.RequestCapability("Tc"), @@ -26,7 +24,7 @@ func (m model) Init() (tea.Model, tea.Cmd) { } // Update implements tea.Model. -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m, tea.Quit @@ -47,8 +45,9 @@ func (m model) View() fmt.Stringer { func main() { myFancyColor, _ = colorful.Hex("#6b50ff") - p := tea.NewProgram(model{}, tea.WithColorProfile(colorprofile.TrueColor)) - if _, err := p.Run(); err != nil { + p := tea.NewProgram(model{}) + p.Profile = colorprofile.TrueColor + if err := p.Run(); err != nil { log.Fatal(err) } } diff --git a/examples/composable-views/main.go b/examples/composable-views/main.go index 443744816e..6b82bd7cc8 100644 --- a/examples/composable-views/main.go +++ b/examples/composable-views/main.go @@ -73,14 +73,14 @@ func newModel(timeout time.Duration) mainModel { return m } -func (m mainModel) Init() (tea.Model, tea.Cmd) { +func (m mainModel) Init() (mainModel, tea.Cmd) { // start the timer and spinner on program start timer, cmd := m.timer.Init() m.timer = timer return m, tea.Batch(cmd, m.spinner.Tick) } -func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m mainModel) Update(msg tea.Msg) (mainModel, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { @@ -160,7 +160,7 @@ func (m *mainModel) resetSpinner() { func main() { p := tea.NewProgram(newModel(defaultTime)) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } diff --git a/examples/credit-card-form/main.go b/examples/credit-card-form/main.go index 5d75955a3a..fc77080b01 100644 --- a/examples/credit-card-form/main.go +++ b/examples/credit-card-form/main.go @@ -14,7 +14,7 @@ import ( func main() { p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } @@ -124,11 +124,11 @@ func initialModel() model { } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, textinput.Blink } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmds []tea.Cmd = make([]tea.Cmd, len(m.inputs)) switch msg := msg.(type) { diff --git a/examples/cursor-style/main.go b/examples/cursor-style/main.go index e1992702f7..f4e08cf0ac 100644 --- a/examples/cursor-style/main.go +++ b/examples/cursor-style/main.go @@ -8,35 +8,35 @@ import ( ) type model struct { - style tea.CursorStyle + shape tea.CursorShape blink bool } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { m.blink = true return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+q", "q": return m, tea.Quit case "h", "left": - if m.style == tea.CursorBlock && m.blink { + if m.shape == tea.CursorBlock && m.blink { break } if m.blink { - m.style-- + m.shape-- } m.blink = !m.blink case "l", "right": - if m.style == tea.CursorBar && !m.blink { + if m.shape == tea.CursorBar && !m.blink { break } if !m.blink { - m.style++ + m.shape++ } m.blink = !m.blink } @@ -49,7 +49,7 @@ func (m model) View() fmt.Stringer { "\n\n" + " <- This is the cursor (a " + m.describeCursor() + ")") f.Cursor = tea.NewCursor(0, 2) - f.Cursor.Style = m.style + f.Cursor.Shape = m.shape f.Cursor.Blink = m.blink return f } @@ -63,7 +63,7 @@ func (m model) describeCursor() string { adj = "steady" } - switch m.style { + switch m.shape { case tea.CursorBlock: noun = "block" case tea.CursorUnderline: @@ -77,7 +77,7 @@ func (m model) describeCursor() string { func main() { p := tea.NewProgram(model{}) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v", err) os.Exit(1) } diff --git a/examples/debounce/main.go b/examples/debounce/main.go index 2e1187be1c..bf2bb592ec 100644 --- a/examples/debounce/main.go +++ b/examples/debounce/main.go @@ -26,11 +26,11 @@ type model struct { tag int } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: // Increment the tag on the model... @@ -59,7 +59,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(model{}).Run(); err != nil { + if err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("uh oh:", err) os.Exit(1) } diff --git a/examples/doom-fire/main.go b/examples/doom-fire/main.go index 7ec28ee3f5..89ed2af766 100644 --- a/examples/doom-fire/main.go +++ b/examples/doom-fire/main.go @@ -23,11 +23,14 @@ type model struct { startTime time.Time } -func (m model) Init() (tea.Model, tea.Cmd) { - return m, tick +func (m model) Init() (model, tea.Cmd) { + return m, tea.Batch( + tick, + tea.EnterAltScreen, + ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if msg.String() == "q" || msg.String() == "ctrl+c" { @@ -128,8 +131,8 @@ func initialModel() model { } func main() { - p := tea.NewProgram(initialModel(), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { + p := tea.NewProgram(initialModel()) + if err := p.Run(); err != nil { fmt.Printf("Error running program: %v", err) } } diff --git a/examples/exec/main.go b/examples/exec/main.go index 026f4a880e..e133ab44ce 100644 --- a/examples/exec/main.go +++ b/examples/exec/main.go @@ -26,11 +26,11 @@ type model struct { err error } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -64,7 +64,7 @@ func (m model) View() fmt.Stringer { func main() { m := model{} - if _, err := tea.NewProgram(m).Run(); err != nil { + if err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/file-picker/main.go b/examples/file-picker/main.go index a1421a99a7..87d18edf2e 100644 --- a/examples/file-picker/main.go +++ b/examples/file-picker/main.go @@ -26,13 +26,16 @@ func clearErrorAfter(t time.Duration) tea.Cmd { }) } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { fp, cmd := m.filepicker.Init() m.filepicker = fp - return m, cmd + return m, tea.Batch( + tea.EnterAltScreen, + cmd, + ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -90,7 +93,10 @@ func main() { m := model{ filepicker: fp, } - tm, _ := tea.NewProgram(&m, tea.WithAltScreen()).Run() - mm := tm.(model) - fmt.Println("\n You selected: " + m.filepicker.Styles.Selected.Render(mm.selectedFile) + "\n") + tm := tea.NewProgram(&m) + if err := tm.Run(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } + fmt.Println("\n You selected: " + m.filepicker.Styles.Selected.Render(tm.Model.selectedFile) + "\n") } diff --git a/examples/focus-blur/main.go b/examples/focus-blur/main.go index 0690def1dd..28a1b98e09 100644 --- a/examples/focus-blur/main.go +++ b/examples/focus-blur/main.go @@ -14,8 +14,8 @@ func main() { // assume we start focused... focused: true, reporting: true, - }, tea.WithReportFocus()) - if _, err := p.Run(); err != nil { + }) + if err := p.Run(); err != nil { log.Fatal(err) } } @@ -25,11 +25,11 @@ type model struct { reporting bool } -func (m model) Init() (tea.Model, tea.Cmd) { - return m, nil +func (m model) Init() (model, tea.Cmd) { + return m, tea.EnabledReportFocus } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.FocusMsg: m.focused = true diff --git a/examples/fullscreen/main.go b/examples/fullscreen/main.go index 4dff5fe23d..57bffc3646 100644 --- a/examples/fullscreen/main.go +++ b/examples/fullscreen/main.go @@ -16,17 +16,20 @@ type model int type tickMsg time.Time func main() { - p := tea.NewProgram(model(5), tea.WithAltScreen()) - if _, err := p.Run(); err != nil { + p := tea.NewProgram(model(5)) + if err := p.Run(); err != nil { log.Fatal(err) } } -func (m model) Init() (tea.Model, tea.Cmd) { - return m, tick() +func (m model) Init() (model, tea.Cmd) { + return m, tea.Batch( + tea.EnterAltScreen, + tick(), + ) } -func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(message tea.Msg) (model, tea.Cmd) { switch msg := message.(type) { case tea.KeyPressMsg: switch msg.String() { diff --git a/examples/glamour/main.go b/examples/glamour/main.go index 1873964a25..81c1603112 100644 --- a/examples/glamour/main.go +++ b/examples/glamour/main.go @@ -101,11 +101,11 @@ func newExample() (*example, error) { }, nil } -func (e example) Init() (tea.Model, tea.Cmd) { +func (e example) Init() (example, tea.Cmd) { return e, nil } -func (e example) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (e example) Update(msg tea.Msg) (example, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -136,7 +136,7 @@ func main() { os.Exit(1) } - if _, err := tea.NewProgram(model).Run(); err != nil { + if err := tea.NewProgram(model).Run(); err != nil { fmt.Println("Bummer, there's been an error:", err) os.Exit(1) } diff --git a/examples/go.mod b/examples/go.mod index 9478e2692d..9157dfb264 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -3,9 +3,9 @@ module examples go 1.23.1 require ( - github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250114183437-fbe642df174c - github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0 - github.com/charmbracelet/colorprofile v0.1.10 + github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250123213707-518ff7d0d016 + github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1 + github.com/charmbracelet/colorprofile v0.2.0 github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250127174346-dc93e7a83a4b @@ -23,7 +23,7 @@ require ( github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/lipgloss v1.0.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.8 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f // indirect github.com/charmbracelet/x/input v0.3.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect diff --git a/examples/go.sum b/examples/go.sum index dc48c41759..41233ea365 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -14,10 +14,10 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.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/v2 v2.0.0-alpha.2.0.20250114183437-fbe642df174c h1:hhR5M/3Wt/mKLTPF/MyvA4/WWtnTmIzLXo69pW/9S5s= -github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250114183437-fbe642df174c/go.mod h1:M271uOSMoLQsiVV1yhFZx6JprPQCVXgLYpSEbWXtidM= -github.com/charmbracelet/colorprofile v0.1.10 h1:k6jIGJg4bPWvHZqcoLjFxH1bm9uT28Ysxg8guonDJ1Y= -github.com/charmbracelet/colorprofile v0.1.10/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk= +github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250123213707-518ff7d0d016 h1:qLul2uRoupbiXXjixWPE3yk5tMKEhWEmTHOGZpewFqs= +github.com/charmbracelet/bubbles/v2 v2.0.0-alpha.2.0.20250123213707-518ff7d0d016/go.mod h1:DBfWo/ohdtbvjyDk/Q4UffdRd/rRKfYTwTqHdPFVHT8= +github.com/charmbracelet/colorprofile v0.2.0 h1:iiIQlp3LSvoJPtR11KoDfIf9wqWm2mn/iU420rHOZ/A= +github.com/charmbracelet/colorprofile v0.2.0/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk= github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= @@ -28,8 +28,8 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250127174346-dc93e7a83a4 github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250127174346-dc93e7a83a4b/go.mod h1:wNdvvvSFeykFQxJ9rXcTA+IbZE9wgMEqhM4c8gV/faE= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.7 h1:u+ArcmMqOuY+f0rFGHzTJxALno2kJAPxK8u2PVo5YIQ= -github.com/charmbracelet/x/cellbuf v0.0.7/go.mod h1:WU1sKZkKCLaBjrRneV4AGFYygeFiGk5rFAKxqRyJuPE= +github.com/charmbracelet/x/cellbuf v0.0.8 h1:seFe/rierwnDBVmGWWnQj3vHqzQkGYzuJYfKEY48TqM= +github.com/charmbracelet/x/cellbuf v0.0.8/go.mod h1:dKfNBxLovpvzzxAP6/GZfs5eb7vNxHlUDnwGhRmvIdY= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20241016014612-3b4d04043233 h1:2bTR/MtnJuq9RrCZSPwCOO34YSDByKL6nzXQMnsKK6U= diff --git a/examples/help/main.go b/examples/help/main.go index 4e3a17795c..e99cde2a87 100644 --- a/examples/help/main.go +++ b/examples/help/main.go @@ -80,11 +80,11 @@ func newModel() model { } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: // If we set a width on the help menu it can gracefully truncate @@ -140,7 +140,7 @@ func main() { defer f.Close() // nolint:errcheck } - if _, err := tea.NewProgram(newModel()).Run(); err != nil { + if err := tea.NewProgram(newModel()).Run(); err != nil { fmt.Printf("Could not start program :(\n%v\n", err) os.Exit(1) } diff --git a/examples/http/main.go b/examples/http/main.go index b5a2137bad..6ebc627224 100644 --- a/examples/http/main.go +++ b/examples/http/main.go @@ -26,16 +26,16 @@ func (e errMsg) Error() string { return e.error.Error() } func main() { p := tea.NewProgram(model{}) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, checkServer } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { diff --git a/examples/list-default/main.go b/examples/list-default/main.go index 487c0bed19..99c356d4e0 100644 --- a/examples/list-default/main.go +++ b/examples/list-default/main.go @@ -23,11 +23,11 @@ type model struct { list list.Model } -func (m model) Init() (tea.Model, tea.Cmd) { - return m, nil +func (m model) Init() (model, tea.Cmd) { + return m, tea.EnterAltScreen } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if msg.String() == "ctrl+c" { @@ -77,9 +77,9 @@ func main() { m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)} m.list.Title = "My Fave Things" - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tea.NewProgram(m) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/list-fancy/main.go b/examples/list-fancy/main.go index 212969d184..e064853fda 100644 --- a/examples/list-fancy/main.go +++ b/examples/list-fancy/main.go @@ -105,7 +105,7 @@ type model struct { delegateKeys *delegateKeyMap } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { m.once = new(sync.Once) return m, tea.Batch( tea.RequestBackgroundColor, @@ -163,7 +163,7 @@ func (m *model) updateListProperties() { m.list.SetSize(m.width-h, m.height-v) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -244,7 +244,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(model{}).Run(); err != nil { + if err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/list-simple/main.go b/examples/list-simple/main.go index e7da9ab43c..c3d7701f2e 100644 --- a/examples/list-simple/main.go +++ b/examples/list-simple/main.go @@ -70,7 +70,7 @@ type model struct { quitting bool } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { items := []list.Item{ item("Ramen"), item("Tomato Soup"), @@ -95,7 +95,7 @@ func (m model) Init() (tea.Model, tea.Cmd) { return m, tea.RequestBackgroundColor } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.BackgroundColorMsg: // Wait until we know the background color to initialize our styles. @@ -147,7 +147,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(model{}).Run(); err != nil { + if err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/mouse/main.go b/examples/mouse/main.go index 857fb3b41d..9588afefc3 100644 --- a/examples/mouse/main.go +++ b/examples/mouse/main.go @@ -11,19 +11,19 @@ import ( ) func main() { - p := tea.NewProgram(model{}, tea.WithMouseAllMotion()) - if _, err := p.Run(); err != nil { + p := tea.NewProgram(model{}) + if err := p.Run(); err != nil { log.Fatal(err) } } type model struct{} -func (m model) Init() (tea.Model, tea.Cmd) { - return m, nil +func (m model) Init() (model, tea.Cmd) { + return m, tea.EnableMouseAllMotion } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if s := msg.String(); s == "ctrl+c" || s == "q" || s == "esc" { diff --git a/examples/package-manager/main.go b/examples/package-manager/main.go index 7f39b1b5a2..eeb1857db7 100644 --- a/examples/package-manager/main.go +++ b/examples/package-manager/main.go @@ -44,11 +44,11 @@ func newModel() model { } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tea.Batch(downloadAndInstall(m.packages[m.index]), m.spinner.Tick) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height @@ -133,7 +133,7 @@ func max(a, b int) int { } func main() { - if _, err := tea.NewProgram(newModel()).Run(); err != nil { + if err := tea.NewProgram(newModel()).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/pager/main.go b/examples/pager/main.go index 11114f285c..e0146fc144 100644 --- a/examples/pager/main.go +++ b/examples/pager/main.go @@ -33,11 +33,14 @@ type model struct { viewport viewport.Model } -func (m model) Init() (tea.Model, tea.Cmd) { - return m, nil +func (m model) Init() (model, tea.Cmd) { + return m, tea.Batch( + tea.EnterAltScreen, + tea.EnableMouseCellMotion, + ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd @@ -112,11 +115,9 @@ func main() { p := tea.NewProgram( model{content: string(content)}, - tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" - tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel ) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { fmt.Println("could not run program:", err) os.Exit(1) } diff --git a/examples/paginator/main.go b/examples/paginator/main.go index e466e566e2..4da1721c8d 100644 --- a/examples/paginator/main.go +++ b/examples/paginator/main.go @@ -33,7 +33,7 @@ type model struct { ready bool } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { var items []string for i := 1; i < 101; i++ { text := fmt.Sprintf("Item %d", i) @@ -51,7 +51,7 @@ func (m model) Init() (tea.Model, tea.Cmd) { return m, tea.RequestBackgroundColor } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.BackgroundColorMsg: @@ -88,7 +88,7 @@ func (m model) View() fmt.Stringer { func main() { p := tea.NewProgram(model{}) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } diff --git a/examples/pipe/main.go b/examples/pipe/main.go index 6951a6f042..85507d030f 100644 --- a/examples/pipe/main.go +++ b/examples/pipe/main.go @@ -45,7 +45,7 @@ func main() { model := newModel(strings.TrimSpace(b.String())) - if _, err := tea.NewProgram(model).Run(); err != nil { + if err := tea.NewProgram(model).Run(); err != nil { fmt.Println("Couldn't start program:", err) os.Exit(1) } @@ -68,11 +68,11 @@ func newModel(initialValue string) (m model) { return } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, textinput.Blink } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "ctrl+c", "esc", "enter": diff --git a/examples/prevent-quit/main.go b/examples/prevent-quit/main.go index 91674acf92..1cf2a77642 100644 --- a/examples/prevent-quit/main.go +++ b/examples/prevent-quit/main.go @@ -20,19 +20,19 @@ var ( ) func main() { - p := tea.NewProgram(initialModel(), tea.WithFilter(filter)) + p := tea.NewProgram(initialModel()) + p.Filter = filter - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } -func filter(teaModel tea.Model, msg tea.Msg) tea.Msg { +func filter(m model, msg tea.Msg) tea.Msg { if _, ok := msg.(tea.QuitMsg); !ok { return msg } - m := teaModel.(model) if m.hasChanges { return nil } @@ -75,11 +75,11 @@ func initialModel() model { } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, textarea.Blink } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { if m.quitting { return m.updatePromptView(msg) } @@ -87,7 +87,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateTextView(msg) } -func (m model) updateTextView(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) updateTextView(msg tea.Msg) (model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd @@ -117,7 +117,7 @@ func (m model) updateTextView(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m model) updatePromptView(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) updatePromptView(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: // For simplicity's sake, we'll treat any key besides "y" as "no" diff --git a/examples/print-key/main.go b/examples/print-key/main.go index ab726bee38..94f1e75c44 100644 --- a/examples/print-key/main.go +++ b/examples/print-key/main.go @@ -9,11 +9,14 @@ import ( type model struct{} -func (m model) Init() (tea.Model, tea.Cmd) { - return m, nil +func (m model) Init() (model, tea.Cmd) { + return m, tea.RequestKeyboardEnhancements( + tea.WithKeyReleases, + tea.WithUniformKeyLayout, + ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyboardEnhancementsMsg: return m, tea.Printf("Keyboard enhancements: Disambiguation: %v, ReleaseKeys: %v, Uniform keys: %v\n", @@ -45,8 +48,8 @@ func (m model) View() fmt.Stringer { } func main() { - p := tea.NewProgram(model{}, tea.WithKeyboardEnhancements(tea.WithKeyReleases, tea.WithUniformKeyLayout)) - if _, err := p.Run(); err != nil { + p := tea.NewProgram(model{}) + if err := p.Run(); err != nil { log.Printf("Error running program: %v", err) } } diff --git a/examples/progress-animated/main.go b/examples/progress-animated/main.go index efdc52c9ff..049db890b8 100644 --- a/examples/progress-animated/main.go +++ b/examples/progress-animated/main.go @@ -30,7 +30,7 @@ func main() { progress: progress.New(progress.WithDefaultGradient()), } - if _, err := tea.NewProgram(m).Run(); err != nil { + if err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Oh no!", err) os.Exit(1) } @@ -42,11 +42,11 @@ type model struct { progress progress.Model } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tickCmd() } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit diff --git a/examples/progress-download/main.go b/examples/progress-download/main.go index c61897eb0b..3831228981 100644 --- a/examples/progress-download/main.go +++ b/examples/progress-download/main.go @@ -13,7 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" ) -var p *tea.Program +var p *tea.Program[model] type progressWriter struct { total int @@ -100,7 +100,7 @@ func main() { // Start the download go pw.Start() - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { fmt.Println("error running program:", err) os.Exit(1) } diff --git a/examples/progress-download/tui.go b/examples/progress-download/tui.go index 6e6139f72c..fe0f9a87b6 100644 --- a/examples/progress-download/tui.go +++ b/examples/progress-download/tui.go @@ -33,11 +33,11 @@ type model struct { err error } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit diff --git a/examples/progress-static/main.go b/examples/progress-static/main.go index 58d78bf39c..ce9d16800e 100644 --- a/examples/progress-static/main.go +++ b/examples/progress-static/main.go @@ -37,7 +37,7 @@ var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render func main() { prog := progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C")) - if _, err := tea.NewProgram(model{progress: prog}).Run(); err != nil { + if err := tea.NewProgram(model{progress: prog}).Run(); err != nil { fmt.Println("Oh no!", err) os.Exit(1) } @@ -50,11 +50,11 @@ type model struct { progress progress.Model } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tickCmd() } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit diff --git a/examples/query-term/main.go b/examples/query-term/main.go index e377f31886..340688d528 100644 --- a/examples/query-term/main.go +++ b/examples/query-term/main.go @@ -28,11 +28,11 @@ type model struct { err error } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, textinput.Blink } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -91,7 +91,7 @@ func (m model) View() fmt.Stringer { func main() { p := tea.NewProgram(newModel()) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } diff --git a/examples/realtime/main.go b/examples/realtime/main.go index 98a601701e..cc035c0afc 100644 --- a/examples/realtime/main.go +++ b/examples/realtime/main.go @@ -44,7 +44,7 @@ type model struct { quitting bool } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tea.Batch( m.spinner.Tick, listenForActivity(m.sub), // generate activity @@ -52,7 +52,7 @@ func (m model) Init() (tea.Model, tea.Cmd) { ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg.(type) { case tea.KeyPressMsg: m.quitting = true @@ -83,7 +83,7 @@ func main() { spinner: spinner.New(), }) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { fmt.Println("could not start program:", err) os.Exit(1) } diff --git a/examples/result/main.go b/examples/result/main.go index 8efc963e8d..bcc55e9893 100644 --- a/examples/result/main.go +++ b/examples/result/main.go @@ -18,11 +18,11 @@ type model struct { choice string } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -73,14 +73,14 @@ func main() { p := tea.NewProgram(model{}) // Run returns the model as a tea.Model. - m, err := p.Run() + err := p.Run() if err != nil { fmt.Println("Oh no:", err) os.Exit(1) } // Assert the final tea.Model to our local model and print the choice. - if m, ok := m.(model); ok && m.choice != "" { + if m := p.Model; m.choice != "" { fmt.Printf("\n---\nYou chose %s!\n", m.choice) } } diff --git a/examples/send-msg/main.go b/examples/send-msg/main.go index 37af6e64ef..f7c8c15012 100644 --- a/examples/send-msg/main.go +++ b/examples/send-msg/main.go @@ -52,11 +52,11 @@ func newModel() model { } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, m.spinner.Tick } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: m.quitting = true @@ -115,7 +115,7 @@ func main() { } }() - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/sequence/main.go b/examples/sequence/main.go index 0dc6392899..0791797f28 100644 --- a/examples/sequence/main.go +++ b/examples/sequence/main.go @@ -11,7 +11,7 @@ import ( type model struct{} -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { // A tea.Sequence is a command that runs a series of commands in // order. Contrast this with tea.Batch, which runs a series of commands // concurrently, with no order guarantees. @@ -28,7 +28,7 @@ func (m model) Init() (tea.Model, tea.Cmd) { ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg.(type) { case tea.KeyPressMsg: return m, tea.Quit @@ -41,7 +41,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(model{}).Run(); err != nil { + if err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Uh oh:", err) os.Exit(1) } diff --git a/examples/set-terminal-color/main.go b/examples/set-terminal-color/main.go index 8293ce380a..9e44433390 100644 --- a/examples/set-terminal-color/main.go +++ b/examples/set-terminal-color/main.go @@ -47,11 +47,11 @@ type model struct { err error } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, textinput.Blink } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -178,7 +178,7 @@ func main() { ti: ti, }) - _, err := p.Run() + err := p.Run() if err != nil { log.Fatalf("Error running program: %v", err) } diff --git a/examples/set-window-title/main.go b/examples/set-window-title/main.go index b8655af919..7cb7d1d75f 100644 --- a/examples/set-window-title/main.go +++ b/examples/set-window-title/main.go @@ -14,11 +14,11 @@ const windowTitle = "Hello, Bubble Tea" type model struct{} -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tea.SetWindowTitle(windowTitle) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg.(type) { case tea.KeyPressMsg: return m, tea.Quit @@ -33,7 +33,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(model{}).Run(); err != nil { + if err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Uh oh:", err) os.Exit(1) } diff --git a/examples/simple/main.go b/examples/simple/main.go index 14c91783c6..86c8ac46fc 100644 --- a/examples/simple/main.go +++ b/examples/simple/main.go @@ -21,9 +21,46 @@ func main() { } } - // Initialize our program - p := tea.NewProgram(model(5)) - if _, err := p.Run(); err != nil { + // Declare our program. + p := tea.Program[model]{ + // Init optionally returns an initial command we should run. In this + // case we want to start the timer. + Init: func() (model, tea.Cmd) { + return model(5), tea.Batch(tick) + }, + + // Update is called when messages are received. The idea is that you + // inspect the message and send back an updated model accordingly. You + // can also return a command, which is a function that performs I/O and + // returns a message. + Update: func(m model, msg tea.Msg) (model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "ctrl+z": + return m, tea.Suspend + } + + case tickMsg: + m-- + if m <= 0 { + return m, tea.Quit + } + return m, tick + } + return m, nil + }, + + // View returns a string based on data in the model. That string which + // will be rendered to the terminal. + View: func(m model) fmt.Stringer { + return tea.NewFrame(fmt.Sprintf("Hi. This program will exit in %d seconds.\n\nTo quit sooner press ctrl-c, or press ctrl-z to suspend...\n", m)) + }, + } + + if err := p.Run(); err != nil { log.Fatal(err) } } @@ -33,41 +70,6 @@ func main() { // we'll need is a simple integer. type model int -// Init optionally returns an initial command we should run. In this case we -// want to start the timer. -func (m model) Init() (tea.Model, tea.Cmd) { - return m, tick -} - -// Update is called when messages are received. The idea is that you inspect the -// message and send back an updated model accordingly. You can also return -// a command, which is a function that performs I/O and returns a message. -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch msg.String() { - case "ctrl+c", "q": - return m, tea.Quit - case "ctrl+z": - return m, tea.Suspend - } - - case tickMsg: - m-- - if m <= 0 { - return m, tea.Quit - } - return m, tick - } - return m, nil -} - -// View returns a string based on data in the model. That string which will be -// rendered to the terminal. -func (m model) View() fmt.Stringer { - return tea.NewFrame(fmt.Sprintf("Hi. This program will exit in %d seconds.\n\nTo quit sooner press ctrl-c, or press ctrl-z to suspend...\n", m)) -} - // Messages are events that we respond to in our Update function. This // particular one indicates that the timer has ticked. type tickMsg time.Time diff --git a/examples/space/main.go b/examples/space/main.go index b5a1f3dbc5..b0501f73b5 100644 --- a/examples/space/main.go +++ b/examples/space/main.go @@ -26,7 +26,7 @@ type model struct { height int } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tea.Batch( tea.EnterAltScreen, tickCmd(), @@ -41,7 +41,7 @@ func tickCmd() tea.Cmd { type tickMsg struct{} -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -117,9 +117,8 @@ func (m model) View() fmt.Stringer { } func main() { - p := tea.NewProgram(model{}, tea.WithAltScreen()) - - _, err := p.Run() + p := tea.NewProgram(model{}) + err := p.Run() if err != nil { fmt.Printf("Error running program: %v", err) os.Exit(1) diff --git a/examples/spinner/main.go b/examples/spinner/main.go index 5ee5ddba8c..f21d6aee87 100644 --- a/examples/spinner/main.go +++ b/examples/spinner/main.go @@ -27,11 +27,11 @@ func initialModel() model { return model{spinner: s} } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, m.spinner.Tick } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -66,7 +66,7 @@ func (m model) View() fmt.Stringer { func main() { p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { fmt.Println(err) os.Exit(1) } diff --git a/examples/spinners/main.go b/examples/spinners/main.go index 83e7dbd9d5..9161b29502 100644 --- a/examples/spinners/main.go +++ b/examples/spinners/main.go @@ -32,7 +32,7 @@ func main() { m := model{} m.resetSpinner() - if _, err := tea.NewProgram(m).Run(); err != nil { + if err := tea.NewProgram(m).Run(); err != nil { fmt.Println("could not run program:", err) os.Exit(1) } @@ -43,11 +43,11 @@ type model struct { spinner spinner.Model } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, m.spinner.Tick } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { diff --git a/examples/splash/main.go b/examples/splash/main.go index 2a5f586e7e..5b06af4d7d 100644 --- a/examples/splash/main.go +++ b/examples/splash/main.go @@ -37,12 +37,15 @@ type model struct { rate int64 } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { m.rate = 90 - return m, tick + return m, tea.Batch( + tea.EnterAltScreen, + tick, + ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit @@ -170,12 +173,8 @@ func abs(i int) int { } func main() { - p := tea.NewProgram( - model{}, - tea.WithAltScreen(), - ) - - if _, err := p.Run(); err != nil { + p := tea.NewProgram(model{}) + if err := p.Run(); err != nil { fmt.Printf("Error running program: %v", err) } } diff --git a/examples/split-editors/main.go b/examples/split-editors/main.go index 47799906fd..c831f4dd0f 100644 --- a/examples/split-editors/main.go +++ b/examples/split-editors/main.go @@ -51,7 +51,7 @@ func newTextarea() textarea.Model { t.Prompt = "" t.Placeholder = "Type something" t.ShowLineNumbers = true - t.Cursor.Style = cursorStyle + t.VirtualCursor.Style = cursorStyle t.Styles.Focused.Placeholder = focusedPlaceholderStyle t.Styles.Blurred.Placeholder = placeholderStyle t.Styles.Focused.CursorLine = cursorLineStyle @@ -111,11 +111,14 @@ func newModel() model { return m } -func (m model) Init() (tea.Model, tea.Cmd) { - return m, textarea.Blink +func (m model) Init() (model, tea.Cmd) { + return m, tea.Batch( + tea.EnterAltScreen, + textarea.Blink, + ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -198,7 +201,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(newModel(), tea.WithAltScreen()).Run(); err != nil { + if err := tea.NewProgram(newModel()).Run(); err != nil { fmt.Println("Error while running program:", err) os.Exit(1) } diff --git a/examples/stopwatch/main.go b/examples/stopwatch/main.go index afa63ff864..b7303608b1 100644 --- a/examples/stopwatch/main.go +++ b/examples/stopwatch/main.go @@ -25,7 +25,7 @@ type keymap struct { quit key.Binding } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { sw, cmd := m.stopwatch.Init() m.stopwatch = sw return m, cmd @@ -52,7 +52,7 @@ func (m model) helpView() string { }) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { @@ -98,7 +98,7 @@ func main() { m.keymap.start.SetEnabled(false) - if _, err := tea.NewProgram(m).Run(); err != nil { + if err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Oh no, it didn't work:", err) os.Exit(1) } diff --git a/examples/suspend/main.go b/examples/suspend/main.go index 7b61a389eb..e49397efba 100644 --- a/examples/suspend/main.go +++ b/examples/suspend/main.go @@ -13,11 +13,11 @@ type model struct { suspending bool } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.ResumeMsg: m.suspending = false @@ -47,7 +47,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(model{}).Run(); err != nil { + if err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Error running program:", err) if errors.Is(err, tea.ErrInterrupted) { os.Exit(130) diff --git a/examples/table-resize/main.go b/examples/table-resize/main.go index bca025166a..248022b93c 100644 --- a/examples/table-resize/main.go +++ b/examples/table-resize/main.go @@ -28,9 +28,9 @@ type model struct { table *table.Table } -func (m model) Init() (tea.Model, tea.Cmd) { return m, nil } +func (m model) Init() (model, tea.Cmd) { return m, tea.EnterAltScreen } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -154,7 +154,7 @@ func main() { }). Border(lipgloss.ThickBorder()) - if _, err := tea.NewProgram(model{t}, tea.WithAltScreen()).Run(); err != nil { + if err := tea.NewProgram(model{t}).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/table/main.go b/examples/table/main.go index 4e3c43b8a1..5366040bf5 100644 --- a/examples/table/main.go +++ b/examples/table/main.go @@ -17,9 +17,9 @@ type model struct { table table.Model } -func (m model) Init() (tea.Model, tea.Cmd) { return m, nil } +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: @@ -177,7 +177,7 @@ func main() { t.SetStyles(s) m := model{t} - if _, err := tea.NewProgram(m).Run(); err != nil { + if err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/tabs/main.go b/examples/tabs/main.go index 9cbf8e1472..22c0a3899b 100644 --- a/examples/tabs/main.go +++ b/examples/tabs/main.go @@ -49,11 +49,11 @@ type model struct { activeTab int } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tea.RequestBackgroundColor } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.BackgroundColorMsg: m.styles = newStyles(msg.IsDark()) @@ -126,7 +126,7 @@ func main() { tabs := []string{"Lip Gloss", "Blush", "Eye Shadow", "Mascara", "Foundation"} tabContent := []string{"Lip Gloss Tab", "Blush Tab", "Eye Shadow Tab", "Mascara Tab", "Foundation Tab"} m := model{Tabs: tabs, TabContent: tabContent} - if _, err := tea.NewProgram(m).Run(); err != nil { + if err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } diff --git a/examples/textarea/main.go b/examples/textarea/main.go index d47280df56..9e6fe0c9a7 100644 --- a/examples/textarea/main.go +++ b/examples/textarea/main.go @@ -13,9 +13,19 @@ import ( ) func main() { - p := tea.NewProgram(initialModel()) + // p := tea.NewProgram(initialModel()) + m := initialModel() + p := &tea.Program[model]{ + Init: m.Init, + Update: func(m model, msg tea.Msg) (model, tea.Cmd) { + return m.Update(msg) + }, + View: func(m model) fmt.Stringer { + return m.View() + }, + } - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } @@ -31,7 +41,7 @@ func initialModel() model { ti := textarea.New() ti.Placeholder = "Once upon a time..." ti.Focus() - ti.Cursor.SetMode(cursor.CursorHide) + ti.VirtualCursor.SetMode(cursor.CursorHide) return model{ textarea: ti, @@ -39,13 +49,13 @@ func initialModel() model { } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, tea.Batch( textarea.Blink, ) } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd @@ -87,7 +97,8 @@ func (m model) View() fmt.Stringer { "(ctrl+c to quit)", ) + "\n\n") - x, y := m.textarea.CursorPosition() + cur := m.textarea.Cursor() + x, y := cur.Position.X, cur.Position.Y f.Cursor = tea.NewCursor(x+xOffset, y+yOffset) return f diff --git a/examples/textinput/main.go b/examples/textinput/main.go index fb004a5ad6..cfb36b5ff8 100644 --- a/examples/textinput/main.go +++ b/examples/textinput/main.go @@ -13,7 +13,7 @@ import ( func main() { p := tea.NewProgram(initialModel()) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } @@ -40,11 +40,11 @@ func initialModel() model { } } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, textinput.Blink } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { diff --git a/examples/textinputs/main.go b/examples/textinputs/main.go index 6f1c33d1be..a7116d9ccd 100644 --- a/examples/textinputs/main.go +++ b/examples/textinputs/main.go @@ -64,11 +64,11 @@ func initialModel() model { return m } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, textinput.Blink } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -171,7 +171,7 @@ func (m model) View() fmt.Stringer { } func main() { - if _, err := tea.NewProgram(initialModel()).Run(); err != nil { + if err := tea.NewProgram(initialModel()).Run(); err != nil { fmt.Printf("could not start program: %s\n", err) os.Exit(1) } diff --git a/examples/timer/main.go b/examples/timer/main.go index 9a1147176a..0edd60a0de 100644 --- a/examples/timer/main.go +++ b/examples/timer/main.go @@ -27,13 +27,13 @@ type keymap struct { quit key.Binding } -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { timer, cmd := m.timer.Init() m.timer = timer return m, cmd } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case timer.TickMsg: var cmd tea.Cmd @@ -117,7 +117,7 @@ func main() { } m.keymap.start.SetEnabled(false) - if _, err := tea.NewProgram(m).Run(); err != nil { + if err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Uh oh, we encountered an error:", err) os.Exit(1) } diff --git a/examples/views/main.go b/examples/views/main.go index abc02a28a1..2e325ef2e8 100644 --- a/examples/views/main.go +++ b/examples/views/main.go @@ -9,6 +9,7 @@ package main import ( "fmt" "math" + "os" "strconv" "strings" "time" @@ -41,10 +42,14 @@ var ( ) func main() { - initialModel := model{0, false, 10, 0, 0, false, false} - p := tea.NewProgram(initialModel) - if _, err := p.Run(); err != nil { - fmt.Println("could not start program:", err) + p := tea.Program[model]{ + Init: Init, + Update: Update, + View: View, + } + if err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "could not start program:", err) + os.Exit(1) } } @@ -75,12 +80,12 @@ type model struct { Quitting bool } -func (m model) Init() (tea.Model, tea.Cmd) { - return m, tick() +func Init() (model, tea.Cmd) { + return model{Ticks: 10}, tick() } // Main update function. -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func Update(m model, msg tea.Msg) (model, tea.Cmd) { // Make sure these keys always quit if msg, ok := msg.(tea.KeyMsg); ok { k := msg.String() @@ -93,13 +98,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Hand off the message and model to the appropriate update function for the // appropriate view based on the current state. if !m.Chosen { - return updateChoices(msg, m) + return updateChoices(m, msg) } - return updateChosen(msg, m) + return updateChosen(m, msg) } // The main view, which just calls the appropriate sub-view -func (m model) View() fmt.Stringer { +func View(m model) fmt.Stringer { var s string if m.Quitting { return tea.NewFrame("\n See you later!\n\n") @@ -115,7 +120,7 @@ func (m model) View() fmt.Stringer { // Sub-update functions // Update loop for the first view where you're choosing a task. -func updateChoices(msg tea.Msg, m model) (tea.Model, tea.Cmd) { +func updateChoices(m model, msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -147,7 +152,7 @@ func updateChoices(msg tea.Msg, m model) (tea.Model, tea.Cmd) { } // Update loop for the second view after a choice has been made -func updateChosen(msg tea.Msg, m model) (tea.Model, tea.Cmd) { +func updateChosen(m model, msg tea.Msg) (model, tea.Cmd) { switch msg.(type) { case frameMsg: if !m.Loaded { diff --git a/examples/window-size/main.go b/examples/window-size/main.go index c33ea55487..758c2bd779 100644 --- a/examples/window-size/main.go +++ b/examples/window-size/main.go @@ -11,18 +11,18 @@ import ( func main() { p := tea.NewProgram(model{}) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { log.Fatal(err) } } type model struct{} -func (m model) Init() (tea.Model, tea.Cmd) { +func (m model) Init() (model, tea.Cmd) { return m, nil } -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m model) Update(msg tea.Msg) (model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if s := msg.String(); s == "ctrl+c" || s == "q" || s == "esc" { diff --git a/exec.go b/exec.go index ef353eb6bc..a83557195f 100644 --- a/exec.go +++ b/exec.go @@ -99,7 +99,7 @@ func (c *osExecCommand) SetStderr(w io.Writer) { } // exec runs an ExecCommand and delivers the results to the program as a Msg. -func (p *Program) exec(c ExecCommand, fn ExecCallback) { +func (p *Program[T]) exec(c ExecCommand, fn ExecCallback) { if err := p.ReleaseTerminal(); err != nil { // If we can't release input, abort. if fn != nil { @@ -108,8 +108,8 @@ func (p *Program) exec(c ExecCommand, fn ExecCallback) { return } - c.SetStdin(p.input) - c.SetStdout(p.output.Writer()) + c.SetStdin(p.Input) + c.SetStdout(p.Output) c.SetStderr(os.Stderr) // Execute system command. diff --git a/exec_test.go b/exec_test.go index ac5b6c26e4..98ec414b9b 100644 --- a/exec_test.go +++ b/exec_test.go @@ -15,14 +15,15 @@ type testExecModel struct { err error } -func (m *testExecModel) Init() (Model, Cmd) { +func newTestExecModel(cmd string) (*testExecModel, Cmd) { + m := &testExecModel{cmd: cmd} c := exec.Command(m.cmd) //nolint:gosec return m, ExecProcess(c, func(err error) Msg { return execFinishedMsg{err} }) } -func (m *testExecModel) Update(msg Msg) (Model, Cmd) { +func (m *testExecModel) Update(msg Msg) (*testExecModel, Cmd) { switch msg := msg.(type) { case execFinishedMsg: if msg.err != nil { @@ -74,16 +75,22 @@ func TestTeaExec(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer - m := &testExecModel{cmd: test.cmd} - p := NewProgram(m, WithInput(&in), WithOutput(&buf)) - if _, err := p.Run(); err != nil { + p := Program[*testExecModel]{ + Init: func() (*testExecModel, Cmd) { return newTestExecModel(test.cmd) }, + Update: (*testExecModel).Update, + View: (*testExecModel).View, + } + p.Input = &in + p.Output = &buf + p.ForceInputTTY = true + if err := p.Run(); err != nil { t.Error(err) } - if m.err != nil && !test.expectErr { - t.Errorf("expected no error, got %v", m.err) + if p.Model.err != nil && !test.expectErr { + t.Errorf("expected no error, got %v", p.Model.err) } - if m.err == nil && test.expectErr { + if p.Model.err == nil && test.expectErr { t.Error("expected error, got nil") } }) diff --git a/go.mod b/go.mod index 68223cebbe..95024c65d7 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/charmbracelet/bubbletea/v2 go 1.18 require ( - github.com/charmbracelet/colorprofile v0.1.10 + github.com/charmbracelet/colorprofile v0.2.0 github.com/charmbracelet/x/ansi v0.8.0 - github.com/charmbracelet/x/cellbuf v0.0.7 + github.com/charmbracelet/x/cellbuf v0.0.8 github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f github.com/charmbracelet/x/input v0.3.1 github.com/charmbracelet/x/term v0.2.1 diff --git a/go.sum b/go.sum index 974432d0f4..e2188748be 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/colorprofile v0.1.10 h1:k6jIGJg4bPWvHZqcoLjFxH1bm9uT28Ysxg8guonDJ1Y= -github.com/charmbracelet/colorprofile v0.1.10/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk= +github.com/charmbracelet/colorprofile v0.2.0 h1:iiIQlp3LSvoJPtR11KoDfIf9wqWm2mn/iU420rHOZ/A= +github.com/charmbracelet/colorprofile v0.2.0/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.7 h1:u+ArcmMqOuY+f0rFGHzTJxALno2kJAPxK8u2PVo5YIQ= -github.com/charmbracelet/x/cellbuf v0.0.7/go.mod h1:WU1sKZkKCLaBjrRneV4AGFYygeFiGk5rFAKxqRyJuPE= +github.com/charmbracelet/x/cellbuf v0.0.8 h1:seFe/rierwnDBVmGWWnQj3vHqzQkGYzuJYfKEY48TqM= +github.com/charmbracelet/x/cellbuf v0.0.8/go.mod h1:dKfNBxLovpvzzxAP6/GZfs5eb7vNxHlUDnwGhRmvIdY= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/input v0.3.1 h1:TE4s3fTRj+OUpJ86dKphrN99+NgBnto//EkWncMJQIg= diff --git a/input.go b/input.go index 600474b01b..4864a8460d 100644 --- a/input.go +++ b/input.go @@ -5,7 +5,7 @@ import ( ) // translateInputEvent translates an input event into a Bubble Tea Msg. -func (p *Program) translateInputEvent(e input.Event) Msg { +func (p *Program[T]) translateInputEvent(e input.Event) Msg { switch e := e.(type) { case input.ClipboardEvent: switch e.Selection { diff --git a/options.go b/options.go deleted file mode 100644 index a0f059b4d4..0000000000 --- a/options.go +++ /dev/null @@ -1,284 +0,0 @@ -package tea - -import ( - "context" - "io" - "sync/atomic" - - "github.com/charmbracelet/colorprofile" -) - -// ProgramOption is used to set options when initializing a Program. Program can -// accept a variable number of options. -// -// Example usage: -// -// p := NewProgram(model, WithInput(someInput), WithOutput(someOutput)) -type ProgramOption func(*Program) - -// WithContext lets you specify a context in which to run the Program. This is -// useful if you want to cancel the execution from outside. When a Program gets -// cancelled it will exit with an error ErrProgramKilled. -func WithContext(ctx context.Context) ProgramOption { - return func(p *Program) { - p.ctx = ctx - } -} - -// WithOutput sets the output which, by default, is stdout. In most cases you -// won't need to use this. -func WithOutput(output io.Writer) ProgramOption { - return func(p *Program) { - p.output = newSafeWriter(output) - } -} - -// WithInput sets the input which, by default, is stdin. In most cases you -// won't need to use this. To disable input entirely pass nil. -// -// p := NewProgram(model, WithInput(nil)) -func WithInput(input io.Reader) ProgramOption { - return func(p *Program) { - p.input = input - p.inputType = customInput - } -} - -// WithInputTTY opens a new TTY for input (or console input device on Windows). -func WithInputTTY() ProgramOption { - return func(p *Program) { - p.inputType = ttyInput - } -} - -// WithEnvironment sets the environment variables that the program will use. -// This useful when the program is running in a remote session (e.g. SSH) and -// you want to pass the environment variables from the remote session to the -// program. -// -// Example: -// -// var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package -// pty, _, _ := sess.Pty() -// environ := append(sess.Environ(), "TERM="+pty.Term) -// p := tea.NewProgram(model, tea.WithEnvironment(environ) -func WithEnvironment(env []string) ProgramOption { - return func(p *Program) { - p.environ = env - } -} - -// WithoutSignalHandler disables the signal handler that Bubble Tea sets up for -// Programs. This is useful if you want to handle signals yourself. -func WithoutSignalHandler() ProgramOption { - return func(p *Program) { - p.startupOptions |= withoutSignalHandler - } -} - -// WithoutCatchPanics disables the panic catching that Bubble Tea does by -// default. If panic catching is disabled the terminal will be in a fairly -// unusable state after a panic because Bubble Tea will not perform its usual -// cleanup on exit. -func WithoutCatchPanics() ProgramOption { - return func(p *Program) { - p.startupOptions |= withoutCatchPanics - } -} - -// WithoutSignals will ignore OS signals. -// This is mainly useful for testing. -func WithoutSignals() ProgramOption { - return func(p *Program) { - atomic.StoreUint32(&p.ignoreSignals, 1) - } -} - -// WithAltScreen starts the program with the alternate screen buffer enabled -// (i.e. the program starts in full window mode). Note that the altscreen will -// be automatically exited when the program quits. -// -// Example: -// -// p := tea.NewProgram(Model{}, tea.WithAltScreen()) -// if _, err := p.Run(); err != nil { -// fmt.Println("Error running program:", err) -// os.Exit(1) -// } -// -// To enter the altscreen once the program has already started running use the -// EnterAltScreen command. -func WithAltScreen() ProgramOption { - return func(p *Program) { - p.startupOptions |= withAltScreen - } -} - -// WithoutBracketedPaste starts the program with bracketed paste disabled. -func WithoutBracketedPaste() ProgramOption { - return func(p *Program) { - p.startupOptions |= withoutBracketedPaste - } -} - -// WithMouseCellMotion starts the program with the mouse enabled in "cell -// motion" mode. -// -// Cell motion mode enables mouse click, release, and wheel events. Mouse -// 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. -// -// The mouse will be automatically disabled when the program exits. -func WithMouseCellMotion() ProgramOption { - return func(p *Program) { - p.startupOptions |= withMouseCellMotion // set - p.startupOptions &^= withMouseAllMotion // clear - } -} - -// WithMouseAllMotion starts the program with the mouse enabled in "all motion" -// mode. -// -// EnableMouseAllMotion is a special command that enables mouse click, release, -// 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. -// -// To enable the mouse once the program has already started running use the -// EnableMouseAllMotion command. To disable the mouse when the program is -// running use the DisableMouse command. -// -// The mouse will be automatically disabled when the program exits. -func WithMouseAllMotion() ProgramOption { - return func(p *Program) { - p.startupOptions |= withMouseAllMotion // set - p.startupOptions &^= withMouseCellMotion // clear - } -} - -// WithoutRenderer disables the renderer. When this is set output and log -// statements will be plainly sent to stdout (or another output if one is set) -// without any rendering and redrawing logic. In other words, printing and -// logging will behave the same way it would in a non-TUI commandline tool. -// This can be useful if you want to use the Bubble Tea framework for a non-TUI -// application, or to provide an additional non-TUI mode to your Bubble Tea -// programs. For example, your program could behave like a daemon if output is -// not a TTY. -func WithoutRenderer() ProgramOption { - return func(p *Program) { - p.renderer = &nilRenderer{} - } -} - -// WithFilter supplies an event filter that will be invoked before Bubble Tea -// processes a tea.Msg. The event filter can return any tea.Msg which will then -// get handled by Bubble Tea instead of the original event. If the event filter -// returns nil, the event will be ignored and Bubble Tea will not process it. -// -// As an example, this could be used to prevent a program from shutting down if -// there are unsaved changes. -// -// Example: -// -// func filter(m tea.Model, msg tea.Msg) tea.Msg { -// if _, ok := msg.(tea.QuitMsg); !ok { -// return msg -// } -// -// model := m.(myModel) -// if model.hasChanges { -// return nil -// } -// -// return msg -// } -// -// p := tea.NewProgram(Model{}, tea.WithFilter(filter)); -// -// if _,err := p.Run(); err != nil { -// fmt.Println("Error running program:", err) -// os.Exit(1) -// } -func WithFilter(filter func(Model, Msg) Msg) ProgramOption { - return func(p *Program) { - p.filter = filter - } -} - -// WithFPS sets a custom maximum FPS at which the renderer should run. If -// less than 1, the default value of 60 will be used. If over 120, the FPS -// will be capped at 120. -func WithFPS(fps int) ProgramOption { - return func(p *Program) { - p.fps = fps - } -} - -// WithReportFocus enables reporting when the terminal gains and loses -// focus. When this is enabled [FocusMsg] and [BlurMsg] messages will be sent -// to your Update method. -// -// Note that while most terminals and multiplexers support focus reporting, -// some do not. Also note that tmux needs to be configured to report focus -// events. -func WithReportFocus() ProgramOption { - return func(p *Program) { - p.startupOptions |= withReportFocus - } -} - -// WithKeyboardEnhancements enables support for enhanced keyboard features. You -// can enable different keyboard features by passing one or more -// KeyboardEnhancement functions. -// -// This is not supported on all terminals. On Windows, these features are -// enabled by default. -func WithKeyboardEnhancements(enhancements ...KeyboardEnhancementOption) ProgramOption { - var ke KeyboardEnhancements - for _, e := range append(enhancements, withKeyDisambiguation) { - e(&ke) - } - return func(p *Program) { - p.startupOptions |= withKeyboardEnhancements - p.requestedEnhancements = ke - } -} - -// WithGraphemeClustering disables grapheme clustering. This is useful if you -// want to disable grapheme clustering for your program. -// -// Grapheme clustering is a character width calculation method that accurately -// calculates the width of wide characters in a terminal. This is useful for -// properly rendering double width characters such as emojis and CJK -// characters. -// -// See https://mitchellh.com/writing/grapheme-clusters-in-terminals -func WithGraphemeClustering() ProgramOption { - return func(p *Program) { - p.startupOptions |= withGraphemeClustering - } -} - -// WithColorProfile sets the color profile that the program will use. This is -// useful when you want to force a specific color profile. By default, Bubble -// Tea will try to detect the terminal's color profile from environment -// variables and terminfo capabilities. Use [tea.WithEnvironment] to set custom -// environment variables. -func WithColorProfile(profile colorprofile.Profile) ProgramOption { - return func(p *Program) { - p.startupOptions |= withColorProfile - p.profile = profile - } -} diff --git a/options_test.go b/options_test.go deleted file mode 100644 index 84d8285357..0000000000 --- a/options_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package tea - -import ( - "bytes" - "os" - "sync/atomic" - "testing" -) - -func TestOptions(t *testing.T) { - t.Run("output", func(t *testing.T) { - var b bytes.Buffer - p := NewProgram(nil, WithOutput(&b)) - if f, ok := p.output.Writer().(*os.File); ok { - t.Errorf("expected output to custom, got %v", f.Fd()) - } - }) - - t.Run("custom input", func(t *testing.T) { - var b bytes.Buffer - p := NewProgram(nil, WithInput(&b)) - if p.input != &b { - t.Errorf("expected input to custom, got %v", p.input) - } - if p.inputType != customInput { - t.Errorf("expected startup options to have custom input set, got %v", p.input) - } - }) - - t.Run("renderer", func(t *testing.T) { - p := NewProgram(nil, WithoutRenderer()) - switch p.renderer.(type) { - case *nilRenderer: - return - default: - t.Errorf("expected renderer to be a nilRenderer, got %v", p.renderer) - } - }) - - t.Run("without signals", func(t *testing.T) { - p := NewProgram(nil, WithoutSignals()) - if atomic.LoadUint32(&p.ignoreSignals) == 0 { - t.Errorf("ignore signals should have been set") - } - }) - - t.Run("filter", func(t *testing.T) { - p := NewProgram(nil, WithFilter(func(_ Model, msg Msg) Msg { return msg })) - if p.filter == nil { - t.Errorf("expected filter to be set") - } - }) - - t.Run("input options", func(t *testing.T) { - exercise := func(t *testing.T, opt ProgramOption, expect inputType) { - p := NewProgram(nil, opt) - if p.inputType != expect { - t.Errorf("expected input type %s, got %s", expect, p.inputType) - } - } - - t.Run("tty input", func(t *testing.T) { - exercise(t, WithInputTTY(), ttyInput) - }) - - t.Run("custom input", func(t *testing.T) { - var b bytes.Buffer - exercise(t, WithInput(&b), customInput) - }) - }) - - t.Run("startup options", func(t *testing.T) { - exercise := func(t *testing.T, opt ProgramOption, expect startupOptions) { - p := NewProgram(nil, opt) - if !p.startupOptions.has(expect) { - t.Errorf("expected startup options have %v, got %v", expect, p.startupOptions) - } - } - - t.Run("alt screen", func(t *testing.T) { - exercise(t, WithAltScreen(), withAltScreen) - }) - - t.Run("bracketed paste disabled", func(t *testing.T) { - exercise(t, WithoutBracketedPaste(), withoutBracketedPaste) - }) - - t.Run("without catch panics", func(t *testing.T) { - exercise(t, WithoutCatchPanics(), withoutCatchPanics) - }) - - t.Run("without signal handler", func(t *testing.T) { - exercise(t, WithoutSignalHandler(), withoutSignalHandler) - }) - - t.Run("mouse cell motion", func(t *testing.T) { - p := NewProgram(nil, WithMouseAllMotion(), WithMouseCellMotion()) - if !p.startupOptions.has(withMouseCellMotion) { - t.Errorf("expected startup options have %v, got %v", withMouseCellMotion, p.startupOptions) - } - if p.startupOptions.has(withMouseAllMotion) { - t.Errorf("expected startup options not have %v, got %v", withMouseAllMotion, p.startupOptions) - } - }) - - t.Run("mouse all motion", func(t *testing.T) { - p := NewProgram(nil, WithMouseCellMotion(), WithMouseAllMotion()) - if !p.startupOptions.has(withMouseAllMotion) { - t.Errorf("expected startup options have %v, got %v", withMouseAllMotion, p.startupOptions) - } - if p.startupOptions.has(withMouseCellMotion) { - t.Errorf("expected startup options not have %v, got %v", withMouseCellMotion, p.startupOptions) - } - }) - }) - - t.Run("multiple", func(t *testing.T) { - p := NewProgram(nil, WithMouseAllMotion(), WithoutBracketedPaste(), WithAltScreen(), WithInputTTY()) - for _, opt := range []startupOptions{withMouseAllMotion, withoutBracketedPaste, withAltScreen} { - if !p.startupOptions.has(opt) { - t.Errorf("expected startup options have %v, got %v", opt, p.startupOptions) - } - if p.inputType != ttyInput { - t.Errorf("expected input to be %v, got %v", opt, p.startupOptions) - } - } - }) -} diff --git a/screen_test.go b/screen_test.go index 4af55d64e1..7d0295fc4c 100644 --- a/screen_test.go +++ b/screen_test.go @@ -88,20 +88,17 @@ func TestClearMsg(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer - m := &testModel{} - p := NewProgram(m, WithInput(&in), WithOutput(&buf), - WithEnvironment([]string{ - "TERM=xterm-256color", // always use xterm and 256 colors for tests - }), - // Use ANSI256 to increase test coverage. - WithColorProfile(colorprofile.ANSI256)) + p := newTestProgram(&in, &buf) + p.Env = []string{"TERM=xterm-256color"} // always use xterm and 256 colors for tests + // Use ANSI256 to increase test coverage. + p.Profile = colorprofile.ANSI256 // Set the initial window size for the program. p.width, p.height = 80, 24 go p.Send(append(test.cmds, Quit)) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { t.Fatal(err) } golden.RequireEqual(t, buf.Bytes()) diff --git a/signals_unix.go b/signals_unix.go index 409540385f..7aefa572d2 100644 --- a/signals_unix.go +++ b/signals_unix.go @@ -12,7 +12,7 @@ import ( // listenForResize sends messages (or errors) when the terminal resizes. // Argument output should be the file descriptor for the terminal; usually // os.Stdout. -func (p *Program) listenForResize(done chan struct{}) { +func (p *Program[T]) listenForResize(done chan struct{}) { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGWINCH) diff --git a/signals_windows.go b/signals_windows.go index 2fc6f8ae79..3d3246db34 100644 --- a/signals_windows.go +++ b/signals_windows.go @@ -5,6 +5,6 @@ package tea // listenForResize is not available on windows because windows does not // implement syscall.SIGWINCH. -func (p *Program) listenForResize(done chan struct{}) { +func (p *Program[T]) listenForResize(done chan struct{}) { close(done) } diff --git a/sync.go b/sync.go deleted file mode 100644 index d3e4ab2ea6..0000000000 --- a/sync.go +++ /dev/null @@ -1,36 +0,0 @@ -package tea - -import ( - "io" - "log" - "sync" -) - -// safeWriter is a thread-safe writer. -type safeWriter struct { - w io.Writer - mu sync.Mutex - trace bool -} - -var _ io.Writer = &safeWriter{} - -// newSafeWriter returns a new safeWriter. -func newSafeWriter(w io.Writer) *safeWriter { - return &safeWriter{w: w} -} - -// Writer returns the underlying writer. -func (w *safeWriter) Writer() io.Writer { - return w.w -} - -// Write writes to the underlying writer. -func (w *safeWriter) Write(p []byte) (n int, err error) { - w.mu.Lock() - defer w.mu.Unlock() - if w.trace { - log.Printf("output %q", p) - } - return w.w.Write(p) -} diff --git a/tea.go b/tea.go index 565f28a8ba..ab19275fa9 100644 --- a/tea.go +++ b/tea.go @@ -15,11 +15,14 @@ import ( "fmt" "image/color" "io" + "log" "os" "os/signal" + "path/filepath" "runtime" "runtime/debug" "strconv" + "strings" "sync" "sync/atomic" "syscall" @@ -44,14 +47,14 @@ var ErrInterrupted = errors.New("program was interrupted") type Msg interface{} // Model contains the program's state as well as its core functions. -type Model interface { +type Model[T any] interface { // Init is the first function that will be called. It returns an optional // initial command. To not perform an initial command return nil. - Init() (Model, Cmd) + Init() (T, Cmd) // Update is called when a message is received. Use it to inspect messages // and, in response, update the model and/or send a command. - Update(Msg) (Model, Cmd) + Update(Msg) (T, Cmd) // View renders the program's UI, which is just a [fmt.Stringer]. The view // is rendered after every Update. @@ -69,55 +72,6 @@ type Model interface { // update function. type Cmd func() Msg -type inputType int - -const ( - defaultInput inputType = iota - ttyInput - customInput -) - -// String implements the stringer interface for [inputType]. It is inteded to -// be used in testing. -func (i inputType) String() string { - return [...]string{ - "default input", - "tty input", - "custom input", - }[i] -} - -// Options to customize the program during its initialization. These are -// generally set with ProgramOptions. -// -// The options here are treated as bits. -type startupOptions int16 - -func (s startupOptions) has(option startupOptions) bool { - return s&option != 0 -} - -const ( - withAltScreen startupOptions = 1 << iota - withMouseCellMotion - withMouseAllMotion - withoutSignalHandler - // Catching panics is incredibly useful for restoring the terminal to a - // usable state after a panic occurs. When this is set, Bubble Tea will - // recover from panics, print the stack trace, and disable raw mode. This - // feature is on by default. - withoutCatchPanics - withoutBracketedPaste - withReportFocus - withKittyKeyboard - withModifyOtherKeys - withWindowsInputMode - withoutGraphemeClustering - withColorProfile - withKeyboardEnhancements - withGraphemeClustering -) - // channelHandlers manages the series of channels returned by various processes. // It allows us to wait for those processes to terminate before exiting the // program. @@ -151,47 +105,108 @@ func (h *channelHandlers) shutdown() { wg.Wait() } -// Program is a terminal user interface. -type Program struct { - initialModel Model +// Program is a terminal user interface. It's the main entry point for building +// your Bubble Tea program. +// +// A minimal program contain an [Program.Init] and [Program.Update] function. The +// [Program.Init] function initializes the model and returns an optional initial +// command. The [Program.Update] function updates the model based on messages +// received and returns the updated model and an optional command. +// +// The [Program.View] function is optional and defines how your program's UI +// should look like. It returns a [Frame] that will be rendered to the +// terminal. The view function is called after every update and is responsible +// for rendering the program's UI. +// If you don't set a view function, Bubble Tea will run the program without +// rendering anything to the terminal. +type Program[T any] struct { + // Input is the used program's input reader used to listen for input events + // like key presses. If no input is set, it defaults to os.Stdin. + Input io.Reader + + // Output defines where the program will write its output. If no output is + // set, it defaults to os.Stdout. + Output io.Writer + + // Env is a list of environment variables that will be used to determine + // terminal capabilities and color support. If no environment is set, it + // defaults to [os.Environ]. + // When the program starts, it will send an [EnvMsg] with the environment + // variables used in the program. + Env []string + + // Init is a program's function that initializes the model and returns an + // optional initial command. Initial commands can be used to perform + // various terminal operations before the program starts. For example, to + // run your program in alt-screen mode, or full-screen mode, you can use + // [EnterAltScreen] here. + Init func() (T, Cmd) + + // Filter is an optional function that can be used to filter messages before + // they reach the update function. This can be useful for logging, debugging, + // or to prevent certain messages from reaching the update function. + Filter func(T, Msg) Msg + + // Update is a program's function that updates the model based on messages + // received. It returns the updated model and an optional command. The + // update function is the heart of the program and is where you'll handle + // messages and update the model. It's basically the main event loop of + // your program. + Update func(T, Msg) (T, Cmd) + + // View is a program's function that defines how your program's UI should + // look like. It returns a [Frame] that will be rendered to the terminal. + // To have more control over the cursor position and style, you can use + // [Frame.Cursor] and [NewCursor] to position the cursor and define its + // style. + View func(T) fmt.Stringer - // handlers is a list of channels that need to be waited on before the - // program can exit. - handlers channelHandlers + // Model contains the last state of the program. If the program hasn't + // started yet, it will be nil. After the program finish executing, it will + // contain the final state of the program. + Model T + + // DontCatchPanics is a flag that determines whether or not the program should + // catch panics. + DontCatchPanics bool - // Configuration options that will set as the program is initializing, - // treated as bits. These options can be set via various ProgramOptions. - startupOptions startupOptions + // IgnoreSignals is a flag that determines whether or not the program should + // ignore signals. + IgnoreSignals bool - // startupTitle is the title that will be set on the terminal when the - // program starts. - startupTitle string + // Profile is the color profile of the terminal. To force a color profile + // set this to a specific value. If this is not set, or set to + // [colorprofile.NoTTY], Bubble Tea will try to detect the color profile of + // the terminal based on the program's output and environment variables. + Profile colorprofile.Profile - inputType inputType + // ForceInputTTY is true if the input is a TTY. Use this to tell the program + // that the input is a TTY and that it should be treated as such. + ForceInputTTY bool + + // FPS is the frames per second we should set on the renderer, if + // applicable, + FPS int + + // handlers is a list of channels that need to be waited on before the + // program can exit. + handlers channelHandlers ctx context.Context cancel context.CancelFunc msgs chan Msg errs chan error - finished chan struct{} shutdownOnce sync.Once - - profile colorprofile.Profile // the terminal color profile - - // where to send output, this will usually be os.Stdout. - output *safeWriter + killed int32 + initialized int32 // ttyOutput is null if output is not a TTY. ttyOutput term.File previousOutputState *term.State renderer renderer + traceOutput bool // true if output should be traced - // the environment variables for the program, defaults to os.Environ(). - environ environ - - // where to read inputs from, this will usually be os.Stdin. - input io.Reader // ttyInput is null if input is not a TTY. ttyInput term.File previousTtyInputState *term.State @@ -199,16 +214,13 @@ type Program struct { traceInput bool // true if input should be traced readLoopDone chan struct{} + // logger is the logger used by the program when tracing is enabled. + logger *log.Logger + // modes keeps track of terminal modes that have been enabled or disabled. modes ansi.Modes ignoreSignals uint32 - filter func(Model, Msg) Msg - - // fps is the frames per second we should set on the renderer, if - // applicable, - fps int - // ticker is the ticker that will be used to write to the renderer. ticker *time.Ticker @@ -281,66 +293,83 @@ func Interrupt() Msg { } // NewProgram creates a new Program. -func NewProgram(model Model, opts ...ProgramOption) *Program { - p := &Program{ - initialModel: model, - msgs: make(chan Msg), - rendererDone: make(chan struct{}), - keyboardc: make(chan struct{}), - modes: ansi.Modes{}, - } +func NewProgram[T any](model Model[T]) *Program[T] { + p := new(Program[T]) + p.Init = model.Init + p.Update = func(t T, msg Msg) (T, Cmd) { return any(t).(Model[T]).Update(msg) } + p.View = func(t T) fmt.Stringer { return any(t).(Model[T]).View() } + return p +} - // Apply all options to the program. - for _, opt := range opts { - opt(p) +func (p *Program[T]) init() { + if atomic.LoadInt32(&p.initialized) == 1 { + return } - // A context can be provided with a ProgramOption, but if none was provided - // we'll use the default background context. - if p.ctx == nil { - p.ctx = context.Background() - } + p.msgs = make(chan Msg) + p.rendererDone = make(chan struct{}) + p.keyboardc = make(chan struct{}) + p.modes = ansi.Modes{} + p.handlers = channelHandlers{} + p.errs = make(chan error, 1) + // Initialize context and teardown channel. - p.ctx, p.cancel = context.WithCancel(p.ctx) + p.ctx, p.cancel = context.WithCancel(context.Background()) // if no output was set, set it to stdout - if p.output == nil { - p.output = newSafeWriter(os.Stdout) + if p.Output == nil { + p.Output = os.Stdout + } + + if p.Input == nil { + p.Input = os.Stdin } // if no environment was set, set it to os.Environ() - if p.environ == nil { - p.environ = os.Environ() + if p.Env == nil { + p.Env = os.Environ() } - if p.fps < 1 { - p.fps = defaultFPS - } else if p.fps > maxFPS { - p.fps = maxFPS + if p.FPS < 1 { + p.FPS = defaultFPS + } else if p.FPS > maxFPS { + p.FPS = maxFPS } // Detect if tracing is enabled. if tracePath := os.Getenv("TEA_TRACE"); tracePath != "" { - switch tracePath { - case "0", "false", "off": - break - } + p.logger = log.New(os.Stderr, "bubbletea", log.LstdFlags|log.Lshortfile) - if _, err := LogToFile(tracePath, "bubbletea"); err == nil { + setTracing := func() { // Enable different types of tracing. if output, _ := strconv.ParseBool(os.Getenv("TEA_TRACE_OUTPUT")); output { - p.output.trace = true + p.traceOutput = true } if input, _ := strconv.ParseBool(os.Getenv("TEA_TRACE_INPUT")); input { p.traceInput = true } } + + switch strings.TrimSpace(tracePath) { + case "1", "true", "on": + setTracing() + case "0", "false", "off": + break + default: + abs, err := filepath.Abs(tracePath) + if err != nil { + break + } + if _, err := LogToFileWith(abs, "bubbletea", p.logger); err == nil { + setTracing() + } + } } - return p + atomic.StoreInt32(&p.initialized, 1) } -func (p *Program) handleSignals() chan struct{} { +func (p *Program[T]) handleSignals() chan struct{} { ch := make(chan struct{}) // Listen for SIGINT and SIGTERM. @@ -365,15 +394,13 @@ func (p *Program) handleSignals() chan struct{} { return case s := <-sig: - if atomic.LoadUint32(&p.ignoreSignals) == 0 { - switch s { - case syscall.SIGINT: - p.msgs <- InterruptMsg{} - default: - p.msgs <- QuitMsg{} - } - return + switch s { + case syscall.SIGINT: + p.msgs <- InterruptMsg{} + default: + p.msgs <- QuitMsg{} } + return } } }() @@ -382,7 +409,7 @@ func (p *Program) handleSignals() chan struct{} { } // handleResize handles terminal resize events. -func (p *Program) handleResize() chan struct{} { +func (p *Program[T]) handleResize() chan struct{} { ch := make(chan struct{}) if p.ttyOutput != nil { @@ -397,7 +424,7 @@ func (p *Program) handleResize() chan struct{} { // handleCommands runs commands in a goroutine and sends the result to the // program's message channel. -func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { +func (p *Program[T]) handleCommands(cmds chan Cmd) chan struct{} { ch := make(chan struct{}) go func() { @@ -420,7 +447,7 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { // until Cmd returns. go func() { // Recover from panics. - if !p.startupOptions.has(withoutCatchPanics) { + if p.DontCatchPanics { defer p.recoverFromPanic() } @@ -436,19 +463,16 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { // 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) { +func (p *Program[T]) eventLoop(cmds chan Cmd) { for { select { case <-p.ctx.Done(): - return model, nil - - case err := <-p.errs: - return model, err + return case msg := <-p.msgs: // Filter messages. - if p.filter != nil { - msg = p.filter(model, msg) + if p.Filter != nil { + msg = p.Filter(p.Model, msg) } if msg == nil { continue @@ -457,10 +481,11 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { // Handle special internal messages. switch msg := msg.(type) { case QuitMsg: - return model, nil + return case InterruptMsg: - return model, ErrInterrupted + go func() { p.errs <- ErrInterrupted }() + return case SuspendMsg: if suspendSupported { @@ -470,9 +495,9 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { case CapabilityMsg: switch msg { case "RGB", "Tc": - if p.profile != colorprofile.TrueColor { - p.profile = colorprofile.TrueColor - go p.Send(ColorProfileMsg{p.profile}) + if p.Profile != colorprofile.TrueColor { + p.Profile = colorprofile.TrueColor + go p.Send(ColorProfileMsg{p.Profile}) } } @@ -606,7 +631,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { } if p.activeEnhancements.modifyOtherKeys > 0 { - p.execute(ansi.DisableModifyOtherKeys) + p.execute(ansi.ResetModifyOtherKeys) p.activeEnhancements.modifyOtherKeys = 0 p.requestedEnhancements.modifyOtherKeys = 0 } @@ -621,7 +646,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.exec(msg.cmd, msg.fn) case terminalVersion: - p.execute(ansi.RequestXTVersion) + p.execute(ansi.RequestNameVersion) case requestCapabilityMsg: p.execute(ansi.RequestTermcap(string(msg))) @@ -674,7 +699,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { go p.checkResize() case requestCursorPosMsg: - p.execute(ansi.RequestCursorPosition) + p.execute(ansi.RequestCursorPositionReport) case RawMsg: p.execute(fmt.Sprint(msg.Msg)) @@ -693,10 +718,9 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { } var cmd Cmd - model, cmd = model.Update(msg) // run update - cmds <- cmd // process command (if any) - - view := model.View() + p.Model, cmd = p.Update(p.Model, msg) // run update + cmds <- cmd // process command (if any) + view := p.View(p.Model) switch view := view.(type) { case Frame: // Ensure we reset the cursor color on exit. @@ -713,80 +737,77 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { // Run initializes the program and runs its event loops, blocking until it gets // terminated by either [Program.Quit], [Program.Kill], or its signal handler. // Returns the final model. -func (p *Program) Run() (Model, error) { - p.handlers = channelHandlers{} - cmds := make(chan Cmd) - p.errs = make(chan error) - p.finished = make(chan struct{}, 1) - - defer p.cancel() - - switch p.inputType { - case defaultInput: - p.input = os.Stdin - - // 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.(term.File) - if !isFile { - break - } - if term.IsTerminal(f.Fd()) { - break - } +func (p *Program[T]) Run() error { + if err := p.Start(); err != nil { + return err + } + return p.Wait() +} - f, err := openInputTTY() - if err != nil { - return p.initialModel, err - } - defer f.Close() //nolint:errcheck - p.input = f +// Start initializes the program and starts its event loops. It returns an +// error if the program couldn't be started. +// Use [Program.Wait] to wait for the program to finish. Or use [Program.Run] +// to start and wait for the program to finish. +func (p *Program[T]) Start() error { + p.init() - case ttyInput: - // Open a new TTY, by request + if p.Init == nil { + return errors.New("no Init function set") + } + if p.Update == nil { + return errors.New("no Update function set") + } + if p.View == nil { + return errors.New("no View function set") + } + + cmds := make(chan Cmd) + + // 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. + if f, ok := p.Input.(term.File); !p.ForceInputTTY && (!ok || !term.IsTerminal(f.Fd())) { f, err := openInputTTY() if err != nil { - return p.initialModel, err + return err } - defer f.Close() //nolint:errcheck - p.input = f - - case customInput: - // (There is nothing extra to do.) + p.Input = f + p.ForceInputTTY = true } // Handle signals. - if !p.startupOptions.has(withoutSignalHandler) { + if !p.IgnoreSignals { p.handlers.add(p.handleSignals()) } // Recover from panics. - if !p.startupOptions.has(withoutCatchPanics) { + if !p.DontCatchPanics { defer p.recoverFromPanic() } // Check if output is a TTY before entering raw mode, hiding the cursor and // so on. if err := p.initTerminal(); err != nil { - return p.initialModel, err + return err } if p.renderer == nil { // If no renderer is set use the ferocious one. - p.renderer = newCursedRenderer(p.output, p.getenv("TERM"), p.useHardTabs) + output := p.Output + if p.traceOutput { + output = &traceWriter{Writer: output, logger: p.logger} + } + p.renderer = newCursedRenderer(output, p.getenv("TERM"), p.useHardTabs) } // Get the color profile and send it to the program. - if !p.startupOptions.has(withColorProfile) { - p.profile = colorprofile.Detect(p.output.Writer(), p.environ) + if p.Profile == colorprofile.NoTTY { + p.Profile = colorprofile.Detect(p.Output, p.Env) } // Set the color profile on the renderer and send it to the program. - p.renderer.setColorProfile(p.profile) - go p.Send(ColorProfileMsg{p.profile}) + p.renderer.setColorProfile(p.Profile) + go p.Send(ColorProfileMsg{p.Profile}) // Get the initial window size. resizeMsg := WindowSizeMsg{Width: p.width, Height: p.height} @@ -794,7 +815,7 @@ func (p *Program) Run() (Model, error) { // Set the initial size of the terminal. w, h, err := term.GetSize(p.ttyOutput.Fd()) if err != nil { - return p.initialModel, err + return err } resizeMsg.Width, resizeMsg.Height = w, h @@ -805,13 +826,12 @@ func (p *Program) Run() (Model, error) { p.renderer.resize(resizeMsg.Width, resizeMsg.Height) // Send the environment variables used by the program. - go p.Send(EnvMsg(p.environ)) + go p.Send(EnvMsg(p.Env)) // Init the input reader and initial model. - model := p.initialModel - if p.input != nil { + if p.Input != nil { if err := p.initInputReader(); err != nil { - return model, err + return err } } @@ -820,54 +840,16 @@ func (p *Program) Run() (Model, error) { p.modes.Reset(ansi.TextCursorEnableMode) p.renderer.hideCursor() - // Honor program startup options. - if p.startupTitle != "" { - p.execute(ansi.SetWindowTitle(p.startupTitle)) - } - if p.startupOptions&withAltScreen != 0 { - // Enter alternate screen mode. This is handled by the renderer so we - // don't need to write the sequence here. - p.modes.Set(ansi.AltScreenSaveCursorMode) - p.renderer.enterAltScreen() - } - if p.startupOptions&withoutBracketedPaste == 0 { - p.execute(ansi.SetBracketedPasteMode) - p.modes.Set(ansi.BracketedPasteMode) - } - if p.startupOptions&withGraphemeClustering != 0 { - p.execute(ansi.SetGraphemeClusteringMode) - p.execute(ansi.RequestGraphemeClusteringMode) - // We store the state of grapheme clustering after we query it and get - // a response in the eventLoop. - } - if p.startupOptions&withMouseCellMotion != 0 { - p.execute(ansi.SetButtonEventMouseMode + ansi.SetSgrExtMouseMode) - p.modes.Set(ansi.ButtonEventMouseMode, ansi.SgrExtMouseMode) - } else if p.startupOptions&withMouseAllMotion != 0 { - p.execute(ansi.SetAnyEventMouseMode + ansi.SetSgrExtMouseMode) - p.modes.Set(ansi.AnyEventMouseMode, ansi.SgrExtMouseMode) - } - - if p.startupOptions&withReportFocus != 0 { - p.execute(ansi.SetFocusEventMode) - p.modes.Set(ansi.FocusEventMode) - } - if p.startupOptions&withKeyboardEnhancements != 0 && runtime.GOOS != "windows" { - // We use the Windows Console API which supports keyboard - // enhancements. - p.requestKeyboardEnhancements() - - // Ensure we send a message so that terminals that don't support the - // requested features can disable them. - go p.sendKeyboardEnhancementsMsg() - } + // Always enable bracketed paste mode. + p.execute(ansi.SetBracketedPasteMode) + p.modes.Set(ansi.BracketedPasteMode) // Start the renderer. p.startRenderer() // Initialize the program. var initCmd Cmd - model, initCmd = model.Init() + p.Model, initCmd = p.Init() if initCmd != nil { ch := make(chan struct{}) p.handlers.add(ch) @@ -883,7 +865,7 @@ func (p *Program) Run() (Model, error) { } // Render the initial view. - p.renderer.render(model.View()) //nolint:errcheck + p.renderer.render(p.View(p.Model)) //nolint:errcheck // Handle resize events. p.handlers.add(p.handleResize()) @@ -891,21 +873,16 @@ func (p *Program) Run() (Model, error) { // Process commands. p.handlers.add(p.handleCommands(cmds)) - // Run event loop, handle updates and draw. - model, err := p.eventLoop(model, cmds) - killed := p.ctx.Err() != nil || err != nil - if killed && err == nil { - err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err()) - } - if err == nil { + go func() { + // Run event loop, handle updates and draw. + p.eventLoop(cmds) // Ensure we rendered the final state of the model. - p.renderer.render(model.View()) //nolint:errcheck - } - - // Restore terminal state. - p.shutdown(killed) + p.renderer.render(p.View(p.Model)) //nolint:errcheck + // Restore terminal state. + p.shutdown(atomic.LoadInt32(&p.killed) == 1) + }() - return model, err + return nil } // Send sends a message to the main update function, effectively allowing @@ -915,7 +892,10 @@ func (p *Program) Run() (Model, error) { // If the program hasn't started yet this will be a blocking operation. // If the program has already been terminated this will be a no-op, so it's safe // to send messages after the program has exited. -func (p *Program) Send(msg Msg) { +func (p *Program[T]) Send(msg Msg) { + for atomic.LoadInt32(&p.initialized) == 0 { + time.Sleep(10 * time.Millisecond) + } select { case <-p.ctx.Done(): case p.msgs <- msg: @@ -929,30 +909,47 @@ func (p *Program) Send(msg Msg) { // // If the program is not running this will be a no-op, so it's safe to call // if the program is unstarted or has already exited. -func (p *Program) Quit() { +func (p *Program[T]) Quit() { p.Send(Quit()) } // Kill stops the program immediately and restores the former terminal state. // The final render that you would normally see when quitting will be skipped. // [program.Run] returns a [ErrProgramKilled] error. -func (p *Program) Kill() { +func (p *Program[T]) Kill() { + atomic.StoreInt32(&p.killed, 1) p.shutdown(true) } // Wait waits/blocks until the underlying Program finished shutting down. -func (p *Program) Wait() { - <-p.finished +func (p *Program[T]) Wait() (lastErr error) { + defer func() { + // Restore terminal state. + p.shutdown(atomic.LoadInt32(&p.killed) == 1) + }() + + select { + case err := <-p.errs: + return err + case <-p.ctx.Done(): + if atomic.LoadInt32(&p.killed) == 1 { + return ErrProgramKilled + } + return nil + } } // execute writes the given sequence to the program output. -func (p *Program) execute(seq string) { - io.WriteString(p.output, seq) //nolint:errcheck +func (p *Program[T]) execute(seq string) { + if p.traceOutput && p.logger != nil { + p.logger.Printf("output: %q", seq) + } + io.WriteString(p.Output, seq) //nolint:errcheck } // shutdown performs operations to free up resources and restore the terminal // to its original state. -func (p *Program) shutdown(kill bool) { +func (p *Program[T]) shutdown(kill bool) { p.shutdownOnce.Do(func() { p.cancel() @@ -975,15 +972,12 @@ func (p *Program) shutdown(kill bool) { } _ = p.restoreTerminalState() - if !kill { - p.finished <- struct{}{} - } }) } // recoverFromPanic recovers from a panic, prints the stack trace, and restores // the terminal to a usable state. -func (p *Program) recoverFromPanic() { +func (p *Program[T]) recoverFromPanic() { if r := recover(); r != nil { p.shutdown(true) fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r) @@ -993,7 +987,7 @@ func (p *Program) recoverFromPanic() { // 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 { +func (p *Program[T]) ReleaseTerminal() error { atomic.StoreUint32(&p.ignoreSignals, 1) if p.inputReader != nil { p.inputReader.Cancel() @@ -1011,7 +1005,7 @@ func (p *Program) ReleaseTerminal() error { // RestoreTerminal reinitializes the Program's input reader, restores the // 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 { +func (p *Program[T]) RestoreTerminal() error { atomic.StoreUint32(&p.ignoreSignals, 0) if err := p.initTerminal(); err != nil { @@ -1030,7 +1024,7 @@ func (p *Program) RestoreTerminal() error { p.execute(ansi.SetBracketedPasteMode) } if p.activeEnhancements.modifyOtherKeys != 0 { - p.execute(ansi.ModifyOtherKeys(p.activeEnhancements.modifyOtherKeys)) + p.execute(ansi.SetKeyModifierOptions(4, p.activeEnhancements.modifyOtherKeys)) } if p.activeEnhancements.kittyFlags != 0 { p.execute(ansi.PushKittyKeyboard(p.activeEnhancements.kittyFlags)) @@ -1039,13 +1033,11 @@ func (p *Program) RestoreTerminal() error { p.execute(ansi.SetFocusEventMode) } if p.modes.IsSet(ansi.ButtonEventMouseMode) || p.modes.IsSet(ansi.AnyEventMouseMode) { - if p.startupOptions&withMouseCellMotion != 0 { - p.execute(ansi.SetButtonEventMouseMode) - p.execute(ansi.SetSgrExtMouseMode) - } else if p.startupOptions&withMouseAllMotion != 0 { - p.execute(ansi.SetAnyEventMouseMode) - p.execute(ansi.SetSgrExtMouseMode) - } + p.execute(ansi.SetButtonEventMouseMode) + p.execute(ansi.SetSgrExtMouseMode) + } else if p.modes.IsSet(ansi.AnyEventMouseMode) { + p.execute(ansi.SetAnyEventMouseMode) + p.execute(ansi.SetSgrExtMouseMode) } if p.modes.IsSet(ansi.GraphemeClusteringMode) { p.execute(ansi.SetGraphemeClusteringMode) @@ -1075,7 +1067,7 @@ func (p *Program) RestoreTerminal() error { // and will persist across renders by the Program. // // If the altscreen is active no output will be printed. -func (p *Program) Println(args ...interface{}) { +func (p *Program[T]) Println(args ...interface{}) { p.msgs <- printLineMessage{ messageBody: fmt.Sprint(args...), } @@ -1089,15 +1081,15 @@ func (p *Program) Println(args ...interface{}) { // its own line. // // If the altscreen is active no output will be printed. -func (p *Program) Printf(template string, args ...interface{}) { +func (p *Program[T]) Printf(template string, args ...interface{}) { p.msgs <- printLineMessage{ messageBody: fmt.Sprintf(template, args...), } } // startRenderer starts the renderer. -func (p *Program) startRenderer() { - framerate := time.Second / time.Duration(p.fps) +func (p *Program[T]) startRenderer() { + framerate := time.Second / time.Duration(p.FPS) if p.ticker == nil { p.ticker = time.NewTicker(framerate) } else { @@ -1131,7 +1123,7 @@ func (p *Program) startRenderer() { // stopRenderer stops the renderer. // If kill is true, the renderer will be stopped immediately without flushing // the last frame. -func (p *Program) stopRenderer(kill bool) { +func (p *Program[T]) stopRenderer(kill bool) { // Stop the renderer before acquiring the mutex to avoid a deadlock. p.once.Do(func() { p.rendererDone <- struct{}{} @@ -1148,7 +1140,7 @@ func (p *Program) stopRenderer(kill bool) { // sendKeyboardEnhancementsMsg sends a message with the active keyboard // enhancements to the program after a short timeout, or immediately if the // keyboard enhancements have been read from the terminal. -func (p *Program) sendKeyboardEnhancementsMsg() { +func (p *Program[T]) sendKeyboardEnhancementsMsg() { if runtime.GOOS == "windows" { // We use the Windows Console API which supports keyboard enhancements. p.Send(KeyboardEnhancementsMsg{}) @@ -1167,10 +1159,10 @@ func (p *Program) sendKeyboardEnhancementsMsg() { // requestKeyboardEnhancements tries to enable keyboard enhancements and read // the active keyboard enhancements from the terminal. -func (p *Program) requestKeyboardEnhancements() { +func (p *Program[T]) requestKeyboardEnhancements() { if p.requestedEnhancements.modifyOtherKeys > 0 { - p.execute(ansi.ModifyOtherKeys(p.requestedEnhancements.modifyOtherKeys)) - p.execute(ansi.RequestModifyOtherKeys) + p.execute(ansi.SetKeyModifierOptions(4, p.requestedEnhancements.modifyOtherKeys)) + p.execute(ansi.QueryModifyOtherKeys) } if p.requestedEnhancements.kittyFlags > 0 { p.execute(ansi.PushKittyKeyboard(p.requestedEnhancements.kittyFlags)) diff --git a/tea_test.go b/tea_test.go index 3477c80e9d..ff62ff7d2c 100644 --- a/tea_test.go +++ b/tea_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io" "sync/atomic" "testing" "time" @@ -17,11 +18,23 @@ type testModel struct { counter atomic.Value } -func (m *testModel) Init() (Model, Cmd) { - return m, nil +func newTestModel() (*testModel, Cmd) { + return &testModel{}, nil +} + +func newTestProgram(in io.Reader, out io.Writer) *Program[*testModel] { + p := Program[*testModel]{ + Init: newTestModel, + Update: (*testModel).Update, + View: (*testModel).View, + } + p.Input = in + p.Output = out + p.ForceInputTTY = true + return &p } -func (m *testModel) Update(msg Msg) (Model, Cmd) { +func (m *testModel) Update(msg Msg) (*testModel, Cmd) { switch msg.(type) { case incrementMsg: i := m.counter.Load() @@ -51,8 +64,12 @@ func TestTeaModel(t *testing.T) { ctx, cancel := context.WithTimeout(context.TODO(), 3*time.Second) defer cancel() - p := NewProgram(&testModel{}, WithInput(&in), WithOutput(&buf), WithContext(ctx)) - if _, err := p.Run(); err != nil { + p := newTestProgram(&in, &buf) + go func() { + <-ctx.Done() + p.Quit() + }() + if err := p.Run(); err != nil { t.Fatal(err) } @@ -65,19 +82,18 @@ func TestTeaQuit(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer - m := &testModel{} - p := NewProgram(m, WithInput(&in), WithOutput(&buf)) + p := newTestProgram(&in, &buf) go func() { for { time.Sleep(time.Millisecond) - if m.executed.Load() != nil { + if p.Model != nil && p.Model.executed.Load() != nil { p.Quit() return } } }() - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { t.Fatal(err) } } @@ -92,21 +108,18 @@ func testTeaWithFilter(t *testing.T, preventCount uint32) { var buf bytes.Buffer var in bytes.Buffer - m := &testModel{} shutdowns := uint32(0) - p := NewProgram(m, - WithInput(&in), - WithOutput(&buf), - WithFilter(func(_ Model, msg Msg) Msg { - if _, ok := msg.(QuitMsg); !ok { - return msg - } - if shutdowns < preventCount { - atomic.AddUint32(&shutdowns, 1) - return nil - } + p := newTestProgram(&in, &buf) + p.Filter = func(_ *testModel, msg Msg) Msg { + if _, ok := msg.(QuitMsg); !ok { return msg - })) + } + if shutdowns < preventCount { + atomic.AddUint32(&shutdowns, 1) + return nil + } + return msg + } go func() { for atomic.LoadUint32(&shutdowns) <= preventCount { @@ -115,7 +128,7 @@ func testTeaWithFilter(t *testing.T, preventCount uint32) { } }() - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { t.Fatal(err) } if shutdowns != preventCount { @@ -127,19 +140,18 @@ func TestTeaKill(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer - m := &testModel{} - p := NewProgram(m, WithInput(&in), WithOutput(&buf)) + p := newTestProgram(&in, &buf) go func() { for { time.Sleep(time.Millisecond) - if m.executed.Load() != nil { + if p.Model != nil && p.Model.executed.Load() != nil { p.Kill() return } } }() - if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) { + if err := p.Run(); !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } } @@ -149,19 +161,22 @@ func TestTeaContext(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer - m := &testModel{} - p := NewProgram(m, WithContext(ctx), WithInput(&in), WithOutput(&buf)) + p := newTestProgram(&in, &buf) + go func() { + <-ctx.Done() + p.Kill() + }() go func() { for { time.Sleep(time.Millisecond) - if m.executed.Load() != nil { + if p.Model != nil && p.Model.executed.Load() != nil { cancel() return } } }() - if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) { + if err := p.Run(); !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } } @@ -174,14 +189,13 @@ func TestTeaBatchMsg(t *testing.T) { return incrementMsg{} } - m := &testModel{} - p := NewProgram(m, WithInput(&in), WithOutput(&buf)) + p := newTestProgram(&in, &buf) go func() { p.Send(BatchMsg{inc, inc}) for { time.Sleep(time.Millisecond) - i := m.counter.Load() + i := p.Model.counter.Load() if i != nil && i.(int) >= 2 { p.Quit() return @@ -189,12 +203,12 @@ func TestTeaBatchMsg(t *testing.T) { } }() - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { t.Fatal(err) } - if m.counter.Load() != 2 { - t.Fatalf("counter should be 2, got %d", m.counter.Load()) + if p.Model.counter.Load() != 2 { + t.Fatalf("counter should be 2, got %d", p.Model.counter.Load()) } } @@ -206,16 +220,15 @@ func TestTeaSequenceMsg(t *testing.T) { return incrementMsg{} } - m := &testModel{} - p := NewProgram(m, WithInput(&in), WithOutput(&buf)) + p := newTestProgram(&in, &buf) go p.Send(sequenceMsg{inc, inc, Quit}) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { t.Fatal(err) } - if m.counter.Load() != 2 { - t.Fatalf("counter should be 2, got %d", m.counter.Load()) + if p.Model.counter.Load() != 2 { + t.Fatalf("counter should be 2, got %d", p.Model.counter.Load()) } } @@ -230,16 +243,15 @@ func TestTeaSequenceMsgWithBatchMsg(t *testing.T) { return BatchMsg{inc, inc} } - m := &testModel{} - p := NewProgram(m, WithInput(&in), WithOutput(&buf)) + p := newTestProgram(&in, &buf) go p.Send(sequenceMsg{batch, inc, Quit}) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { t.Fatal(err) } - if m.counter.Load() != 3 { - t.Fatalf("counter should be 3, got %d", m.counter.Load()) + if p.Model.counter.Load() != 3 { + t.Fatalf("counter should be 3, got %d", p.Model.counter.Load()) } } @@ -247,13 +259,12 @@ func TestTeaSend(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer - m := &testModel{} - p := NewProgram(m, WithInput(&in), WithOutput(&buf)) + p := newTestProgram(&in, &buf) // sending before the program is started is a blocking operation go p.Send(Quit()) - if _, err := p.Run(); err != nil { + if err := p.Run(); err != nil { t.Fatal(err) } @@ -265,6 +276,5 @@ func TestTeaNoRun(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer - m := &testModel{} - NewProgram(m, WithInput(&in), WithOutput(&buf)) + _ = newTestProgram(&in, &buf) } diff --git a/trace.go b/trace.go new file mode 100644 index 0000000000..abb67e868a --- /dev/null +++ b/trace.go @@ -0,0 +1,20 @@ +package tea + +import ( + "io" + "log" +) + +// traceWriter is a writer that logs writes to an underlying writer. +type traceWriter struct { + io.Writer + logger *log.Logger +} + +// Write writes to the underlying writer and logs the write if tracing is enabled. +func (w *traceWriter) Write(p []byte) (n int, err error) { + if w.logger != nil { + w.logger.Printf("output: %q", p) + } + return w.Writer.Write(p) +} diff --git a/tty.go b/tty.go index 427a315552..8782f59afd 100644 --- a/tty.go +++ b/tty.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "io" - "log" "time" "github.com/charmbracelet/x/ansi" @@ -13,7 +12,7 @@ import ( "github.com/muesli/cancelreader" ) -func (p *Program) suspend() { +func (p *Program[T]) suspend() { if err := p.ReleaseTerminal(); err != nil { // If we can't release input, abort. return @@ -25,7 +24,7 @@ func (p *Program) suspend() { go p.Send(ResumeMsg{}) } -func (p *Program) initTerminal() error { +func (p *Program[T]) initTerminal() error { if _, ok := p.renderer.(*nilRenderer); ok { // No need to initialize the terminal if we're not rendering return nil @@ -36,7 +35,7 @@ func (p *Program) initTerminal() error { // restoreTerminalState restores the terminal to the state prior to running the // Bubble Tea program. -func (p *Program) restoreTerminalState() error { +func (p *Program[T]) restoreTerminalState() error { // We don't need to reset [ansi.AltScreenSaveCursorMode] and // [ansi.TextCursorEnableMode] because they are automatically reset when we // close the renderer. See [screenRenderer.close] and @@ -58,7 +57,7 @@ func (p *Program) restoreTerminalState() error { p.execute(ansi.ResetSgrExtMouseMode) } if p.activeEnhancements.modifyOtherKeys != 0 { - p.execute(ansi.DisableModifyOtherKeys) + p.execute(ansi.ResetModifyOtherKeys) } if p.activeEnhancements.kittyFlags != 0 { p.execute(ansi.DisableKittyKeyboard) @@ -85,7 +84,7 @@ func (p *Program) restoreTerminalState() error { } // restoreInput restores the tty input to its original state. -func (p *Program) restoreInput() error { +func (p *Program[T]) restoreInput() error { if p.ttyInput != nil && p.previousTtyInputState != nil { if err := term.Restore(p.ttyInput.Fd(), p.previousTtyInputState); err != nil { return fmt.Errorf("error restoring console: %w", err) @@ -100,7 +99,7 @@ func (p *Program) restoreInput() error { } // initInputReader (re)commences reading inputs. -func (p *Program) initInputReader() error { +func (p *Program[T]) initInputReader() error { term := p.getenv("TERM") // Initialize the input reader. @@ -109,13 +108,13 @@ func (p *Program) initInputReader() error { // On Windows, this will change the console mode to enable mouse and window // events. var flags int // TODO: make configurable through environment variables? - drv, err := input.NewReader(p.input, term, flags) + drv, err := input.NewReader(p.Input, term, flags) if err != nil { return err } if p.traceInput { - drv.SetLogger(log.Default()) + drv.SetLogger(p.logger) } p.inputReader = drv p.readLoopDone = make(chan struct{}) @@ -124,7 +123,7 @@ func (p *Program) initInputReader() error { return nil } -func (p *Program) readInputs() error { +func (p *Program[T]) readInputs() error { for { events, err := p.inputReader.ReadEvents() if err != nil { @@ -147,7 +146,7 @@ func (p *Program) readInputs() error { } } -func (p *Program) readLoop() { +func (p *Program[T]) readLoop() { defer close(p.readLoopDone) err := p.readInputs() @@ -160,7 +159,7 @@ func (p *Program) readLoop() { } // waitForReadLoop waits for the cancelReader to finish its read loop. -func (p *Program) waitForReadLoop() { +func (p *Program[T]) waitForReadLoop() { select { case <-p.readLoopDone: case <-time.After(500 * time.Millisecond): //nolint:gomnd @@ -172,7 +171,7 @@ func (p *Program) waitForReadLoop() { // checkResize detects the current size of the output and informs the program // via a WindowSizeMsg. -func (p *Program) checkResize() { +func (p *Program[T]) checkResize() { if p.ttyOutput == nil { // can't query window size return diff --git a/tty_unix.go b/tty_unix.go index 6134bbcb8d..24ef8f4ad8 100644 --- a/tty_unix.go +++ b/tty_unix.go @@ -13,9 +13,9 @@ import ( "golang.org/x/sys/unix" ) -func (p *Program) initInput() (err error) { +func (p *Program[T]) initInput() (err error) { // Check if input is a terminal - if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { + if f, ok := p.Input.(term.File); ok && term.IsTerminal(f.Fd()) { p.ttyInput = f p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) if err != nil { @@ -27,7 +27,7 @@ func (p *Program) initInput() (err error) { p.useHardTabs = p.previousTtyInputState.Oflag&unix.TABDLY == 0 } - if f, ok := p.output.Writer().(term.File); ok && term.IsTerminal(f.Fd()) { + if f, ok := p.Output.(term.File); ok && term.IsTerminal(f.Fd()) { p.ttyOutput = f } diff --git a/tty_windows.go b/tty_windows.go index 235d99c4a6..2d87292ded 100644 --- a/tty_windows.go +++ b/tty_windows.go @@ -11,11 +11,11 @@ import ( "golang.org/x/sys/windows" ) -func (p *Program) initInput() (err error) { +func (p *Program[T]) initInput() (err error) { // Save stdin state and enable VT input // We also need to enable VT // input here. - if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { + if f, ok := p.Input.(term.File); ok && term.IsTerminal(f.Fd()) { p.ttyInput = f p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) if err != nil { @@ -34,7 +34,7 @@ func (p *Program) initInput() (err error) { } // Save output screen buffer state and enable VT processing. - if f, ok := p.output.Writer().(term.File); ok && term.IsTerminal(f.Fd()) { + if f, ok := p.Output.(term.File); ok && term.IsTerminal(f.Fd()) { p.ttyOutput = f p.previousOutputState, err = term.GetState(f.Fd()) if err != nil {