diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 04e9cf94..a4f76529 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -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
diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py
index 4714547d..afecb30a 100644
--- a/novelwriter/gui/mainmenu.py
+++ b/novelwriter/gui/mainmenu.py
@@ -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
diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py
index 5b33a5fb..8db8a1e0 100644
--- a/novelwriter/gui/noveltree.py
+++ b/novelwriter/gui/noveltree.py
@@ -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
##
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 9863fb83..6bcae09d 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -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
diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py
index e29d491d..11dabf78 100644
--- a/novelwriter/guimain.py
+++ b/novelwriter/guimain.py
@@ -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
%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
%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
diff --git a/novelwriter/shared.py b/novelwriter/shared.py
index e2e2d00a..a42c0ffa 100644
--- a/novelwriter/shared.py
+++ b/novelwriter/shared.py
@@ -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
##
diff --git a/tests/conftest.py b/tests/conftest.py
index 4ec3eeb3..ada43e28 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -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()
diff --git a/tests/test_base/test_base_error.py b/tests/test_base/test_base_error.py
index 4d9990f2..05aff792 100644
--- a/tests/test_base/test_base_error.py
+++ b/tests/test_base/test_base_error.py
@@ -20,6 +20,8 @@ along with this program. If not, see .
"""
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()
diff --git a/tests/test_base/test_base_shared.py b/tests/test_base/test_base_shared.py
index bcacba67..cb256fc8 100644
--- a/tests/test_base/test_base_shared.py
+++ b/tests/test_base/test_base_shared.py
@@ -20,8 +20,12 @@ along with this program. If not, see .
"""
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."""
diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py
index eb87ac31..622d81b6 100644
--- a/tests/test_gui/test_gui_guimain.py
+++ b/tests/test_gui/test_gui_guimain.py
@@ -20,19 +20,22 @@ along with this program. If not, see .
"""
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."""
diff --git a/tests/test_gui/test_gui_mainmenu.py b/tests/test_gui/test_gui_mainmenu.py
index 34d6901b..fced796e 100644
--- a/tests/test_gui/test_gui_mainmenu.py
+++ b/tests/test_gui/test_gui_mainmenu.py
@@ -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()