Update vim mode (#3133)
This commit is contained in:
@@ -11,6 +11,11 @@ shortcuts. Most of them are also listed in the application's user interface.
|
||||
|
||||
On MacOS, replace :kbd:`Ctrl` with :kbd:`Cmd`.
|
||||
|
||||
.. note::
|
||||
|
||||
Shortcuts marked with (*) are remapped when Vim Mode is enabled and no longer work as listed in
|
||||
the tables below. See :ref:`docs_features_vim_mode` for more details.
|
||||
|
||||
|
||||
Main Window Shortcuts
|
||||
=====================
|
||||
@@ -53,7 +58,7 @@ Project Tree Shortcuts
|
||||
":kbd:`Ctrl+L`", "Open the **Quick Links** menu"
|
||||
":kbd:`Ctrl+N`", "Open the **Create New Item** menu"
|
||||
":kbd:`Ctrl+O`", "Open the selected document in the editor"
|
||||
":kbd:`Ctrl+R`", "Open the selected document in the viewer"
|
||||
":kbd:`Ctrl+R`", "Open the selected document in the viewer (*)"
|
||||
":kbd:`Ctrl+Up`", "Move selected item one step up in the tree"
|
||||
":kbd:`Ctrl+Down`", "Move selected item one step down in the tree"
|
||||
":kbd:`Ctrl+Shift+Del`", "Move the selected item to Trash"
|
||||
@@ -116,7 +121,7 @@ Other Editor Shortcuts
|
||||
":kbd:`Ctrl+A`", "Select all text in the document"
|
||||
":kbd:`Ctrl+C`", "Copy selected text to clipboard"
|
||||
":kbd:`Ctrl+K`", "Activate the insert commands (see list in :ref:`docs_features_shortcuts_insert`)"
|
||||
":kbd:`Ctrl+R`", "Open or reload the current document in the viewer"
|
||||
":kbd:`Ctrl+R`", "Open or reload the current document in the viewer (*)"
|
||||
":kbd:`Ctrl+S`", "Save the current document"
|
||||
":kbd:`Ctrl+V`", "Paste text from clipboard to cursor position"
|
||||
":kbd:`Ctrl+W`", "Close the current document"
|
||||
@@ -205,4 +210,4 @@ Document Viewer Shortcuts
|
||||
":kbd:`Ctrl+-`", "Zoom out"
|
||||
":kbd:`Ctrl+_`", "Zoom out"
|
||||
":kbd:`Ctrl+Shift+A`", "Select all text in the current paragraph"
|
||||
":kbd:`Ctrl+Shift+R`", "Close the document viewer"
|
||||
":kbd:`Ctrl+Shift+R`", "Close the document viewer (*)"
|
||||
|
||||
@@ -39,13 +39,17 @@ To switch between the various vim modes, these keystrokes implemented:
|
||||
"Normal", "Insert", ":kbd:`i`, :kbd:`I`, :kbd:`a`, :kbd:`A`, :kbd:`o`, :kbd:`O`"
|
||||
"Insert", "Normal", ":kbd:`Esc`"
|
||||
"Normal", "Visual", ":kbd:`v`, :kbd:`V`"
|
||||
"Visual", "V-Line", ":kbd:`V`"
|
||||
"V-Line", "Visual", ":kbd:`v`"
|
||||
"Visual", "Normal", ":kbd:`Esc`, :kbd:`v`"
|
||||
"V-Line", "Normal", ":kbd:`Esc`, :kbd:`V`"
|
||||
|
||||
You can exit visual mode back to normal mode by pressing :kbd:`Esc`, but all visual mode commands
|
||||
that logically "terminate" the visual mode usage will return you to normal mode.
|
||||
|
||||
For instance, press :kbd:`V` to enter V-Line mode, select next word with :kbd:`w`. press :kbd:`y`
|
||||
to yank (copy), you are now automatically put back in normal mode as you have completed selecting
|
||||
your text.
|
||||
For instance, press :kbd:`V` to enter V-Line mode, extend the selection to the next line with
|
||||
:kbd:`j`, and press :kbd:`y` to yank (copy). You are now automatically put back in normal mode as
|
||||
you have completed selecting your text.
|
||||
|
||||
|
||||
Implemented Keystrokes (Vim Motions)
|
||||
@@ -56,50 +60,57 @@ The table below shows the vim motions currently implemented in novelWriter.
|
||||
.. csv-table::
|
||||
:header: "Motion", "Mode(s)", "Description"
|
||||
|
||||
":kbd:`i`", "Normal → Insert", "Enter insert mode"
|
||||
":kbd:`I`", "Normal → Insert", "Jump to first non-blank of line and enter insert"
|
||||
":kbd:`v`", "Normal → Visual", "Enter character-wise visual mode"
|
||||
":kbd:`V`", "Normal → V-Line", "Enter line-wise visual mode"
|
||||
":kbd:`dd`", "Normal", "Delete (cut) current line"
|
||||
":kbd:`x`", "Normal", "Delete character under cursor"
|
||||
":kbd:`w`", "Normal / Visual", "Move to next word start"
|
||||
":kbd:`b`", "Normal / Visual", "Move to previous word start"
|
||||
":kbd:`e`", "Normal / Visual", "Move to next word end"
|
||||
":kbd:`dw`", "Normal", "Delete from cursor to next word start"
|
||||
":kbd:`de`", "Normal", "Delete from cursor to next word end"
|
||||
":kbd:`db`", "Normal", "Delete from cursor to previous word start"
|
||||
":kbd:`d$`", "Normal", "Delete from cursor to end of line"
|
||||
":kbd:`yw`", "Normal", "Yank (copy) from cursor to next word start"
|
||||
":kbd:`gg`", "Normal / Visual", "Jump to first line"
|
||||
":kbd:`G`", "Normal / Visual", "Jump to last line"
|
||||
":kbd:`yy`", "Normal", "Yank current line"
|
||||
":kbd:`p`", "Normal", "Paste after current line"
|
||||
":kbd:`P`", "Normal", "Paste before current line"
|
||||
":kbd:`o`", "Normal → Insert", "Open new line below and enter insert"
|
||||
":kbd:`O`", "Normal → Insert", "Open new line above and enter insert"
|
||||
":kbd:`$`", "Normal / Visual", "Move to end of line"
|
||||
":kbd:`a`", "Normal → Insert", "Enter insert after cursor"
|
||||
":kbd:`A`", "Normal → Insert", "Enter insert at end of line"
|
||||
":kbd:`u`", "Normal", "Undo last change"
|
||||
":kbd:`zz`", "Normal", "Centre cursor vertically"
|
||||
":kbd:`h`", "Normal / Visual", "Move left"
|
||||
":kbd:`j`", "Normal / Visual", "Move down"
|
||||
":kbd:`k`", "Normal / Visual", "Move up"
|
||||
":kbd:`l`", "Normal / Visual", "Move right"
|
||||
":kbd:`d`", "Visual / V-Line", "Delete selection"
|
||||
":kbd:`x`", "Visual / V-Line", "Delete selection"
|
||||
":kbd:`y`", "Visual / V-Line", "Yank selection"
|
||||
":kbd:`0`", "Visual", "Move to start of line (extend selection)"
|
||||
":kbd:`i`", "Normal → Insert", "Enter insert mode"
|
||||
":kbd:`I`", "Normal → Insert", "Jump to start of line and enter insert"
|
||||
":kbd:`v`", "Normal → Visual", "Enter character-wise visual mode"
|
||||
":kbd:`V`", "Normal → V-Line", "Enter line-wise visual mode"
|
||||
":kbd:`dd`", "Normal", "Delete (cut) current line"
|
||||
":kbd:`x`", "Normal", "Delete character under cursor"
|
||||
":kbd:`w`", "Normal / Visual", "Move to next word start"
|
||||
":kbd:`b`", "Normal / Visual", "Move to previous word start"
|
||||
":kbd:`e`", "Normal / Visual", "Move to next word end"
|
||||
":kbd:`dw`", "Normal", "Delete from cursor to next word start"
|
||||
":kbd:`de`", "Normal", "Delete from cursor to next word end"
|
||||
":kbd:`db`", "Normal", "Delete from cursor to previous word start"
|
||||
":kbd:`d$`", "Normal", "Delete from cursor to end of line"
|
||||
":kbd:`yw`", "Normal", "Yank (copy) from cursor to next word start"
|
||||
":kbd:`gg`", "Normal / Visual", "Jump to first line"
|
||||
":kbd:`G`", "Normal / Visual", "Jump to last line"
|
||||
":kbd:`yy`", "Normal", "Yank current line"
|
||||
":kbd:`p`", "Normal", "Paste after current line or cursor"
|
||||
":kbd:`P`", "Normal", "Paste before current line or at cursor"
|
||||
":kbd:`o`", "Normal → Insert", "Open new line below and enter insert"
|
||||
":kbd:`O`", "Normal → Insert", "Open new line above and enter insert"
|
||||
":kbd:`$`", "Normal / Visual", "Move to end of line"
|
||||
":kbd:`0`", "Normal / Visual", "Move to start of line"
|
||||
":kbd:`a`", "Normal → Insert", "Enter insert after cursor"
|
||||
":kbd:`A`", "Normal → Insert", "Enter insert at end of line"
|
||||
":kbd:`u`", "Normal", "Undo last change"
|
||||
":kbd:`Ctrl+R`", "Normal", "Redo last undone change (*)"
|
||||
":kbd:`zz`", "Normal", "Centre cursor vertically"
|
||||
":kbd:`h`", "Normal / Visual", "Move left"
|
||||
":kbd:`j`", "Normal / Visual", "Move down"
|
||||
":kbd:`k`", "Normal / Visual", "Move up"
|
||||
":kbd:`l`", "Normal / Visual", "Move right"
|
||||
":kbd:`d`", "Visual / V-Line", "Delete selection"
|
||||
":kbd:`x`", "Visual / V-Line", "Delete selection"
|
||||
":kbd:`y`", "Visual / V-Line", "Yank selection"
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
Shortcuts marked with (*) are remapped when Vim Mode is enabled. These are shortcuts that
|
||||
usually do other things when Vim Mode is off.
|
||||
|
||||
|
||||
Known Issues
|
||||
============
|
||||
|
||||
* Currently, :kbd:`dd` on an empty line will not delete, but using :kbd:`x` will.
|
||||
* :kbd:`vypyy` will have :kbd:`ypyy` in command memory and thus not register :kbd:`yy`. Expected
|
||||
behavior would be visual mode, yank, paste, yank line.
|
||||
* Differing behavior from vim: the :kbd:`e` command behaves a bit differently with regards to the
|
||||
last character of a word. The behavior is inconsistent with vim but functional and still logical
|
||||
* Differing behaviour from vim: :kbd:`j` and :kbd:`k` move by displayed line when a line is
|
||||
wrapped, like :kbd:`gj` and :kbd:`gk` in vim. All other line commands act on the full line.
|
||||
* :kbd:`Esc` in normal mode will leave Focus Mode if there is no pending command to cancel.
|
||||
* Differing behaviour from vim: the :kbd:`e` command behaves a bit differently with regards to the
|
||||
last character of a word. The behaviour is inconsistent with vim but functional and still logical
|
||||
to use. The cursor is placed at the end of the word after the last character rather than on the
|
||||
last character.
|
||||
|
||||
@@ -110,6 +121,6 @@ Known Issues
|
||||
test
|
||||
^ Cursor placed here in novelWriter vim mode
|
||||
|
||||
You will only really ever notice this behavior if you try to combine the :kbd:`e` command with
|
||||
You will only really ever notice this behaviour if you try to combine the :kbd:`e` command with
|
||||
another. For instance, :kbd:`de` will not delete the last character but delete forward as it
|
||||
starts one character after the word boundary.
|
||||
|
||||
@@ -90,6 +90,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 EditorVimMode
|
||||
from novelwriter.enum import (
|
||||
nwChange,
|
||||
nwComment,
|
||||
@@ -128,23 +129,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,
|
||||
@@ -247,7 +238,7 @@ class GuiDocEditor(QTextEdit):
|
||||
"_trSpellSuggest",
|
||||
"_trSplitDoc",
|
||||
"_trViewTag",
|
||||
"_vim",
|
||||
"_vimMode",
|
||||
"_vpMargin",
|
||||
"_wheelDelta",
|
||||
"_zoomIn",
|
||||
@@ -472,8 +463,8 @@ class GuiDocEditor(QTextEdit):
|
||||
self.searchVisible = self.docSearch.isVisible
|
||||
self.changeFocusState = self.docHeader.changeFocusState
|
||||
|
||||
# Vim State for vimMode
|
||||
self._vim = VimState()
|
||||
# Vim Mode
|
||||
self._vimMode: EditorVimMode | None = None
|
||||
|
||||
# Finalise
|
||||
self.updateSyntaxColors()
|
||||
@@ -662,8 +653,7 @@ class GuiDocEditor(QTextEdit):
|
||||
else:
|
||||
self.clearEditor()
|
||||
|
||||
# Refresh Vim Mode
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
self._initVimMode()
|
||||
|
||||
def initViewport(self) -> None:
|
||||
"""Initialise the settings of the editor viewport."""
|
||||
@@ -693,7 +683,7 @@ class GuiDocEditor(QTextEdit):
|
||||
self.docHeader.setHandle(self._docHandle)
|
||||
self.docSearch.setAutoReplaceEnabled(CONFIG.doReplace)
|
||||
if updateVimMode:
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
self._initVimMode()
|
||||
|
||||
def loadText(self, tHandle: str, tLine: int | None = None) -> bool:
|
||||
"""Load text from a document into the editor. If we have an I/O
|
||||
@@ -753,6 +743,8 @@ class GuiDocEditor(QTextEdit):
|
||||
self.setDocumentChanged(False)
|
||||
self._qDocument.clearUndoRedoStacks()
|
||||
self.docToolBar.setVisible(CONFIG.showEditToolBar)
|
||||
if self._vimMode:
|
||||
self._vimMode.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
# Process State Changes
|
||||
SHARED.project.data.setLastHandle(tHandle, "editor")
|
||||
@@ -909,18 +901,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,22 +1250,14 @@ class GuiDocEditor(QTextEdit):
|
||||
if key == QtKeyEscape:
|
||||
if self.searchVisible():
|
||||
self.closeSearch()
|
||||
elif CONFIG.vimMode:
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
elif self._vimMode and self._vimMode.cancel():
|
||||
pass # Vim mode consumed the key
|
||||
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 self._vimMode and self._vimMode.processVimKeyEvent(event):
|
||||
return
|
||||
|
||||
if self.docSearch.anyFocus() and key in ENTER_KEYS:
|
||||
@@ -2746,323 +2718,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
|
||||
##
|
||||
@@ -3079,6 +2734,16 @@ class GuiDocEditor(QTextEdit):
|
||||
options.setFlags(options.flags() | QTextOption.Flag.ShowLineAndParagraphSeparators)
|
||||
self._qDocument.setDefaultTextOption(options)
|
||||
|
||||
def _initVimMode(self) -> None:
|
||||
"""Enable or disable vim mode from the user settings."""
|
||||
if CONFIG.vimMode:
|
||||
if self._vimMode is None:
|
||||
self._vimMode = EditorVimMode(self, self.docFooter.updateVimModeStatusBar)
|
||||
self._vimMode.setVimMode(nwVimMode.NORMAL)
|
||||
else:
|
||||
self._vimMode = None
|
||||
self.docFooter.updateVimModeStatusBar(None)
|
||||
|
||||
def _dispatchKeyPress(self, event: QKeyEvent) -> None:
|
||||
"""Send a key event to the base class, except plain Return.
|
||||
|
||||
@@ -3380,67 +3045,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
|
||||
|
||||
556
novelwriter/editor/vim.py
Normal file
556
novelwriter/editor/vim.py
Normal file
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
novelWriter - GUI Editor Vim Mode
|
||||
=================================
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright (C) 2025 Alexis Dumelie, 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 PyQt6.QtCore import Qt
|
||||
|
||||
from novelwriter.enum import nwDocAction, nwVimMode
|
||||
from novelwriter.types import (
|
||||
QtKeepAnchor,
|
||||
QtKeyDown,
|
||||
QtKeyEnd,
|
||||
QtKeyHome,
|
||||
QtKeyLeft,
|
||||
QtKeyPageDown,
|
||||
QtKeyPageUp,
|
||||
QtKeyRight,
|
||||
QtKeyUp,
|
||||
QtModCtrl,
|
||||
QtModShift,
|
||||
QtMoveDown,
|
||||
QtMoveEnd,
|
||||
QtMoveEndOfBlock,
|
||||
QtMoveEndOfWord,
|
||||
QtMoveLeft,
|
||||
QtMoveNextChar,
|
||||
QtMoveNextWord,
|
||||
QtMovePreviousWord,
|
||||
QtMoveRight,
|
||||
QtMoveStart,
|
||||
QtMoveStartOfBlock,
|
||||
QtMoveUp,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from PyQt6.QtGui import QKeyEvent, QTextCursor
|
||||
|
||||
from novelwriter.editor.editor import GuiDocEditor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NAV_KEYS = (QtKeyLeft, QtKeyRight, QtKeyUp, QtKeyDown, QtKeyHome, QtKeyEnd, QtKeyPageUp, QtKeyPageDown)
|
||||
|
||||
|
||||
class EditorVimMode:
|
||||
"""Editor Component: Vim Mode."""
|
||||
|
||||
__slots__ = ("_callback", "_editor", "_vim")
|
||||
|
||||
def __init__(self, editor: GuiDocEditor, callback: Callable[[nwVimMode], None]) -> None:
|
||||
|
||||
self._editor = editor
|
||||
self._vim = VimState()
|
||||
self._callback = callback
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
|
||||
def setVimMode(self, mode: nwVimMode) -> None:
|
||||
"""Change the vim mode."""
|
||||
if mode == nwVimMode.NORMAL:
|
||||
cursor = self.textCursor()
|
||||
cursor.clearSelection()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.setMode(mode)
|
||||
self._callback(mode)
|
||||
|
||||
def setTextCursor(self, cursor: QTextCursor) -> None:
|
||||
"""Set the text cursor."""
|
||||
self._editor.setTextCursor(cursor)
|
||||
|
||||
##
|
||||
# Methods
|
||||
##
|
||||
|
||||
def textCursor(self) -> QTextCursor:
|
||||
"""Return a new text cursor."""
|
||||
return self._editor.textCursor()
|
||||
|
||||
def cancel(self) -> bool:
|
||||
"""Cancel the current mode or pending command, if any."""
|
||||
if self._vim.mode != nwVimMode.NORMAL:
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
return True
|
||||
elif self._vim.command:
|
||||
self._vim.resetCommand()
|
||||
return True
|
||||
return False
|
||||
|
||||
def processVimKeyEvent(self, event: QKeyEvent) -> bool:
|
||||
"""Process a keypress for vim actions."""
|
||||
if self._vim.mode == nwVimMode.INSERT or self._editor.isReadOnly():
|
||||
return False
|
||||
elif self._vim.mode == nwVimMode.NORMAL and event.key() in NAV_KEYS and not event.modifiers() & QtModShift:
|
||||
self._vim.resetCommand()
|
||||
return False
|
||||
elif self._vim.mode in (nwVimMode.VISUAL, nwVimMode.V_LINE):
|
||||
self._handleVimVisualMode(event)
|
||||
elif self._handleVimNormalModeModeSwitching(event):
|
||||
return True
|
||||
else:
|
||||
self._handleVimNormalMode(event)
|
||||
return True
|
||||
|
||||
##
|
||||
# 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)
|
||||
return True
|
||||
elif text == "I":
|
||||
cursor = self.textCursor()
|
||||
cursor.movePosition(QtMoveStartOfBlock)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
return True
|
||||
elif text == "v":
|
||||
self.setVimMode(nwVimMode.VISUAL)
|
||||
cursor = self.textCursor()
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
return True
|
||||
elif text == "V":
|
||||
self.setVimMode(nwVimMode.V_LINE)
|
||||
cursor = self.textCursor()
|
||||
self._selectBlocks(cursor)
|
||||
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()
|
||||
if event.key() == Qt.Key.Key_R and event.modifiers() == QtModCtrl:
|
||||
key = "<C-r>"
|
||||
|
||||
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":
|
||||
self._selectBlocks(cursor)
|
||||
self._deleteBlocks(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "x":
|
||||
if not cursor.atBlockEnd():
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor)
|
||||
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:
|
||||
cursor.movePosition(QtMoveNextChar)
|
||||
cursor.movePosition(QtMoveEndOfWord)
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "dw":
|
||||
blockEnd = cursor.block().position() + cursor.block().length() - 1
|
||||
cursor.movePosition(QtMoveNextWord, QtKeepAnchor)
|
||||
if cursor.position() > blockEnd:
|
||||
cursor.setPosition(blockEnd, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "db":
|
||||
cursor.movePosition(QtMovePreviousWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "de":
|
||||
# Extend selection to end of current/next word
|
||||
origPos = cursor.position()
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
if cursor.position() == origPos: # Already at end-of-word
|
||||
cursor.movePosition(QtMoveNextChar, QtKeepAnchor)
|
||||
cursor.movePosition(QtMoveEndOfWord, QtKeepAnchor)
|
||||
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "d$":
|
||||
cursor.movePosition(QtMoveEndOfBlock, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "yw":
|
||||
cursor.movePosition(QtMoveNextWord, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.clearSelection()
|
||||
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":
|
||||
self._selectBlocks(cursor)
|
||||
self._vim.yankToInternal(cursor.selectedText(), lineWise=True)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "p":
|
||||
if text := self._vim.pasteFromInternal():
|
||||
if self._vim.lineWise:
|
||||
cursor.movePosition(QtMoveEndOfBlock)
|
||||
cursor.insertText("\n" + text)
|
||||
else:
|
||||
if not cursor.atBlockEnd():
|
||||
cursor.movePosition(QtMoveRight)
|
||||
cursor.insertText(text)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "P":
|
||||
if text := self._vim.pasteFromInternal():
|
||||
if self._vim.lineWise:
|
||||
cursor.movePosition(QtMoveStartOfBlock)
|
||||
cursor.insertText(text + "\n")
|
||||
else:
|
||||
cursor.insertText(text)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "o":
|
||||
cursor.movePosition(QtMoveEndOfBlock)
|
||||
cursor.insertText("\n")
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "O":
|
||||
cursor.movePosition(QtMoveStartOfBlock)
|
||||
cursor.insertText("\n")
|
||||
cursor.movePosition(QtMoveUp)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "$":
|
||||
cursor.movePosition(QtMoveEndOfBlock)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "0":
|
||||
cursor.movePosition(QtMoveStartOfBlock)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "a":
|
||||
cursor.movePosition(QtMoveRight)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "A":
|
||||
cursor.movePosition(QtMoveEndOfBlock)
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "u":
|
||||
self._editor.docAction(nwDocAction.UNDO)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "<C-r>":
|
||||
self._editor.docAction(nwDocAction.REDO)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "zz":
|
||||
self._editor.ensureCursorVisible(centre=True)
|
||||
self._vim.resetCommand()
|
||||
|
||||
# Single-step navigation
|
||||
elif command == "h":
|
||||
if not cursor.atBlockStart():
|
||||
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":
|
||||
if not cursor.atBlockEnd():
|
||||
cursor.movePosition(QtMoveRight)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif len(command) > 1 or command not in self._vim.PREFIX_KEYS:
|
||||
# Not a known command or a pending prefix
|
||||
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 non-repeating visual mode motions,
|
||||
# need to add a suffix case, see normal mode.
|
||||
self._vim.setCommand(key)
|
||||
|
||||
# --- VISUAL / VISUALLINE mode ---
|
||||
lineWise = self._vim.mode == nwVimMode.V_LINE
|
||||
if (command := self._vim.command) in ("d", "x"):
|
||||
if lineWise:
|
||||
self._deleteBlocks(cursor)
|
||||
else:
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
elif command == "y":
|
||||
self._vim.yankToInternal(cursor.selectedText(), lineWise=lineWise)
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
|
||||
elif command == "v":
|
||||
if self._vim.mode == nwVimMode.VISUAL:
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
else:
|
||||
self.setVimMode(nwVimMode.VISUAL)
|
||||
|
||||
elif command == "V":
|
||||
if self._vim.mode == nwVimMode.V_LINE:
|
||||
self.setVimMode(nwVimMode.NORMAL)
|
||||
else:
|
||||
self.setVimMode(nwVimMode.V_LINE)
|
||||
|
||||
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:
|
||||
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":
|
||||
if not cursor.atBlockStart():
|
||||
cursor.movePosition(QtMoveLeft, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "l":
|
||||
if not cursor.atBlockEnd():
|
||||
cursor.movePosition(QtMoveRight, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "j":
|
||||
cursor.movePosition(QtMoveDown, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "k":
|
||||
cursor.movePosition(QtMoveUp, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "$":
|
||||
cursor.movePosition(QtMoveEndOfBlock, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "0":
|
||||
cursor.movePosition(QtMoveStartOfBlock, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "G":
|
||||
cursor.movePosition(QtMoveEnd, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif len(command) > 1 or command not in self._vim.VISUAL_PREFIX_KEYS:
|
||||
# Not a known command or a pending prefix
|
||||
self._vim.resetCommand()
|
||||
|
||||
# V-LINE mode always selects whole lines
|
||||
if self._vim.mode == nwVimMode.V_LINE:
|
||||
cursor = self.textCursor()
|
||||
self._selectBlocks(cursor)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
def _selectBlocks(self, cursor: QTextCursor) -> None:
|
||||
"""Extend the selection to whole blocks, keeping its direction."""
|
||||
forward = cursor.anchor() <= cursor.position()
|
||||
position = cursor.position()
|
||||
cursor.setPosition(cursor.anchor())
|
||||
cursor.movePosition(QtMoveStartOfBlock if forward else QtMoveEndOfBlock)
|
||||
cursor.setPosition(position, QtKeepAnchor)
|
||||
cursor.movePosition(QtMoveEndOfBlock if forward else QtMoveStartOfBlock, QtKeepAnchor)
|
||||
|
||||
def _deleteBlocks(self, cursor: QTextCursor) -> None:
|
||||
"""Delete the selected blocks, including the block separator."""
|
||||
cursor.beginEditBlock()
|
||||
self._vim.yankToInternal(cursor.selectedText(), lineWise=True)
|
||||
cursor.removeSelectedText()
|
||||
if cursor.block().next().isValid():
|
||||
cursor.deleteChar()
|
||||
else:
|
||||
cursor.deletePreviousChar()
|
||||
cursor.movePosition(QtMoveStartOfBlock)
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
|
||||
class VimState:
|
||||
"""Minimal Vim state machine."""
|
||||
|
||||
__slots__ = ("_buffer", "_command", "_lineWise", "_mode")
|
||||
|
||||
PREFIX_KEYS = "dygz"
|
||||
VISUAL_PREFIX_KEYS = "g"
|
||||
SUFFIX_KEYS = "web$"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mode: nwVimMode = nwVimMode.NORMAL
|
||||
self._buffer = ""
|
||||
self._lineWise = False
|
||||
self._command = ""
|
||||
|
||||
@property
|
||||
def mode(self) -> nwVimMode:
|
||||
"""Return current vim mode."""
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def command(self) -> str:
|
||||
"""Return the current vim command."""
|
||||
return self._command
|
||||
|
||||
@property
|
||||
def lineWise(self) -> bool:
|
||||
"""Return True if the vim buffer holds whole lines."""
|
||||
return self._lineWise
|
||||
|
||||
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._command = ""
|
||||
|
||||
def pushCommandKey(self, key: str) -> None:
|
||||
"""Push key to the current command building stack."""
|
||||
if self._mode != nwVimMode.INSERT:
|
||||
self._command += key
|
||||
|
||||
def setCommand(self, key: str) -> None:
|
||||
"""Set the state of the current vim command."""
|
||||
if self._mode != nwVimMode.INSERT:
|
||||
self._command = key
|
||||
|
||||
def yankToInternal(self, text: str, lineWise: bool = False) -> None:
|
||||
"""Put text into internal vim buffer."""
|
||||
self._buffer = text
|
||||
self._lineWise = lineWise
|
||||
|
||||
def pasteFromInternal(self) -> str:
|
||||
"""Paste from the internal vim buffer."""
|
||||
return self._buffer
|
||||
@@ -85,10 +85,24 @@ class GuiMainMenu(QMenuBar):
|
||||
self._buildToolsMenu()
|
||||
self._buildHelpMenu()
|
||||
|
||||
self.updateVimModeKeys()
|
||||
self.installEventFilter(StatusTipFilter(mainGui))
|
||||
|
||||
logger.debug("Ready: GuiMainMenu")
|
||||
|
||||
##
|
||||
# Methods
|
||||
##
|
||||
|
||||
def updateVimModeKeys(self) -> None:
|
||||
"""Update shortcuts that depend on vim mode state."""
|
||||
if CONFIG.vimMode:
|
||||
self.aViewDoc.setShortcuts([])
|
||||
self.aCloseView.setShortcuts([])
|
||||
else:
|
||||
self.aViewDoc.setShortcut("Ctrl+R")
|
||||
self.aCloseView.setShortcut("Ctrl+Shift+R")
|
||||
|
||||
##
|
||||
# Public Slots
|
||||
##
|
||||
@@ -263,12 +277,10 @@ class GuiMainMenu(QMenuBar):
|
||||
|
||||
# Document > Preview
|
||||
self.aViewDoc = qtAddAction(self.docuMenu, self.tr("View Document"))
|
||||
self.aViewDoc.setShortcut("Ctrl+R")
|
||||
self.aViewDoc.triggered.connect(qtWeakLambda(self.mainGui.docPanes.viewDocument, None))
|
||||
|
||||
# Document > Close Preview
|
||||
self.aCloseView = qtAddAction(self.docuMenu, self.tr("Close Document View"))
|
||||
self.aCloseView.setShortcut("Ctrl+Shift+R")
|
||||
self.aCloseView.triggered.connect(self.mainGui.docPanes.closeDocViewer)
|
||||
|
||||
# Document > Separator
|
||||
|
||||
@@ -775,6 +775,8 @@ class GuiMain(QMainWindow):
|
||||
|
||||
if update.theme:
|
||||
self.refreshThemeColors(syntax=update.syntax, force=True)
|
||||
if update.vim:
|
||||
self.mainMenu.updateVimModeKeys()
|
||||
self.docPanes.applySettings(update)
|
||||
if update.viewport:
|
||||
self.projView.initViewport()
|
||||
|
||||
@@ -130,6 +130,8 @@ QtKeyUp = Qt.Key.Key_Up
|
||||
QtKeyDown = Qt.Key.Key_Down
|
||||
QtKeyPageUp = Qt.Key.Key_PageUp
|
||||
QtKeyPageDown = Qt.Key.Key_PageDown
|
||||
QtKeyHome = Qt.Key.Key_Home
|
||||
QtKeyEnd = Qt.Key.Key_End
|
||||
QtKeyTab = Qt.Key.Key_Tab
|
||||
QtKeyEscape = Qt.Key.Key_Escape
|
||||
QtKeyBackspace = Qt.Key.Key_Backspace
|
||||
@@ -170,6 +172,8 @@ QtMoveUp = QTextCursor.MoveOperation.Up
|
||||
QtMoveDown = QTextCursor.MoveOperation.Down
|
||||
QtMoveEndOfLine = QTextCursor.MoveOperation.EndOfLine
|
||||
QtMoveStartOfLine = QTextCursor.MoveOperation.StartOfLine
|
||||
QtMoveEndOfBlock = QTextCursor.MoveOperation.EndOfBlock
|
||||
QtMoveStartOfBlock = QTextCursor.MoveOperation.StartOfBlock
|
||||
QtMoveEnd = QTextCursor.MoveOperation.End
|
||||
QtMoveStart = QTextCursor.MoveOperation.Start
|
||||
QtMoveNextWord = QTextCursor.MoveOperation.NextWord
|
||||
|
||||
@@ -62,7 +62,7 @@ from novelwriter.dialogs.editlink import GuiEditLink
|
||||
from novelwriter.editor.editor import GuiDocEditor, _TagAction
|
||||
from novelwriter.editor.editsearch import GuiDocEditSearch
|
||||
from novelwriter.editor.textblock import TextBlockData, formatCheckText
|
||||
from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwState, nwVimMode
|
||||
from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwState
|
||||
from novelwriter.guimain import GuiMain
|
||||
from novelwriter.shared import _GuiAlert
|
||||
from novelwriter.text.counting import standardCounter
|
||||
@@ -72,7 +72,6 @@ from novelwriter.types import (
|
||||
QtImCurrentSelection,
|
||||
QtImCursorRectangle,
|
||||
QtKeepAnchor,
|
||||
QtKeyEscape,
|
||||
QtKeyReturn,
|
||||
QtModCtrl,
|
||||
QtModNone,
|
||||
@@ -4464,586 +4463,6 @@ def testGuiDocEditor_BigFixes(qtbot: QtBot, nwGUI: GuiMain):
|
||||
assert isinstance(cV, QVariant)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiDocEditor_Vim_EnableVimMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test that enabling CONFIG.vimMode activates vim behavior."""
|
||||
inputDelay = 2
|
||||
buildTestProject(NWProject(), projPath)
|
||||
nwGUI.openProject(projPath)
|
||||
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
|
||||
|
||||
docEditor = nwGUI.docEditor
|
||||
CONFIG.vimMode = True
|
||||
|
||||
def resetText():
|
||||
"""Reset test text."""
|
||||
docEditor.setPlainText("Line1\nLine2\nLine3")
|
||||
return docEditor.getText()
|
||||
|
||||
original_text = resetText()
|
||||
|
||||
# Normal mode: hjkl should NOT change text
|
||||
for key in "hjkl":
|
||||
qtbot.keyClick(docEditor, key, delay=inputDelay)
|
||||
assert docEditor.getText() == original_text
|
||||
|
||||
# Enter insert mode with "i"
|
||||
qtbot.keyClick(docEditor, "i", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "TEST", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
|
||||
# Text must have changed now
|
||||
new_text = docEditor.getText()
|
||||
assert new_text != original_text
|
||||
assert "TEST" in new_text
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiDocEditor_Vim_StateInsertModeNoOp(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test that the vim state machine ignores command keys while in
|
||||
INSERT mode, which is a state neither the NORMAL nor VISUAL mode
|
||||
key handlers ever pass through.
|
||||
"""
|
||||
buildTestProject(NWProject(), projPath)
|
||||
nwGUI.openProject(projPath)
|
||||
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
|
||||
docEditor = nwGUI.docEditor
|
||||
|
||||
vim = docEditor._vim
|
||||
vim.setMode(nwVimMode.INSERT)
|
||||
vim.pushCommandKey("d")
|
||||
assert vim.command == ""
|
||||
vim.setCommand("d")
|
||||
assert vim.command == ""
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiDocEditor_Vim_InsertMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim hjkl movements and insert commands (i, I, A)."""
|
||||
inputDelay = 2
|
||||
buildTestProject(NWProject(), projPath)
|
||||
nwGUI.openProject(projPath)
|
||||
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
|
||||
|
||||
docEditor = nwGUI.docEditor
|
||||
CONFIG.vimMode = True
|
||||
|
||||
def resetText():
|
||||
"""Reset test text."""
|
||||
docEditor.setPlainText("Line1\nLine2\nLine3")
|
||||
return docEditor.getText()
|
||||
|
||||
# Insert Mode
|
||||
# ===========
|
||||
|
||||
# i: Insert before cursor
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "i", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "X", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
assert "X" in docEditor.getText()
|
||||
|
||||
# I: Insert at beginning of line
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
qtbot.keyClick(docEditor, "h", delay=inputDelay) # Move forward 1
|
||||
qtbot.keyClick(docEditor, "h", delay=inputDelay) # Move forward 1
|
||||
qtbot.keyClick(docEditor, "I", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "START", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
lines = docEditor.getText().splitlines()
|
||||
assert lines[0].startswith("START")
|
||||
|
||||
# A: Append at end of line
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "A", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "END", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
lines = docEditor.getText().splitlines()
|
||||
assert lines[0].endswith("END")
|
||||
|
||||
# o: Open new line below
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "o", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "below", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
lines = docEditor.getText().splitlines()
|
||||
assert "below" in lines[1] # Inserted after Line1
|
||||
|
||||
# O: Open new line above
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "O", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "above", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
lines = docEditor.getText().splitlines()
|
||||
assert "above" in lines[0] # Inserted before Line1
|
||||
|
||||
# INSERT MODE MOTIONS
|
||||
# ===================
|
||||
|
||||
# i: Insert before cursor
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "i", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "TEST", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
|
||||
assert "TEST" in docEditor.getText()
|
||||
|
||||
# aX: Append after cursor
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "a", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "X", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
|
||||
assert "X" in docEditor.getText()
|
||||
|
||||
# New line below
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "o", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "below", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
|
||||
lines = docEditor.getText().splitlines()
|
||||
assert "below" in lines[1]
|
||||
|
||||
# New line above
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "O", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "above", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
|
||||
lines = docEditor.getText().splitlines()
|
||||
assert "above" in lines[0]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiDocEditor_Vim_DeleteYankPaste(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim delete (dd, x), yank (yy) and paste (p, P) commands."""
|
||||
inputDelay = 2
|
||||
buildTestProject(NWProject(), projPath)
|
||||
nwGUI.openProject(projPath)
|
||||
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
|
||||
|
||||
docEditor = nwGUI.docEditor
|
||||
CONFIG.vimMode = True
|
||||
|
||||
def resetText():
|
||||
"""Reset test text."""
|
||||
docEditor.setPlainText("Line1\nLine2\nLine3")
|
||||
return docEditor.getText()
|
||||
|
||||
# p/P: Pasting with nothing yanked yet does nothing
|
||||
resetText()
|
||||
assert docEditor._vim.pasteFromInternal() == ""
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "p", delay=inputDelay)
|
||||
assert docEditor.getText() == "Line1\nLine2\nLine3"
|
||||
qtbot.keyClicks(docEditor, "P", delay=inputDelay)
|
||||
assert docEditor.getText() == "Line1\nLine2\nLine3"
|
||||
|
||||
# dd: Delete entire line
|
||||
resetText()
|
||||
docEditor.setCursorPosition(docEditor.getText().find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "dd", delay=inputDelay)
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line1", "Line3"]
|
||||
|
||||
# x: Delete single character
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "x", delay=inputDelay)
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines[0] == "ine1" # 'L' deleted
|
||||
|
||||
# p: Paste after current line
|
||||
resetText()
|
||||
line2_pos = docEditor.getText().find("Line2")
|
||||
docEditor.setCursorPosition(line2_pos)
|
||||
qtbot.keyClicks(docEditor, "yy", delay=inputDelay) # Yank Line2
|
||||
qtbot.keyClicks(docEditor, "p", delay=inputDelay) # Paste after Line2
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line1", "Line2", "Line2", "Line3"]
|
||||
|
||||
# P: Paste before current line (using Line3)
|
||||
resetText()
|
||||
line2_pos = docEditor.getText().find("Line2")
|
||||
docEditor.setCursorPosition(line2_pos)
|
||||
line3_pos = docEditor.getText().find("Line3")
|
||||
docEditor.setCursorPosition(line3_pos)
|
||||
qtbot.keyClicks(docEditor, "yy", delay=inputDelay) # Yank Line3
|
||||
docEditor.setCursorPosition(line2_pos)
|
||||
qtbot.keyClicks(docEditor, "P", delay=inputDelay) # Paste before Line2
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line1", "Line3", "Line2", "Line3"]
|
||||
|
||||
# dw: Delete word (from Line1 up to next word boundary)
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0) # Start of Line1
|
||||
qtbot.keyClicks(docEditor, "dw", delay=inputDelay)
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line2", "Line3"]
|
||||
|
||||
# d$: Delete line
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0) # Start of Line1
|
||||
qtbot.keyClicks(docEditor, "d$", delay=inputDelay)
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line2", "Line3"]
|
||||
|
||||
# de: Delete word (from Line1 up to end of word boundary)
|
||||
resetText()
|
||||
line2_pos = docEditor.getText().find("Line2")
|
||||
docEditor.setCursorPosition(line2_pos)
|
||||
qtbot.keyClicks(docEditor, "de", delay=inputDelay)
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line1", "Line3"]
|
||||
|
||||
# de: Delete word already at end of word
|
||||
resetText()
|
||||
line2_pos = docEditor.getText().find("Line2")
|
||||
end_pos = docEditor.getText().find("Line2") + len("Line2")
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClicks(docEditor, "de", delay=inputDelay)
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line1", "Line2"]
|
||||
|
||||
# de: Already at the very end of the document, nowhere further to delete
|
||||
docEditor.setPlainText("Line1")
|
||||
end_pos = len(docEditor.getText())
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClicks(docEditor, "de", delay=inputDelay)
|
||||
assert docEditor.getText() == "Line1"
|
||||
|
||||
# db: Delete word back (from Line2 up to end of word boundary)
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0) # Start of Line1
|
||||
qtbot.keyClicks(docEditor, "w", delay=inputDelay) # End of line1
|
||||
qtbot.keyClicks(docEditor, "db", delay=inputDelay)
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line2", "Line3"]
|
||||
|
||||
# yw: Yank word (yank Line1)
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0) # Start of Line1
|
||||
qtbot.keyClicks(docEditor, "yw", delay=inputDelay)
|
||||
# Move to start of Line2 and paste
|
||||
docEditor.setCursorPosition(docEditor.getText().find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "p", delay=inputDelay)
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines[0] == "Line1"
|
||||
assert lines[1] == "Line2"
|
||||
assert lines[2] == "Line1"
|
||||
assert lines[3] == "Line3"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiDocEditor_Vim_VisualMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim visual mode selection, yank and paste."""
|
||||
inputDelay = 2
|
||||
buildTestProject(NWProject(), projPath)
|
||||
nwGUI.openProject(projPath)
|
||||
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
|
||||
|
||||
docEditor = nwGUI.docEditor
|
||||
CONFIG.vimMode = True
|
||||
|
||||
def resetText():
|
||||
"""Reset test text."""
|
||||
docEditor.setPlainText("Line1\nLine2\nLine3")
|
||||
return docEditor.getText()
|
||||
|
||||
original_text = resetText()
|
||||
|
||||
# Visual mode with yy
|
||||
resetText()
|
||||
docEditor.setCursorPosition(docEditor.getText().find("Line3") + 1) # Inside Line3
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "j", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "h", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "k", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "l", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "y", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "p", delay=inputDelay)
|
||||
text = docEditor.getText()
|
||||
assert "Line3" in text or text.endswith("n")
|
||||
|
||||
# Line-wise visual mode (V) with yank and paste
|
||||
resetText()
|
||||
line2_pos = docEditor.getText().find("Line2")
|
||||
docEditor.setCursorPosition(line2_pos)
|
||||
qtbot.keyClick(docEditor, "V", delay=inputDelay) # Line-wise visual mode
|
||||
qtbot.keyClick(docEditor, "j", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "h", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "k", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "l", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "y", delay=inputDelay) # Yank Line2
|
||||
qtbot.keyClick(docEditor, "j", delay=inputDelay) # Move to Line3
|
||||
qtbot.keyClick(docEditor, "p", delay=inputDelay) # Paste Line2 after Line3
|
||||
|
||||
lines = docEditor.getText().splitlines()
|
||||
assert lines == ["Line1", "Line2", "Line3", "Line2"]
|
||||
|
||||
# Visual mode with w motion
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0) # start of Line1
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
|
||||
qtbot.keyClicks(docEditor, "w", delay=inputDelay) # Move by word
|
||||
cursor_pos = docEditor.textCursor().position()
|
||||
expected_pos = docEditor.getText().find("Line1") + len("Line1")
|
||||
assert cursor_pos == expected_pos
|
||||
|
||||
# 0: Move to start of line
|
||||
resetText()
|
||||
line2_pos = docEditor.getText().find("Line2") + 3 # Middle of Line2
|
||||
docEditor.setCursorPosition(line2_pos)
|
||||
qtbot.keyClicks(docEditor, "0", delay=inputDelay)
|
||||
cursor_pos = docEditor.textCursor().position()
|
||||
|
||||
# Start of Line2
|
||||
expected_pos = docEditor.getText().find("Line2")
|
||||
assert cursor_pos == expected_pos
|
||||
|
||||
# No-op command 'a': nothing happens
|
||||
resetText()
|
||||
line3_pos = docEditor.getText().find("Line3") + 2
|
||||
docEditor.setCursorPosition(line3_pos)
|
||||
text_before = docEditor.getText()
|
||||
cursor_before = docEditor.textCursor().position()
|
||||
qtbot.keyClicks(docEditor, "a", delay=inputDelay)
|
||||
text_after = docEditor.getText()
|
||||
cursor_after = docEditor.textCursor().position()
|
||||
|
||||
# Nothing should have changed
|
||||
assert text_after == text_before
|
||||
assert cursor_after == cursor_before
|
||||
|
||||
resetText()
|
||||
start_pos = docEditor.getText().find("Line2") + 2 # Inside "Line2"
|
||||
docEditor.setCursorPosition(start_pos)
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
|
||||
qtbot.keyClicks(docEditor, "b", delay=inputDelay)
|
||||
selected = docEditor.textCursor().selectedText()
|
||||
|
||||
# Should have selected from inside "Line2" back to its start
|
||||
assert selected == "Li"
|
||||
|
||||
resetText()
|
||||
start_pos = docEditor.getText().find("Line1") # Start of "Line1"
|
||||
docEditor.setCursorPosition(start_pos)
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
|
||||
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
|
||||
selected = docEditor.textCursor().selectedText()
|
||||
# Should have selected "Line1"
|
||||
assert selected == "Line1"
|
||||
|
||||
docEditor.setPlainText("Line1 lineExtra Line2 Line3")
|
||||
end_pos = docEditor.getText().find("Line1") + len("Line1") - 1
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
|
||||
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
|
||||
selected = docEditor.textCursor().selectedText().strip()
|
||||
# Should have selected whitespace + "lineExtra"
|
||||
assert "lineExtra" in selected
|
||||
|
||||
# At the very end of the document, there's nowhere further to extend to
|
||||
docEditor.setPlainText("Line1")
|
||||
end_pos = len(docEditor.getText())
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
|
||||
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
|
||||
assert docEditor.textCursor().position() == end_pos
|
||||
|
||||
resetText()
|
||||
# Visual select all with ggVG
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay) # Go to start
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
|
||||
qtbot.keyClick(docEditor, "G", delay=inputDelay) # Extend to end of file
|
||||
|
||||
# Delete selection
|
||||
qtbot.keyClick(docEditor, "d", delay=inputDelay)
|
||||
assert docEditor.getText().strip() == "" # Everything deleted
|
||||
|
||||
# Undo
|
||||
qtbot.keyClick(docEditor, "u", delay=inputDelay)
|
||||
restored_text = docEditor.getText()
|
||||
assert restored_text == original_text
|
||||
|
||||
# Move back to start
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
|
||||
cursor_pos = docEditor.textCursor().position()
|
||||
assert restored_text[cursor_pos] == "L" # First char restored
|
||||
|
||||
# Visual select all with Gvgg
|
||||
qtbot.keyClick(docEditor, "G", delay=inputDelay) # End of file
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay) # Extend select to start
|
||||
|
||||
# Delete selection
|
||||
qtbot.keyClick(docEditor, "d", delay=inputDelay)
|
||||
assert docEditor.getText().strip() == "" # Everything deleted
|
||||
|
||||
# Undo
|
||||
qtbot.keyClick(docEditor, "u", delay=inputDelay)
|
||||
restored_text = docEditor.getText()
|
||||
assert restored_text == original_text
|
||||
|
||||
# Move back to start
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
|
||||
cursor_pos = docEditor.textCursor().position()
|
||||
assert restored_text[cursor_pos] == "L" # First char restored
|
||||
|
||||
# VISUAL MODE MOTIONS
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual
|
||||
qtbot.keyClick(docEditor, "l", delay=inputDelay) # Extend selection
|
||||
qtbot.keyClick(docEditor, "y", delay=inputDelay) # Yank selection
|
||||
qtbot.keyClick(docEditor, "p", delay=inputDelay) # Paste it
|
||||
assert "Li" in docEditor.getText()
|
||||
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "$", delay=inputDelay) # Extend to EOL
|
||||
qtbot.keyClick(docEditor, "d", delay=inputDelay) # Delete
|
||||
line0 = docEditor.getText().splitlines()[0]
|
||||
assert line0.strip() == "" # first line emptied
|
||||
|
||||
# VISUAL LINE MODE MOTIONS
|
||||
resetText()
|
||||
line2_pos = docEditor.getText().find("Line2")
|
||||
docEditor.setCursorPosition(line2_pos)
|
||||
qtbot.keyClick(docEditor, "V", delay=inputDelay) # Vline mode
|
||||
qtbot.keyClick(docEditor, "j", delay=inputDelay) # Extend
|
||||
qtbot.keyClick(docEditor, "y", delay=inputDelay) # Yank
|
||||
qtbot.keyClick(docEditor, "p", delay=inputDelay) # Paste
|
||||
lines = docEditor.getText().splitlines()
|
||||
assert lines.count("Line2") > 1
|
||||
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "V", delay=inputDelay) # Vline select
|
||||
qtbot.keyClick(docEditor, "G", delay=inputDelay) # Extend to EOF
|
||||
qtbot.keyClick(docEditor, "d", delay=inputDelay) # Delete all
|
||||
assert docEditor.getText().strip() == ""
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiDocEditor_Vim_NormalMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim NORMAL mode commands."""
|
||||
inputDelay = 2
|
||||
buildTestProject(NWProject(), projPath)
|
||||
nwGUI.openProject(projPath)
|
||||
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
|
||||
|
||||
docEditor = nwGUI.docEditor
|
||||
CONFIG.vimMode = True
|
||||
|
||||
def resetText():
|
||||
"""Reset test text."""
|
||||
docEditor.setPlainText("Line1\nLine2\nLine3")
|
||||
return docEditor.getText()
|
||||
|
||||
originalText = resetText()
|
||||
|
||||
# NORMAL MODE: hjkl should NOT modify text
|
||||
for key in "hjkl":
|
||||
qtbot.keyClick(docEditor, key)
|
||||
assert docEditor.getText() == originalText
|
||||
|
||||
# b: Move to beginning of word
|
||||
resetText()
|
||||
start_pos = docEditor.getText().find("Line2") + 2 # Inside the word
|
||||
docEditor.setCursorPosition(start_pos)
|
||||
qtbot.keyClicks(docEditor, "b", delay=inputDelay)
|
||||
cursorPos = docEditor.textCursor().position()
|
||||
expectedPos = docEditor.getText().find("Line2") # Beginning of Line2
|
||||
assert cursorPos == expectedPos
|
||||
|
||||
# e: Move to end of current word
|
||||
resetText()
|
||||
start_pos = docEditor.getText().find("Line1") # Start of Line1
|
||||
docEditor.setCursorPosition(start_pos)
|
||||
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
|
||||
cursorPos = docEditor.textCursor().position()
|
||||
expectedPos = docEditor.getText().find("Line1") + len("Line1")
|
||||
assert cursorPos == expectedPos
|
||||
|
||||
# e: Move to end of next word when already at end
|
||||
docEditor.setPlainText("Line1 lineExtra Line2 Line3")
|
||||
end_pos = docEditor.getText().find("Line1") + len("Line1")
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
|
||||
cursorPos = docEditor.textCursor().position()
|
||||
expectedPos = docEditor.getText().find("lineExtra") + len("lineExtra")
|
||||
assert cursorPos == expectedPos
|
||||
|
||||
# e: At the very end of the document, there's nowhere further to move
|
||||
docEditor.setPlainText("Line1")
|
||||
end_pos = len(docEditor.getText())
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
|
||||
assert docEditor.textCursor().position() == end_pos
|
||||
|
||||
# $: Move to end of first line
|
||||
docEditor.setCursorPosition(0) # Start of Line1
|
||||
qtbot.keyClick(docEditor, "$", delay=inputDelay)
|
||||
cursorPos = docEditor.textCursor().position()
|
||||
text = docEditor.getText().splitlines()[0]
|
||||
assert cursorPos == len(text) # Cursor at end of first line
|
||||
|
||||
# a: Move right and enter insert mode
|
||||
docEditor.setCursorPosition(0) # Start of Line1 again
|
||||
qtbot.keyClick(docEditor, "a", delay=inputDelay)
|
||||
# Cursor should have moved one character right
|
||||
cursorPos = docEditor.textCursor().position()
|
||||
assert cursorPos == 1
|
||||
# Insert some text
|
||||
qtbot.keyClicks(docEditor, "TEST", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=inputDelay)
|
||||
newText = docEditor.getText()
|
||||
assert newText.startswith(("LTESSTine1", "LTESTine1"))
|
||||
|
||||
# w: Move forward by word
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0) # Start of "Line1"
|
||||
qtbot.keyClicks(docEditor, "w", delay=inputDelay)
|
||||
cursorPos = docEditor.textCursor().position()
|
||||
# Cursor should now be at end of "Line1"
|
||||
expectedPos = docEditor.getText().find("Line1") + len("Line1")
|
||||
assert cursorPos == expectedPos
|
||||
|
||||
# NORMAL MODE MOTIONS
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "l", delay=inputDelay) # Move right
|
||||
assert docEditor.textCursor().position() == 1
|
||||
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "0", delay=inputDelay) # Beginning of line
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0) # Start of Line1
|
||||
qtbot.keyClick(docEditor, "h", delay=inputDelay)
|
||||
|
||||
# Move otherwise "$" is somehow not captured always
|
||||
qtbot.keyClick(docEditor, "l", delay=inputDelay)
|
||||
|
||||
qtbot.keyClick(docEditor, "$", delay=inputDelay) # End of line
|
||||
cursorPos = docEditor.textCursor().position()
|
||||
assert cursorPos == len("Line1")
|
||||
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "g", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "g", delay=inputDelay) # Top of buffer
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
qtbot.keyClick(docEditor, "z", delay=inputDelay)
|
||||
qtbot.keyClick(docEditor, "z", delay=inputDelay) # Center view
|
||||
|
||||
resetText()
|
||||
qtbot.keyClick(docEditor, "G", delay=inputDelay) # Bottom of buffer
|
||||
cursorPos = docEditor.textCursor().position()
|
||||
assert cursorPos == len(docEditor.getText())
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiDocEditor_BigDocLifecycle(
|
||||
qtbot: QtBot,
|
||||
|
||||
747
tests/editor/test_vim.py
Normal file
747
tests/editor/test_vim.py
Normal file
@@ -0,0 +1,747 @@
|
||||
"""
|
||||
novelWriter - Editor Vim Mode Tests
|
||||
===================================
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright (C) 2025 Alexis Dumelie, 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
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.dialogs.preferences import GuiNeedsUpdate
|
||||
from novelwriter.editor.editor import GuiDocEditor
|
||||
from novelwriter.editor.vim import VimState
|
||||
from novelwriter.enum import nwVimMode
|
||||
from novelwriter.guimain import GuiMain
|
||||
from novelwriter.types import QtKeyEscape, QtKeyRight, QtModCtrl, QtModShift
|
||||
|
||||
from tests.helpers import C, MockRnd, buildTestProject
|
||||
|
||||
KEY_DELAY = 2
|
||||
VIM_CHANGED = GuiNeedsUpdate(
|
||||
restart=False,
|
||||
tree=False,
|
||||
theme=False,
|
||||
syntax=False,
|
||||
spelling=False,
|
||||
vim=True,
|
||||
editor=False,
|
||||
viewer=False,
|
||||
viewport=False,
|
||||
)
|
||||
|
||||
|
||||
def openVimEditor(nwGUI: GuiMain, projPath: Path) -> GuiDocEditor:
|
||||
"""Open the scene document in the editor with vim mode enabled."""
|
||||
buildTestProject(NWProject(), projPath)
|
||||
nwGUI.openProject(projPath)
|
||||
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
|
||||
CONFIG.vimMode = True
|
||||
nwGUI._processConfigChanges(VIM_CHANGED)
|
||||
return nwGUI.docEditor
|
||||
|
||||
|
||||
def resetText(docEditor: GuiDocEditor) -> str:
|
||||
"""Reset the editor to the standard test text."""
|
||||
docEditor.setPlainText("Line1\nLine2\nLine3")
|
||||
return docEditor.getText()
|
||||
|
||||
|
||||
def getLines(docEditor: GuiDocEditor) -> list[str]:
|
||||
"""Return the non-empty lines of the editor text."""
|
||||
return list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
|
||||
|
||||
def testVimState_InsertModeNoOp():
|
||||
"""Test that the vim state ignores command keys in INSERT mode."""
|
||||
vim = VimState()
|
||||
vim.setMode(nwVimMode.INSERT)
|
||||
vim.pushCommandKey("d")
|
||||
assert vim.command == ""
|
||||
vim.setCommand("d")
|
||||
assert vim.command == ""
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testEditorVimMode_Init(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test enabling, refreshing and disabling vim mode in the editor."""
|
||||
buildTestProject(NWProject(), projPath)
|
||||
nwGUI.openProject(projPath)
|
||||
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
|
||||
docEditor = nwGUI.docEditor
|
||||
docFooter = docEditor.docFooter
|
||||
mainMenu = nwGUI.mainMenu
|
||||
|
||||
# Disabled by default
|
||||
assert docEditor._vimMode is None
|
||||
assert docFooter.vimStatus.isHidden() is True
|
||||
assert mainMenu.aViewDoc.shortcut().toString() == "Ctrl+R"
|
||||
assert mainMenu.aCloseView.shortcut().toString() == "Ctrl+Shift+R"
|
||||
|
||||
# Enable from settings, which also releases conflicting shortcuts
|
||||
CONFIG.vimMode = True
|
||||
nwGUI._processConfigChanges(VIM_CHANGED)
|
||||
vimMode = docEditor._vimMode
|
||||
assert vimMode is not None
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
assert docFooter._vimMode == nwVimMode.NORMAL
|
||||
assert docFooter.vimStatus.isHidden() is False
|
||||
assert mainMenu.aViewDoc.shortcut().isEmpty() is True
|
||||
assert mainMenu.aCloseView.shortcut().isEmpty() is True
|
||||
|
||||
# Mode changes are reported to the footer
|
||||
qtbot.keyClick(docEditor, "i", delay=KEY_DELAY)
|
||||
assert vimMode._vim.mode == nwVimMode.INSERT
|
||||
assert docFooter._vimMode == nwVimMode.INSERT
|
||||
|
||||
# Escape returns to NORMAL mode first, then cancels a pending
|
||||
# command, and then leaves Focus Mode
|
||||
nwGUI.toggleFocusMode()
|
||||
assert SHARED.focusMode is True
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
assert docFooter._vimMode == nwVimMode.NORMAL
|
||||
assert SHARED.focusMode is True
|
||||
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
assert vimMode._vim.command == ""
|
||||
assert SHARED.focusMode is True
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
assert SHARED.focusMode is False
|
||||
|
||||
# Commands are ignored when the editor is read-only
|
||||
text = docEditor.getText()
|
||||
docEditor.setReadOnly(True)
|
||||
qtbot.keyClicks(docEditor, "xV", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == text
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
docEditor.setReadOnly(False)
|
||||
|
||||
# Unrelated settings changes don't reset the mode
|
||||
vimMode.setVimMode(nwVimMode.INSERT)
|
||||
docEditor.initSettings()
|
||||
assert vimMode._vim.mode == nwVimMode.INSERT
|
||||
|
||||
# Re-initialising the editor keeps the instance, but resets the mode
|
||||
vimMode._vim.yankToInternal("Text")
|
||||
docEditor.initEditor()
|
||||
assert docEditor._vimMode is vimMode
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
assert vimMode._vim.pasteFromInternal() == "Text"
|
||||
|
||||
# Loading a document resets the mode
|
||||
vimMode.setVimMode(nwVimMode.INSERT)
|
||||
assert nwGUI.docPanes.openDocument(C.hChapterDoc)
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
|
||||
# Disable from settings, which also restores the shortcuts
|
||||
CONFIG.vimMode = False
|
||||
nwGUI._processConfigChanges(VIM_CHANGED)
|
||||
assert docEditor._vimMode is None
|
||||
assert docFooter._vimMode is None
|
||||
assert docFooter.vimStatus.isHidden() is True
|
||||
assert mainMenu.aViewDoc.shortcut().toString() == "Ctrl+R"
|
||||
assert mainMenu.aCloseView.shortcut().toString() == "Ctrl+Shift+R"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testEditorVimMode_InsertMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim commands entering INSERT mode."""
|
||||
docEditor = openVimEditor(nwGUI, projPath)
|
||||
|
||||
# NORMAL mode doesn't insert text
|
||||
originalText = resetText(docEditor)
|
||||
for key in "hjkl":
|
||||
qtbot.keyClick(docEditor, key, delay=KEY_DELAY)
|
||||
assert docEditor.getText() == originalText
|
||||
|
||||
# i: Insert before cursor
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "i", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "X", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
assert "X" in docEditor.getText()
|
||||
|
||||
# I: Insert at beginning of line
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "I", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "START", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
assert docEditor.getText().splitlines()[0].startswith("START")
|
||||
|
||||
# a: Append after cursor
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "a", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "X", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
assert "X" in docEditor.getText()
|
||||
|
||||
# A: Append at end of line
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "A", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "END", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
assert docEditor.getText().splitlines()[0].endswith("END")
|
||||
|
||||
# o: Open new line below
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "o", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "below", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
assert "below" in docEditor.getText().splitlines()[1]
|
||||
|
||||
# O: Open new line above
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "O", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "above", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
assert "above" in docEditor.getText().splitlines()[0]
|
||||
|
||||
# A/I: Append and insert at the ends of a wrapped paragraph
|
||||
para = " ".join(["word"] * 500)
|
||||
docEditor.setPlainText(para)
|
||||
docEditor.setCursorPosition(5)
|
||||
qtbot.keyClick(docEditor, "A", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "X", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
qtbot.keyClick(docEditor, "I", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "Y", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape)
|
||||
assert docEditor.getText() == f"Y{para}X"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testEditorVimMode_NormalMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim NORMAL mode motions."""
|
||||
docEditor = openVimEditor(nwGUI, projPath)
|
||||
|
||||
# b: Move to beginning of word
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2") + 2)
|
||||
qtbot.keyClicks(docEditor, "b", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == text.find("Line2")
|
||||
|
||||
# e: Move to end of current word
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len("Line1")
|
||||
|
||||
# e: Move to end of next word when already at end
|
||||
docEditor.setPlainText("Line1 lineExtra Line2 Line3")
|
||||
text = docEditor.getText()
|
||||
docEditor.setCursorPosition(len("Line1"))
|
||||
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == text.find("lineExtra") + len("lineExtra")
|
||||
|
||||
# e: At the end of the document, there's nowhere further to move
|
||||
docEditor.setPlainText("Line1")
|
||||
docEditor.setCursorPosition(5)
|
||||
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 5
|
||||
|
||||
# $: Move to end of line
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClick(docEditor, "$", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len("Line1")
|
||||
|
||||
# a: Move right and enter INSERT mode
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClick(docEditor, "a", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 1
|
||||
qtbot.keyClicks(docEditor, "TEST", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
assert docEditor.getText().startswith(("LTESSTine1", "LTESTine1"))
|
||||
|
||||
# w: Move forward by word
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "w", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len("Line1")
|
||||
|
||||
# l: Move right
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 1
|
||||
|
||||
# h: Move left
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(2)
|
||||
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 1
|
||||
|
||||
# h/l: Don't move past the start or end of the line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == text.find("Line2")
|
||||
docEditor.setCursorPosition(len("Line1"))
|
||||
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len("Line1")
|
||||
|
||||
# Navigation keys move the cursor and cancel a pending command
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, QtKeyRight, delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 1
|
||||
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == text
|
||||
|
||||
# Navigation keys with Shift are ignored
|
||||
qtbot.keyClick(docEditor, QtKeyRight, QtModShift, delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 1
|
||||
assert docEditor.textCursor().hasSelection() is False
|
||||
|
||||
# 0: Move to beginning of line
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(3)
|
||||
qtbot.keyClick(docEditor, "0", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
# $: Move to end of line after a motion
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY) # Otherwise "$" is not always captured
|
||||
qtbot.keyClick(docEditor, "$", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len("Line1")
|
||||
|
||||
# gg: Move to start of document
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "g", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "g", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
# zz: Centre view
|
||||
qtbot.keyClick(docEditor, "z", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "z", delay=KEY_DELAY)
|
||||
|
||||
# G: Move to end of document
|
||||
text = resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len(text)
|
||||
|
||||
# $/0: Move to the end and start of a wrapped paragraph
|
||||
para = " ".join(["word"] * 500)
|
||||
docEditor.setPlainText(para)
|
||||
docEditor.setCursorPosition(5)
|
||||
qtbot.keyClick(docEditor, "$", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len(para)
|
||||
qtbot.keyClick(docEditor, "0", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
# Unknown keys are discarded
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "qw", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len("Line1")
|
||||
|
||||
# Unknown prefixed commands are discarded
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "dgdd", delay=KEY_DELAY)
|
||||
assert "Line1" not in docEditor.getText()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testEditorVimMode_Delete(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim delete commands."""
|
||||
docEditor = openVimEditor(nwGUI, projPath)
|
||||
vimMode = docEditor._vimMode
|
||||
assert vimMode is not None
|
||||
|
||||
# dd: Delete line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "dd", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line1\nLine3"
|
||||
|
||||
# dd: Delete last line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line3"))
|
||||
qtbot.keyClicks(docEditor, "dd", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line1\nLine2"
|
||||
assert docEditor.textCursor().position() == text.find("Line2")
|
||||
|
||||
# dd: Delete a wrapped paragraph
|
||||
text = " ".join(["word"] * 500)
|
||||
docEditor.setPlainText(f"{text}\nLine2")
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "dd", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line2"
|
||||
assert vimMode._vim.pasteFromInternal() == text
|
||||
|
||||
# d$: Delete to end of a wrapped paragraph
|
||||
docEditor.setPlainText(f"{text}\nLine2")
|
||||
docEditor.setCursorPosition(5)
|
||||
qtbot.keyClicks(docEditor, "d$", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "word \nLine2"
|
||||
|
||||
# x: Delete character
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "x", delay=KEY_DELAY)
|
||||
assert getLines(docEditor)[0] == "ine1"
|
||||
|
||||
# x: Don't delete the line break at the end of the line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(len("Line1"))
|
||||
qtbot.keyClicks(docEditor, "x", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == text
|
||||
|
||||
# dw: Delete word
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "dw", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "\nLine2\nLine3"
|
||||
|
||||
# dw: Don't delete the line break at the end of the line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(len("Line1"))
|
||||
qtbot.keyClicks(docEditor, "dw", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == text
|
||||
|
||||
# d$: Delete to end of line
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "d$", delay=KEY_DELAY)
|
||||
assert getLines(docEditor) == ["Line2", "Line3"]
|
||||
|
||||
# de: Delete to end of word
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "de", delay=KEY_DELAY)
|
||||
assert getLines(docEditor) == ["Line1", "Line3"]
|
||||
|
||||
# de: Delete next word when already at end of word
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2") + len("Line2"))
|
||||
qtbot.keyClicks(docEditor, "de", delay=KEY_DELAY)
|
||||
assert getLines(docEditor) == ["Line1", "Line2"]
|
||||
|
||||
# de: At the end of the document, there's nothing to delete
|
||||
docEditor.setPlainText("Line1")
|
||||
docEditor.setCursorPosition(5)
|
||||
qtbot.keyClicks(docEditor, "de", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line1"
|
||||
|
||||
# db: Delete word backwards
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "w", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "db", delay=KEY_DELAY)
|
||||
assert getLines(docEditor) == ["Line2", "Line3"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testEditorVimMode_YankPaste(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim yank and paste commands."""
|
||||
docEditor = openVimEditor(nwGUI, projPath)
|
||||
vimMode = docEditor._vimMode
|
||||
assert vimMode is not None
|
||||
|
||||
# p/P: Pasting with nothing yanked does nothing
|
||||
resetText(docEditor)
|
||||
assert vimMode._vim.pasteFromInternal() == ""
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "p", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line1\nLine2\nLine3"
|
||||
qtbot.keyClicks(docEditor, "P", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line1\nLine2\nLine3"
|
||||
|
||||
# yy + p: Paste after current line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "yy", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "p", delay=KEY_DELAY)
|
||||
assert getLines(docEditor) == ["Line1", "Line2", "Line2", "Line3"]
|
||||
|
||||
# yy + P: Paste before current line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line3"))
|
||||
qtbot.keyClicks(docEditor, "yy", delay=KEY_DELAY)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "P", delay=KEY_DELAY)
|
||||
assert getLines(docEditor) == ["Line1", "Line3", "Line2", "Line3"]
|
||||
|
||||
# yy: Yank a wrapped paragraph without moving the cursor
|
||||
para = " ".join(["word"] * 500)
|
||||
docEditor.setPlainText(f"{para}\nLine2")
|
||||
docEditor.setCursorPosition(5)
|
||||
qtbot.keyClicks(docEditor, "yy", delay=KEY_DELAY)
|
||||
assert vimMode._vim.pasteFromInternal() == para
|
||||
assert vimMode._vim.lineWise is True
|
||||
assert docEditor.textCursor().position() == 5
|
||||
|
||||
# yw + p: Paste word after the cursor
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "yw", delay=KEY_DELAY)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "p", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line1\nLLine1ine2\nLine3"
|
||||
|
||||
# x + p: Paste character at the end of a line
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "x", delay=KEY_DELAY)
|
||||
docEditor.setCursorPosition(len("ine1"))
|
||||
qtbot.keyClicks(docEditor, "p", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "ine1L\nLine2\nLine3"
|
||||
|
||||
# x + P: Paste character before the cursor
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "xP", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line1\nLine2\nLine3"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testEditorVimMode_VisualMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim VISUAL and V-LINE mode selection, yank and paste."""
|
||||
docEditor = openVimEditor(nwGUI, projPath)
|
||||
vimMode = docEditor._vimMode
|
||||
assert vimMode is not None
|
||||
originalText = resetText(docEditor)
|
||||
|
||||
# v: Select, yank and paste
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line3") + 1)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "k", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "y", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "p", delay=KEY_DELAY)
|
||||
text = docEditor.getText()
|
||||
assert "Line3" in text or text.endswith("n")
|
||||
|
||||
# V: Select line, yank and paste
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClick(docEditor, "V", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "h", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "k", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "y", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "p", delay=KEY_DELAY)
|
||||
assert docEditor.getText().splitlines() == ["Line1", "Line2", "Line3", "Line2"]
|
||||
|
||||
# w: Extend selection by word
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "w", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len("Line1")
|
||||
|
||||
# 0: Extend selection to start of line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2") + 3)
|
||||
qtbot.keyClicks(docEditor, "0", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == text.find("Line2")
|
||||
|
||||
# a: Not a visual command, so nothing happens
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line3") + 2)
|
||||
position = docEditor.textCursor().position()
|
||||
qtbot.keyClicks(docEditor, "a", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == text
|
||||
assert docEditor.textCursor().position() == position
|
||||
|
||||
# b: Extend selection to start of word
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2") + 2)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "b", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().selectedText() == "Li"
|
||||
|
||||
# e: Extend selection to end of word
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().selectedText() == "Line1"
|
||||
|
||||
# e: Extend selection to end of next word
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
docEditor.setPlainText("Line1 lineExtra Line2 Line3")
|
||||
docEditor.setCursorPosition(len("Line1") - 1)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
|
||||
assert "lineExtra" in docEditor.textCursor().selectedText()
|
||||
|
||||
# e: At the end of the document, there's nowhere further to extend
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
docEditor.setPlainText("Line1")
|
||||
docEditor.setCursorPosition(5)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "e", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 5
|
||||
|
||||
# ggvG: Select all, delete and undo
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
resetText(docEditor)
|
||||
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
|
||||
assert docEditor.getText().strip() == ""
|
||||
qtbot.keyClick(docEditor, "u", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == originalText
|
||||
|
||||
# Ctrl+R: Redo and undo again
|
||||
qtbot.keyClick(docEditor, Qt.Key.Key_R, QtModCtrl, delay=KEY_DELAY)
|
||||
assert docEditor.getText().strip() == ""
|
||||
qtbot.keyClick(docEditor, "u", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == originalText
|
||||
|
||||
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
# Gvgg: Select all backwards, delete and undo
|
||||
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
|
||||
assert docEditor.getText().strip() == ""
|
||||
qtbot.keyClick(docEditor, "u", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == originalText
|
||||
|
||||
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "g", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
# vly: Yank selection and paste
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "l", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "y", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "p", delay=KEY_DELAY)
|
||||
assert "Li" in docEditor.getText()
|
||||
|
||||
# v$d: Delete to end of line
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "$", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
|
||||
assert docEditor.getText().splitlines()[0].strip() == ""
|
||||
|
||||
# Vjy: Yank lines and paste
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClick(docEditor, "V", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "y", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "p", delay=KEY_DELAY)
|
||||
assert docEditor.getText().splitlines().count("Line2") > 1
|
||||
|
||||
# VGd: Delete all lines
|
||||
resetText(docEditor)
|
||||
qtbot.keyClick(docEditor, "V", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
|
||||
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
|
||||
assert docEditor.getText().strip() == ""
|
||||
|
||||
# Motions don't leave a pending command
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line3"))
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "jgg", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
# Unknown keys are discarded
|
||||
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "qgg", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
# v: Leave VISUAL mode
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
text = resetText(docEditor)
|
||||
line2 = text.find("Line2")
|
||||
docEditor.setCursorPosition(line2 + 2)
|
||||
qtbot.keyClicks(docEditor, "vv", delay=KEY_DELAY)
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
assert docEditor.textCursor().hasSelection() is False
|
||||
|
||||
# V: Switch from VISUAL to V-LINE mode
|
||||
qtbot.keyClicks(docEditor, "vV", delay=KEY_DELAY)
|
||||
assert vimMode._vim.mode == nwVimMode.V_LINE
|
||||
assert docEditor.textCursor().selectedText() == "Line2"
|
||||
|
||||
# v: Switch from V-LINE to VISUAL mode
|
||||
qtbot.keyClick(docEditor, "v", delay=KEY_DELAY)
|
||||
assert vimMode._vim.mode == nwVimMode.VISUAL
|
||||
assert docEditor.textCursor().selectedText() == "Line2"
|
||||
|
||||
# V: Leave V-LINE mode
|
||||
qtbot.keyClicks(docEditor, "VV", delay=KEY_DELAY)
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
assert docEditor.textCursor().hasSelection() is False
|
||||
|
||||
# V: Keep the direction of a backwards selection
|
||||
docEditor.setCursorPosition(text.find("Line3") + 2)
|
||||
qtbot.keyClicks(docEditor, "vkV", delay=KEY_DELAY)
|
||||
cursor = docEditor.textCursor()
|
||||
assert vimMode._vim.mode == nwVimMode.V_LINE
|
||||
assert cursor.position() == line2
|
||||
assert cursor.anchor() == len(text)
|
||||
|
||||
# i: Not a visual command, so nothing happens
|
||||
qtbot.keyClick(docEditor, "i", delay=KEY_DELAY)
|
||||
assert vimMode._vim.mode == nwVimMode.V_LINE
|
||||
assert docEditor.getText() == text
|
||||
|
||||
# V: Select and delete whole wrapped paragraphs
|
||||
qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY)
|
||||
para = " ".join(["word"] * 500)
|
||||
docEditor.setPlainText(f"{para}\nLine2\nLine3")
|
||||
docEditor.setCursorPosition(5)
|
||||
qtbot.keyClick(docEditor, "V", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().selectedText() == para
|
||||
qtbot.keyClick(docEditor, "j", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len(f"{para}\nLine2")
|
||||
qtbot.keyClick(docEditor, "d", delay=KEY_DELAY)
|
||||
assert docEditor.getText() == "Line3"
|
||||
assert vimMode._vim.lineWise is True
|
||||
|
||||
# h/l: Don't extend past the start or end of the line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "vhh", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == text.find("Line2")
|
||||
qtbot.keyClicks(docEditor, "$l", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == text.find("Line3") - 1
|
||||
@@ -456,7 +456,10 @@ def testGuiMain_ProcessConfigChanges_LightweightSettings(
|
||||
docViewer = nwGUI.docViewer
|
||||
|
||||
CONFIG.vimMode = True
|
||||
docEditor._vim.setMode(nwVimMode.INSERT)
|
||||
docEditor.initSettings(updateVimMode=True)
|
||||
vimMode = docEditor._vimMode
|
||||
assert vimMode is not None
|
||||
vimMode._vim.setMode(nwVimMode.INSERT)
|
||||
|
||||
# Clear the headers so we can tell if they were refreshed
|
||||
docEditor.docHeader._docHandle = None
|
||||
@@ -478,7 +481,7 @@ def testGuiMain_ProcessConfigChanges_LightweightSettings(
|
||||
nwGUI._processConfigChanges(noChange)
|
||||
assert docEditor.docHeader._docHandle == C.hSceneDoc
|
||||
assert docViewer.docHeader._docHandle == C.hSceneDoc
|
||||
assert docEditor._vim.mode == nwVimMode.INSERT
|
||||
assert vimMode._vim.mode == nwVimMode.INSERT
|
||||
|
||||
# A save where the Vim setting changed must reset the mode to Normal
|
||||
vimChanged = GuiNeedsUpdate(
|
||||
@@ -493,11 +496,11 @@ def testGuiMain_ProcessConfigChanges_LightweightSettings(
|
||||
viewport=False,
|
||||
)
|
||||
nwGUI._processConfigChanges(vimChanged)
|
||||
assert docEditor._vim.mode == nwVimMode.NORMAL
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
|
||||
# A save that fully reinitialises the editor and viewer must not
|
||||
# additionally run the lightweight path
|
||||
docEditor._vim.setMode(nwVimMode.INSERT)
|
||||
vimMode._vim.setMode(nwVimMode.INSERT)
|
||||
fullReinit = GuiNeedsUpdate(
|
||||
restart=False,
|
||||
tree=False,
|
||||
@@ -510,7 +513,7 @@ def testGuiMain_ProcessConfigChanges_LightweightSettings(
|
||||
viewport=False,
|
||||
)
|
||||
nwGUI._processConfigChanges(fullReinit)
|
||||
assert docEditor._vim.mode == nwVimMode.NORMAL
|
||||
assert vimMode._vim.mode == nwVimMode.NORMAL
|
||||
|
||||
# A viewport-only change must reinitialise the viewport on both the
|
||||
# editor and viewer even though neither is otherwise reinitialised
|
||||
|
||||
Reference in New Issue
Block a user