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: buildWin64:
needs: buildAssets needs: buildAssets
runs-on: windows-latest runs-on: windows-2022
steps: steps:
- name: Python Setup - name: Python Setup
uses: actions/setup-python@v5 uses: actions/setup-python@v5
+23
View File
@@ -1,5 +1,28 @@
# novelWriter Changelog # 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] ## Version 2.7.4 [2025-07-15]
### Release Notes ### Release Notes
+49 -23
View File
@@ -39,13 +39,14 @@ from enum import Enum, IntFlag
from time import time from time import time
from PyQt6.QtCore import ( from PyQt6.QtCore import (
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal, QObject, QPoint, QRect, QRegularExpression, QRunnable, Qt, QTimer,
pyqtSlot QVariant, pyqtSignal, pyqtSlot
) )
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QAction, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeyEvent, QAction, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent,
QKeySequence, QMouseEvent, QPalette, QPixmap, QResizeEvent, QShortcut, QInputMethodEvent, QKeyEvent, QKeySequence, QMouseEvent, QPalette, QPixmap,
QTextBlock, QTextCursor, QTextDocument, QTextFormat, QTextOption QResizeEvent, QShortcut, QTextBlock, QTextCursor, QTextDocument,
QTextFormat, QTextOption
) )
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMenu, 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.tools.lipsum import GuiLipsum
from novelwriter.types import ( from novelwriter.types import (
QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop, QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop,
QtAlignRight, QtKeepAnchor, QtModCtrl, QtModNone, QtModShift, QtMouseLeft, QtAlignRight, QtImCursorRectangle, QtKeepAnchor, QtModCtrl, QtModNone,
QtMoveAnchor, QtMoveLeft, QtMoveRight, QtScrollAlwaysOff, QtScrollAsNeeded, QtModShift, QtMouseLeft, QtMoveAnchor, QtMoveLeft, QtMoveRight,
QtTransparent QtScrollAlwaysOff, QtScrollAsNeeded, QtTransparent
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -154,7 +155,7 @@ class GuiDocEditor(QPlainTextEdit):
# Completer # Completer
self._completer = CommandCompleter(self) self._completer = CommandCompleter(self)
self._completer.complete.connect(self._insertCompletion) self._completer.insertText.connect(self._insertCompletion)
# Create Custom Document # Create Custom Document
self._qDocument = GuiTextDocument(self) self._qDocument = GuiTextDocument(self)
@@ -905,7 +906,7 @@ class GuiDocEditor(QPlainTextEdit):
return True return True
## ##
# Document Events and Maintenance # Events and Overloads
## ##
def keyPressEvent(self, event: QKeyEvent) -> None: def keyPressEvent(self, event: QKeyEvent) -> None:
@@ -1003,6 +1004,26 @@ class GuiDocEditor(QPlainTextEdit):
self.updateDocMargins() self.updateDocMargins()
super().resizeEvent(event) 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 # Public Slots
## ##
@@ -1062,24 +1083,20 @@ class GuiDocEditor(QPlainTextEdit):
if (block := self._qDocument.findBlock(pos)).isValid(): if (block := self._qDocument.findBlock(pos)).isValid():
text = block.text() text = block.text()
if text and text[0] in "@%" and added + removed == 1: if text and text[0] in "@%" and added + removed == 1:
# Only run on single character changes, or it will trigger # Only run on single character changes, or it will trigger
# at unwanted times when other changes are made to the document # at unwanted times when other changes are made to the document
cursor = self.textCursor() cursor = self.textCursor()
bPos = cursor.positionInBlock() bPos = cursor.positionInBlock()
if bPos > 0 and (viewport := self.viewport()): if bPos > 0:
if text[0] == "@": if text[0] == "@":
show = self._completer.updateMetaText(text, bPos) show = self._completer.updateMetaText(text, bPos)
else: else:
show = self._completer.updateCommentText(text, bPos) show = self._completer.updateCommentText(text, bPos)
if show: if show:
point = self.cursorRect().bottomRight()
self._completer.move(viewport.mapToGlobal(point))
self._completer.show() self._completer.show()
else: self._completerToCursor()
self._completer.close()
else:
self._completer.close()
if self._doReplace and added == 1: if self._doReplace and added == 1:
cursor = self.textCursor() cursor = self.textCursor()
@@ -1104,7 +1121,7 @@ class GuiDocEditor(QPlainTextEdit):
cursor.setPosition(check, QtMoveAnchor) cursor.setPosition(check, QtMoveAnchor)
cursor.setPosition(check + length, QtKeepAnchor) cursor.setPosition(check + length, QtKeepAnchor)
cursor.insertText(text) cursor.insertText(text)
self._completer.hide() self._completer.close()
@pyqtSlot() @pyqtSlot()
def _openContextFromCursor(self) -> None: def _openContextFromCursor(self) -> None:
@@ -1866,6 +1883,12 @@ class GuiDocEditor(QPlainTextEdit):
# Internal Functions # 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: def _correctWord(self, cursor: QTextCursor, word: str) -> None:
"""Slot for the spell check context menu triggering the """Slot for the spell check context menu triggering the
replacement of a word with the word from the dictionary. 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 %. 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: def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
self._parent = parent
def updateMetaText(self, text: str, pos: int) -> bool: def updateMetaText(self, text: str, pos: int) -> bool:
"""Update the menu options based on the line of text.""" """Update the menu options based on the line of text."""
@@ -2139,14 +2165,14 @@ class CommandCompleter(QMenu):
def keyPressEvent(self, event: QKeyEvent) -> None: def keyPressEvent(self, event: QKeyEvent) -> None:
"""Capture keypresses and forward most of them to the editor.""" """Capture keypresses and forward most of them to the editor."""
parent = self.parent()
if event.key() in ( if event.key() in (
Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Return, Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Return,
Qt.Key.Key_Enter, Qt.Key.Key_Escape Qt.Key.Key_Enter, Qt.Key.Key_Escape
): ):
super().keyPressEvent(event) super().keyPressEvent(event)
elif isinstance(parent, GuiDocEditor): else:
parent.keyPressEvent(event) self.close() # Close to release the event lock before forwarding the key press (#2510)
self._parent.keyPressEvent(event)
## ##
# Internal Functions # Internal Functions
@@ -2154,7 +2180,7 @@ class CommandCompleter(QMenu):
def _emitComplete(self, pos: int, length: int, value: str) -> None: def _emitComplete(self, pos: int, length: int, value: str) -> None:
"""Emit the signal to indicate a selection has been made.""" """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): class BackgroundWordCounter(QRunnable):
+2
View File
@@ -112,6 +112,8 @@ QtMoveAnchor = QTextCursor.MoveMode.MoveAnchor
QtMoveLeft = QTextCursor.MoveOperation.Left QtMoveLeft = QTextCursor.MoveOperation.Left
QtMoveRight = QTextCursor.MoveOperation.Right QtMoveRight = QTextCursor.MoveOperation.Right
QtImCursorRectangle = Qt.InputMethodQuery.ImCursorRectangle
# Size Policy # Size Policy
QtSizeExpanding = QSizePolicy.Policy.Expanding QtSizeExpanding = QSizePolicy.Policy.Expanding
+1 -2
View File
@@ -260,8 +260,7 @@ if __name__ == "__main__":
cmdBuildUbuntu = parsers.add_parser( cmdBuildUbuntu = parsers.add_parser(
"build-ubuntu", help=( "build-ubuntu", help=(
"Build a .deb package for Debian and Ubuntu. " "Build a .deb package for Debian and Ubuntu. "
"Add --sign to sign package. " "Add --sign to sign package."
"Add --first to set build number to 0."
) )
) )
cmdBuildUbuntu.add_argument("--sign", action="store_true", help="Sign the 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" description = "A plain text editor for planning and writing novels"
readme = {file = "setup/description_pypi.md", content-type = "text/markdown"} 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 = "GPL-3.0-or-later AND Apache-2.0 AND CC-BY-4.0"
license-files = [ license-files = ["LICENSE.md", "setup/LICENSE-Apache-2.0.txt"]
"LICENSE.md",
"setup/LICENSE-Apache-2.0.txt",
]
classifiers = [ classifiers = [
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10", "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.QtCore import QEvent, QMimeData, QPointF, Qt, QThreadPool, QUrl
from PyQt6.QtGui import ( from PyQt6.QtGui import (
QAction, QClipboard, QDesktopServices, QDragEnterEvent, QDragMoveEvent, QAction, QClipboard, QDesktopServices, QDragEnterEvent, QDragMoveEvent,
QDropEvent, QFont, QMouseEvent, QTextBlock, QTextCursor, QTextDocument, QDropEvent, QFont, QInputMethodEvent, QMouseEvent, QTextBlock, QTextCursor,
QTextOption QTextDocument, QTextOption
) )
from PyQt6.QtWidgets import QApplication, QMenu, QPlainTextEdit from PyQt6.QtWidgets import QApplication, QMenu, QPlainTextEdit
@@ -1953,6 +1953,19 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd):
"%Note.Consistency: \n" "%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() # qtbot.stop()
+8 -7
View File
@@ -36,7 +36,7 @@ SIGN_KEY = "D6A9F6B8F227CF7C6F6D1EE84DBBE4B734B0BD08"
def makeDebianPackage( def makeDebianPackage(
signKey: str | None = None, sourceBuild: bool = False, distName: str = "unstable", signKey: str | None = None, sourceBuild: bool = False, distName: str = "unstable",
buildName: str = "", forLaunchpad: bool = False buildName: str = "", forLaunchpad: bool = False, oldLicense: bool = False,
) -> str: ) -> str:
"""Build a Debian package.""" """Build a Debian package."""
print("") print("")
@@ -96,7 +96,7 @@ def makeDebianPackage(
print("Copying or generating additional files ...") print("Copying or generating additional files ...")
print("") print("")
copyPackageFiles(outDir, setupPy=True) copyPackageFiles(outDir, oldLicense=oldLicense)
# Copy/Write Debian Files # Copy/Write Debian Files
# ======================= # =======================
@@ -180,14 +180,14 @@ def launchpad(args: argparse.Namespace) -> None:
bldNum = "0" bldNum = "0"
distLoop = [ distLoop = [
("24.04", "noble"), ("24.04", "noble", True),
("25.04", "plucky"), ("25.04", "plucky", True),
("25.10", "questing"), ("25.10", "questing", False),
] ]
print("Building Ubuntu packages for:") print("Building Ubuntu packages for:")
print("") print("")
for distNum, codeName in distLoop: for distNum, codeName, _ in distLoop:
print(f" * Ubuntu {distNum} {codeName.title()}") print(f" * Ubuntu {distNum} {codeName.title()}")
print("") print("")
@@ -197,7 +197,7 @@ def launchpad(args: argparse.Namespace) -> None:
print("") print("")
dputCmd = [] dputCmd = []
for distNum, codeName in distLoop: for distNum, codeName, oldLicense in distLoop:
buildName = f"ubuntu{distNum}.{bldNum}" buildName = f"ubuntu{distNum}.{bldNum}"
dCmd = makeDebianPackage( dCmd = makeDebianPackage(
signKey=signKey, signKey=signKey,
@@ -205,6 +205,7 @@ def launchpad(args: argparse.Namespace) -> None:
distName=codeName, distName=codeName,
buildName=buildName, buildName=buildName,
forLaunchpad=True, forLaunchpad=True,
oldLicense=oldLicense,
) )
dputCmd.append(dCmd) dputCmd.append(dCmd)
+12 -8
View File
@@ -90,27 +90,31 @@ def copySourceCode(dst: Path) -> None:
print("Copied:", relSrc, flush=True) 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.""" """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: for copyFile in copyFiles:
shutil.copyfile(copyFile, dst / copyFile) shutil.copyfile(copyFile, dst / copyFile)
print("Copied:", copyFile, flush=True) print("Copied:", copyFile, flush=True)
writeFile(dst / "MANIFEST.in", ( writeFile(dst / "MANIFEST.in", (
"include LICENSE.md\n" "include LICENSE.md\n"
"include setup/LICENSE-Apache-2.0.txt\n"
"include CREDITS.md\n" "include CREDITS.md\n"
"recursive-include novelwriter/assets *\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 = readFile(ROOT_DIR / "pyproject.toml")
text = text.replace("setup/description_pypi.md", "data/description_short.txt") 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) writeFile(dst / "pyproject.toml", text)