From ba5555aa93219c1788da27ffba5e15ba26161e65 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 25 May 2025 08:09:25 -0400 Subject: [PATCH 01/11] fix(cursor): set ID on virutal cursors --- cursor/cursor.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cursor/cursor.go b/cursor/cursor.go index 7e168fd..ec271e0 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -4,6 +4,7 @@ package cursor import ( "context" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea/v2" @@ -12,6 +13,14 @@ import ( const defaultBlinkSpeed = time.Millisecond * 530 +// Internal ID management. Used during animating to ensure that frame messages +// are received only by spinner components that sent them. +var lastID int64 + +func nextID() int { + return int(atomic.AddInt64(&lastID, 1)) +} + // initialBlinkMsg initializes cursor blinking. type initialBlinkMsg struct{} @@ -65,6 +74,7 @@ type Model struct { BlinkSpeed time.Duration // Blink is the state of the cursor blink. When true, the cursor is hidden. + // TODO: rename to Blinking Blink bool // char is the character under the cursor @@ -89,10 +99,10 @@ type Model struct { // New creates a new model with default settings. func New() Model { return Model{ + id: nextID(), BlinkSpeed: defaultBlinkSpeed, - - Blink: true, - mode: CursorBlink, + Blink: true, + mode: CursorBlink, blinkCtx: &blinkCtx{ ctx: context.Background(), @@ -170,6 +180,7 @@ func (m *Model) SetMode(mode Mode) tea.Cmd { } // BlinkCmd is a command used to manage cursor blinking. +// TODO: Rename to Blink func (m *Model) BlinkCmd() tea.Cmd { if m.mode != CursorBlink { return nil From 8e84f33a49359719b67acadf00b42d5d7ea8acf3 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 25 May 2025 22:05:04 -0400 Subject: [PATCH 02/11] fix!(textinput): cursor fixes and improvements * Fix virtual cursor blinking * Use getter and setter for styles * Use getter and setter for virtual-real cursor management --- cursor/cursor.go | 7 ++-- textinput/textinput.go | 90 ++++++++++++++++++++++++++++-------------- 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/cursor/cursor.go b/cursor/cursor.go index ec271e0..b382f61 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -74,7 +74,8 @@ type Model struct { BlinkSpeed time.Duration // Blink is the state of the cursor blink. When true, the cursor is hidden. - // TODO: rename to Blinking + // + // TODO: rename to Blinking. Blink bool // char is the character under the cursor @@ -180,7 +181,8 @@ func (m *Model) SetMode(mode Mode) tea.Cmd { } // BlinkCmd is a command used to manage cursor blinking. -// TODO: Rename to Blink +// +// TODO: Rename to Blink. func (m *Model) BlinkCmd() tea.Cmd { if m.mode != CursorBlink { return nil @@ -194,7 +196,6 @@ func (m *Model) BlinkCmd() tea.Cmd { m.blinkCtx.cancel = cancel m.blinkTag++ - blinkMsg := BlinkMsg{id: m.id, tag: m.blinkTag} return func() tea.Msg { diff --git a/textinput/textinput.go b/textinput/textinput.go index acf966c..5774ea1 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -95,19 +95,21 @@ type Model struct { EchoMode EchoMode EchoCharacter rune - // VirtualCursor determines whether or not to use the virtual cursor. If + // useVirtualCursor 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 - virtualCursor cursor.Model + useVirtualCursor bool - // Styling. FocusedStyle and BlurredStyle are used to style the textarea in - // focused and blurred states. - Styles Styles + // Virtual cursor manager. + virtualCursor cursor.Model // CharLimit is the maximum amount of characters this input element will // accept. If 0 or less, there's no limit. CharLimit int + // Styling. FocusedStyle and BlurredStyle are used to style the textarea in + // focused and blurred states. + styles Styles + // Width is the maximum number of characters that can be displayed at once. // It essentially treats the text field like a horizontally scrolling // viewport. If 0 or less this setting is ignored. @@ -152,19 +154,45 @@ type Model struct { // New creates a new model with default settings. func New() Model { - return Model{ - Prompt: "> ", - EchoCharacter: '*', - CharLimit: 0, - Styles: DefaultDarkStyles(), - ShowSuggestions: false, - virtualCursor: cursor.New(), - KeyMap: DefaultKeyMap(), - suggestions: [][]rune{}, - value: nil, - focus: false, - pos: 0, + m := Model{ + Prompt: "> ", + EchoCharacter: '*', + CharLimit: 0, + styles: DefaultDarkStyles(), + ShowSuggestions: false, + useVirtualCursor: true, + virtualCursor: cursor.New(), + KeyMap: DefaultKeyMap(), + suggestions: [][]rune{}, + value: nil, + focus: false, + pos: 0, } + m.updateVirtualCursorStyle() + return m +} + +// VirtualCursor returns whether the model is using a virtual cursor. +func (m Model) VirtualCursor() bool { + return m.useVirtualCursor +} + +// SetVirtualCursor sets whether the model should use a virtual cursor. If +// disabled, use [Model.Cursor] to return a real cursor for rendering. +func (m *Model) SetVirtualCursor(v bool) { + m.useVirtualCursor = v + m.updateVirtualCursorStyle() +} + +// Styles returns the current set of styles. +func (m Model) Styles() Styles { + return m.styles +} + +// SetStyles sets the styles for the text input. +func (m *Model) SetStyles(s Styles) { + m.styles = s + m.updateVirtualCursorStyle() } // Width returns the width of the text input. @@ -549,7 +577,6 @@ func (m Model) echoTransform(v string) string { // Update is the Bubble Tea update loop. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { - m.updateVirtualCursorStyle() if !m.focus { return m, nil } @@ -636,8 +663,10 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd - m.virtualCursor, cmd = m.virtualCursor.Update(msg) - cmds = append(cmds, cmd) + if m.useVirtualCursor { + m.virtualCursor, cmd = m.virtualCursor.Update(msg) + cmds = append(cmds, cmd) + } if oldPos != m.pos && m.virtualCursor.Mode() == cursor.CursorBlink { m.virtualCursor.Blink = false @@ -882,7 +911,7 @@ func (m Model) validate(v []rune) error { // f.Cursor.Position.X += offsetX // f.Cursor.Position.Y += offsetY func (m Model) Cursor() *tea.Cursor { - if m.VirtualCursor { + if m.useVirtualCursor { return nil } @@ -895,7 +924,7 @@ func (m Model) Cursor() *tea.Cursor { xOffset = min(xOffset, m.width+promptWidth) } - style := m.Styles.Cursor + style := m.styles.Cursor c := tea.NewCursor(xOffset, 0) c.Blink = style.Blink c.Color = style.Color @@ -906,18 +935,19 @@ func (m Model) Cursor() *tea.Cursor { // updateVirtualCursorStyle sets styling on the virtual cursor based on the // textarea's style settings. func (m *Model) updateVirtualCursorStyle() { - if !m.VirtualCursor { + if !m.useVirtualCursor { + // Hide the virtual cursor if we're using a real cursor. m.virtualCursor.SetMode(cursor.CursorHide) return } - m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.Styles.Cursor.Color) + m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.styles.Cursor.Color) // By default, the blink speed of the cursor is set to a default // internally. - if m.Styles.Cursor.Blink { - if m.Styles.Cursor.BlinkSpeed > 0 { - m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed + if m.styles.Cursor.Blink { + if m.styles.Cursor.BlinkSpeed > 0 { + m.virtualCursor.BlinkSpeed = m.styles.Cursor.BlinkSpeed } m.virtualCursor.SetMode(cursor.CursorBlink) return @@ -929,7 +959,7 @@ func (m *Model) updateVirtualCursorStyle() { // whether the textarea is focused or blurred. func (m Model) activeStyle() *StyleState { if m.focus { - return &m.Styles.Focused + return &m.styles.Focused } - return &m.Styles.Blurred + return &m.styles.Blurred } From b531de8f1cfb3551998fbb0c30ec78b7f7d1b047 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 26 May 2025 07:46:33 -0400 Subject: [PATCH 03/11] fix!(textarea): virtual cursor blink --- textarea/textarea.go | 66 +++++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 0db907c..285c394 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -260,17 +260,9 @@ type Model struct { // KeyMap encodes the keybindings recognized by the widget. KeyMap KeyMap - // Styling. FocusedStyle and BlurredStyle are used to style the textarea in - // focused and blurred states. - Styles Styles - // virtualCursor manages the virtual cursor. virtualCursor cursor.Model - // 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. CharLimit int @@ -283,6 +275,15 @@ type Model struct { // there's no limit. MaxWidth int + // Styling. Styles are defined in [Styles]. Use [SetStyles] and [GetStyles] + // to work with this value publicly. + styles Styles + + // useVirtualCursor determines whether or not to use the virtual cursor. + // Use [SetVirtualCursor] and [VirtualCursor] to work with this this + // value publicly. + useVirtualCursor bool + // If promptFunc is set, it replaces Prompt as a generator for // prompt strings at the beginning of each line. promptFunc func(line int) string @@ -337,11 +338,11 @@ func New() Model { MaxHeight: defaultMaxHeight, MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", - Styles: styles, + styles: styles, cache: memoization.NewMemoCache[line, [][]rune](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, - VirtualCursor: true, + useVirtualCursor: true, virtualCursor: cur, KeyMap: DefaultKeyMap(), @@ -403,21 +404,43 @@ func DefaultDarkStyles() Styles { return DefaultStyles(true) } +// Styles returns the current styles for the textarea. +func (m Model) Styles() Styles { + return m.styles +} + +// SetStyles updates styling for the textarea. +func (m *Model) SetStyles(s Styles) { + m.styles = s + m.updateVirtualCursorStyle() +} + +// VirtualCursor returns whether or not the virtual cursor is enabled. +func (m Model) VirtualCursor() bool { + return m.useVirtualCursor +} + +// SetVirtualCursor sets whether or not to use the virtual cursor. +func (m *Model) SetVirtualCursor(v bool) { + m.useVirtualCursor = v + m.updateVirtualCursorStyle() +} + // updateVirtualCursorStyle sets styling on the virtual cursor based on the // textarea's style settings. func (m *Model) updateVirtualCursorStyle() { - if !m.VirtualCursor { + if !m.useVirtualCursor { m.virtualCursor.SetMode(cursor.CursorHide) return } - m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.Styles.Cursor.Color) + m.virtualCursor.Style = lipgloss.NewStyle().Foreground(m.styles.Cursor.Color) // By default, the blink speed of the cursor is set to a default // internally. - if m.Styles.Cursor.Blink { - if m.Styles.Cursor.BlinkSpeed > 0 { - m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed + if m.styles.Cursor.Blink { + if m.styles.Cursor.BlinkSpeed > 0 { + m.virtualCursor.BlinkSpeed = m.styles.Cursor.BlinkSpeed } m.virtualCursor.SetMode(cursor.CursorBlink) return @@ -663,9 +686,9 @@ func (m Model) Focused() bool { // whether the textarea is focused or blurred. func (m Model) activeStyle() *StyleState { if m.focus { - return &m.Styles.Focused + return &m.styles.Focused } - return &m.Styles.Blurred + return &m.styles.Blurred } // Focus sets the focus state on the model. When the model is in focus it can @@ -1188,7 +1211,6 @@ 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() } @@ -1431,7 +1453,7 @@ func Blink() tea.Msg { // f.Cursor.Position.X += offsetX // f.Cursor.Position.Y += offsetY func (m Model) Cursor() *tea.Cursor { - if m.VirtualCursor { + if m.useVirtualCursor { return nil } @@ -1453,9 +1475,9 @@ func (m Model) Cursor() *tea.Cursor { baseStyle.GetBorderTopSize() c := tea.NewCursor(xOffset, yOffset) - c.Blink = m.Styles.Cursor.Blink - c.Color = m.Styles.Cursor.Color - c.Shape = m.Styles.Cursor.Shape + c.Blink = m.styles.Cursor.Blink + c.Color = m.styles.Cursor.Color + c.Shape = m.styles.Cursor.Shape return c } From bb1d1d275df7efa3a2a017daa48cb21291fdd03d Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 26 May 2025 07:53:43 -0400 Subject: [PATCH 04/11] fix(textarea): suppress blink messages when real cursor is active --- textarea/textarea.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 285c394..f56bc83 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1196,13 +1196,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.viewport = &vp 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() + if m.useVirtualCursor { + m.virtualCursor, cmd = m.virtualCursor.Update(msg) + + // If the cursor has moved, reset the blink state. This is a small UX + // nuance that makes cursor movement obvious and feel snappy. + newRow, newCol := m.cursorLineNumber(), m.col + if (newRow != oldRow || newCol != oldCol) && m.virtualCursor.Mode() == cursor.CursorBlink { + m.virtualCursor.Blink = false + cmd = m.virtualCursor.BlinkCmd() + } + cmds = append(cmds, cmd) } - cmds = append(cmds, cmd) m.repositionView() From 3cbcdd9249746eddfd32eceb6a10fa46eb2825d0 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 26 May 2025 07:54:46 -0400 Subject: [PATCH 05/11] chore(textinput): minor logic improvement with regard to cursors --- textinput/textinput.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/textinput/textinput.go b/textinput/textinput.go index 5774ea1..fe61757 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -666,11 +666,13 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if m.useVirtualCursor { m.virtualCursor, cmd = m.virtualCursor.Update(msg) cmds = append(cmds, cmd) - } - if oldPos != m.pos && m.virtualCursor.Mode() == cursor.CursorBlink { - m.virtualCursor.Blink = false - cmds = append(cmds, m.virtualCursor.BlinkCmd()) + // If the cursor position changed, reset the blink state. This is a + // small UX nuance that makes cursor movement obvious and feel snappy. + if oldPos != m.pos && m.virtualCursor.Mode() == cursor.CursorBlink { + m.virtualCursor.Blink = false + cmds = append(cmds, m.virtualCursor.BlinkCmd()) + } } m.handleOverflow() From d42b7c42d5c72d789705a1d1e454dc08edafc8a7 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 26 May 2025 08:00:12 -0400 Subject: [PATCH 06/11] fix(textarea/tests): update tests per API changes --- textarea/textarea_test.go | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/textarea/textarea_test.go b/textarea/textarea_test.go index d1ccad5..dc778fb 100644 --- a/textarea/textarea_test.go +++ b/textarea/textarea_test.go @@ -1032,7 +1032,9 @@ func TestView(t *testing.T) { { name: "set width with style", modelFunc: func(m Model) Model { - m.Styles.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + s := m.Styles() + s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.SetStyles(s) m.Focus() m.SetWidth(12) @@ -1060,7 +1062,9 @@ func TestView(t *testing.T) { { name: "set width with style max width minus one", modelFunc: func(m Model) Model { - m.Styles.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + s := m.Styles() + s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.SetStyles(s) m.Focus() m.SetWidth(12) @@ -1088,7 +1092,9 @@ func TestView(t *testing.T) { { name: "set width with style max width", modelFunc: func(m Model) Model { - m.Styles.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + s := m.Styles() + s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.SetStyles(s) m.Focus() m.SetWidth(12) @@ -1116,7 +1122,9 @@ func TestView(t *testing.T) { { name: "set width with style max width plus one", modelFunc: func(m Model) Model { - m.Styles.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + s := m.Styles() + s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.SetStyles(s) m.Focus() m.SetWidth(12) @@ -1144,7 +1152,9 @@ func TestView(t *testing.T) { { name: "set width without line numbers with style", modelFunc: func(m Model) Model { - m.Styles.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + s := m.Styles() + s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.SetStyles(s) m.Focus() m.ShowLineNumbers = false @@ -1173,7 +1183,9 @@ func TestView(t *testing.T) { { name: "set width without line numbers with style max width minus one", modelFunc: func(m Model) Model { - m.Styles.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + s := m.Styles() + s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.SetStyles(s) m.Focus() m.ShowLineNumbers = false @@ -1202,7 +1214,9 @@ func TestView(t *testing.T) { { name: "set width without line numbers with style max width", modelFunc: func(m Model) Model { - m.Styles.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + s := m.Styles() + s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.SetStyles(s) m.Focus() m.ShowLineNumbers = false @@ -1231,7 +1245,9 @@ func TestView(t *testing.T) { { name: "set width without line numbers with style max width plus one", modelFunc: func(m Model) Model { - m.Styles.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + s := m.Styles() + s.Focused.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.SetStyles(s) m.Focus() m.ShowLineNumbers = false From b2bcf2232b53688b9bd58a62096457b91b3cf55b Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 26 May 2025 08:05:35 -0400 Subject: [PATCH 07/11] chore(textarea,textinput/lint): modernize loops --- textarea/textarea.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index f56bc83..769becf 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -487,7 +487,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Split the input into lines. var lines [][]rune lstart := 0 - for i := 0; i < len(runes); i++ { + for i := range runes { if runes[i] == '\n' { // Queue a line to become a new row in the text area below. // Beware to clamp the max capacity of the slice, to ensure no @@ -1375,7 +1375,7 @@ func (m Model) placeholderView() string { // split string by new lines plines := strings.Split(strings.TrimSpace(pwrap), "\n") - for i := 0; i < m.height; i++ { + for i := range m.height { isLineNumber := len(plines) > i lineStyle := styles.computedPlaceholder() From 0f113d10c4372424f3134557640d1b9a3db1d893 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 26 May 2025 08:11:26 -0400 Subject: [PATCH 08/11] chore(textarea,textinput/lint): use slices.Delete() --- textarea/textarea.go | 3 ++- textinput/textinput.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 769becf..d892468 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "fmt" "image/color" + "slices" "strconv" "strings" "time" @@ -1125,7 +1126,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } case key.Matches(msg, m.KeyMap.DeleteCharacterForward): if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) { - m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][m.col+1:]...) + m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1) } if m.col >= len(m.value[m.row]) { m.mergeLineBelow(m.row) diff --git a/textinput/textinput.go b/textinput/textinput.go index fe61757..1156001 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -4,6 +4,7 @@ package textinput import ( "reflect" + "slices" "strings" "unicode" @@ -624,7 +625,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.CursorStart() case key.Matches(msg, m.KeyMap.DeleteCharacterForward): if len(m.value) > 0 && m.pos < len(m.value) { - m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) + m.value = slices.Delete(m.value, m.pos, m.pos+1) m.Err = m.validate(m.value) } case key.Matches(msg, m.KeyMap.LineEnd): From e89dc94c85bf06c188d167d4a5c2f428e7a5f16f Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 26 May 2025 08:13:36 -0400 Subject: [PATCH 09/11] chore!(cursor): improve naming around 'blinking' --- cursor/cursor.go | 31 ++++++++++++++----------------- cursor/cursor_test.go | 12 ++++++------ textarea/textarea.go | 4 ++-- textinput/textinput.go | 4 ++-- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/cursor/cursor.go b/cursor/cursor.go index b382f61..d0dc4b3 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -73,10 +73,9 @@ type Model struct { // unless [CursorMode] is not set to [CursorBlink]. BlinkSpeed time.Duration - // Blink is the state of the cursor blink. When true, the cursor is hidden. - // - // TODO: rename to Blinking. - Blink bool + // IsBlinked is the state of the cursor blink. When true, the cursor is + // hidden. + IsBlinked bool // char is the character under the cursor char string @@ -102,7 +101,7 @@ func New() Model { return Model{ id: nextID(), BlinkSpeed: defaultBlinkSpeed, - Blink: true, + IsBlinked: true, mode: CursorBlink, blinkCtx: &blinkCtx{ @@ -121,7 +120,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } - cmd := m.BlinkCmd() + cmd := m.Blink() return m, cmd case tea.FocusMsg: @@ -147,8 +146,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd if m.mode == CursorBlink { - m.Blink = !m.Blink - cmd = m.BlinkCmd() + m.IsBlinked = !m.IsBlinked + cmd = m.Blink() } return m, cmd @@ -173,17 +172,15 @@ func (m *Model) SetMode(mode Mode) tea.Cmd { return nil } m.mode = mode - m.Blink = m.mode == CursorHide || !m.focus + m.IsBlinked = m.mode == CursorHide || !m.focus if mode == CursorBlink { return Blink } return nil } -// BlinkCmd is a command used to manage cursor blinking. -// -// TODO: Rename to Blink. -func (m *Model) BlinkCmd() tea.Cmd { +// Blink is a command used to manage cursor blinking. +func (m *Model) Blink() tea.Cmd { if m.mode != CursorBlink { return nil } @@ -216,10 +213,10 @@ func Blink() tea.Msg { // Focus focuses the cursor to allow it to blink if desired. func (m *Model) Focus() tea.Cmd { m.focus = true - m.Blink = m.mode == CursorHide // show the cursor unless we've explicitly hidden it + m.IsBlinked = m.mode == CursorHide // show the cursor unless we've explicitly hidden it if m.mode == CursorBlink && m.focus { - return m.BlinkCmd() + return m.Blink() } return nil } @@ -227,7 +224,7 @@ func (m *Model) Focus() tea.Cmd { // Blur blurs the cursor. func (m *Model) Blur() { m.focus = false - m.Blink = true + m.IsBlinked = true } // SetChar sets the character under the cursor. @@ -237,7 +234,7 @@ func (m *Model) SetChar(char string) { // View displays the cursor. func (m Model) View() string { - if m.Blink { + if m.IsBlinked { return m.TextStyle.Inline(true).Render(m.char) } return m.Style.Inline(true).Reverse(true).Render(m.char) diff --git a/cursor/cursor_test.go b/cursor/cursor_test.go index c526c4a..237bc87 100644 --- a/cursor/cursor_test.go +++ b/cursor/cursor_test.go @@ -8,7 +8,7 @@ import ( // TestBlinkCmdDataRace tests for a race on [Cursor.blinkTag]. // -// The original [Model.BlinkCmd] implementation returned a closure over the pointer receiver: +// The original [Model.Blink] implementation returned a closure over the pointer receiver: // // return func() tea.Msg { // defer cancel() @@ -20,12 +20,12 @@ import ( // } // // A race on “m.blinkTag” will occur if: -// 1. [Model.BlinkCmd] is called e.g. by calling [Model.Focus] from +// 1. [Model.Blink] is called e.g. by calling [Model.Focus] from // ["github.com/charmbracelet/bubbletea".Model.Update]; // 2. ["github.com/charmbracelet/bubbletea".handleCommands] is kept sufficiently busy that it does not recieve and -// execute the [Model.BlinkCmd] e.g. by other long running command or commands; +// execute the [Model.Blink] e.g. by other long running command or commands; // 3. at least [Mode.BlinkSpeed] time elapses; -// 4. [Model.BlinkCmd] is called again; +// 4. [Model.Blink] is called again; // 5. ["github.com/charmbracelet/bubbletea".handleCommands] gets around to receiving and executing the original // closure. // @@ -33,7 +33,7 @@ import ( // current value rather than the value at the time the closure was created). func TestBlinkCmdDataRace(t *testing.T) { m := New() - cmd := m.BlinkCmd() + cmd := m.Blink() var wg sync.WaitGroup wg.Add(2) go func() { @@ -44,7 +44,7 @@ func TestBlinkCmdDataRace(t *testing.T) { go func() { defer wg.Done() time.Sleep(m.BlinkSpeed * 2) - m.BlinkCmd() + m.Blink() }() wg.Wait() } diff --git a/textarea/textarea.go b/textarea/textarea.go index d892468..c88e92f 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1204,8 +1204,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // nuance that makes cursor movement obvious and feel snappy. newRow, newCol := m.cursorLineNumber(), m.col if (newRow != oldRow || newCol != oldCol) && m.virtualCursor.Mode() == cursor.CursorBlink { - m.virtualCursor.Blink = false - cmd = m.virtualCursor.BlinkCmd() + m.virtualCursor.IsBlinked = false + cmd = m.virtualCursor.Blink() } cmds = append(cmds, cmd) } diff --git a/textinput/textinput.go b/textinput/textinput.go index 1156001..9ac68eb 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -671,8 +671,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // If the cursor position changed, reset the blink state. This is a // small UX nuance that makes cursor movement obvious and feel snappy. if oldPos != m.pos && m.virtualCursor.Mode() == cursor.CursorBlink { - m.virtualCursor.Blink = false - cmds = append(cmds, m.virtualCursor.BlinkCmd()) + m.virtualCursor.IsBlinked = false + cmds = append(cmds, m.virtualCursor.Blink()) } } From b3f0c9e423182d999f2824f5ceb1b56c0ef08e1c Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Mon, 26 May 2025 09:08:32 -0400 Subject: [PATCH 10/11] fix(textarea): cursorline now fills the line when a placeholder is present --- textarea/textarea.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/textarea/textarea.go b/textarea/textarea.go index c88e92f..1fa63ce 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1418,6 +1418,11 @@ func (m Model) placeholderView() string { // the rest of the first line s.WriteString(lineStyle.Render(styles.computedPlaceholder().Render(rest))) + + // extend the first line with spaces to fill the width, so that + // the entire line is filled when cursorline is enabled. + gap := strings.Repeat(" ", max(0, m.width-lipgloss.Width(plines[0]))) + s.WriteString(lineStyle.Render(gap)) // remaining lines case len(plines) > i: // current line placeholder text From 04ec518a956d772037f5ff5c6ff9be77ebc01791 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Fri, 30 May 2025 17:20:01 -0300 Subject: [PATCH 11/11] chore: run `modernize` --- internal/memoization/memoization.go | 2 +- internal/memoization/memoization_test.go | 11 ++++++----- internal/runeutil/runeutil.go | 2 +- paginator/paginator.go | 2 +- progress/progress.go | 2 +- viewport/viewport_test.go | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/internal/memoization/memoization.go b/internal/memoization/memoization.go index 46c347a..845c280 100644 --- a/internal/memoization/memoization.go +++ b/internal/memoization/memoization.go @@ -121,5 +121,5 @@ type HInt int // Hash is a method that returns the hash of the integer. func (h HInt) Hash() string { - return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", h)))) + return fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%d", h))) } diff --git a/internal/memoization/memoization_test.go b/internal/memoization/memoization_test.go index 7e21232..4d63b1d 100644 --- a/internal/memoization/memoization_test.go +++ b/internal/memoization/memoization_test.go @@ -4,6 +4,7 @@ import ( "encoding/binary" "fmt" "os" + "slices" "testing" ) @@ -17,8 +18,8 @@ const ( type cacheAction struct { actionType actionType key HString - value interface{} - expectedValue interface{} + value any + expectedValue any } type testCase struct { @@ -121,7 +122,7 @@ func TestCache(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cache := NewMemoCache[HString, interface{}](tt.capacity) + cache := NewMemoCache[HString, any](tt.capacity) for _, action := range tt.actions { switch action.actionType { case set: @@ -174,7 +175,7 @@ func FuzzCache(f *testing.F) { // If the key is already in accessOrder, we remove it and append it again later for index, accessedKey := range accessOrder { if accessedKey == key { - accessOrder = append(accessOrder[:index], accessOrder[index+1:]...) + accessOrder = slices.Delete(accessOrder, index, index+1) break } } @@ -206,7 +207,7 @@ func FuzzCache(f *testing.F) { // If the key was accessed, move it to the end of the accessOrder to represent recent use for index, accessedKey := range accessOrder { if accessedKey == key { - accessOrder = append(accessOrder[:index], accessOrder[index+1:]...) + accessOrder = slices.Delete(accessOrder, index, index+1) accessOrder = append(accessOrder, key) break } diff --git a/internal/runeutil/runeutil.go b/internal/runeutil/runeutil.go index 6856cc8..3d5b288 100644 --- a/internal/runeutil/runeutil.go +++ b/internal/runeutil/runeutil.go @@ -61,7 +61,7 @@ func (s *sanitizer) Sanitize(runes []rune) []rune { // is smaller or equal to the input. copied := false - for src := 0; src < len(runes); src++ { + for src := range runes { r := runes[src] switch { case r == utf8.RuneError: diff --git a/paginator/paginator.go b/paginator/paginator.go index 7abe326..d5b786e 100644 --- a/paginator/paginator.go +++ b/paginator/paginator.go @@ -185,7 +185,7 @@ func (m Model) View() string { func (m Model) dotsView() string { var s string - for i := 0; i < m.TotalPages; i++ { + for i := range m.TotalPages { if i == m.Page { s += m.ActiveDot continue diff --git a/progress/progress.go b/progress/progress.go index c0bc6a6..dc933df 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -295,7 +295,7 @@ func (m Model) barView(b *strings.Builder, percent float64, textWidth int) { if m.useRamp { // Gradient fill - for i := 0; i < fw; i++ { + for i := range fw { if fw == 1 { // this is up for debate: in a gradient of width=1, should the // single character rendered be the first color, the last color diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index b64e9fd..bf504dd 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -374,7 +374,7 @@ func TestRightOverscroll(t *testing.T) { m := New(WithHeight(5), WithWidth(len(content)+1)) m.SetContent(content) - for i := 0; i < 10; i++ { + for range 10 { m.ScrollRight(m.horizontalStep) }