Files
diple/text_editor.go

1055 lines
28 KiB
Go

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.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) 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()
}