diff --git a/README.md b/README.md index ca3b875ebd..fb1aa1265a 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,8 @@ complex terminal applications, either inline, full-window, or a mix of both.

Bubble Tea is in use in production and includes a number of features and -performance optimizations we’ve added along the way. Among those is a standard -framerate-based renderer, a renderer for high-performance scrollable -regions which works alongside the main renderer, and mouse support. +performance optimizations we’ve added along the way. Among those is +a framerate-based renderer, mouse support, focus reporting and more. To get started, see the tutorial below, the [examples][examples], the [docs][docs], the [video tutorials][youtube] and some common [resources](#libraries-we-use-with-bubble-tea). @@ -61,8 +60,8 @@ import will be the Bubble Tea library, which we'll call `tea` for short. ```go package main -// XXX: These imports will be used later on the tutorial. If you save the file -// now, Go might complain they are unused, but that is to be expected. +// These imports will be used later on the tutorial. If you save the file +// now, Go might complain they are unused, but that's fine. // You may also need to run `go mod tidy` to download bubbletea and its // dependencies. import ( @@ -307,20 +306,16 @@ your program in another window. - [Harmonica][harmonica]: A spring animation library for smooth, natural motion - [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components - [ntcharts][ntcharts]: A terminal charting library built for Bubble Tea and [Lip Gloss][lipgloss] -- [Termenv][termenv]: Advanced ANSI styling for terminal applications -- [Reflow][reflow]: Advanced ANSI-aware methods for working with text [bubbles]: /~https://github.com/charmbracelet/bubbles [lipgloss]: /~https://github.com/charmbracelet/lipgloss [harmonica]: /~https://github.com/charmbracelet/harmonica [bubblezone]: /~https://github.com/lrstanley/bubblezone [ntcharts]: /~https://github.com/NimbleMarkets/ntcharts -[termenv]: /~https://github.com/muesli/termenv -[reflow]: /~https://github.com/muesli/reflow ## Bubble Tea in the Wild -There are over 8k applications built with Bubble Tea! Here are a handful of ’em. +There are over [10,000 applications](/~https://github.com/charmbracelet/bubbletea/network/dependents) built with Bubble Tea! Here are a handful of ’em. ### Staff favourites @@ -328,15 +323,19 @@ There are over 8k applications built with Bubble Tea! Here are a handful of ’e - [circumflex](/~https://github.com/bensadeh/circumflex): read Hacker News in the terminal - [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues - [Tetrigo](/~https://github.com/Broderick-Westrope/tetrigo): Tetris in the terminal +- [Signls](/~https://github.com/emprcl/signls): a generative midi sequencer designed for composition and live performance +- [Superfile](/~https://github.com/yorukot/superfile): a super file manager ### In Industry - Microsoft Azure – [Aztify](/~https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform - Daytona – [Daytona](/~https://github.com/daytonaio/daytona): open source dev environment manager +- Cockroach Labs – [CockroachDB](/~https://github.com/cockroachdb/cockroach): a cloud-native, high-availability distributed SQL database - Truffle Security Co. – [Trufflehog](/~https://github.com/trufflesecurity/trufflehog): find leaked credentials -- NVIDIA – [container-canary](/~https://github.com/NVIDIA/container-canary) from NVIDIA: a container validator -- AWS – [eks-node-viewer](/~https://github.com/awslabs/eks-node-viewer) from AWS: a tool for visualizing dynamic node usage within an EKS cluster -- MinIO – [mc](/~https://github.com/minio/mc) from Min.io: the official [MinIO](https://min.io) client +- NVIDIA – [container-canary](/~https://github.com/NVIDIA/container-canary): a container validator +- AWS – [eks-node-viewer](/~https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an EKS cluster +- MinIO – [mc](/~https://github.com/minio/mc): the official [MinIO](https://min.io) client +- Ubuntu – [Authd](/~https://github.com/ubuntu/authd): an authentication daemon for cloud-based identity providers ### Charm stuff diff --git a/examples/glamour/main.go b/examples/glamour/main.go index a0aa9569f4..2d4939ad65 100644 --- a/examples/glamour/main.go +++ b/examples/glamour/main.go @@ -70,9 +70,20 @@ func newExample() (*example, error) { BorderForeground(lipgloss.Color("62")). PaddingRight(2) + // We need to adjust the width of the glamour render from our main width + // to account for a few things: + // + // * The viewport border width + // * The viewport padding + // * The viewport margins + // * The gutter glamour applies to the left side of the content + // + const glamourGutter = 2 + glamourRenderWidth := width - vp.Style.GetHorizontalFrameSize() - glamourGutter + renderer, err := glamour.NewTermRenderer( glamour.WithAutoStyle(), - glamour.WithWordWrap(width), + glamour.WithWordWrap(glamourRenderWidth), ) if err != nil { return nil, err diff --git a/examples/go.mod b/examples/go.mod index 334b631375..cf45a08c53 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -5,7 +5,7 @@ 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.20250114183054-9f703251e0d7 - github.com/charmbracelet/colorprofile v0.1.8 + github.com/charmbracelet/colorprofile v0.1.9 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.20250114171829-b67eb015d607 @@ -22,12 +22,11 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/lipgloss v0.13.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.6 // indirect - github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect + github.com/charmbracelet/lipgloss v1.0.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f // indirect github.com/charmbracelet/x/input v0.3.0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4 // indirect github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect @@ -43,11 +42,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.4 // indirect github.com/yuin/goldmark-emoji v1.0.3 // indirect - golang.org/x/net v0.27.0 // indirect + golang.org/x/net v0.33.0 // indirect golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.20.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.27.0 // indirect + golang.org/x/text v0.21.0 // indirect ) replace github.com/charmbracelet/bubbletea/v2 => ../ diff --git a/examples/go.sum b/examples/go.sum index 9320d9aecb..b9d9ff0929 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -16,30 +16,28 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP 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.8 h1:PywDeXsiAzlPtkiiKgMEVLvb6nlEuKrMj9+FJBtj4jU= -github.com/charmbracelet/colorprofile v0.1.8/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60= +github.com/charmbracelet/colorprofile v0.1.9 h1:5JnfvX+I9D6rRNu8xK3pgIqknaBVTXHU9pGu1jkZxLw= +github.com/charmbracelet/colorprofile v0.1.9/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60= 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= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= -github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607 h1:lERE4ow371r5WMqQAt7Eqlg1A4tBNA8T4RLwdXnKyBo= github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607/go.mod h1:MD7Vb+O1zFRgBo+F94JHHuME7df8XBByNKuX5k/L/qs= github.com/charmbracelet/x/ansi v0.7.0 h1:/QfFmiXOGGwN6fRbzvQaYp7fu1pkxpZ3qFBZWBsP404= github.com/charmbracelet/x/ansi v0.7.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= -github.com/charmbracelet/x/cellbuf v0.0.6 h1:pJUWN/G1jbt1Nj/+ILfC2/ABQoZzWu1vG73yHQEYELI= -github.com/charmbracelet/x/cellbuf v0.0.6/go.mod h1:d72o71glp8flkCz54PHLe3+nuw5u2v3UxmKqruUERWQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 h1:P90NI2rZuBISjB1HIHdkBDE+riKtVzIOi6Xun3qjUn8= +github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72/go.mod h1:VXZSjC/QYH0t+9CG1qtcEx3XZubTDJb5ilWS6qJg4/0= +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= github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20241016014612-3b4d04043233/go.mod h1:cw9df32BXdkcd0LzAHsFMmvXOsrrlDKazIW8PCq0cPM= github.com/charmbracelet/x/input v0.3.0 h1:lVzEz92E2u9jCU0mUwcyKeSOxkoeat+1eUkjzL0WCYI= github.com/charmbracelet/x/input v0.3.0/go.mod h1:M8CHPIYnmmiNHA17hqXmvSfeZLO2lj9pzJFX3aWvzgw= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4 h1:EacjHxcQEEgOZ7TbkAU3b84hd1Bn5NwA8YV5uyJ9EI4= -github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4/go.mod h1:1/jFoHl7/I4br0StC9OXXEondkK9qi3nUtKoqI35HcI= github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 h1:14czE6R5CgOlvONsJYa2B1uTyLvXzGXpBqw2AyZeTh4= github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:hyua5CY63kyl7IfyIxv1SjVEqoKze/XmDkEglItuVjA= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= @@ -86,14 +84,14 @@ github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhb github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= diff --git a/examples/simple/main_test.go b/examples/simple/main_test.go index e489658608..2ea67adba6 100644 --- a/examples/simple/main_test.go +++ b/examples/simple/main_test.go @@ -54,6 +54,9 @@ func TestApp(t *testing.T) { } func TestAppInteractive(t *testing.T) { + t.Skip("This test is flaky and needs to be fixed.\n" + + "We need a more concrete way to set the initial terminal size") + m := model(10) tm := teatest.NewTestModel( t, m, diff --git a/examples/split-editors/main.go b/examples/split-editors/main.go index 1267f61e9b..bb9ed36ec3 100644 --- a/examples/split-editors/main.go +++ b/examples/split-editors/main.go @@ -55,6 +55,7 @@ func newTextarea() textarea.Model { t.Styles.Focused.Placeholder = focusedPlaceholderStyle t.Styles.Blurred.Placeholder = placeholderStyle t.Styles.Focused.CursorLine = cursorLineStyle + t.Styles.Focused.CursorLineNumber = cursorLineStyle t.Styles.Focused.Base = focusedBorderStyle t.Styles.Blurred.Base = blurredBorderStyle t.Styles.Focused.EndOfBuffer = endOfBufferStyle diff --git a/ferocious_renderer.go b/ferocious_renderer.go index 2cd6948ba0..27c0364fe4 100644 --- a/ferocious_renderer.go +++ b/ferocious_renderer.go @@ -1,606 +1,184 @@ package tea import ( - "bytes" - "image" "io" "strings" "sync" "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/cellbuf" - "github.com/charmbracelet/x/vt" ) -var undefPoint = image.Pt(-1, -1) - -// cursor represents a terminal cursor. -type cursor struct { - image.Point - visible bool -} - -// screen represents a terminal screen. -type screen struct { - dirty map[int]int // keeps track of dirty cells - linew []int // keeps track of the width of each line - cur cursor // cursor state - *vt.Buffer // the cell buffer -} - -// isDirty returns true if the cell at the given position is dirty. -func (s *screen) isDirty(x, y int) bool { - idx := y*s.Width() + x - v, ok := s.dirty[idx] - return ok && v == 1 -} - -// reset resets the screen to its initial state. -func (s *screen) reset() { - s.Buffer = vt.NewBuffer(0, 0) - s.dirty = make(map[int]int) - s.cur = cursor{} - s.linew = make([]int, 0) -} - -// Set implements [cellbuf.Grid] and marks changed cells as dirty. -func (s *screen) SetCell(x, y int, cell *cellbuf.Cell) (v bool) { - c := s.Cell(x, y) - if c.Equal(cell) { - // Cells are the same, no need to update. - return - } - - v = s.Buffer.SetCell(x, y, cell) - if v { - // Mark the cell as dirty. You nasty one ;) - idx := y*s.Width() + x - s.dirty[idx] = 1 - } - +type screenRenderer struct { + w io.Writer + scr *cellbuf.Screen + lastFrame *string + term string // the terminal type $TERM + width, height int + mu sync.Mutex + profile colorprofile.Profile + altScreen bool + cursorHidden bool + hardTabs bool // whether to use hard tabs to optimize cursor movements +} + +var _ renderer = &screenRenderer{} + +func newScreenRenderer(w io.Writer, term string, hardTabs bool) (s *screenRenderer) { + s = new(screenRenderer) + s.w = w + s.term = term + s.hardTabs = hardTabs + s.reset() return } -// ferociousRenderer is a cell-based terminal renderer. It's ferocious! -type ferociousRenderer struct { - mtx sync.Mutex - out io.Writer // we only write to the output during flush and close - buf bytes.Buffer // the internal buffer for rendering - - scrs [2]screen // Both inline and alt-screen - scr *screen // Points to the current used screen - - method cellbuf.Method - - finalCur image.Point // The final cursor position - - pen cellbuf.Style - link cellbuf.Link - - queueAbove []string - lastRenders [2]string // The last render for both inline and alt-screen buffers - lastRender *string // Points to the last render string - frame string // The current frame to render - lastHeight int // The height of the last render - - // modes - altScreen bool - cursorHidden bool - - profile colorprofile.Profile -} - -func newFerociousRenderer(p colorprofile.Profile) *ferociousRenderer { - r := &ferociousRenderer{ - // TODO: Update this if Grapheme Clustering is supported. - method: cellbuf.WcWidth, - finalCur: undefPoint, - profile: p, - } - r.reset() - return r -} - -var _ renderer = &ferociousRenderer{} - // close implements renderer. -func (c *ferociousRenderer) close() error { - c.mtx.Lock() - defer c.mtx.Unlock() - - seq := c.buf.String() - c.buf.Reset() - - y := c.scr.cur.Y - if !c.altScreen && y < c.lastHeight { - diff := c.lastHeight - y - 1 - // Ensure the cursor is at the bottom of the screen - seq += strings.Repeat("\n", diff) - y += diff - c.scr.cur.Y = y - } - - if c.scr.cur.X != 0 { - seq += "\r" - c.scr.cur.X = 0 - } - - if _, line := cellbuf.RenderLine( - c.scr, y, - cellbuf.WithRenderProfile(c.profile), - ); line != "" { - // OPTIM: We only clear the line if there's content on it. - seq += ansi.EraseEntireLine - } - - if seq == "" { - // Nothing to clear. - return nil - } - - _, err := io.WriteString(c.out, seq) - return err -} - -// clearScreen returns a string to clear the screen and moves the cursor to the -// origin location i.e. top-left. -func (c *ferociousRenderer) clearScreen() { - c.moveCursor(0, 0) - if c.altScreen { - c.buf.WriteString(ansi.EraseEntireScreen) //nolint:errcheck - return - } - - c.buf.WriteString(ansi.EraseScreenBelow) //nolint:errcheck +func (s *screenRenderer) close() (err error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.scr.Close() } // flush implements renderer. -func (c *ferociousRenderer) flush() error { - c.mtx.Lock() - defer c.mtx.Unlock() - - if c.finalCur == c.scr.cur.Point && len(c.queueAbove) == 0 && - c.frame == *c.lastRender && c.lastHeight == c.scr.Height() { - return nil - } - - queueAbove := c.queueAbove - if !c.altScreen && len(queueAbove) > 0 { - c.moveCursor(0, 0) - for _, line := range c.queueAbove { - c.buf.WriteString(line + ansi.EraseLineRight + "\r\n") - } - c.queueAbove = queueAbove[:0] - c.repaint() - } - - if *c.lastRender == "" { - // First render and repaints clear the screen. - c.clearScreen() - } - - if c.scr.cur.X > c.scr.Width()-1 { - // When the cursor is at EOL, we need to put it back to the beginning - // of line. Otherwise, the autowrap (DECAWM), which is enabled by - // default, will move the cursor to the next line on the next cell - // write. - c.buf.WriteByte(ansi.CR) - c.scr.cur.X = 0 - } - - c.changes() - - // XXX: We need to move the cursor to the final position before rendering - // the frame to avoid flickering. - shouldHideCursor := !c.cursorHidden - if c.finalCur != image.Pt(-1, -1) { - shouldMove := len(queueAbove) == 0 && c.finalCur != c.scr.cur.Point - shouldHideCursor = shouldHideCursor && shouldMove - if shouldMove { - c.moveCursor(c.finalCur.X, c.finalCur.Y) - } - } - - c.scr.dirty = make(map[int]int) - c.lastHeight = cellbuf.Height(c.frame) - *c.lastRender = c.frame - render := c.buf.String() - c.buf.Reset() - if render == "" { - return nil - } - - if shouldHideCursor { - // Hide the cursor while rendering to avoid flickering. - render = ansi.HideCursor + render + ansi.ShowCursor - } - - _, err := io.WriteString(c.out, render) - return err +func (s *screenRenderer) flush() error { + s.mu.Lock() + defer s.mu.Unlock() + s.scr.Render() + return nil } // render implements renderer. -func (c *ferociousRenderer) render(s string) { - c.mtx.Lock() - defer c.mtx.Unlock() - c.frame = s - // Ensure the buffer is at least the height of the new frame. - height := cellbuf.Height(s) - c.scr.Resize(c.scr.Width(), height) - linew := cellbuf.Paint(c.scr, c.method, s, nil) - c.scr.linew = linew -} - -// reset implements renderer. -func (c *ferociousRenderer) reset() { - c.mtx.Lock() - defer c.mtx.Unlock() - - c.lastRenders[0] = "" - c.lastRenders[1] = "" - c.scrs[0].reset() - c.scrs[1].reset() - // alt-screen buffer cursor always starts from where the main buffer cursor - // is. We need to set it to (-1,-1) to force the cursor to be moved to the - // origin on the first render. - c.scrs[1].cur.Point = undefPoint - if c.altScreen { - c.scr = &c.scrs[1] - c.lastRender = &c.lastRenders[1] - } else { - c.scr = &c.scrs[0] - c.lastRender = &c.lastRenders[0] - } -} +func (s *screenRenderer) render(frame string) { + s.mu.Lock() + defer s.mu.Unlock() -// repaint forces a repaint of the screen. -func (c *ferociousRenderer) repaint() { - *c.lastRender = "" -} - -// updateCursorVisibility ensures the cursor state is in sync with the -// renderer. -func (c *ferociousRenderer) updateCursorVisibility() { - if !c.cursorHidden != c.scr.cur.visible { - c.scr.cur.visible = !c.cursorHidden - // cmd.exe and other terminals keep separate cursor states for the AltScreen - // and the main buffer. We have to explicitly reset the cursor visibility - // whenever we exit AltScreen. - if c.cursorHidden { - io.WriteString(&c.buf, ansi.HideCursor) //nolint:errcheck - } else { - io.WriteString(&c.buf, ansi.ShowCursor) //nolint:errcheck - } - } -} - -// update implements renderer. -func (c *ferociousRenderer) update(msg Msg) { - c.mtx.Lock() - defer c.mtx.Unlock() - switch msg := msg.(type) { - case ColorProfileMsg: - c.profile = msg.Profile - - case rendererWriter: - c.out = msg.Writer - - case WindowSizeMsg: - c.scrs[0].Resize(msg.Width, msg.Height) - c.scrs[1].Resize(msg.Width, msg.Height) - c.lastRenders[0] = "" - c.lastRenders[1] = "" - - case clearScreenMsg: - seq := ansi.EraseEntireScreen + ansi.HomeCursorPosition - if !c.cursorHidden { - seq = ansi.HideCursor + seq + ansi.ShowCursor - } - - io.WriteString(c.out, seq) //nolint:errcheck - c.repaint() - - case repaintMsg: - c.repaint() - - case printLineMessage: - if !c.altScreen { - c.queueAbove = append(c.queueAbove, strings.Split(msg.messageBody, "\n")...) - } - - case enableModeMsg: - switch ansi.DECMode(msg) { - case ansi.AltScreenSaveCursorMode: - if c.altScreen { - return - } - - c.scr = &c.scrs[1] - c.altScreen = true - - // NOTE: Using `CSI ? 1049` clears the screen so we need to repaint - // the alt screen buffer. - c.repaint() - - // Some terminals keep separate cursor states for the AltScreen and - // the main buffer. We have to explicitly reset the cursor visibility - // whenever we enter or leave AltScreen. - c.updateCursorVisibility() - - case ansi.TextCursorEnableMode: - if !c.cursorHidden { - return - } - - c.cursorHidden = false - } - - case disableModeMsg: - switch ansi.DECMode(msg) { - case ansi.AltScreenSaveCursorMode: - if !c.altScreen { - return - } - - c.scr = &c.scrs[0] - c.altScreen = false - - // Some terminals keep separate cursor states for the AltScreen and - // the main buffer. We have to explicitly reset the cursor visibility - // whenever we enter or leave AltScreen. - c.updateCursorVisibility() - - case ansi.TextCursorEnableMode: - if c.cursorHidden { - return - } - - c.cursorHidden = true - } - - case setCursorPosMsg: - c.finalCur = image.Pt(clamp(msg.X, 0, c.scr.Width()-1), clamp(msg.Y, 0, c.scr.Height()-1)) - } -} - -var spaceCell = &cellbuf.Cell{Content: " ", Width: 1} - -// changes commits the changes from the cell buffer using the dirty cells map -// and writes them to the internal buffer. -func (c *ferociousRenderer) changes() { - width := c.scr.Width() - if width <= 0 { + if s.lastFrame != nil && frame == *s.lastFrame { return } - height := c.scr.Height() - if *c.lastRender == "" { - // We render the changes line by line to be able to get the cursor - // position using the width of each line. - var x int - for y := 0; y < height; y++ { - var line string - x, line = cellbuf.RenderLine(c.scr, y, cellbuf.WithRenderProfile(c.profile)) - c.buf.WriteString(line) - if y < height-1 { - x = 0 - c.buf.WriteString("\r\n") - } - } - - c.scr.cur.X, c.scr.cur.Y = x, height-1 - return + s.lastFrame = &frame + if !s.altScreen { + // Inline mode resizes the screen based on the frame height and + // terminal width. This is because the frame height can change based on + // the content of the frame. For example, if the frame contains a list + // of items, the height of the frame will be the number of items in the + // list. This is different from the alt screen buffer, which has a + // fixed height and width. + frameHeight := strings.Count(frame, "\n") + 1 + s.scr.Resize(s.width, frameHeight) } - // TODO: iterate over the dirty cells instead of the whole buffer. - // TODO: optimize continuous space-only segments i.e. concatenate them to - // erase the line instead of using spaces to erase the line. - for y := 0; y < height; y++ { - var seg *cellbuf.Segment - var segX int // The start position of the current segment. - var eraser bool // Whether we're erasing using spaces and no styles or links. - for x := 0; x < width; x++ { - cell := c.scr.Cell(x, y) - if cell.Width == 0 { - continue - } - - // Convert the cell to respect the current color profile. - // TODO: ?? - // cell.Style = cell.Style.Convert(c.profile) - // cell.Link = cell.Link.Convert(c.profile) - - if !c.scr.isDirty(x, y) { - if seg != nil { - erased := c.flushSegment(seg, image.Pt(segX, y), eraser) - seg = nil - if erased { - // If the segment erased the rest of the line, we don't need to - // render the rest of the line. - break - } - } - continue - } - - if seg == nil { - segX = x - eraser = cell.Equal(spaceCell) - seg = &cellbuf.Segment{ - Style: cell.Style, - Link: cell.Link, - Content: cell.Content, - Width: cell.Width, - } - continue - } - - if !seg.Style.Equal(cell.Style) || seg.Link != cell.Link { - erased := c.flushSegment(seg, image.Pt(segX, y), eraser) - if erased { - seg = nil - // If the segment erased the rest of the line, we don't need to - // render the rest of the line. - break - } - segX = x - eraser = cell.Equal(spaceCell) - seg = &cellbuf.Segment{ - Style: cell.Style, - Link: cell.Link, - Content: cell.Content, - Width: cell.Width, - } - continue - } - - eraser = eraser && cell.Equal(spaceCell) - seg.Content += cell.Content - seg.Width += cell.Width - } - - if seg != nil { - c.flushSegment(seg, image.Pt(segX, y), eraser) - seg = nil - } - } - - // Reset the style and hyperlink if necessary. - if c.link.URL != "" { - c.buf.WriteString(ansi.ResetHyperlink()) //nolint:errcheck - c.link.Reset() - } - if !c.pen.Empty() { - c.buf.WriteString(ansi.ResetStyle) //nolint:errcheck - c.pen.Reset() - } - - // Delete extra lines from previous render. - if c.lastHeight > height { - // Move the cursor to the last line of this render and erase the rest - // of the screen. - c.moveCursor(c.scr.cur.X, height-1) - c.buf.WriteString(ansi.EraseScreenBelow) + if ctx := s.scr.DefaultWindow(); ctx != nil { + ctx.SetContent(frame) } } -// flushSegment flushes the segment to the buffer. It returns true if the -// segment the rest of the line was erased. -func (c *ferociousRenderer) flushSegment(seg *cellbuf.Segment, to image.Point, eraser bool) (erased bool) { - if c.scr.cur.Point != to { - c.renderReset(seg) - c.moveCursor(to.X, to.Y) - } - - // We use [ansi.EraseLineRight] to erase the rest of the line if the segment - // is an "eraser" i.e. it's just a bunch of spaces with no styles or links. We erase the - // rest of the line when: - // 1. The segment is an eraser. - // 2. The segment reaches the end of the line to erase i.e. the new line is shorter. - // 3. The segment takes more bytes than [ansi.EraseLineRight] to erase which is 4 bytes. - if eraser && to.Y < len(c.scr.linew) && seg.Width > 4 && (c.scr.linew)[to.Y] < seg.Width+to.X { - c.renderReset(seg) - c.buf.WriteString(ansi.EraseLineRight) //nolint:errcheck - erased = true - } else { - c.renderSegment(seg) - } - return -} - -func (c *ferociousRenderer) renderReset(seg *cellbuf.Segment) { - if seg.Link != c.link && c.link.URL != "" { - c.buf.WriteString(ansi.ResetHyperlink()) //nolint:errcheck - c.link.Reset() - } - if seg.Style.Empty() && !c.pen.Empty() { - c.buf.WriteString(ansi.ResetStyle) //nolint:errcheck - c.pen.Reset() - } -} - -func (c *ferociousRenderer) renderSegment(seg *cellbuf.Segment) { - isSpaces := strings.Trim(seg.Content, " ") == "" && c.pen.Empty() && seg.Style.Empty() - if !isSpaces && !seg.Style.Equal(c.pen) { - // We don't apply the style if the content is spaces. It's more efficient - // to just write the spaces. - c.buf.WriteString(seg.Style.DiffSequence(c.pen)) // nolint:errcheck - c.pen = seg.Style - } - if seg.Link != c.link { - c.buf.WriteString(ansi.SetHyperlink(seg.Link.URL, seg.Link.URLID)) // nolint:errcheck - c.link = seg.Link - } - - c.buf.WriteString(seg.Content) - c.scr.cur.X += seg.Width - - if c.scr.cur.X >= c.scr.Width() { - // NOTE: We need to reset the cursor when at phantom cell i.e. outside - // the screen, otherwise, the cursor position will be out of sync. - c.scr.cur.X = 0 - c.buf.WriteByte(ansi.CR) - } -} - -// moveCursor moves the cursor to the given position. -func (c *ferociousRenderer) moveCursor(x, y int) { - if c.scr.cur.X == x && c.scr.cur.Y == y { - return - } - - if c.altScreen { - // TODO: Optimize for small movements i.e. movements that cost less - // than 8 bytes in total. [ansi.MoveCursor] is at least 6 bytes long. - c.buf.WriteString(ansi.SetCursorPosition(x+1, y+1)) - } else { - if c.scr.cur.X < x { - dx := x - c.scr.cur.X - switch dx { - case 1: - // OPTIM: We write the cell content under the cursor if it's the same - // style and link. This is more efficient than moving the cursor which - // costs at least 3 bytes [ansi.CursorRight]. - cell := c.scr.Cell(c.scr.cur.X, c.scr.cur.Y) - if cell.Style.Equal(c.pen) && cell.Link == c.link { - c.buf.WriteString(cell.Content) - break - } - fallthrough - default: - c.buf.WriteString(ansi.CursorRight(dx)) - } - } else if c.scr.cur.X > x { - if x == 0 { - // We use [ansi.CR] instead of [ansi.CursorLeft] to avoid - // writing multiple bytes. - c.buf.WriteByte(ansi.CR) - } else { - dx := c.scr.cur.X - x - if dx >= 3 { - // [ansi.CursorLeft] is at least 3 bytes long, so we use [ansi.BS] - // when we can to avoid writing more bytes than necessary. - c.buf.WriteString(ansi.CursorLeft(dx)) - } else { - c.buf.WriteString(strings.Repeat("\b", dx)) - } - } - } - if c.scr.cur.Y < y { - dy := y - c.scr.cur.Y - if dy >= 3 { - // [ansi.CursorDown] is at least 3 bytes long, so we use "\n" when - // we can to avoid writing more bytes than necessary. - c.buf.WriteString(ansi.CursorDown(dy)) - } else { - c.buf.WriteString(strings.Repeat("\n", dy)) - } - } else if c.scr.cur.Y > y { - dy := c.scr.cur.Y - y - c.buf.WriteString(ansi.CursorUp(dy)) - } - } - - c.scr.cur.X, c.scr.cur.Y = x, y +// reset implements renderer. +func (s *screenRenderer) reset() { + s.mu.Lock() + s.scr = cellbuf.NewScreen(s.w, &cellbuf.ScreenOptions{ + Term: s.term, + Profile: s.profile, + AltScreen: s.altScreen, + RelativeCursor: !s.altScreen, + ShowCursor: !s.cursorHidden, + Width: s.width, + Height: s.height, + HardTabs: s.hardTabs, + }) + s.mu.Unlock() +} + +// setColorProfile implements renderer. +func (s *screenRenderer) setColorProfile(p colorprofile.Profile) { + s.mu.Lock() + s.profile = p + s.scr.SetColorProfile(p) + s.mu.Unlock() +} + +// resize implements renderer. +func (s *screenRenderer) resize(w, h int) { + s.mu.Lock() + s.width, s.height = w, h + if s.altScreen { + // We only resize the screen if we're in the alt screen buffer. Inline + // mode resizes the screen based on the frame height and terminal + // width. See [screenRenderer.render] for more details. + s.scr.Resize(s.width, s.height) + } + + repaint(s) + s.mu.Unlock() +} + +// clearScreen implements renderer. +func (s *screenRenderer) clearScreen() { + s.mu.Lock() + s.scr.Clear() + repaint(s) + s.mu.Unlock() +} + +// enterAltScreen implements renderer. +func (s *screenRenderer) enterAltScreen() { + s.mu.Lock() + s.altScreen = true + s.scr.EnterAltScreen() + s.scr.SetRelativeCursor(!s.altScreen) + s.scr.Resize(s.width, s.height) + s.lastFrame = nil + s.mu.Unlock() +} + +// exitAltScreen implements renderer. +func (s *screenRenderer) exitAltScreen() { + s.mu.Lock() + s.altScreen = false + s.scr.ExitAltScreen() + s.scr.SetRelativeCursor(!s.altScreen) + s.scr.Resize(s.width, strings.Count(*s.lastFrame, "\n")+1) + repaint(s) + s.mu.Unlock() +} + +// showCursor implements renderer. +func (s *screenRenderer) showCursor() { + s.mu.Lock() + s.cursorHidden = false + s.scr.ShowCursor() + s.mu.Unlock() +} + +// hideCursor implements renderer. +func (s *screenRenderer) hideCursor() { + s.mu.Lock() + s.cursorHidden = true + s.scr.HideCursor() + s.mu.Unlock() +} + +// insertAbove implements renderer. +func (s *screenRenderer) insertAbove(lines string) { + s.mu.Lock() + s.scr.InsertAbove(lines) + s.mu.Unlock() +} + +// moveTo implements renderer. +func (s *screenRenderer) moveTo(x, y int) { + s.mu.Lock() + s.scr.MoveTo(x, y) + s.mu.Unlock() +} + +func (s *screenRenderer) repaint() { + s.mu.Lock() + repaint(s) + s.mu.Unlock() +} + +func repaint(s *screenRenderer) { + s.lastFrame = nil } diff --git a/go.mod b/go.mod index 93380d150d..0e8d987669 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,19 @@ module github.com/charmbracelet/bubbletea/v2 go 1.18 require ( - github.com/charmbracelet/colorprofile v0.1.8 + github.com/charmbracelet/colorprofile v0.1.9 github.com/charmbracelet/x/ansi v0.7.0 - github.com/charmbracelet/x/cellbuf v0.0.6 + github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 + github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f github.com/charmbracelet/x/input v0.3.0 github.com/charmbracelet/x/term v0.2.1 - github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4 github.com/muesli/cancelreader v0.2.2 golang.org/x/sync v0.10.0 - golang.org/x/sys v0.28.0 + golang.org/x/sys v0.29.0 ) require ( + github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/go.sum b/go.sum index 2d79b1c426..928fbd6bf4 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,17 @@ -github.com/charmbracelet/colorprofile v0.1.8 h1:PywDeXsiAzlPtkiiKgMEVLvb6nlEuKrMj9+FJBtj4jU= -github.com/charmbracelet/colorprofile v0.1.8/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60= +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.9 h1:5JnfvX+I9D6rRNu8xK3pgIqknaBVTXHU9pGu1jkZxLw= +github.com/charmbracelet/colorprofile v0.1.9/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60= github.com/charmbracelet/x/ansi v0.7.0 h1:/QfFmiXOGGwN6fRbzvQaYp7fu1pkxpZ3qFBZWBsP404= github.com/charmbracelet/x/ansi v0.7.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= -github.com/charmbracelet/x/cellbuf v0.0.6 h1:pJUWN/G1jbt1Nj/+ILfC2/ABQoZzWu1vG73yHQEYELI= -github.com/charmbracelet/x/cellbuf v0.0.6/go.mod h1:d72o71glp8flkCz54PHLe3+nuw5u2v3UxmKqruUERWQ= +github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 h1:P90NI2rZuBISjB1HIHdkBDE+riKtVzIOi6Xun3qjUn8= +github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72/go.mod h1:VXZSjC/QYH0t+9CG1qtcEx3XZubTDJb5ilWS6qJg4/0= +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.0 h1:lVzEz92E2u9jCU0mUwcyKeSOxkoeat+1eUkjzL0WCYI= github.com/charmbracelet/x/input v0.3.0/go.mod h1:M8CHPIYnmmiNHA17hqXmvSfeZLO2lj9pzJFX3aWvzgw= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4 h1:EacjHxcQEEgOZ7TbkAU3b84hd1Bn5NwA8YV5uyJ9EI4= -github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4/go.mod h1:1/jFoHl7/I4br0StC9OXXEondkK9qi3nUtKoqI35HcI= github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 h1:14czE6R5CgOlvONsJYa2B1uTyLvXzGXpBqw2AyZeTh4= github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:hyua5CY63kyl7IfyIxv1SjVEqoKze/XmDkEglItuVjA= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= @@ -25,7 +27,7 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= diff --git a/nil_renderer.go b/nil_renderer.go index c6d03cd8a8..ed7341bd03 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -1,11 +1,43 @@ package tea +import "github.com/charmbracelet/colorprofile" + // nilRenderer is a no-op renderer. It implements the Renderer interface but // doesn't render anything to the terminal. type nilRenderer struct{} var _ renderer = nilRenderer{} +// clearScreen implements renderer. +func (n nilRenderer) clearScreen() {} + +// repaint implements renderer. +func (n nilRenderer) repaint() {} + +// enterAltScreen implements renderer. +func (n nilRenderer) enterAltScreen() {} + +// exitAltScreen implements renderer. +func (n nilRenderer) exitAltScreen() {} + +// hideCursor implements renderer. +func (n nilRenderer) hideCursor() {} + +// insertAbove implements renderer. +func (n nilRenderer) insertAbove(string) {} + +// moveTo implements renderer. +func (n nilRenderer) moveTo(int, int) {} + +// resize implements renderer. +func (n nilRenderer) resize(int, int) {} + +// setColorProfile implements renderer. +func (n nilRenderer) setColorProfile(colorprofile.Profile) {} + +// showCursor implements renderer. +func (n nilRenderer) showCursor() {} + // flush implements the Renderer interface. func (nilRenderer) flush() error { return nil } @@ -17,6 +49,3 @@ func (nilRenderer) render(string) {} // reset implements the Renderer interface. func (nilRenderer) reset() {} - -// update implements the Renderer interface. -func (nilRenderer) update(Msg) {} diff --git a/options.go b/options.go index c0ae254158..235bf6708d 100644 --- a/options.go +++ b/options.go @@ -271,25 +271,6 @@ func WithGraphemeClustering() ProgramOption { } } -// experimentalOptions are experimental features that are not yet stable. These -// features may change or be removed in future versions. -type experimentalOptions []string - -// has returns true if the experimental option is enabled. -func (e experimentalOptions) has(option string) bool { - for _, o := range e { - if o == option { - return true - } - } - return false -} - -const ( - // Ferocious enables the "ferocious" renderer. - experimentalFerocious = "ferocious" -) - // 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 diff --git a/renderer.go b/renderer.go index 871e9391c0..ae8738e37f 100644 --- a/renderer.go +++ b/renderer.go @@ -1,6 +1,17 @@ package tea -import "io" +import ( + "fmt" + + "github.com/charmbracelet/colorprofile" +) + +const ( + // defaultFramerate specifies the maximum interval at which we should + // update the view. + defaultFPS = 60 + maxFPS = 120 +) // renderer is the interface for Bubble Tea renderers. type renderer interface { @@ -16,15 +27,71 @@ type renderer interface { // reset resets the renderer's state to its initial state. reset() - // update updates the renderer's state with the given message. It returns a - // [tea.Cmd] that can be used to send messages back to the program. - update(Msg) + // insertAbove inserts unmanaged lines above the renderer. + insertAbove(string) + + // enterAltScreen enters the alternate screen buffer. + enterAltScreen() + + // exitAltScreen exits the alternate screen buffer. + exitAltScreen() + + // showCursor shows the cursor. + showCursor() + + // hideCursor hides the cursor. + hideCursor() + + // resize notify the renderer of a terminal resize. + resize(int, int) + + // setColorProfile sets the color profile. + setColorProfile(colorprofile.Profile) + + // moveTo moves the cursor to the given position. + moveTo(int, int) + + // clearScreen clears the screen. + clearScreen() + + // repaint forces a full repaint. + repaint() } // repaintMsg forces a full repaint. type repaintMsg struct{} -// rendererWriter is an internal message used to set the output of the renderer. -type rendererWriter struct { - io.Writer +type printLineMessage struct { + messageBody string +} + +// Println prints above the Program. This output is unmanaged by the program and +// will persist across renders by the Program. +// +// Unlike fmt.Println (but similar to log.Println) the message will be print on +// its own line. +// +// If the altscreen is active no output will be printed. +func Println(args ...interface{}) Cmd { + return func() Msg { + return printLineMessage{ + messageBody: fmt.Sprint(args...), + } + } +} + +// Printf prints above the Program. It takes a format template followed by +// values similar to fmt.Printf. This output is unmanaged by the program and +// will persist across renders by the Program. +// +// Unlike fmt.Printf (but similar to log.Printf) the message will be print on +// its own line. +// +// If the altscreen is active no output will be printed. +func Printf(template string, args ...interface{}) Cmd { + return func() Msg { + return printLineMessage{ + messageBody: fmt.Sprintf(template, args...), + } + } } diff --git a/screen.go b/screen.go index 8cb193c590..d7c0d3599f 100644 --- a/screen.go +++ b/screen.go @@ -32,7 +32,7 @@ type clearScreenMsg struct{} // model's Init function. To initialize your program with the altscreen enabled // use the WithAltScreen ProgramOption instead. func EnterAltScreen() Msg { - return enableMode(ansi.AltScreenSaveCursorMode) + return enableModeMsg{ansi.AltScreenSaveCursorMode} } // ExitAltScreen is a special command that tells the Bubble Tea program to exit @@ -42,7 +42,7 @@ func EnterAltScreen() Msg { // Note that the alternate screen buffer will be automatically exited when the // program quits. func ExitAltScreen() Msg { - return disableMode(ansi.AltScreenSaveCursorMode) + return disableModeMsg{ansi.AltScreenSaveCursorMode} } // EnableMouseCellMotion is a special command that enables mouse click, @@ -53,8 +53,8 @@ func ExitAltScreen() Msg { // model's Init function. Use the WithMouseCellMotion ProgramOption instead. func EnableMouseCellMotion() Msg { return sequenceMsg{ - func() Msg { return enableMode(ansi.ButtonEventMouseMode) }, - func() Msg { return enableMode(ansi.SgrExtMouseMode) }, + func() Msg { return enableModeMsg{ansi.ButtonEventMouseMode} }, + func() Msg { return enableModeMsg{ansi.SgrExtMouseMode} }, } } @@ -69,17 +69,17 @@ func EnableMouseCellMotion() Msg { // model's Init function. Use the WithMouseAllMotion ProgramOption instead. func EnableMouseAllMotion() Msg { return sequenceMsg{ - func() Msg { return enableMode(ansi.AnyEventMouseMode) }, - func() Msg { return enableMode(ansi.SgrExtMouseMode) }, + func() Msg { return enableModeMsg{ansi.AnyEventMouseMode} }, + func() Msg { return enableModeMsg{ansi.SgrExtMouseMode} }, } } // DisableMouse is a special command that stops listening for mouse events. func DisableMouse() Msg { return sequenceMsg{ - func() Msg { return disableMode(ansi.ButtonEventMouseMode) }, - func() Msg { return disableMode(ansi.AnyEventMouseMode) }, - func() Msg { return disableMode(ansi.SgrExtMouseMode) }, + func() Msg { return disableModeMsg{ansi.ButtonEventMouseMode} }, + func() Msg { return disableModeMsg{ansi.AnyEventMouseMode} }, + func() Msg { return disableModeMsg{ansi.SgrExtMouseMode} }, } } @@ -88,13 +88,13 @@ func DisableMouse() Msg { // to show the cursor, which is normally hidden for the duration of a Bubble // Tea program's lifetime. You will most likely not need to use this command. func HideCursor() Msg { - return disableMode(ansi.TextCursorEnableMode) + return disableModeMsg{ansi.TextCursorEnableMode} } // ShowCursor is a special command for manually instructing Bubble Tea to show // the cursor. func ShowCursor() Msg { - return enableMode(ansi.TextCursorEnableMode) + return enableModeMsg{ansi.TextCursorEnableMode} } // EnableBracketedPaste is a special command that tells the Bubble Tea program @@ -103,7 +103,7 @@ func ShowCursor() Msg { // Note that bracketed paste will be automatically disabled when the // program quits. func EnableBracketedPaste() Msg { - return enableMode(ansi.BracketedPasteMode) + return enableModeMsg{ansi.BracketedPasteMode} } // DisableBracketedPaste is a special command that tells the Bubble Tea program @@ -112,42 +112,32 @@ func EnableBracketedPaste() Msg { // Note that bracketed paste will be automatically disabled when the // program quits. func DisableBracketedPaste() Msg { - return disableMode(ansi.BracketedPasteMode) + return disableModeMsg{ansi.BracketedPasteMode} } // EnableGraphemeClustering is a special command that tells the Bubble Tea // program to enable grapheme clustering. This is enabled by default. func EnableGraphemeClustering() Msg { - return enableMode(ansi.GraphemeClusteringMode) + return enableModeMsg{ansi.GraphemeClusteringMode} } // DisableGraphemeClustering is a special command that tells the Bubble Tea // program to disable grapheme clustering. This mode will be disabled // automatically when the program quits. func DisableGraphemeClustering() Msg { - return disableMode(ansi.GraphemeClusteringMode) + return disableModeMsg{ansi.GraphemeClusteringMode} } // EnabledReportFocus is a special command that tells the Bubble Tea program // to enable focus reporting. -func EnabledReportFocus() Msg { return enableMode(ansi.FocusEventMode) } +func EnabledReportFocus() Msg { return enableModeMsg{ansi.FocusEventMode} } // DisabledReportFocus is a special command that tells the Bubble Tea program // to disable focus reporting. -func DisabledReportFocus() Msg { return disableMode(ansi.FocusEventMode) } +func DisabledReportFocus() Msg { return disableModeMsg{ansi.FocusEventMode} } // enableModeMsg is an internal message that signals to set a terminal mode. -type enableModeMsg ansi.DECMode - -// enableMode is an internal command that signals to set a terminal mode. -func enableMode(mode ansi.DECMode) Msg { - return enableModeMsg(mode) -} +type enableModeMsg struct{ ansi.Mode } // disableModeMsg is an internal message that signals to unset a terminal mode. -type disableModeMsg ansi.DECMode - -// disableMode is an internal command that signals to unset a terminal mode. -func disableMode(mode ansi.DECMode) Msg { - return disableModeMsg(mode) -} +type disableModeMsg struct{ ansi.Mode } diff --git a/screen_test.go b/screen_test.go index b151eabf8b..c244c417f3 100644 --- a/screen_test.go +++ b/screen_test.go @@ -7,94 +7,79 @@ import ( "testing" "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/exp/golden" ) func TestClearMsg(t *testing.T) { type test struct { - name string - cmds sequenceMsg - expected string + name string + cmds sequenceMsg } tests := []test{ { - name: "clear_screen", - cmds: []Cmd{ClearScreen}, - expected: "\x1b[?25l\x1b[?2004h\x1b[2J\x1b[H\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + name: "clear_screen", + cmds: []Cmd{ClearScreen}, }, { - name: "altscreen", - cmds: []Cmd{EnterAltScreen, ExitAltScreen}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l\x1b[?1049l\x1b[?25l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + name: "altscreen", + cmds: []Cmd{EnterAltScreen, ExitAltScreen}, }, { - name: "altscreen_autoexit", - cmds: []Cmd{EnterAltScreen}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l\x1b[H\rsuccess\r\n\x1b[2;H\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1049l\x1b[?25h", + name: "altscreen_autoexit", + cmds: []Cmd{EnterAltScreen}, }, { - name: "mouse_cellmotion", - cmds: []Cmd{EnableMouseCellMotion}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1002h\x1b[?1006h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", + name: "mouse_cellmotion", + cmds: []Cmd{EnableMouseCellMotion}, }, { - name: "mouse_allmotion", - cmds: []Cmd{EnableMouseAllMotion}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", + name: "mouse_allmotion", + cmds: []Cmd{EnableMouseAllMotion}, }, { - name: "mouse_disable", - cmds: []Cmd{EnableMouseAllMotion, DisableMouse}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006l\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + name: "mouse_disable", + cmds: []Cmd{EnableMouseAllMotion, DisableMouse}, }, { - name: "cursor_hide", - cmds: []Cmd{HideCursor}, - expected: "\x1b[?25l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + name: "cursor_hide", + cmds: []Cmd{HideCursor}, }, { - name: "cursor_hideshow", - cmds: []Cmd{HideCursor, ShowCursor}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?25h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l", + name: "cursor_hideshow", + cmds: []Cmd{HideCursor, ShowCursor}, }, { - name: "bp_stop_start", - cmds: []Cmd{DisableBracketedPaste, EnableBracketedPaste}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + name: "bp_stop_start", + cmds: []Cmd{DisableBracketedPaste, EnableBracketedPaste}, }, { - name: "read_set_clipboard", - cmds: []Cmd{ReadClipboard, SetClipboard("success")}, - expected: "\x1b[?25l\x1b[?2004h\x1b]52;c;?\a\x1b]52;c;c3VjY2Vzcw==\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + name: "read_set_clipboard", + cmds: []Cmd{ReadClipboard, SetClipboard("success")}, }, { - name: "bg_fg_cur_color", - cmds: []Cmd{RequestForegroundColor, RequestBackgroundColor, RequestCursorColor}, - expected: "\x1b[?25l\x1b[?2004h\x1b]10;?\a\x1b]11;?\a\x1b]12;?\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + name: "bg_fg_cur_color", + cmds: []Cmd{RequestForegroundColor, RequestBackgroundColor, RequestCursorColor}, }, { - name: "bg_set_color", - cmds: []Cmd{SetBackgroundColor(color.RGBA{255, 255, 255, 255})}, - expected: "\x1b[?25l\x1b[?2004h\x1b]11;#ffffff\a\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b]111\a", + name: "bg_set_color", + cmds: []Cmd{SetBackgroundColor(color.RGBA{255, 255, 255, 255})}, }, { - name: "grapheme_clustering", - cmds: []Cmd{EnableGraphemeClustering}, - expected: "\x1b[?25l\x1b[?2004h\x1b[?2027h\x1b[?2027$p\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?2027l", + name: "grapheme_clustering", + cmds: []Cmd{EnableGraphemeClustering}, }, } if runtime.GOOS == "windows" { // Windows supports enhanced keyboard features through the Windows API, not through ANSI sequences. tests = append(tests, test{ - name: "kitty_start", - cmds: []Cmd{DisableKeyboardEnhancements, EnableKeyboardEnhancements(WithKeyReleases)}, - expected: "\x1b[?25l\x1b[?2004h\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h", + name: "kitty_start_windows", + cmds: []Cmd{DisableKeyboardEnhancements, EnableKeyboardEnhancements(WithKeyReleases)}, }) } else { tests = append(tests, test{ - name: "kitty_start", - cmds: []Cmd{DisableKeyboardEnhancements, EnableKeyboardEnhancements(WithKeyReleases)}, - expected: "\x1b[?25l\x1b[?2004h\x1b[>4;1m\x1b[>3u\rsuccess\r\n\x1b[D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[>4;0m\x1b[>u", + name: "kitty_start_other", + cmds: []Cmd{DisableKeyboardEnhancements, EnableKeyboardEnhancements(WithKeyReleases)}, }) } @@ -105,19 +90,21 @@ func TestClearMsg(t *testing.T) { 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)) - test.cmds = append(test.cmds, Quit) - go p.Send(test.cmds) + // 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 { t.Fatal(err) } - - if buf.String() != test.expected { - t.Errorf("expected embedded sequence:\n%q\ngot:\n%q", test.expected, buf.String()) - } + golden.RequireEqual(t, buf.Bytes()) }) } } diff --git a/standard_renderer.go b/standard_renderer.go deleted file mode 100644 index 465ad491fa..0000000000 --- a/standard_renderer.go +++ /dev/null @@ -1,408 +0,0 @@ -package tea - -import ( - "bytes" - "fmt" - "io" - "strings" - "sync" - - "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" -) - -const ( - // defaultFramerate specifies the maximum interval at which we should - // update the view. - defaultFPS = 60 - maxFPS = 120 -) - -// standardRenderer is a framerate-based terminal renderer, updating the view -// at a given framerate to avoid overloading the terminal emulator. -// -// 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 { - mtx *sync.Mutex - out io.Writer - - // the color profile to use - profile colorprofile.Profile - - buf bytes.Buffer - queuedMessageLines []string - lastRender string - lastRenderedLines []string - linesRendered int - altLinesRendered int - useANSICompressor bool - once sync.Once - - // cursor visibility state - cursorHidden bool - - // essentially whether or not we're using the full size of the terminal - altScreenActive bool - - // renderer dimensions; usually the size of the window - width int - height int - - // lines explicitly set not to render - ignoreLines map[int]struct{} -} - -// newStandardRenderer creates a new renderer. Normally you'll want to initialize it -// with os.Stdout as the first argument. -func newStandardRenderer(p colorprofile.Profile) renderer { - r := &standardRenderer{ - mtx: &sync.Mutex{}, - queuedMessageLines: []string{}, - profile: p, - } - return r -} - -// setOutput sets the output for the renderer. -func (r *standardRenderer) setOutput(out io.Writer) { - r.mtx.Lock() - r.out = &colorprofile.Writer{ - Forward: out, - Profile: r.profile, - } - r.mtx.Unlock() -} - -// close closes the renderer and flushes any remaining data. -func (r *standardRenderer) close() (err error) { - // Move the cursor back to the beginning of the line - // NOTE: execute locks the mutex - r.execute(ansi.EraseEntireLine + "\r") - - return -} - -// execute writes the given sequence to the output. -func (r *standardRenderer) execute(seq string) { - r.mtx.Lock() - _, _ = io.WriteString(r.out, seq) - r.mtx.Unlock() -} - -// flush renders the buffer. -func (r *standardRenderer) flush() (err error) { - r.mtx.Lock() - defer r.mtx.Unlock() - - if r.buf.Len() == 0 || r.buf.String() == r.lastRender { - // Nothing to do. - return - } - - // Output buffer. - buf := &bytes.Buffer{} - - // Moving to the beginning of the section, that we rendered. - if r.altScreenActive { - buf.WriteString(ansi.CursorHomePosition) - } else if r.linesRendered > 1 { - buf.WriteString(ansi.CursorUp(r.linesRendered - 1)) - } - - newLines := strings.Split(r.buf.String(), "\n") - - // If we know the output's height, we can use it to determine how many - // lines we can render. We drop lines from the top of the render buffer if - // necessary, as we can't navigate the cursor into the terminal's scrollback - // buffer. - if r.height > 0 && len(newLines) > r.height { - newLines = newLines[len(newLines)-r.height:] - } - - flushQueuedMessages := len(r.queuedMessageLines) > 0 && !r.altScreenActive - - if flushQueuedMessages { - // Dump the lines we've queued up for printing. - for _, line := range r.queuedMessageLines { - if ansi.StringWidth(line) < r.width { - // We only erase the rest of the line when the line is shorter than - // the width of the terminal. When the cursor reaches the end of - // the line, any escape sequences that follow will only affect the - // last cell of the line. - - // Removing previously rendered content at the end of line. - line = line + ansi.EraseLineRight - } - - _, _ = buf.WriteString(line) - _, _ = buf.WriteString("\r\n") - } - // Clear the queued message lines. - r.queuedMessageLines = []string{} - } - - // Paint new lines. - for i := 0; i < len(newLines); i++ { - canSkip := !flushQueuedMessages && // Queuing messages triggers repaint -> we don't have access to previous frame content. - len(r.lastRenderedLines) > i && r.lastRenderedLines[i] == newLines[i] // Previously rendered line is the same. - - if _, ignore := r.ignoreLines[i]; ignore || canSkip { - // Unless this is the last line, move the cursor down. - if i < len(newLines)-1 { - buf.WriteString(ansi.CUD1) - } - continue - } - - if i == 0 && r.lastRender == "" { - // On first render, reset the cursor to the start of the line - // before writing anything. - buf.WriteByte('\r') - } - - line := newLines[i] - - // Truncate lines wider than the width of the window to avoid - // wrapping, which will mess up rendering. If we don't have the - // width of the window this will be ignored. - // - // Note that on Windows we only get the width of the window on - // program initialization, so after a resize this won't perform - // correctly (signal SIGWINCH is not supported on Windows). - if r.width > 0 { - line = ansi.Truncate(line, r.width, "") - } - - if ansi.StringWidth(line) < r.width { - // We only erase the rest of the line when the line is shorter than - // the width of the terminal. When the cursor reaches the end of - // the line, any escape sequences that follow will only affect the - // last cell of the line. - - // Removing previously rendered content at the end of line. - line = line + ansi.EraseLineRight - } - - _, _ = buf.WriteString(line) - - if i < len(newLines)-1 { - _, _ = buf.WriteString("\r\n") - } - } - - // Clearing left over content from last render. - if r.lastLinesRendered() > len(newLines) { - buf.WriteString(ansi.EraseScreenBelow) - } - - if r.altScreenActive { - r.altLinesRendered = len(newLines) - } else { - r.linesRendered = len(newLines) - } - - // Make sure the cursor is at the start of the last line to keep rendering - // behavior consistent. - if r.altScreenActive { - // 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. - buf.WriteString(ansi.CursorPosition(0, len(newLines))) - } else { - buf.WriteString(ansi.CursorBackward(r.width)) - } - - _, err = r.out.Write(buf.Bytes()) - r.lastRender = r.buf.String() - - // Save previously rendered lines for comparison in the next render. If we - // don't do this, we can't skip rendering lines that haven't changed. - // See /~https://github.com/charmbracelet/bubbletea/pull/1233 - r.lastRenderedLines = newLines - r.buf.Reset() - return -} - -// lastLinesRendered returns the number of lines rendered lastly. -func (r *standardRenderer) lastLinesRendered() int { - if r.altScreenActive { - return r.altLinesRendered - } - return r.linesRendered -} - -// render renders the frame to the internal buffer. The buffer will be -// outputted via the ticker which calls flush(). -func (r *standardRenderer) render(s string) { - r.mtx.Lock() - defer r.mtx.Unlock() - r.buf.Reset() - - // If an empty string was passed we should clear existing output and - // rendering nothing. Rather than introduce additional state to manage - // this, we render a single space as a simple (albeit less correct) - // solution. - if s == "" { - s = " " - } - - _, _ = r.buf.WriteString(s) -} - -// repaint forces a full repaint. -func (r *standardRenderer) repaint() { - r.lastRender = "" - r.lastRenderedLines = nil -} - -// reset resets the standardRenderer to its initial state. -func (r *standardRenderer) reset() { - r.repaint() -} - -func (r *standardRenderer) clearScreen() { - r.execute(ansi.EraseEntireScreen + ansi.HomeCursorPosition) - r.repaint() -} - -// setAltScreenBuffer restores the terminal screen buffer state. -func (r *standardRenderer) setAltScreenBuffer(on bool) { - if on { - // Ensure that the terminal is cleared, even when it doesn't support - // alt screen (or alt screen support is disabled, like GNU screen by - // default). - r.execute(ansi.EraseEntireScreen) - r.execute(ansi.HomeCursorPosition) - } - - // cmd.exe and other terminals keep separate cursor states for the AltScreen - // and the main buffer. We have to explicitly reset the cursor visibility - // whenever we exit AltScreen. - if r.cursorHidden { - r.execute(ansi.HideCursor) - } else { - r.execute(ansi.ShowCursor) - } -} - -// update handles internal messages for the renderer. -func (r *standardRenderer) update(msg Msg) { - switch msg := msg.(type) { - case ColorProfileMsg: - r.profile = msg.Profile - - case enableModeMsg: - switch ansi.DECMode(msg) { - case ansi.AltScreenSaveCursorMode: - if r.altScreenActive { - return - } - - r.setAltScreenBuffer(true) - r.altScreenActive = true - r.repaint() - case ansi.TextCursorEnableMode: - if !r.cursorHidden { - return - } - - r.cursorHidden = false - } - - case disableModeMsg: - switch ansi.DECMode(msg) { - case ansi.AltScreenSaveCursorMode: - if !r.altScreenActive { - return - } - - r.setAltScreenBuffer(false) - r.altScreenActive = false - r.repaint() - case ansi.TextCursorEnableMode: - if r.cursorHidden { - return - } - - r.cursorHidden = true - } - - case rendererWriter: - r.setOutput(msg.Writer) - - case WindowSizeMsg: - r.resize(msg.Width, msg.Height) - - case clearScreenMsg: - r.clearScreen() - - case printLineMessage: - r.insertAbove(msg.messageBody) - - case repaintMsg: - // Force a repaint by clearing the render cache as we slide into a - // render. - r.mtx.Lock() - r.repaint() - r.mtx.Unlock() - } -} - -// resize sets the size of the terminal. -func (r *standardRenderer) resize(w int, h int) { - r.mtx.Lock() - r.width = w - r.height = h - r.repaint() - r.mtx.Unlock() -} - -// insertAbove inserts lines above the current frame. This only works in -// inline mode. -func (r *standardRenderer) insertAbove(s string) { - if r.altScreenActive { - return - } - - lines := strings.Split(s, "\n") - r.mtx.Lock() - r.queuedMessageLines = append(r.queuedMessageLines, lines...) - r.repaint() - r.mtx.Unlock() -} - -type printLineMessage struct { - messageBody string -} - -// Println prints above the Program. This output is unmanaged by the program and -// will persist across renders by the Program. -// -// Unlike fmt.Println (but similar to log.Println) the message will be print on -// its own line. -// -// If the altscreen is active no output will be printed. -func Println(args ...interface{}) Cmd { - return func() Msg { - return printLineMessage{ - messageBody: fmt.Sprint(args...), - } - } -} - -// Printf prints above the Program. It takes a format template followed by -// values similar to fmt.Printf. This output is unmanaged by the program and -// will persist across renders by the Program. -// -// Unlike fmt.Printf (but similar to log.Printf) the message will be print on -// its own line. -// -// If the altscreen is active no output will be printed. -func Printf(template string, args ...interface{}) Cmd { - return func() Msg { - return printLineMessage{ - messageBody: fmt.Sprintf(template, args...), - } - } -} diff --git a/tea.go b/tea.go index 9907dfe52b..43aa93008a 100644 --- a/tea.go +++ b/tea.go @@ -20,7 +20,6 @@ import ( "runtime" "runtime/debug" "strconv" - "strings" "sync" "sync/atomic" "syscall" @@ -201,7 +200,7 @@ type Program struct { readLoopDone chan struct{} // modes keeps track of terminal modes that have been enabled or disabled. - modes map[ansi.DECMode]bool + modes ansi.Modes ignoreSignals uint32 filter func(Model, Msg) Msg @@ -226,8 +225,11 @@ type Program struct { // when the program is resumed. setBg, setFg, setCc color.Color - // exp stores program experimental features. - exp experimentalOptions + // Initial window size. Mainly used for testing. + width, height int + + // whether to use hard tabs to optimize cursor movements + useHardTabs bool } // Quit is a special command that tells the Bubble Tea program to exit. @@ -276,8 +278,7 @@ func NewProgram(model Model, opts ...ProgramOption) *Program { initialModel: model, msgs: make(chan Msg), rendererDone: make(chan struct{}), - modes: make(map[ansi.DECMode]bool), - exp: experimentalOptions{}, + modes: ansi.Modes{}, } // Apply all options to the program. @@ -327,12 +328,6 @@ func NewProgram(model Model, opts ...ProgramOption) *Program { } } - // Experimental features. Right now, we only have one experimental feature - // to use the new cell buffer as a default renderer. - if exp := p.getenv("TEA_EXPERIMENTAL"); exp != "" { - p.exp = strings.Split(exp, ",") - } - return p } @@ -479,32 +474,46 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { switch msg.Mode { case ansi.GraphemeClusteringMode: // 1 means mode is set (see DECRPM). - p.modes[ansi.GraphemeClusteringMode] = msg.Value == 1 || msg.Value == 3 + p.modes[ansi.GraphemeClusteringMode] = msg.Value } case enableModeMsg: - mode := ansi.DECMode(msg) - if on, ok := p.modes[mode]; ok && on { + mode := p.modes.Get(msg.Mode) + if mode.IsSet() { break } - p.execute(fmt.Sprintf("\x1b[?%dh", mode.Mode())) - p.modes[mode] = true - switch mode { + p.modes.Set(msg.Mode) + + switch msg.Mode { + case ansi.AltScreenSaveCursorMode: + p.renderer.enterAltScreen() + case ansi.TextCursorEnableMode: + p.renderer.showCursor() case ansi.GraphemeClusteringMode: // We store the state of grapheme clustering after we enable it // and get a response in the eventLoop. - p.execute(ansi.RequestGraphemeClusteringMode) + p.execute(ansi.SetGraphemeClusteringMode + ansi.RequestGraphemeClusteringMode) + default: + p.execute(ansi.SetMode(msg.Mode)) } case disableModeMsg: - mode := ansi.DECMode(msg) - if on, ok := p.modes[mode]; ok && !on { + mode := p.modes.Get(msg.Mode) + if mode.IsReset() { break } - p.execute(fmt.Sprintf("\x1b[?%dl", mode)) - p.modes[mode] = false + p.modes.Reset(msg.Mode) + + switch msg.Mode { + case ansi.AltScreenSaveCursorMode: + p.renderer.exitAltScreen() + case ansi.TextCursorEnableMode: + p.renderer.hideCursor() + default: + p.execute(ansi.ResetMode(msg.Mode)) + } case readClipboardMsg: p.execute(ansi.RequestSystemClipboard) @@ -644,15 +653,30 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { case setWindowTitleMsg: p.execute(ansi.SetWindowTitle(string(msg))) + case WindowSizeMsg: + p.renderer.resize(msg.Width, msg.Height) + case windowSizeMsg: go p.checkResize() case requestCursorPosMsg: p.execute(ansi.RequestCursorPosition) - } - // Process internal messages for the renderer. - p.renderer.update(msg) + case setCursorPosMsg: + p.renderer.moveTo(msg.X, msg.Y) + + case printLineMessage: + p.renderer.insertAbove(msg.messageBody) + + case repaintMsg: + p.renderer.repaint() + + case clearScreenMsg: + p.renderer.clearScreen() + + case ColorProfileMsg: + p.renderer.setColorProfile(msg.Profile) + } var cmd Cmd model, cmd = model.Update(msg) // run update @@ -726,24 +750,22 @@ func (p *Program) Run() (Model, error) { if err := p.initTerminal(); err != nil { return p.initialModel, err } + if p.renderer == nil { + // If no renderer is set use the ferocious one. + p.renderer = newScreenRenderer(p.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) } + // Set the color profile on the renderer and send it to the program. + p.renderer.setColorProfile(p.profile) go p.Send(ColorProfileMsg{p.profile}) - if p.renderer == nil { - // If no renderer is set use the ferocious one. - if p.startupOptions&withFerociousRenderer != 0 || p.exp.has(experimentalFerocious) { - p.renderer = newFerociousRenderer(p.profile) - } else { - p.renderer = newStandardRenderer(p.profile) - } - } - // Set the renderer output. - p.renderer.update(rendererWriter{p.output}) + // Get the initial window size. + resizeMsg := WindowSizeMsg{Width: p.width, Height: p.height} if p.ttyOutput != nil { // Set the initial size of the terminal. w, h, err := term.GetSize(p.ttyOutput.Fd()) @@ -751,13 +773,13 @@ func (p *Program) Run() (Model, error) { return p.initialModel, err } - // Send the initial size to the program. - var resizeMsg WindowSizeMsg - resizeMsg.Width = w - resizeMsg.Height = h - go p.Send(resizeMsg) + resizeMsg.Width, resizeMsg.Height = w, h } + // Send the initial size to the program. + go p.Send(resizeMsg) + p.renderer.resize(resizeMsg.Width, resizeMsg.Height) + // Init the input reader and initial model. model := p.initialModel if p.input != nil { @@ -766,23 +788,24 @@ func (p *Program) Run() (Model, error) { } } - // Hide the cursor before starting the renderer. - p.modes[ansi.TextCursorEnableMode] = false - p.execute(ansi.HideCursor) - p.renderer.update(disableMode(ansi.TextCursorEnableMode)) + // Hide the cursor before starting the renderer. This is handled by the + // renderer so we don't need to write the sequence here. + 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 { - p.execute(ansi.SetAltScreenSaveCursorMode) - p.modes[ansi.AltScreenSaveCursorMode] = true - p.renderer.update(enableMode(ansi.AltScreenSaveCursorMode)) + // 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[ansi.BracketedPasteMode] = true + p.modes.Set(ansi.BracketedPasteMode) } if p.startupOptions&withGraphemeClustering != 0 { p.execute(ansi.SetGraphemeClusteringMode) @@ -791,20 +814,16 @@ func (p *Program) Run() (Model, error) { // a response in the eventLoop. } if p.startupOptions&withMouseCellMotion != 0 { - p.execute(ansi.SetButtonEventMouseMode) - p.execute(ansi.SetSgrExtMouseMode) - p.modes[ansi.ButtonEventMouseMode] = true - p.modes[ansi.SgrExtMouseMode] = true + p.execute(ansi.SetButtonEventMouseMode + ansi.SetSgrExtMouseMode) + p.modes.Set(ansi.ButtonEventMouseMode, ansi.SgrExtMouseMode) } else if p.startupOptions&withMouseAllMotion != 0 { - p.execute(ansi.SetAnyEventMouseMode) - p.execute(ansi.SetSgrExtMouseMode) - p.modes[ansi.AnyEventMouseMode] = true - p.modes[ansi.SgrExtMouseMode] = true + p.execute(ansi.SetAnyEventMouseMode + ansi.SetSgrExtMouseMode) + p.modes.Set(ansi.AnyEventMouseMode, ansi.SgrExtMouseMode) } if p.startupOptions&withReportFocus != 0 { p.execute(ansi.SetFocusEventMode) - p.modes[ansi.FocusEventMode] = true + p.modes.Set(ansi.FocusEventMode) } if p.startupOptions&withKeyboardEnhancements != 0 && runtime.GOOS != "windows" { // We use the Windows Console API which supports keyboard @@ -978,20 +997,13 @@ func (p *Program) RestoreTerminal() error { if err := p.initInputReader(); err != nil { return err } - if p.modes[ansi.AltScreenSaveCursorMode] { - p.execute(ansi.SetAltScreenSaveCursorMode) - } else { + if p.modes.IsReset(ansi.AltScreenSaveCursorMode) { // entering alt screen already causes a repaint. go p.Send(repaintMsg{}) } p.startRenderer() - if !p.modes[ansi.TextCursorEnableMode] { - p.execute(ansi.HideCursor) - } else { - p.execute(ansi.ShowCursor) - } - if p.modes[ansi.BracketedPasteMode] { + if p.modes.IsSet(ansi.BracketedPasteMode) { p.execute(ansi.SetBracketedPasteMode) } if p.keyboard.modifyOtherKeys != 0 { @@ -1000,10 +1012,10 @@ func (p *Program) RestoreTerminal() error { if p.keyboard.kittyFlags != 0 { p.execute(ansi.PushKittyKeyboard(p.keyboard.kittyFlags)) } - if p.modes[ansi.FocusEventMode] { + if p.modes.IsSet(ansi.FocusEventMode) { p.execute(ansi.SetFocusEventMode) } - if p.modes[ansi.ButtonEventMouseMode] || p.modes[ansi.AnyEventMouseMode] { + if p.modes.IsSet(ansi.ButtonEventMouseMode) || p.modes.IsSet(ansi.AnyEventMouseMode) { if p.startupOptions&withMouseCellMotion != 0 { p.execute(ansi.SetButtonEventMouseMode) p.execute(ansi.SetSgrExtMouseMode) @@ -1012,7 +1024,7 @@ func (p *Program) RestoreTerminal() error { p.execute(ansi.SetSgrExtMouseMode) } } - if p.modes[ansi.GraphemeClusteringMode] { + if p.modes.IsSet(ansi.GraphemeClusteringMode) { p.execute(ansi.SetGraphemeClusteringMode) } diff --git a/testdata/TestClearMsg/altscreen.golden b/testdata/TestClearMsg/altscreen.golden new file mode 100644 index 0000000000..95737454f2 --- /dev/null +++ b/testdata/TestClearMsg/altscreen.golden @@ -0,0 +1,4 @@ +[?2004h[?25l + +Msuccess +[?25h[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/altscreen_autoexit.golden b/testdata/TestClearMsg/altscreen_autoexit.golden new file mode 100644 index 0000000000..e7a2aaa72b --- /dev/null +++ b/testdata/TestClearMsg/altscreen_autoexit.golden @@ -0,0 +1 @@ +[?2004h[?1049h[?25lsuccess [?1049l[?25h[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/bg_fg_cur_color.golden b/testdata/TestClearMsg/bg_fg_cur_color.golden new file mode 100644 index 0000000000..9a9bd029ef --- /dev/null +++ b/testdata/TestClearMsg/bg_fg_cur_color.golden @@ -0,0 +1,4 @@ +[?2004h]10;?]11;?]12;?[?25l + +Msuccess +[?25h[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/bg_set_color.golden b/testdata/TestClearMsg/bg_set_color.golden new file mode 100644 index 0000000000..80a10499f1 --- /dev/null +++ b/testdata/TestClearMsg/bg_set_color.golden @@ -0,0 +1,4 @@ +[?2004h]11;#ffffff[?25l + +Msuccess +[?25h[?2004l]111 \ No newline at end of file diff --git a/testdata/TestClearMsg/bp_stop_start.golden b/testdata/TestClearMsg/bp_stop_start.golden new file mode 100644 index 0000000000..cfedd50d68 --- /dev/null +++ b/testdata/TestClearMsg/bp_stop_start.golden @@ -0,0 +1,4 @@ +[?2004h[?2004l[?2004h[?25l + +Msuccess +[?25h[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/clear_screen.golden b/testdata/TestClearMsg/clear_screen.golden new file mode 100644 index 0000000000..95737454f2 --- /dev/null +++ b/testdata/TestClearMsg/clear_screen.golden @@ -0,0 +1,4 @@ +[?2004h[?25l + +Msuccess +[?25h[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/cursor_hide.golden b/testdata/TestClearMsg/cursor_hide.golden new file mode 100644 index 0000000000..f74ac40471 --- /dev/null +++ b/testdata/TestClearMsg/cursor_hide.golden @@ -0,0 +1,4 @@ +[?2004h[?25l + +Msuccess +[?25h[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/cursor_hideshow.golden b/testdata/TestClearMsg/cursor_hideshow.golden new file mode 100644 index 0000000000..911a2a5ae4 --- /dev/null +++ b/testdata/TestClearMsg/cursor_hideshow.golden @@ -0,0 +1,4 @@ +[?2004h[?25l + +Msuccess [?25h +[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/grapheme_clustering.golden b/testdata/TestClearMsg/grapheme_clustering.golden new file mode 100644 index 0000000000..3776fd72b5 --- /dev/null +++ b/testdata/TestClearMsg/grapheme_clustering.golden @@ -0,0 +1,4 @@ +[?2004h[?2027h[?2027$p[?25l + +Msuccess +[?25h[?2004l[?2027l \ No newline at end of file diff --git a/testdata/TestClearMsg/kitty_start_other.golden b/testdata/TestClearMsg/kitty_start_other.golden new file mode 100644 index 0000000000..77916d6ad9 --- /dev/null +++ b/testdata/TestClearMsg/kitty_start_other.golden @@ -0,0 +1,4 @@ +[?2004h[>4;1m[>3u[?25l + +Msuccess +[?25h[?2004l[>4;0m[>u \ No newline at end of file diff --git a/testdata/TestClearMsg/kitty_start_windows.golden b/testdata/TestClearMsg/kitty_start_windows.golden new file mode 100644 index 0000000000..f74ac40471 --- /dev/null +++ b/testdata/TestClearMsg/kitty_start_windows.golden @@ -0,0 +1,4 @@ +[?2004h[?25l + +Msuccess +[?25h[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/mouse_allmotion.golden b/testdata/TestClearMsg/mouse_allmotion.golden new file mode 100644 index 0000000000..62fa670604 --- /dev/null +++ b/testdata/TestClearMsg/mouse_allmotion.golden @@ -0,0 +1,4 @@ +[?2004h[?1003h[?1006h[?25l + +Msuccess +[?25h[?2004l[?1003l[?1006l \ No newline at end of file diff --git a/testdata/TestClearMsg/mouse_cellmotion.golden b/testdata/TestClearMsg/mouse_cellmotion.golden new file mode 100644 index 0000000000..fcb3f3737e --- /dev/null +++ b/testdata/TestClearMsg/mouse_cellmotion.golden @@ -0,0 +1,4 @@ +[?2004h[?1002h[?1006h[?25l + +Msuccess +[?25h[?2004l[?1002l[?1006l \ No newline at end of file diff --git a/testdata/TestClearMsg/mouse_disable.golden b/testdata/TestClearMsg/mouse_disable.golden new file mode 100644 index 0000000000..4e3548ea45 --- /dev/null +++ b/testdata/TestClearMsg/mouse_disable.golden @@ -0,0 +1,4 @@ +[?2004h[?1003h[?1006h[?1002l[?1003l[?1006l[?25l + +Msuccess +[?25h[?2004l \ No newline at end of file diff --git a/testdata/TestClearMsg/read_set_clipboard.golden b/testdata/TestClearMsg/read_set_clipboard.golden new file mode 100644 index 0000000000..4a040cc9f9 --- /dev/null +++ b/testdata/TestClearMsg/read_set_clipboard.golden @@ -0,0 +1,4 @@ +[?2004h]52;c;?]52;c;c3VjY2Vzcw==[?25l + +Msuccess +[?25h[?2004l \ No newline at end of file diff --git a/tty.go b/tty.go index e6dbc0467f..ec04ac8907 100644 --- a/tty.go +++ b/tty.go @@ -38,15 +38,24 @@ func (p *Program) initTerminal() error { // restoreTerminalState restores the terminal to the state prior to running the // Bubble Tea program. func (p *Program) restoreTerminalState() error { - if p.modes[ansi.BracketedPasteMode] { + // 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 + // [cellbuf.Screen.Close]. + + if p.modes.IsSet(ansi.BracketedPasteMode) { p.execute(ansi.ResetBracketedPasteMode) } - if !p.modes[ansi.TextCursorEnableMode] { - p.execute(ansi.ShowCursor) - } - if p.modes[ansi.ButtonEventMouseMode] || p.modes[ansi.AnyEventMouseMode] { - p.execute(ansi.ResetButtonEventMouseMode) - p.execute(ansi.ResetAnyEventMouseMode) + + btnEvents := p.modes.IsSet(ansi.ButtonEventMouseMode) + allEvents := p.modes.IsSet(ansi.AnyEventMouseMode) + if btnEvents || allEvents { + if btnEvents { + p.execute(ansi.ResetButtonEventMouseMode) + } + if allEvents { + p.execute(ansi.ResetAnyEventMouseMode) + } p.execute(ansi.ResetSgrExtMouseMode) } if p.keyboard.modifyOtherKeys != 0 { @@ -55,22 +64,12 @@ func (p *Program) restoreTerminalState() error { if p.keyboard.kittyFlags != 0 { p.execute(ansi.DisableKittyKeyboard) } - if p.modes[ansi.FocusEventMode] { + if p.modes.IsSet(ansi.FocusEventMode) { p.execute(ansi.ResetFocusEventMode) } - if p.modes[ansi.GraphemeClusteringMode] { + if p.modes.IsSet(ansi.GraphemeClusteringMode) { p.execute(ansi.ResetGraphemeClusteringMode) } - if p.modes[ansi.AltScreenSaveCursorMode] { - p.execute(ansi.ResetAltScreenSaveCursorMode) - // cmd.exe and other terminals keep separate cursor states for the AltScreen - // and the main buffer. We have to explicitly reset the cursor visibility - // whenever we exit AltScreen. - p.execute(ansi.ShowCursor) - - // give the terminal a moment to catch up - time.Sleep(time.Millisecond * 10) //nolint:gomnd - } // Restore terminal colors. if p.setBg != nil { diff --git a/tty_unix.go b/tty_unix.go index 36c9b3688d..6134bbcb8d 100644 --- a/tty_unix.go +++ b/tty_unix.go @@ -10,6 +10,7 @@ import ( "syscall" "github.com/charmbracelet/x/term" + "golang.org/x/sys/unix" ) func (p *Program) initInput() (err error) { @@ -20,6 +21,10 @@ func (p *Program) initInput() (err error) { if err != nil { return fmt.Errorf("error entering raw mode: %w", err) } + + // OPTIM: We can use hard tabs to optimize cursor movements if the + // terminal doesn't have tab expansion enabled. + p.useHardTabs = p.previousTtyInputState.Oflag&unix.TABDLY == 0 } if f, ok := p.output.Writer().(term.File); ok && term.IsTerminal(f.Fd()) { diff --git a/utils.go b/utils.go deleted file mode 100644 index 5de3fc7ead..0000000000 --- a/utils.go +++ /dev/null @@ -1,11 +0,0 @@ -package tea - -func clamp(x, min, max int) int { - if x < min { - return min - } - if x > max { - return max - } - return x -}