Merge 2.7.5 (#2524)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user