package main import ( "strings" "unicode" "unicode/utf8" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" "github.com/rivo/uniseg" ) type textEditorMode string const ( textEditorNormal textEditorMode = "NORMAL" textEditorInsert textEditorMode = "INSERT" textEditorVisual textEditorMode = "VISUAL" ) type textFind struct { command rune target rune valid bool } type editorProtectedStyle struct { start, end int color string } // textEditor owns buffer and motion state independently of any particular // screen. Inputs can opt into modal behavior without duplicating cursor logic. type textEditor struct { Text string Cursor int Modal bool Mode textEditorMode pendingFind rune pendingG bool lastFind textFind visualAnchor int visualLine bool clipboard textClipboard err error hardwareCursor bool highlightMarkdown bool protectedPrefix int protectedStyles []editorProtectedStyle keys KeyBindings } func newTextEditor(text string, modal bool) textEditor { editor := textEditor{ Text: text, Modal: modal, Mode: textEditorInsert, clipboard: systemTextClipboard{}, keys: defaultKeyBindings(), } editor.Cursor = len([]rune(text)) if modal { editor.Mode = textEditorNormal editor.Cursor = 0 } return editor } func (e *textEditor) handleKey(key tea.KeyMsg, multiline bool) bool { return e.handleKeyAtWidth(key, multiline, 0) } func (e *textEditor) handleKeyAtWidth(key tea.KeyMsg, multiline bool, wrapWidth int) bool { if !e.Modal { return e.handleStandardKey(key, multiline, wrapWidth) } if e.Mode == textEditorInsert { if keyMatches(key.String(), e.keys.Input.Cancel) { e.Mode = textEditorNormal e.clearPending() start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) if e.Cursor > start { e.Cursor = max(start, previousGraphemeBoundary(e.Text, e.Cursor)) } return true } return e.handleStandardKey(key, multiline, wrapWidth) } if e.Mode == textEditorVisual { return e.handleVisualKey(key, multiline, wrapWidth) } return e.handleNormalKey(key, multiline, wrapWidth) } func (e *textEditor) movePage(delta, wrapWidth int) { normalCursor := e.Mode != textEditorInsert e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, delta, wrapWidth, normalCursor) e.clearPending() } func (e *textEditor) handleStandardKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool { k := key.String() switch { case key.Type != tea.KeyRunes && key.Type != tea.KeySpace && keyMatches(k, e.keys.Navigation.Left): e.Cursor = previousGraphemeBoundary(e.Text, e.Cursor) case key.Type != tea.KeyRunes && key.Type != tea.KeySpace && keyMatches(k, e.keys.Navigation.Right): e.Cursor = nextGraphemeBoundary(e.Text, e.Cursor) case key.Type != tea.KeyRunes && key.Type != tea.KeySpace && keyMatches(k, e.keys.Navigation.Up): if !multiline { return false } e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, false) case key.Type != tea.KeyRunes && key.Type != tea.KeySpace && keyMatches(k, e.keys.Navigation.Down): if !multiline { return false } e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, false) case keyMatches(k, e.keys.Input.LineStart): e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth) case keyMatches(k, e.keys.Input.LineEnd): _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) case keyMatches(k, e.keys.Input.DeleteBackward): e.deleteBefore() case keyMatches(k, e.keys.Input.DeleteForward): e.deleteAt() case keyMatches(k, e.keys.Input.Newline): if !multiline { return false } e.insert("\n") default: if key.Type == tea.KeyRunes { e.insert(string(key.Runes)) } else if key.Type == tea.KeySpace { e.insert(" ") } else { return false } } e.Cursor = previousOrCurrentGraphemeBoundary(e.Text, e.Cursor) return true } func (e *textEditor) handleNormalKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool { if e.pendingFind != 0 { if key.Type == tea.KeyRunes && len(key.Runes) > 0 { command := e.pendingFind e.pendingFind = 0 e.performFind(command, key.Runes[0], true, wrapWidth) return true } if key.Type == tea.KeySpace { command := e.pendingFind e.pendingFind = 0 e.performFind(command, ' ', true, wrapWidth) return true } e.pendingFind = 0 } if e.pendingG { e.pendingG = false if keyMatches(key.String(), e.keys.Vim.GoPrefix) { e.Cursor = firstNonBlank(e.Text, 0) return true } } k := key.String() switch { case keyMatches(k, e.keys.Input.Cancel): e.clearPending() return false case keyMatches(k, e.keys.Vim.Insert): e.Mode = textEditorInsert case keyMatches(k, e.keys.Vim.Append): _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Cursor = min(end, nextGraphemeBoundary(e.Text, e.Cursor)) e.Mode = textEditorInsert case keyMatches(k, e.keys.Vim.InsertLineStart): e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth) e.Mode = textEditorInsert case keyMatches(k, e.keys.Vim.AppendLineEnd): _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Mode = textEditorInsert case keyMatches(k, e.keys.Vim.OpenBelow): if !multiline { return false } _, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth) e.insert("\n") e.Mode = textEditorInsert case keyMatches(k, e.keys.Vim.OpenAbove): if !multiline { return false } start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Cursor = start e.insert("\n") e.Cursor = start e.Mode = textEditorInsert case keyMatches(k, e.keys.Navigation.Left): start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) e.Cursor = max(start, previousGraphemeBoundary(e.Text, e.Cursor)) case keyMatches(k, e.keys.Navigation.Right): e.Cursor = min( normalEditorLineLast(e.Text, e.Cursor, wrapWidth), nextGraphemeBoundary(e.Text, e.Cursor), ) case keyMatches(k, e.keys.Navigation.Down): if multiline { e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, true) } case keyMatches(k, e.keys.Navigation.Up): if multiline { e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, true) } case keyMatches(k, e.keys.Vim.LineStart): e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth) case keyMatches(k, e.keys.Vim.FirstNonBlank): e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth) case keyMatches(k, e.keys.Vim.LineEnd): e.Cursor = normalEditorLineLast(e.Text, e.Cursor, wrapWidth) case keyMatches(k, e.keys.Vim.WordForward): e.Cursor = nextWordStart(e.Text, e.Cursor, false) case keyMatches(k, e.keys.Vim.WORDForward): e.Cursor = nextWordStart(e.Text, e.Cursor, true) case keyMatches(k, e.keys.Vim.WordBackward): e.Cursor = previousWordStart(e.Text, e.Cursor, false) case keyMatches(k, e.keys.Vim.WORDBackward): e.Cursor = previousWordStart(e.Text, e.Cursor, true) case keyMatches(k, e.keys.Vim.WordEnd): e.Cursor = wordEndAtWidth(e.Text, e.Cursor, false, wrapWidth) case keyMatches(k, e.keys.Vim.WORDEnd): e.Cursor = wordEndAtWidth(e.Text, e.Cursor, true, wrapWidth) case keyMatches(k, e.keys.Vim.GoPrefix): e.pendingG = true case keyMatches(k, e.keys.Navigation.Last): e.Cursor = firstNonBlank(e.Text, len([]rune(e.Text))) case keyMatches(k, e.keys.Vim.Visual): e.startVisual(false) case keyMatches(k, e.keys.Vim.VisualLine): e.startVisual(true) case keyMatches(k, e.keys.Vim.Paste): e.pasteClipboard(false, wrapWidth) case keyMatches(k, e.keys.Vim.ReplaceCharacter): _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) if e.Cursor < end { e.deleteAt() } e.Mode = textEditorInsert case keyMatches(k, e.keys.Vim.FindForward): e.pendingFind = 'f' case keyMatches(k, e.keys.Vim.FindBackward): e.pendingFind = 'F' case keyMatches(k, e.keys.Vim.TillForward): e.pendingFind = 't' case keyMatches(k, e.keys.Vim.TillBackward): e.pendingFind = 'T' case keyMatches(k, e.keys.Vim.RepeatFind): if e.lastFind.valid { e.performFind(e.lastFind.command, e.lastFind.target, false, wrapWidth) } case keyMatches(k, e.keys.Vim.RepeatFindReverse): if e.lastFind.valid { e.performFind(reverseFind(e.lastFind.command), e.lastFind.target, false, wrapWidth) } case keyMatches(k, e.keys.Vim.Delete): _, end := editorLineBounds(e.Text, e.Cursor, wrapWidth) if e.Cursor < end { e.deleteAt() } case keyMatches(k, e.keys.Vim.DeleteBefore): start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth) if e.Cursor > start { e.deleteBefore() } default: return false } e.Cursor = previousOrCurrentGraphemeBoundary(e.Text, e.Cursor) return true } func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth int) bool { if e.pendingFind != 0 || e.pendingG { e.Mode = textEditorNormal handled := e.handleNormalKey(key, multiline, wrapWidth) e.Mode = textEditorVisual return handled } k := key.String() switch { case keyMatches(k, e.keys.Input.Cancel), keyMatches(k, e.keys.Vim.Visual): e.stopVisual() case keyMatches(k, e.keys.Vim.VisualLine): if e.visualLine { e.stopVisual() } else { e.visualLine = true } case keyMatches(k, e.keys.Vim.SelectionOtherEnd): e.Cursor, e.visualAnchor = e.visualAnchor, e.Cursor case keyMatches(k, e.keys.Vim.Yank): e.yankSelection(wrapWidth) case keyMatches(k, e.keys.Vim.Delete): e.deleteSelection(wrapWidth) case keyMatches(k, e.keys.Vim.ReplaceCharacter): e.substituteSelection(wrapWidth) case keyMatches(k, e.keys.Vim.Paste): e.pasteClipboard(true, wrapWidth) default: if !e.isVisualMotion(k) { return false } e.Mode = textEditorNormal handled := e.handleNormalKey(key, multiline, wrapWidth) e.Mode = textEditorVisual return handled } return true } func (e textEditor) isVisualMotion(key string) bool { groups := [][]string{ e.keys.Navigation.Left, e.keys.Navigation.Down, e.keys.Navigation.Up, e.keys.Navigation.Right, e.keys.Navigation.Last, e.keys.Vim.LineStart, e.keys.Vim.FirstNonBlank, e.keys.Vim.LineEnd, e.keys.Vim.WordForward, e.keys.Vim.WORDForward, e.keys.Vim.WordBackward, e.keys.Vim.WORDBackward, e.keys.Vim.WordEnd, e.keys.Vim.WORDEnd, e.keys.Vim.GoPrefix, e.keys.Vim.FindForward, e.keys.Vim.FindBackward, e.keys.Vim.TillForward, e.keys.Vim.TillBackward, e.keys.Vim.RepeatFind, e.keys.Vim.RepeatFindReverse, } for _, group := range groups { if keyMatches(key, group) { return true } } return false } func (e *textEditor) startVisual(linewise bool) { e.Mode = textEditorVisual e.visualAnchor = e.Cursor e.visualLine = linewise e.clearPending() } func (e *textEditor) stopVisual() { e.Mode = textEditorNormal e.visualLine = false e.clearPending() } func (e textEditor) selectionBounds(wrapWidth int) (int, int, bool) { if e.Mode != textEditorVisual { return 0, 0, false } runes := []rune(e.Text) anchor := clamp(e.visualAnchor, 0, len(runes)) cursor := clamp(e.Cursor, 0, len(runes)) if !e.visualLine { start := previousOrCurrentGraphemeBoundary(e.Text, min(anchor, cursor)) end := nextGraphemeBoundary(e.Text, max(anchor, cursor)) end = min(len(runes), end) return start, end, end > start } anchorStart, anchorEnd := editorLineBounds(e.Text, anchor, wrapWidth) cursorStart, cursorEnd := editorLineBounds(e.Text, cursor, wrapWidth) start, end := min(anchorStart, cursorStart), max(anchorEnd, cursorEnd) if end < len(runes) && runes[end] == '\n' { end++ } return start, end, end > start } func (e *textEditor) yankSelection(wrapWidth int) { start, end, ok := e.selectionBounds(wrapWidth) if !ok { e.stopVisual() return } if e.clipboard == nil { e.clipboard = systemTextClipboard{} } if err := e.clipboard.WriteText(string([]rune(e.Text)[start:end])); err != nil { e.err = err return } e.Cursor = start e.stopVisual() } func (e *textEditor) deleteSelection(wrapWidth int) { start, end, ok := e.selectionBounds(wrapWidth) if !ok { e.stopVisual() return } runes := []rune(e.Text) e.Text = string(append(runes[:start], runes[end:]...)) e.Cursor = min(start, len([]rune(e.Text))) if e.Cursor == len([]rune(e.Text)) && e.Cursor > 0 { e.Cursor = previousGraphemeBoundary(e.Text, e.Cursor) } e.stopVisual() } func (e *textEditor) substituteSelection(wrapWidth int) { start, end, ok := e.selectionBounds(wrapWidth) if !ok { e.stopVisual() return } runes := []rune(e.Text) e.Text = string(append(runes[:start], runes[end:]...)) e.Cursor = start e.Mode = textEditorInsert e.visualLine = false e.clearPending() } func (e *textEditor) pasteClipboard(replaceSelection bool, wrapWidth int) { if e.clipboard == nil { e.clipboard = systemTextClipboard{} } value, err := e.clipboard.ReadText() if err != nil { e.err = err return } value = normalizeLineEndings(value) if replaceSelection { start, end, ok := e.selectionBounds(wrapWidth) if ok { runes := []rune(e.Text) e.Text = string(append(runes[:start], runes[end:]...)) e.Cursor = start } e.stopVisual() } else { _, end := editorLineBounds(e.Text, e.Cursor, 0) e.Cursor = min(end, nextGraphemeBoundary(e.Text, e.Cursor)) } e.insert(value) if e.Cursor > 0 { e.Cursor = previousGraphemeBoundary(e.Text, e.Cursor) } } func (e *textEditor) clearPending() { e.pendingFind = 0 e.pendingG = false } func (e *textEditor) insert(text string) { value := []rune(e.Text) insert := []rune(text) cursor := clamp(e.Cursor, 0, len(value)) updated := make([]rune, 0, len(value)+len(insert)) updated = append(updated, value[:cursor]...) updated = append(updated, insert...) updated = append(updated, value[cursor:]...) e.Text = string(updated) e.Cursor = cursor + len(insert) } func (e *textEditor) deleteBefore() { value := []rune(e.Text) e.Cursor = clamp(e.Cursor, 0, len(value)) if e.Cursor == 0 { return } start := previousGraphemeBoundary(e.Text, e.Cursor) value = append(value[:start], value[e.Cursor:]...) e.Cursor = start e.Text = string(value) } func (e *textEditor) deleteAt() { value := []rune(e.Text) e.Cursor = clamp(e.Cursor, 0, len(value)) if e.Cursor == len(value) { return } end := nextGraphemeBoundary(e.Text, e.Cursor) value = append(value[:e.Cursor], value[end:]...) e.Text = string(value) } func graphemeBoundaries(value string) []int { boundaries := []int{0} graphemes := uniseg.NewGraphemes(value) offset := 0 for graphemes.Next() { offset += utf8.RuneCountInString(graphemes.Str()) boundaries = append(boundaries, offset) } return boundaries } func previousGraphemeBoundary(value string, cursor int) int { cursor = clamp(cursor, 0, len([]rune(value))) previous := 0 for _, boundary := range graphemeBoundaries(value) { if boundary >= cursor { return previous } previous = boundary } return previous } func previousOrCurrentGraphemeBoundary(value string, cursor int) int { cursor = clamp(cursor, 0, len([]rune(value))) previous := 0 for _, boundary := range graphemeBoundaries(value) { if boundary > cursor { return previous } previous = boundary } return previous } func nextGraphemeBoundary(value string, cursor int) int { cursor = clamp(cursor, 0, len([]rune(value))) for _, boundary := range graphemeBoundaries(value) { if boundary > cursor { return boundary } } return len([]rune(value)) } func (e *textEditor) performFind(command, target rune, remember bool, wrapWidth int) { runes := []rune(e.Text) cursor := clamp(e.Cursor, 0, len(runes)) start, end := editorLineBounds(e.Text, cursor, wrapWidth) found := -1 switch command { case 'f', 't': searchStart := cursor + 1 if !remember && command == 't' { searchStart++ } for index := min(searchStart, end); index < end; index++ { if runes[index] == target { found = index break } } case 'F', 'T': searchStart := cursor - 1 if !remember && command == 'T' { searchStart-- } for index := min(searchStart, end-1); index >= start; index-- { if runes[index] == target { found = index break } } } if found >= 0 { switch command { case 't': found = max(cursor, found-1) case 'T': found = min(cursor, found+1) } e.Cursor = found } if remember { e.lastFind = textFind{command: command, target: target, valid: true} } } func reverseFind(command rune) rune { switch command { case 'f': return 'F' case 'F': return 'f' case 't': return 'T' default: return 't' } } func firstNonBlank(value string, cursor int) int { return firstNonBlankAtWidth(value, cursor, 0) } func firstNonBlankAtWidth(value string, cursor, wrapWidth int) int { runes := []rune(value) start, end := editorLineBounds(value, cursor, wrapWidth) for start < end && unicode.IsSpace(runes[start]) { start++ } return start } func normalLineLast(value string, cursor int) int { return normalEditorLineLast(value, cursor, 0) } func normalEditorLineLast(value string, cursor, wrapWidth int) int { start, end := editorLineBounds(value, cursor, wrapWidth) if end > start { return end - 1 } return start } func moveNormalCursorLine(value string, cursor, delta int) int { return moveEditorCursorLine(value, cursor, delta, 0, true) } func nextWordStart(value string, cursor int, big bool) int { runes := []rune(value) cursor = clamp(cursor, 0, len(runes)) if cursor == len(runes) { return cursor } if big { for cursor < len(runes) && !unicode.IsSpace(runes[cursor]) { cursor++ } } else { category := wordCategory(runes[cursor]) for cursor < len(runes) && wordCategory(runes[cursor]) == category { cursor++ } } for cursor < len(runes) && unicode.IsSpace(runes[cursor]) { cursor++ } return cursor } func previousWordStart(value string, cursor int, big bool) int { runes := []rune(value) cursor = clamp(cursor, 0, len(runes)) if cursor == 0 { return 0 } cursor-- for cursor > 0 && unicode.IsSpace(runes[cursor]) { cursor-- } if big { for cursor > 0 && !unicode.IsSpace(runes[cursor-1]) { cursor-- } return cursor } category := wordCategory(runes[cursor]) for cursor > 0 && wordCategory(runes[cursor-1]) == category { cursor-- } return cursor } func wordEnd(value string, cursor int, big bool) int { return wordEndAtWidth(value, cursor, big, 0) } func wordEndAtWidth(value string, cursor int, big bool, wrapWidth int) int { runes := []rune(value) cursor = clamp(cursor, 0, len(runes)) if cursor == len(runes) { return cursor } original := cursor for { _, end := editorLineBounds(value, cursor, wrapWidth) if candidate, found := wordEndWithin(runes, cursor, end, big); found { return candidate } next := end if next < len(runes) && runes[next] == '\n' { next++ } if next >= len(runes) || next <= cursor { return original } cursor = next } } func wordEndWithin(runes []rune, cursor, end int, big bool) (int, bool) { cursor = clamp(cursor, 0, min(end, len(runes))) if cursor >= end { return cursor, false } if unicode.IsSpace(runes[cursor]) { for cursor < end && unicode.IsSpace(runes[cursor]) { cursor++ } if cursor >= end { return cursor, false } } else if cursor+1 >= end || wordEndCategory(runes[cursor+1], big) != wordEndCategory(runes[cursor], big) { cursor++ for cursor < end && unicode.IsSpace(runes[cursor]) { cursor++ } if cursor >= end { return cursor, false } } category := wordEndCategory(runes[cursor], big) for cursor+1 < end && wordEndCategory(runes[cursor+1], big) == category { cursor++ } return cursor, true } func wordEndCategory(value rune, big bool) int { if big { if unicode.IsSpace(value) { return 0 } return 1 } return wordCategory(value) } func wordCategory(value rune) int { if unicode.IsSpace(value) { return 0 } if value == '_' || unicode.IsLetter(value) || unicode.IsNumber(value) { return 1 } return 2 } func (e textEditor) modeLabel() string { if !e.Modal { return "" } return string(e.Mode) } func normalizeSingleLine(value string) string { return strings.NewReplacer("\r", "", "\n", "").Replace(value) } func normalizeLineEndings(value string) string { value = strings.ReplaceAll(value, "\r\n", "\n") return strings.ReplaceAll(value, "\r", "\n") } type editorVisualLine struct { text string start, end int logicalStart, logicalEnd int } type editorRenderedLine struct { text string active bool } func editorVisualLines(value string, width int) []editorVisualLine { runes := []rune(value) var visual []editorVisualLine logicalStart := 0 for index := 0; index <= len(runes); index++ { if index < len(runes) && runes[index] != '\n' { continue } visual = append(visual, wrapEditorLogicalLine(runes, logicalStart, index, max(1, width))...) logicalStart = index + 1 } if len(visual) == 0 { return []editorVisualLine{{}} } return visual } func editorVisualLineIndex(lines []editorVisualLine, cursor int) int { for index, line := range lines { if cursor >= line.start && cursor < line.end { return index } if line.start == line.end && cursor == line.start { return index } if cursor == line.logicalEnd && line.end == line.logicalEnd { return index } } return max(0, len(lines)-1) } func editorLineBounds(value string, cursor, wrapWidth int) (int, int) { if wrapWidth <= 0 { return textLineStart(value, cursor), textLineEnd(value, cursor) } lines := editorVisualLines(value, wrapWidth) line := lines[editorVisualLineIndex(lines, clamp(cursor, 0, len([]rune(value))))] return line.start, line.end } func moveEditorCursorLine(value string, cursor, delta, wrapWidth int, normal bool) int { if wrapWidth <= 0 { moved := moveTextCursorLine(value, cursor, delta) if normal { return min(moved, normalLineLast(value, moved)) } return moved } runes := []rune(value) lines := editorVisualLines(value, wrapWidth) index := editorVisualLineIndex(lines, clamp(cursor, 0, len(runes))) targetIndex := clamp(index+delta, 0, len(lines)-1) if targetIndex == index { return cursor } column := lipgloss.Width(string(runes[lines[index].start:clamp(cursor, lines[index].start, lines[index].end)])) target := lines[targetIndex] position, usedWidth := target.start, 0 for position < target.end { runeWidth := lipgloss.Width(string(runes[position])) if usedWidth+runeWidth > column { break } usedWidth += runeWidth position++ } if normal && target.end > target.start { position = min(position, target.end-1) } return position } func renderTextEditor(editor textEditor, width int, active bool) []editorRenderedLine { width = max(1, width) runes := []rune(editor.Text) cursor := clamp(editor.Cursor, 0, len(runes)) visual := editorVisualLines(editor.Text, width) activeIndex := editorVisualLineIndex(visual, cursor) selectionStart, selectionEnd, hasSelection := editor.selectionBounds(width) var markdownStyles []editorMarkdownStyle if editor.highlightMarkdown { markdownStyles = editorMarkdownStyles(editor.Text) } lines := make([]editorRenderedLine, 0, len(visual)) for index, line := range visual { onVisualLine := index == activeIndex rendered := renderEditorVisualLine( line, cursor, editor.Mode, selectionStart, selectionEnd, active && hasSelection, active && onVisualLine, editor.hardwareCursor, markdownStyles, width, editor.protectedPrefix, editor.protectedStyles, ) if active && onVisualLine { rendered = pad(rendered, width) } lines = append(lines, editorRenderedLine{ text: rendered, active: active && onVisualLine, }) } return lines } func editorCursorVisualLine(editor textEditor, width int) int { width = max(1, width) visual := editorVisualLines(editor.Text, width) return editorVisualLineIndex(visual, clamp(editor.Cursor, 0, len([]rune(editor.Text)))) } func editorCursorVisualPosition(editor textEditor, width int) (int, int) { width = max(1, width) runes := []rune(editor.Text) cursor := clamp(editor.Cursor, 0, len(runes)) visual := editorVisualLines(editor.Text, width) index := editorVisualLineIndex(visual, cursor) line := visual[index] column := lipgloss.Width(string(runes[line.start:clamp(cursor, line.start, line.end)])) return index, column } func wrapEditorLogicalLine(runes []rune, start, end, width int) []editorVisualLine { if start == end { return []editorVisualLine{{ start: start, end: end, logicalStart: start, logicalEnd: end, }} } var lines []editorVisualLine for offset := start; offset < end; { next := offset lineWidth := 0 for next < end { runeWidth := lipgloss.Width(string(runes[next])) if next > offset && lineWidth+runeWidth > width { break } lineWidth += runeWidth next++ if lineWidth >= width { break } } if next == offset { next++ } lines = append(lines, editorVisualLine{ text: string(runes[offset:next]), start: offset, end: next, logicalStart: start, logicalEnd: end, }) offset = next } return lines } func renderEditorVisualLine( line editorVisualLine, cursor int, mode textEditorMode, selectionStart, selectionEnd int, hasSelection, showCursor, hardwareCursor bool, markdownStyles []editorMarkdownStyle, width, protectedPrefix int, protectedStyles []editorProtectedStyle, ) string { const ( reverseStart = "\x1b[7m" reverseEnd = "\x1b[27m" underlineStart = "\x1b[4m" underlineEnd = "\x1b[24m" ) runes := []rune(line.text) var rendered strings.Builder selected := false protectedColor := "" markdownStyle := editorMarkdownPlain for offset, value := range runes { position := line.start + offset nextProtectedColor := "" if position < protectedPrefix { nextProtectedColor = editorMarkdownTheme.Dim } for _, style := range protectedStyles { if position >= style.start && position < style.end { nextProtectedColor = style.color break } } if nextProtectedColor != protectedColor { if colorEnabled && nextProtectedColor != "" { rendered.WriteString(foregroundSequence(nextProtectedColor)) } else if colorEnabled && protectedColor != "" { if showCursor { rendered.WriteString(foregroundSequence(editorMarkdownTheme.EditorForeground)) } else { rendered.WriteString("\x1b[39m") } } protectedColor = nextProtectedColor } nextMarkdownStyle := editorMarkdownPlain if position < len(markdownStyles) { nextMarkdownStyle = markdownStyles[position] } if nextMarkdownStyle != markdownStyle { if markdownStyle != editorMarkdownPlain { rendered.WriteString(editorMarkdownStyleEnd(showCursor)) } if nextMarkdownStyle != editorMarkdownPlain { rendered.WriteString(editorMarkdownStyleStart(nextMarkdownStyle)) } markdownStyle = nextMarkdownStyle } nowSelected := hasSelection && position >= selectionStart && position < selectionEnd if nowSelected != selected { if nowSelected { rendered.WriteString(reverseStart) } else { rendered.WriteString(reverseEnd) } selected = nowSelected } if showCursor && position == cursor { switch mode { case textEditorInsert: if hardwareCursor { rendered.WriteRune(value) continue } rendered.WriteString(underlineStart) rendered.WriteRune(value) rendered.WriteString(underlineEnd) continue case textEditorVisual: rendered.WriteString(underlineStart) rendered.WriteRune(value) rendered.WriteString(underlineEnd) continue default: if !selected { rendered.WriteString(reverseStart) } rendered.WriteRune(value) if !selected { rendered.WriteString(reverseEnd) } continue } } rendered.WriteRune(value) } if selected { rendered.WriteString(reverseEnd) } if markdownStyle != editorMarkdownPlain { rendered.WriteString(editorMarkdownStyleEnd(showCursor)) } if protectedColor != "" && colorEnabled { if showCursor { rendered.WriteString(foregroundSequence(editorMarkdownTheme.EditorForeground)) } else { rendered.WriteString("\x1b[39m") } } if showCursor && cursor == line.end { switch mode { case textEditorInsert: if hardwareCursor { break } if lipgloss.Width(line.text) < width { rendered.WriteString(underlineStart + " " + underlineEnd) } else if len(runes) > 0 { value := rendered.String() rendered.Reset() rendered.WriteString(ansi.Truncate(value, max(0, width-1), "")) rendered.WriteString(underlineStart) rendered.WriteRune(runes[len(runes)-1]) rendered.WriteString(underlineEnd) } case textEditorVisual: rendered.WriteString(underlineStart + " " + underlineEnd) default: if lipgloss.Width(line.text) < width { rendered.WriteString(reverseStart + " " + reverseEnd) } else if len(runes) > 0 { // A full visual row has no spare cell. Re-render its last // character as the block cursor without adding layout width. value := rendered.String() rendered.Reset() rendered.WriteString(ansi.Truncate(value, max(0, width-1), "")) rendered.WriteString(reverseStart) rendered.WriteRune(runes[len(runes)-1]) rendered.WriteString(reverseEnd) } } } return rendered.String() }