Repository: charmbracelet/bubbletea (go)

Issue:
Tea and termenv do not honor WithOutput properly
There's a mismatch in API semantics:

- termenv relies on the output going to `os.Stdout`
- bubbletea makes this configurable (via `WithOutput`)

When bubbletea `WithOutput` is used with a different file, `termenv` does not recognize color profiles properly any more.

Generally, the mistake being made is the assumption that stdout is the file descriptor connected to the terminal. *generally*, the terminal is to be found on stdin, not stdout (and neither stderr!)

This mismatch is the cause of the following issues:
- https://github.com/charmbracelet/bubbletea/issues/172
- https://github.com/charmbracelet/bubbletea/issues/206
- https://github.com/charmbracelet/bubbletea/issues/302
- https://github.com/charmbracelet/bubbletea/discussions/386

I understand that the "next generation termenv" at https://github.com/muesli/termenv/tree/termenv-next would help with this, but I don't seem to see a timeline to get this moving forward?

Pull request:
feat: use Termenv.Output to write to tty
- Uses a `termenv.Output` to manage writing to outputs
- Enables us to use arbitrary output devices with color detection
- Prevents concurrent output access from lipgloss (depending on its `next` branch, to be merged) 
- Removes code duplication (ANSI screen handling, Windows console) with termenv

Fixes #420, #172, #206, #302.

Patch:
diff --git a/README.md b/README.md
index 4ee2f15..65edc9f 100644
--- a/README.md
+++ b/README.md
@@ -338,6 +338,7 @@ For some Bubble Tea programs in production, see:
 * [gitflow-toolkit](https://github.com/mritd/gitflow-toolkit): a GitFlow submission tool
 * [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser and online markdown stash
 * [gocovsh](https://github.com/orlangure/gocovsh): explore Go coverage reports from the CLI
+* [got](https://github.com/fedeztk/got): a simple translator and text-to-speech app build on top of simplytranslate's APIs
 * [httpit](https://github.com/gonetx/httpit): a rapid http(s) benchmark tool
 * [IDNT](https://github.com/r-darwish/idnt): batch software uninstaller
 * [kboard](https://github.com/CamiloGarciaLaRotta/kboard): a typing game
diff --git a/examples/glamour/main.go b/examples/glamour/main.go
index dacf8f9..3b38911 100644
--- a/examples/glamour/main.go
+++ b/examples/glamour/main.go
@@ -11,7 +11,9 @@ import (
 )
 
 const content = `
-Today’s Menu
+# Today’s Menu
+
+## Appetizers
 
 | Name        | Price | Notes                           |
 | ---         | ---   | ---                             |
@@ -20,7 +22,7 @@ Today’s Menu
 | Okonomiyaki | $4    | Takes a few minutes to make     |
 | Curry       | $3    | We can add squash if you’d like |
 
-Seasonal Dishes
+## Seasonal Dishes
 
 | Name                 | Price | Notes              |
 | ---                  | ---   | ---                |
@@ -28,7 +30,7 @@ Seasonal Dishes
 | Takoyaki             | $3    | Fun to eat         |
 | Winter squash        | $3    | Today it's pumpkin |
 
-Desserts
+## Desserts
 
 | Name         | Price | Notes                 |
 | ---          | ---   | ---                   |
@@ -64,7 +66,7 @@ func newExample() (*example, error) {
 		PaddingRight(2)
 
 	renderer, err := glamour.NewTermRenderer(
-		glamour.WithStylePath("notty"),
+		glamour.WithAutoStyle(),
 		glamour.WithWordWrap(width),
 	)
 	if err != nil {
diff --git a/examples/go.mod b/examples/go.mod
index 4e68c8b..042b22f 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/lucasb-eyer/go-colorful v1.2.0
 	github.com/mattn/go-isatty v0.0.16
 	github.com/muesli/reflow v0.3.0
-	github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898
+	github.com/muesli/termenv v0.13.0
 )
 
 replace github.com/charmbracelet/bubbletea => ../
diff --git a/examples/go.sum b/examples/go.sum
index 8b5d157..65731b4 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -39,8 +39,9 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
 github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
@@ -53,8 +54,8 @@ github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKt
 github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
 github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
 github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
-github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898 h1:0j+cbZdhLgpNxjg0nWCasHUA82fgWOXxxGgWNVOLS1I=
-github.com/muesli/termenv v0.12.1-0.20220615005108-4e9068de9898/go.mod h1:bN6sPNtkiahdhHv2Xm6RGU16LSCxfbIZvMfqjOCfrR4=
+github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
+github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -81,7 +82,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -91,7 +91,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/go.mod b/go.mod
index fedb6b0..d3a0c97 100644
--- a/go.mod
+++ b/go.mod
@@ -9,8 +9,7 @@ require (
 	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b
 	github.com/muesli/cancelreader v0.2.2
 	github.com/muesli/reflow v0.3.0
-	github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
-	golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab
+	github.com/muesli/termenv v0.13.0
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
 	golang.org/x/text v0.3.7 // indirect
 )
diff --git a/go.sum b/go.sum
index 270f8f5..76e0059 100644
--- a/go.sum
+++ b/go.sum
@@ -1,29 +1,30 @@
+github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
-github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
-github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
+github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/options.go b/options.go
index b60d23c..6ede540 100644
--- a/options.go
+++ b/options.go
@@ -1,6 +1,10 @@
 package tea
 
-import "io"
+import (
+	"io"
+
+	"github.com/muesli/termenv"
+)
 
 // ProgramOption is used to set options when initializing a Program. Program can
 // accept a variable number of options.
@@ -13,17 +17,17 @@ type ProgramOption func(*Program)
 // 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(m *Program) {
-		m.output = output
+	return func(p *Program) {
+		p.output = termenv.NewOutput(output, termenv.WithColorCache(true))
 	}
 }
 
 // WithInput sets the input which, by default, is stdin. In most cases you
 // won't need to use this.
 func WithInput(input io.Reader) ProgramOption {
-	return func(m *Program) {
-		m.input = input
-		m.startupOptions |= withCustomInput
+	return func(p *Program) {
+		p.input = input
+		p.startupOptions |= withCustomInput
 	}
 }
 
@@ -39,8 +43,8 @@ func WithInputTTY() ProgramOption {
 // unusable state after a panic because Bubble Tea will not perform its usual
 // cleanup on exit.
 func WithoutCatchPanics() ProgramOption {
-	return func(m *Program) {
-		m.CatchPanics = false
+	return func(p *Program) {
+		p.CatchPanics = false
 	}
 }
 
@@ -114,8 +118,8 @@ func WithMouseAllMotion() ProgramOption {
 // programs. For example, your program could behave like a daemon if output is
 // not a TTY.
 func WithoutRenderer() ProgramOption {
-	return func(m *Program) {
-		m.renderer = &nilRenderer{}
+	return func(p *Program) {
+		p.renderer = &nilRenderer{}
 	}
 }
 
diff --git a/screen.go b/screen.go
deleted file mode 100644
index 63db7ae..0000000
--- a/screen.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package tea
-
-import (
-	"fmt"
-	"io"
-
-	te "github.com/muesli/termenv"
-)
-
-func hideCursor(w io.Writer) {
-	fmt.Fprintf(w, te.CSI+te.HideCursorSeq)
-}
-
-func showCursor(w io.Writer) {
-	fmt.Fprintf(w, te.CSI+te.ShowCursorSeq)
-}
-
-func clearLine(w io.Writer) {
-	fmt.Fprintf(w, te.CSI+te.EraseLineSeq, 2)
-}
-
-func cursorUp(w io.Writer) {
-	fmt.Fprintf(w, te.CSI+te.CursorUpSeq, 1)
-}
-
-func cursorDown(w io.Writer) {
-	fmt.Fprintf(w, te.CSI+te.CursorDownSeq, 1)
-}
-
-func insertLine(w io.Writer, numLines int) {
-	fmt.Fprintf(w, te.CSI+"%dL", numLines)
-}
-
-func moveCursor(w io.Writer, row, col int) {
-	fmt.Fprintf(w, te.CSI+te.CursorPositionSeq, row, col)
-}
-
-func changeScrollingRegion(w io.Writer, top, bottom int) {
-	fmt.Fprintf(w, te.CSI+te.ChangeScrollingRegionSeq, top, bottom)
-}
-
-func cursorBack(w io.Writer, n int) {
-	fmt.Fprintf(w, te.CSI+te.CursorBackSeq, n)
-}
-
-func enterAltScreen(w io.Writer) {
-	fmt.Fprintf(w, te.CSI+te.AltScreenSeq)
-	moveCursor(w, 0, 0)
-}
-
-func exitAltScreen(w io.Writer) {
-	fmt.Fprintf(w, te.CSI+te.ExitAltScreenSeq)
-}
diff --git a/standard_renderer.go b/standard_renderer.go
index d2bc387..6c1c50b 100644
--- a/standard_renderer.go
+++ b/standard_renderer.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/muesli/ansi/compressor"
 	"github.com/muesli/reflow/truncate"
+	"github.com/muesli/termenv"
 )
 
 const (
@@ -24,7 +25,7 @@ const (
 // In cases where very high performance is needed the renderer can be told
 // to exclude ranges of lines, allowing them to be written to directly.
 type standardRenderer struct {
-	out                io.Writer
+	out                *termenv.Output
 	buf                bytes.Buffer
 	queuedMessageLines []string
 	framerate          time.Duration
@@ -49,7 +50,7 @@ type standardRenderer struct {
 
 // newRenderer creates a new renderer. Normally you'll want to initialize it
 // with os.Stdout as the first argument.
-func newRenderer(out io.Writer, mtx *sync.Mutex, useANSICompressor bool) renderer {
+func newRenderer(out *termenv.Output, mtx *sync.Mutex, useANSICompressor bool) renderer {
 	r := &standardRenderer{
 		out:                out,
 		mtx:                mtx,
@@ -58,7 +59,7 @@ func newRenderer(out io.Writer, mtx *sync.Mutex, useANSICompressor bool) rendere
 		queuedMessageLines: []string{},
 	}
 	if r.useANSICompressor {
-		r.out = &compressor.Writer{Forward: out}
+		r.out = termenv.NewOutput(&compressor.Writer{Forward: out})
 	}
 	return r
 }
@@ -75,13 +76,13 @@ func (r *standardRenderer) start() {
 // stop permanently halts the renderer, rendering the final frame.
 func (r *standardRenderer) stop() {
 	r.flush()
-	clearLine(r.out)
+	r.out.ClearLine()
 	r.once.Do(func() {
 		close(r.done)
 	})
 
 	if r.useANSICompressor {
-		if w, ok := r.out.(io.WriteCloser); ok {
+		if w, ok := r.out.TTY().(io.WriteCloser); ok {
 			_ = w.Close()
 		}
 	}
@@ -89,7 +90,7 @@ func (r *standardRenderer) stop() {
 
 // kill halts the renderer. The final frame will not be rendered.
 func (r *standardRenderer) kill() {
-	clearLine(r.out)
+	r.out.ClearLine()
 	r.once.Do(func() {
 		close(r.done)
 	})
@@ -122,7 +123,8 @@ func (r *standardRenderer) flush() {
 	}
 
 	// Output buffer
-	out := new(bytes.Buffer)
+	buf := &bytes.Buffer{}
+	out := termenv.NewOutput(buf)
 
 	newLines := strings.Split(r.buf.String(), "\n")
 	numLinesThisFlush := len(newLines)
@@ -145,10 +147,10 @@ func (r *standardRenderer) flush() {
 			if (len(newLines) <= len(oldLines)) && (len(newLines) > i && len(oldLines) > i) && (newLines[i] == oldLines[i]) {
 				skipLines[i] = struct{}{}
 			} else if _, exists := r.ignoreLines[i]; !exists {
-				clearLine(out)
+				out.ClearLine()
 			}
 
-			cursorUp(out)
+			out.CursorUp(1)
 		}
 
 		if _, exists := r.ignoreLines[0]; !exists {
@@ -161,8 +163,8 @@ func (r *standardRenderer) flush() {
 			// standard (whereas others are proprietary to, say, VT100/VT52).
 			// If cursor previous line (ESC[ + <n> + F) were better supported
 			// we could use that above to eliminate this step.
-			cursorBack(out, r.width)
-			clearLine(out)
+			out.CursorBack(r.width)
+			out.ClearLine()
 		}
 	}
 
@@ -179,7 +181,7 @@ func (r *standardRenderer) flush() {
 		if _, skip := skipLines[i]; skip {
 			// Unless this is the last line, move the cursor down.
 			if i < len(newLines)-1 {
-				cursorDown(out)
+				out.CursorDown(1)
 			}
 		} else {
 			line := newLines[i]
@@ -195,10 +197,10 @@ func (r *standardRenderer) flush() {
 				line = truncate.String(line, uint(r.width))
 			}
 
-			_, _ = io.WriteString(out, line)
+			_, _ = out.WriteString(line)
 
 			if i < len(newLines)-1 {
-				_, _ = io.WriteString(out, "\r\n")
+				_, _ = out.WriteString("\r\n")
 			}
 		}
 	}
@@ -210,12 +212,12 @@ func (r *standardRenderer) flush() {
 		// This case fixes a bug in macOS terminal. In other terminals the
 		// other case seems to do the job regardless of whether or not we're
 		// using the full terminal window.
-		moveCursor(out, r.linesRendered, 0)
+		out.MoveCursor(r.linesRendered, 0)
 	} else {
-		cursorBack(out, r.width)
+		out.CursorBack(r.width)
 	}
 
-	_, _ = r.out.Write(out.Bytes())
+	_, _ = r.out.Write(buf.Bytes())
 	r.lastRender = r.buf.String()
 	r.buf.Reset()
 }
@@ -270,15 +272,17 @@ func (r *standardRenderer) setIgnoredLines(from int, to int) {
 
 	// Erase ignored lines
 	if r.linesRendered > 0 {
-		out := new(bytes.Buffer)
+		buf := &bytes.Buffer{}
+		out := termenv.NewOutput(buf)
+
 		for i := r.linesRendered - 1; i >= 0; i-- {
 			if _, exists := r.ignoreLines[i]; exists {
-				clearLine(out)
+				out.ClearLine()
 			}
-			cursorUp(out)
+			out.CursorUp(1)
 		}
-		moveCursor(out, r.linesRendered, 0) // put cursor back
-		_, _ = r.out.Write(out.Bytes())
+		out.MoveCursor(r.linesRendered, 0) // put cursor back
+		_, _ = r.out.Write(buf.Bytes())
 	}
 }
 
@@ -311,18 +315,19 @@ func (r *standardRenderer) insertTop(lines []string, topBoundary, bottomBoundary
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	b := new(bytes.Buffer)
+	buf := &bytes.Buffer{}
+	out := termenv.NewOutput(buf)
 
-	changeScrollingRegion(b, topBoundary, bottomBoundary)
-	moveCursor(b, topBoundary, 0)
-	insertLine(b, len(lines))
-	_, _ = io.WriteString(b, strings.Join(lines, "\r\n"))
-	changeScrollingRegion(b, 0, r.height)
+	out.ChangeScrollingRegion(topBoundary, bottomBoundary)
+	out.MoveCursor(topBoundary, 0)
+	out.InsertLines(len(lines))
+	_, _ = out.WriteString(strings.Join(lines, "\r\n"))
+	out.ChangeScrollingRegion(0, r.height)
 
 	// Move cursor back to where the main rendering routine expects it to be
-	moveCursor(b, r.linesRendered, 0)
+	out.MoveCursor(r.linesRendered, 0)
 
-	_, _ = r.out.Write(b.Bytes())
+	_, _ = r.out.Write(buf.Bytes())
 }
 
 // insertBottom effectively scrolls down. It inserts lines at the bottom of
@@ -338,17 +343,18 @@ func (r *standardRenderer) insertBottom(lines []string, topBoundary, bottomBound
 	r.mtx.Lock()
 	defer r.mtx.Unlock()
 
-	b := new(bytes.Buffer)
+	buf := &bytes.Buffer{}
+	out := termenv.NewOutput(buf)
 
-	changeScrollingRegion(b, topBoundary, bottomBoundary)
-	moveCursor(b, bottomBoundary, 0)
-	_, _ = io.WriteString(b, "\r\n"+strings.Join(lines, "\r\n"))
-	changeScrollingRegion(b, 0, r.height)
+	out.ChangeScrollingRegion(topBoundary, bottomBoundary)
+	out.MoveCursor(bottomBoundary, 0)
+	_, _ = out.WriteString("\r\n" + strings.Join(lines, "\r\n"))
+	out.ChangeScrollingRegion(0, r.height)
 
 	// Move cursor back to where the main rendering routine expects it to be
-	moveCursor(b, r.linesRendered, 0)
+	out.MoveCursor(r.linesRendered, 0)
 
-	_, _ = r.out.Write(b.Bytes())
+	_, _ = r.out.Write(buf.Bytes())
 }
 
 // handleMessages handles internal messages for the renderer.
diff --git a/tea.go b/tea.go
index 547e011..f25d55f 100644
--- a/tea.go
+++ b/tea.go
@@ -23,7 +23,7 @@ import (
 	"github.com/containerd/console"
 	isatty "github.com/mattn/go-isatty"
 	"github.com/muesli/cancelreader"
-	te "github.com/muesli/termenv"
+	"github.com/muesli/termenv"
 	"golang.org/x/term"
 )
 
@@ -89,9 +89,10 @@ type Program struct {
 	errs         chan error
 	readLoopDone chan struct{}
 
-	output       io.Writer // where to send output. this will usually be os.Stdout.
-	input        io.Reader // this will usually be os.Stdin.
-	cancelReader cancelreader.CancelReader
+	output        *termenv.Output // where to send output. this will usually be os.Stdout.
+	restoreOutput func() error
+	input         io.Reader // this will usually be os.Stdin.
+	cancelReader  cancelreader.CancelReader
 
 	renderer           renderer
 	altScreenActive    bool
@@ -253,7 +254,6 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 	p := &Program{
 		mtx:          &sync.Mutex{},
 		initialModel: model,
-		output:       os.Stdout,
 		input:        os.Stdin,
 		msgs:         make(chan Msg),
 		CatchPanics:  true,
@@ -265,6 +265,16 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
 		opt(p)
 	}
 
+	// if no output was set, set it to stdout
+	if p.output == nil {
+		p.output = termenv.DefaultOutput()
+
+		// cache detected color values
+		termenv.WithColorCache(true)(p.output)
+	}
+
+	p.restoreOutput, _ = termenv.EnableVirtualTerminalProcessing(p.output)
+
 	return p
 }
 
@@ -429,7 +439,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 	}
 	defer p.cancelReader.Close() // nolint:errcheck
 
-	if f, ok := p.output.(*os.File); ok && isatty.IsTerminal(f.Fd()) {
+	if f, ok := p.output.TTY().(*os.File); ok && isatty.IsTerminal(f.Fd()) {
 		// Get the initial terminal size and send it to the program.
 		go func() {
 			w, h, err := term.GetSize(int(f.Fd()))
@@ -527,7 +537,7 @@ func (p *Program) StartReturningModel() (Model, error) {
 				p.DisableMouseAllMotion()
 
 			case hideCursorMsg:
-				hideCursor(p.output)
+				p.output.HideCursor()
 
 			case execMsg:
 				// NB: this blocks.
@@ -606,6 +616,10 @@ func (p *Program) shutdown(kill bool) {
 	p.DisableMouseCellMotion()
 	p.DisableMouseAllMotion()
 	_ = p.restoreTerminalState()
+
+	if p.restoreOutput != nil {
+		_ = p.restoreOutput()
+	}
 }
 
 // EnterAltScreen enters the alternate screen buffer, which consumes the entire
@@ -620,7 +634,8 @@ func (p *Program) EnterAltScreen() {
 		return
 	}
 
-	enterAltScreen(p.output)
+	p.output.AltScreen()
+	p.output.MoveCursor(0, 0)
 
 	p.altScreenActive = true
 	if p.renderer != nil {
@@ -639,7 +654,7 @@ func (p *Program) ExitAltScreen() {
 		return
 	}
 
-	exitAltScreen(p.output)
+	p.output.ExitAltScreen()
 
 	p.altScreenActive = false
 	if p.renderer != nil {
@@ -654,7 +669,8 @@ func (p *Program) ExitAltScreen() {
 func (p *Program) EnableMouseCellMotion() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
-	fmt.Fprintf(p.output, te.CSI+te.EnableMouseCellMotionSeq)
+
+	p.output.EnableMouseCellMotion()
 }
 
 // DisableMouseCellMotion disables Mouse Cell Motion tracking. This will be
@@ -664,7 +680,8 @@ func (p *Program) EnableMouseCellMotion() {
 func (p *Program) DisableMouseCellMotion() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
-	fmt.Fprintf(p.output, te.CSI+te.DisableMouseCellMotionSeq)
+
+	p.output.DisableMouseCellMotion()
 }
 
 // EnableMouseAllMotion enables mouse click, release, wheel and motion events,
@@ -675,7 +692,8 @@ func (p *Program) DisableMouseCellMotion() {
 func (p *Program) EnableMouseAllMotion() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
-	fmt.Fprintf(p.output, te.CSI+te.EnableMouseAllMotionSeq)
+
+	p.output.EnableMouseAllMotion()
 }
 
 // DisableMouseAllMotion disables All Motion mouse tracking. This will be
@@ -685,7 +703,8 @@ func (p *Program) EnableMouseAllMotion() {
 func (p *Program) DisableMouseAllMotion() {
 	p.mtx.Lock()
 	defer p.mtx.Unlock()
-	fmt.Fprintf(p.output, te.CSI+te.DisableMouseAllMotionSeq)
+
+	p.output.DisableMouseAllMotion()
 }
 
 // ReleaseTerminal restores the original terminal state and cancels the input
diff --git a/tty.go b/tty.go
index 17e508b..e4f75cf 100644
--- a/tty.go
+++ b/tty.go
@@ -20,14 +20,14 @@ func (p *Program) initTerminal() error {
 		}
 	}
 
-	hideCursor(p.output)
+	p.output.HideCursor()
 	return nil
 }
 
 // restoreTerminalState restores the terminal to the state prior to running the
 // Bubble Tea program.
 func (p Program) restoreTerminalState() error {
-	showCursor(p.output)
+	p.output.ShowCursor()
 
 	if p.console != nil {
 		err := p.console.Reset()
diff --git a/tty_windows.go b/tty_windows.go
index 9cfc7d9..be415ae 100644
--- a/tty_windows.go
+++ b/tty_windows.go
@@ -4,11 +4,9 @@
 package tea
 
 import (
-	"io"
 	"os"
 
 	"github.com/containerd/console"
-	"golang.org/x/sys/windows"
 )
 
 func (p *Program) initInput() error {
@@ -26,8 +24,6 @@ func (p *Program) initInput() error {
 		p.console = c
 	}
 
-	enableAnsiColors(p.output)
-
 	return nil
 }
 
@@ -49,18 +45,3 @@ func openInputTTY() (*os.File, error) {
 	}
 	return f, nil
 }
-
-// enableAnsiColors enables support for ANSI color sequences in Windows
-// default console. Note that this only works with Windows 10.
-func enableAnsiColors(w io.Writer) {
-	f, ok := w.(*os.File)
-	if !ok {
-		return
-	}
-
-	stdout := windows.Handle(f.Fd())
-	var originalMode uint32
-
-	_ = windows.GetConsoleMode(stdout, &originalMode)
-	_ = windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
-}
diff --git a/tutorials/commands/README.md b/tutorials/commands/README.md
index a1275f1..2492058 100644
--- a/tutorials/commands/README.md
+++ b/tutorials/commands/README.md
@@ -2,7 +2,7 @@ Commands in Bubble Tea
 ======================
 
 This is the second tutorial for Bubble Tea covering commands, which deal with
-I/O. The tutorial assumes you have a working knowlege of Go and a decent
+I/O. The tutorial assumes you have a working knowledge of Go and a decent
 understanding of [the first tutorial][basics].
 
 You can find the non-annotated version of this program [on GitHub][source].
@@ -50,7 +50,7 @@ type model struct {
 `Cmd`s are functions that perform some I/O and then return a `Msg`. Checking the
 time, ticking a timer, reading from the disk, and network stuff are all I/O and
 should be run through commands. That might sound harsh, but it will keep your
-Bubble Tea program staightforward and simple.
+Bubble Tea program straightforward and simple.
 
 Anyway, let's write a `Cmd` that makes a request to a server and returns the
 result as a `Msg`.
@@ -87,7 +87,7 @@ First, let's write our initialization function.
 
 ## The Initialization Method
 
-The initilization method is very simple: we return the `Cmd` we made earlier.
+The initialization method is very simple: we return the `Cmd` we made earlier.
 Note that we don't call the function; the Bubble Tea runtime will do that when
 the time is right.
 
diff --git a/tutorials/go.sum b/tutorials/go.sum
index 270f8f5..76e0059 100644
--- a/tutorials/go.sum
+++ b/tutorials/go.sum
@@ -1,29 +1,30 @@
+github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
-github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
-github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
-github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
+github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
+github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=


Test patch:
diff --git a/options_test.go b/options_test.go
index 1b77977..6648ddd 100644
--- a/options_test.go
+++ b/options_test.go
@@ -9,8 +9,8 @@ func TestOptions(t *testing.T) {
 	t.Run("output", func(t *testing.T) {
 		var b bytes.Buffer
 		p := NewProgram(nil, WithOutput(&b))
-		if p.output != &b {
-			t.Errorf("expected output to custom, got %v", p.output)
+		if p.output.TTY() != nil {
+			t.Errorf("expected output to custom, got %v", p.output.TTY().Fd())
 		}
 	})
 
diff --git a/screen_test.go b/screen_test.go
deleted file mode 100644
index 93cb50d..0000000
--- a/screen_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package tea
-
-import (
-	"bytes"
-	"io"
-	"testing"
-)
-
-func TestScreen(t *testing.T) {
-	exercise := func(t *testing.T, fn func(io.Writer), expect []byte) {
-		var w bytes.Buffer
-		fn(&w)
-		if !bytes.Equal(w.Bytes(), expect) {
-			t.Errorf("expected %q, got %q", expect, w.Bytes())
-		}
-	}
-
-	t.Run("change scrolling region", func(t *testing.T) {
-		exercise(t, func(w io.Writer) {
-			changeScrollingRegion(w, 16, 22)
-		}, []byte("\x1b[16;22r"))
-	})
-
-	t.Run("line", func(t *testing.T) {
-		t.Run("clear", func(t *testing.T) {
-			exercise(t, clearLine, []byte("\x1b[2K"))
-		})
-
-		t.Run("insert", func(t *testing.T) {
-			exercise(t, func(w io.Writer) {
-				insertLine(w, 12)
-			}, []byte("\x1b[12L"))
-		})
-	})
-
-	t.Run("cursor", func(t *testing.T) {
-		t.Run("hide", func(t *testing.T) {
-			exercise(t, hideCursor, []byte("\x1b[?25l"))
-		})
-
-		t.Run("show", func(t *testing.T) {
-			exercise(t, showCursor, []byte("\x1b[?25h"))
-		})
-
-		t.Run("up", func(t *testing.T) {
-			exercise(t, cursorUp, []byte("\x1b[1A"))
-		})
-
-		t.Run("down", func(t *testing.T) {
-			exercise(t, cursorDown, []byte("\x1b[1B"))
-		})
-
-		t.Run("move", func(t *testing.T) {
-			exercise(t, func(w io.Writer) {
-				moveCursor(w, 10, 20)
-			}, []byte("\x1b[10;20H"))
-		})
-
-		t.Run("back", func(t *testing.T) {
-			exercise(t, func(w io.Writer) {
-				cursorBack(w, 15)
-			}, []byte("\x1b[15D"))
-		})
-	})
-}