diff --git a/cursor/cursor.go b/cursor/cursor.go index c49781ed..824b4503 100644 --- a/cursor/cursor.go +++ b/cursor/cursor.go @@ -1,3 +1,5 @@ +// Package cursor provides a virtual cursor to support the textinput and +// textarea elements. package cursor import ( @@ -51,25 +53,35 @@ 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. Style lipgloss.Style - // TextStyle is the style used for the cursor when it is hidden (when blinking). - // I.e. displaying normal text. + + // TextStyle is the style used for the cursor when it is blinking + // (hidden), i.e. displaying normal text. 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 state of the cursor blink. When true, the cursor is hidden. + 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 } diff --git a/textarea/textarea.go b/textarea/textarea.go index d35c6ca7..8e0d4132 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -3,8 +3,10 @@ package textarea import ( "crypto/sha256" "fmt" + "image/color" "strconv" "strings" + "time" "unicode" "github.com/atotto/clipboard" @@ -102,19 +104,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 @@ -122,12 +130,41 @@ 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. + Color color.Color + + // 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 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 +} + // 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. type Styles struct { Focused StyleState Blurred StyleState + Cursor CursorStyle } // StyleState that will be applied to the text area. @@ -139,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 { @@ -204,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 @@ -225,14 +262,12 @@ 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 - // VirtualCursor is the text area 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. @@ -301,11 +336,11 @@ func New() Model { MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", Styles: styles, - activeStyle: &styles.Blurred, cache: memoization.NewMemoCache[line, [][]rune](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, - VirtualCursor: cur, + VirtualCursor: true, + virtualCursor: cur, KeyMap: DefaultKeyMap(), value: make([][]rune, minHeight, maxLines), @@ -348,6 +383,11 @@ 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{ + Color: lipgloss.Color("7"), + Shape: tea.CursorBlock, + Blink: true, + } return s } @@ -361,6 +401,33 @@ func DefaultDarkStyles() Styles { return DefaultStyles(true) } +// 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 = 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.BlinkSpeed > 0 { + m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed + } + + if !m.VirtualCursor { + m.virtualCursor.SetMode(cursor.CursorHide) + return + } + if m.Styles.Cursor.Blink { + m.virtualCursor.SetMode(cursor.CursorBlink) + return + } + m.virtualCursor.SetMode(cursor.CursorStatic) +} + // SetValue sets the value of the text input. func (m *Model) SetValue(s string) { m.Reset() @@ -463,7 +530,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. @@ -571,9 +638,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. @@ -582,12 +649,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. @@ -595,20 +662,27 @@ 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() + 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.VirtualCursor.Blur() + m.virtualCursor.Blur() } // Reset sets the input to its default state with no input. @@ -617,7 +691,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. @@ -634,7 +708,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 @@ -642,7 +716,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 @@ -654,11 +728,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) } } @@ -674,22 +748,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 } @@ -712,12 +786,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 } @@ -729,13 +803,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++ @@ -756,7 +830,7 @@ func (m *Model) characterLeft(insideLine bool) { } } if m.col > 0 { - m.SetCursor(m.col - 1) + m.SetCursorColumn(m.col - 1) } } @@ -775,7 +849,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) } } @@ -802,7 +876,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++ } } @@ -892,13 +966,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. @@ -909,22 +983,32 @@ 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. + // + // XXX: Do we even need this or can we calculate the prompt width + // at render time? m.promptWidth = uniseg.StringWidth(m.Prompt) } // 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 // Add line number width to reserved inner width. if m.ShowLineNumbers { - 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 @@ -945,14 +1029,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 } @@ -976,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.VirtualCursor.Blur() + m.virtualCursor.Blur() return m, nil } @@ -1021,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): @@ -1098,10 +1181,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.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) @@ -1112,10 +1195,11 @@ 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() } - m.VirtualCursor.TextStyle = m.activeStyle.computedCursorLine() + m.virtualCursor.TextStyle = m.activeStyle().computedCursorLine() var ( s strings.Builder @@ -1123,6 +1207,7 @@ func (m Model) View() string { newLines int widestLineNumber int lineInfo = m.LineInfo() + styles = m.activeStyle() ) displayLine := 0 @@ -1130,35 +1215,25 @@ 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 := m.promptView(displayLine) + prompt = styles.computedPrompt().Render(prompt) s.WriteString(style.Render(prompt)) displayLine++ 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)) } } @@ -1184,11 +1259,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.virtualCursor.SetChar(" ") + s.WriteString(m.virtualCursor.View()) } else { - m.VirtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) - s.WriteString(style.Render(m.VirtualCursor.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 { @@ -1203,71 +1278,94 @@ 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 = m.activeStyle.computedPrompt().Render(prompt) - s.WriteString(prompt) + s.WriteString(m.promptView(displayLine)) displayLine++ // Write end of buffer content 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()) } -// 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. - digits := len(strconv.Itoa(m.MaxHeight)) - return fmt.Sprintf(" %*v ", digits, x) -} - -func (m Model) getPromptString(displayLine int) (prompt string) { +// 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) - pl := uniseg.StringWidth(prompt) - if pl < m.promptWidth { - prompt = fmt.Sprintf("%*s%s", m.promptWidth-pl, "", prompt) + width := lipgloss.Width(prompt) + if width < m.promptWidth { + prompt = fmt.Sprintf("%*s%s", m.promptWidth-width, "", prompt) + } + + return m.activeStyle().computedPrompt().Render(prompt) +} + +// 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. +// +// 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) + } + + // XXX: is textStyle really necessary here? + textStyle := m.activeStyle().computedText() + lineNumberStyle := m.activeStyle().computedLineNumber() + if isCursorLine { + textStyle = m.activeStyle().computedCursorLine() + lineNumberStyle = m.activeStyle().computedCursorLineNumber() } - return prompt + + // Format line number dynamically based on the maximum number of lines. + digits := len(strconv.Itoa(m.MaxHeight)) + str = fmt.Sprintf(" %*v ", digits, str) + + 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 - style = m.activeStyle.computedPlaceholder() + s strings.Builder + p = m.Placeholder + styles = m.activeStyle() ) - // 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") for i := 0; i < m.height; i++ { - lineStyle := m.activeStyle.computedPlaceholder() - lineNumberStyle := m.activeStyle.computedLineNumber() + isLineNumber := len(plines) > i + + lineStyle := styles.computedPlaceholder() if len(plines) > i { - lineStyle = m.activeStyle.computedCursorLine() - lineNumberStyle = m.activeStyle.computedCursorLineNumber() + lineStyle = styles.computedCursorLine() } // render prompt - prompt := m.getPromptString(i) - prompt = m.activeStyle.computedPrompt().Render(prompt) + prompt := m.promptView(i) + prompt = styles.computedPrompt().Render(prompt) s.WriteString(lineStyle.Render(prompt)) // when show line numbers enabled: @@ -1275,14 +1373,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: } } @@ -1291,21 +1389,26 @@ 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.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])))))) + 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(style.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 - eob := m.activeStyle.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter)) + eob := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter)) s.WriteString(eob) } @@ -1314,24 +1417,40 @@ 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 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. +// 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.Cursor() +// 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 { 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 - // TODO: sort out where these properties live. c := tea.NewCursor(x, y) - c.Blink = true - c.Color = m.VirtualCursor.Style.GetForeground() + c.Blink = m.Styles.Cursor.Blink + c.Color = m.Styles.Cursor.Color + c.Shape = m.Styles.Cursor.Shape return c } @@ -1496,6 +1615,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 @@ -1516,3 +1649,10 @@ func max(a, b int) int { } return b } + +func abs(n int) int { + if n < 0 { + return -n + } + return n +}