Files
2026-02-24 17:40:13 -08:00

1155 lines
21 KiB
Go

package edit
import (
"fmt"
"strconv"
"strings"
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/styles"
"github.com/charmbracelet/x/ansi"
"golang.org/x/term"
)
type editorMode int
const (
modeNormal editorMode = iota
modeInsert
modeCommand
)
type exitAction int
const (
actionNone exitAction = iota
actionQuit
actionSaveQuit
)
type editorResult struct {
Content string
Modified bool
Action exitAction
Force bool
}
type editorModel struct {
lines [][]rune
row int
col int
top int
left int
width int
height int
mode editorMode
command string
commandPrefix rune
pending rune
dirty bool
filename string
showLineNumbers bool
syntaxName string
lexer chroma.Lexer
formatter chroma.Formatter
style *chroma.Style
highlighted []string
highlightOn bool
highlightDirty bool
lastSearch string
lastSearchForward bool
action exitAction
forceQuit bool
message string
clearMessage bool
}
var (
lineStyle = lipgloss.NewStyle()
)
const (
ansiReverseOn = "\x1b[7m"
ansiReverseOff = "\x1b[0m"
)
func newEditorModel(content, filename string, lexer chroma.Lexer, syntaxName string, showLineNumbers bool) *editorModel {
lines := splitLines(content)
model := &editorModel{
lines: lines,
mode: modeNormal,
filename: filename,
showLineNumbers: showLineNumbers,
commandPrefix: ':',
}
model.setSyntax(lexer, syntaxName)
return model
}
func (m *editorModel) Init() tea.Cmd {
if m.width == 0 || m.height == 0 {
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
m.width = w
m.height = h
} else {
m.width = 80
m.height = 24
}
m.ensureCursorVisible()
}
return nil
}
func (m *editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.ensureCursorVisible()
return m, nil
case tea.KeyPressMsg:
if m.clearMessage {
m.message = ""
m.clearMessage = false
}
key := msg.Key()
if key.Mod.Contains(tea.ModCtrl) && key.Code == 'c' {
m.action = actionQuit
return m, tea.Quit
}
switch m.mode {
case modeCommand:
return m.handleCommand(key)
case modeInsert:
return m.handleInsert(key)
default:
return m.handleNormal(key)
}
}
return m, nil
}
func (m *editorModel) View() tea.View {
if m.width == 0 || m.height == 0 {
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
m.width = w
m.height = h
} else {
m.width = 80
m.height = 24
}
m.ensureCursorVisible()
}
textHeight := m.textHeight()
var b strings.Builder
for i := 0; i < textHeight; i++ {
lineIndex := m.top + i
b.WriteString(m.renderLine(lineIndex))
if i < textHeight-1 {
b.WriteByte('\n')
}
}
if m.height < 2 {
return editorView(b.String())
}
b.WriteByte('\n')
b.WriteString(renderStatusBar(m.width, m.statusLine()))
if m.height < 3 {
return editorView(b.String())
}
b.WriteByte('\n')
b.WriteString(lineStyle.Width(m.width).Render(m.commandLine()))
return editorView(b.String())
}
func renderStatusBar(width int, text string) string {
return ansiReverseOn + lineStyle.Width(width).Render(text) + ansiReverseOff
}
func renderCursorCell(cell string) string {
return ansiReverseOn + cell + ansiReverseOff
}
func editorView(content string) tea.View {
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m *editorModel) result() editorResult {
return editorResult{
Content: m.content(),
Modified: m.dirty,
Action: m.action,
Force: m.forceQuit,
}
}
func (m *editorModel) handleCommand(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.mode = modeNormal
m.command = ""
m.commandPrefix = ':'
case tea.KeyEnter:
if m.commandPrefix == '/' {
m.executeSearch(strings.TrimSpace(m.command))
} else {
m.executeCommand(strings.TrimSpace(m.command))
}
m.command = ""
m.mode = modeNormal
m.commandPrefix = ':'
if m.action != actionNone {
return m, tea.Quit
}
case tea.KeyBackspace:
m.command = popRune(m.command)
default:
if key.Text != "" {
m.command += key.Text
}
}
return m, nil
}
func (m *editorModel) handleInsert(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.mode = modeNormal
case tea.KeyEnter:
m.insertNewline()
case tea.KeyBackspace:
m.backspace()
case tea.KeyDelete:
m.deleteChar()
case tea.KeyLeft:
m.moveLeft()
case tea.KeyRight:
m.moveRight()
case tea.KeyUp:
m.moveUp()
case tea.KeyDown:
m.moveDown()
case tea.KeyTab:
m.insertRune('\t')
default:
for _, r := range key.Text {
m.insertRune(r)
}
}
m.ensureCursorVisible()
return m, nil
}
func (m *editorModel) handleNormal(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.pending = 0
case tea.KeyLeft:
m.moveLeft()
case tea.KeyRight:
m.moveRight()
case tea.KeyUp:
m.moveUp()
case tea.KeyDown:
m.moveDown()
default:
for _, r := range key.Text {
if m.handleNormalRune(r) {
break
}
}
}
m.ensureCursorVisible()
return m, nil
}
func (m *editorModel) handleNormalRune(r rune) bool {
if m.pending != 0 {
handled := true
switch m.pending {
case 'd':
if r == 'd' {
m.deleteLine()
} else {
handled = false
}
case 'g':
if r == 'g' {
m.gotoTop()
} else {
handled = false
}
default:
handled = false
}
m.pending = 0
if handled {
return true
}
}
switch r {
case 'h':
m.moveLeft()
case 'j':
m.moveDown()
case 'k':
m.moveUp()
case 'l':
m.moveRight()
case 'w':
m.moveWordForward()
case 'b':
m.moveWordBackward()
case 'e':
m.moveWordEnd()
case '^':
m.moveLineFirstNonWhitespace()
case '0':
m.moveLineStart()
case '$':
m.moveLineEnd()
case 'i':
m.mode = modeInsert
case 'a':
m.moveRightNoWrap()
m.mode = modeInsert
case 'A':
m.moveLineEnd()
m.mode = modeInsert
case 'I':
m.moveLineStart()
m.mode = modeInsert
case 'o':
m.openLineBelow()
case 'O':
m.openLineAbove()
case 'x':
m.deleteCharNormal()
case 'd':
m.pending = 'd'
case 'g':
m.pending = 'g'
case 'G':
m.gotoBottom()
case 'n':
m.searchNext(true)
case 'N':
m.searchNext(false)
case ':':
m.mode = modeCommand
m.command = ""
m.commandPrefix = ':'
case '/':
m.mode = modeCommand
m.command = ""
m.commandPrefix = '/'
}
return false
}
func (m *editorModel) executeCommand(cmd string) {
if isDigits(cmd) {
target, err := strconv.Atoi(cmd)
if err == nil {
m.gotoLine(target)
return
}
}
switch strings.ToLower(cmd) {
case "":
return
case "q":
m.action = actionQuit
case "q!":
m.action = actionQuit
m.forceQuit = true
case "wq", "x":
m.action = actionSaveQuit
case "w":
m.message = "Use :wq to upload and exit"
m.clearMessage = true
case "n":
m.showLineNumbers = !m.showLineNumbers
m.ensureCursorVisible()
default:
m.message = fmt.Sprintf("Unknown command: %s", cmd)
m.clearMessage = true
}
}
func (m *editorModel) renderLine(index int) string {
totalWidth := m.textWidth()
if totalWidth <= 0 {
return ""
}
prefixWidth := m.lineNumberWidth()
contentWidth := totalWidth - prefixWidth
if contentWidth < 0 {
contentWidth = 0
}
prefix := m.linePrefix(index, prefixWidth)
if index >= len(m.lines) {
return lineStyle.Width(totalWidth).Render(prefix + "~")
}
line := m.lines[index]
start := clamp(m.left, 0, len(line))
end := clamp(start+contentWidth, 0, len(line))
if m.highlightOn {
m.ensureHighlighted()
highlighted := m.highlightedLine(index)
if highlighted != "" {
lineText := ansi.Cut(highlighted, start, end)
if index == m.row {
lineText = m.applyCursor(lineText, highlighted, line, start, end)
}
return lineStyle.Width(totalWidth).Render(prefix + lineText)
}
}
visible := line[start:end]
lineText := string(visible)
if index == m.row {
cursor := clamp(m.col, 0, len(line))
if cursor >= start && cursor <= start+contentWidth {
rel := cursor - start
if rel < len(visible) {
lineText = string(visible[:rel]) + renderCursorCell(string(visible[rel])) + string(visible[rel+1:])
} else {
lineText = string(visible) + renderCursorCell(" ")
}
}
}
return lineStyle.Width(totalWidth).Render(prefix + lineText)
}
func (m *editorModel) statusLine() string {
mode := "NORMAL"
switch m.mode {
case modeInsert:
mode = "INSERT"
case modeCommand:
mode = "COMMAND"
}
fileName := baseName(m.filename)
if fileName == "" {
fileName = "[No Name]"
}
modified := ""
if m.dirty {
modified = " [+]"
}
syntax := ""
if m.syntaxName != "" {
syntax = " " + m.syntaxName
}
return fmt.Sprintf(" %s %s Ln %d, Col %d%s%s", mode, fileName, m.row+1, m.col+1, modified, syntax)
}
func (m *editorModel) commandLine() string {
if m.mode == modeCommand {
prefix := ":"
if m.commandPrefix == '/' {
prefix = "/"
}
return prefix + m.command
}
if m.message != "" {
return m.message
}
if m.mode == modeInsert {
return "ESC: normal / search :wq save+quit :q quit :n line numbers :<num> goto line"
}
return "i: insert h/j/k/l: move / search :wq save+quit :q quit :n line numbers :<num> goto line"
}
func (m *editorModel) content() string {
var b strings.Builder
for i, line := range m.lines {
b.WriteString(string(line))
if i < len(m.lines)-1 {
b.WriteByte('\n')
}
}
return b.String()
}
func (m *editorModel) textHeight() int {
if m.height <= 2 {
return 1
}
return m.height - 2
}
func (m *editorModel) textWidth() int {
if m.width <= 0 {
return 1
}
return m.width
}
func (m *editorModel) lineNumberWidth() int {
if !m.showLineNumbers {
return 0
}
total := len(m.lines)
if total < 1 {
total = 1
}
return len(fmt.Sprintf("%d", total)) + 1
}
func (m *editorModel) linePrefix(index, width int) string {
if width == 0 {
return ""
}
if index >= len(m.lines) {
return fmt.Sprintf("%*s ", width-1, "")
}
return fmt.Sprintf("%*d ", width-1, index+1)
}
func (m *editorModel) ensureCursorVisible() {
if m.row < 0 {
m.row = 0
}
if m.row >= len(m.lines) {
m.row = len(m.lines) - 1
}
if m.row < 0 {
m.row = 0
}
if len(m.lines) == 0 {
m.lines = [][]rune{{}}
m.row = 0
m.col = 0
}
lineLen := len(m.lines[m.row])
if m.col < 0 {
m.col = 0
}
if m.col > lineLen {
m.col = lineLen
}
height := m.textHeight()
if m.row < m.top {
m.top = m.row
} else if m.row >= m.top+height {
m.top = m.row - height + 1
}
if m.top < 0 {
m.top = 0
}
width := m.textWidth() - m.lineNumberWidth()
if width < 1 {
width = 1
}
if m.col < m.left {
m.left = m.col
} else if m.col >= m.left+width {
m.left = m.col - width + 1
}
if m.left < 0 {
m.left = 0
}
}
func (m *editorModel) moveLeft() {
if m.col > 0 {
m.col--
}
}
func (m *editorModel) moveRight() {
lineLen := len(m.lines[m.row])
if m.col < lineLen {
m.col++
}
}
func (m *editorModel) moveRightNoWrap() {
lineLen := len(m.lines[m.row])
if m.col < lineLen {
m.col++
}
}
func (m *editorModel) moveUp() {
if m.row > 0 {
m.row--
m.clampCol()
}
}
func (m *editorModel) moveDown() {
if m.row < len(m.lines)-1 {
m.row++
m.clampCol()
}
}
func (m *editorModel) moveLineStart() {
m.col = 0
}
func (m *editorModel) moveLineFirstNonWhitespace() {
line := m.lines[m.row]
col := 0
for col < len(line) {
if line[col] != ' ' && line[col] != '\t' {
break
}
col++
}
m.col = col
}
func (m *editorModel) moveLineEnd() {
m.col = len(m.lines[m.row])
}
func (m *editorModel) gotoTop() {
m.row = 0
m.clampCol()
}
func (m *editorModel) gotoBottom() {
if len(m.lines) == 0 {
m.row = 0
m.col = 0
return
}
m.row = len(m.lines) - 1
m.clampCol()
}
func (m *editorModel) gotoLine(lineNumber int) {
if lineNumber < 1 {
lineNumber = 1
}
if len(m.lines) == 0 {
m.row = 0
m.col = 0
return
}
if lineNumber > len(m.lines) {
lineNumber = len(m.lines)
}
m.row = lineNumber - 1
m.clampCol()
m.ensureCursorVisible()
}
func (m *editorModel) moveWordForward() {
row := m.row
col := m.col
for {
if row >= len(m.lines) {
return
}
line := m.lines[row]
if col >= len(line) {
row++
col = 0
continue
}
if isWordChar(line[col]) {
for col < len(line) && isWordChar(line[col]) {
col++
}
}
for col < len(line) && !isWordChar(line[col]) {
col++
}
if col < len(line) {
m.row = row
m.col = col
return
}
row++
col = 0
}
}
func (m *editorModel) moveWordBackward() {
row := m.row
col := m.col - 1
for {
if row < 0 {
return
}
line := m.lines[row]
if col >= len(line) {
col = len(line) - 1
}
for col >= 0 && (col >= len(line) || !isWordChar(line[col])) {
col--
}
for col >= 0 && isWordChar(line[col]) {
col--
}
if col+1 >= 0 && col+1 < len(line) {
m.row = row
m.col = col + 1
return
}
row--
if row >= 0 {
col = len(m.lines[row]) - 1
}
}
}
func (m *editorModel) moveWordEnd() {
row := m.row
col := m.col
for {
if row >= len(m.lines) {
return
}
line := m.lines[row]
if col >= len(line) {
row++
col = 0
continue
}
if isWordChar(line[col]) {
for col+1 < len(line) && isWordChar(line[col+1]) {
col++
}
m.row = row
m.col = col
return
}
for col < len(line) && !isWordChar(line[col]) {
col++
}
if col < len(line) {
continue
}
row++
col = 0
}
}
func (m *editorModel) clampCol() {
lineLen := len(m.lines[m.row])
if m.col > lineLen {
m.col = lineLen
}
}
func (m *editorModel) insertRune(r rune) {
line := m.lines[m.row]
if m.col > len(line) {
m.col = len(line)
}
line = append(line[:m.col], append([]rune{r}, line[m.col:]...)...)
m.lines[m.row] = line
m.col++
m.dirty = true
m.highlightDirty = true
}
func (m *editorModel) insertNewline() {
line := m.lines[m.row]
if m.col > len(line) {
m.col = len(line)
}
left := append([]rune(nil), line[:m.col]...)
right := append([]rune(nil), line[m.col:]...)
m.lines[m.row] = left
m.lines = append(m.lines[:m.row+1], append([][]rune{right}, m.lines[m.row+1:]...)...)
m.row++
m.col = 0
m.dirty = true
m.highlightDirty = true
}
func (m *editorModel) backspace() {
if m.col > 0 {
line := m.lines[m.row]
line = append(line[:m.col-1], line[m.col:]...)
m.lines[m.row] = line
m.col--
m.dirty = true
m.highlightDirty = true
return
}
if m.row == 0 {
return
}
prev := m.lines[m.row-1]
line := m.lines[m.row]
m.col = len(prev)
m.lines[m.row-1] = append(prev, line...)
m.lines = append(m.lines[:m.row], m.lines[m.row+1:]...)
m.row--
m.dirty = true
m.highlightDirty = true
}
func (m *editorModel) deleteChar() {
line := m.lines[m.row]
if m.col < len(line) {
line = append(line[:m.col], line[m.col+1:]...)
m.lines[m.row] = line
m.dirty = true
m.highlightDirty = true
return
}
if m.row < len(m.lines)-1 {
next := m.lines[m.row+1]
m.lines[m.row] = append(line, next...)
m.lines = append(m.lines[:m.row+1], m.lines[m.row+2:]...)
m.dirty = true
m.highlightDirty = true
}
}
func (m *editorModel) deleteCharNormal() {
line := m.lines[m.row]
if m.col < len(line) {
line = append(line[:m.col], line[m.col+1:]...)
m.lines[m.row] = line
m.dirty = true
m.highlightDirty = true
}
}
func (m *editorModel) deleteLine() {
if len(m.lines) == 0 {
return
}
if len(m.lines) == 1 {
m.lines[0] = []rune{}
m.col = 0
m.dirty = true
m.highlightDirty = true
return
}
m.lines = append(m.lines[:m.row], m.lines[m.row+1:]...)
if m.row >= len(m.lines) {
m.row = len(m.lines) - 1
}
m.clampCol()
m.dirty = true
m.highlightDirty = true
}
func (m *editorModel) openLineBelow() {
m.lines = append(m.lines[:m.row+1], append([][]rune{{}}, m.lines[m.row+1:]...)...)
m.row++
m.col = 0
m.mode = modeInsert
m.dirty = true
m.highlightDirty = true
}
func (m *editorModel) openLineAbove() {
m.lines = append(m.lines[:m.row], append([][]rune{{}}, m.lines[m.row:]...)...)
m.col = 0
m.mode = modeInsert
m.dirty = true
m.highlightDirty = true
}
func (m *editorModel) setSyntax(lexer chroma.Lexer, syntaxName string) {
if lexer == nil {
m.lexer = nil
m.syntaxName = "none"
m.highlightOn = false
m.highlighted = nil
m.highlightDirty = false
return
}
m.lexer = lexer
m.syntaxName = syntaxName
m.formatter = formatters.Get("terminal16m")
if m.formatter == nil {
m.formatter = formatters.Fallback
}
m.style = styles.Get("monokai")
if m.style == nil {
m.style = styles.Fallback
}
m.highlightOn = true
m.highlightDirty = true
}
func (m *editorModel) ensureHighlighted() {
if !m.highlightOn || !m.highlightDirty {
return
}
iterator, err := m.lexer.Tokenise(nil, m.content())
if err != nil {
m.highlighted = nil
m.highlightDirty = false
return
}
var b strings.Builder
if err := m.formatter.Format(&b, m.style, iterator); err != nil {
m.highlighted = nil
m.highlightDirty = false
return
}
lines := strings.Split(b.String(), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
for len(lines) < len(m.lines) {
lines = append(lines, "")
}
m.highlighted = lines
m.highlightDirty = false
}
func (m *editorModel) highlightedLine(index int) string {
if index < 0 || index >= len(m.highlighted) {
return ""
}
return m.highlighted[index]
}
func (m *editorModel) applyCursor(rendered, highlighted string, line []rune, start, end int) string {
cursor := clamp(m.col, 0, len(line))
if cursor < start || cursor > end {
return rendered
}
if cursor == len(line) {
left := ansi.Cut(highlighted, start, cursor)
return left + renderCursorCell(" ")
}
left := ansi.Cut(highlighted, start, cursor)
right := ansi.Cut(highlighted, cursor+1, end)
return left + renderCursorCell(string(line[cursor])) + right
}
func (m *editorModel) executeSearch(pattern string) {
if pattern == "" {
if m.lastSearch == "" {
return
}
pattern = m.lastSearch
}
m.lastSearch = pattern
m.lastSearchForward = true
if !m.searchNext(true) {
m.message = fmt.Sprintf("Pattern not found: %s", pattern)
m.clearMessage = true
}
}
func (m *editorModel) searchNext(forward bool) bool {
pattern := m.lastSearch
if pattern == "" {
m.message = "No previous search"
m.clearMessage = true
return false
}
row, col, ok := m.findMatch(pattern, forward)
if !ok {
m.message = fmt.Sprintf("Pattern not found: %s", pattern)
m.clearMessage = true
return false
}
m.row = row
m.col = col
m.lastSearchForward = forward
m.ensureCursorVisible()
return true
}
func (m *editorModel) findMatch(pattern string, forward bool) (int, int, bool) {
if pattern == "" {
return 0, 0, false
}
pat := []rune(pattern)
if forward {
return m.findForward(pat)
}
return m.findBackward(pat)
}
func (m *editorModel) findForward(pattern []rune) (int, int, bool) {
row := m.row
col := m.col + 1
for r := row; r < len(m.lines); r++ {
line := m.lines[r]
start := 0
if r == row {
start = col
}
if idx := indexOfRunes(line, pattern, start); idx >= 0 {
return r, idx, true
}
}
for r := 0; r <= row && r < len(m.lines); r++ {
line := m.lines[r]
end := len(line)
if r == row {
end = min(end, col)
}
if idx := indexOfRunes(line, pattern, 0); idx >= 0 && idx < end {
return r, idx, true
}
}
return 0, 0, false
}
func (m *editorModel) findBackward(pattern []rune) (int, int, bool) {
row := m.row
col := m.col - 1
for r := row; r >= 0; r-- {
line := m.lines[r]
end := len(line)
if r == row {
end = col + 1
}
if idx := lastIndexOfRunes(line, pattern, end); idx >= 0 {
return r, idx, true
}
}
for r := len(m.lines) - 1; r >= row && r >= 0; r-- {
line := m.lines[r]
end := len(line)
if r == row {
end = len(line)
}
if idx := lastIndexOfRunes(line, pattern, end); idx >= 0 {
return r, idx, true
}
}
return 0, 0, false
}
func splitLines(content string) [][]rune {
parts := strings.Split(content, "\n")
if len(parts) == 0 {
return [][]rune{{}}
}
lines := make([][]rune, len(parts))
for i, line := range parts {
lines[i] = []rune(line)
}
return lines
}
func popRune(value string) string {
runes := []rune(value)
if len(runes) == 0 {
return value
}
return string(runes[:len(runes)-1])
}
func isDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func isWordChar(r rune) bool {
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
}
func indexOfRunes(haystack, needle []rune, start int) int {
if len(needle) == 0 {
return -1
}
if start < 0 {
start = 0
}
if start >= len(haystack) {
return -1
}
for i := start; i+len(needle) <= len(haystack); i++ {
match := true
for j := range needle {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func lastIndexOfRunes(haystack, needle []rune, end int) int {
if len(needle) == 0 {
return -1
}
if end > len(haystack) {
end = len(haystack)
}
for i := end - len(needle); i >= 0; i-- {
match := true
for j := range needle {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func baseName(path string) string {
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx != -1 {
return clean[idx+1:]
}
return path
}
func clamp(value, minValue, maxValue int) int {
if value < minValue {
return minValue
}
if value > maxValue {
return maxValue
}
return value
}