Make dashboard updateable
This commit is contained in:
938
text_editor.go
Normal file
938
text_editor.go
Normal file
@@ -0,0 +1,938 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user