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