Move vim mode into a separate class
This commit is contained in:
@@ -89,6 +89,7 @@ from novelwriter.editor.highlighter import BLOCK_META, BLOCK_TITLE
|
||||
from novelwriter.editor.hovercard import GuiDocHoverCard
|
||||
from novelwriter.editor.runnables import T_TextCheckPayload, TextCheckDispatcher, WordCounterDispatcher
|
||||
from novelwriter.editor.textblock import T_TextCheckList, TextBlockData
|
||||
from novelwriter.editor.vim import DocumentVimMode
|
||||
from novelwriter.enum import (
|
||||
nwChange,
|
||||
nwComment,
|
||||
@@ -127,23 +128,13 @@ from novelwriter.types import (
|
||||
QtModNone,
|
||||
QtModShift,
|
||||
QtMoveAnchor,
|
||||
QtMoveDown,
|
||||
QtMoveEnd,
|
||||
QtMoveEndOfLine,
|
||||
QtMoveEndOfWord,
|
||||
QtMoveLeft,
|
||||
QtMoveNextChar,
|
||||
QtMoveNextWord,
|
||||
QtMovePreviousWord,
|
||||
QtMoveRight,
|
||||
QtMoveStart,
|
||||
QtMoveStartOfLine,
|
||||
QtMoveUp,
|
||||
QtScrollAlwaysOff,
|
||||
QtScrollAsNeeded,
|
||||
QtSelectBlock,
|
||||
QtSelectDocument,
|
||||
QtSelectLine,
|
||||
QtSelectWord,
|
||||
QtTransparent,
|
||||
QtWidgetShortcut,
|
||||
@@ -246,7 +237,7 @@ class GuiDocEditor(QTextEdit):
|
||||
"_trSpellSuggest",
|
||||
"_trSplitDoc",
|
||||
"_trViewTag",
|
||||
"_vim",
|
||||
"_vimMode",
|
||||
"_vpMargin",
|
||||
"_wheelDelta",
|
||||
"_zoomIn",
|
||||
@@ -472,7 +463,7 @@ class GuiDocEditor(QTextEdit):
|
||||
self.changeFocusState = self.docHeader.changeFocusState
|
||||
|
||||
# Vim State for vimMode
|
||||
self._vim = VimState()
|
||||
self._vimMode = DocumentVimMode(self)
|
||||
|
||||
# Finalise
|
||||
self.updateSyntaxColors()
|
||||
@@ -662,7 +653,7 @@ class GuiDocEditor(QTextEdit):
|
||||
self.clearEditor()
|
||||
|
||||
# Refresh Vim Mode
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
self._vimMode.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
def initViewport(self) -> None:
|
||||
"""Initialise the settings of the editor viewport."""
|
||||
@@ -692,7 +683,7 @@ class GuiDocEditor(QTextEdit):
|
||||
self.docHeader.setHandle(self._docHandle)
|
||||
self.docSearch.setAutoReplaceEnabled(CONFIG.doReplace)
|
||||
if updateVimMode:
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
self._vimMode.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
def loadText(self, tHandle: str, tLine: int | None = None) -> bool:
|
||||
"""Load text from a document into the editor. If we have an I/O
|
||||
@@ -908,18 +899,6 @@ class GuiDocEditor(QTextEdit):
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setVimMode(self, mode: nwVimMode) -> None:
|
||||
"""Change the vim mode."""
|
||||
if CONFIG.vimMode:
|
||||
if mode == nwVimMode.NORMAL:
|
||||
cursor = self.textCursor()
|
||||
cursor.clearSelection()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.setMode(mode)
|
||||
self.docFooter.updateVimModeStatusBar(mode)
|
||||
else:
|
||||
self.docFooter.updateVimModeStatusBar(None)
|
||||
|
||||
def setDocumentChanged(self, state: bool) -> None:
|
||||
"""Keep track of the document changed variable, and emit the
|
||||
document change signal.
|
||||
@@ -1270,21 +1249,13 @@ class GuiDocEditor(QTextEdit):
|
||||
if self.searchVisible():
|
||||
self.closeSearch()
|
||||
elif CONFIG.vimMode:
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
self._vimMode.setVimMode(nwVimMode.NORMAL)
|
||||
elif SHARED.focusMode:
|
||||
SHARED.setFocusMode(False)
|
||||
event.ignore()
|
||||
return
|
||||
|
||||
if CONFIG.vimMode and self._vim.mode != nwVimMode.INSERT:
|
||||
if self._handleVimNormalModeModeSwitching(event):
|
||||
return
|
||||
|
||||
if self._vim.mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
self._handleVimVisualMode(event)
|
||||
else:
|
||||
self._handleVimNormalMode(event)
|
||||
|
||||
if CONFIG.vimMode and self._vimMode.processVimKeyEvent(event):
|
||||
return
|
||||
|
||||
if self.docSearch.anyFocus() and key in ENTER_KEYS:
|
||||
@@ -2745,323 +2716,6 @@ class GuiDocEditor(QTextEdit):
|
||||
# Deferred to avoid re-entrancy, see #2917
|
||||
QTimer.singleShot(0, lambda: self.ensureCursorVisible(centre=False))
|
||||
|
||||
##
|
||||
# Internal Functions : Vim Mode
|
||||
##
|
||||
|
||||
def _handleVimNormalModeModeSwitching(self, event: QKeyEvent) -> bool:
|
||||
"""Handle key events for Vim mode switching in NORMAL mode."""
|
||||
if (text := event.text()) == "i":
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
elif text == "I":
|
||||
cursor = self.textCursor()
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
return True
|
||||
elif text == "v":
|
||||
self.setVimMode(nwVimMode.VISUAL)
|
||||
cursor = self.textCursor()
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor, 1)
|
||||
self.setTextCursor(cursor)
|
||||
return True
|
||||
elif text == "V":
|
||||
self.setVimMode(nwVimMode.V_LINE)
|
||||
cursor = self.textCursor()
|
||||
cursor.select(QtSelectLine)
|
||||
self.setTextCursor(cursor)
|
||||
return True
|
||||
return False # Not a mode switching motion
|
||||
|
||||
def _handleVimNormalMode(self, event: QKeyEvent) -> None:
|
||||
"""Handle key events for Vim mode NORMAL mode motions."""
|
||||
key = event.text()
|
||||
cursor = self.textCursor()
|
||||
# -- NORMAL mode PREFIX
|
||||
if self._vim.mode == nwVimMode.NORMAL:
|
||||
if key in self._vim.PREFIX_KEYS or key in self._vim.SUFFIX_KEYS:
|
||||
self._vim.pushCommandKey(key)
|
||||
else:
|
||||
self._vim.setCommand(key)
|
||||
|
||||
if (command := self._vim.command) == "dd":
|
||||
cursor.beginEditBlock()
|
||||
cursor.select(QtSelectLine)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
cursor.setPosition(cursor.selectionEnd())
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "x":
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor, 1)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "w":
|
||||
cursor.movePosition(QtMoveNextWord)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "b":
|
||||
cursor.movePosition(QtMovePreviousWord)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "e":
|
||||
# Try to move to end of the current word
|
||||
origPos = cursor.position()
|
||||
cursor.movePosition(QtMoveEndOfWord)
|
||||
|
||||
# If we didn't move (we were already at end of a word),
|
||||
# step forward one character (if possible) and then move to the next EndOfWord.
|
||||
if cursor.position() == origPos:
|
||||
textLen = len(self.toPlainText())
|
||||
if origPos < textLen:
|
||||
cursor.movePosition(QtMoveNextChar)
|
||||
cursor.movePosition(QtMoveEndOfWord)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "dw":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveNextWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "db":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMovePreviousWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "de":
|
||||
cursor.beginEditBlock()
|
||||
# Extend selection to end of current/next word
|
||||
origPos = cursor.position()
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
if cursor.position() == origPos: # Already at end-of-word
|
||||
textLen = len(self.toPlainText())
|
||||
if origPos < textLen:
|
||||
cursor.movePosition(QtMoveNextChar, QtKeepAnchor)
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "d$":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveEndOfLine, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "yw":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveNextWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.clearSelection()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "gg":
|
||||
cursor.movePosition(QtMoveStart)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "G":
|
||||
cursor.movePosition(QtMoveEnd)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "yy":
|
||||
cursor.select(QtSelectLine)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.clearSelection()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "p":
|
||||
if text := self._vim.pasteFromInternal():
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
cursor.insertText("\n" + text)
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "P":
|
||||
if text := self._vim.pasteFromInternal():
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
cursor.insertText(text + "\n")
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "o":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
cursor.insertText("\n")
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "O":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
cursor.insertText("\n")
|
||||
cursor.movePosition(QtMoveUp)
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "$":
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "a":
|
||||
cursor.movePosition(QtMoveRight)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "A":
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "u":
|
||||
self.docAction(nwDocAction.UNDO)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "zz":
|
||||
self.setTextCursor(cursor)
|
||||
self.ensureCursorVisible(centre=True)
|
||||
self._vim.resetCommand()
|
||||
|
||||
# Single-step navigation
|
||||
elif command == "h":
|
||||
cursor.movePosition(QtMoveLeft)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "j":
|
||||
cursor.movePosition(QtMoveDown)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "k":
|
||||
cursor.movePosition(QtMoveUp)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "l":
|
||||
cursor.movePosition(QtMoveRight)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
def _handleVimVisualMode(self, event: QKeyEvent) -> None:
|
||||
"""Handle key events for Vim mode VISUAL and VLINE mode motions."""
|
||||
key = event.text()
|
||||
cursor = self.textCursor()
|
||||
|
||||
# -- VISUAL mode PREFIX
|
||||
if key in self._vim.VISUAL_PREFIX_KEYS:
|
||||
self._vim.pushCommandKey(key)
|
||||
else:
|
||||
# If adding none repeating visual mode motions,
|
||||
# need to add a suffix case, see normal mode.
|
||||
self._vim.setCommand(key)
|
||||
|
||||
# --- VISUAL / VISUALLINE mode ---
|
||||
if (command := self._vim.command) in ("d", "x"):
|
||||
cursor.beginEditBlock()
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
elif command == "y":
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.clearSelection()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
elif command == "w":
|
||||
cursor.movePosition(QtMoveNextWord, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "b":
|
||||
cursor.movePosition(QtMovePreviousWord, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "e":
|
||||
origPos = cursor.position()
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
if cursor.position() == origPos:
|
||||
textLen = len(self.toPlainText())
|
||||
if origPos < textLen:
|
||||
cursor.movePosition(QtMoveNextChar, QtKeepAnchor)
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "gg":
|
||||
cursor.movePosition(QtMoveStart, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
# Handle motions (extend selection)
|
||||
elif command == "h":
|
||||
cursor.movePosition(QtMoveLeft, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "l":
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "j":
|
||||
cursor.movePosition(QtMoveDown, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "k":
|
||||
cursor.movePosition(QtMoveUp, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "$":
|
||||
cursor.movePosition(QtMoveEndOfLine, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "0":
|
||||
cursor.movePosition(QtMoveStartOfLine, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "G":
|
||||
cursor.movePosition(QtMoveEnd, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
@@ -3379,67 +3033,3 @@ class GuiDocEditor(QTextEdit):
|
||||
cursor.setPosition(block.position())
|
||||
self.setTextCursor(cursor)
|
||||
break
|
||||
|
||||
|
||||
class VimState:
|
||||
"""Minimal Vim state machine."""
|
||||
|
||||
__slots__ = ("_internalClipboard", "_mode", "_normalCommand", "_visualCommand")
|
||||
|
||||
PREFIX_KEYS = "dygz"
|
||||
VISUAL_PREFIX_KEYS = "g"
|
||||
SUFFIX_KEYS = "web$"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mode: nwVimMode = nwVimMode.NORMAL
|
||||
self._normalCommand = ""
|
||||
self._visualCommand = ""
|
||||
self._internalClipboard = ""
|
||||
self.setMode(nwVimMode.NORMAL)
|
||||
|
||||
@property
|
||||
def mode(self) -> nwVimMode:
|
||||
"""Return current vim mode."""
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def command(self) -> str:
|
||||
"""Return the current vim command."""
|
||||
if self._mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
return self._visualCommand
|
||||
else:
|
||||
return self._normalCommand
|
||||
|
||||
def setMode(self, mode: nwVimMode) -> None:
|
||||
"""Switch vim mode."""
|
||||
if mode != self._mode:
|
||||
logger.debug("Vim Mode changed to %s", mode.name)
|
||||
self._mode = mode
|
||||
self.resetCommand()
|
||||
|
||||
def resetCommand(self) -> None:
|
||||
"""Reset internal vim command."""
|
||||
self._normalCommand = ""
|
||||
self._visualCommand = ""
|
||||
|
||||
def pushCommandKey(self, key: str) -> None:
|
||||
"""Push key to the current command building stack."""
|
||||
if self._mode is nwVimMode.NORMAL:
|
||||
self._normalCommand += key
|
||||
elif self._mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
self._visualCommand += key
|
||||
|
||||
def setCommand(self, key: str) -> None:
|
||||
"""Set the state of the current vim command."""
|
||||
if self._mode is nwVimMode.NORMAL:
|
||||
self._normalCommand = key
|
||||
elif self._mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
self._visualCommand = key
|
||||
|
||||
def yankToInternal(self, text: str) -> None:
|
||||
"""Put text into internal vim buffer."""
|
||||
self._internalClipboard = text
|
||||
|
||||
def pasteFromInternal(self) -> str:
|
||||
"""Paste from the internal vim clipboard."""
|
||||
return self._internalClipboard
|
||||
|
||||
479
novelwriter/editor/vim.py
Normal file
479
novelwriter/editor/vim.py
Normal file
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
novelWriter - GUI Document Vim Mode
|
||||
===================================
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright (C) 2026 Veronica Berglyd Olsen and novelWriter contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but
|
||||
WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
""" # noqa
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from novelwriter import CONFIG
|
||||
from novelwriter.enum import nwDocAction, nwVimMode
|
||||
from novelwriter.types import (
|
||||
QtKeepAnchor,
|
||||
QtMoveDown,
|
||||
QtMoveEnd,
|
||||
QtMoveEndOfLine,
|
||||
QtMoveEndOfWord,
|
||||
QtMoveLeft,
|
||||
QtMoveNextChar,
|
||||
QtMoveNextWord,
|
||||
QtMovePreviousWord,
|
||||
QtMoveRight,
|
||||
QtMoveStart,
|
||||
QtMoveStartOfLine,
|
||||
QtMoveUp,
|
||||
QtSelectLine,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PyQt6.QtGui import QKeyEvent, QTextCursor
|
||||
|
||||
from novelwriter.editor.editor import GuiDocEditor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentVimMode:
|
||||
"""Gui Widget: Editor Vim Mode."""
|
||||
|
||||
__slots__ = ("_editor", "_vim")
|
||||
|
||||
def __init__(self, editor: GuiDocEditor) -> None:
|
||||
|
||||
self._editor = editor
|
||||
self._vim = VimState()
|
||||
|
||||
def setVimMode(self, mode: nwVimMode) -> None:
|
||||
"""Change the vim mode."""
|
||||
if CONFIG.vimMode:
|
||||
if mode == nwVimMode.NORMAL:
|
||||
cursor = self.textCursor()
|
||||
cursor.clearSelection()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.setMode(mode)
|
||||
self._editor.docFooter.updateVimModeStatusBar(mode)
|
||||
else:
|
||||
self._editor.docFooter.updateVimModeStatusBar(None)
|
||||
|
||||
def setTextCursor(self, cursor: QTextCursor) -> None:
|
||||
"""Set the text cursor."""
|
||||
self._editor.setTextCursor(cursor)
|
||||
|
||||
def textCursor(self) -> QTextCursor:
|
||||
"""Return a new text cursor."""
|
||||
return self._editor.textCursor()
|
||||
|
||||
def processVimKeyEvent(self, event: QKeyEvent) -> bool:
|
||||
"""Process a keypress for vim actions."""
|
||||
if self._vim.mode != nwVimMode.INSERT:
|
||||
if self._handleVimNormalModeModeSwitching(event):
|
||||
return True
|
||||
|
||||
if self._vim.mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
self._handleVimVisualMode(event)
|
||||
else:
|
||||
self._handleVimNormalMode(event)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _handleVimNormalModeModeSwitching(self, event: QKeyEvent) -> bool:
|
||||
"""Handle key events for Vim mode switching in NORMAL mode."""
|
||||
if (text := event.text()) == "i":
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
elif text == "I":
|
||||
cursor = self.textCursor()
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
return True
|
||||
elif text == "v":
|
||||
self.setVimMode(nwVimMode.VISUAL)
|
||||
cursor = self.textCursor()
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor, 1)
|
||||
self.setTextCursor(cursor)
|
||||
return True
|
||||
elif text == "V":
|
||||
self.setVimMode(nwVimMode.V_LINE)
|
||||
cursor = self.textCursor()
|
||||
cursor.select(QtSelectLine)
|
||||
self.setTextCursor(cursor)
|
||||
return True
|
||||
return False # Not a mode switching motion
|
||||
|
||||
def _handleVimNormalMode(self, event: QKeyEvent) -> None:
|
||||
"""Handle key events for Vim mode NORMAL mode motions."""
|
||||
key = event.text()
|
||||
cursor = self.textCursor()
|
||||
# -- NORMAL mode PREFIX
|
||||
if self._vim.mode == nwVimMode.NORMAL:
|
||||
if key in self._vim.PREFIX_KEYS or key in self._vim.SUFFIX_KEYS:
|
||||
self._vim.pushCommandKey(key)
|
||||
else:
|
||||
self._vim.setCommand(key)
|
||||
|
||||
if (command := self._vim.command) == "dd":
|
||||
cursor.beginEditBlock()
|
||||
cursor.select(QtSelectLine)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
cursor.setPosition(cursor.selectionEnd())
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "x":
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor, 1)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "w":
|
||||
cursor.movePosition(QtMoveNextWord)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "b":
|
||||
cursor.movePosition(QtMovePreviousWord)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "e":
|
||||
# Try to move to end of the current word
|
||||
origPos = cursor.position()
|
||||
cursor.movePosition(QtMoveEndOfWord)
|
||||
|
||||
# If we didn't move (we were already at end of a word),
|
||||
# step forward one character (if possible) and then move to the next EndOfWord.
|
||||
if cursor.position() == origPos:
|
||||
textLen = len(self._editor.toPlainText())
|
||||
if origPos < textLen:
|
||||
cursor.movePosition(QtMoveNextChar)
|
||||
cursor.movePosition(QtMoveEndOfWord)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "dw":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveNextWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "db":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMovePreviousWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "de":
|
||||
cursor.beginEditBlock()
|
||||
# Extend selection to end of current/next word
|
||||
origPos = cursor.position()
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
if cursor.position() == origPos: # Already at end-of-word
|
||||
textLen = len(self._editor.toPlainText())
|
||||
if origPos < textLen:
|
||||
cursor.movePosition(QtMoveNextChar, QtKeepAnchor)
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "d$":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveEndOfLine, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "yw":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveNextWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.clearSelection()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "gg":
|
||||
cursor.movePosition(QtMoveStart)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "G":
|
||||
cursor.movePosition(QtMoveEnd)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "yy":
|
||||
cursor.select(QtSelectLine)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.clearSelection()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "p":
|
||||
if text := self._vim.pasteFromInternal():
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
cursor.insertText("\n" + text)
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "P":
|
||||
if text := self._vim.pasteFromInternal():
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
cursor.insertText(text + "\n")
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "o":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
cursor.insertText("\n")
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "O":
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
cursor.insertText("\n")
|
||||
cursor.movePosition(QtMoveUp)
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "$":
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "a":
|
||||
cursor.movePosition(QtMoveRight)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "A":
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "u":
|
||||
self._editor.docAction(nwDocAction.UNDO)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "zz":
|
||||
self.setTextCursor(cursor)
|
||||
self._editor.ensureCursorVisible(centre=True)
|
||||
self._vim.resetCommand()
|
||||
|
||||
# Single-step navigation
|
||||
elif command == "h":
|
||||
cursor.movePosition(QtMoveLeft)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "j":
|
||||
cursor.movePosition(QtMoveDown)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "k":
|
||||
cursor.movePosition(QtMoveUp)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "l":
|
||||
cursor.movePosition(QtMoveRight)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
def _handleVimVisualMode(self, event: QKeyEvent) -> None:
|
||||
"""Handle key events for Vim mode VISUAL and VLINE mode motions."""
|
||||
key = event.text()
|
||||
cursor = self.textCursor()
|
||||
|
||||
# -- VISUAL mode PREFIX
|
||||
if key in self._vim.VISUAL_PREFIX_KEYS:
|
||||
self._vim.pushCommandKey(key)
|
||||
else:
|
||||
# If adding none repeating visual mode motions,
|
||||
# need to add a suffix case, see normal mode.
|
||||
self._vim.setCommand(key)
|
||||
|
||||
# --- VISUAL / VISUALLINE mode ---
|
||||
if (command := self._vim.command) in ("d", "x"):
|
||||
cursor.beginEditBlock()
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
elif command == "y":
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.clearSelection()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
elif command == "w":
|
||||
cursor.movePosition(QtMoveNextWord, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "b":
|
||||
cursor.movePosition(QtMovePreviousWord, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "e":
|
||||
origPos = cursor.position()
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
if cursor.position() == origPos:
|
||||
textLen = len(self._editor.toPlainText())
|
||||
if origPos < textLen:
|
||||
cursor.movePosition(QtMoveNextChar, QtKeepAnchor)
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "gg":
|
||||
cursor.movePosition(QtMoveStart, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
# Handle motions (extend selection)
|
||||
elif command == "h":
|
||||
cursor.movePosition(QtMoveLeft, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "l":
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "j":
|
||||
cursor.movePosition(QtMoveDown, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "k":
|
||||
cursor.movePosition(QtMoveUp, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "$":
|
||||
cursor.movePosition(QtMoveEndOfLine, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "0":
|
||||
cursor.movePosition(QtMoveStartOfLine, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
elif command == "G":
|
||||
cursor.movePosition(QtMoveEnd, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
|
||||
class VimState:
|
||||
"""Minimal Vim state machine."""
|
||||
|
||||
__slots__ = ("_internalClipboard", "_mode", "_normalCommand", "_visualCommand")
|
||||
|
||||
PREFIX_KEYS = "dygz"
|
||||
VISUAL_PREFIX_KEYS = "g"
|
||||
SUFFIX_KEYS = "web$"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mode: nwVimMode = nwVimMode.NORMAL
|
||||
self._normalCommand = ""
|
||||
self._visualCommand = ""
|
||||
self._internalClipboard = ""
|
||||
self.setMode(nwVimMode.NORMAL)
|
||||
|
||||
@property
|
||||
def mode(self) -> nwVimMode:
|
||||
"""Return current vim mode."""
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def command(self) -> str:
|
||||
"""Return the current vim command."""
|
||||
if self._mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
return self._visualCommand
|
||||
else:
|
||||
return self._normalCommand
|
||||
|
||||
def setMode(self, mode: nwVimMode) -> None:
|
||||
"""Switch vim mode."""
|
||||
if mode != self._mode:
|
||||
logger.debug("Vim Mode changed to %s", mode.name)
|
||||
self._mode = mode
|
||||
self.resetCommand()
|
||||
|
||||
def resetCommand(self) -> None:
|
||||
"""Reset internal vim command."""
|
||||
self._normalCommand = ""
|
||||
self._visualCommand = ""
|
||||
|
||||
def pushCommandKey(self, key: str) -> None:
|
||||
"""Push key to the current command building stack."""
|
||||
if self._mode is nwVimMode.NORMAL:
|
||||
self._normalCommand += key
|
||||
elif self._mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
self._visualCommand += key
|
||||
|
||||
def setCommand(self, key: str) -> None:
|
||||
"""Set the state of the current vim command."""
|
||||
if self._mode is nwVimMode.NORMAL:
|
||||
self._normalCommand = key
|
||||
elif self._mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
self._visualCommand = key
|
||||
|
||||
def yankToInternal(self, text: str) -> None:
|
||||
"""Put text into internal vim buffer."""
|
||||
self._internalClipboard = text
|
||||
|
||||
def pasteFromInternal(self) -> str:
|
||||
"""Paste from the internal vim clipboard."""
|
||||
return self._internalClipboard
|
||||
@@ -17,6 +17,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Programming Language :: Python :: 3.15",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Operating System :: OS Independent",
|
||||
|
||||
Reference in New Issue
Block a user