grimoir/keybindings.go
2026-07-04 13:04:33 +03:00

863 lines
No EOL
21 KiB
Go
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.SetStatusMsg("")
case "a":
m.mode = INSERT
line := []rune(m.lines[m.cursorY])
if m.cursorX < len(line) {
m.cursorX++
}
m.SetStatusMsg("")
case "A":
m.mode = INSERT
m.cursorX = len([]rune(m.lines[m.cursorY]))
m.SetStatusMsg("")
case "I":
m.mode = INSERT
m.cursorX = 0
m.SetStatusMsg("")
case "o":
m.saveUndoSnapshot()
m.insertLine(m.cursorY + 1)
m.cursorY++
m.cursorX = 0
m.mode = INSERT
m.MarkDirty()
m.SetStatusMsg("")
case "O":
m.saveUndoSnapshot()
m.insertLine(m.cursorY)
m.cursorX = 0
m.mode = INSERT
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
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.saveUndoSnapshot()
m.splitLineRune(m.cursorY, m.cursorX)
m.cursorY++
m.cursorX = 0
m.MarkDirty()
case "backspace":
m.saveUndoSnapshot()
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.saveUndoSnapshot()
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.saveUndoSnapshot()
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.saveUndoSnapshot()
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{""}
}
}