grimoir/keybindings.go
2026-05-07 09:52:06 +03:00

639 lines
15 KiB
Go
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 (
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// handleKey диспетчеризирует по фокусу и режиму
func (m *EditorModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
// === Help экран — обрабатываем первым ===
if m.showHelp {
if key == "q" || key == "esc" || key == "?" || key == ":help" {
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("toggle_tree") {
m.showFileTree = !m.showFileTree
if m.showFileTree {
m.fileTree.reload()
m.focus = FocusFileTree
m.fileTree.focused = true
m.SetStatusMsg("File tree ON")
} else {
m.focus = FocusEditor
m.fileTree.focused = false
m.SetStatusMsg("File tree OFF")
}
m.clampPanelWidths()
m.updatePreview()
return m, nil
}
if key == m.config.KeyForAction("focus_switch") {
if m.showFileTree {
if m.focus == FocusEditor {
m.focus = FocusFileTree
m.fileTree.focused = true
m.SetStatusMsg("Focus: file tree")
} else {
m.focus = FocusEditor
m.fileTree.focused = false
m.SetStatusMsg("")
}
}
return m, nil
}
if key == m.config.KeyForAction("panel_shrink") || key == m.config.KeyForAction("panel_grow") {
delta := 2
if key == m.config.KeyForAction("panel_shrink") {
delta = -2
}
if m.focus == FocusFileTree && m.showFileTree {
m.resizeTree(delta)
} else if m.showPreview && m.fileType == "md" {
m.resizePreview(delta)
}
m.updatePreview()
return m, nil
}
// === Фокус на дереве ===
if m.focus == FocusFileTree {
return m.handleFileTreeKey(msg)
}
// === Фокус на редакторе ===
switch m.mode {
case NORMAL:
return m.handleNormalMode(msg)
case INSERT:
return m.handleInsertMode(msg)
case COMMAND:
return m.handleCommandMode(msg)
case SEARCH:
return m.handleSearchMode(msg)
}
return m, nil
}
// handleFileTreeKey — клавиши при фокусе на дереве
func (m *EditorModel) handleFileTreeKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
ft := &m.fileTree
switch key {
case "j", "down":
ft.moveDown(m.parseNumberPrefix())
case "k", "up":
ft.moveUp(m.parseNumberPrefix())
case "g":
if m.pendingKeys == "g" {
ft.cursor = 0
ft.scroll = 0
m.pendingKeys = ""
} else {
m.pendingKeys = "g"
}
case "G":
ft.cursor = len(ft.nodes) - 1
ft.adjustScroll(m.height - 3)
m.pendingKeys = ""
case "enter", "l":
if ft.selectedIsDir() {
ft.toggle()
} else if path := ft.selectedPath(); path != "" {
m.openFile(path)
m.focus = FocusEditor
m.fileTree.focused = false
}
case "h":
if ft.cursor < len(ft.nodes) {
node := ft.nodes[ft.cursor]
if node.isDir && node.expanded {
ft.toggle()
} else if node.depth > 0 {
for i := ft.cursor - 1; i >= 0; i-- {
if ft.nodes[i].depth < node.depth {
ft.cursor = i
break
}
}
}
}
case "r":
ft.reload()
m.SetStatusMsg("File tree refreshed")
case "esc":
m.focus = FocusEditor
m.fileTree.focused = false
m.SetStatusMsg("")
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
case "0":
if m.numPrefix != "" {
m.numPrefix += key
m.SetStatusMsg(m.numPrefix)
}
}
return m, nil
}
// handleNormalMode — NORMAL режим
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 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
}
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("-- INSERT --")
case "a":
m.mode = INSERT
// 'a' — после курсора: сдвигаем на одну руну вправо
line := []rune(m.lines[m.cursorY])
if m.cursorX < len(line) {
m.cursorX++
}
m.SetStatusMsg("-- INSERT --")
case "A":
m.mode = INSERT
m.cursorX = len([]rune(m.lines[m.cursorY]))
m.SetStatusMsg("-- INSERT --")
case "I":
m.mode = INSERT
m.cursorX = 0
m.SetStatusMsg("-- INSERT --")
case "o":
m.saveUndoSnapshot()
m.insertLine(m.cursorY + 1)
m.cursorY++
m.cursorX = 0
m.mode = INSERT
m.MarkDirty()
m.SetStatusMsg("-- INSERT --")
case "O":
m.saveUndoSnapshot()
m.insertLine(m.cursorY)
m.cursorX = 0
m.mode = INSERT
m.MarkDirty()
m.SetStatusMsg("-- INSERT --")
default:
return nil
}
return tea.Batch()
}
func (m *EditorModel) handleMovement(key string) {
switch key {
case "h", "left":
count := m.parseNumberPrefix()
m.cursorX -= count
m.CursorWithinBounds()
case "j", "down":
m.moveByLines(m.parseNumberPrefix(), 1)
case "k", "up":
m.moveByLines(m.parseNumberPrefix(), -1)
case "l", "right":
count := m.parseNumberPrefix()
m.cursorX += count
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 "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
}
}
}
func (m *EditorModel) handleEditing(key string) {
switch key {
case "x":
m.saveUndoSnapshot()
m.deleteCharAt(m.cursorY, m.cursorX)
m.CursorWithinBounds()
m.MarkDirty()
case "d":
if m.pendingKeys == "d" {
count := m.parseNumberPrefix()
m.saveUndoSnapshot()
for i := 0; i < count; i++ {
m.deleteLine(m.cursorY)
}
m.CursorWithinBounds()
m.MarkDirty()
m.pendingKeys = ""
} else if m.pendingKeys == "" {
m.pendingKeys = "d"
}
case "y":
if m.pendingKeys == "y" {
m.clipboard = m.lines[m.cursorY]
m.SetStatusMsg("Line yanked")
m.pendingKeys = ""
} else 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 = ""
}
}
}
// handleInsertMode — INSERT режим с полной поддержкой Unicode и навигации
func (m *EditorModel) handleInsertMode(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
switch key {
case "esc":
m.mode = NORMAL
// В 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 {
// Merge с предыдущей строкой
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 {
// Merge со следующей строкой
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:
// Любой печатаемый символ — включая кириллицу и другие Unicode
// Пробел приходит как tea.KeySpace, остальные печатаемые — tea.KeyRunes
runes := []rune(msg.String())
isPrintable := msg.Type == tea.KeyRunes ||
msg.Type == tea.KeySpace
if len(runes) == 1 && runes[0] >= 32 && isPrintable {
m.saveUndoSnapshot()
m.insertRuneAt(m.cursorY, m.cursorX, runes)
m.cursorX++
m.MarkDirty()
}
}
m.CursorWithinBounds()
m.updatePreview()
return m, nil
}
// handleCommandMode — режим команд
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:
// Принимаем Unicode в командном буфере
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
}
// handleSearchMode — режим поиска
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--
}
}
// === Редактирование (rune-safe) ===
// insertRuneAt вставляет руны в строку на позиции pos (в рунах)
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)
}
// deleteCharAt удаляет символ на позиции pos (в рунах)
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:]...))
}
// splitLineRune разбивает строку на позиции pos (в рунах)
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{""}
}
}