Fix some smaller bugs in the vim mode implementation
This commit is contained in:
@@ -1249,7 +1249,7 @@ class GuiDocEditor(QTextEdit):
|
||||
if key == QtKeyEscape:
|
||||
if self.searchVisible():
|
||||
self.closeSearch()
|
||||
elif self._vimMode:
|
||||
elif self._vimMode and self._vimMode.mode != nwVimMode.NORMAL:
|
||||
self._vimMode.setVimMode(nwVimMode.NORMAL)
|
||||
elif SHARED.focusMode:
|
||||
SHARED.setFocusMode(False)
|
||||
|
||||
@@ -30,6 +30,7 @@ from novelwriter.types import (
|
||||
QtKeepAnchor,
|
||||
QtMoveDown,
|
||||
QtMoveEnd,
|
||||
QtMoveEndOfBlock,
|
||||
QtMoveEndOfLine,
|
||||
QtMoveEndOfWord,
|
||||
QtMoveLeft,
|
||||
@@ -38,6 +39,7 @@ from novelwriter.types import (
|
||||
QtMovePreviousWord,
|
||||
QtMoveRight,
|
||||
QtMoveStart,
|
||||
QtMoveStartOfBlock,
|
||||
QtMoveStartOfLine,
|
||||
QtMoveUp,
|
||||
QtSelectLine,
|
||||
@@ -64,6 +66,15 @@ class EditorVimMode:
|
||||
self._vim = VimState()
|
||||
self._callback = callback
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
|
||||
@property
|
||||
def mode(self) -> nwVimMode:
|
||||
"""Return the current vim mode."""
|
||||
return self._vim.mode
|
||||
|
||||
##
|
||||
# Setters
|
||||
##
|
||||
@@ -91,7 +102,7 @@ class EditorVimMode:
|
||||
|
||||
def processVimKeyEvent(self, event: QKeyEvent) -> bool:
|
||||
"""Process a keypress for vim actions."""
|
||||
if self._vim.mode == nwVimMode.INSERT:
|
||||
if self._vim.mode == nwVimMode.INSERT or self._editor.isReadOnly():
|
||||
return False
|
||||
elif self._handleVimNormalModeModeSwitching(event):
|
||||
return True
|
||||
@@ -141,9 +152,16 @@ class EditorVimMode:
|
||||
self._vim.setCommand(key)
|
||||
|
||||
if (command := self._vim.command) == "dd":
|
||||
cursor.select(QtSelectLine)
|
||||
cursor.beginEditBlock()
|
||||
cursor.movePosition(QtMoveStartOfBlock)
|
||||
cursor.movePosition(QtMoveEndOfBlock, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
if cursor.block().next().isValid():
|
||||
cursor.deleteChar()
|
||||
else:
|
||||
cursor.deletePreviousChar()
|
||||
cursor.endEditBlock()
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
@@ -206,7 +224,7 @@ class EditorVimMode:
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "d$":
|
||||
cursor.movePosition(QtMoveEndOfLine, QtKeepAnchor)
|
||||
cursor.movePosition(QtMoveEndOfBlock, QtKeepAnchor)
|
||||
self._vim.yankToInternal(cursor.selectedText())
|
||||
cursor.removeSelectedText()
|
||||
self.setTextCursor(cursor)
|
||||
@@ -238,26 +256,26 @@ class EditorVimMode:
|
||||
|
||||
elif command == "p":
|
||||
if text := self._vim.pasteFromInternal():
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
cursor.movePosition(QtMoveEndOfBlock)
|
||||
cursor.insertText("\n" + text)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "P":
|
||||
if text := self._vim.pasteFromInternal():
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
cursor.movePosition(QtMoveStartOfBlock)
|
||||
cursor.insertText(text + "\n")
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "o":
|
||||
cursor.movePosition(QtMoveEndOfLine)
|
||||
cursor.movePosition(QtMoveEndOfBlock)
|
||||
cursor.insertText("\n")
|
||||
self.setTextCursor(cursor)
|
||||
self.setVimMode(nwVimMode.INSERT)
|
||||
|
||||
elif command == "O":
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
cursor.movePosition(QtMoveStartOfBlock)
|
||||
cursor.insertText("\n")
|
||||
cursor.movePosition(QtMoveUp)
|
||||
self.setTextCursor(cursor)
|
||||
@@ -268,6 +286,11 @@ class EditorVimMode:
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "0":
|
||||
cursor.movePosition(QtMoveStartOfLine)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "a":
|
||||
cursor.movePosition(QtMoveRight)
|
||||
self.setTextCursor(cursor)
|
||||
@@ -307,6 +330,10 @@ class EditorVimMode:
|
||||
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()
|
||||
@@ -360,30 +387,41 @@ class EditorVimMode:
|
||||
elif command == "h":
|
||||
cursor.movePosition(QtMoveLeft, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "l":
|
||||
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(QtMoveEndOfLine, QtKeepAnchor)
|
||||
self.setTextCursor(cursor)
|
||||
self._vim.resetCommand()
|
||||
|
||||
elif command == "0":
|
||||
cursor.movePosition(QtMoveStartOfLine, 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()
|
||||
|
||||
|
||||
class VimState:
|
||||
|
||||
@@ -169,6 +169,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
|
||||
|
||||
@@ -27,7 +27,7 @@ import pytest
|
||||
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
from novelwriter import CONFIG
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.editor.editor import GuiDocEditor
|
||||
from novelwriter.editor.vim import VimState
|
||||
@@ -56,6 +56,11 @@ def resetText(docEditor: GuiDocEditor) -> str:
|
||||
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()
|
||||
@@ -93,10 +98,23 @@ def testEditorVimMode_Init(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd
|
||||
assert vimMode._vim.mode == nwVimMode.INSERT
|
||||
assert docFooter._vimMode == nwVimMode.INSERT
|
||||
|
||||
# Escape returns to NORMAL mode
|
||||
# Escape returns to NORMAL mode first, 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 vimMode.mode == nwVimMode.NORMAL
|
||||
assert docFooter._vimMode == nwVimMode.NORMAL
|
||||
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.mode == nwVimMode.NORMAL
|
||||
docEditor.setReadOnly(False)
|
||||
|
||||
# Unrelated settings changes don't reset the mode
|
||||
vimMode.setVimMode(nwVimMode.INSERT)
|
||||
@@ -235,6 +253,7 @@ def testEditorVimMode_NormalMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, m
|
||||
|
||||
# 0: Move to beginning of line
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(3)
|
||||
qtbot.keyClick(docEditor, "0", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == 0
|
||||
|
||||
@@ -261,76 +280,81 @@ def testEditorVimMode_NormalMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, m
|
||||
qtbot.keyClick(docEditor, "G", delay=KEY_DELAY)
|
||||
assert docEditor.textCursor().position() == len(text)
|
||||
|
||||
# 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_DeleteYankPaste(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
|
||||
"""Test vim delete, yank and paste commands."""
|
||||
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
|
||||
|
||||
def getLines() -> list[str]:
|
||||
return list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
|
||||
# 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"
|
||||
|
||||
# dd: Delete line
|
||||
text = resetText(docEditor)
|
||||
docEditor.setCursorPosition(text.find("Line2"))
|
||||
qtbot.keyClicks(docEditor, "dd", delay=KEY_DELAY)
|
||||
assert getLines() == ["Line1", "Line3"]
|
||||
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"
|
||||
|
||||
# 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()[0] == "ine1"
|
||||
|
||||
# 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() == ["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() == ["Line1", "Line3", "Line2", "Line3"]
|
||||
assert getLines(docEditor)[0] == "ine1"
|
||||
|
||||
# dw: Delete word
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "dw", delay=KEY_DELAY)
|
||||
assert getLines() == ["Line2", "Line3"]
|
||||
assert getLines(docEditor) == ["Line2", "Line3"]
|
||||
|
||||
# d$: Delete to end of line
|
||||
resetText(docEditor)
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "d$", delay=KEY_DELAY)
|
||||
assert getLines() == ["Line2", "Line3"]
|
||||
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() == ["Line1", "Line3"]
|
||||
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() == ["Line1", "Line2"]
|
||||
assert getLines(docEditor) == ["Line1", "Line2"]
|
||||
|
||||
# de: At the end of the document, there's nothing to delete
|
||||
docEditor.setPlainText("Line1")
|
||||
@@ -343,15 +367,47 @@ def testEditorVimMode_DeleteYankPaste(qtbot: QtBot, nwGUI: GuiMain, projPath: Pa
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "w", delay=KEY_DELAY)
|
||||
qtbot.keyClicks(docEditor, "db", delay=KEY_DELAY)
|
||||
assert getLines() == ["Line2", "Line3"]
|
||||
assert getLines(docEditor) == ["Line2", "Line3"]
|
||||
|
||||
# yw: Yank word
|
||||
|
||||
@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"]
|
||||
|
||||
# yw + p: Yank word and paste
|
||||
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 getLines() == ["Line1", "Line2", "Line1", "Line3"]
|
||||
assert getLines(docEditor) == ["Line1", "Line2", "Line1", "Line3"]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
@@ -494,3 +550,15 @@ def testEditorVimMode_VisualMode(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, m
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user