From 9d5d140b3cfd8a9456f45f8c6d0a577eef873553 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 10:21:58 -0500 Subject: [PATCH] chore(textarea,cursor): flesh out real cursor API --- cursor/cursor.go | 45 +++++++++++++++++++----- textarea/textarea.go | 78 ++++++++++++++++++++++++++++-------------- textinput/textinput.go | 4 +-- 3 files changed, 92 insertions(+), 35 deletions(-) diff --git a/cursor/cursor.go b/cursor/cursor.go index c49781e..2ee2ef5 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -1,3 +1,8 @@ +// Package cursor provides a virtual cursor to support the textinput and +// textarea elements. +// +// Both the textinput and textarea elements also use this package to style an +// optional real cursor. package cursor import ( @@ -51,25 +56,49 @@ func (c Mode) String() string { // Model is the Bubble Tea model for this cursor element. type Model struct { - BlinkSpeed time.Duration - // Style for styling the cursor block. + // Style styles the cursor block. + // + // For real cursors, the foreground color set here will be used as the + // cursor color. Style lipgloss.Style - // TextStyle is the style used for the cursor when it is hidden (when blinking). - // I.e. displaying normal text. - TextStyle lipgloss.Style + + // Shape is the cursor shape. The following shapes are available: + // + // - tea.CursorBlock + // - tea.CursorUnderline + // - tea.CursorBar + // + // This is only used for real cursors. + Shape tea.CursorShape + + // BlinkedStyle is the style used for the cursor when it is blinking + // (hidden), i.e. displaying normal text. This has no effect on real + // cursors. + BlinkedStyle lipgloss.Style + + // BlinkSpeed is the speed at which the cursor blinks. This has no effect + // on real cursors as well as no effect if the [CursorMode] is not set to + // [CursorBlink]. + BlinkSpeed time.Duration + + // Blink is the cursor blink state. + Blink bool // char is the character under the cursor char string + // The ID of this Model as it relates to other cursors id int + // focus indicates whether the containing input is focused focus bool - // Cursor Blink state. - Blink bool + // Used to manage cursor blink blinkCtx *blinkCtx + // The ID of the blink message we're expecting to receive. blinkTag int + // mode determines the behavior of the cursor mode Mode } @@ -212,7 +241,7 @@ func (m *Model) SetChar(char string) { // View displays the cursor. func (m Model) View() string { if m.Blink { - return m.TextStyle.Inline(true).Render(m.char) + return m.BlinkedStyle.Inline(true).Render(m.char) } return m.Style.Inline(true).Reverse(true).Render(m.char) } diff --git a/textarea/textarea.go b/textarea/textarea.go index d35c6ca..9de266c 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -102,19 +102,25 @@ func DefaultKeyMap() KeyMap { type LineInfo struct { // Width is the number of columns in the line. Width int + // CharWidth is the number of characters in the line to account for // double-width runes. CharWidth int + // Height is the number of rows in the line. Height int + // StartColumn is the index of the first column of the line. StartColumn int + // ColumnOffset is the number of columns that the cursor is offset from the // start of the line. ColumnOffset int + // RowOffset is the number of rows that the cursor is offset from the start // of the line. RowOffset int + // CharOffset is the number of characters that the cursor is offset // from the start of the line. This will generally be equivalent to // ColumnOffset, but will be different there are double-width runes before @@ -231,8 +237,27 @@ type Model struct { // when switching focus states. activeStyle *StyleState - // VirtualCursor is the text area cursor. - VirtualCursor cursor.Model + // Cursor manages the virtual cursor and contains styling settings for + // both a real and virtual cursor. + Cursor cursor.Model + + // UseRealCursor determines whether or not to use the real cursor. By + // default, a virtual cursor is used. + // + // When [UseRealCursor] is enabled, the virual cursor is hidden and you + // must use [Model.RealCursor] to produce a real cursor for a [tea.Frame]. + // + // Note that you will almost certainly also need to adjust the offset + // postion of the textarea to properly set the cursor position. + // + // Example: + // + // // In your top-level View function: + // f := tea.NewFrame(m.textarea.View()) + // f.Cursor = m.textarea.RealCursor() + // f.Cursor.Position.X += offsetX + // f.Cursor.Position.Y += offsetY + UseRealCursor bool // CharLimit is the maximum number of characters this input element will // accept. If 0 or less, there's no limit. @@ -305,7 +330,7 @@ func New() Model { cache: memoization.NewMemoCache[line, [][]rune](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, - VirtualCursor: cur, + Cursor: cur, KeyMap: DefaultKeyMap(), value: make([][]rune, minHeight, maxLines), @@ -600,7 +625,7 @@ func (m Model) Focused() bool { func (m *Model) Focus() tea.Cmd { m.focus = true m.activeStyle = &m.Styles.Focused - return m.VirtualCursor.Focus() + return m.Cursor.Focus() } // Blur removes the focus state on the model. When the model is blurred it can @@ -608,7 +633,7 @@ func (m *Model) Focus() tea.Cmd { func (m *Model) Blur() { m.focus = false m.activeStyle = &m.Styles.Blurred - m.VirtualCursor.Blur() + m.Cursor.Blur() } // Reset sets the input to its default state with no input. @@ -976,7 +1001,7 @@ func (m *Model) SetHeight(h int) { // Update is the Bubble Tea update loop. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if !m.focus { - m.VirtualCursor.Blur() + m.Cursor.Blur() return m, nil } @@ -1098,10 +1123,10 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmds = append(cmds, cmd) newRow, newCol := m.cursorLineNumber(), m.col - m.VirtualCursor, cmd = m.VirtualCursor.Update(msg) - if (newRow != oldRow || newCol != oldCol) && m.VirtualCursor.Mode() == cursor.CursorBlink { - m.VirtualCursor.Blink = false - cmd = m.VirtualCursor.BlinkCmd() + m.Cursor, cmd = m.Cursor.Update(msg) + if (newRow != oldRow || newCol != oldCol) && m.Cursor.Mode() == cursor.CursorBlink { + m.Cursor.Blink = false + cmd = m.Cursor.BlinkCmd() } cmds = append(cmds, cmd) @@ -1115,7 +1140,7 @@ func (m Model) View() string { if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" { return m.placeholderView() } - m.VirtualCursor.TextStyle = m.activeStyle.computedCursorLine() + m.Cursor.BlinkedStyle = m.activeStyle.computedCursorLine() var ( s strings.Builder @@ -1184,11 +1209,11 @@ func (m Model) View() string { if m.row == l && lineInfo.RowOffset == wl { s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset]))) if m.col >= len(line) && lineInfo.CharOffset >= m.width { - m.VirtualCursor.SetChar(" ") - s.WriteString(m.VirtualCursor.View()) + m.Cursor.SetChar(" ") + s.WriteString(m.Cursor.View()) } else { - m.VirtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) - s.WriteString(style.Render(m.VirtualCursor.View())) + m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) + s.WriteString(style.Render(m.Cursor.View())) s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) } } else { @@ -1291,9 +1316,9 @@ func (m Model) placeholderView() string { // first line case i == 0: // first character of first line as cursor with character - m.VirtualCursor.TextStyle = m.activeStyle.computedPlaceholder() - m.VirtualCursor.SetChar(string(plines[0][0])) - s.WriteString(lineStyle.Render(m.VirtualCursor.View())) + m.Cursor.BlinkedStyle = m.activeStyle.computedPlaceholder() + m.Cursor.SetChar(string(plines[0][0])) + s.WriteString(lineStyle.Render(m.Cursor.View())) // the rest of the first line s.WriteString(lineStyle.Render(style.Render(plines[0][1:] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0])))))) @@ -1317,21 +1342,24 @@ func (m Model) placeholderView() string { return m.activeStyle.Base.Render(m.viewport.View()) } -// Blink returns the blink command for the cursor. +// Blink returns the blink command for the virtual cursor. func Blink() tea.Msg { return cursor.Blink() } -// Cursor returns the current cursor position accounting any -// soft-wrapped lines. -func (m Model) Cursor() *tea.Cursor { +// RealCursor returns a [tea.Cursor] for rendering a real cursor in a Bubble +// Tea program. +func (m Model) RealCursor() *tea.Cursor { + if m.Cursor.Mode() == cursor.CursorHide { + return nil + } + lineInfo := m.LineInfo() x, y := lineInfo.CharOffset, m.cursorLineNumber()-m.viewport.YOffset - // TODO: sort out where these properties live. c := tea.NewCursor(x, y) - c.Blink = true - c.Color = m.VirtualCursor.Style.GetForeground() + c.Blink = m.Cursor.Mode() == cursor.CursorBlink + c.Color = m.Cursor.Style.GetForeground() return c } diff --git a/textinput/textinput.go b/textinput/textinput.go index 92c468a..b98d00b 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -671,7 +671,7 @@ func (m Model) View() string { if m.canAcceptSuggestion() { suggestion := m.matchedSuggestions[m.currentSuggestionIndex] if len(value) < len(suggestion) { - m.Cursor.TextStyle = m.CompletionStyle + m.Cursor.BlinkedStyle = m.CompletionStyle m.Cursor.SetChar(m.echoTransform(string(suggestion[pos]))) v += m.Cursor.View() v += m.completionView(1) @@ -709,7 +709,7 @@ func (m Model) placeholderView() string { p := make([]rune, m.Width()+1) copy(p, []rune(m.Placeholder)) - m.Cursor.TextStyle = m.PlaceholderStyle + m.Cursor.BlinkedStyle = m.PlaceholderStyle m.Cursor.SetChar(string(p[:1])) v += m.Cursor.View()