939 lines
23 KiB
Go
939 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"unicode"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
func newTextEditor(text string, modal bool) textEditor {
|
|
editor := textEditor{
|
|
Text: text, Modal: modal, Mode: textEditorInsert,
|
|
clipboard: systemTextClipboard{},
|
|
}
|
|
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 key.String() == "esc" {
|
|
e.Mode = textEditorNormal
|
|
e.clearPending()
|
|
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
if e.Cursor > start {
|
|
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 {
|
|
switch key.String() {
|
|
case "left":
|
|
e.Cursor = max(0, e.Cursor-1)
|
|
case "right":
|
|
e.Cursor = min(len([]rune(e.Text)), e.Cursor+1)
|
|
case "up":
|
|
if !multiline {
|
|
return false
|
|
}
|
|
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, false)
|
|
case "down":
|
|
if !multiline {
|
|
return false
|
|
}
|
|
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, false)
|
|
case "home", "ctrl+a":
|
|
e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
case "end", "ctrl+e":
|
|
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
case "backspace":
|
|
e.deleteBefore()
|
|
case "delete":
|
|
e.deleteAt()
|
|
case "enter":
|
|
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
|
|
}
|
|
}
|
|
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 key.String() == "g" {
|
|
e.Cursor = firstNonBlank(e.Text, 0)
|
|
return true
|
|
}
|
|
}
|
|
|
|
switch key.String() {
|
|
case "esc":
|
|
e.clearPending()
|
|
return false
|
|
case "i":
|
|
e.Mode = textEditorInsert
|
|
case "a":
|
|
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
e.Cursor = min(end, e.Cursor+1)
|
|
e.Mode = textEditorInsert
|
|
case "I":
|
|
e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth)
|
|
e.Mode = textEditorInsert
|
|
case "A":
|
|
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
e.Mode = textEditorInsert
|
|
case "o":
|
|
if !multiline {
|
|
return false
|
|
}
|
|
_, e.Cursor = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
e.insert("\n")
|
|
e.Mode = textEditorInsert
|
|
case "O":
|
|
if !multiline {
|
|
return false
|
|
}
|
|
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
e.Cursor = start
|
|
e.insert("\n")
|
|
e.Cursor = start
|
|
e.Mode = textEditorInsert
|
|
case "h", "left":
|
|
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
e.Cursor = max(start, e.Cursor-1)
|
|
case "l", "right":
|
|
e.Cursor = min(normalEditorLineLast(e.Text, e.Cursor, wrapWidth), e.Cursor+1)
|
|
case "j", "down":
|
|
if multiline {
|
|
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, 1, wrapWidth, true)
|
|
}
|
|
case "k", "up":
|
|
if multiline {
|
|
e.Cursor = moveEditorCursorLine(e.Text, e.Cursor, -1, wrapWidth, true)
|
|
}
|
|
case "0", "home":
|
|
e.Cursor, _ = editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
case "^":
|
|
e.Cursor = firstNonBlankAtWidth(e.Text, e.Cursor, wrapWidth)
|
|
case "$", "end":
|
|
e.Cursor = normalEditorLineLast(e.Text, e.Cursor, wrapWidth)
|
|
case "w":
|
|
e.Cursor = nextWordStart(e.Text, e.Cursor, false)
|
|
case "W":
|
|
e.Cursor = nextWordStart(e.Text, e.Cursor, true)
|
|
case "b":
|
|
e.Cursor = previousWordStart(e.Text, e.Cursor, false)
|
|
case "B":
|
|
e.Cursor = previousWordStart(e.Text, e.Cursor, true)
|
|
case "e":
|
|
e.Cursor = wordEndAtWidth(e.Text, e.Cursor, false, wrapWidth)
|
|
case "E":
|
|
e.Cursor = wordEndAtWidth(e.Text, e.Cursor, true, wrapWidth)
|
|
case "g":
|
|
e.pendingG = true
|
|
case "G":
|
|
e.Cursor = firstNonBlank(e.Text, len([]rune(e.Text)))
|
|
case "v":
|
|
e.startVisual(false)
|
|
case "V":
|
|
e.startVisual(true)
|
|
case "p":
|
|
e.pasteClipboard(false, wrapWidth)
|
|
case "s":
|
|
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
if e.Cursor < end {
|
|
e.deleteAt()
|
|
}
|
|
e.Mode = textEditorInsert
|
|
case "f", "F", "t", "T":
|
|
e.pendingFind = []rune(key.String())[0]
|
|
case ";":
|
|
if e.lastFind.valid {
|
|
e.performFind(e.lastFind.command, e.lastFind.target, false, wrapWidth)
|
|
}
|
|
case ",":
|
|
if e.lastFind.valid {
|
|
e.performFind(reverseFind(e.lastFind.command), e.lastFind.target, false, wrapWidth)
|
|
}
|
|
case "x", "delete":
|
|
_, end := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
if e.Cursor < end {
|
|
e.deleteAt()
|
|
}
|
|
case "X", "backspace":
|
|
start, _ := editorLineBounds(e.Text, e.Cursor, wrapWidth)
|
|
if e.Cursor > start {
|
|
e.deleteBefore()
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
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
|
|
}
|
|
switch key.String() {
|
|
case "esc", "v":
|
|
e.stopVisual()
|
|
case "V":
|
|
if e.visualLine {
|
|
e.stopVisual()
|
|
} else {
|
|
e.visualLine = true
|
|
}
|
|
case "o":
|
|
e.Cursor, e.visualAnchor = e.visualAnchor, e.Cursor
|
|
case "y":
|
|
e.yankSelection(wrapWidth)
|
|
case "d", "x", "delete":
|
|
e.deleteSelection(wrapWidth)
|
|
case "p":
|
|
e.pasteClipboard(true, wrapWidth)
|
|
default:
|
|
if !isVisualMotion(key.String()) {
|
|
return false
|
|
}
|
|
e.Mode = textEditorNormal
|
|
handled := e.handleNormalKey(key, multiline, wrapWidth)
|
|
e.Mode = textEditorVisual
|
|
return handled
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isVisualMotion(key string) bool {
|
|
switch key {
|
|
case "h", "j", "k", "l", "left", "down", "up", "right",
|
|
"0", "^", "$", "home", "end",
|
|
"w", "W", "b", "B", "e", "E", "g", "G",
|
|
"f", "F", "t", "T", ";", ",":
|
|
return true
|
|
default:
|
|
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, end := min(anchor, cursor), min(len(runes), max(anchor, cursor)+1)
|
|
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--
|
|
}
|
|
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, e.Cursor+1)
|
|
}
|
|
e.insert(value)
|
|
if e.Cursor > 0 {
|
|
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
|
|
}
|
|
value = append(value[:e.Cursor-1], value[e.Cursor:]...)
|
|
e.Cursor--
|
|
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
|
|
}
|
|
value = append(value[:e.Cursor], value[e.Cursor+1:]...)
|
|
e.Text = string(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()
|
|
}
|