diff --git a/.github/workflows/build_win.yml b/.github/workflows/build_win.yml index b9e96e9d..bee832c7 100644 --- a/.github/workflows/build_win.yml +++ b/.github/workflows/build_win.yml @@ -8,7 +8,7 @@ jobs: buildWin64: needs: buildAssets - runs-on: windows-latest + runs-on: windows-2022 steps: - name: Python Setup uses: actions/setup-python@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc17163..d68344bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # novelWriter Changelog +## Version 2.7.5 [2025-09-14] + +### Release Notes + +This is a patch release that fixes an issue related to crashes when using the completer menu under +certain conditions, and improves positioning of the input box for CJK languages. + +### Detailed Changelog + +**Bugfixes** + +* Fixes an issue where the app would crash of deleting the `@` character with the completer menu + visible and the text margins of the editor set to "justified". This is likely crashing due to + some unhandled corner case in the Qt library, but the implementation of the completer menu in + novelWriter uses a small hack to bypass some intended behaviour of the menu. Extra steps have + been added to the implementation that seems to avoid the crash. Issue #2510. PR #2511. +* Fixes an issue where the input box that shows up when typing CJK languages were covering the text + due to an incorrect offset of the box location. The incorrect offset is caused by the text + margins not being taken into account. Fix by @Euophrys based on solution by @Jack-name. + Issues #2267 and #2517. PR #2518. + +---- + ## Version 2.7.4 [2025-07-15] ### Release Notes diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index a7349699..c6773f24 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -39,13 +39,14 @@ from enum import Enum, IntFlag from time import time from PyQt6.QtCore import ( - QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal, - pyqtSlot + QObject, QPoint, QRect, QRegularExpression, QRunnable, Qt, QTimer, + QVariant, pyqtSignal, pyqtSlot ) from PyQt6.QtGui import ( - QAction, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeyEvent, - QKeySequence, QMouseEvent, QPalette, QPixmap, QResizeEvent, QShortcut, - QTextBlock, QTextCursor, QTextDocument, QTextFormat, QTextOption + QAction, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, + QInputMethodEvent, QKeyEvent, QKeySequence, QMouseEvent, QPalette, QPixmap, + QResizeEvent, QShortcut, QTextBlock, QTextCursor, QTextDocument, + QTextFormat, QTextOption ) from PyQt6.QtWidgets import ( QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMenu, @@ -74,9 +75,9 @@ from novelwriter.text.counting import standardCounter from novelwriter.tools.lipsum import GuiLipsum from novelwriter.types import ( QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop, - QtAlignRight, QtKeepAnchor, QtModCtrl, QtModNone, QtModShift, QtMouseLeft, - QtMoveAnchor, QtMoveLeft, QtMoveRight, QtScrollAlwaysOff, QtScrollAsNeeded, - QtTransparent + QtAlignRight, QtImCursorRectangle, QtKeepAnchor, QtModCtrl, QtModNone, + QtModShift, QtMouseLeft, QtMoveAnchor, QtMoveLeft, QtMoveRight, + QtScrollAlwaysOff, QtScrollAsNeeded, QtTransparent ) logger = logging.getLogger(__name__) @@ -154,7 +155,7 @@ class GuiDocEditor(QPlainTextEdit): # Completer self._completer = CommandCompleter(self) - self._completer.complete.connect(self._insertCompletion) + self._completer.insertText.connect(self._insertCompletion) # Create Custom Document self._qDocument = GuiTextDocument(self) @@ -905,7 +906,7 @@ class GuiDocEditor(QPlainTextEdit): return True ## - # Document Events and Maintenance + # Events and Overloads ## def keyPressEvent(self, event: QKeyEvent) -> None: @@ -1003,6 +1004,26 @@ class GuiDocEditor(QPlainTextEdit): self.updateDocMargins() super().resizeEvent(event) + def inputMethodEvent(self, event: QInputMethodEvent) -> None: + """Handle text being input from CJK input methods.""" + super().inputMethodEvent(event) + if event.commitString(): + # See issues #2267 and #2517 + self.ensureCursorVisible() + self._completerToCursor() + + def inputMethodQuery(self, query: Qt.InputMethodQuery) -> QRect | QVariant: + """Adjust completion windows for CJK input methods to consider + the viewport margins. + """ + if query == QtImCursorRectangle: + # See issues #2267 and #2517 + vM = self.viewportMargins() + rect = self.cursorRect() + rect.translate(vM.left(), vM.top()) + return rect + return super().inputMethodQuery(query) + ## # Public Slots ## @@ -1062,24 +1083,20 @@ class GuiDocEditor(QPlainTextEdit): if (block := self._qDocument.findBlock(pos)).isValid(): text = block.text() + if text and text[0] in "@%" and added + removed == 1: # Only run on single character changes, or it will trigger # at unwanted times when other changes are made to the document cursor = self.textCursor() bPos = cursor.positionInBlock() - if bPos > 0 and (viewport := self.viewport()): + if bPos > 0: if text[0] == "@": show = self._completer.updateMetaText(text, bPos) else: show = self._completer.updateCommentText(text, bPos) if show: - point = self.cursorRect().bottomRight() - self._completer.move(viewport.mapToGlobal(point)) self._completer.show() - else: - self._completer.close() - else: - self._completer.close() + self._completerToCursor() if self._doReplace and added == 1: cursor = self.textCursor() @@ -1104,7 +1121,7 @@ class GuiDocEditor(QPlainTextEdit): cursor.setPosition(check, QtMoveAnchor) cursor.setPosition(check + length, QtKeepAnchor) cursor.insertText(text) - self._completer.hide() + self._completer.close() @pyqtSlot() def _openContextFromCursor(self) -> None: @@ -1866,6 +1883,12 @@ class GuiDocEditor(QPlainTextEdit): # Internal Functions ## + def _completerToCursor(self) -> None: + """Make sure the completer menu is positioned by the cursor.""" + if self._completer.isVisible() and (viewport := self.viewport()): + point = self.cursorRect().bottomLeft() + self._completer.move(viewport.mapToGlobal(point)) + def _correctWord(self, cursor: QTextCursor, word: str) -> None: """Slot for the spell check context menu triggering the replacement of a word with the word from the dictionary. @@ -2050,10 +2073,13 @@ class CommandCompleter(QMenu): called on every keystroke on a line starting with @ or %. """ - complete = pyqtSignal(int, int, str) + __slots__ = ("_parent",) + + insertText = pyqtSignal(int, int, str) def __init__(self, parent: QWidget) -> None: super().__init__(parent=parent) + self._parent = parent def updateMetaText(self, text: str, pos: int) -> bool: """Update the menu options based on the line of text.""" @@ -2139,14 +2165,14 @@ class CommandCompleter(QMenu): def keyPressEvent(self, event: QKeyEvent) -> None: """Capture keypresses and forward most of them to the editor.""" - parent = self.parent() if event.key() in ( Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Escape ): super().keyPressEvent(event) - elif isinstance(parent, GuiDocEditor): - parent.keyPressEvent(event) + else: + self.close() # Close to release the event lock before forwarding the key press (#2510) + self._parent.keyPressEvent(event) ## # Internal Functions @@ -2154,7 +2180,7 @@ class CommandCompleter(QMenu): def _emitComplete(self, pos: int, length: int, value: str) -> None: """Emit the signal to indicate a selection has been made.""" - self.complete.emit(pos, length, value) + self.insertText.emit(pos, length, value) class BackgroundWordCounter(QRunnable): diff --git a/novelwriter/types.py b/novelwriter/types.py index ba803715..0d5e64d8 100644 --- a/novelwriter/types.py +++ b/novelwriter/types.py @@ -112,6 +112,8 @@ QtMoveAnchor = QTextCursor.MoveMode.MoveAnchor QtMoveLeft = QTextCursor.MoveOperation.Left QtMoveRight = QTextCursor.MoveOperation.Right +QtImCursorRectangle = Qt.InputMethodQuery.ImCursorRectangle + # Size Policy QtSizeExpanding = QSizePolicy.Policy.Expanding diff --git a/pkgutils.py b/pkgutils.py index 4ef16fd5..29d6983f 100755 --- a/pkgutils.py +++ b/pkgutils.py @@ -260,8 +260,7 @@ if __name__ == "__main__": cmdBuildUbuntu = parsers.add_parser( "build-ubuntu", help=( "Build a .deb package for Debian and Ubuntu. " - "Add --sign to sign package. " - "Add --first to set build number to 0." + "Add --sign to sign package." ) ) cmdBuildUbuntu.add_argument("--sign", action="store_true", help="Sign the package.") diff --git a/pyproject.toml b/pyproject.toml index 920d7d6d..ce9fe191 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,7 @@ authors = [ description = "A plain text editor for planning and writing novels" readme = {file = "setup/description_pypi.md", content-type = "text/markdown"} license = "GPL-3.0-or-later AND Apache-2.0 AND CC-BY-4.0" -license-files = [ - "LICENSE.md", - "setup/LICENSE-Apache-2.0.txt", -] +license-files = ["LICENSE.md", "setup/LICENSE-Apache-2.0.txt"] classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 13589957..ba03b103 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -27,8 +27,8 @@ import pytest from PyQt6.QtCore import QEvent, QMimeData, QPointF, Qt, QThreadPool, QUrl from PyQt6.QtGui import ( QAction, QClipboard, QDesktopServices, QDragEnterEvent, QDragMoveEvent, - QDropEvent, QFont, QMouseEvent, QTextBlock, QTextCursor, QTextDocument, - QTextOption + QDropEvent, QFont, QInputMethodEvent, QMouseEvent, QTextBlock, QTextCursor, + QTextDocument, QTextOption ) from PyQt6.QtWidgets import QApplication, QMenu, QPlainTextEdit @@ -1953,6 +1953,19 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd): "%Note.Consistency: \n" ) + # CJK completer reposition (#2267 and #2517) + qtbot.keyClick(docEditor, "%", delay=KEY_DELAY) + assert completer.isVisible() is True + completer.move(0, 0) + assert completer.pos().x() == 0 # Completer menu at 0 + assert completer.pos().y() == 0 # Completer menu at 0 + + event = QInputMethodEvent() + event.setCommitString("Text") + docEditor.inputMethodEvent(event) + assert completer.pos().x() > 0 # Completer should have moved + assert completer.pos().y() > 0 # Completer should have moved + # qtbot.stop() diff --git a/utils/build_debian.py b/utils/build_debian.py index 6feac9de..9ac0bd2e 100644 --- a/utils/build_debian.py +++ b/utils/build_debian.py @@ -36,7 +36,7 @@ SIGN_KEY = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08" def makeDebianPackage( signKey: str | None = None, sourceBuild: bool = False, distName: str = "unstable", - buildName: str = "", forLaunchpad: bool = False + buildName: str = "", forLaunchpad: bool = False, oldLicense: bool = False, ) -> str: """Build a Debian package.""" print("") @@ -96,7 +96,7 @@ def makeDebianPackage( print("Copying or generating additional files ...") print("") - copyPackageFiles(outDir, setupPy=True) + copyPackageFiles(outDir, oldLicense=oldLicense) # Copy/Write Debian Files # ======================= @@ -180,14 +180,14 @@ def launchpad(args: argparse.Namespace) -> None: bldNum = "0" distLoop = [ - ("24.04", "noble"), - ("25.04", "plucky"), - ("25.10", "questing"), + ("24.04", "noble", True), + ("25.04", "plucky", True), + ("25.10", "questing", False), ] print("Building Ubuntu packages for:") print("") - for distNum, codeName in distLoop: + for distNum, codeName, _ in distLoop: print(f" * Ubuntu {distNum} {codeName.title()}") print("") @@ -197,7 +197,7 @@ def launchpad(args: argparse.Namespace) -> None: print("") dputCmd = [] - for distNum, codeName in distLoop: + for distNum, codeName, oldLicense in distLoop: buildName = f"ubuntu{distNum}.{bldNum}" dCmd = makeDebianPackage( signKey=signKey, @@ -205,6 +205,7 @@ def launchpad(args: argparse.Namespace) -> None: distName=codeName, buildName=buildName, forLaunchpad=True, + oldLicense=oldLicense, ) dputCmd.append(dCmd) diff --git a/utils/common.py b/utils/common.py index d8590225..54528ba2 100644 --- a/utils/common.py +++ b/utils/common.py @@ -90,27 +90,31 @@ def copySourceCode(dst: Path) -> None: print("Copied:", relSrc, flush=True) -def copyPackageFiles(dst: Path, setupPy: bool = False) -> None: +def copyPackageFiles(dst: Path, oldLicense: bool = False) -> None: """Copy files needed for packaging.""" - copyFiles = ["LICENSE.md", "CREDITS.md", "pyproject.toml"] + copyFiles = ["LICENSE.md", "setup/LICENSE-Apache-2.0.txt", "CREDITS.md", "pyproject.toml"] for copyFile in copyFiles: shutil.copyfile(copyFile, dst / copyFile) print("Copied:", copyFile, flush=True) writeFile(dst / "MANIFEST.in", ( "include LICENSE.md\n" + "include setup/LICENSE-Apache-2.0.txt\n" "include CREDITS.md\n" "recursive-include novelwriter/assets *\n" )) - if setupPy: - writeFile(dst / "setup.py", ( - "import setuptools\n" - "setuptools.setup()\n" - )) - text = readFile(ROOT_DIR / "pyproject.toml") text = text.replace("setup/description_pypi.md", "data/description_short.txt") + if oldLicense: + new = [] + for line in text.splitlines(): + if line.startswith("license = "): + line = 'license = {text = "GPL-3.0-or-later AND Apache-2.0 AND CC-BY-4.0"}' + if line.startswith("license-files = "): + continue + new.append(line) + text = "\n".join(new) writeFile(dst / "pyproject.toml", text)