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