diff --git a/tests/editor/test_editor.py b/tests/editor/test_editor.py index cabb86b47..c9a4a4c4a 100644 --- a/tests/editor/test_editor.py +++ b/tests/editor/test_editor.py @@ -61,7 +61,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 @@ -71,7 +71,6 @@ from novelwriter.types import ( QtImCurrentSelection, QtImCursorRectangle, QtKeepAnchor, - QtKeyEscape, QtKeyReturn, QtModCtrl, QtModNone, @@ -4463,586 +4462,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, diff --git a/tests/editor/test_vim.py b/tests/editor/test_vim.py new file mode 100644 index 000000000..d8b1a50c6 --- /dev/null +++ b/tests/editor/test_vim.py @@ -0,0 +1,496 @@ +""" +novelWriter - Editor Vim Mode Tests +=================================== + +This file is a part of novelWriter +Copyright (C) 2026 Veronica Berglyd Olsen and novelWriter contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" # noqa + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pytestqt.qtbot import QtBot + +from novelwriter import CONFIG +from novelwriter.core.project import NWProject +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 + +from tests.helpers import C, MockRnd, buildTestProject + +KEY_DELAY = 2 + + +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.docEditor.initSettings(updateVimMode=True) + 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 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 + + # Disabled by default + assert docEditor._vimMode is None + assert docFooter.vimStatus.isHidden() is True + + # Enable from settings + CONFIG.vimMode = True + docEditor.initSettings(updateVimMode=True) + 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 + + # 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 + qtbot.keyClick(docEditor, QtKeyEscape, delay=KEY_DELAY) + assert vimMode._vim.mode == nwVimMode.NORMAL + assert docFooter._vimMode == nwVimMode.NORMAL + + # 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 + CONFIG.vimMode = False + docEditor.initSettings(updateVimMode=True) + assert docEditor._vimMode is None + assert docFooter._vimMode is None + assert docFooter.vimStatus.isHidden() is True + + +@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] + + +@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 + + # 0: Move to beginning of line + resetText(docEditor) + 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) + + +@pytest.mark.gui +def testEditorVimMode_DeleteYankPaste(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd): + """Test vim delete, yank and paste 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"] + + # 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"] + + # dw: Delete word + resetText(docEditor) + docEditor.setCursorPosition(0) + qtbot.keyClicks(docEditor, "dw", delay=KEY_DELAY) + assert getLines() == ["Line2", "Line3"] + + # d$: Delete to end of line + resetText(docEditor) + docEditor.setCursorPosition(0) + qtbot.keyClicks(docEditor, "d$", delay=KEY_DELAY) + assert getLines() == ["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"] + + # 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"] + + # 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() == ["Line2", "Line3"] + + # yw: Yank word + 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"] + + +@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) + 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 + 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 + 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 + 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 + 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 + 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 + + 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() == "" diff --git a/tests/test_guimain.py b/tests/test_guimain.py index d6247ba6b..511531322 100644 --- a/tests/test_guimain.py +++ b/tests/test_guimain.py @@ -455,7 +455,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 @@ -477,7 +480,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( @@ -492,11 +495,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, @@ -509,7 +512,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