Repository: charmbracelet/bubbletea
Pull request #1191 (2024-10-17 20:48:59)

(v2) feat: support setting cursor position
You can now set the cursor position in Bubble Tea cell renderer.

```go
// func SetCursorPosition(x, y int) Cmd
// In your Update
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  // ...
  return m, tea.SetCursorPosition(0, 3) // set the cursor position to the beginning of the 4th line
}
```

Fixes: https://github.com/charmbracelet/bubbletea/issues/918
Fixes: https://github.com/charmbracelet/bubbletea/issues/874


Patch:
diff --git a/cell_renderer.go b/cell_renderer.go
index 46b4da7..e552c9f 100644
--- a/cell_renderer.go
+++ b/cell_renderer.go
@@ -11,6 +11,8 @@ import (
 	"github.com/charmbracelet/x/cellbuf"
 )
 
+var undefPoint = image.Pt(-1, -1)
+
 // cursor represents a terminal cursor.
 type cursor struct {
 	image.Point
@@ -88,7 +90,8 @@ type cellRenderer struct {
 func newCellRenderer() *cellRenderer {
 	r := &cellRenderer{
 		// TODO: Update this if Grapheme Clustering is supported.
-		method: cellbuf.WcWidth,
+		method:   cellbuf.WcWidth,
+		finalCur: undefPoint,
 	}
 	r.reset()
 	return r
@@ -148,7 +151,8 @@ func (c *cellRenderer) flush() error {
 	c.mtx.Lock()
 	defer c.mtx.Unlock()
 
-	if c.frame == *c.lastRender && c.lastHeight == c.scr.Height() {
+	if c.finalCur == c.scr.cur.Point && len(c.queueAbove) == 0 &&
+		c.frame == *c.lastRender && c.lastHeight == c.scr.Height() {
 		return nil
 	}
 
@@ -177,6 +181,17 @@ func (c *cellRenderer) flush() error {
 
 	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 := 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
@@ -186,7 +201,7 @@ func (c *cellRenderer) flush() error {
 		return nil
 	}
 
-	if !c.cursorHidden {
+	if shouldHideCursor {
 		// Hide the cursor while rendering to avoid flickering.
 		render = ansi.HideCursor + render + ansi.ShowCursor
 	}
@@ -219,7 +234,7 @@ func (c *cellRenderer) 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 = image.Pt(-1, -1)
+	c.scrs[1].cur.Point = undefPoint
 	if c.altScreen {
 		c.scr = &c.scrs[1]
 		c.lastRender = &c.lastRenders[1]
@@ -325,6 +340,20 @@ func (c *cellRenderer) update(msg Msg) {
 
 			c.cursorHidden = true
 		}
+
+	case setCursorPosMsg:
+		x, y := msg.X, msg.Y
+		if x < 0 {
+			x = c.scr.cur.X
+		} else if x >= c.scr.Width() {
+			x = c.scr.Width() - 1
+		}
+		if y < 0 {
+			y = c.scr.cur.Y
+		} else if y >= c.scr.Height() {
+			y = c.scr.Height() - 1
+		}
+		c.finalCur = image.Pt(x, y)
 	}
 }
 
@@ -339,10 +368,10 @@ func (c *cellRenderer) changes() {
 	}
 
 	height := c.scr.Height()
-	var x int
 	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)
@@ -540,9 +569,9 @@ func (c *cellRenderer) moveCursor(x, y int) {
 				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(c.scr.cur.X - x))
+					c.buf.WriteString(ansi.CursorLeft(dx))
 				} else {
-					c.buf.Write(bytes.Repeat([]byte{ansi.BS}, dx))
+					c.buf.WriteString(strings.Repeat("\b", dx))
 				}
 			}
 		}
diff --git a/cursor.go b/cursor.go
index cfb2bc1..61986e5 100644
--- a/cursor.go
+++ b/cursor.go
@@ -1,13 +1,9 @@
 package tea
 
-// CursorPositionMsg is a message that represents the terminal cursor position.
-type CursorPositionMsg struct {
-	// Row is the row number.
-	Row int
+import "image"
 
-	// Column is the column number.
-	Column int
-}
+// CursorPositionMsg is a message that represents the terminal cursor position.
+type CursorPositionMsg image.Point
 
 // CursorStyle is a style that represents the terminal cursor.
 type CursorStyle int
@@ -44,3 +40,15 @@ func SetCursorStyle(style CursorStyle, steady bool) Cmd {
 		return setCursorStyle(style)
 	}
 }
+
+// setCursorPosMsg represents a message to set the cursor position.
+type setCursorPosMsg image.Point
+
+// SetCursorPosition sets the cursor position to the specified relative
+// coordinates. Using -1 for either x or y will not change the cursor position
+// for that axis.
+func SetCursorPosition(x, y int) Cmd {
+	return func() Msg {
+		return setCursorPosMsg{x, y}
+	}
+}
diff --git a/examples/go.mod b/examples/go.mod
index d6d9c80..f630429 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -21,7 +21,7 @@ require (
 	github.com/aymanbagabas/go-udiff v0.2.0 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/x/ansi v0.4.0 // indirect
-	github.com/charmbracelet/x/cellbuf v0.0.0-20241015153255-110a4e49aee6 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.1 // indirect
 	github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect
 	github.com/charmbracelet/x/term v0.2.0 // indirect
 	github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 // indirect
diff --git a/examples/go.sum b/examples/go.sum
index f15b32c..e211f31 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -24,8 +24,8 @@ github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA
 github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
 github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU=
 github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
-github.com/charmbracelet/x/cellbuf v0.0.0-20241015153255-110a4e49aee6 h1:z6Jq8MwJxOqzNSLEyAfRrHvUGFU7SgTFgZrqFHAJIu4=
-github.com/charmbracelet/x/cellbuf v0.0.0-20241015153255-110a4e49aee6/go.mod h1:mFpvlGowTd0Fiv4TdoKyGZaZdigSUHtBJralbADonwE=
+github.com/charmbracelet/x/cellbuf v0.0.1 h1:us+C8dDO1NzO+2I8WItenR6V/oBZr7oVmhU4n3mi4Yg=
+github.com/charmbracelet/x/cellbuf v0.0.1/go.mod h1:mFpvlGowTd0Fiv4TdoKyGZaZdigSUHtBJralbADonwE=
 github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
 github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20241016014612-3b4d04043233 h1:2bTR/MtnJuq9RrCZSPwCOO34YSDByKL6nzXQMnsKK6U=
diff --git a/parse.go b/parse.go
index 17f81c9..6f8c8d1 100644
--- a/parse.go
+++ b/parse.go
@@ -256,7 +256,7 @@ func parseCsi(b []byte) (int, Msg) {
 		// This report may return a third parameter representing the page
 		// number, but we don't really need it.
 		if paramsLen >= 2 && csi.Param(0) != -1 && csi.Param(1) != -1 {
-			return i, CursorPositionMsg{Row: csi.Param(0), Column: csi.Param(1)}
+			return i, CursorPositionMsg{Y: csi.Param(0) - 1, X: csi.Param(1) - 1}
 		}
 	case 'm' | '<'<<parser.MarkerShift, 'M' | '<'<<parser.MarkerShift:
 		// Handle SGR mouse
@@ -275,7 +275,7 @@ func parseCsi(b []byte) (int, Msg) {
 	case 'R':
 		// Cursor position report OR modified F3
 		if paramsLen == 2 && csi.Param(0) != -1 && csi.Param(1) != -1 {
-			m := CursorPositionMsg{Row: csi.Param(0), Column: csi.Param(1)}
+			m := CursorPositionMsg{Y: csi.Param(0) - 1, X: csi.Param(1) - 1}
 			if csi.Param(0) == 1 && csi.Param(1)-1 <= int(ModMeta|ModShift|ModAlt|ModCtrl) {
 				// XXX: We cannot differentiate between cursor position report and
 				// CSI 1 ; <mod> R (which is modified F3) when the cursor is at the


Test patch:
diff --git a/key_test.go b/key_test.go
index aa6670b..d7ba77c 100644
--- a/key_test.go
+++ b/key_test.go
@@ -64,7 +64,7 @@ func buildBaseSeqTests() []seqTest {
 		// position report having the same sequence. See [parseCsi] for more
 		// information.
 		if f3CurPosRegexp.MatchString(seq) {
-			st.msgs = []Msg{k, CursorPositionMsg{Row: 1, Column: int(key.Mod) + 1}}
+			st.msgs = []Msg{k, CursorPositionMsg{Y: 0, X: int(key.Mod)}}
 		}
 		td = append(td, st)
 	}