| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148 |
- 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"
- )
- 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
- )
- // Attachment represents a special object within the text, distinct from regular characters.
- type Attachment struct {
- ID string // A unique identifier for this attachment instance
- Display string // e.g., "@filename.txt"
- URL string
- Filename string
- MediaType string
- }
- // 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:
- 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:
- 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, 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); 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); 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:
- // 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:
- 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) {
- 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)
- }
- // 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.
- func (m Model) GetAttachments() []*Attachment {
- var attachments []*Attachment
- for _, row := range m.value {
- for _, item := range row {
- if att, ok := item.(*Attachment); ok {
- attachments = append(attachments, att)
- }
- }
- }
- 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:
- 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:
- 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:
- 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:
- 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:
- 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:
- 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:
- 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]))
- }
- // 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):
- 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 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); 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:
- return uniseg.StringWidth(v.Display)
- }
- return 0
- }
- 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); ok {
- itemW = uniseg.StringWidth(att.Display)
- }
- if isSpace {
- if !inSpaces {
- // End of a word
- if lineW > 0 && lineW+wordW > width {
- lines = append(lines, word)
- lineW = wordW
- } 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 {
- lines = append(lines, word)
- lineW = wordW
- } 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
- }
|