Merge 2.7.5 (#2524)

This commit is contained in:
Veronica Berglyd Olsen
2025-09-14 19:48:18 +02:00
committed by GitHub
9 changed files with 112 additions and 47 deletions
+1 -1
View File
@@ -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
+23
View File
@@ -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
+49 -23
View File
@@ -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):
+2
View File
@@ -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
+1 -2
View File
@@ -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.")
+1 -4
View File
@@ -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",
+15 -2
View File
@@ -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()
+8 -7
View File
@@ -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)
+12 -8
View File
@@ -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)