From 7c0e45eafa184a12c4a18b20b65d88ef57354b9e Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 10:21:58 -0500 Subject: [PATCH 01/16] 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 c49781ed..2ee2ef51 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 d35c6ca7..9de266c1 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 92c468ac..b98d00b1 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() From a8b5bd8c7982f70a2e0e8b53e4d35774b941554c Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 11:45:25 -0500 Subject: [PATCH 02/16] chore(textarea,cursor): use textarea to manage virtual cursor styling --- cursor/cursor.go | 21 +----- textarea/textarea.go | 164 +++++++++++++++++++++++++++++++------------ 2 files changed, 120 insertions(+), 65 deletions(-) diff --git a/cursor/cursor.go b/cursor/cursor.go index 2ee2ef51..33d29465 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -1,8 +1,5 @@ // 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 ( @@ -57,28 +54,14 @@ func (c Mode) String() string { // Model is the Bubble Tea model for this cursor element. type Model struct { // Style styles the cursor block. - // - // For real cursors, the foreground color set here will be used as the - // cursor color. Style 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. + // (hidden), i.e. displaying normal text. 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]. + // unless [CursorMode] is not set to [CursorBlink]. BlinkSpeed time.Duration // Blink is the cursor blink state. diff --git a/textarea/textarea.go b/textarea/textarea.go index 9de266c1..57c4a8c5 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "time" "unicode" "github.com/atotto/clipboard" @@ -128,6 +129,39 @@ type LineInfo struct { CharOffset int } +// CursorStyle is the style for real and virtual cursors. +type CursorStyle struct { + // Style styles the cursor block. + // + // For real cursors, the foreground color set here will be used as the + // cursor color. + Style lipgloss.Style + + // BlinkedStyle is the style used for the cursor when it is blinking + // (hidden), i.e. displaying normal text. This is only used for virtual + // cursors. + BlinkedStyle 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 + + // CursorBlink determines whether or not the cursor should blink. + Blink bool + + // BlinkSpeed is the speed at which the virtualcursor blinks. This has no + // effect on real cursors as well as no effect if the cursor is set not + // to [CursorBlink]. + // + // By default, the blink speed is set to about 500ms. + BlinkSpeed time.Duration +} + // Styles are the styles for the textarea, separated into focused and blurred // states. The appropriate styles will be chosen based on the focus state of // the textarea. @@ -152,6 +186,7 @@ type StyleState struct { Placeholder lipgloss.Style Prompt lipgloss.Style Text lipgloss.Style + Cursor CursorStyle } func (s StyleState) computedCursorLine() lipgloss.Style { @@ -229,7 +264,7 @@ type Model struct { // Styling. FocusedStyle and BlurredStyle are used to style the textarea in // focused and blurred states. - Styles Styles + styles Styles // activeStyle is the current styling to use. // It is used to abstract the differences in focus state when styling the @@ -237,27 +272,12 @@ type Model struct { // when switching focus states. activeStyle *StyleState - // Cursor manages the virtual cursor and contains styling settings for - // both a real and virtual cursor. - Cursor cursor.Model + // virtualCursor manages the virtual cursor. + virtualCursor 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 + // VirtualCursor determines whether or not to use the virtual cursor. If + // set to false, use [Model.Cursor] to return a real cursor for rendering. + VirtualCursor bool // CharLimit is the maximum number of characters this input element will // accept. If 0 or less, there's no limit. @@ -325,12 +345,13 @@ func New() Model { MaxHeight: defaultMaxHeight, MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", - Styles: styles, + styles: styles, activeStyle: &styles.Blurred, cache: memoization.NewMemoCache[line, [][]rune](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, - Cursor: cur, + VirtualCursor: true, + virtualCursor: cur, KeyMap: DefaultKeyMap(), value: make([][]rune, minHeight, maxLines), @@ -362,6 +383,9 @@ func DefaultStyles(isDark bool) Styles { Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), Text: lipgloss.NewStyle(), + Cursor: CursorStyle{ + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + }, } s.Blurred = StyleState{ Base: lipgloss.NewStyle(), @@ -386,6 +410,40 @@ func DefaultDarkStyles() Styles { return DefaultStyles(true) } +// SetStyles sets the styles for the textarea. +func (m *Model) SetStyles(s Styles) { + m.styles = s + if m.focus { + m.activeStyle = &m.styles.Focused + } else { + m.activeStyle = &m.styles.Blurred + } + + m.virtualCursor.Style = s.Focused.Cursor.Style + m.virtualCursor.BlinkedStyle = s.Focused.Cursor.BlinkedStyle + + // By default, the blink speed of the cursor is set to a deafault + // internally. + if s.Focused.Cursor.BlinkSpeed > 0 { + m.virtualCursor.BlinkSpeed = m.activeStyle.Cursor.BlinkSpeed + } + + if !m.VirtualCursor { + m.virtualCursor.SetMode(cursor.CursorHide) + return + } + if s.Focused.Cursor.Blink { + m.virtualCursor.SetMode(cursor.CursorBlink) + return + } + m.virtualCursor.SetMode(cursor.CursorStatic) +} + +// Styles returns the styles for the textarea. +func (m Model) Styles() Styles { + return m.styles +} + // SetValue sets the value of the text input. func (m *Model) SetValue(s string) { m.Reset() @@ -624,16 +682,16 @@ func (m Model) Focused() bool { // receive keyboard input and the cursor will be hidden. func (m *Model) Focus() tea.Cmd { m.focus = true - m.activeStyle = &m.Styles.Focused - return m.Cursor.Focus() + m.activeStyle = &m.styles.Focused + return m.virtualCursor.Focus() } // Blur removes the focus state on the model. When the model is blurred it can // not receive keyboard input and the cursor will be hidden. func (m *Model) Blur() { m.focus = false - m.activeStyle = &m.Styles.Blurred - m.Cursor.Blur() + m.activeStyle = &m.styles.Blurred + m.virtualCursor.Blur() } // Reset sets the input to its default state with no input. @@ -1001,7 +1059,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.Cursor.Blur() + m.virtualCursor.Blur() return m, nil } @@ -1123,10 +1181,10 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmds = append(cmds, cmd) newRow, newCol := m.cursorLineNumber(), m.col - 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() + 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() } cmds = append(cmds, cmd) @@ -1140,7 +1198,7 @@ func (m Model) View() string { if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" { return m.placeholderView() } - m.Cursor.BlinkedStyle = m.activeStyle.computedCursorLine() + m.virtualCursor.BlinkedStyle = m.activeStyle.computedCursorLine() var ( s strings.Builder @@ -1209,11 +1267,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.Cursor.SetChar(" ") - s.WriteString(m.Cursor.View()) + m.virtualCursor.SetChar(" ") + s.WriteString(m.virtualCursor.View()) } else { - m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) - s.WriteString(style.Render(m.Cursor.View())) + m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) + s.WriteString(style.Render(m.virtualCursor.View())) s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) } } else { @@ -1316,9 +1374,9 @@ func (m Model) placeholderView() string { // first line case i == 0: // first character of first line as cursor with character - m.Cursor.BlinkedStyle = m.activeStyle.computedPlaceholder() - m.Cursor.SetChar(string(plines[0][0])) - s.WriteString(lineStyle.Render(m.Cursor.View())) + m.virtualCursor.BlinkedStyle = m.activeStyle.computedPlaceholder() + m.virtualCursor.SetChar(string(plines[0][0])) + s.WriteString(lineStyle.Render(m.virtualCursor.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])))))) @@ -1347,10 +1405,24 @@ func Blink() tea.Msg { return cursor.Blink() } -// 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 { +// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea +// program. +// +// 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 +// +// Note that you will almost certainly also need to adjust the offset +// position of the textarea to properly set the cursor position. +// +// If you're using a real cursor, you should also set [Model.VirtualCursor] to +// false. +func (m Model) Cursor() *tea.Cursor { + if m.VirtualCursor || !m.Focused() { return nil } @@ -1358,8 +1430,8 @@ func (m Model) RealCursor() *tea.Cursor { x, y := lineInfo.CharOffset, m.cursorLineNumber()-m.viewport.YOffset c := tea.NewCursor(x, y) - c.Blink = m.Cursor.Mode() == cursor.CursorBlink - c.Color = m.Cursor.Style.GetForeground() + c.Blink = m.styles.Focused.Cursor.Blink + c.Color = m.styles.Focused.Cursor.Style.GetForeground() return c } From 85464b30cff06a76d0f2a64054837e86a25c388e Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 12:58:29 -0500 Subject: [PATCH 03/16] fix(textarea): doc comment Co-authored-by: Ayman Bagabas --- textarea/textarea.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 57c4a8c5..3e5fc9b3 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1412,7 +1412,7 @@ func Blink() tea.Msg { // // // In your top-level View function: // f := tea.NewFrame(m.textarea.View()) -// f.Cursor = m.textarea.RealCursor() +// f.Cursor = m.textarea.Cursor() // f.Cursor.Position.X += offsetX // f.Cursor.Position.Y += offsetY // From 832f6e4674edc4b8d8d5d0b0a40757996e3a3727 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 12:58:40 -0500 Subject: [PATCH 04/16] fix(textarea): doc comment typo Co-authored-by: Ayman Bagabas --- textarea/textarea.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 3e5fc9b3..94d6909a 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -422,7 +422,7 @@ func (m *Model) SetStyles(s Styles) { m.virtualCursor.Style = s.Focused.Cursor.Style m.virtualCursor.BlinkedStyle = s.Focused.Cursor.BlinkedStyle - // By default, the blink speed of the cursor is set to a deafault + // By default, the blink speed of the cursor is set to a default // internally. if s.Focused.Cursor.BlinkSpeed > 0 { m.virtualCursor.BlinkSpeed = m.activeStyle.Cursor.BlinkSpeed From 58383a94e4fd6b43c676799f519a61bd3f81a5f4 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 16:20:09 -0500 Subject: [PATCH 05/16] chore(textarea): rename Model.SetCursor to Model.SetCursorColumn Set.CursorColumn sets the cursor column position. This change was made for clarity. --- textarea/textarea.go | 48 ++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 94d6909a..5dd02d99 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -546,7 +546,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Finally add the tail at the end of the last line inserted. m.value[m.row] = append(m.value[m.row], tail...) - m.SetCursor(m.col) + m.SetCursorColumn(m.col) } // Value returns the value of the text input. @@ -654,9 +654,9 @@ func (m *Model) CursorUp() { } } -// SetCursor moves the cursor to the given position. If the position is +// SetCursorColumn moves the cursor to the given position. If the position is // out of bounds the cursor will be moved to the start or end accordingly. -func (m *Model) SetCursor(col int) { +func (m *Model) SetCursorColumn(col int) { m.col = clamp(col, 0, len(m.value[m.row])) // Any time that we move the cursor horizontally we need to reset the last // offset so that the horizontal position when navigating is adjusted. @@ -665,12 +665,12 @@ func (m *Model) SetCursor(col int) { // CursorStart moves the cursor to the start of the input field. func (m *Model) CursorStart() { - m.SetCursor(0) + m.SetCursorColumn(0) } // CursorEnd moves the cursor to the end of the input field. func (m *Model) CursorEnd() { - m.SetCursor(len(m.value[m.row])) + m.SetCursorColumn(len(m.value[m.row])) } // Focused returns the focus state on the model. @@ -700,7 +700,7 @@ func (m *Model) Reset() { m.col = 0 m.row = 0 m.viewport.GotoTop() - m.SetCursor(0) + m.SetCursorColumn(0) } // san initializes or retrieves the rune sanitizer. @@ -717,7 +717,7 @@ func (m *Model) san() runeutil.Sanitizer { // not the cursor blink should be reset. func (m *Model) deleteBeforeCursor() { m.value[m.row] = m.value[m.row][m.col:] - m.SetCursor(0) + m.SetCursorColumn(0) } // deleteAfterCursor deletes all text after the cursor. Returns whether or not @@ -725,7 +725,7 @@ func (m *Model) deleteBeforeCursor() { // the cursor so as not to reveal word breaks in the masked input. func (m *Model) deleteAfterCursor() { m.value[m.row] = m.value[m.row][:m.col] - m.SetCursor(len(m.value[m.row])) + m.SetCursorColumn(len(m.value[m.row])) } // transposeLeft exchanges the runes at the cursor and immediately @@ -737,11 +737,11 @@ func (m *Model) transposeLeft() { return } if m.col >= len(m.value[m.row]) { - m.SetCursor(m.col - 1) + m.SetCursorColumn(m.col - 1) } m.value[m.row][m.col-1], m.value[m.row][m.col] = m.value[m.row][m.col], m.value[m.row][m.col-1] if m.col < len(m.value[m.row]) { - m.SetCursor(m.col + 1) + m.SetCursorColumn(m.col + 1) } } @@ -757,22 +757,22 @@ func (m *Model) deleteWordLeft() { // call into the corresponding if clause does not apply here. oldCol := m.col //nolint:ifshort - m.SetCursor(m.col - 1) + m.SetCursorColumn(m.col - 1) for unicode.IsSpace(m.value[m.row][m.col]) { if m.col <= 0 { break } // ignore series of whitespace before cursor - m.SetCursor(m.col - 1) + m.SetCursorColumn(m.col - 1) } for m.col > 0 { if !unicode.IsSpace(m.value[m.row][m.col]) { - m.SetCursor(m.col - 1) + m.SetCursorColumn(m.col - 1) } else { if m.col > 0 { // keep the previous space - m.SetCursor(m.col + 1) + m.SetCursorColumn(m.col + 1) } break } @@ -795,12 +795,12 @@ func (m *Model) deleteWordRight() { for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) { // ignore series of whitespace after cursor - m.SetCursor(m.col + 1) + m.SetCursorColumn(m.col + 1) } for m.col < len(m.value[m.row]) { if !unicode.IsSpace(m.value[m.row][m.col]) { - m.SetCursor(m.col + 1) + m.SetCursorColumn(m.col + 1) } else { break } @@ -812,13 +812,13 @@ func (m *Model) deleteWordRight() { m.value[m.row] = append(m.value[m.row][:oldCol], m.value[m.row][m.col:]...) } - m.SetCursor(oldCol) + m.SetCursorColumn(oldCol) } // characterRight moves the cursor one character to the right. func (m *Model) characterRight() { if m.col < len(m.value[m.row]) { - m.SetCursor(m.col + 1) + m.SetCursorColumn(m.col + 1) } else { if m.row < len(m.value)-1 { m.row++ @@ -839,7 +839,7 @@ func (m *Model) characterLeft(insideLine bool) { } } if m.col > 0 { - m.SetCursor(m.col - 1) + m.SetCursorColumn(m.col - 1) } } @@ -858,7 +858,7 @@ func (m *Model) wordLeft() { if unicode.IsSpace(m.value[m.row][m.col-1]) { break } - m.SetCursor(m.col - 1) + m.SetCursorColumn(m.col - 1) } } @@ -885,7 +885,7 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { break } fn(charIdx, m.col) - m.SetCursor(m.col + 1) + m.SetCursorColumn(m.col + 1) charIdx++ } } @@ -975,13 +975,13 @@ func (m Model) Width() int { // moveToBegin moves the cursor to the beginning of the input. func (m *Model) moveToBegin() { m.row = 0 - m.SetCursor(0) + m.SetCursorColumn(0) } // moveToEnd moves the cursor to the end of the input. func (m *Model) moveToEnd() { m.row = len(m.value) - 1 - m.SetCursor(len(m.value[m.row])) + m.SetCursorColumn(len(m.value[m.row])) } // SetWidth sets the width of the textarea to fit exactly within the given width. @@ -1104,7 +1104,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if len(m.value[m.row]) > 0 { m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...) if m.col > 0 { - m.SetCursor(m.col - 1) + m.SetCursorColumn(m.col - 1) } } case key.Matches(msg, m.KeyMap.DeleteCharacterForward): From e56a320737b6c7db5359b763a171e82bf15c5cfb Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 16:21:20 -0500 Subject: [PATCH 06/16] chore(textarea): improve cursor styling API --- textarea/textarea.go | 61 ++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 5dd02d99..ea1a0342 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -154,9 +154,9 @@ type CursorStyle struct { // CursorBlink determines whether or not the cursor should blink. Blink bool - // BlinkSpeed is the speed at which the virtualcursor blinks. This has no - // effect on real cursors as well as no effect if the cursor is set not - // to [CursorBlink]. + // BlinkSpeed is the speed at which the virtual cursor blinks. This has no + // effect on real cursors as well as no effect if the cursor is set not to + // [CursorBlink]. // // By default, the blink speed is set to about 500ms. BlinkSpeed time.Duration @@ -168,6 +168,7 @@ type CursorStyle struct { type Styles struct { Focused StyleState Blurred StyleState + Cursor CursorStyle } // StyleState that will be applied to the text area. @@ -186,7 +187,6 @@ type StyleState struct { Placeholder lipgloss.Style Prompt lipgloss.Style Text lipgloss.Style - Cursor CursorStyle } func (s StyleState) computedCursorLine() lipgloss.Style { @@ -264,7 +264,7 @@ type Model struct { // Styling. FocusedStyle and BlurredStyle are used to style the textarea in // focused and blurred states. - styles Styles + Styles Styles // activeStyle is the current styling to use. // It is used to abstract the differences in focus state when styling the @@ -345,7 +345,7 @@ func New() Model { MaxHeight: defaultMaxHeight, MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", - styles: styles, + Styles: styles, activeStyle: &styles.Blurred, cache: memoization.NewMemoCache[line, [][]rune](maxLines), EndOfBufferCharacter: ' ', @@ -383,9 +383,6 @@ func DefaultStyles(isDark bool) Styles { Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), Text: lipgloss.NewStyle(), - Cursor: CursorStyle{ - Style: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - }, } s.Blurred = StyleState{ Base: lipgloss.NewStyle(), @@ -397,6 +394,10 @@ func DefaultStyles(isDark bool) Styles { Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), } + s.Cursor = CursorStyle{ + Style: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Blink: true, + } return s } @@ -410,40 +411,34 @@ func DefaultDarkStyles() Styles { return DefaultStyles(true) } -// SetStyles sets the styles for the textarea. -func (m *Model) SetStyles(s Styles) { - m.styles = s - if m.focus { - m.activeStyle = &m.styles.Focused - } else { - m.activeStyle = &m.styles.Blurred +// updateVirtualCursorStyle sets styling on the virtual cursor based on the +// textarea's style settings. +func (m *Model) updateVirtualCursorStyle() { + if !m.VirtualCursor { + m.virtualCursor.SetMode(cursor.CursorHide) + return } - m.virtualCursor.Style = s.Focused.Cursor.Style - m.virtualCursor.BlinkedStyle = s.Focused.Cursor.BlinkedStyle + m.virtualCursor.Style = m.Styles.Cursor.Style + m.virtualCursor.BlinkedStyle = m.Styles.Cursor.BlinkedStyle // By default, the blink speed of the cursor is set to a default // internally. - if s.Focused.Cursor.BlinkSpeed > 0 { - m.virtualCursor.BlinkSpeed = m.activeStyle.Cursor.BlinkSpeed + if m.Styles.Cursor.BlinkSpeed > 0 { + m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed } if !m.VirtualCursor { m.virtualCursor.SetMode(cursor.CursorHide) return } - if s.Focused.Cursor.Blink { + if m.Styles.Cursor.Blink { m.virtualCursor.SetMode(cursor.CursorBlink) return } m.virtualCursor.SetMode(cursor.CursorStatic) } -// Styles returns the styles for the textarea. -func (m Model) Styles() Styles { - return m.styles -} - // SetValue sets the value of the text input. func (m *Model) SetValue(s string) { m.Reset() @@ -682,7 +677,7 @@ func (m Model) Focused() bool { // receive keyboard input and the cursor will be hidden. func (m *Model) Focus() tea.Cmd { m.focus = true - m.activeStyle = &m.styles.Focused + m.activeStyle = &m.Styles.Focused return m.virtualCursor.Focus() } @@ -690,7 +685,7 @@ func (m *Model) Focus() tea.Cmd { // not receive keyboard input and the cursor will be hidden. func (m *Model) Blur() { m.focus = false - m.activeStyle = &m.styles.Blurred + m.activeStyle = &m.Styles.Blurred m.virtualCursor.Blur() } @@ -1195,6 +1190,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // View renders the text area in its current state. func (m Model) View() string { + m.updateVirtualCursorStyle() if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" { return m.placeholderView() } @@ -1422,16 +1418,13 @@ func Blink() tea.Msg { // If you're using a real cursor, you should also set [Model.VirtualCursor] to // false. func (m Model) Cursor() *tea.Cursor { - if m.VirtualCursor || !m.Focused() { - return nil - } - lineInfo := m.LineInfo() x, y := lineInfo.CharOffset, m.cursorLineNumber()-m.viewport.YOffset c := tea.NewCursor(x, y) - c.Blink = m.styles.Focused.Cursor.Blink - c.Color = m.styles.Focused.Cursor.Style.GetForeground() + c.Blink = m.Styles.Cursor.Blink + c.Color = m.Styles.Cursor.Style.GetForeground() + c.Shape = m.Styles.Cursor.Shape return c } From f052a440ec75ee6b980e7830e069b723fc51eb9d Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 22:06:47 -0500 Subject: [PATCH 07/16] chore(textarea,cursor): simplify cursor API --- cursor/cursor.go | 8 ++++---- textarea/textarea.go | 20 ++++++++------------ textinput/textinput.go | 4 ++-- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/cursor/cursor.go b/cursor/cursor.go index 33d29465..824b4503 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -56,15 +56,15 @@ type Model struct { // Style styles the cursor block. Style lipgloss.Style - // BlinkedStyle is the style used for the cursor when it is blinking + // TextStyle is the style used for the cursor when it is blinking // (hidden), i.e. displaying normal text. - BlinkedStyle lipgloss.Style + TextStyle lipgloss.Style // BlinkSpeed is the speed at which the cursor blinks. This has no effect // unless [CursorMode] is not set to [CursorBlink]. BlinkSpeed time.Duration - // Blink is the cursor blink state. + // Blink is the state of the cursor blink. When true, the cursor is hidden. Blink bool // char is the character under the cursor @@ -224,7 +224,7 @@ func (m *Model) SetChar(char string) { // View displays the cursor. func (m Model) View() string { if m.Blink { - return m.BlinkedStyle.Inline(true).Render(m.char) + return m.TextStyle.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 ea1a0342..f57faaf7 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -3,6 +3,7 @@ package textarea import ( "crypto/sha256" "fmt" + "image/color" "strconv" "strings" "time" @@ -135,12 +136,7 @@ type CursorStyle struct { // // For real cursors, the foreground color set here will be used as the // cursor color. - Style lipgloss.Style - - // BlinkedStyle is the style used for the cursor when it is blinking - // (hidden), i.e. displaying normal text. This is only used for virtual - // cursors. - BlinkedStyle lipgloss.Style + Color color.Color // Shape is the cursor shape. The following shapes are available: // @@ -395,7 +391,8 @@ func DefaultStyles(isDark bool) Styles { Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), } s.Cursor = CursorStyle{ - Style: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Color: lipgloss.Color("7"), + Shape: tea.CursorBlock, Blink: true, } return s @@ -419,8 +416,7 @@ func (m *Model) updateVirtualCursorStyle() { return } - m.virtualCursor.Style = m.Styles.Cursor.Style - m.virtualCursor.BlinkedStyle = m.Styles.Cursor.BlinkedStyle + m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.Styles.Cursor.Color) // By default, the blink speed of the cursor is set to a default // internally. @@ -1194,7 +1190,7 @@ func (m Model) View() string { if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" { return m.placeholderView() } - m.virtualCursor.BlinkedStyle = m.activeStyle.computedCursorLine() + m.virtualCursor.TextStyle = m.activeStyle.computedCursorLine() var ( s strings.Builder @@ -1370,7 +1366,7 @@ func (m Model) placeholderView() string { // first line case i == 0: // first character of first line as cursor with character - m.virtualCursor.BlinkedStyle = m.activeStyle.computedPlaceholder() + m.virtualCursor.TextStyle = m.activeStyle.computedPlaceholder() m.virtualCursor.SetChar(string(plines[0][0])) s.WriteString(lineStyle.Render(m.virtualCursor.View())) @@ -1423,7 +1419,7 @@ func (m Model) Cursor() *tea.Cursor { c := tea.NewCursor(x, y) c.Blink = m.Styles.Cursor.Blink - c.Color = m.Styles.Cursor.Style.GetForeground() + c.Color = m.Styles.Cursor.Color c.Shape = m.Styles.Cursor.Shape return c } diff --git a/textinput/textinput.go b/textinput/textinput.go index b98d00b1..92c468ac 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.BlinkedStyle = m.CompletionStyle + m.Cursor.TextStyle = 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.BlinkedStyle = m.PlaceholderStyle + m.Cursor.TextStyle = m.PlaceholderStyle m.Cursor.SetChar(string(p[:1])) v += m.Cursor.View() From e633da1598ba08950265d53b4dc089f74404b5a6 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 28 Jan 2025 22:12:24 -0500 Subject: [PATCH 08/16] chore(textarea): improve comments, note ares that need attention --- textarea/textarea.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index f57faaf7..226c45ad 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -241,7 +241,7 @@ type Model struct { // When changing the value of Prompt after the model has been // initialized, ensure that SetWidth() gets called afterwards. // - // See also SetPromptFunc(). + // See also [SetPromptFunc] for a dynamic prompt. Prompt string // Placeholder is the text displayed when the user @@ -983,9 +983,11 @@ func (m *Model) moveToEnd() { // It is important that the width of the textarea be exactly the given width // and no more. func (m *Model) SetWidth(w int) { - // Update prompt width only if there is no prompt function as SetPromptFunc - // updates the prompt width when it is called. + // Update prompt width only if there is no prompt function as + // [SetPromptFunc] updates the prompt width when it is called. if m.promptFunc == nil { + // XXX: This should account for a styled prompt and use lipglosss.Width + // instead of uniseg.StringWidth. m.promptWidth = uniseg.StringWidth(m.Prompt) } @@ -997,6 +999,7 @@ func (m *Model) SetWidth(w int) { // Add line number width to reserved inner width. if m.ShowLineNumbers { + // XXX: this should almost certainly not be hardcoded. const lnWidth = 4 // Up to 3 digits for line number plus 1 margin. reservedInner += lnWidth } @@ -1019,14 +1022,13 @@ func (m *Model) SetWidth(w int) { m.width = inputWidth - reservedOuter - reservedInner } -// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt -// instead. -// If the function returns a prompt that is shorter than the -// specified promptWidth, it will be padded to the left. -// If it returns a prompt that is longer, display artifacts -// may occur; the caller is responsible for computing an adequate -// promptWidth. -func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIdx int) string) { +// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt instead. +// +// If the function returns a prompt that is shorter than the specified +// promptWidth, it will be padded to the left. If it returns a prompt that is +// longer, display artifacts may occur; the caller is responsible for computing +// an adequate promptWidth. +func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIndex int) string) { m.promptFunc = fn m.promptWidth = promptWidth } From a1234dd74bbc3c58d9cb4d020f97c55d88862aed Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 30 Jan 2025 12:15:27 -0500 Subject: [PATCH 09/16] chore(textarea): consolidate line number rendering into a method --- textarea/textarea.go | 80 +++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 226c45ad..2e5b0c4d 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -176,13 +176,13 @@ type Styles struct { // https://github.com/charmbracelet/lipgloss type StyleState struct { Base lipgloss.Style - CursorLine lipgloss.Style + Text lipgloss.Style + LineNumber lipgloss.Style CursorLineNumber lipgloss.Style + CursorLine lipgloss.Style EndOfBuffer lipgloss.Style - LineNumber lipgloss.Style Placeholder lipgloss.Style Prompt lipgloss.Style - Text lipgloss.Style } func (s StyleState) computedCursorLine() lipgloss.Style { @@ -1220,22 +1220,12 @@ func (m Model) View() string { var ln string if m.ShowLineNumbers { //nolint:nestif - if wl == 0 { - if m.row == l { - ln = style.Render(m.activeStyle.computedCursorLineNumber().Render(m.formatLineNumber(l + 1))) - s.WriteString(ln) - } else { - ln = style.Render(m.activeStyle.computedLineNumber().Render(m.formatLineNumber(l + 1))) - s.WriteString(ln) - } - } else { - if m.row == l { - ln = style.Render(m.activeStyle.computedCursorLineNumber().Render(m.formatLineNumber(" "))) - s.WriteString(ln) - } else { - ln = style.Render(m.activeStyle.computedLineNumber().Render(m.formatLineNumber(" "))) - s.WriteString(ln) - } + if wl == 0 { // normal line + isCursorLine := m.row == l + s.WriteString(m.lineNumberView(l+1, isCursorLine)) + } else { // soft wrapped line + isCursorLine := m.row == l + s.WriteString(m.lineNumberView(-1, isCursorLine)) } } @@ -1297,13 +1287,42 @@ func (m Model) View() string { return m.activeStyle.Base.Render(m.viewport.View()) } -// formatLineNumber formats the line number for display dynamically based on -// the maximum number of lines. -func (m Model) formatLineNumber(x any) string { - // XXX: ultimately we should use a max buffer height, which has yet to be - // implemented. +// promptView returns the prompt for a single line (as prompts are applited to +// each line). +func (m Model) promptView() string { + return "" +} + +// lineNumberView returns the line number. +// +// If the argument is less than 0, a space styled as a line number is returned +// instead. Such cases are used for soft-wrapped lines. +// +// The second argument indicates whether this line number is for a 'cursorline' +// line number. +func (m Model) lineNumberView(n int, isCursorLine bool) (str string) { + if !m.ShowLineNumbers { + return "" + } + + if n < 0 { + str = " " + } else { + str = strconv.Itoa(n) + } + + textStyle := m.activeStyle.computedText() + lineNumberStyle := m.activeStyle.computedLineNumber() + if isCursorLine { + textStyle = m.activeStyle.computedCursorLine() + lineNumberStyle = m.activeStyle.computedCursorLineNumber() + } + + // Format line number dynamically based on the maximum number of lines. digits := len(strconv.Itoa(m.MaxHeight)) - return fmt.Sprintf(" %*v ", digits, x) + str = fmt.Sprintf(" %*v ", digits, str) + + return textStyle.Render(lineNumberStyle.Render(str)) } func (m Model) getPromptString(displayLine int) (prompt string) { @@ -1335,11 +1354,12 @@ func (m Model) placeholderView() string { plines := strings.Split(strings.TrimSpace(pwrap), "\n") for i := 0; i < m.height; i++ { + isLineNumber := len(plines) > i + + // XXX: This will go. lineStyle := m.activeStyle.computedPlaceholder() - lineNumberStyle := m.activeStyle.computedLineNumber() if len(plines) > i { lineStyle = m.activeStyle.computedCursorLine() - lineNumberStyle = m.activeStyle.computedCursorLineNumber() } // render prompt @@ -1352,14 +1372,14 @@ func (m Model) placeholderView() string { // - indent other placeholder lines // this is consistent with vim with line numbers enabled if m.ShowLineNumbers { - var ln string + var ln int switch { case i == 0: - ln = strconv.Itoa(i + 1) + ln = i + 1 fallthrough case len(plines) > i: - s.WriteString(lineStyle.Render(lineNumberStyle.Render(m.formatLineNumber(ln)))) + s.WriteString(m.lineNumberView(ln, isLineNumber)) default: } } From 60728a7518964a1663705ebf6f08c92195866dff Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 30 Jan 2025 12:26:51 -0500 Subject: [PATCH 10/16] fix(textarea): infer if we should use focused or blurred styles --- textarea/textarea.go | 69 +++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 2e5b0c4d..d29c671a 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -262,12 +262,6 @@ type Model struct { // focused and blurred states. Styles Styles - // activeStyle is the current styling to use. - // It is used to abstract the differences in focus state when styling the - // model, since we can simply assign the set of activeStyle to this variable - // when switching focus states. - activeStyle *StyleState - // virtualCursor manages the virtual cursor. virtualCursor cursor.Model @@ -342,7 +336,6 @@ func New() Model { MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", Styles: styles, - activeStyle: &styles.Blurred, cache: memoization.NewMemoCache[line, [][]rune](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, @@ -669,11 +662,19 @@ func (m Model) Focused() bool { return m.focus } +// activeStyle returns the appropriate set of styles to use depending on +// whether the textarea is focused or blurred. +func (m Model) activeStyle() *StyleState { + if m.focus { + return &m.Styles.Focused + } + return &m.Styles.Blurred +} + // Focus sets the focus state on the model. When the model is in focus it can // receive keyboard input and the cursor will be hidden. func (m *Model) Focus() tea.Cmd { m.focus = true - m.activeStyle = &m.Styles.Focused return m.virtualCursor.Focus() } @@ -681,7 +682,6 @@ func (m *Model) Focus() tea.Cmd { // not receive keyboard input and the cursor will be hidden. func (m *Model) Blur() { m.focus = false - m.activeStyle = &m.Styles.Blurred m.virtualCursor.Blur() } @@ -992,7 +992,7 @@ func (m *Model) SetWidth(w int) { } // Add base style borders and padding to reserved outer width. - reservedOuter := m.activeStyle.Base.GetHorizontalFrameSize() + reservedOuter := m.activeStyle().Base.GetHorizontalFrameSize() // Add prompt width to reserved inner width. reservedInner := m.promptWidth @@ -1192,7 +1192,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.virtualCursor.TextStyle = m.activeStyle().computedCursorLine() var ( s strings.Builder @@ -1200,6 +1200,7 @@ func (m Model) View() string { newLines int widestLineNumber int lineInfo = m.LineInfo() + styles = m.activeStyle() ) displayLine := 0 @@ -1207,14 +1208,14 @@ func (m Model) View() string { wrappedLines := m.memoizedWrap(line, m.width) if m.row == l { - style = m.activeStyle.computedCursorLine() + style = styles.computedCursorLine() } else { - style = m.activeStyle.computedText() + style = styles.computedText() } for wl, wrappedLine := range wrappedLines { prompt := m.getPromptString(displayLine) - prompt = m.activeStyle.computedPrompt().Render(prompt) + prompt = styles.computedPrompt().Render(prompt) s.WriteString(style.Render(prompt)) displayLine++ @@ -1271,7 +1272,7 @@ func (m Model) View() string { // To do this we can simply pad out a few extra new lines in the view. for i := 0; i < m.height; i++ { prompt := m.getPromptString(displayLine) - prompt = m.activeStyle.computedPrompt().Render(prompt) + prompt = styles.computedPrompt().Render(prompt) s.WriteString(prompt) displayLine++ @@ -1279,12 +1280,12 @@ func (m Model) View() string { leftGutter := string(m.EndOfBufferCharacter) rightGapWidth := m.Width() - lipgloss.Width(leftGutter) + widestLineNumber rightGap := strings.Repeat(" ", max(0, rightGapWidth)) - s.WriteString(m.activeStyle.computedEndOfBuffer().Render(leftGutter + rightGap)) + s.WriteString(styles.computedEndOfBuffer().Render(leftGutter + rightGap)) s.WriteRune('\n') } m.viewport.SetContent(s.String()) - return m.activeStyle.Base.Render(m.viewport.View()) + return styles.Base.Render(m.viewport.View()) } // promptView returns the prompt for a single line (as prompts are applited to @@ -1305,17 +1306,18 @@ func (m Model) lineNumberView(n int, isCursorLine bool) (str string) { return "" } - if n < 0 { + if n <= 0 { str = " " } else { str = strconv.Itoa(n) } - textStyle := m.activeStyle.computedText() - lineNumberStyle := m.activeStyle.computedLineNumber() + // XXX: is textStyle really necessary here? + textStyle := m.activeStyle().computedText() + lineNumberStyle := m.activeStyle().computedLineNumber() if isCursorLine { - textStyle = m.activeStyle.computedCursorLine() - lineNumberStyle = m.activeStyle.computedCursorLineNumber() + textStyle = m.activeStyle().computedCursorLine() + lineNumberStyle = m.activeStyle().computedCursorLineNumber() } // Format line number dynamically based on the maximum number of lines. @@ -1341,9 +1343,10 @@ func (m Model) getPromptString(displayLine int) (prompt string) { // placeholderView returns the prompt and placeholder view, if any. func (m Model) placeholderView() string { var ( - s strings.Builder - p = m.Placeholder - style = m.activeStyle.computedPlaceholder() + s strings.Builder + p = m.Placeholder + styles = m.activeStyle() + // placeholderStyle = m.activeStyle().computedPlaceholder() ) // word wrap lines @@ -1357,14 +1360,14 @@ func (m Model) placeholderView() string { isLineNumber := len(plines) > i // XXX: This will go. - lineStyle := m.activeStyle.computedPlaceholder() + lineStyle := m.activeStyle().computedPlaceholder() if len(plines) > i { - lineStyle = m.activeStyle.computedCursorLine() + lineStyle = m.activeStyle().computedCursorLine() } // render prompt prompt := m.getPromptString(i) - prompt = m.activeStyle.computedPrompt().Render(prompt) + prompt = styles.computedPrompt().Render(prompt) s.WriteString(lineStyle.Render(prompt)) // when show line numbers enabled: @@ -1388,21 +1391,21 @@ 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.TextStyle = styles.computedPlaceholder() m.virtualCursor.SetChar(string(plines[0][0])) s.WriteString(lineStyle.Render(m.virtualCursor.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])))))) + s.WriteString(lineStyle.Render(plines[0][1:] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0]))))) // remaining lines case len(plines) > i: // current line placeholder text if len(plines) > i { - s.WriteString(lineStyle.Render(style.Render(plines[i] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i])))))) + s.WriteString(lineStyle.Render(plines[i] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i]))))) } default: // end of line buffer character - eob := m.activeStyle.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter)) + eob := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter)) s.WriteString(eob) } @@ -1411,7 +1414,7 @@ func (m Model) placeholderView() string { } m.viewport.SetContent(s.String()) - return m.activeStyle.Base.Render(m.viewport.View()) + return styles.Base.Render(m.viewport.View()) } // Blink returns the blink command for the virtual cursor. From a84b68deff39a72d90ccf8eb300aaf22d299e1f7 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 30 Jan 2025 13:50:25 -0500 Subject: [PATCH 11/16] fix(textarea): don't hardcode line number gutter width --- textarea/textarea.go | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index d29c671a..6f6a8716 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -988,6 +988,9 @@ func (m *Model) SetWidth(w int) { if m.promptFunc == nil { // XXX: This should account for a styled prompt and use lipglosss.Width // instead of uniseg.StringWidth. + // + // XXX: Do we even need this or can we calculate the prompt width + // at render time? m.promptWidth = uniseg.StringWidth(m.Prompt) } @@ -999,9 +1002,13 @@ func (m *Model) SetWidth(w int) { // Add line number width to reserved inner width. if m.ShowLineNumbers { - // XXX: this should almost certainly not be hardcoded. - const lnWidth = 4 // Up to 3 digits for line number plus 1 margin. - reservedInner += lnWidth + // XXX: this was originally documented as needing "1 cell" but was, + // in practice, hardcoded to effectively 2 cells. We can, and should, + // reduce this to one gap and update the tests accordingly. + const gap = 2 + + // Number of digits plus 1 cell for the margin. + reservedInner += numDigits(m.MaxHeight) + gap } // Input width must be at least one more than the reserved inner and outer @@ -1610,6 +1617,20 @@ func repeatSpaces(n int) []rune { return []rune(strings.Repeat(string(' '), n)) } +// numDigits returns the number of digits in an integer. +func numDigits(n int) int { + if n == 0 { + return 1 + } + count := 0 + num := abs(n) + for num > 0 { + count++ + num /= 10 + } + return count +} + func clamp(v, low, high int) int { if high < low { low, high = high, low @@ -1630,3 +1651,10 @@ func max(a, b int) int { } return b } + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} From da0d113e18ca3fac5182177679e2c01c0bf7ad52 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 30 Jan 2025 17:09:22 -0500 Subject: [PATCH 12/16] chore(textarea): consolidate prompt rendering in a method --- textarea/textarea.go | 40 +++++++++++++++++----------------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 6f6a8716..29a1fc5c 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1221,7 +1221,7 @@ func (m Model) View() string { } for wl, wrappedLine := range wrappedLines { - prompt := m.getPromptString(displayLine) + prompt := m.promptView(displayLine) prompt = styles.computedPrompt().Render(prompt) s.WriteString(style.Render(prompt)) displayLine++ @@ -1278,9 +1278,7 @@ func (m Model) View() string { // Always show at least `m.Height` lines at all times. // To do this we can simply pad out a few extra new lines in the view. for i := 0; i < m.height; i++ { - prompt := m.getPromptString(displayLine) - prompt = styles.computedPrompt().Render(prompt) - s.WriteString(prompt) + s.WriteString(m.promptView(displayLine)) displayLine++ // Write end of buffer content @@ -1295,13 +1293,22 @@ func (m Model) View() string { return styles.Base.Render(m.viewport.View()) } -// promptView returns the prompt for a single line (as prompts are applited to -// each line). -func (m Model) promptView() string { - return "" +// promptView renders a single line of the prompt. +func (m Model) promptView(displayLine int) (prompt string) { + prompt = m.Prompt + if m.promptFunc == nil { + return prompt + } + prompt = m.promptFunc(displayLine) + width := lipgloss.Width(prompt) + if width < m.promptWidth { + prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt) + } + + return m.activeStyle().computedPrompt().Render(prompt) } -// lineNumberView returns the line number. +// lineNumberView renders the line number. // // If the argument is less than 0, a space styled as a line number is returned // instead. Such cases are used for soft-wrapped lines. @@ -1334,19 +1341,6 @@ func (m Model) lineNumberView(n int, isCursorLine bool) (str string) { return textStyle.Render(lineNumberStyle.Render(str)) } -func (m Model) getPromptString(displayLine int) (prompt string) { - prompt = m.Prompt - if m.promptFunc == nil { - return prompt - } - prompt = m.promptFunc(displayLine) - pl := uniseg.StringWidth(prompt) - if pl < m.promptWidth { - prompt = fmt.Sprintf("%*s%s", m.promptWidth-pl, "", prompt) - } - return prompt -} - // placeholderView returns the prompt and placeholder view, if any. func (m Model) placeholderView() string { var ( @@ -1373,7 +1367,7 @@ func (m Model) placeholderView() string { } // render prompt - prompt := m.getPromptString(i) + prompt := m.promptView(i) prompt = styles.computedPrompt().Render(prompt) s.WriteString(lineStyle.Render(prompt)) From 02431d1a949bbd2461c42017042c1fcab3426b5a Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 30 Jan 2025 17:47:17 -0500 Subject: [PATCH 13/16] chore(textarea): make placeholder rendering more readable --- textarea/textarea.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 29a1fc5c..afb597af 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1341,18 +1341,16 @@ func (m Model) lineNumberView(n int, isCursorLine bool) (str string) { return textStyle.Render(lineNumberStyle.Render(str)) } -// placeholderView returns the prompt and placeholder view, if any. +// placeholderView returns the prompt and placeholder, if any. func (m Model) placeholderView() string { var ( s strings.Builder p = m.Placeholder styles = m.activeStyle() - // placeholderStyle = m.activeStyle().computedPlaceholder() ) - // word wrap lines pwordwrap := ansi.Wordwrap(p, m.width, "") - // wrap lines (handles lines that could not be word wrapped) + // hard wrap lines (handles lines that could not be word wrapped) pwrap := ansi.Hardwrap(pwordwrap, m.width, true) // split string by new lines plines := strings.Split(strings.TrimSpace(pwrap), "\n") @@ -1360,10 +1358,9 @@ func (m Model) placeholderView() string { for i := 0; i < m.height; i++ { isLineNumber := len(plines) > i - // XXX: This will go. - lineStyle := m.activeStyle().computedPlaceholder() + lineStyle := styles.computedPlaceholder() if len(plines) > i { - lineStyle = m.activeStyle().computedCursorLine() + lineStyle = styles.computedCursorLine() } // render prompt @@ -1397,12 +1394,17 @@ func (m Model) placeholderView() string { s.WriteString(lineStyle.Render(m.virtualCursor.View())) // the rest of the first line - s.WriteString(lineStyle.Render(plines[0][1:] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0]))))) + placeholderTail := plines[0][1:] + gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[0]))) + renderedPlaceholder := styles.computedPlaceholder().Render(placeholderTail + gap) + s.WriteString(lineStyle.Render(renderedPlaceholder)) // remaining lines case len(plines) > i: // current line placeholder text if len(plines) > i { - s.WriteString(lineStyle.Render(plines[i] + strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i]))))) + placeholderLine := plines[i] + gap := strings.Repeat(" ", max(0, m.width-uniseg.StringWidth(plines[i]))) + s.WriteString(lineStyle.Render(placeholderLine + gap)) } default: // end of line buffer character From 02ecc7950fc0ce13489730505b684cf611c1f43e Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 30 Jan 2025 18:00:35 -0500 Subject: [PATCH 14/16] feat(textarea): auto-calculate inner X/Y cursor offset --- textarea/textarea.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index afb597af..8e0d4132 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1443,7 +1443,9 @@ func Blink() tea.Msg { // false. func (m Model) Cursor() *tea.Cursor { lineInfo := m.LineInfo() - x, y := lineInfo.CharOffset, m.cursorLineNumber()-m.viewport.YOffset + w := lipgloss.Width + x := lineInfo.CharOffset + w(m.promptView(0)) + w(m.lineNumberView(0, false)) + y := m.cursorLineNumber() - m.viewport.YOffset c := tea.NewCursor(x, y) c.Blink = m.Styles.Cursor.Blink From 8052fc4d258c9ff8477fd96142d17358773120ef Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Thu, 30 Jan 2025 18:08:12 -0500 Subject: [PATCH 15/16] chore(textarea): comments and cleanup --- textarea/textarea.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 8e0d4132..7adb9024 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -986,9 +986,6 @@ func (m *Model) SetWidth(w int) { // Update prompt width only if there is no prompt function as // [SetPromptFunc] updates the prompt width when it is called. if m.promptFunc == nil { - // XXX: This should account for a styled prompt and use lipglosss.Width - // instead of uniseg.StringWidth. - // // XXX: Do we even need this or can we calculate the prompt width // at render time? m.promptWidth = uniseg.StringWidth(m.Prompt) @@ -1003,7 +1000,7 @@ func (m *Model) SetWidth(w int) { // Add line number width to reserved inner width. if m.ShowLineNumbers { // XXX: this was originally documented as needing "1 cell" but was, - // in practice, hardcoded to effectively 2 cells. We can, and should, + // in practice, effectively hardcoded to 2 cells. We can, and should, // reduce this to one gap and update the tests accordingly. const gap = 2 @@ -1238,7 +1235,7 @@ func (m Model) View() string { } // Note the widest line number for padding purposes later. - lnw := lipgloss.Width(ln) + lnw := uniseg.StringWidth(ln) if lnw > widestLineNumber { widestLineNumber = lnw } @@ -1283,7 +1280,7 @@ func (m Model) View() string { // Write end of buffer content leftGutter := string(m.EndOfBufferCharacter) - rightGapWidth := m.Width() - lipgloss.Width(leftGutter) + widestLineNumber + rightGapWidth := m.Width() - uniseg.StringWidth(leftGutter) + widestLineNumber rightGap := strings.Repeat(" ", max(0, rightGapWidth)) s.WriteString(styles.computedEndOfBuffer().Render(leftGutter + rightGap)) s.WriteRune('\n') @@ -1444,10 +1441,10 @@ func Blink() tea.Msg { func (m Model) Cursor() *tea.Cursor { lineInfo := m.LineInfo() w := lipgloss.Width - x := lineInfo.CharOffset + w(m.promptView(0)) + w(m.lineNumberView(0, false)) - y := m.cursorLineNumber() - m.viewport.YOffset + xOffset := lineInfo.CharOffset + w(m.promptView(0)) + w(m.lineNumberView(0, false)) + yOffset := m.cursorLineNumber() - m.viewport.YOffset - c := tea.NewCursor(x, y) + c := tea.NewCursor(xOffset, yOffset) c.Blink = m.Styles.Cursor.Blink c.Color = m.Styles.Cursor.Color c.Shape = m.Styles.Cursor.Shape From 42b0377b5a131ef9cb94c6edf149ed864ad94a17 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Fri, 31 Jan 2025 15:18:23 -0500 Subject: [PATCH 16/16] chore(textarea): account for margins and padding in cursor offsets --- textarea/textarea.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 7adb9024..39e5fed4 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1441,8 +1441,20 @@ func Blink() tea.Msg { func (m Model) Cursor() *tea.Cursor { lineInfo := m.LineInfo() w := lipgloss.Width - xOffset := lineInfo.CharOffset + w(m.promptView(0)) + w(m.lineNumberView(0, false)) - yOffset := m.cursorLineNumber() - m.viewport.YOffset + baseStyle := m.activeStyle().Base + + xOffset := lineInfo.CharOffset + + w(m.promptView(0)) + + w(m.lineNumberView(0, false)) + + baseStyle.GetMarginLeft() + + baseStyle.GetPaddingLeft() + + baseStyle.GetBorderLeftSize() + + yOffset := m.cursorLineNumber() + + m.viewport.YOffset + + baseStyle.GetMarginTop() + + baseStyle.GetPaddingTop() + + baseStyle.GetBorderTopSize() c := tea.NewCursor(xOffset, yOffset) c.Blink = m.Styles.Cursor.Blink