1017 lines
27 KiB
Go
1017 lines
27 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
|
|
}
|
|
|
|
// 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
|
|
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,
|
|
)
|
|
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 int,
|
|
) string {
|
|
const (
|
|
reverseStart = "\x1b[7m"
|
|
reverseEnd = "\x1b[27m"
|
|
underlineStart = "\x1b[4m"
|
|
underlineEnd = "\x1b[24m"
|
|
)
|
|
runes := []rune(line.text)
|
|
var rendered strings.Builder
|
|
selected := false
|
|
markdownStyle := editorMarkdownPlain
|
|
for offset, value := range runes {
|
|
position := line.start + offset
|
|
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 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()
|
|
}
|