870 lines
No EOL
21 KiB
Go
Executable file
870 lines
No EOL
21 KiB
Go
Executable file
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
key := msg.String()
|
||
|
||
// Попап Тзинча — любая клавиша закрывает
|
||
if m.showTzeentch {
|
||
m.showTzeentch = false
|
||
return m, nil
|
||
}
|
||
|
||
// Help экран
|
||
if m.showHelp {
|
||
if key == "q" || key == "esc" || key == "?" {
|
||
m.showHelp = false
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// Глобальные клавиши
|
||
if key == m.config.KeyForAction("quit") || key == m.config.KeyForAction("quit_alt") {
|
||
return m, tea.Quit
|
||
}
|
||
|
||
if key == m.config.KeyForAction("panel_shrink") || key == m.config.KeyForAction("panel_grow") {
|
||
if m.showPreview && m.fileType == "md" {
|
||
delta := 2
|
||
if key == m.config.KeyForAction("panel_shrink") {
|
||
delta = -2
|
||
}
|
||
m.resizePreview(delta)
|
||
m.updatePreview()
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
switch m.mode {
|
||
case NORMAL:
|
||
return m.handleNormalMode(msg)
|
||
case VISUAL:
|
||
return m.handleVisualMode(msg)
|
||
case INSERT:
|
||
return m.handleInsertMode(msg)
|
||
case COMMAND:
|
||
return m.handleCommandMode(msg)
|
||
case SEARCH:
|
||
return m.handleSearchMode(msg)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// handleVisualMode — VISUAL LINE режим (построчное выделение, вход через V).
|
||
// Курсор и visualStartY задают диапазон выделенных строк.
|
||
// Движение по строкам расширяет/сужает выделение, d/y применяются к диапазону,
|
||
// Esc выходит без действия.
|
||
func (m *EditorModel) handleVisualMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
key := msg.String()
|
||
|
||
switch key {
|
||
case "esc", "V":
|
||
m.mode = NORMAL
|
||
m.SetStatusMsg("")
|
||
return m, nil
|
||
|
||
case "j", "down":
|
||
m.moveByLines(m.parseNumberPrefix(), 1)
|
||
case "k", "up":
|
||
m.moveByLines(m.parseNumberPrefix(), -1)
|
||
case "g":
|
||
if m.pendingKeys == "g" {
|
||
m.cursorY = 0
|
||
m.pendingKeys = ""
|
||
} else {
|
||
m.pendingKeys = "g"
|
||
}
|
||
return m, nil
|
||
case "G":
|
||
count := m.parseNumberPrefix()
|
||
if count == 1 && m.numPrefix == "" {
|
||
m.cursorY = len(m.lines) - 1
|
||
} else if count > 0 && count <= len(m.lines) {
|
||
m.cursorY = count - 1
|
||
}
|
||
|
||
case "d", "x":
|
||
start, end := m.visualRange()
|
||
m.saveUndoSnapshot()
|
||
for i := start; i <= end; i++ {
|
||
m.deleteLine(start)
|
||
}
|
||
m.cursorY = start
|
||
m.CursorWithinBounds()
|
||
m.MarkDirty()
|
||
m.mode = NORMAL
|
||
n := end - start + 1
|
||
if n == 1 {
|
||
m.SetStatusMsg("1 line deleted")
|
||
} else {
|
||
m.SetStatusMsg(fmt.Sprintf("%d lines deleted", n))
|
||
}
|
||
return m, nil
|
||
|
||
case "y":
|
||
start, end := m.visualRange()
|
||
m.clipboard = strings.Join(m.lines[start:end+1], "\n")
|
||
m.cursorY = start
|
||
m.CursorWithinBounds()
|
||
m.mode = NORMAL
|
||
n := end - start + 1
|
||
if n == 1 {
|
||
m.SetStatusMsg("1 line yanked")
|
||
} else {
|
||
m.SetStatusMsg(fmt.Sprintf("%d lines yanked", n))
|
||
}
|
||
return m, nil
|
||
|
||
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
|
||
m.numPrefix += key
|
||
m.SetStatusMsg(m.numPrefix)
|
||
return m, nil
|
||
case "0":
|
||
if m.numPrefix != "" {
|
||
m.numPrefix += key
|
||
m.SetStatusMsg(m.numPrefix)
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
m.updateVisualStatus()
|
||
return m, nil
|
||
}
|
||
|
||
// visualRange возвращает нормализованный диапазон [start, end] выделенных строк
|
||
func (m *EditorModel) visualRange() (start, end int) {
|
||
start, end = m.visualStartY, m.cursorY
|
||
if start > end {
|
||
start, end = end, start
|
||
}
|
||
return start, end
|
||
}
|
||
|
||
// updateVisualStatus обновляет статус-строку с количеством выделенных строк
|
||
func (m *EditorModel) updateVisualStatus() {
|
||
start, end := m.visualRange()
|
||
n := end - start + 1
|
||
if n == 1 {
|
||
m.SetStatusMsg("1 line selected")
|
||
} else {
|
||
m.SetStatusMsg(fmt.Sprintf("%d lines selected", n))
|
||
}
|
||
}
|
||
|
||
func (m *EditorModel) handleNormalMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
key := msg.String()
|
||
|
||
if key >= "1" && key <= "9" && m.pendingKeys == "" {
|
||
m.numPrefix += key
|
||
m.SetStatusMsg(m.numPrefix)
|
||
return m, nil
|
||
}
|
||
if key == "0" && m.numPrefix != "" {
|
||
m.numPrefix += key
|
||
m.SetStatusMsg(m.numPrefix)
|
||
return m, nil
|
||
}
|
||
|
||
switch key {
|
||
case m.config.KeyForAction("redo"):
|
||
m.Redo()
|
||
case ":":
|
||
m.mode = COMMAND
|
||
m.commandBuffer = ""
|
||
m.SetStatusMsg(":")
|
||
case "/":
|
||
m.startSearch()
|
||
case "?":
|
||
m.showHelp = true
|
||
return m, nil
|
||
case "V":
|
||
m.mode = VISUAL
|
||
m.visualStartY = m.cursorY
|
||
m.SetStatusMsg("")
|
||
return m, nil
|
||
case m.config.KeyForAction("toggle_preview"):
|
||
if m.fileType == "md" {
|
||
m.showPreview = !m.showPreview
|
||
if m.showPreview {
|
||
m.SetStatusMsg("Preview ON")
|
||
} else {
|
||
m.SetStatusMsg("Preview OFF")
|
||
}
|
||
m.clampPanelWidths()
|
||
}
|
||
default:
|
||
if cmd := m.handleModeSwitch(key); cmd != nil {
|
||
m.updatePreview()
|
||
return m, cmd
|
||
}
|
||
// Операторы d/y/c с motion (dw, de, d$, dd, yw и т.д.)
|
||
// должны перехватываться ДО handleMovement, иначе motion
|
||
// просто подвинет курсор вместо применения оператора.
|
||
if m.pendingKeys == "d" || m.pendingKeys == "y" || m.pendingKeys == "c" {
|
||
if m.handleOperatorMotion(key) {
|
||
m.updatePreview()
|
||
return m, nil
|
||
}
|
||
}
|
||
m.handleMovement(key)
|
||
m.handleEditing(key)
|
||
m.handleSearchKeys(key)
|
||
}
|
||
|
||
m.updatePreview()
|
||
return m, nil
|
||
}
|
||
|
||
func (m *EditorModel) handleModeSwitch(key string) tea.Cmd {
|
||
switch key {
|
||
case "i":
|
||
m.mode = INSERT
|
||
m.insertUndoTaken = false
|
||
m.SetStatusMsg("")
|
||
case "a":
|
||
m.mode = INSERT
|
||
m.insertUndoTaken = false
|
||
line := []rune(m.lines[m.cursorY])
|
||
if m.cursorX < len(line) {
|
||
m.cursorX++
|
||
}
|
||
m.SetStatusMsg("")
|
||
case "A":
|
||
m.mode = INSERT
|
||
m.insertUndoTaken = false
|
||
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
||
m.SetStatusMsg("")
|
||
case "I":
|
||
m.mode = INSERT
|
||
m.insertUndoTaken = false
|
||
m.cursorX = 0
|
||
m.SetStatusMsg("")
|
||
case "o":
|
||
m.saveUndoSnapshot()
|
||
m.insertLine(m.cursorY + 1)
|
||
m.cursorY++
|
||
m.cursorX = 0
|
||
m.mode = INSERT
|
||
m.insertUndoTaken = true
|
||
m.MarkDirty()
|
||
m.SetStatusMsg("")
|
||
case "O":
|
||
m.saveUndoSnapshot()
|
||
m.insertLine(m.cursorY)
|
||
m.cursorX = 0
|
||
m.mode = INSERT
|
||
m.insertUndoTaken = true
|
||
m.MarkDirty()
|
||
m.SetStatusMsg("")
|
||
default:
|
||
return nil
|
||
}
|
||
return tea.Batch()
|
||
}
|
||
|
||
func (m *EditorModel) handleMovement(key string) {
|
||
switch key {
|
||
case "h", "left":
|
||
m.cursorX -= m.parseNumberPrefix()
|
||
m.CursorWithinBounds()
|
||
case "j", "down":
|
||
m.moveByLines(m.parseNumberPrefix(), 1)
|
||
case "k", "up":
|
||
m.moveByLines(m.parseNumberPrefix(), -1)
|
||
case "l", "right":
|
||
m.cursorX += m.parseNumberPrefix()
|
||
m.CursorWithinBounds()
|
||
case "w":
|
||
m.moveByWords(m.parseNumberPrefix(), true)
|
||
case "b":
|
||
m.moveByWords(m.parseNumberPrefix(), false)
|
||
case "e":
|
||
count := m.parseNumberPrefix()
|
||
for i := 0; i < count; i++ {
|
||
m.moveNextWord()
|
||
if m.cursorX > 0 {
|
||
m.cursorX--
|
||
}
|
||
}
|
||
case "$":
|
||
m.numPrefix = ""
|
||
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
||
case "0":
|
||
m.numPrefix = ""
|
||
m.cursorX = 0
|
||
case "^":
|
||
m.numPrefix = ""
|
||
line := []rune(m.lines[m.cursorY])
|
||
x := 0
|
||
for x < len(line) && (line[x] == ' ' || line[x] == '\t') {
|
||
x++
|
||
}
|
||
m.cursorX = x
|
||
case "g":
|
||
if m.pendingKeys == "g" {
|
||
m.cursorY = 0
|
||
m.cursorX = 0
|
||
m.pendingKeys = ""
|
||
} else {
|
||
m.pendingKeys = "g"
|
||
}
|
||
case "G":
|
||
count := m.parseNumberPrefix()
|
||
if count == 1 && m.numPrefix == "" {
|
||
m.cursorY = len(m.lines) - 1
|
||
} else if count > 0 && count <= len(m.lines) {
|
||
m.cursorY = count - 1
|
||
}
|
||
m.cursorX = 0
|
||
m.pendingKeys = ""
|
||
case "%":
|
||
m.numPrefix = ""
|
||
m.findMatchingBracket()
|
||
case ";":
|
||
m.repeatLastFind(false)
|
||
case ",":
|
||
m.repeatLastFind(true)
|
||
case "n":
|
||
m.nextMatch()
|
||
case "N":
|
||
m.prevMatch()
|
||
case "f", "F", "t", "T":
|
||
if m.pendingKeys == "" {
|
||
m.pendingKeys = key
|
||
}
|
||
}
|
||
}
|
||
|
||
// handleOperatorMotion обрабатывает d{motion} / y{motion} / c{motion}
|
||
// (dw, de, db, d$, d0, d^, dd, yw, ye, yy и т.д.)
|
||
// Возвращает true если ключ был обработан как часть operator+motion.
|
||
func (m *EditorModel) handleOperatorMotion(key string) bool {
|
||
op := m.pendingKeys
|
||
count := m.parseNumberPrefix()
|
||
|
||
startX, startY := m.cursorX, m.cursorY
|
||
line := []rune(m.lines[m.cursorY])
|
||
|
||
switch key {
|
||
case "w":
|
||
// dw/yw — от курсора до начала следующего слова (исключая его)
|
||
endX, endY := startX, startY
|
||
for i := 0; i < count; i++ {
|
||
endX, endY = m.wordForwardPos(endX, endY)
|
||
}
|
||
m.applyOperatorRange(op, startX, startY, endX, endY, false)
|
||
m.pendingKeys = ""
|
||
return true
|
||
|
||
case "e":
|
||
// de/ye — от курсора до конца текущего/следующего слова (включая последний символ)
|
||
endX, endY := startX, startY
|
||
for i := 0; i < count; i++ {
|
||
endX, endY = m.wordEndPos(endX, endY)
|
||
}
|
||
m.applyOperatorRange(op, startX, startY, endX, endY, true)
|
||
m.pendingKeys = ""
|
||
return true
|
||
|
||
case "b":
|
||
// db/yb — от начала текущего/предыдущего слова до курсора
|
||
startX2, startY2 := startX, startY
|
||
for i := 0; i < count; i++ {
|
||
startX2, startY2 = m.wordBackPos(startX2, startY2)
|
||
}
|
||
m.applyOperatorRange(op, startX2, startY2, startX, startY, false)
|
||
m.pendingKeys = ""
|
||
return true
|
||
|
||
case "$":
|
||
// d$/y$ — от курсора до конца строки (включительно)
|
||
endX := len(line)
|
||
m.applyOperatorRange(op, startX, startY, endX, startY, false)
|
||
m.pendingKeys = ""
|
||
return true
|
||
|
||
case "0", "^":
|
||
// d0/y0 — от начала строки до курсора (исключая текущий символ)
|
||
m.applyOperatorRange(op, 0, startY, startX, startY, false)
|
||
m.pendingKeys = ""
|
||
return true
|
||
|
||
case "d":
|
||
// dd/yy — вся строка(и)
|
||
if op == "d" {
|
||
m.saveUndoSnapshot()
|
||
for i := 0; i < count; i++ {
|
||
m.deleteLine(m.cursorY)
|
||
}
|
||
m.CursorWithinBounds()
|
||
m.MarkDirty()
|
||
} else if op == "y" {
|
||
end := m.cursorY + count
|
||
if end > len(m.lines) {
|
||
end = len(m.lines)
|
||
}
|
||
m.clipboard = strings.Join(m.lines[m.cursorY:end], "\n")
|
||
m.SetStatusMsg("Line(s) yanked")
|
||
}
|
||
m.pendingKeys = ""
|
||
return true
|
||
|
||
case "y", "c":
|
||
// yy уже покрыт выше через "d" case проверки op=="y";
|
||
// здесь второй y для тех кто печатает "yy" — key == "y" совпадает с op == "y"
|
||
if key == op {
|
||
if op == "y" {
|
||
m.clipboard = m.lines[m.cursorY]
|
||
m.SetStatusMsg("Line yanked")
|
||
}
|
||
m.pendingKeys = ""
|
||
return true
|
||
}
|
||
return false
|
||
|
||
default:
|
||
// Неизвестный motion для оператора — сбрасываем pending,
|
||
// чтобы не залипнуть в режиме ожидания
|
||
m.pendingKeys = ""
|
||
return false
|
||
}
|
||
}
|
||
|
||
// applyOperatorRange применяет delete или yank к диапазону [startX,startY)-(endX,endY)
|
||
// inclusive=true означает что символ под endX тоже входит в диапазон (для de/ye)
|
||
func (m *EditorModel) applyOperatorRange(op string, startX, startY, endX, endY int, inclusive bool) {
|
||
// Нормализуем порядок (motion может быть "назад", как у b)
|
||
if startY > endY || (startY == endY && startX > endX) {
|
||
startX, endX = endX, startX
|
||
startY, endY = endY, startY
|
||
}
|
||
|
||
if inclusive {
|
||
endX++
|
||
}
|
||
|
||
if startY == endY {
|
||
line := []rune(m.lines[startY])
|
||
if startX < 0 {
|
||
startX = 0
|
||
}
|
||
if endX > len(line) {
|
||
endX = len(line)
|
||
}
|
||
if startX >= endX {
|
||
m.pendingKeys = ""
|
||
return
|
||
}
|
||
text := string(line[startX:endX])
|
||
|
||
if op == "y" {
|
||
m.clipboard = text
|
||
m.SetStatusMsg("Yanked")
|
||
return
|
||
}
|
||
|
||
// delete (op == "d", change handled same way for now)
|
||
m.saveUndoSnapshot()
|
||
newLine := append(append([]rune{}, line[:startX]...), line[endX:]...)
|
||
m.lines[startY] = string(newLine)
|
||
m.cursorX = startX
|
||
m.cursorY = startY
|
||
m.CursorWithinBounds()
|
||
m.MarkDirty()
|
||
return
|
||
}
|
||
|
||
// Многострочный диапазон — пока редкий случай для w/e/b (обычно не выходят за строку),
|
||
// но на всякий случай обрабатываем как соединение строк.
|
||
firstLine := []rune(m.lines[startY])
|
||
lastLine := []rune(m.lines[endY])
|
||
if startX > len(firstLine) {
|
||
startX = len(firstLine)
|
||
}
|
||
if endX > len(lastLine) {
|
||
endX = len(lastLine)
|
||
}
|
||
|
||
if op == "y" {
|
||
var sb strings.Builder
|
||
sb.WriteString(string(firstLine[startX:]))
|
||
for i := startY + 1; i < endY; i++ {
|
||
sb.WriteString("\n" + m.lines[i])
|
||
}
|
||
sb.WriteString("\n" + string(lastLine[:endX]))
|
||
m.clipboard = sb.String()
|
||
m.SetStatusMsg("Yanked")
|
||
return
|
||
}
|
||
|
||
m.saveUndoSnapshot()
|
||
merged := string(firstLine[:startX]) + string(lastLine[endX:])
|
||
newLines := append([]string{}, m.lines[:startY]...)
|
||
newLines = append(newLines, merged)
|
||
newLines = append(newLines, m.lines[endY+1:]...)
|
||
m.lines = newLines
|
||
m.cursorX = startX
|
||
m.cursorY = startY
|
||
m.CursorWithinBounds()
|
||
m.MarkDirty()
|
||
}
|
||
|
||
// wordForwardPos возвращает позицию начала следующего слова от (x,y) — логика как у "w"
|
||
func (m *EditorModel) wordForwardPos(x, y int) (int, int) {
|
||
line := []rune(m.lines[y])
|
||
// Пропускаем текущее слово
|
||
for x < len(line) && line[x] != ' ' {
|
||
x++
|
||
}
|
||
// Пропускаем пробелы
|
||
for x < len(line) && line[x] == ' ' {
|
||
x++
|
||
}
|
||
if x >= len(line) && y < len(m.lines)-1 {
|
||
return 0, y + 1
|
||
}
|
||
return x, y
|
||
}
|
||
|
||
// wordEndPos возвращает позицию конца текущего/следующего слова — логика как у "e"
|
||
func (m *EditorModel) wordEndPos(x, y int) (int, int) {
|
||
line := []rune(m.lines[y])
|
||
if x < len(line) {
|
||
x++
|
||
}
|
||
for x < len(line) && line[x] == ' ' {
|
||
x++
|
||
}
|
||
for x < len(line) && line[x] != ' ' {
|
||
x++
|
||
}
|
||
if x > 0 {
|
||
x--
|
||
}
|
||
if x >= len(line) && y < len(m.lines)-1 {
|
||
return 0, y + 1
|
||
}
|
||
return x, y
|
||
}
|
||
|
||
// wordBackPos возвращает позицию начала текущего/предыдущего слова — логика как у "b"
|
||
func (m *EditorModel) wordBackPos(x, y int) (int, int) {
|
||
line := []rune(m.lines[y])
|
||
if x > 0 {
|
||
x--
|
||
} else if y > 0 {
|
||
y--
|
||
line = []rune(m.lines[y])
|
||
x = len(line)
|
||
if x > 0 {
|
||
x--
|
||
}
|
||
}
|
||
for x > 0 && line[x] == ' ' {
|
||
x--
|
||
}
|
||
for x > 0 && line[x-1] != ' ' {
|
||
x--
|
||
}
|
||
return x, y
|
||
}
|
||
|
||
func (m *EditorModel) handleEditing(key string) {
|
||
switch key {
|
||
case "x":
|
||
m.saveUndoSnapshot()
|
||
m.deleteCharAt(m.cursorY, m.cursorX)
|
||
m.CursorWithinBounds()
|
||
m.MarkDirty()
|
||
case "d":
|
||
// dd/dw/de/d$ и т.д. обрабатываются в handleOperatorMotion
|
||
// (перехватывается раньше handleMovement/handleEditing).
|
||
// Сюда попадаем только при первом "d" — устанавливаем pending.
|
||
if m.pendingKeys == "" {
|
||
m.pendingKeys = "d"
|
||
}
|
||
case "y":
|
||
// yy/yw/ye и т.д. — аналогично, через handleOperatorMotion.
|
||
if m.pendingKeys == "" {
|
||
m.pendingKeys = "y"
|
||
}
|
||
case "p":
|
||
if m.clipboard != "" {
|
||
m.saveUndoSnapshot()
|
||
m.insertLineAfter(m.cursorY, m.clipboard)
|
||
m.cursorY++
|
||
m.MarkDirty()
|
||
}
|
||
case "P":
|
||
if m.clipboard != "" {
|
||
m.saveUndoSnapshot()
|
||
m.insertLine(m.cursorY)
|
||
m.lines[m.cursorY] = m.clipboard
|
||
m.MarkDirty()
|
||
}
|
||
case "u":
|
||
m.Undo()
|
||
}
|
||
}
|
||
|
||
func (m *EditorModel) handleSearchKeys(key string) {
|
||
if m.pendingKeys == "f" || m.pendingKeys == "F" || m.pendingKeys == "t" || m.pendingKeys == "T" {
|
||
runes := []rune(key)
|
||
if len(runes) == 1 {
|
||
char := runes[0]
|
||
switch m.pendingKeys {
|
||
case "f":
|
||
m.findCharInLine(char, true)
|
||
case "F":
|
||
m.findCharInLine(char, false)
|
||
case "t":
|
||
m.findCharToLine(char, true)
|
||
case "T":
|
||
m.findCharToLine(char, false)
|
||
}
|
||
m.pendingKeys = ""
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
key := msg.String()
|
||
switch key {
|
||
case "esc":
|
||
m.mode = NORMAL
|
||
m.insertUndoTaken = false
|
||
line := []rune(m.lines[m.cursorY])
|
||
if m.cursorX > 0 && m.cursorX >= len(line) {
|
||
m.cursorX = len(line) - 1
|
||
}
|
||
if m.cursorX < 0 {
|
||
m.cursorX = 0
|
||
}
|
||
m.SetStatusMsg("")
|
||
case "up":
|
||
m.moveByLines(1, -1)
|
||
case "down":
|
||
m.moveByLines(1, 1)
|
||
case "left":
|
||
if m.cursorX > 0 {
|
||
m.cursorX--
|
||
} else if m.cursorY > 0 {
|
||
m.cursorY--
|
||
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
||
}
|
||
case "right":
|
||
line := []rune(m.lines[m.cursorY])
|
||
if m.cursorX < len(line) {
|
||
m.cursorX++
|
||
} else if m.cursorY < len(m.lines)-1 {
|
||
m.cursorY++
|
||
m.cursorX = 0
|
||
}
|
||
case "home":
|
||
m.cursorX = 0
|
||
case "end":
|
||
m.cursorX = len([]rune(m.lines[m.cursorY]))
|
||
case "enter":
|
||
m.saveInsertUndoSnapshot()
|
||
m.splitLineRune(m.cursorY, m.cursorX)
|
||
m.cursorY++
|
||
m.cursorX = 0
|
||
m.MarkDirty()
|
||
case "backspace":
|
||
m.saveInsertUndoSnapshot()
|
||
if m.cursorX > 0 {
|
||
m.deleteCharAt(m.cursorY, m.cursorX-1)
|
||
m.cursorX--
|
||
m.MarkDirty()
|
||
} else if m.cursorY > 0 {
|
||
prevLen := len([]rune(m.lines[m.cursorY-1]))
|
||
m.lines[m.cursorY-1] += m.lines[m.cursorY]
|
||
m.lines = append(m.lines[:m.cursorY], m.lines[m.cursorY+1:]...)
|
||
m.cursorY--
|
||
m.cursorX = prevLen
|
||
m.MarkDirty()
|
||
}
|
||
case "delete":
|
||
m.saveInsertUndoSnapshot()
|
||
line := []rune(m.lines[m.cursorY])
|
||
if m.cursorX < len(line) {
|
||
m.deleteCharAt(m.cursorY, m.cursorX)
|
||
m.MarkDirty()
|
||
} else if m.cursorY < len(m.lines)-1 {
|
||
m.lines[m.cursorY] += m.lines[m.cursorY+1]
|
||
m.lines = append(m.lines[:m.cursorY+1], m.lines[m.cursorY+2:]...)
|
||
m.MarkDirty()
|
||
}
|
||
case "tab":
|
||
m.saveInsertUndoSnapshot()
|
||
m.insertRuneAt(m.cursorY, m.cursorX, []rune(strings.Repeat(" ", m.tabWidth)))
|
||
m.cursorX += m.tabWidth
|
||
m.MarkDirty()
|
||
default:
|
||
runes := []rune(msg.String())
|
||
if len(runes) == 1 && runes[0] >= 32 &&
|
||
(msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace) {
|
||
m.saveInsertUndoSnapshot()
|
||
m.insertRuneAt(m.cursorY, m.cursorX, runes)
|
||
m.cursorX++
|
||
m.MarkDirty()
|
||
}
|
||
}
|
||
m.CursorWithinBounds()
|
||
m.updatePreview()
|
||
return m, nil
|
||
}
|
||
|
||
func (m *EditorModel) handleCommandMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch msg.String() {
|
||
case "esc":
|
||
m.mode = NORMAL
|
||
m.commandBuffer = ""
|
||
m.SetStatusMsg("")
|
||
case "enter":
|
||
cmd := m.executeCommand(m.commandBuffer)
|
||
m.mode = NORMAL
|
||
m.commandBuffer = ""
|
||
return m, cmd
|
||
case "backspace":
|
||
runes := []rune(m.commandBuffer)
|
||
if len(runes) > 0 {
|
||
m.commandBuffer = string(runes[:len(runes)-1])
|
||
}
|
||
m.SetStatusMsg(":" + m.commandBuffer)
|
||
default:
|
||
runes := []rune(msg.String())
|
||
if len(runes) == 1 && (runes[0] >= 32 || msg.Type == tea.KeyRunes) {
|
||
m.commandBuffer += string(runes)
|
||
m.SetStatusMsg(":" + m.commandBuffer)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *EditorModel) handleSearchMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch msg.String() {
|
||
case "esc":
|
||
m.mode = NORMAL
|
||
m.searchBuffer = ""
|
||
m.SetStatusMsg("")
|
||
case "enter":
|
||
m.performSearch(m.searchBuffer)
|
||
m.mode = NORMAL
|
||
case "backspace":
|
||
runes := []rune(m.searchBuffer)
|
||
if len(runes) > 0 {
|
||
m.searchBuffer = string(runes[:len(runes)-1])
|
||
}
|
||
m.SetStatusMsg("/" + m.searchBuffer)
|
||
default:
|
||
runes := []rune(msg.String())
|
||
if len(runes) == 1 && (runes[0] >= 32 || msg.Type == tea.KeyRunes) {
|
||
m.searchBuffer += string(runes)
|
||
m.SetStatusMsg("/" + m.searchBuffer)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// === Навигация ===
|
||
|
||
func (m *EditorModel) moveNextWord() {
|
||
line := []rune(m.lines[m.cursorY])
|
||
for m.cursorX < len(line) && line[m.cursorX] == ' ' {
|
||
m.cursorX++
|
||
}
|
||
for m.cursorX < len(line) && line[m.cursorX] != ' ' {
|
||
m.cursorX++
|
||
}
|
||
}
|
||
|
||
func (m *EditorModel) movePrevWord() {
|
||
line := []rune(m.lines[m.cursorY])
|
||
if m.cursorX > 0 {
|
||
m.cursorX--
|
||
}
|
||
for m.cursorX > 0 && line[m.cursorX] == ' ' {
|
||
m.cursorX--
|
||
}
|
||
for m.cursorX > 0 && line[m.cursorX-1] != ' ' {
|
||
m.cursorX--
|
||
}
|
||
}
|
||
|
||
// === Редактирование ===
|
||
|
||
func (m *EditorModel) insertRuneAt(lineIdx, pos int, runes []rune) {
|
||
line := []rune(m.lines[lineIdx])
|
||
if pos < 0 {
|
||
pos = 0
|
||
}
|
||
if pos > len(line) {
|
||
pos = len(line)
|
||
}
|
||
newLine := make([]rune, 0, len(line)+len(runes))
|
||
newLine = append(newLine, line[:pos]...)
|
||
newLine = append(newLine, runes...)
|
||
newLine = append(newLine, line[pos:]...)
|
||
m.lines[lineIdx] = string(newLine)
|
||
}
|
||
|
||
func (m *EditorModel) deleteCharAt(lineIdx, pos int) {
|
||
line := []rune(m.lines[lineIdx])
|
||
if pos < 0 || pos >= len(line) {
|
||
return
|
||
}
|
||
m.lines[lineIdx] = string(append(line[:pos], line[pos+1:]...))
|
||
}
|
||
|
||
func (m *EditorModel) splitLineRune(lineIdx, pos int) {
|
||
line := []rune(m.lines[lineIdx])
|
||
if pos < 0 {
|
||
pos = 0
|
||
}
|
||
if pos > len(line) {
|
||
pos = len(line)
|
||
}
|
||
m.insertLine(lineIdx + 1)
|
||
m.lines[lineIdx] = string(line[:pos])
|
||
m.lines[lineIdx+1] = string(line[pos:])
|
||
}
|
||
|
||
func (m *EditorModel) insertChar(ch string) {
|
||
m.insertRuneAt(m.cursorY, m.cursorX, []rune(ch))
|
||
}
|
||
|
||
func (m *EditorModel) deleteChar() {
|
||
m.deleteCharAt(m.cursorY, m.cursorX)
|
||
}
|
||
|
||
func (m *EditorModel) splitLine(idx, pos int) {
|
||
m.splitLineRune(idx, pos)
|
||
}
|
||
|
||
func (m *EditorModel) insertLine(idx int) {
|
||
newLines := make([]string, 0, len(m.lines)+1)
|
||
newLines = append(newLines, m.lines[:idx]...)
|
||
newLines = append(newLines, "")
|
||
newLines = append(newLines, m.lines[idx:]...)
|
||
m.lines = newLines
|
||
}
|
||
|
||
func (m *EditorModel) insertLineAfter(idx int, content string) {
|
||
m.insertLine(idx + 1)
|
||
m.lines[idx+1] = content
|
||
}
|
||
|
||
func (m *EditorModel) deleteLine(idx int) {
|
||
if len(m.lines) > 1 {
|
||
m.lines = append(m.lines[:idx], m.lines[idx+1:]...)
|
||
} else {
|
||
m.lines = []string{""}
|
||
}
|
||
} |