639 lines
15 KiB
Go
639 lines
15 KiB
Go
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{""}
|
||
}
|
||
}
|