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{""} } }