summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/textarea
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-11-02 18:43:17 -0500
committerDax Raad <[email protected]>2025-11-02 18:43:33 -0500
commitf68374ad2223ddc213bdea9519ca6a699819ee0e (patch)
tree04f0fe21b8e12cd62d7274961bb0cff64f966f40 /packages/tui/internal/components/textarea
parent5e86c9b7916f75c7ad227b80eab18c7c54fc8ffe (diff)
downloadopencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.tar.gz
opencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.zip
DELETE GO BUBBLETEA CRAP HOORAY
Diffstat (limited to 'packages/tui/internal/components/textarea')
-rw-r--r--packages/tui/internal/components/textarea/memoization.go125
-rw-r--r--packages/tui/internal/components/textarea/runeutil.go102
-rw-r--r--packages/tui/internal/components/textarea/textarea.go2377
-rw-r--r--packages/tui/internal/components/textarea/textarea_test.go75
4 files changed, 0 insertions, 2679 deletions
diff --git a/packages/tui/internal/components/textarea/memoization.go b/packages/tui/internal/components/textarea/memoization.go
deleted file mode 100644
index 2c9aec4f7..000000000
--- a/packages/tui/internal/components/textarea/memoization.go
+++ /dev/null
@@ -1,125 +0,0 @@
-// Package memoization implement a simple memoization cache. It's designed to
-// improve performance in textarea.
-package textarea
-
-import (
- "container/list"
- "crypto/sha256"
- "fmt"
- "sync"
-)
-
-// Hasher is an interface that requires a Hash method. The Hash method is
-// expected to return a string representation of the hash of the object.
-type Hasher interface {
- Hash() string
-}
-
-// entry is a struct that holds a key-value pair. It is used as an element
-// in the evictionList of the MemoCache.
-type entry[T any] struct {
- key string
- value T
-}
-
-// MemoCache is a struct that represents a cache with a set capacity. It
-// uses an LRU (Least Recently Used) eviction policy. It is safe for
-// concurrent use.
-type MemoCache[H Hasher, T any] struct {
- capacity int
- mutex sync.Mutex
- cache map[string]*list.Element // The cache holding the results
- evictionList *list.List // A list to keep track of the order for LRU
- hashableItems map[string]T // This map keeps track of the original hashable items (optional)
-}
-
-// NewMemoCache is a function that creates a new MemoCache with a given
-// capacity. It returns a pointer to the created MemoCache.
-func NewMemoCache[H Hasher, T any](capacity int) *MemoCache[H, T] {
- return &MemoCache[H, T]{
- capacity: capacity,
- cache: make(map[string]*list.Element),
- evictionList: list.New(),
- hashableItems: make(map[string]T),
- }
-}
-
-// Capacity is a method that returns the capacity of the MemoCache.
-func (m *MemoCache[H, T]) Capacity() int {
- return m.capacity
-}
-
-// Size is a method that returns the current size of the MemoCache. It is
-// the number of items currently stored in the cache.
-func (m *MemoCache[H, T]) Size() int {
- m.mutex.Lock()
- defer m.mutex.Unlock()
- return m.evictionList.Len()
-}
-
-// Get is a method that returns the value associated with the given
-// hashable item in the MemoCache. If there is no corresponding value, the
-// method returns nil.
-func (m *MemoCache[H, T]) Get(h H) (T, bool) {
- m.mutex.Lock()
- defer m.mutex.Unlock()
-
- hashedKey := h.Hash()
- if element, found := m.cache[hashedKey]; found {
- m.evictionList.MoveToFront(element)
- return element.Value.(*entry[T]).value, true
- }
- var result T
- return result, false
-}
-
-// Set is a method that sets the value for the given hashable item in the
-// MemoCache. If the cache is at capacity, it evicts the least recently
-// used item before adding the new item.
-func (m *MemoCache[H, T]) Set(h H, value T) {
- m.mutex.Lock()
- defer m.mutex.Unlock()
-
- hashedKey := h.Hash()
- if element, found := m.cache[hashedKey]; found {
- m.evictionList.MoveToFront(element)
- element.Value.(*entry[T]).value = value
- return
- }
-
- // Check if the cache is at capacity
- if m.evictionList.Len() >= m.capacity {
- // Evict the least recently used item from the cache
- toEvict := m.evictionList.Back()
- if toEvict != nil {
- evictedEntry := m.evictionList.Remove(toEvict).(*entry[T])
- delete(m.cache, evictedEntry.key)
- delete(m.hashableItems, evictedEntry.key) // if you're keeping track of original items
- }
- }
-
- // Add the value to the cache and the evictionList
- newEntry := &entry[T]{
- key: hashedKey,
- value: value,
- }
- element := m.evictionList.PushFront(newEntry)
- m.cache[hashedKey] = element
- m.hashableItems[hashedKey] = value // if you're keeping track of original items
-}
-
-// HString is a type that implements the Hasher interface for strings.
-type HString string
-
-// Hash is a method that returns the hash of the string.
-func (h HString) Hash() string {
- return fmt.Sprintf("%x", sha256.Sum256([]byte(h)))
-}
-
-// HInt is a type that implements the Hasher interface for integers.
-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))))
-}
diff --git a/packages/tui/internal/components/textarea/runeutil.go b/packages/tui/internal/components/textarea/runeutil.go
deleted file mode 100644
index c4fc87f80..000000000
--- a/packages/tui/internal/components/textarea/runeutil.go
+++ /dev/null
@@ -1,102 +0,0 @@
-// Package runeutil provides utility functions for tidying up incoming runes
-// from Key messages.
-package textarea
-
-import (
- "unicode"
- "unicode/utf8"
-)
-
-// Sanitizer is a helper for bubble widgets that want to process
-// Runes from input key messages.
-type Sanitizer interface {
- // Sanitize removes control characters from runes in a KeyRunes
- // message, and optionally replaces newline/carriage return/tabs by a
- // specified character.
- //
- // The rune array is modified in-place if possible. In that case, the
- // returned slice is the original slice shortened after the control
- // characters have been removed/translated.
- Sanitize(runes []rune) []rune
-}
-
-// NewSanitizer constructs a rune sanitizer.
-func NewSanitizer(opts ...Option) Sanitizer {
- s := sanitizer{
- replaceNewLine: []rune("\n"),
- replaceTab: []rune(" "),
- }
- for _, o := range opts {
- s = o(s)
- }
- return &s
-}
-
-// Option is the type of option that can be passed to Sanitize().
-type Option func(sanitizer) sanitizer
-
-// ReplaceTabs replaces tabs by the specified string.
-func ReplaceTabs(tabRepl string) Option {
- return func(s sanitizer) sanitizer {
- s.replaceTab = []rune(tabRepl)
- return s
- }
-}
-
-// ReplaceNewlines replaces newline characters by the specified string.
-func ReplaceNewlines(nlRepl string) Option {
- return func(s sanitizer) sanitizer {
- s.replaceNewLine = []rune(nlRepl)
- return s
- }
-}
-
-func (s *sanitizer) Sanitize(runes []rune) []rune {
- // dstrunes are where we are storing the result.
- dstrunes := runes[:0:len(runes)]
- // copied indicates whether dstrunes is an alias of runes
- // or a copy. We need a copy when dst moves past src.
- // We use this as an optimization to avoid allocating
- // a new rune slice in the common case where the output
- // is smaller or equal to the input.
- copied := false
-
- for src := 0; src < len(runes); src++ {
- r := runes[src]
- switch {
- case r == utf8.RuneError:
- // skip
-
- case r == '\r' || r == '\n':
- if len(dstrunes)+len(s.replaceNewLine) > src && !copied {
- dst := len(dstrunes)
- dstrunes = make([]rune, dst, len(runes)+len(s.replaceNewLine))
- copy(dstrunes, runes[:dst])
- copied = true
- }
- dstrunes = append(dstrunes, s.replaceNewLine...)
-
- case r == '\t':
- if len(dstrunes)+len(s.replaceTab) > src && !copied {
- dst := len(dstrunes)
- dstrunes = make([]rune, dst, len(runes)+len(s.replaceTab))
- copy(dstrunes, runes[:dst])
- copied = true
- }
- dstrunes = append(dstrunes, s.replaceTab...)
-
- case unicode.IsControl(r):
- // Other control characters: skip.
-
- default:
- // Keep the character.
- dstrunes = append(dstrunes, runes[src])
- }
- }
- return dstrunes
-}
-
-type sanitizer struct {
- replaceNewLine []rune
- replaceTab []rune
-}
diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go
deleted file mode 100644
index 6e6695917..000000000
--- a/packages/tui/internal/components/textarea/textarea.go
+++ /dev/null
@@ -1,2377 +0,0 @@
-package textarea
-
-import (
- "crypto/sha256"
- "fmt"
- "image/color"
- "strconv"
- "strings"
- "time"
- "unicode"
-
- "slices"
-
- "github.com/charmbracelet/bubbles/v2/cursor"
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- rw "github.com/mattn/go-runewidth"
- "github.com/rivo/uniseg"
- "github.com/sst/opencode/internal/attachment"
-)
-
-const (
- minHeight = 1
- defaultHeight = 1
- defaultWidth = 40
- defaultCharLimit = 0 // no limit
- defaultMaxHeight = 99
- defaultMaxWidth = 500
-
- // XXX: in v2, make max lines dynamic and default max lines configurable.
- maxLines = 10000
-)
-
-// Helper functions for converting between runes and any slices
-
-// runesToInterfaces converts a slice of runes to a slice of interfaces
-func runesToInterfaces(runes []rune) []any {
- result := make([]any, len(runes))
- for i, r := range runes {
- result[i] = r
- }
- return result
-}
-
-// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes)
-func interfacesToRunes(items []any) []rune {
- var result []rune
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- result = append(result, val)
- case *attachment.Attachment:
- result = append(result, []rune(val.Display)...)
- }
- }
- return result
-}
-
-// copyInterfaceSlice creates a copy of an any slice
-func copyInterfaceSlice(src []any) []any {
- dst := make([]any, len(src))
- copy(dst, src)
- return dst
-}
-
-// interfacesToString converts a slice of interfaces to a string for display
-func interfacesToString(items []any) string {
- var s strings.Builder
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- s.WriteRune(val)
- case *attachment.Attachment:
- s.WriteString(val.Display)
- }
- }
- return s.String()
-}
-
-// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment.
-// This allows for proper highlighting even when the cursor is technically at the position
-// after the attachment object in the underlying slice.
-func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) {
- if m.row >= len(m.value) {
- return nil, -1, -1
- }
-
- row := m.value[m.row]
- col := m.col
-
- if col < 0 || col > len(row) {
- return nil, -1, -1
- }
-
- // Check if the cursor is at the same index as an attachment.
- if col < len(row) {
- if att, ok := row[col].(*attachment.Attachment); ok {
- return att, col, col
- }
- }
-
- // Check if the cursor is immediately after an attachment. This is a common
- // state, for example, after just inserting one.
- if col > 0 && col <= len(row) {
- if att, ok := row[col-1].(*attachment.Attachment); ok {
- return att, col - 1, col - 1
- }
- }
-
- return nil, -1, -1
-}
-
-// renderLineWithAttachments renders a line with proper attachment highlighting
-func (m Model) renderLineWithAttachments(
- items []any,
- style lipgloss.Style,
-) string {
- var s strings.Builder
- currentAttachment, _, _ := m.isAttachmentAtCursor()
-
- for _, item := range items {
- switch val := item.(type) {
- case rune:
- s.WriteString(style.Render(string(val)))
- case *attachment.Attachment:
- // Check if this is the attachment the cursor is currently on
- if currentAttachment != nil && currentAttachment.ID == val.ID {
- // Cursor is on this attachment, highlight it
- s.WriteString(m.Styles.SelectedAttachment.Render(val.Display))
- } else {
- s.WriteString(m.Styles.Attachment.Render(val.Display))
- }
- }
- }
- return s.String()
-}
-
-// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune
-func getRuneAt(items []any, index int) rune {
- if index < 0 || index >= len(items) {
- return 0
- }
- if r, ok := items[index].(rune); ok {
- return r
- }
- return 0
-}
-
-// isSpaceAt checks if the item at index is a space rune
-func isSpaceAt(items []any, index int) bool {
- r := getRuneAt(items, index)
- return r != 0 && unicode.IsSpace(r)
-}
-
-// setRuneAt safely sets a rune at a specific position if it's a rune
-func setRuneAt(items []any, index int, r rune) {
- if index >= 0 && index < len(items) {
- if _, ok := items[index].(rune); ok {
- items[index] = r
- }
- }
-}
-
-// Internal messages for clipboard operations.
-type (
- pasteMsg string
- pasteErrMsg struct{ error }
-)
-
-// KeyMap is the key bindings for different actions within the textarea.
-type KeyMap struct {
- CharacterBackward key.Binding
- CharacterForward key.Binding
- DeleteAfterCursor key.Binding
- DeleteBeforeCursor key.Binding
- DeleteCharacterBackward key.Binding
- DeleteCharacterForward key.Binding
- DeleteWordBackward key.Binding
- DeleteWordForward key.Binding
- InsertNewline key.Binding
- LineEnd key.Binding
- LineNext key.Binding
- LinePrevious key.Binding
- LineStart key.Binding
- Paste key.Binding
- WordBackward key.Binding
- WordForward key.Binding
- InputBegin key.Binding
- InputEnd key.Binding
-
- UppercaseWordForward key.Binding
- LowercaseWordForward key.Binding
- CapitalizeWordForward key.Binding
-
- TransposeCharacterBackward key.Binding
-}
-
-// DefaultKeyMap returns the default set of key bindings for navigating and acting
-// upon the textarea.
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- CharacterForward: key.NewBinding(
- key.WithKeys("right", "ctrl+f"),
- key.WithHelp("right", "character forward"),
- ),
- CharacterBackward: key.NewBinding(
- key.WithKeys("left", "ctrl+b"),
- key.WithHelp("left", "character backward"),
- ),
- WordForward: key.NewBinding(
- key.WithKeys("alt+right", "ctrl+right", "alt+f"),
- key.WithHelp("alt+right", "word forward"),
- ),
- WordBackward: key.NewBinding(
- key.WithKeys("alt+left", "ctrl+left", "alt+b"),
- key.WithHelp("alt+left", "word backward"),
- ),
- LineNext: key.NewBinding(
- key.WithKeys("down", "ctrl+n"),
- key.WithHelp("down", "next line"),
- ),
- LinePrevious: key.NewBinding(
- key.WithKeys("up", "ctrl+p"),
- key.WithHelp("up", "previous line"),
- ),
- DeleteWordBackward: key.NewBinding(
- key.WithKeys("alt+backspace", "ctrl+w"),
- key.WithHelp("alt+backspace", "delete word backward"),
- ),
- DeleteWordForward: key.NewBinding(
- key.WithKeys("alt+delete", "alt+d"),
- key.WithHelp("alt+delete", "delete word forward"),
- ),
- DeleteAfterCursor: key.NewBinding(
- key.WithKeys("ctrl+k"),
- key.WithHelp("ctrl+k", "delete after cursor"),
- ),
- DeleteBeforeCursor: key.NewBinding(
- key.WithKeys("ctrl+u"),
- key.WithHelp("ctrl+u", "delete before cursor"),
- ),
- InsertNewline: key.NewBinding(
- key.WithKeys("enter", "ctrl+m"),
- key.WithHelp("enter", "insert newline"),
- ),
- DeleteCharacterBackward: key.NewBinding(
- key.WithKeys("backspace", "ctrl+h"),
- key.WithHelp("backspace", "delete character backward"),
- ),
- DeleteCharacterForward: key.NewBinding(
- key.WithKeys("delete", "ctrl+d"),
- key.WithHelp("delete", "delete character forward"),
- ),
- LineStart: key.NewBinding(
- key.WithKeys("home", "ctrl+a"),
- key.WithHelp("home", "line start"),
- ),
- LineEnd: key.NewBinding(
- key.WithKeys("end", "ctrl+e"),
- key.WithHelp("end", "line end"),
- ),
- Paste: key.NewBinding(
- key.WithKeys("ctrl+v"),
- key.WithHelp("ctrl+v", "paste"),
- ),
- InputBegin: key.NewBinding(
- key.WithKeys("alt+<", "ctrl+home"),
- key.WithHelp("alt+<", "input begin"),
- ),
- InputEnd: key.NewBinding(
- key.WithKeys("alt+>", "ctrl+end"),
- key.WithHelp("alt+>", "input end"),
- ),
-
- CapitalizeWordForward: key.NewBinding(
- key.WithKeys("alt+c"),
- key.WithHelp("alt+c", "capitalize word forward"),
- ),
- LowercaseWordForward: key.NewBinding(
- key.WithKeys("alt+l"),
- key.WithHelp("alt+l", "lowercase word forward"),
- ),
- UppercaseWordForward: key.NewBinding(
- key.WithKeys("alt+u"),
- key.WithHelp("alt+u", "uppercase word forward"),
- ),
-
- TransposeCharacterBackward: key.NewBinding(
- key.WithKeys("ctrl+t"),
- key.WithHelp("ctrl+t", "transpose character backward"),
- ),
- }
-}
-
-// LineInfo is a helper for keeping track of line information regarding
-// soft-wrapped lines.
-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
- // the cursor.
- 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
- Attachment lipgloss.Style
- SelectedAttachment lipgloss.Style
-}
-
-// StyleState that will be applied to the text area.
-//
-// StyleState can be applied to focused and unfocused states to change the styles
-// depending on the focus state.
-//
-// For an introduction to styling with Lip Gloss see:
-// https://github.com/charmbracelet/lipgloss
-type StyleState struct {
- Base lipgloss.Style
- Text lipgloss.Style
- LineNumber lipgloss.Style
- CursorLineNumber lipgloss.Style
- CursorLine lipgloss.Style
- EndOfBuffer lipgloss.Style
- Placeholder lipgloss.Style
- Prompt lipgloss.Style
-}
-
-func (s StyleState) computedCursorLine() lipgloss.Style {
- return s.CursorLine.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedCursorLineNumber() lipgloss.Style {
- return s.CursorLineNumber.
- Inherit(s.CursorLine).
- Inherit(s.Base).
- Inline(true)
-}
-
-func (s StyleState) computedEndOfBuffer() lipgloss.Style {
- return s.EndOfBuffer.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedLineNumber() lipgloss.Style {
- return s.LineNumber.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedPlaceholder() lipgloss.Style {
- return s.Placeholder.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedPrompt() lipgloss.Style {
- return s.Prompt.Inherit(s.Base).Inline(true)
-}
-
-func (s StyleState) computedText() lipgloss.Style {
- return s.Text.Inherit(s.Base).Inline(true)
-}
-
-// line is the input to the text wrapping function. This is stored in a struct
-// so that it can be hashed and memoized.
-type line struct {
- content []any // Contains runes and *Attachment
- width int
-}
-
-// Hash returns a hash of the line.
-func (w line) Hash() string {
- var s strings.Builder
- for _, item := range w.content {
- switch v := item.(type) {
- case rune:
- s.WriteRune(v)
- case *attachment.Attachment:
- s.WriteString(v.ID)
- }
- }
- v := fmt.Sprintf("%s:%d", s.String(), w.width)
- return fmt.Sprintf("%x", sha256.Sum256([]byte(v)))
-}
-
-// Model is the Bubble Tea model for this text area element.
-type Model struct {
- Err error
-
- // General settings.
- cache *MemoCache[line, [][]any]
-
- // Prompt is printed at the beginning of each line.
- //
- // When changing the value of Prompt after the model has been
- // initialized, ensure that SetWidth() gets called afterwards.
- //
- // See also [SetPromptFunc] for a dynamic prompt.
- Prompt string
-
- // Placeholder is the text displayed when the user
- // hasn't entered anything yet.
- Placeholder string
-
- // ShowLineNumbers, if enabled, causes line numbers to be printed
- // after the prompt.
- ShowLineNumbers bool
-
- // EndOfBufferCharacter is displayed at the end of the input.
- EndOfBufferCharacter rune
-
- // 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
-
- // MaxHeight is the maximum height of the text area in rows. If 0 or less,
- // there's no limit.
- MaxHeight int
-
- // MaxWidth is the maximum width of the text area in columns. If 0 or less,
- // there's no limit.
- MaxWidth int
-
- // If promptFunc is set, it replaces Prompt as a generator for
- // prompt strings at the beginning of each line.
- promptFunc func(line int) string
-
- // promptWidth is the width of the prompt.
- promptWidth int
-
- // width is the maximum number of characters that can be displayed at once.
- // If 0 or less this setting is ignored.
- width int
-
- // height is the maximum number of lines that can be displayed at once. It
- // essentially treats the text field like a vertically scrolling viewport
- // if there are more lines than the permitted height.
- height int
-
- // Underlying text value. Contains either rune or *Attachment types.
- value [][]any
-
- // focus indicates whether user input focus should be on this input
- // component. When false, ignore keyboard input and hide the cursor.
- focus bool
-
- // Cursor column (slice index).
- col int
-
- // Cursor row.
- row int
-
- // Last character offset, used to maintain state when the cursor is moved
- // vertically such that we can maintain the same navigating position.
- lastCharOffset int
-
- // rune sanitizer for input.
- rsan Sanitizer
-}
-
-// New creates a new model with default settings.
-func New() Model {
- cur := cursor.New()
-
- styles := DefaultDarkStyles()
-
- m := Model{
- CharLimit: defaultCharLimit,
- MaxHeight: defaultMaxHeight,
- MaxWidth: defaultMaxWidth,
- Prompt: lipgloss.ThickBorder().Left + " ",
- Styles: styles,
- cache: NewMemoCache[line, [][]any](maxLines),
- EndOfBufferCharacter: ' ',
- ShowLineNumbers: true,
- VirtualCursor: true,
- virtualCursor: cur,
- KeyMap: DefaultKeyMap(),
-
- value: make([][]any, minHeight, maxLines),
- focus: false,
- col: 0,
- row: 0,
- }
-
- m.SetWidth(defaultWidth)
- m.SetHeight(defaultHeight)
-
- return m
-}
-
-// DefaultStyles returns the default styles for focused and blurred states for
-// the textarea.
-func DefaultStyles(isDark bool) Styles {
- lightDark := lipgloss.LightDark(isDark)
-
- var s Styles
- s.Focused = StyleState{
- Base: lipgloss.NewStyle(),
- CursorLine: lipgloss.NewStyle().
- Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))),
- CursorLineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))),
- EndOfBuffer: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
- LineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
- Text: lipgloss.NewStyle(),
- }
- s.Blurred = StyleState{
- Base: lipgloss.NewStyle(),
- CursorLine: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
- CursorLineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- EndOfBuffer: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))),
- LineNumber: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))),
- Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
- Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")),
- Text: lipgloss.NewStyle().
- Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))),
- }
- s.Attachment = lipgloss.NewStyle().
- Background(lipgloss.Color("11")).
- Foreground(lipgloss.Color("0"))
- s.SelectedAttachment = lipgloss.NewStyle().
- Background(lipgloss.Color("11")).
- Foreground(lipgloss.Color("0"))
- s.Cursor = CursorStyle{
- Color: lipgloss.Color("7"),
- Shape: tea.CursorBlock,
- Blink: true,
- }
- return s
-}
-
-// DefaultLightStyles returns the default styles for a light background.
-func DefaultLightStyles() Styles {
- return DefaultStyles(false)
-}
-
-// DefaultDarkStyles returns the default styles for a dark background.
-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.Blink {
- if m.Styles.Cursor.BlinkSpeed > 0 {
- m.virtualCursor.BlinkSpeed = m.Styles.Cursor.BlinkSpeed
- }
- 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()
- m.InsertString(s)
-}
-
-// InsertString inserts a string at the cursor position.
-func (m *Model) InsertString(s string) {
- m.InsertRunesFromUserInput([]rune(s))
-}
-
-// InsertRune inserts a rune at the cursor position.
-func (m *Model) InsertRune(r rune) {
- m.InsertRunesFromUserInput([]rune{r})
-}
-
-// InsertAttachment inserts an attachment at the cursor position.
-func (m *Model) InsertAttachment(att *attachment.Attachment) {
- if m.CharLimit > 0 {
- availSpace := m.CharLimit - m.Length()
- // If the char limit's been reached, cancel.
- if availSpace <= 0 {
- return
- }
- }
-
- // Insert the attachment at the current cursor position
- m.value[m.row] = append(
- m.value[m.row][:m.col],
- append([]any{att}, m.value[m.row][m.col:]...)...)
- m.col++
- m.SetCursorColumn(m.col)
-}
-
-// removeAttachmentAtCursor replaces the attachment at or immediately before the
-// cursor with its textual display and positions the cursor at the end of the
-// inserted text. Returns true if an attachment was removed.
-func (m *Model) removeAttachmentAtCursor() bool {
- att, startIdx, _ := m.isAttachmentAtCursor()
- if att == nil {
- return false
- }
- // Replace the attachment element with the display runes
- before := m.value[m.row][:startIdx]
- after := m.value[m.row][startIdx+1:]
- replacement := runesToInterfaces([]rune(att.Display))
- newRow := make([]any, 0, len(before)+len(replacement)+len(after))
- newRow = append(newRow, before...)
- newRow = append(newRow, replacement...)
- newRow = append(newRow, after...)
- m.value[m.row] = newRow
- m.col = startIdx + len(replacement)
- m.SetCursorColumn(m.col)
- return true
-}
-
-// ReplaceRange replaces text from startCol to endCol on the current row with the given string.
-// This preserves attachments outside the replaced range.
-func (m *Model) ReplaceRange(startCol, endCol int, replacement string) {
- if m.row >= len(m.value) || startCol < 0 || endCol < startCol {
- return
- }
-
- // Ensure bounds are within the current row
- rowLen := len(m.value[m.row])
- startCol = max(0, min(startCol, rowLen))
- endCol = max(startCol, min(endCol, rowLen))
-
- // Create new row content: before + replacement + after
- before := m.value[m.row][:startCol]
- after := m.value[m.row][endCol:]
- replacementRunes := runesToInterfaces([]rune(replacement))
-
- // Combine the parts
- newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after))
- newRow = append(newRow, before...)
- newRow = append(newRow, replacementRunes...)
- newRow = append(newRow, after...)
-
- m.value[m.row] = newRow
-
- // Position cursor at end of replacement
- m.col = startCol + len(replacementRunes)
- m.SetCursorColumn(m.col)
-}
-
-// CurrentRowLength returns the length of the current row.
-func (m *Model) CurrentRowLength() int {
- if m.row >= len(m.value) {
- return 0
- }
- return len(m.value[m.row])
-}
-
-// GetAttachments returns all attachments in the textarea with accurate position indices.
-func (m Model) GetAttachments() []*attachment.Attachment {
- var attachments []*attachment.Attachment
- position := 0 // Track absolute position in the text
-
- for rowIdx, row := range m.value {
- colPosition := 0 // Track position within the current row
-
- for _, item := range row {
- switch v := item.(type) {
- case *attachment.Attachment:
- // Clone the attachment to avoid modifying the original
- att := *v
- att.StartIndex = position + colPosition
- att.EndIndex = position + colPosition + len(v.Display)
- attachments = append(attachments, &att)
- colPosition += len(v.Display)
- case rune:
- colPosition++
- }
- }
-
- // Add newline character position (except for last row)
- if rowIdx < len(m.value)-1 {
- position += colPosition + 1 // +1 for newline
- } else {
- position += colPosition
- }
- }
-
- return attachments
-}
-
-// InsertRunesFromUserInput inserts runes at the current cursor position.
-func (m *Model) InsertRunesFromUserInput(runes []rune) {
- // Clean up any special characters in the input provided by the
- // clipboard. This avoids bugs due to e.g. tab characters and
- // whatnot.
- runes = m.san().Sanitize(runes)
-
- if m.CharLimit > 0 {
- availSpace := m.CharLimit - m.Length()
- // If the char limit's been reached, cancel.
- if availSpace <= 0 {
- return
- }
- // If there's not enough space to paste the whole thing cut the pasted
- // runes down so they'll fit.
- if availSpace < len(runes) {
- runes = runes[:availSpace]
- }
- }
-
- // Split the input into lines.
- var lines [][]rune
- lstart := 0
- 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
- // data from different rows get overwritten when later edits
- // will modify this line.
- lines = append(lines, runes[lstart:i:i])
- lstart = i + 1
- }
- }
- if lstart <= len(runes) {
- // The last line did not end with a newline character.
- // Take it now.
- lines = append(lines, runes[lstart:])
- }
-
- // Obey the maximum line limit.
- if maxLines > 0 && len(m.value)+len(lines)-1 > maxLines {
- allowedHeight := max(0, maxLines-len(m.value)+1)
- lines = lines[:allowedHeight]
- }
-
- if len(lines) == 0 {
- // Nothing left to insert.
- return
- }
-
- // Save the remainder of the original line at the current
- // cursor position.
- tail := copyInterfaceSlice(m.value[m.row][m.col:])
-
- // Paste the first line at the current cursor position.
- m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...)
- m.col += len(lines[0])
-
- if numExtraLines := len(lines) - 1; numExtraLines > 0 {
- // Add the new lines.
- // We try to reuse the slice if there's already space.
- var newGrid [][]any
- if cap(m.value) >= len(m.value)+numExtraLines {
- // Can reuse the extra space.
- newGrid = m.value[:len(m.value)+numExtraLines]
- } else {
- // No space left; need a new slice.
- newGrid = make([][]any, len(m.value)+numExtraLines)
- copy(newGrid, m.value[:m.row+1])
- }
- // Add all the rows that were after the cursor in the original
- // grid at the end of the new grid.
- copy(newGrid[m.row+1+numExtraLines:], m.value[m.row+1:])
- m.value = newGrid
- // Insert all the new lines in the middle.
- for _, l := range lines[1:] {
- m.row++
- m.value[m.row] = runesToInterfaces(l)
- m.col = len(l)
- }
- }
-
- // Finally add the tail at the end of the last line inserted.
- m.value[m.row] = append(m.value[m.row], tail...)
-
- m.SetCursorColumn(m.col)
-}
-
-// Value returns the value of the text input.
-func (m Model) Value() string {
- if m.value == nil {
- return ""
- }
-
- var v strings.Builder
- for _, l := range m.value {
- for _, item := range l {
- switch val := item.(type) {
- case rune:
- v.WriteRune(val)
- case *attachment.Attachment:
- v.WriteString(val.Display)
- }
- }
- v.WriteByte('\n')
- }
-
- return strings.TrimSuffix(v.String(), "\n")
-}
-
-// Length returns the number of characters currently in the text input.
-func (m *Model) Length() int {
- var l int
- for _, row := range m.value {
- for _, item := range row {
- switch val := item.(type) {
- case rune:
- l += rw.RuneWidth(val)
- case *attachment.Attachment:
- l += uniseg.StringWidth(val.Display)
- }
- }
- }
- // We add len(m.value) to include the newline characters.
- return l + len(m.value) - 1
-}
-
-// LineCount returns the number of lines that are currently in the text input.
-func (m *Model) LineCount() int {
- return m.ContentHeight()
-}
-
-// Line returns the line position.
-func (m Model) Line() int {
- return m.row
-}
-
-// CursorColumn returns the cursor's column position (slice index).
-func (m Model) CursorColumn() int {
- return m.col
-}
-
-// LastRuneIndex returns the index of the last occurrence of a rune on the current line,
-// searching backwards from the current cursor position.
-// Returns -1 if the rune is not found before the cursor.
-func (m Model) LastRuneIndex(r rune) int {
- if m.row >= len(m.value) {
- return -1
- }
- // Iterate backwards from just before the cursor position
- for i := m.col - 1; i >= 0; i-- {
- if i < len(m.value[m.row]) {
- if item, ok := m.value[m.row][i].(rune); ok && item == r {
- return i
- }
- }
- }
- return -1
-}
-
-func (m *Model) Newline() {
- if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight {
- return
- }
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- m.splitLine(m.row, m.col)
-}
-
-// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index.
-// This is used to maintain the cursor's horizontal position when moving vertically.
-func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
- if row < 0 || row >= len(m.value) {
- return 0
- }
-
- offset := 0
- // Find the slice index that corresponds to the visual offset.
- for i, item := range m.value[row] {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
-
- // If the target offset falls within the current item, this is our index.
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- return i + 1
- }
- return i
- }
- offset += itemWidth
- }
-
- return len(m.value[row])
-}
-
-// CursorDown moves the cursor down by one line.
-func (m *Model) CursorDown() {
- li := m.LineInfo()
- charOffset := max(m.lastCharOffset, li.CharOffset)
- m.lastCharOffset = charOffset
-
- if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
- // Move to the next model line
- m.row++
-
- // We want to land on the first wrapped line of the new model line.
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[0]
-
- // Find position within the first wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundNextLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundNextLine:
- m.col = colInLine // startCol is 0 for the first wrapped line
- } else if li.RowOffset+1 < li.Height {
- // Move to the next wrapped line within the same model line
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[li.RowOffset+1]
-
- startCol := 0
- for i := 0; i < li.RowOffset+1; i++ {
- startCol += len(grid[i])
- }
-
- // Find position within the target wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundSameLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundSameLine:
- m.col = startCol + colInLine
- }
- m.SetCursorColumn(m.col)
-}
-
-// CursorUp moves the cursor up by one line.
-func (m *Model) CursorUp() {
- li := m.LineInfo()
- charOffset := max(m.lastCharOffset, li.CharOffset)
- m.lastCharOffset = charOffset
-
- if li.RowOffset <= 0 && m.row > 0 {
- // Move to the previous model line. We want to land on the last wrapped
- // line of the previous model line.
- m.row--
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[len(grid)-1]
-
- // Find start of last wrapped line.
- startCol := len(m.value[m.row]) - len(targetLineContent)
-
- // Find position within the last wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundPrevLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundPrevLine:
- m.col = startCol + colInLine
- } else if li.RowOffset > 0 {
- // Move to the previous wrapped line within the same model line.
- grid := m.memoizedWrap(m.value[m.row], m.width)
- targetLineContent := grid[li.RowOffset-1]
-
- startCol := 0
- for i := 0; i < li.RowOffset-1; i++ {
- startCol += len(grid[i])
- }
-
- // Find position within the target wrapped line.
- offset := 0
- colInLine := 0
- for i, item := range targetLineContent {
- var itemWidth int
- switch v := item.(type) {
- case rune:
- itemWidth = rw.RuneWidth(v)
- case *attachment.Attachment:
- itemWidth = uniseg.StringWidth(v.Display)
- }
- if offset+itemWidth > charOffset {
- // Decide whether to stick with the previous index or move to the current
- // one based on which is closer to the target offset.
- if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
- colInLine = i + 1
- } else {
- colInLine = i
- }
- goto foundSameLine
- }
- offset += itemWidth
- }
- colInLine = len(targetLineContent)
- foundSameLine:
- m.col = startCol + colInLine
- }
- m.SetCursorColumn(m.col)
-}
-
-// 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) 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.
- m.lastCharOffset = 0
-}
-
-// CursorStart moves the cursor to the start of the input field.
-func (m *Model) CursorStart() {
- m.SetCursorColumn(0)
-}
-
-// CursorEnd moves the cursor to the end of the input field.
-func (m *Model) CursorEnd() {
- m.SetCursorColumn(len(m.value[m.row]))
-}
-
-func (m *Model) IsCursorAtEnd() bool {
- return m.CursorColumn() == len(m.value[m.row])
-}
-
-// Focused returns the focus state on the model.
-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
- 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.virtualCursor.Blur()
-}
-
-// Reset sets the input to its default state with no input.
-func (m *Model) Reset() {
- m.value = make([][]any, minHeight, maxLines)
- m.col = 0
- m.row = 0
- m.SetCursorColumn(0)
-}
-
-// san initializes or retrieves the rune sanitizer.
-func (m *Model) san() Sanitizer {
- if m.rsan == nil {
- // Textinput has all its input on a single line so collapse
- // newlines/tabs to single spaces.
- m.rsan = NewSanitizer()
- }
- return m.rsan
-}
-
-// deleteBeforeCursor deletes all text before the cursor. Returns whether or
-// not the cursor blink should be reset.
-func (m *Model) deleteBeforeCursor() {
- m.value[m.row] = m.value[m.row][m.col:]
- m.SetCursorColumn(0)
-}
-
-// deleteAfterCursor deletes all text after the cursor. Returns whether or not
-// the cursor blink should be reset. If input is masked delete everything after
-// 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.SetCursorColumn(len(m.value[m.row]))
-}
-
-// transposeLeft exchanges the runes at the cursor and immediately
-// before. No-op if the cursor is at the beginning of the line. If
-// the cursor is not at the end of the line yet, moves the cursor to
-// the right.
-func (m *Model) transposeLeft() {
- if m.col == 0 || len(m.value[m.row]) < 2 {
- return
- }
- if m.col >= len(m.value[m.row]) {
- 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.SetCursorColumn(m.col + 1)
- }
-}
-
-// deleteWordLeft deletes the word left to the cursor. Returns whether or not
-// the cursor blink should be reset.
-func (m *Model) deleteWordLeft() {
- if m.col == 0 || len(m.value[m.row]) == 0 {
- return
- }
-
- // Linter note: it's critical that we acquire the initial cursor position
- // here prior to altering it via SetCursor() below. As such, moving this
- // call into the corresponding if clause does not apply here.
- oldCol := m.col //nolint:ifshort
-
- m.SetCursorColumn(m.col - 1)
- for isSpaceAt(m.value[m.row], m.col) {
- if m.col <= 0 {
- break
- }
- // ignore series of whitespace before cursor
- m.SetCursorColumn(m.col - 1)
- }
-
- for m.col > 0 {
- if !isSpaceAt(m.value[m.row], m.col) {
- m.SetCursorColumn(m.col - 1)
- } else {
- if m.col > 0 {
- // keep the previous space
- m.SetCursorColumn(m.col + 1)
- }
- break
- }
- }
-
- if oldCol > len(m.value[m.row]) {
- m.value[m.row] = m.value[m.row][:m.col]
- } else {
- m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][oldCol:]...)
- }
-}
-
-// deleteWordRight deletes the word right to the cursor.
-func (m *Model) deleteWordRight() {
- if m.col >= len(m.value[m.row]) || len(m.value[m.row]) == 0 {
- return
- }
-
- oldCol := m.col
-
- for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) {
- // ignore series of whitespace after cursor
- m.SetCursorColumn(m.col + 1)
- }
-
- for m.col < len(m.value[m.row]) {
- if !isSpaceAt(m.value[m.row], m.col) {
- m.SetCursorColumn(m.col + 1)
- } else {
- break
- }
- }
-
- if m.col > len(m.value[m.row]) {
- m.value[m.row] = m.value[m.row][:oldCol]
- } else {
- m.value[m.row] = append(m.value[m.row][:oldCol], m.value[m.row][m.col:]...)
- }
-
- 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.SetCursorColumn(m.col + 1)
- } else {
- if m.row < len(m.value)-1 {
- m.row++
- m.CursorStart()
- }
- }
-}
-
-// characterLeft moves the cursor one character to the left.
-// If insideLine is set, the cursor is moved to the last
-// character in the previous line, instead of one past that.
-func (m *Model) characterLeft(insideLine bool) {
- if m.col == 0 && m.row != 0 {
- m.row--
- m.CursorEnd()
- if !insideLine {
- return
- }
- }
- if m.col > 0 {
- m.SetCursorColumn(m.col - 1)
- }
-}
-
-// wordLeft moves the cursor one word to the left. Returns whether or not the
-// cursor blink should be reset. If input is masked, move input to the start
-// so as not to reveal word breaks in the masked input.
-func (m *Model) wordLeft() {
- for {
- m.characterLeft(true /* insideLine */)
- if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) {
- break
- }
- }
-
- for m.col > 0 {
- if isSpaceAt(m.value[m.row], m.col-1) {
- break
- }
- m.SetCursorColumn(m.col - 1)
- }
-}
-
-// wordRight moves the cursor one word to the right. Returns whether or not the
-// cursor blink should be reset. If the input is masked, move input to the end
-// so as not to reveal word breaks in the masked input.
-func (m *Model) wordRight() {
- m.doWordRight(func(int, int) { /* nothing */ })
-}
-
-func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
- // Skip spaces forward.
- for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) {
- if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) {
- // End of text.
- break
- }
- m.characterRight()
- }
-
- charIdx := 0
- for m.col < len(m.value[m.row]) {
- if isSpaceAt(m.value[m.row], m.col) {
- break
- }
- fn(charIdx, m.col)
- m.SetCursorColumn(m.col + 1)
- charIdx++
- }
-}
-
-// uppercaseRight changes the word to the right to uppercase.
-func (m *Model) uppercaseRight() {
- m.doWordRight(func(_ int, i int) {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToUpper(r)
- }
- })
-}
-
-// lowercaseRight changes the word to the right to lowercase.
-func (m *Model) lowercaseRight() {
- m.doWordRight(func(_ int, i int) {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToLower(r)
- }
- })
-}
-
-// capitalizeRight changes the word to the right to title case.
-func (m *Model) capitalizeRight() {
- m.doWordRight(func(charIdx int, i int) {
- if charIdx == 0 {
- if r, ok := m.value[m.row][i].(rune); ok {
- m.value[m.row][i] = unicode.ToTitle(r)
- }
- }
- })
-}
-
-// LineInfo returns the number of characters from the start of the
-// (soft-wrapped) line and the (soft-wrapped) line width.
-func (m Model) LineInfo() LineInfo {
- grid := m.memoizedWrap(m.value[m.row], m.width)
-
- // Find out which line we are currently on. This can be determined by the
- // m.col and counting the number of runes that we need to skip.
- var counter int
- for i, line := range grid {
- start := counter
- end := counter + len(line)
-
- if m.col >= start && m.col <= end {
- // This is the wrapped line the cursor is on.
-
- // Special case: if the cursor is at the end of a wrapped line,
- // and there's another wrapped line after it, the cursor should
- // be considered at the beginning of the next line.
- if m.col == end && i < len(grid)-1 {
- nextLine := grid[i+1]
- return LineInfo{
- CharOffset: 0,
- ColumnOffset: 0,
- Height: len(grid),
- RowOffset: i + 1,
- StartColumn: end,
- Width: len(nextLine),
- CharWidth: uniseg.StringWidth(interfacesToString(nextLine)),
- }
- }
-
- return LineInfo{
- CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])),
- ColumnOffset: m.col - start,
- Height: len(grid),
- RowOffset: i,
- StartColumn: start,
- Width: len(line),
- CharWidth: uniseg.StringWidth(interfacesToString(line)),
- }
- }
- counter = end
- }
- return LineInfo{}
-}
-
-// Width returns the width of the textarea.
-func (m Model) Width() int {
- return m.width
-}
-
-// MoveToBegin moves the cursor to the beginning of the input.
-func (m *Model) MoveToBegin() {
- m.row = 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.SetCursorColumn(len(m.value[m.row]))
-}
-
-// SetWidth sets the width of the textarea to fit exactly within the given width.
-// This means that the textarea will account for the width of the prompt and
-// whether or not line numbers are being shown.
-//
-// Ensure that SetWidth is called after setting the Prompt and ShowLineNumbers,
-// 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.
- if m.promptFunc == nil {
- // 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()
-
- // Add prompt width to reserved inner width.
- reservedInner := m.promptWidth
-
- // Add line number width to reserved inner width.
- if m.ShowLineNumbers {
- // XXX: this was originally documented as needing "1 cell" but was,
- // in practice, effectively hardcoded to 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
- // width. This gives us a minimum input width of 1.
- minWidth := reservedInner + reservedOuter + 1
- inputWidth := max(w, minWidth)
-
- // Input width must be no more than maximum width.
- if m.MaxWidth > 0 {
- inputWidth = min(inputWidth, m.MaxWidth)
- }
-
- // Since the width of the viewport and input area is dependent on the width of
- // borders, prompt and line numbers, we need to calculate it by subtracting
- // the reserved width from them.
-
- 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(lineIndex int) string) {
- m.promptFunc = fn
- m.promptWidth = promptWidth
-}
-
-// Height returns the current height of the textarea.
-func (m Model) Height() int {
- return m.height
-}
-
-// ContentHeight returns the actual height needed to display all content
-// including wrapped lines.
-func (m Model) ContentHeight() int {
- totalLines := 0
- for _, line := range m.value {
- wrappedLines := m.memoizedWrap(line, m.width)
- totalLines += len(wrappedLines)
- }
- // Ensure at least one line is shown
- if totalLines == 0 {
- totalLines = 1
- }
- return totalLines
-}
-
-// SetHeight sets the height of the textarea.
-func (m *Model) SetHeight(h int) {
- // Calculate the actual content height
- contentHeight := m.ContentHeight()
-
- // Use the content height as the actual height
- if m.MaxHeight > 0 {
- m.height = clamp(contentHeight, minHeight, m.MaxHeight)
- } else {
- m.height = max(contentHeight, minHeight)
- }
-}
-
-// Update is the Bubble Tea update loop.
-func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
- if !m.focus {
- m.virtualCursor.Blur()
- return m, nil
- }
-
- // Used to determine if the cursor should blink.
- oldRow, oldCol := m.cursorLineNumber(), m.col
-
- var cmds []tea.Cmd
-
- if m.row >= len(m.value) {
- m.value = append(m.value, make([]any, 0))
- }
- if m.value[m.row] == nil {
- m.value[m.row] = make([]any, 0)
- }
-
- if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() {
- m.cache = NewMemoCache[line, [][]any](m.MaxHeight)
- }
-
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col >= len(m.value[m.row]) {
- m.mergeLineBelow(m.row)
- break
- }
- m.deleteAfterCursor()
- case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- m.deleteBeforeCursor()
- case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
- // If the cursor is at or just after an attachment, convert it to text instead of deleting
- if att, _, _ := m.isAttachmentAtCursor(); att != nil {
- if m.removeAttachmentAtCursor() {
- break
- }
- }
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- if len(m.value[m.row]) > 0 && m.col > 0 {
- m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col)
- m.SetCursorColumn(m.col - 1)
- }
- case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
- // If the cursor is on an attachment, convert it to text instead of deleting
- if att, _, _ := m.isAttachmentAtCursor(); att != nil {
- if m.removeAttachmentAtCursor() {
- break
- }
- }
- if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
- 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)
- break
- }
- case key.Matches(msg, m.KeyMap.DeleteWordBackward):
- if m.col <= 0 {
- m.mergeLineAbove(m.row)
- break
- }
- m.deleteWordLeft()
- case key.Matches(msg, m.KeyMap.DeleteWordForward):
- m.col = clamp(m.col, 0, len(m.value[m.row]))
- if m.col >= len(m.value[m.row]) {
- m.mergeLineBelow(m.row)
- break
- }
- m.deleteWordRight()
- case key.Matches(msg, m.KeyMap.InsertNewline):
- m.Newline()
- case key.Matches(msg, m.KeyMap.LineEnd):
- m.CursorEnd()
- case key.Matches(msg, m.KeyMap.LineStart):
- m.CursorStart()
- case key.Matches(msg, m.KeyMap.CharacterForward):
- m.characterRight()
- case key.Matches(msg, m.KeyMap.LineNext):
- m.CursorDown()
- case key.Matches(msg, m.KeyMap.WordForward):
- m.wordRight()
- case key.Matches(msg, m.KeyMap.CharacterBackward):
- m.characterLeft(false /* insideLine */)
- case key.Matches(msg, m.KeyMap.LinePrevious):
- m.CursorUp()
- case key.Matches(msg, m.KeyMap.WordBackward):
- m.wordLeft()
- case key.Matches(msg, m.KeyMap.InputBegin):
- m.MoveToBegin()
- case key.Matches(msg, m.KeyMap.InputEnd):
- m.MoveToEnd()
- case key.Matches(msg, m.KeyMap.LowercaseWordForward):
- m.lowercaseRight()
- case key.Matches(msg, m.KeyMap.UppercaseWordForward):
- m.uppercaseRight()
- case key.Matches(msg, m.KeyMap.CapitalizeWordForward):
- m.capitalizeRight()
- case key.Matches(msg, m.KeyMap.TransposeCharacterBackward):
- m.transposeLeft()
-
- default:
- m.InsertRunesFromUserInput([]rune(msg.Text))
- }
-
- case pasteMsg:
- m.InsertRunesFromUserInput([]rune(msg))
-
- case pasteErrMsg:
- m.Err = msg
- }
-
- var cmd tea.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()
- }
- cmds = append(cmds, cmd)
-
- return m, tea.Batch(cmds...)
-}
-
-// 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()
-
- var (
- s strings.Builder
- style lipgloss.Style
- newLines int
- widestLineNumber int
- lineInfo = m.LineInfo()
- styles = m.activeStyle()
- )
-
- displayLine := 0
- for l, line := range m.value {
- wrappedLines := m.memoizedWrap(line, m.width)
-
- if m.row == l {
- style = styles.computedCursorLine()
- } else {
- style = styles.computedText()
- }
-
- for wl, wrappedLine := range wrappedLines {
- prompt := m.promptView(displayLine)
- prompt = styles.computedPrompt().Render(prompt)
- s.WriteString(style.Render(prompt))
- displayLine++
-
- var ln string
- if m.ShowLineNumbers {
- 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))
- }
- }
-
- // Note the widest line number for padding purposes later.
- lnw := uniseg.StringWidth(ln)
- if lnw > widestLineNumber {
- widestLineNumber = lnw
- }
-
- wrappedLineStr := interfacesToString(wrappedLine)
- strwidth := uniseg.StringWidth(wrappedLineStr)
- padding := m.width - strwidth
- // If the trailing space causes the line to be wider than the
- // width, we should not draw it to the screen since it will result
- // in an extra space at the end of the line which can look off when
- // the cursor line is showing.
- if strwidth > m.width {
- // The character causing the line to be wider than the width is
- // guaranteed to be a space since any other character would
- // have been wrapped.
- wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ")
- padding = m.width - uniseg.StringWidth(wrappedLineStr)
- }
-
- if m.row == l && lineInfo.RowOffset == wl {
- // Render the part of the line before the cursor
- s.WriteString(
- m.renderLineWithAttachments(
- wrappedLine[:lineInfo.ColumnOffset],
- style,
- ),
- )
-
- if m.col >= len(line) && lineInfo.CharOffset >= m.width {
- m.virtualCursor.SetChar(" ")
- s.WriteString(m.virtualCursor.View())
- } else if lineInfo.ColumnOffset < len(wrappedLine) {
- // Render the item under the cursor
- item := wrappedLine[lineInfo.ColumnOffset]
- if att, ok := item.(*attachment.Attachment); ok {
- // Item at cursor is an attachment. Render it with the selection style.
- // This becomes the "cursor" visually.
- s.WriteString(m.Styles.SelectedAttachment.Render(att.Display))
- } else {
- // Item at cursor is a rune. Render it with the virtual cursor.
- m.virtualCursor.SetChar(string(item.(rune)))
- s.WriteString(style.Render(m.virtualCursor.View()))
- }
-
- // Render the part of the line after the cursor
- s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style))
- } else {
- // Cursor is at the end of the line
- m.virtualCursor.SetChar(" ")
- s.WriteString(style.Render(m.virtualCursor.View()))
- }
- } else {
- s.WriteString(m.renderLineWithAttachments(wrappedLine, style))
- }
-
- s.WriteString(style.Render(strings.Repeat(" ", max(0, padding))))
- s.WriteRune('\n')
- newLines++
- }
- }
-
- // Remove the trailing newline from the last line
- result := s.String()
- if len(result) > 0 && result[len(result)-1] == '\n' {
- result = result[:len(result)-1]
- }
-
- return styles.Base.Render(result)
-}
-
-// 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 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()
- }
-
- // 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, if any.
-func (m Model) placeholderView() string {
- var (
- s strings.Builder
- p = m.Placeholder
- styles = m.activeStyle()
- )
- // word wrap lines
- pwordwrap := ansi.Wordwrap(p, m.width, "")
- // 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")
-
- // Only render the actual placeholder lines, not padded to m.height
- maxLines := max(len(plines), 1) // At least show one line for cursor
- for i := range maxLines {
- isLineNumber := len(plines) > i
-
- lineStyle := styles.computedPlaceholder()
- if len(plines) > i {
- lineStyle = styles.computedCursorLine()
- }
-
- // render prompt
- prompt := m.promptView(i)
- prompt = styles.computedPrompt().Render(prompt)
- s.WriteString(lineStyle.Render(prompt))
-
- // when show line numbers enabled:
- // - render line number for only the cursor line
- // - indent other placeholder lines
- // this is consistent with vim with line numbers enabled
- if m.ShowLineNumbers {
- var ln int
-
- switch {
- case i == 0:
- ln = i + 1
- fallthrough
- case len(plines) > i:
- s.WriteString(m.lineNumberView(ln, isLineNumber))
- default:
- }
- }
-
- switch {
- // first line
- case i == 0:
- // first character of first line as cursor with character
- 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
- 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 {
- 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 := styles.computedEndOfBuffer().Render(string(m.EndOfBufferCharacter))
- s.WriteString(eob)
- }
-
- // terminate with new line (except for last line)
- if i < maxLines-1 {
- s.WriteRune('\n')
- }
- }
-
- return styles.Base.Render(s.String())
-}
-
-// Blink returns the blink command for the virtual cursor.
-func Blink() tea.Msg {
- return cursor.Blink()
-}
-
-// Cursor returns a [tea.Cursor] for rendering a real cursor in a Bubble Tea
-// program. This requires that [Model.VirtualCursor] is set to false.
-//
-// Note that you will almost certainly also need to adjust the offset cursor
-// position per the textarea's per the textarea's position in the terminal.
-//
-// 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
-func (m Model) Cursor() *tea.Cursor {
- if m.VirtualCursor {
- return nil
- }
-
- lineInfo := m.LineInfo()
- w := lipgloss.Width
- 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() -
- baseStyle.GetMarginTop() +
- baseStyle.GetPaddingTop() +
- 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
- return c
-}
-
-func (m Model) memoizedWrap(content []any, width int) [][]any {
- input := line{content: content, width: width}
- if v, ok := m.cache.Get(input); ok {
- return v
- }
- v := wrapInterfaces(content, width)
- m.cache.Set(input, v)
- return v
-}
-
-// cursorLineNumber returns the line number that the cursor is on.
-// This accounts for soft wrapped lines.
-func (m Model) cursorLineNumber() int {
- line := 0
- for i := range m.row {
- // Calculate the number of lines that the current line will be split
- // into.
- line += len(m.memoizedWrap(m.value[i], m.width))
- }
- line += m.LineInfo().RowOffset
- return line
-}
-
-// mergeLineBelow merges the current line the cursor is on with the line below.
-func (m *Model) mergeLineBelow(row int) {
- if row >= len(m.value)-1 {
- return
- }
-
- // To perform a merge, we will need to combine the two lines and then
- m.value[row] = append(m.value[row], m.value[row+1]...)
-
- // Shift all lines up by one
- for i := row + 1; i < len(m.value)-1; i++ {
- m.value[i] = m.value[i+1]
- }
-
- // And, remove the last line
- if len(m.value) > 0 {
- m.value = m.value[:len(m.value)-1]
- }
-}
-
-// mergeLineAbove merges the current line the cursor is on with the line above.
-func (m *Model) mergeLineAbove(row int) {
- if row <= 0 {
- return
- }
-
- m.col = len(m.value[row-1])
- m.row = m.row - 1
-
- // To perform a merge, we will need to combine the two lines and then
- m.value[row-1] = append(m.value[row-1], m.value[row]...)
-
- // Shift all lines up by one
- for i := row; i < len(m.value)-1; i++ {
- m.value[i] = m.value[i+1]
- }
-
- // And, remove the last line
- if len(m.value) > 0 {
- m.value = m.value[:len(m.value)-1]
- }
-}
-
-func (m *Model) splitLine(row, col int) {
- // To perform a split, take the current line and keep the content before
- // the cursor, take the content after the cursor and make it the content of
- // the line underneath, and shift the remaining lines down by one
- head, tailSrc := m.value[row][:col], m.value[row][col:]
- tail := copyInterfaceSlice(tailSrc)
-
- m.value = append(m.value[:row+1], m.value[row:]...)
-
- m.value[row] = head
- m.value[row+1] = tail
-
- m.col = 0
- m.row++
-}
-
-func itemWidth(item any) int {
- switch v := item.(type) {
- case rune:
- return rw.RuneWidth(v)
- case *attachment.Attachment:
- return uniseg.StringWidth(v.Display)
- }
- return 0
-}
-
-// forceWrapAttachment splits an attachment's display text across multiple lines
-func forceWrapAttachment(att *attachment.Attachment, width int) [][]any {
- if width <= 0 {
- return [][]any{{att}}
- }
-
- display := att.Display
- displayRunes := []rune(display)
-
- if len(displayRunes) <= width {
- return [][]any{{att}}
- }
-
- var lines [][]any
- start := 0
-
- for start < len(displayRunes) {
- // Calculate how many runes fit in this line
- end := start + width
- if end > len(displayRunes) {
- end = len(displayRunes)
- }
-
- // Create a wrapped attachment for this segment
- wrappedAtt := &attachment.Attachment{
- ID: att.ID,
- Type: att.Type,
- Display: string(displayRunes[start:end]),
- URL: att.URL,
- Filename: att.Filename,
- MediaType: att.MediaType,
- Source: att.Source,
- }
-
- lines = append(lines, []any{wrappedAtt})
- start = end
- }
-
- return lines
-}
-
-// forceWrapWord splits a word that's too long to fit within the given width
-func forceWrapWord(word []any, width int) [][]any {
- if width <= 0 || len(word) == 0 {
- return [][]any{word}
- }
-
- var lines [][]any
- currentLine := []any{}
- currentWidth := 0
-
- for _, item := range word {
- if att, ok := item.(*attachment.Attachment); ok {
- // Handle attachment that might be too wide
- attWidth := uniseg.StringWidth(att.Display)
-
- // If the attachment display is too wide, split it
- if attWidth > width {
- // Finish current line if it has content
- if len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- // Split the attachment display across multiple lines
- wrappedAttachment := forceWrapAttachment(att, width)
- lines = append(lines, wrappedAttachment...)
- continue
- }
-
- // If adding this attachment would exceed the width, start a new line
- if currentWidth+attWidth > width && len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- currentLine = append(currentLine, item)
- currentWidth += attWidth
- } else if r, ok := item.(rune); ok {
- itemWidth := rw.RuneWidth(r)
-
- // If adding this rune would exceed the width, start a new line
- if currentWidth+itemWidth > width && len(currentLine) > 0 {
- lines = append(lines, currentLine)
- currentLine = []any{}
- currentWidth = 0
- }
-
- currentLine = append(currentLine, item)
- currentWidth += itemWidth
- }
- }
-
- // Add the last line if it has content
- if len(currentLine) > 0 {
- lines = append(lines, currentLine)
- }
-
- return lines
-}
-
-func wrapInterfaces(content []any, width int) [][]any {
- if width <= 0 {
- return [][]any{content}
- }
-
- var (
- lines = [][]any{{}}
- word = []any{}
- wordW int
- lineW int
- spaceW int
- inSpaces bool
- )
-
- for _, item := range content {
- itemW := 0
- isSpace := false
-
- if r, ok := item.(rune); ok {
- if unicode.IsSpace(r) {
- isSpace = true
- }
- itemW = rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- itemW = uniseg.StringWidth(att.Display)
- }
-
- if isSpace {
- if !inSpaces {
- // End of a word
- if lineW > 0 && lineW+wordW > width {
- // If the word itself is too long to fit on a line, force-wrap it
- if wordW > width {
- wrappedLines := forceWrapWord(word, width)
- lines = append(lines, wrappedLines...)
- // Calculate width of the last wrapped line
- lastLine := wrappedLines[len(wrappedLines)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- } else {
- lines = append(lines, word)
- lineW = wordW
- }
- } else {
- // Check if the word needs to be force-wrapped even when it fits on the current line
- if wordW > width {
- currentLine := lines[len(lines)-1]
- wrappedWord := forceWrapWord(word, width-lineW)
- if len(wrappedWord) > 0 {
- lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
- for i := 1; i < len(wrappedWord); i++ {
- lines = append(lines, wrappedWord[i])
- }
- // Calculate width of the last wrapped line
- lastLine := wrappedWord[len(wrappedWord)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- }
- } else {
- lines[len(lines)-1] = append(lines[len(lines)-1], word...)
- lineW += wordW
- }
- }
- word = nil
- wordW = 0
- }
- inSpaces = true
- spaceW += itemW
- } else { // It's not a space, it's a character for a word.
- if inSpaces {
- // We just finished a block of spaces. Handle them now.
- lineW += spaceW
- for i := 0; i < spaceW; i++ {
- lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
- }
- if lineW > width {
- // The spaces made the line overflow. Start a new line for the upcoming word.
- lines = append(lines, []any{})
- lineW = 0
- }
- spaceW = 0
- }
- inSpaces = false
- word = append(word, item)
- wordW += itemW
- }
- }
-
- // Handle any remaining word/spaces at the end of the content.
- if wordW > 0 {
- if lineW > 0 && lineW+wordW > width {
- // If the word itself is too long to fit on a line, force-wrap it
- if wordW > width {
- wrappedLines := forceWrapWord(word, width)
- lines = append(lines, wrappedLines...)
- // Calculate width of the last wrapped line
- lastLine := wrappedLines[len(wrappedLines)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- } else {
- lines = append(lines, word)
- lineW = wordW
- }
- } else {
- // Check if the word needs to be force-wrapped even when it fits on the current line
- if wordW > width {
- currentLine := lines[len(lines)-1]
- wrappedWord := forceWrapWord(word, width-lineW)
- if len(wrappedWord) > 0 {
- lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
- for i := 1; i < len(wrappedWord); i++ {
- lines = append(lines, wrappedWord[i])
- }
- // Calculate width of the last wrapped line
- lastLine := wrappedWord[len(wrappedWord)-1]
- lineW = 0
- for _, item := range lastLine {
- if r, ok := item.(rune); ok {
- lineW += rw.RuneWidth(r)
- } else if att, ok := item.(*attachment.Attachment); ok {
- lineW += uniseg.StringWidth(att.Display)
- }
- }
- }
- } else {
- lines[len(lines)-1] = append(lines[len(lines)-1], word...)
- lineW += wordW
- }
- }
- }
- if spaceW > 0 {
- // There are trailing spaces. Add them.
- for i := 0; i < spaceW; i++ {
- lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
- lineW += 1
- }
- if lineW > width {
- lines = append(lines, []any{})
- }
- }
-
- return lines
-}
-
-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
- }
- return min(high, max(low, v))
-}
-
-func abs(n int) int {
- if n < 0 {
- return -n
- }
- return n
-}
diff --git a/packages/tui/internal/components/textarea/textarea_test.go b/packages/tui/internal/components/textarea/textarea_test.go
deleted file mode 100644
index fb3c5b8ba..000000000
--- a/packages/tui/internal/components/textarea/textarea_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package textarea
-
-import (
- "testing"
-
- "github.com/sst/opencode/internal/attachment"
-)
-
-func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorAfterAttachment(t *testing.T) {
- m := New()
- m.InsertString("a ")
- att := &attachment.Attachment{ID: "1", Display: "@file.txt"}
- m.InsertAttachment(att)
- m.InsertString(" b")
-
- // Position cursor immediately after the attachment (index 3: 'a',' ',att,' ', 'b')
- m.SetCursorColumn(3)
-
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur")
- }
- got := m.Value()
- want := "a @file.txt b"
- if got != want {
- t.Fatalf("expected %q, got %q", want, got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorOnAttachment(t *testing.T) {
- m := New()
- m.InsertString("x ")
- att := &attachment.Attachment{ID: "2", Display: "@img.png"}
- m.InsertAttachment(att)
- m.InsertString(" y")
-
- // Position cursor on the attachment token (index 2: 'x',' ',att,' ', 'y')
- m.SetCursorColumn(2)
-
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur")
- }
- got := m.Value()
- want := "x @img.png y"
- if got != want {
- t.Fatalf("expected %q, got %q", want, got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_StartOfLine(t *testing.T) {
- m := New()
- att := &attachment.Attachment{ID: "3", Display: "@a.txt"}
- m.InsertAttachment(att)
- m.InsertString(" tail")
-
- // Position cursor immediately after the attachment at start of line (index 1)
- m.SetCursorColumn(1)
- if ok := m.removeAttachmentAtCursor(); !ok {
- t.Fatalf("expected removal to occur at start of line")
- }
- if got := m.Value(); got != "@a.txt tail" {
- t.Fatalf("unexpected value: %q", got)
- }
-}
-
-func TestRemoveAttachmentAtCursor_NoAttachment_NoChange(t *testing.T) {
- m := New()
- m.InsertString("hello world")
- col := m.CursorColumn()
- if ok := m.removeAttachmentAtCursor(); ok {
- t.Fatalf("did not expect removal to occur")
- }
- if m.Value() != "hello world" || m.CursorColumn() != col {
- t.Fatalf("value or cursor unexpectedly changed")
- }
-}