Improve document editing and test coverage (#2077)

This commit is contained in:
Veronica Berglyd Olsen
2024-10-31 00:21:19 +01:00
committed by GitHub
11 changed files with 307 additions and 182 deletions
+70 -68
View File
@@ -38,13 +38,12 @@ from enum import Enum
from time import time
from PyQt5.QtCore import (
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, QUrl,
pyqtSignal, pyqtSlot
QObject, QPoint, QRegularExpression, QRunnable, Qt, QTimer, pyqtSignal,
pyqtSlot
)
from PyQt5.QtGui import (
QColor, QCursor, QDesktopServices, QKeyEvent, QKeySequence, QMouseEvent,
QPalette, QPixmap, QResizeEvent, QTextBlock, QTextCursor, QTextDocument,
QTextOption
QColor, QCursor, QKeyEvent, QKeySequence, QMouseEvent, QPalette, QPixmap,
QResizeEvent, QTextBlock, QTextCursor, QTextDocument, QTextOption
)
from PyQt5.QtWidgets import (
QAction, QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit,
@@ -52,10 +51,13 @@ from PyQt5.QtWidgets import (
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, transferCase
from novelwriter.common import minmax, qtLambda, transferCase
from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
from novelwriter.core.document import NWDocument
from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary
from novelwriter.enum import (
nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwItemType,
nwTrinary
)
from novelwriter.extensions.configlayout import NColourLabel
from novelwriter.extensions.eventfilters import WheelEventFilter
from novelwriter.extensions.modified import NIconToggleButton, NIconToolButton
@@ -90,20 +92,21 @@ class GuiDocEditor(QPlainTextEdit):
)
# Custom Signals
statusMessage = pyqtSignal(str)
closeEditorRequest = pyqtSignal()
docCountsChanged = pyqtSignal(str, int, int, int)
docTextChanged = pyqtSignal(str, float)
editedStatusChanged = pyqtSignal(bool)
itemHandleChanged = pyqtSignal(str)
loadDocumentTagRequest = pyqtSignal(str, Enum)
novelStructureChanged = pyqtSignal()
novelItemMetaChanged = pyqtSignal(str)
spellCheckStateChanged = pyqtSignal(bool)
closeDocumentRequest = pyqtSignal()
toggleFocusModeRequest = pyqtSignal()
requestProjectItemSelected = pyqtSignal(str, bool)
requestProjectItemRenamed = pyqtSignal(str, str)
novelStructureChanged = pyqtSignal()
requestNewNoteCreation = pyqtSignal(str, nwItemClass)
requestNextDocument = pyqtSignal(str, bool)
requestProjectItemRenamed = pyqtSignal(str, str)
requestProjectItemSelected = pyqtSignal(str, bool)
spellCheckStateChanged = pyqtSignal(bool)
toggleFocusModeRequest = pyqtSignal()
updateStatusMessage = pyqtSignal(str)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
@@ -271,6 +274,8 @@ class GuiDocEditor(QPlainTextEdit):
self.docFooter.setHandle(self._docHandle)
self.docToolBar.setVisible(False)
self.itemHandleChanged.emit("")
return
def updateTheme(self) -> None:
@@ -389,9 +394,12 @@ class GuiDocEditor(QPlainTextEdit):
"""
self._nwDocument = SHARED.project.storage.getDocument(tHandle)
self._nwItem = self._nwDocument.nwItem
if not ((nwItem := self._nwItem) and nwItem.itemType == nwItemType.FILE):
logger.debug("Requested item '%s' is not a document", tHandle)
self.clearEditor()
return False
docText = self._nwDocument.readDocument()
if docText is None:
if (docText := self._nwDocument.readDocument()) is None:
# There was an I/O error
self.clearEditor()
return False
@@ -412,10 +420,10 @@ class GuiDocEditor(QPlainTextEdit):
self.setReadOnly(False)
self.updateDocMargins()
if tLine is None and self._nwItem is not None:
self.setCursorPosition(self._nwItem.cursorPos)
elif isinstance(tLine, int):
if isinstance(tLine, int):
self.setCursorLine(tLine)
else:
self.setCursorPosition(nwItem.cursorPos)
self.docHeader.setHandle(tHandle)
self.docFooter.setHandle(tHandle)
@@ -431,11 +439,15 @@ class GuiDocEditor(QPlainTextEdit):
self._qDocument.clearUndoRedoStacks()
self.docToolBar.setVisible(CONFIG.showEditToolBar)
QApplication.restoreOverrideCursor()
# Process State Changes
SHARED.project.data.setLastHandle(tHandle, "editor")
self.itemHandleChanged.emit(tHandle)
# Update the status bar
if self._nwItem is not None:
self.statusMessage.emit(self.tr("Opened Document: {0}").format(self._nwItem.itemName))
# Finalise
QApplication.restoreOverrideCursor()
self.updateStatusMessage.emit(
self.tr("Opened Document: {0}").format(nwItem.itemName)
)
return True
@@ -506,7 +518,7 @@ class GuiDocEditor(QPlainTextEdit):
self.docFooter.updateInfo()
# Update the status bar
self.statusMessage.emit(self.tr("Saved Document: {0}").format(self._nwItem.itemName))
self.updateStatusMessage.emit(self.tr("Saved Document: {0}").format(self._nwItem.itemName))
return True
@@ -701,7 +713,7 @@ class GuiDocEditor(QPlainTextEdit):
self._qDocument.syntaxHighlighter.rehighlight()
QApplication.restoreOverrideCursor()
logger.debug("Document highlighted in %.3f ms", 1000*(time() - start))
self.statusMessage.emit(self.tr("Spell check complete"))
self.updateStatusMessage.emit(self.tr("Spell check complete"))
return
##
@@ -989,7 +1001,7 @@ class GuiDocEditor(QPlainTextEdit):
cursor = self.cursorForPosition(event.pos())
mData, mType = self._qDocument.metaDataAtPos(cursor.position())
if mData and mType == "url":
self._openWebsite(mData)
SHARED.openWebsite(mData)
else:
self._processTag(cursor)
super().mouseReleaseEvent(event)
@@ -1120,48 +1132,48 @@ class GuiDocEditor(QPlainTextEdit):
ctxMenu.setObjectName("ContextMenu")
if pBlock.userState() == BLOCK_TITLE:
action = ctxMenu.addAction(self.tr("Set as Document Name"))
action.triggered.connect(lambda: self._emitRenameItem(pBlock))
action.triggered.connect(qtLambda(self._emitRenameItem, pBlock))
# URL
(mData, mType) = self._qDocument.metaDataAtPos(pCursor.position())
if mData and mType == "url":
action = ctxMenu.addAction(self.tr("Open URL"))
action.triggered.connect(lambda: self._openWebsite(mData))
action.triggered.connect(qtLambda(SHARED.openWebsite, mData))
ctxMenu.addSeparator()
# Follow
status = self._processTag(cursor=pCursor, follow=False)
if status == nwTrinary.POSITIVE:
action = ctxMenu.addAction(self.tr("Follow Tag"))
action.triggered.connect(lambda: self._processTag(cursor=pCursor, follow=True))
action.triggered.connect(qtLambda(self._processTag, cursor=pCursor, follow=True))
ctxMenu.addSeparator()
elif status == nwTrinary.NEGATIVE:
action = ctxMenu.addAction(self.tr("Create Note for Tag"))
action.triggered.connect(lambda: self._processTag(cursor=pCursor, create=True))
action.triggered.connect(qtLambda(self._processTag, cursor=pCursor, create=True))
ctxMenu.addSeparator()
# Cut, Copy and Paste
if uCursor.hasSelection():
action = ctxMenu.addAction(self.tr("Cut"))
action.triggered.connect(lambda: self.docAction(nwDocAction.CUT))
action.triggered.connect(qtLambda(self.docAction, nwDocAction.CUT))
action = ctxMenu.addAction(self.tr("Copy"))
action.triggered.connect(lambda: self.docAction(nwDocAction.COPY))
action.triggered.connect(qtLambda(self.docAction, nwDocAction.COPY))
action = ctxMenu.addAction(self.tr("Paste"))
action.triggered.connect(lambda: self.docAction(nwDocAction.PASTE))
action.triggered.connect(qtLambda(self.docAction, nwDocAction.PASTE))
ctxMenu.addSeparator()
# Selections
action = ctxMenu.addAction(self.tr("Select All"))
action.triggered.connect(lambda: self.docAction(nwDocAction.SEL_ALL))
action.triggered.connect(qtLambda(self.docAction, nwDocAction.SEL_ALL))
action = ctxMenu.addAction(self.tr("Select Word"))
action.triggered.connect(
lambda: self._makePosSelection(QTextCursor.SelectionType.WordUnderCursor, pos)
)
action.triggered.connect(qtLambda(
self._makePosSelection, QTextCursor.SelectionType.WordUnderCursor, pos,
))
action = ctxMenu.addAction(self.tr("Select Paragraph"))
action.triggered.connect(lambda: self._makePosSelection(
QTextCursor.SelectionType.BlockUnderCursor, pos)
)
action.triggered.connect(qtLambda(
self._makePosSelection, QTextCursor.SelectionType.BlockUnderCursor, pos
))
# Spell Checking
if SHARED.project.data.spellCheck:
@@ -1177,18 +1189,16 @@ class GuiDocEditor(QPlainTextEdit):
ctxMenu.addAction(self.tr("Spelling Suggestion(s)"))
for option in suggest[:15]:
action = ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {option}")
action.triggered.connect(
lambda _, option=option: self._correctWord(sCursor, option)
)
action.triggered.connect(qtLambda(self._correctWord, sCursor, option))
else:
trNone = self.tr("No Suggestions")
ctxMenu.addAction(f"{nwUnicode.U_ENDASH} {trNone}")
ctxMenu.addSeparator()
action = ctxMenu.addAction(self.tr("Ignore Word"))
action.triggered.connect(lambda: self._addWord(word, block, False))
action.triggered.connect(qtLambda(self._addWord, word, block, False))
action = ctxMenu.addAction(self.tr("Add Word to Dictionary"))
action.triggered.connect(lambda: self._addWord(word, block, True))
action.triggered.connect(qtLambda(self._addWord, word, block, True))
# Execute the context menu
ctxMenu.exec(self.viewport().mapToGlobal(pos))
@@ -1196,12 +1206,6 @@ class GuiDocEditor(QPlainTextEdit):
return
@pyqtSlot(str)
def _openWebsite(self, url: str) -> None:
"""Open a URL in the system's default browser."""
QDesktopServices.openUrl(QUrl(url))
return
@pyqtSlot()
def _runDocumentTasks(self) -> None:
"""Run timer document tasks."""
@@ -1274,7 +1278,7 @@ class GuiDocEditor(QPlainTextEdit):
@pyqtSlot()
def _closeCurrentDocument(self) -> None:
"""Close the document. Forwarded to the main Gui."""
self.closeDocumentRequest.emit()
self.closeEditorRequest.emit()
self.docToolBar.setVisible(False)
return
@@ -2201,7 +2205,7 @@ class MetaCompleter(QMenu):
for value in sorted(options):
rep = value + suffix
action = self.addAction(value)
action.triggered.connect(lambda _, r=rep: self._emitComplete(offset, length, r))
action.triggered.connect(qtLambda(self._emitComplete, offset, length, rep))
return True
@@ -2301,61 +2305,61 @@ class GuiDocToolBar(QWidget):
self.tbBoldMD = NIconToolButton(self, iSz)
self.tbBoldMD.setToolTip(self.tr("Markdown Bold"))
self.tbBoldMD.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.MD_BOLD)
qtLambda(self.requestDocAction.emit, nwDocAction.MD_BOLD)
)
self.tbItalicMD = NIconToolButton(self, iSz)
self.tbItalicMD.setToolTip(self.tr("Markdown Italic"))
self.tbItalicMD.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.MD_ITALIC)
qtLambda(self.requestDocAction.emit, nwDocAction.MD_ITALIC)
)
self.tbStrikeMD = NIconToolButton(self, iSz)
self.tbStrikeMD.setToolTip(self.tr("Markdown Strikethrough"))
self.tbStrikeMD.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.MD_STRIKE)
qtLambda(self.requestDocAction.emit, nwDocAction.MD_STRIKE)
)
self.tbBold = NIconToolButton(self, iSz)
self.tbBold.setToolTip(self.tr("Shortcode Bold"))
self.tbBold.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.SC_BOLD)
qtLambda(self.requestDocAction.emit, nwDocAction.SC_BOLD)
)
self.tbItalic = NIconToolButton(self, iSz)
self.tbItalic.setToolTip(self.tr("Shortcode Italic"))
self.tbItalic.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.SC_ITALIC)
qtLambda(self.requestDocAction.emit, nwDocAction.SC_ITALIC)
)
self.tbStrike = NIconToolButton(self, iSz)
self.tbStrike.setToolTip(self.tr("Shortcode Strikethrough"))
self.tbStrike.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.SC_STRIKE)
qtLambda(self.requestDocAction.emit, nwDocAction.SC_STRIKE)
)
self.tbUnderline = NIconToolButton(self, iSz)
self.tbUnderline.setToolTip(self.tr("Shortcode Underline"))
self.tbUnderline.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.SC_ULINE)
qtLambda(self.requestDocAction.emit, nwDocAction.SC_ULINE)
)
self.tbMark = NIconToolButton(self, iSz)
self.tbMark.setToolTip(self.tr("Shortcode Highlight"))
self.tbMark.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.SC_MARK)
qtLambda(self.requestDocAction.emit, nwDocAction.SC_MARK)
)
self.tbSuperscript = NIconToolButton(self, iSz)
self.tbSuperscript.setToolTip(self.tr("Shortcode Superscript"))
self.tbSuperscript.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.SC_SUP)
qtLambda(self.requestDocAction.emit, nwDocAction.SC_SUP)
)
self.tbSubscript = NIconToolButton(self, iSz)
self.tbSubscript.setToolTip(self.tr("Shortcode Subscript"))
self.tbSubscript.clicked.connect(
lambda: self.requestDocAction.emit(nwDocAction.SC_SUB)
qtLambda(self.requestDocAction.emit, nwDocAction.SC_SUB)
)
# Assemble
@@ -2819,7 +2823,7 @@ class GuiDocEditHeader(QWidget):
self.tbButton = NIconToolButton(self, iSz)
self.tbButton.setVisible(False)
self.tbButton.setToolTip(self.tr("Toggle Tool Bar"))
self.tbButton.clicked.connect(lambda: self.toggleToolBarRequest.emit())
self.tbButton.clicked.connect(qtLambda(self.toggleToolBarRequest.emit))
self.outlineButton = NIconToolButton(self, iSz)
self.outlineButton.setVisible(False)
@@ -2834,7 +2838,7 @@ class GuiDocEditHeader(QWidget):
self.minmaxButton = NIconToolButton(self, iSz)
self.minmaxButton.setVisible(False)
self.minmaxButton.setToolTip(self.tr("Toggle Focus Mode"))
self.minmaxButton.clicked.connect(lambda: self.docEditor.toggleFocusModeRequest.emit())
self.minmaxButton.clicked.connect(qtLambda(self.docEditor.toggleFocusModeRequest.emit))
self.closeButton = NIconToolButton(self, iSz)
self.closeButton.setVisible(False)
@@ -2897,9 +2901,7 @@ class GuiDocEditHeader(QWidget):
self.outlineMenu.clear()
for number, text in data.items():
action = self.outlineMenu.addAction(text)
action.triggered.connect(
lambda _, number=number: self._gotoBlock(number)
)
action.triggered.connect(qtLambda(self._gotoBlock, number))
self._docOutline = data
logger.debug("Document outline updated in %.3f ms", 1000*(time() - tStart))
return
+5 -12
View File
@@ -28,8 +28,7 @@ import logging
from pathlib import Path
from typing import TYPE_CHECKING
from PyQt5.QtCore import QUrl, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtCore import pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QAction, QMenuBar
from novelwriter import CONFIG, SHARED
@@ -107,12 +106,6 @@ class GuiMainMenu(QMenuBar):
self.mainGui.docEditor.toggleSpellCheck(None)
return
@pyqtSlot(str)
def _openWebsite(self, url: str) -> None:
"""Open a URL in the system's default browser."""
QDesktopServices.openUrl(QUrl(url))
return
@pyqtSlot()
def _openUserManualFile(self) -> None:
"""Open the documentation in PDF format."""
@@ -1033,7 +1026,7 @@ class GuiMainMenu(QMenuBar):
# Help > User Manual (Online)
self.aHelpDocs = self.helpMenu.addAction(self.tr("User Manual (Online)"))
self.aHelpDocs.setShortcut("F1")
self.aHelpDocs.triggered.connect(qtLambda(self._openWebsite, nwConst.URL_DOCS))
self.aHelpDocs.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_DOCS))
self.mainGui.addAction(self.aHelpDocs)
# Help > User Manual (PDF)
@@ -1048,14 +1041,14 @@ class GuiMainMenu(QMenuBar):
# Document > Report an Issue
self.aIssue = self.helpMenu.addAction(self.tr("Report an Issue (GitHub)"))
self.aIssue.triggered.connect(qtLambda(self._openWebsite, nwConst.URL_REPORT))
self.aIssue.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_REPORT))
# Document > Ask a Question
self.aQuestion = self.helpMenu.addAction(self.tr("Ask a Question (GitHub)"))
self.aQuestion.triggered.connect(qtLambda(self._openWebsite, nwConst.URL_HELP))
self.aQuestion.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_HELP))
# Document > Main Website
self.aWebsite = self.helpMenu.addAction(self.tr("The novelWriter Website"))
self.aWebsite.triggered.connect(qtLambda(self._openWebsite, nwConst.URL_WEB))
self.aWebsite.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_WEB))
return
+23 -18
View File
@@ -87,7 +87,6 @@ class GuiNovelView(QWidget):
# Function Mappings
self.getSelectedHandle = self.novelTree.getSelectedHandle
self.setActiveHandle = self.novelTree.setActiveHandle
return
@@ -163,6 +162,12 @@ class GuiNovelView(QWidget):
# Public Slots
##
@pyqtSlot(str)
def setActiveHandle(self, tHandle: str) -> None:
"""Highlight the rows associated with a given handle."""
self.novelTree.setActiveHandle(tHandle)
return
@pyqtSlot()
def refreshTree(self) -> None:
"""Refresh the current tree."""
@@ -367,11 +372,11 @@ class GuiNovelTree(QTreeWidget):
self.novelView = novelView
# Internal Variables
self._treeMap = {}
self._lastBuild = 0
self._lastCol = NovelTreeColumn.POV
self._lastColSize = 0.25
self._actHandle = None
self._treeMap: dict[str, QTreeWidgetItem] = {}
# Cached Strings
self._povLabel = trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY])
@@ -540,25 +545,25 @@ class GuiNovelTree(QTreeWidget):
self._lastColSize = minmax(colSize, 15, 75)/100.0
return
def setActiveHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
def setActiveHandle(self, tHandle: str | None) -> None:
"""Highlight the rows associated with a given handle."""
didScroll = False
self._actHandle = tHandle
for i in range(self.topLevelItemCount()):
if tItem := self.topLevelItem(i):
if tItem.data(self.C_DATA, self.D_HANDLE) == tHandle:
tItem.setBackground(self.C_TITLE, self.palette().alternateBase())
tItem.setBackground(self.C_WORDS, self.palette().alternateBase())
tItem.setBackground(self.C_EXTRA, self.palette().alternateBase())
tItem.setBackground(self.C_MORE, self.palette().alternateBase())
if doScroll and not didScroll:
self.scrollToItem(tItem, QAbstractItemView.ScrollHint.PositionAtCenter)
brushOn = self.palette().alternateBase()
brushOff = self.palette().base()
if pHandle := self._actHandle:
for key, item in self._treeMap.items():
if key.startswith(pHandle):
for i in range(self.columnCount()):
item.setBackground(i, brushOff)
if tHandle:
for key, item in self._treeMap.items():
if key.startswith(tHandle):
for i in range(self.columnCount()):
item.setBackground(i, brushOn)
if not didScroll:
self.scrollToItem(item, QAbstractItemView.ScrollHint.PositionAtCenter)
didScroll = True
else:
tItem.setBackground(self.C_TITLE, self.palette().base())
tItem.setBackground(self.C_WORDS, self.palette().base())
tItem.setBackground(self.C_EXTRA, self.palette().base())
tItem.setBackground(self.C_MORE, self.palette().base())
self._actHandle = tHandle or None
return
##
+20
View File
@@ -211,6 +211,12 @@ class GuiProjectView(QWidget):
self.projTree.setSelectedHandle(tHandle, doScroll=doScroll)
return
@pyqtSlot(str)
def setActiveHandle(self, tHandle: str | None) -> None:
"""Highlight the active handle."""
self.projTree.setActiveHandle(tHandle)
return
@pyqtSlot(str)
def updateItemValues(self, tHandle: str) -> None:
"""Update tree item."""
@@ -500,6 +506,7 @@ class GuiProjectTree(QTreeWidget):
self._treeMap: dict[str, QTreeWidgetItem] = {}
self._timeChanged = 0.0
self._popAlert = None
self._actHandle = None
# Cached Translations
self.trActive = self.tr("Active")
@@ -1144,6 +1151,19 @@ class GuiProjectTree(QTreeWidget):
return True
def setActiveHandle(self, tHandle: str | None) -> None:
"""Highlight the rows associated with a given handle."""
brushOn = self.palette().alternateBase()
brushOff = self.palette().base()
if (pHandle := self._actHandle) and (item := self._treeMap.get(pHandle)):
for i in range(self.columnCount()):
item.setBackground(i, brushOff)
if tHandle and (item := self._treeMap.get(tHandle)):
for i in range(self.columnCount()):
item.setBackground(i, brushOn)
self._actHandle = tHandle or None
return
def setExpandedFromHandle(self, tHandle: str | None, isExpanded: bool) -> None:
"""Iterate through items below tHandle and change expanded
status for all child items. If tHandle is None, it affects the
+48 -67
View File
@@ -44,7 +44,7 @@ from novelwriter.dialogs.about import GuiAbout
from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwFocus, nwItemType, nwView
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwFocus, nwView
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.gui.docviewer import GuiDocViewer
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
@@ -249,11 +249,13 @@ class GuiMain(QMainWindow):
self.projSearch.openDocumentSelectRequest.connect(self._openDocumentSelection)
self.projSearch.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.docEditor.closeDocumentRequest.connect(self.closeDocEditor)
self.docEditor.closeEditorRequest.connect(self.closeDocEditor)
self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts)
self.docEditor.docCountsChanged.connect(self.projView.updateCounts)
self.docEditor.docTextChanged.connect(self.projSearch.textChanged)
self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus)
self.docEditor.itemHandleChanged.connect(self.novelView.setActiveHandle)
self.docEditor.itemHandleChanged.connect(self.projView.setActiveHandle)
self.docEditor.loadDocumentTagRequest.connect(self._followTag)
self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta)
self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree)
@@ -262,8 +264,8 @@ class GuiMain(QMainWindow):
self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem)
self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
self.docEditor.spellCheckStateChanged.connect(self.mainMenu.setSpellCheckState)
self.docEditor.statusMessage.connect(self.mainStatus.setStatusMessage)
self.docEditor.toggleFocusModeRequest.connect(self.toggleFocusMode)
self.docEditor.updateStatusMessage.connect(self.mainStatus.setStatusMessage)
self.docViewer.closeDocumentRequest.connect(self.closeDocViewer)
self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle)
@@ -479,8 +481,7 @@ class GuiMain(QMainWindow):
QApplication.processEvents()
self.openDocument(lastEdited, doScroll=True)
lastViewed = SHARED.project.data.getLastHandle("viewer")
if lastViewed is not None:
if lastViewed := SHARED.project.data.getLastHandle("viewer"):
QApplication.processEvents()
self.viewDocument(lastViewed)
@@ -510,7 +511,7 @@ class GuiMain(QMainWindow):
# Document Actions
##
def closeDocument(self, beforeOpen: bool = False) -> None:
def closeDocument(self) -> None:
"""Close the document and clear the editor and title field."""
if SHARED.hasProject:
# Disable focus mode if it is active
@@ -518,8 +519,6 @@ class GuiMain(QMainWindow):
SHARED.setFocusMode(False)
self.saveDocument()
self.docEditor.clearEditor()
if not beforeOpen:
self.novelView.setActiveHandle(None)
return
def openDocument(
@@ -531,12 +530,8 @@ class GuiMain(QMainWindow):
doScroll: bool = False
) -> bool:
"""Open a specific document, optionally at a given line."""
if not SHARED.hasProject:
logger.error("No project open")
return False
if not tHandle or not SHARED.project.tree.checkType(tHandle, nwItemType.FILE):
logger.debug("Requested item '%s' is not a document", tHandle)
if not (SHARED.hasProject and tHandle):
logger.error("Nothing to open open")
return False
if sTitle and tLine is None:
@@ -546,19 +541,15 @@ class GuiMain(QMainWindow):
self._changeView(nwView.EDITOR)
if tHandle == self.docEditor.docHandle:
self.docEditor.setCursorLine(tLine)
if changeFocus:
self.docEditor.setFocus()
return True
self.closeDocument(beforeOpen=True)
if self.docEditor.loadText(tHandle, tLine):
SHARED.project.data.setLastHandle(tHandle, "editor")
self.projView.setSelectedHandle(tHandle, doScroll=doScroll)
self.novelView.setActiveHandle(tHandle, doScroll=doScroll)
if changeFocus:
self.docEditor.setFocus()
else:
return False
self.closeDocument()
if self.docEditor.loadText(tHandle, tLine):
self.projView.setSelectedHandle(tHandle, doScroll=doScroll)
else:
return False
if changeFocus:
self.docEditor.setFocus()
return True
@@ -853,35 +844,33 @@ class GuiMain(QMainWindow):
def closeMain(self) -> bool:
"""Save everything, and close novelWriter."""
if SHARED.hasProject:
msgYes = SHARED.question("%s<br>%s" % (
self.tr("Do you want to exit novelWriter?"),
self.tr("Changes are saved automatically.")
))
if not msgYes:
return False
if SHARED.hasProject and SHARED.question("%s<br>%s" % (
self.tr("Do you want to exit novelWriter?"),
self.tr("Changes are saved automatically.")
)):
logger.info("Exiting novelWriter")
logger.info("Exiting novelWriter")
if not SHARED.focusMode:
CONFIG.setMainPanePos(self.splitMain.sizes())
CONFIG.setOutlinePanePos(self.outlineView.splitSizes())
if self.docViewerPanel.isVisible():
CONFIG.setViewPanePos(self.splitView.sizes())
if not SHARED.focusMode:
CONFIG.setMainPanePos(self.splitMain.sizes())
CONFIG.setOutlinePanePos(self.outlineView.splitSizes())
if self.docViewerPanel.isVisible():
CONFIG.setViewPanePos(self.splitView.sizes())
CONFIG.showViewerPanel = self.docViewerPanel.isVisible()
wFull = Qt.WindowState.WindowFullScreen
if self.windowState() & wFull != wFull:
# Ignore window size if in full screen mode
CONFIG.setMainWinSize(self.width(), self.height())
CONFIG.showViewerPanel = self.docViewerPanel.isVisible()
wFull = Qt.WindowState.WindowFullScreen
if self.windowState() & wFull != wFull:
# Ignore window size if in full screen mode
CONFIG.setMainWinSize(self.width(), self.height())
if SHARED.hasProject:
self.closeProject(True)
CONFIG.saveConfig()
if SHARED.hasProject:
self.closeProject(True)
CONFIG.saveConfig()
QApplication.quit()
QApplication.quit()
return True
return True
return False
def closeViewerPanel(self, byUser: bool = True) -> bool:
"""Close the document view panel."""
@@ -1110,8 +1099,16 @@ class GuiMain(QMainWindow):
@pyqtSlot(str, nwDocMode)
def _followTag(self, tag: str, mode: nwDocMode) -> None:
"""Follow a tag after user interaction with a link."""
tHandle, sTitle = self._getTagSource(tag)
if tHandle is not None:
tHandle, sTitle = SHARED.project.index.getTagSource(tag)
if tHandle is None:
SHARED.error(self.tr(
"Could not find the reference for tag '{0}'. It either doesn't "
"exist, or the index is out of date. The index can be updated "
"from the Tools menu, or by pressing {1}."
).format(
tag, "F9"
))
else:
if mode == nwDocMode.EDIT:
self.openDocument(tHandle, sTitle=sTitle)
elif mode == nwDocMode.VIEW:
@@ -1302,19 +1299,3 @@ class GuiMain(QMainWindow):
"""Set the window title and add the project's name."""
self.setWindowTitle(" - ".join(filter(None, [projName, CONFIG.appName])))
return
def _getTagSource(self, tag: str) -> tuple[str | None, str | None]:
"""Handle the index lookup of a tag and display an alert if the
tag cannot be found.
"""
tHandle, sTitle = SHARED.project.index.getTagSource(tag)
if tHandle is None:
SHARED.error(self.tr(
"Could not find the reference for tag '{0}'. It either doesn't "
"exist, or the index is out of date. The index can be updated "
"from the Tools menu, or by pressing {1}."
).format(
tag, "F9"
))
return None, None
return tHandle, sTitle
+12 -2
View File
@@ -30,8 +30,8 @@ from pathlib import Path
from time import time
from typing import TYPE_CHECKING, TypeVar
from PyQt5.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
from PyQt5.QtGui import QFont
from PyQt5.QtCore import QObject, QRunnable, QThreadPool, QTimer, QUrl, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QDesktopServices, QFont
from PyQt5.QtWidgets import QFileDialog, QFontDialog, QMessageBox, QWidget
from novelwriter.common import formatFileFilter
@@ -292,6 +292,16 @@ class SharedData(QObject):
return widget
return None
##
# Public Slots
##
@pyqtSlot(str)
def openWebsite(self, url: str) -> None:
"""Open a URL in the system's default browser."""
QDesktopServices.openUrl(QUrl(url))
return
##
# Signal Proxy
##
+4
View File
@@ -54,8 +54,10 @@ def resetConfigVars():
CONFIG.setBackupPath(_TMP_ROOT)
CONFIG.setGuiFont(None)
CONFIG.setTextFont(None)
CONFIG.backupOnClose = False
CONFIG._homePath = _TMP_ROOT
CONFIG._dLocale = QLocale("en_GB")
CONFIG.pdfDocs = _TMP_ROOT / "manual.pdf"
CONFIG.guiLocale = "en_GB"
return
@@ -72,6 +74,7 @@ def sessionFixture():
shutil.rmtree(_TMP_ROOT)
_TMP_ROOT.mkdir()
_TMP_CONF.mkdir()
(_TMP_ROOT / "manual.pdf").touch()
return
@@ -161,6 +164,7 @@ def nwGUI(qtbot, monkeypatch, functionFixture):
monkeypatch.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.Yes)
nwGUI = main(["--testmode", f"--config={_TMP_CONF}", f"--data={_TMP_CONF}"])
assert nwGUI is not None
qtbot.addWidget(nwGUI)
resetConfigVars()
nwGUI.docEditor.initEditor()
+11 -2
View File
@@ -20,6 +20,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import sys
import pytest
from novelwriter.error import NWErrorMessage, exceptionHandler
@@ -29,8 +31,7 @@ from tests.mocked import causeException
@pytest.mark.base
def testBaseError_Dialog(qtbot, monkeypatch, nwGUI):
"""Test the error dialog.
"""
"""Test the error dialog."""
nwErr = NWErrorMessage(nwGUI)
qtbot.addWidget(nwErr)
nwErr.show()
@@ -57,6 +58,14 @@ def testBaseError_Dialog(qtbot, monkeypatch, nwGUI):
assert message != ""
assert "(Unknown)" in message
# No enchant version retrieved
with monkeypatch.context() as mp:
mp.setitem(sys.modules, "enchant", None)
nwErr.setMessage(Exception, "Almost Fine Error", None) # type: ignore
message = nwErr.msgBody.toPlainText()
assert message != ""
assert "enchant: Unknown" in message
nwErr._doClose()
nwErr.close()
nwGUI.closeMain()
+18
View File
@@ -20,8 +20,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import QFileDialog, QMessageBox, QWidget
from novelwriter.core.project import NWProject
@@ -63,6 +67,20 @@ def testBaseSharedData_Init():
assert shared.projectLock is None
@pytest.mark.base
def testBaseSharedData_Functions(monkeypatch):
"""Test SharedData class functions."""
shared = SharedData()
# Open URL
with monkeypatch.context() as mp:
openUrl = MagicMock()
mp.setattr(QDesktopServices, "openUrl", openUrl)
shared.openWebsite("http://www.example.com")
assert openUrl.called is True
assert openUrl.call_args[0][0] == QUrl("http://www.example.com")
@pytest.mark.base
def testBaseSharedData_Projects(monkeypatch, caplog, fncPath):
"""Test SharedData handling of projects."""
+96 -4
View File
@@ -20,19 +20,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from shutil import copyfile
import pytest
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPalette
from PyQt5.QtWidgets import QInputDialog, QMenu
from PyQt5.QtWidgets import QInputDialog, QMenu, QMessageBox
from novelwriter import CONFIG, SHARED
from novelwriter.constants import nwFiles
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwDocAction, nwFocus, nwItemType, nwView
from novelwriter.enum import nwDocAction, nwDocMode, nwFocus, nwItemType, nwView
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.gui.noveltree import GuiNovelView
from novelwriter.gui.outline import GuiOutlineView
@@ -682,6 +685,12 @@ def testGuiMain_Viewing(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
def testGuiMain_Features(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test various features of the main window."""
buildTestProject(nwGUI, projPath)
cHandle = SHARED.project.newFile("Jane", C.hCharRoot)
newDoc = SHARED.project.storage.getDocument(cHandle)
newDoc.writeDocument("# Jane\n\n@tag: Jane\n\n")
nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.rebuildIndex(beQuiet=True)
assert SHARED.focusMode is False
# Focus Mode
@@ -721,6 +730,20 @@ def testGuiMain_Features(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
assert SHARED.focusMode is True
nwGUI.closeDocument()
assert SHARED.focusMode is False
nwGUI.openDocument(C.hSceneDoc)
# Pressing Escape turns off focus mode
nwGUI.toggleFocusMode()
assert SHARED.focusMode is True
qtbot.keyClick(nwGUI, Qt.Key.Key_Escape)
assert SHARED.focusMode is False
# If search is active, Escape is redirected to editor
nwGUI.toggleFocusMode()
assert SHARED.focusMode is True
nwGUI.docEditor.beginSearch()
qtbot.keyClick(nwGUI, Qt.Key.Key_Escape)
assert SHARED.focusMode is True
# Full Screen Mode
# ================
@@ -738,8 +761,25 @@ def testGuiMain_Features(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwGUI.sideBar.mSettings.show()
nwGUI.sideBar.mSettings.hide()
# Document Open Errors
# ====================
# Redirect Tag Open
# =================
nwGUI.closeDocument()
nwGUI.closeDocViewer()
assert nwGUI.docEditor.docHandle is None
assert nwGUI.docViewer.docHandle is None
nwGUI._followTag("John", nwDocMode.EDIT) # Doesn't exist
assert nwGUI.docEditor.docHandle is None
assert nwGUI.docViewer.docHandle is None
nwGUI._followTag("Jane", nwDocMode.EDIT)
assert nwGUI.docEditor.docHandle == cHandle
assert nwGUI.docViewer.docHandle is None
nwGUI._followTag("Jane", nwDocMode.VIEW)
assert nwGUI.docEditor.docHandle == cHandle
assert nwGUI.docViewer.docHandle == cHandle
# Errors Handling
# ===============
# Cannot edit a folder
assert nwGUI.openDocument(C.hChapterDir) is False
@@ -752,6 +792,58 @@ def testGuiMain_Features(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# qtbot.stop()
@pytest.mark.gui
def testGuiMain_OpenClose(qtbot, monkeypatch, nwGUI, projPath, fncPath, mockRnd):
"""Test various features of the main window."""
buildTestProject(nwGUI, projPath)
nwGUI.openDocument(C.hSceneDoc)
nwGUI.viewDocument(C.hTitlePage)
# Handle broken index on project open
nwGUI.closeProject()
idxPath: Path = projPath / "meta" / nwFiles.INDEX_FILE
assert idxPath.read_text() != "{}"
idxPath.write_text("{}")
assert idxPath.read_text() == "{}"
nwGUI.openProject(projPath)
nwGUI.saveProject()
assert idxPath.read_text() != "{}"
assert nwGUI.docEditor.docHandle == C.hSceneDoc
assert nwGUI.docViewer.docHandle == C.hTitlePage
# Block closing
assert SHARED.hasProject is True
with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
assert nwGUI.openProject(projPath) is False
assert SHARED.hasProject is True
# Don't open on lockfile question: No
lockPath: Path = projPath / nwFiles.PROJ_LOCK
lockBack: Path = projPath / f"{nwFiles.PROJ_LOCK}.bak"
shutil.copyfile(lockPath, lockBack)
nwGUI.closeProject()
shutil.copyfile(lockBack, lockPath)
with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.StandardButton.No)
assert nwGUI.openProject(projPath) is False
assert nwGUI.openProject(projPath) is True
# Backup on close
backDir = CONFIG.backupPath() / SHARED.project.data.name
assert not backDir.exists()
CONFIG.backupOnClose = True
assert nwGUI.openProject(projPath) is True
nwGUI.closeProject()
assert backDir.exists()
assert len(list(backDir.iterdir())) > 0
@pytest.mark.gui
def testGuiMain_FocusView(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test switching focus and view of the main window."""
-9
View File
@@ -24,7 +24,6 @@ from unittest.mock import MagicMock
import pytest
from PyQt5.QtCore import QUrl
from PyQt5.QtGui import QDesktopServices, QTextBlock, QTextCursor
from PyQt5.QtWidgets import QAction, QFileDialog, QMessageBox
@@ -42,14 +41,6 @@ def testGuiMainMenu_Slots(qtbot, monkeypatch, nwGUI, projPath):
"""Test the main menu slots."""
buildTestProject(nwGUI, projPath)
# Open URL
with monkeypatch.context() as mp:
openUrl = MagicMock()
mp.setattr(QDesktopServices, "openUrl", openUrl)
nwGUI.mainMenu._openWebsite("http://www.example.com")
assert openUrl.called is True
assert openUrl.call_args[0][0] == QUrl("http://www.example.com")
# Open Manual
with monkeypatch.context() as mp:
openUrl = MagicMock()