Update vim mode (#3133)

This commit is contained in:
Veronica Berglyd Olsen
2026-11-04 15:53:00 +01:00
committed by GitHub
10 changed files with 1415 additions and 1055 deletions

View File

@@ -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 (*)"

View File

@@ -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.

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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