Add a document outline dropdown (#1764)

This commit is contained in:
Veronica Berglyd Olsen
2024-03-17 18:19:35 +01:00
committed by GitHub
13 changed files with 435 additions and 289 deletions
+9 -6
View File
@@ -519,11 +519,16 @@ class NWIndex:
def getItemHeading(self, tHandle: str, sTitle: str) -> IndexHeading | None:
"""Get the heading entry for a specific item and heading."""
tItem = self._itemIndex[tHandle]
if isinstance(tItem, IndexItem):
if tItem := self._itemIndex[tHandle]:
return tItem[sTitle]
return None
def iterItemHeadings(self, tHandle: str) -> Iterator[str, IndexHeading]:
"""Get all headings for a specific item."""
if tItem := self._itemIndex[tHandle]:
yield from tItem.items()
return []
def novelStructure(
self, rootHandle: str | None = None, activeOnly: bool = True
) -> Iterator[tuple[str, str, str, IndexHeading]]:
@@ -900,13 +905,11 @@ class ItemIndex:
if rHandle is None:
for sTitle in self._items[tHandle].headings():
hItem = self._items[tHandle][sTitle]
if hItem:
if hItem := self._items[tHandle][sTitle]:
yield tHandle, sTitle, hItem
elif tItem.itemRoot == rHandle:
for sTitle in self._items[tHandle].headings():
hItem = self._items[tHandle][sTitle]
if hItem:
if hItem := self._items[tHandle][sTitle]:
yield tHandle, sTitle, hItem
return
+112 -69
View File
@@ -55,11 +55,11 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary
from novelwriter.common import minmax, transferCase
from novelwriter.constants import nwKeyWords, nwLabels, nwShortcode, nwUnicode, trConst
from novelwriter.constants import nwKeyWords, nwShortcode, nwUnicode
from novelwriter.tools.lipsum import GuiLipsum
from novelwriter.core.document import NWDocument
from novelwriter.text.counting import standardCounter
from novelwriter.gui.dochighlight import GuiDocHighlighter
from novelwriter.gui.dochighlight import BLOCK_META, BLOCK_TITLE
from novelwriter.gui.editordocument import GuiTextDocument
from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -99,6 +99,7 @@ class GuiDocEditor(QPlainTextEdit):
toggleFocusModeRequest = pyqtSignal()
requestProjectItemSelected = pyqtSignal(str, bool)
requestProjectItemRenamed = pyqtSignal(str, str)
requestNewNoteCreation = pyqtSignal(str, nwItemClass)
def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui)
@@ -185,18 +186,18 @@ class GuiDocEditor(QPlainTextEdit):
self.followTag2.activated.connect(self._processTag)
# Set Up Document Word Counter
self.wcTimerDoc = QTimer()
self.wcTimerDoc.timeout.connect(self._runDocCounter)
self.wcTimerDoc.setInterval(5000)
self.timerDoc = QTimer(self)
self.timerDoc.timeout.connect(self._runDocumentTasks)
self.timerDoc.setInterval(5000)
self.wCounterDoc = BackgroundWordCounter(self)
self.wCounterDoc.setAutoDelete(False)
self.wCounterDoc.signals.countsReady.connect(self._updateDocCounts)
# Set Up Selection Word Counter
self.wcTimerSel = QTimer()
self.wcTimerSel.timeout.connect(self._runSelCounter)
self.wcTimerSel.setInterval(500)
self.timerSel = QTimer(self)
self.timerSel.timeout.connect(self._runSelCounter)
self.timerSel.setInterval(500)
self.wCounterSel = BackgroundWordCounter(self, forSelection=True)
self.wCounterSel.setAutoDelete(False)
@@ -249,8 +250,8 @@ class GuiDocEditor(QPlainTextEdit):
self._nwDocument = None
self.setReadOnly(True)
self.clear()
self.wcTimerDoc.stop()
self.wcTimerSel.stop()
self.timerDoc.stop()
self.timerSel.stop()
self._docHandle = None
self._lastEdit = 0.0
@@ -259,7 +260,7 @@ class GuiDocEditor(QPlainTextEdit):
self._doReplace = False
self.setDocumentChanged(False)
self.docHeader.setTitleFromHandle(self._docHandle)
self.docHeader.clearHeader()
self.docFooter.setHandle(self._docHandle)
self.docToolBar.setVisible(False)
@@ -363,7 +364,7 @@ class GuiDocEditor(QPlainTextEdit):
# which makes it read only.
if self._docHandle:
self._qDocument.syntaxHighlighter.rehighlight()
self.docHeader.setTitleFromHandle(self._docHandle)
self.docHeader.setHandle(self._docHandle)
else:
self.clearEditor()
@@ -397,8 +398,8 @@ class GuiDocEditor(QPlainTextEdit):
self._lastEdit = time()
self._lastActive = time()
self._runDocCounter()
self.wcTimerDoc.start()
self._runDocumentTasks()
self.timerDoc.start()
self.setReadOnly(False)
self.updateDocMargins()
@@ -408,8 +409,8 @@ class GuiDocEditor(QPlainTextEdit):
elif isinstance(tLine, int):
self.setCursorLine(tLine)
self.docHeader.setTitleFromHandle(self._docHandle)
self.docFooter.setHandle(self._docHandle)
self.docHeader.setHandle(tHandle)
self.docFooter.setHandle(tHandle)
# This is a hack to fix invisible cursor on an empty document
if self._qDocument.characterCount() <= 1:
@@ -432,7 +433,7 @@ class GuiDocEditor(QPlainTextEdit):
def updateTagHighLighting(self) -> None:
"""Rerun the syntax highlighter on all meta data lines."""
self._qDocument.syntaxHighlighter.rehighlightByType(GuiDocHighlighter.BLOCK_META)
self._qDocument.syntaxHighlighter.rehighlightByType(BLOCK_META)
return
def replaceText(self, text: str) -> None:
@@ -548,8 +549,8 @@ class GuiDocEditor(QPlainTextEdit):
sH = hBar.height() if hBar.isVisible() else 0
tM = self._vpMargin
if CONFIG.textWidth > 0 or self.mainGui.isFocusMode:
tW = CONFIG.getTextWidth(self.mainGui.isFocusMode)
if CONFIG.textWidth > 0 or SHARED.focusMode:
tW = CONFIG.getTextWidth(SHARED.focusMode)
tM = max((wW - sW - tW)//2, self._vpMargin)
tB = self.frameWidth()
@@ -991,8 +992,8 @@ class GuiDocEditor(QPlainTextEdit):
"""Called when an item label is changed to check if the document
title bar needs updating,
"""
if tHandle == self._docHandle:
self.docHeader.setTitleFromHandle(self._docHandle)
if tHandle and tHandle == self._docHandle:
self.docHeader.setHandle(tHandle)
self.docFooter.updateInfo()
self.updateDocMargins()
return
@@ -1033,8 +1034,8 @@ class GuiDocEditor(QPlainTextEdit):
if not self._docChanged:
self.setDocumentChanged(removed != 0 or added != 0)
if not self.wcTimerDoc.isActive():
self.wcTimerDoc.start()
if not self.timerDoc.isActive():
self.timerDoc.start()
if (block := self._qDocument.findBlock(pos)).isValid():
text = block.text()
@@ -1084,7 +1085,7 @@ class GuiDocEditor(QPlainTextEdit):
ctxMenu = QMenu(self)
ctxMenu.setObjectName("ContextMenu")
if pBlock.userState() == GuiDocHighlighter.BLOCK_TITLE:
if pBlock.userState() == BLOCK_TITLE:
action = ctxMenu.addAction(self.tr("Set as Document Name"))
action.triggered.connect(lambda: self._emitRenameItem(pBlock))
@@ -1179,10 +1180,8 @@ class GuiDocEditor(QPlainTextEdit):
return
@pyqtSlot()
def _runDocCounter(self) -> None:
"""Decide whether to run the word counter, or not due to
inactivity.
"""
def _runDocumentTasks(self) -> None:
"""Run timer document tasks."""
if self._docHandle is None:
return
@@ -1193,6 +1192,10 @@ class GuiDocEditor(QPlainTextEdit):
if time() - self._lastEdit < 25.0:
logger.debug("Running word counter")
SHARED.runInThreadPool(self.wCounterDoc)
self.docHeader.setOutline({
block.blockNumber(): block.text()
for block in self._qDocument.iterBlockByType(BLOCK_TITLE, maxCount=30)
})
return
@@ -1214,10 +1217,10 @@ class GuiDocEditor(QPlainTextEdit):
information to the footer, and start the selection word counter.
"""
if self.textCursor().hasSelection():
if not self.wcTimerSel.isActive():
self.wcTimerSel.start()
if not self.timerSel.isActive():
self.timerSel.start()
else:
self.wcTimerSel.stop()
self.timerSel.stop()
self.docFooter.updateWordCount(0, False)
return
@@ -1241,7 +1244,7 @@ class GuiDocEditor(QPlainTextEdit):
if self._docHandle and self._nwItem:
logger.debug("User selected %d words", wCount)
self.docFooter.updateWordCount(wCount, True)
self.wcTimerSel.stop()
self.timerSel.stop()
return
@pyqtSlot()
@@ -1303,6 +1306,7 @@ class GuiDocEditor(QPlainTextEdit):
self._docHandle, wrapAround=self.docSearch.doLoop
)
self.beginSearch()
self.setFocus()
return
cursor = self.textCursor()
@@ -1323,6 +1327,7 @@ class GuiDocEditor(QPlainTextEdit):
self._docHandle, wrapAround=self.docSearch.doLoop
)
self.beginSearch()
self.setFocus()
return
else:
resIdx = 0 if doLoop else maxIdx
@@ -1856,7 +1861,10 @@ class GuiDocEditor(QPlainTextEdit):
if text.startswith("@") and self._docHandle:
isGood, tBits, tPos = SHARED.project.index.scanThis(text)
if not isGood or not tBits or tBits[0] == nwKeyWords.TAG_KEY:
if (
not isGood or not tBits or tBits[0] == nwKeyWords.TAG_KEY
or tBits[0] not in nwKeyWords.VALID_KEYS
):
return nwTrinary.NEUTRAL
tag = ""
@@ -1884,13 +1892,9 @@ class GuiDocEditor(QPlainTextEdit):
"Do you want to create a new project note for the tag '{0}'?"
).format(tag)):
itemClass = nwKeyWords.KEY_CLASS.get(tBits[0], nwItemClass.NO_CLASS)
if SHARED.mainGui.projView.createNewNote(tag, itemClass):
self._qDocument.syntaxHighlighter.rehighlightBlock(block)
else:
SHARED.error(self.tr(
"Could not create note in a root folder for '{0}'. "
"If one doesn't exist, you must create one first."
).format(trConst(nwLabels.CLASS_NAME[itemClass])))
self.requestNewNoteCreation.emit(tag, itemClass)
qApp.processEvents()
self._qDocument.syntaxHighlighter.rehighlightBlock(block)
return nwTrinary.POSITIVE if exist else nwTrinary.NEGATIVE
@@ -2704,11 +2708,9 @@ class GuiDocEditSearch(QFrame):
@pyqtSlot()
def _doSearch(self) -> None:
"""Call the search action function for the document editor."""
modKey = qApp.keyboardModifiers()
if modKey == Qt.KeyboardModifier.ShiftModifier:
self.docEditor.findNext(goBack=True)
else:
self.docEditor.findNext()
self.docEditor.findNext(goBack=(
qApp.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier)
)
return
@pyqtSlot()
@@ -2799,9 +2801,9 @@ class GuiDocEditHeader(QWidget):
logger.debug("Create: GuiDocEditHeader")
self.docEditor = docEditor
self.mainGui = docEditor.mainGui
self._docHandle = None
self._docOutline: dict[int, str] = {}
fPx = int(0.9*SHARED.theme.fontPixelSize)
mPx = CONFIG.pxInt(8)
@@ -2824,6 +2826,9 @@ class GuiDocEditHeader(QWidget):
lblFont.setPointSizeF(0.9*SHARED.theme.fontPointSize)
self.itemTitle.setFont(lblFont)
# Other Widgets
self.outlineMenu = QMenu(self)
# Buttons
self.tbButton = QToolButton(self)
self.tbButton.setContentsMargins(0, 0, 0, 0)
@@ -2834,6 +2839,16 @@ class GuiDocEditHeader(QWidget):
self.tbButton.setToolTip(self.tr("Toggle Tool Bar"))
self.tbButton.clicked.connect(lambda: self.toggleToolBarRequest.emit())
self.outlineButton = QToolButton(self)
self.outlineButton.setContentsMargins(0, 0, 0, 0)
self.outlineButton.setIconSize(iconSize)
self.outlineButton.setFixedSize(fPx, fPx)
self.outlineButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.outlineButton.setVisible(False)
self.outlineButton.setToolTip(self.tr("Outline"))
self.outlineButton.setMenu(self.outlineMenu)
self.outlineButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.searchButton = QToolButton(self)
self.searchButton.setContentsMargins(0, 0, 0, 0)
self.searchButton.setIconSize(iconSize)
@@ -2865,14 +2880,19 @@ class GuiDocEditHeader(QWidget):
self.outerBox = QHBoxLayout()
self.outerBox.setSpacing(hSp)
self.outerBox.addWidget(self.tbButton, 0)
self.outerBox.addWidget(self.outlineButton, 0)
self.outerBox.addWidget(self.searchButton, 0)
self.outerBox.addWidget(self.itemTitle, 1)
self.outerBox.addSpacing(fPx + hSp)
self.outerBox.addWidget(self.minmaxButton, 0)
self.outerBox.addWidget(self.closeButton, 0)
self.outerBox.setContentsMargins(mPx, mPx, mPx, mPx)
self.setLayout(self.outerBox)
# Other Signals
SHARED.focusModeChanged.connect(self._focusModeChanged)
# Fix Margins and Size
# This is needed for high DPI systems. See issue #499.
self.setContentsMargins(0, 0, 0, 0)
@@ -2888,9 +2908,38 @@ class GuiDocEditHeader(QWidget):
# Methods
##
def clearHeader(self) -> None:
"""Clear the header."""
self._docHandle = None
self._docOutline = {}
self.itemTitle.setText("")
self.outlineMenu.clear()
self.tbButton.setVisible(False)
self.outlineButton.setVisible(False)
self.searchButton.setVisible(False)
self.closeButton.setVisible(False)
self.minmaxButton.setVisible(False)
return
def setOutline(self, data: dict[int, str]) -> None:
"""Set the document outline dataset."""
if data != self._docOutline:
tStart = time()
self.outlineMenu.clear()
for number, text in data.items():
action = self.outlineMenu.addAction(text)
action.triggered.connect(
lambda _, number=number: self._gotoBlock(number)
)
self._docOutline = data
logger.debug("Document outline updated in %.3f ms", 1000*(time() - tStart))
return
def updateTheme(self) -> None:
"""Update theme elements."""
self.tbButton.setIcon(SHARED.theme.getIcon("menu"))
self.outlineButton.setIcon(SHARED.theme.getIcon("list"))
self.searchButton.setIcon(SHARED.theme.getIcon("search"))
self.minmaxButton.setIcon(SHARED.theme.getIcon("maximise"))
self.closeButton.setIcon(SHARED.theme.getIcon("close"))
@@ -2900,8 +2949,10 @@ class GuiDocEditHeader(QWidget):
"QToolButton {{border: none; background: transparent;}} "
"QToolButton:hover {{border: none; background: rgba({0}, {1}, {2}, 0.2);}}"
).format(colText.red(), colText.green(), colText.blue())
buttonStyleMenu = f"{buttonStyle} QToolButton::menu-indicator {{image: none;}}"
self.tbButton.setStyleSheet(buttonStyle)
self.outlineButton.setStyleSheet(buttonStyleMenu)
self.searchButton.setStyleSheet(buttonStyle)
self.minmaxButton.setStyleSheet(buttonStyle)
self.closeButton.setStyleSheet(buttonStyle)
@@ -2924,18 +2975,11 @@ class GuiDocEditHeader(QWidget):
return
def setTitleFromHandle(self, tHandle: str | None) -> None:
def setHandle(self, tHandle: str) -> None:
"""Set the document title from the handle, or alternatively, set
the whole document path within the project.
"""
self._docHandle = tHandle
if tHandle is None:
self.itemTitle.setText("")
self.tbButton.setVisible(False)
self.searchButton.setVisible(False)
self.closeButton.setVisible(False)
self.minmaxButton.setVisible(False)
return
if CONFIG.showFullPath:
self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
@@ -2946,22 +2990,12 @@ class GuiDocEditHeader(QWidget):
self.tbButton.setVisible(True)
self.searchButton.setVisible(True)
self.outlineButton.setVisible(True)
self.closeButton.setVisible(True)
self.minmaxButton.setVisible(True)
return
def updateFocusMode(self) -> None:
"""Update the minimise/maximise icon of the Focus Mode button.
This function is called by the GuiMain class via the
toggleFocusMode function and should not be activated directly.
"""
if self.mainGui.isFocusMode:
self.minmaxButton.setIcon(SHARED.theme.getIcon("minimise"))
else:
self.minmaxButton.setIcon(SHARED.theme.getIcon("maximise"))
return
##
# Private Slots
##
@@ -2969,11 +3003,20 @@ class GuiDocEditHeader(QWidget):
@pyqtSlot()
def _closeDocument(self) -> None:
"""Trigger the close editor on the main window."""
self.clearHeader()
self.closeDocumentRequest.emit()
self.tbButton.setVisible(False)
self.searchButton.setVisible(False)
self.closeButton.setVisible(False)
self.minmaxButton.setVisible(False)
return
@pyqtSlot(int)
def _gotoBlock(self, blockNumber: int) -> None:
"""Move cursor to a specific heading."""
self.docEditor.setCursorLine(blockNumber + 1)
return
@pyqtSlot(bool)
def _focusModeChanged(self, focusMode: bool) -> None:
"""Update minimise/maximise icon of the Focus Mode button."""
self.minmaxButton.setIcon(SHARED.theme.getIcon("minimise" if focusMode else "maximise"))
return
##
+10 -10
View File
@@ -45,16 +45,16 @@ logger = logging.getLogger(__name__)
SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b")
SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
BLOCK_NONE = 0
BLOCK_TEXT = 1
BLOCK_META = 2
BLOCK_TITLE = 4
class GuiDocHighlighter(QSyntaxHighlighter):
__slots__ = ("_tItem", "_tHandle", "_spellCheck", "_spellErr", "_hRules", "_hStyles")
BLOCK_NONE = 0
BLOCK_TEXT = 1
BLOCK_META = 2
BLOCK_TITLE = 4
def __init__(self, document: QTextDocument) -> None:
super().__init__(document)
@@ -272,12 +272,12 @@ class GuiDocHighlighter(QSyntaxHighlighter):
is significantly faster than running the regex checks used for
text paragraphs.
"""
self.setCurrentBlockState(self.BLOCK_NONE)
self.setCurrentBlockState(BLOCK_NONE)
if self._tHandle is None or not text:
return
if text.startswith("@"): # Keywords and commands
self.setCurrentBlockState(self.BLOCK_META)
self.setCurrentBlockState(BLOCK_META)
index = SHARED.project.index
isValid, bits, pos = index.scanThis(text)
isGood = index.checkThese(bits, self._tHandle)
@@ -301,7 +301,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
return
elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "###! ", "#### ")):
self.setCurrentBlockState(self.BLOCK_TITLE)
self.setCurrentBlockState(BLOCK_TITLE)
if text.startswith("# "): # Heading 1
self.setFormat(0, 1, self._hStyles["head1h"])
@@ -332,7 +332,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self.setFormat(4, len(text), self._hStyles["header3"])
elif text.startswith("%"): # Comments
self.setCurrentBlockState(self.BLOCK_TEXT)
self.setCurrentBlockState(BLOCK_TEXT)
cStyle, _, cPos = processComment(text)
if cStyle == nwComment.PLAIN:
self.setFormat(0, len(text), self._hStyles["hidden"])
@@ -357,7 +357,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
return
# Regular Text
self.setCurrentBlockState(self.BLOCK_TEXT)
self.setCurrentBlockState(BLOCK_TEXT)
for rX, xFmt in self.rxRules:
rxItt = rX.globalMatch(text, 0)
while rxItt.hasNext():
+102 -58
View File
@@ -29,7 +29,6 @@ from __future__ import annotations
import logging
from enum import Enum
from typing import TYPE_CHECKING
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QPoint, QSize, Qt, QUrl
from PyQt5.QtGui import (
@@ -44,13 +43,10 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwItemType, nwDocAction, nwDocMode
from novelwriter.error import logException
from novelwriter.constants import nwUnicode
from novelwriter.constants import nwHeaders, nwUnicode
from novelwriter.core.tohtml import ToHtml
from novelwriter.extensions.eventfilters import WheelEventFilter
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
logger = logging.getLogger(__name__)
@@ -58,17 +54,16 @@ class GuiDocViewer(QTextBrowser):
documentLoaded = pyqtSignal(str)
loadDocumentTagRequest = pyqtSignal(str, Enum)
closeDocumentRequest = pyqtSignal()
reloadDocumentRequest = pyqtSignal()
togglePanelVisibility = pyqtSignal()
requestProjectItemSelected = pyqtSignal(str, bool)
def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
logger.debug("Create: GuiDocViewer")
# Class Variables
self.mainGui = mainGui
# Internal Variables
self._docHandle = None
@@ -128,7 +123,7 @@ class GuiDocViewer(QTextBrowser):
self.clear()
self.setSearchPaths([""])
self._docHandle = None
self.docHeader.setTitleFromHandle(self._docHandle)
self.docHeader.clearHeader()
return
def updateTheme(self) -> None:
@@ -184,8 +179,7 @@ class GuiDocViewer(QTextBrowser):
self.setTabStopDistance(CONFIG.getTabWidth())
# If we have a document open, we should reload it in case the font changed
if self._docHandle is not None:
self.reloadText()
self.reloadText()
return
@@ -239,7 +233,11 @@ class GuiDocViewer(QTextBrowser):
self._docHandle = tHandle
SHARED.project.data.setLastHandle(tHandle, "viewer")
self.docHeader.setTitleFromHandle(self._docHandle)
self.docHeader.setHandle(tHandle)
self.docHeader.setOutline({
sTitle: (hItem.title, nwHeaders.H_LEVEL.get(hItem.level, 0))
for sTitle, hItem in SHARED.project.index.iterItemHeadings(tHandle)
})
self.updateDocMargins()
# Since we change the content while it may still be rendering, we mark
@@ -281,13 +279,6 @@ class GuiDocViewer(QTextBrowser):
return False
return True
def navigateTo(self, tAnchor: str) -> None:
"""Go to a specific #link in the document."""
if isinstance(tAnchor, str) and tAnchor.startswith("#"):
logger.debug("Moving to anchor '%s'", tAnchor)
self.setSource(QUrl(tAnchor))
return
def clearNavHistory(self) -> None:
"""Clear the navigation history."""
self.docHistory.clear()
@@ -340,11 +331,19 @@ class GuiDocViewer(QTextBrowser):
@pyqtSlot(str)
def updateDocInfo(self, tHandle: str) -> None:
"""Update the header title bar if needed."""
if tHandle == self._docHandle:
self.docHeader.setTitleFromHandle(self._docHandle)
if tHandle and tHandle == self._docHandle:
self.docHeader.setHandle(tHandle)
self.updateDocMargins()
return
@pyqtSlot(str)
def navigateTo(self, anchor: str) -> None:
"""Go to a specific #link in the document."""
if isinstance(anchor, str) and anchor.startswith("#"):
logger.debug("Moving to anchor '%s'", anchor)
self.setSource(QUrl(anchor))
return
##
# Private Slots
##
@@ -626,35 +625,50 @@ class GuiDocViewHeader(QWidget):
logger.debug("Create: GuiDocViewHeader")
self.docViewer = docViewer
self.mainGui = docViewer.mainGui
# Internal Variables
self._docHandle = None
self._docOutline: dict[int, tuple[str, int]] = {}
fPx = int(0.9*SHARED.theme.fontPixelSize)
hSp = CONFIG.pxInt(6)
mPx = CONFIG.pxInt(8)
iconSize = QSize(fPx, fPx)
# Main Widget Settings
self.setAutoFillBackground(True)
# Title Label
self.docTitle = QLabel()
self.docTitle.setText("")
self.docTitle.setIndent(0)
self.docTitle.setMargin(0)
self.docTitle.setContentsMargins(0, 0, 0, 0)
self.docTitle.setAutoFillBackground(True)
self.docTitle.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
self.docTitle.setFixedHeight(fPx)
self.itemTitle = QLabel()
self.itemTitle.setText("")
self.itemTitle.setIndent(0)
self.itemTitle.setMargin(0)
self.itemTitle.setContentsMargins(0, 0, 0, 0)
self.itemTitle.setAutoFillBackground(True)
self.itemTitle.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
self.itemTitle.setFixedHeight(fPx)
lblFont = self.docTitle.font()
lblFont = self.itemTitle.font()
lblFont.setPointSizeF(0.9*SHARED.theme.fontPointSize)
self.docTitle.setFont(lblFont)
self.itemTitle.setFont(lblFont)
# Other Widgets
self.outlineMenu = QMenu(self)
# Buttons
self.outlineButton = QToolButton(self)
self.outlineButton.setContentsMargins(0, 0, 0, 0)
self.outlineButton.setIconSize(iconSize)
self.outlineButton.setFixedSize(fPx, fPx)
self.outlineButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.outlineButton.setVisible(False)
self.outlineButton.setToolTip(self.tr("Outline"))
self.outlineButton.setMenu(self.outlineMenu)
self.outlineButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.backButton = QToolButton(self)
self.backButton.setContentsMargins(0, 0, 0, 0)
self.backButton.setIconSize(QSize(fPx, fPx))
self.backButton.setIconSize(iconSize)
self.backButton.setFixedSize(fPx, fPx)
self.backButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.backButton.setVisible(False)
@@ -663,7 +677,7 @@ class GuiDocViewHeader(QWidget):
self.forwardButton = QToolButton(self)
self.forwardButton.setContentsMargins(0, 0, 0, 0)
self.forwardButton.setIconSize(QSize(fPx, fPx))
self.forwardButton.setIconSize(iconSize)
self.forwardButton.setFixedSize(fPx, fPx)
self.forwardButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.forwardButton.setVisible(False)
@@ -672,7 +686,7 @@ class GuiDocViewHeader(QWidget):
self.refreshButton = QToolButton(self)
self.refreshButton.setContentsMargins(0, 0, 0, 0)
self.refreshButton.setIconSize(QSize(fPx, fPx))
self.refreshButton.setIconSize(iconSize)
self.refreshButton.setFixedSize(fPx, fPx)
self.refreshButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.refreshButton.setVisible(False)
@@ -681,7 +695,7 @@ class GuiDocViewHeader(QWidget):
self.closeButton = QToolButton(self)
self.closeButton.setContentsMargins(0, 0, 0, 0)
self.closeButton.setIconSize(QSize(fPx, fPx))
self.closeButton.setIconSize(iconSize)
self.closeButton.setFixedSize(fPx, fPx)
self.closeButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
self.closeButton.setVisible(False)
@@ -691,19 +705,21 @@ class GuiDocViewHeader(QWidget):
# Assemble Layout
self.outerBox = QHBoxLayout()
self.outerBox.setSpacing(hSp)
self.outerBox.addWidget(self.outlineButton, 0)
self.outerBox.addWidget(self.backButton, 0)
self.outerBox.addWidget(self.forwardButton, 0)
self.outerBox.addWidget(self.docTitle, 1)
self.outerBox.addWidget(self.itemTitle, 1)
self.outerBox.addSpacing(fPx + hSp)
self.outerBox.addWidget(self.refreshButton, 0)
self.outerBox.addWidget(self.closeButton, 0)
self.setLayout(self.outerBox)
# Fix Margins and Size
# This is needed for high DPI systems. See issue #499.
cM = CONFIG.pxInt(8)
self.setContentsMargins(0, 0, 0, 0)
self.outerBox.setContentsMargins(cM, cM, cM, cM)
self.setMinimumHeight(fPx + 2*cM)
self.outerBox.setContentsMargins(mPx, mPx, mPx, mPx)
self.setMinimumHeight(fPx + 2*mPx)
# Fix the Colours
self.updateTheme()
@@ -716,8 +732,42 @@ class GuiDocViewHeader(QWidget):
# Methods
##
def clearHeader(self) -> None:
"""Clear the header."""
self._docHandle = None
self._docOutline = {}
self.itemTitle.setText("")
self.outlineMenu.clear()
self.outlineButton.setVisible(False)
self.backButton.setVisible(False)
self.forwardButton.setVisible(False)
self.closeButton.setVisible(False)
self.refreshButton.setVisible(False)
return
def setOutline(self, data: dict[int, tuple[str, int]]) -> None:
"""Set the document outline dataset."""
if data != self._docOutline:
self.outlineMenu.clear()
entries = []
minLevel = 5
for title, (text, level) in data.items():
if title != "T0000":
entries.append((title, text, level))
minLevel = min(minLevel, level)
for title, text, level in entries[:30]:
indent = " "*(level - minLevel)
action = self.outlineMenu.addAction(f"{indent}{text}")
action.triggered.connect(
lambda _, title=title: self.docViewer.navigateTo(f"#{title}")
)
self._docOutline = data
return
def updateTheme(self) -> None:
"""Update theme elements."""
self.outlineButton.setIcon(SHARED.theme.getIcon("list"))
self.backButton.setIcon(SHARED.theme.getIcon("backward"))
self.forwardButton.setIcon(SHARED.theme.getIcon("forward"))
self.refreshButton.setIcon(SHARED.theme.getIcon("refresh"))
@@ -728,7 +778,9 @@ class GuiDocViewHeader(QWidget):
"QToolButton {{border: none; background: transparent;}} "
"QToolButton:hover {{border: none; background: rgba({0}, {1}, {2}, 0.2);}}"
).format(colText.red(), colText.green(), colText.blue())
buttonStyleMenu = f"{buttonStyle} QToolButton::menu-indicator {{image: none;}}"
self.outlineButton.setStyleSheet(buttonStyleMenu)
self.backButton.setStyleSheet(buttonStyle)
self.forwardButton.setStyleSheet(buttonStyle)
self.refreshButton.setStyleSheet(buttonStyle)
@@ -747,31 +799,25 @@ class GuiDocViewHeader(QWidget):
palette.setColor(QPalette.ColorRole.WindowText, SHARED.theme.colText)
palette.setColor(QPalette.ColorRole.Text, SHARED.theme.colText)
self.setPalette(palette)
self.docTitle.setPalette(palette)
self.itemTitle.setPalette(palette)
return
def setTitleFromHandle(self, tHandle: str | None) -> None:
def setHandle(self, tHandle: str) -> None:
"""Sets the document title from the handle, or alternatively,
set the whole document path.
"""
self._docHandle = tHandle
if tHandle is None:
self.docTitle.setText("")
self.backButton.setVisible(False)
self.forwardButton.setVisible(False)
self.closeButton.setVisible(False)
self.refreshButton.setVisible(False)
return
if CONFIG.showFullPath:
self.docTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
[name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)]
)))
else:
self.docTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "")
self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "")
self.backButton.setVisible(True)
self.forwardButton.setVisible(True)
self.outlineButton.setVisible(True)
self.closeButton.setVisible(True)
self.refreshButton.setVisible(True)
@@ -790,15 +836,14 @@ class GuiDocViewHeader(QWidget):
@pyqtSlot()
def _closeDocument(self) -> None:
"""Trigger the close editor/viewer on the main window."""
self.mainGui.closeDocViewer()
self.clearHeader()
self.docViewer.closeDocumentRequest.emit()
return
@pyqtSlot()
def _refreshDocument(self) -> None:
"""Reload the content of the document."""
if self.docViewer.docHandle == self.mainGui.docEditor.docHandle:
self.mainGui.saveDocument()
self.docViewer.reloadText()
self.docViewer.reloadDocumentRequest.emit()
return
##
@@ -829,7 +874,6 @@ class GuiDocViewFooter(QWidget):
logger.debug("Create: GuiDocViewFooter")
self.docViewer = docViewer
self.mainGui = docViewer.mainGui
# Internal Variables
self._docHandle = None
+12 -1
View File
@@ -23,11 +23,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
from collections.abc import Generator
import logging
from time import time
from PyQt5.QtGui import QTextCursor, QTextDocument
from PyQt5.QtGui import QTextBlock, QTextCursor, QTextDocument
from PyQt5.QtCore import QObject, pyqtSlot
from PyQt5.QtWidgets import QPlainTextDocumentLayout, qApp
from novelwriter import SHARED
@@ -113,6 +114,16 @@ class GuiTextDocument(QTextDocument):
return word, cPos, cLen, SHARED.spelling.suggestWords(word)
return "", -1, -1, []
def iterBlockByType(self, cType: int, maxCount: int = 1000) -> Generator[QTextBlock]:
"""Iterate over all text blocks of a given type."""
count = 0
for i in range(self.blockCount()):
block = self.findBlockByNumber(i)
if count < maxCount and block.isValid() and block.userState() & cType > 0:
count += 1
yield block
return None
##
# Public Slots
##
+3 -3
View File
@@ -196,12 +196,12 @@ class GuiMainMenu(QMenuBar):
# Document > Open
self.aOpenDoc = self.docuMenu.addAction(self.tr("Open Document"))
self.aOpenDoc.setShortcut("Ctrl+O")
self.aOpenDoc.triggered.connect(lambda: self.mainGui.openSelectedItem())
self.aOpenDoc.triggered.connect(self.mainGui.openSelectedItem)
# Document > Save
self.aSaveDoc = self.docuMenu.addAction(self.tr("Save Document"))
self.aSaveDoc.setShortcut("Ctrl+S")
self.aSaveDoc.triggered.connect(lambda: self.mainGui.saveDocument())
self.aSaveDoc.triggered.connect(self.mainGui.saveDocument)
# Document > Close
self.aCloseDoc = self.docuMenu.addAction(self.tr("Close Document"))
@@ -219,7 +219,7 @@ class GuiMainMenu(QMenuBar):
# Document > Close Preview
self.aCloseView = self.docuMenu.addAction(self.tr("Close Document View"))
self.aCloseView.setShortcut("Ctrl+Shift+R")
self.aCloseView.triggered.connect(lambda: self.mainGui.closeDocViewer())
self.aCloseView.triggered.connect(self.mainGui.closeDocViewer)
# Document > Separator
self.docuMenu.addSeparator()
+13 -8
View File
@@ -142,7 +142,6 @@ class GuiProjectView(QWidget):
self.requestDeleteItem = self.projTree.requestDeleteItem
self.getSelectedHandle = self.projTree.getSelectedHandle
self.changedSince = self.projTree.changedSince
self.createNewNote = self.projTree.createNewNote
return
@@ -240,6 +239,12 @@ class GuiProjectView(QWidget):
self.projBar.buildQuickLinksMenu()
return
@pyqtSlot(str, nwItemClass)
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Process new not request."""
self.projTree.createNewNote(tag, itemClass)
return
# END Class GuiProjectView
@@ -606,18 +611,18 @@ class GuiProjectTree(QTreeWidget):
self._timeChanged = 0.0
return
def createNewNote(self, tag: str, itemClass: nwItemClass | None) -> bool:
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Create a new note. This function is used by the document
editor to create note files for unknown tags.
"""
rHandle = SHARED.project.tree.findRoot(itemClass)
if rHandle:
tHandle = SHARED.project.newFile(tag, rHandle)
if tHandle:
if itemClass != nwItemClass.NO_CLASS:
if not (rHandle := SHARED.project.tree.findRoot(itemClass)):
self.newTreeItem(nwItemType.ROOT, itemClass)
rHandle = SHARED.project.tree.findRoot(itemClass)
if rHandle and (tHandle := SHARED.project.newFile(tag, rHandle)):
SHARED.project.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
self.revealNewTreeItem(tHandle, wordCount=True)
return True
return False
return
def newTreeItem(self, itemType: nwItemType, itemClass: nwItemClass | None = None,
hLevel: int = 1, isNote: bool = False, copyDoc: str | None = None) -> bool:
+103 -101
View File
@@ -104,9 +104,6 @@ class GuiMain(QMainWindow):
# Initialise UserData Instance
SHARED.initSharedData(self, GuiTheme())
# Core Settings
self.isFocusMode = False
# Prepare Main Window
self.resize(*CONFIG.mainWinSize)
self._updateWindowTitle()
@@ -233,6 +230,7 @@ class GuiMain(QMainWindow):
SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus)
SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage)
SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage)
SHARED.focusModeChanged.connect(self._focusModeChanged)
SHARED.indexChangedTags.connect(self.docViewerPanel.updateChangedTags)
SHARED.indexScannedText.connect(self.docViewerPanel.projectItemChanged)
SHARED.indexScannedText.connect(self.projView.updateItemValues)
@@ -275,9 +273,12 @@ class GuiMain(QMainWindow):
self.docEditor.toggleFocusModeRequest.connect(self.toggleFocusMode)
self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem)
self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote)
self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle)
self.docViewer.loadDocumentTagRequest.connect(self._followTag)
self.docViewer.closeDocumentRequest.connect(self.closeDocViewer)
self.docViewer.reloadDocumentRequest.connect(self._reloadViewer)
self.docViewer.togglePanelVisibility.connect(self._toggleViewerPanelVisibility)
self.docViewer.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
@@ -401,7 +402,7 @@ class GuiMain(QMainWindow):
if saveOK:
self.closeDocument()
self.docViewer.clearNavHistory()
self.closeDocViewer(byUser=False)
self.closeViewerPanel(byUser=False)
self.docViewerPanel.closeProjectTasks()
self.outlineView.closeProjectTasks()
@@ -523,24 +524,19 @@ class GuiMain(QMainWindow):
# Document Actions
##
def closeDocument(self, beforeOpen: bool = False) -> bool:
def closeDocument(self, beforeOpen: bool = False) -> None:
"""Close the document and clear the editor and title field."""
if not SHARED.hasProject:
logger.error("No project open")
return False
# Disable focus mode if it is active
if self.isFocusMode:
self.toggleFocusMode()
self.docEditor.saveCursorPosition()
if self.docEditor.docChanged:
self.saveDocument()
self.docEditor.clearEditor()
if not beforeOpen:
self.novelView.setActiveHandle(None)
return True
if SHARED.hasProject:
# Disable focus mode if it is active
if SHARED.focusMode:
SHARED.setFocusMode(False)
self.docEditor.saveCursorPosition()
if self.docEditor.docChanged:
self.saveDocument()
self.docEditor.clearEditor()
if not beforeOpen:
self.novelView.setActiveHandle(None)
return
def openDocument(self, tHandle: str | None, tLine: int | None = None,
changeFocus: bool = True, doScroll: bool = False) -> bool:
@@ -604,13 +600,12 @@ class GuiMain(QMainWindow):
return False
def saveDocument(self) -> bool:
@pyqtSlot()
def saveDocument(self) -> None:
"""Save the current documents."""
if not SHARED.hasProject:
logger.error("No project open")
return False
self.docEditor.saveText()
return True
if SHARED.hasProject:
self.docEditor.saveText()
return
def viewDocument(self, tHandle: str | None = None, sTitle: str | None = None) -> bool:
"""Load a document for viewing in the view panel."""
@@ -640,7 +635,8 @@ class GuiMain(QMainWindow):
self._changeView(nwView.EDITOR)
logger.debug("Viewing document with handle '%s'", tHandle)
if self.docViewer.loadText(tHandle):
updateHistory = tHandle != self.docViewer.docHandle
if self.docViewer.loadText(tHandle, updateHistory=updateHistory):
if not self.splitView.isVisible():
cursorVisible = self.docEditor.cursorIsVisible()
bPos = self.splitMain.sizes()
@@ -713,80 +709,71 @@ class GuiMain(QMainWindow):
# Tree Item Actions
##
def openSelectedItem(self) -> bool:
@pyqtSlot()
def openSelectedItem(self) -> None:
"""Open the selected item from the tree that is currently
active. It is not checked that the item is actually a document.
That should be handled by the openDocument function.
"""
if not SHARED.hasProject:
logger.error("No project open")
return False
if SHARED.hasProject:
tHandle = None
sTitle = None
tLine = None
if self.projView.treeHasFocus():
tHandle = self.projView.getSelectedHandle()
elif self.novelView.treeHasFocus():
tHandle, sTitle = self.novelView.getSelectedHandle()
elif self.outlineView.treeHasFocus():
tHandle, sTitle = self.outlineView.getSelectedHandle()
else:
logger.warning("No item selected")
return
tHandle = None
sTitle = None
tLine = None
if self.projView.treeHasFocus():
tHandle = self.projView.getSelectedHandle()
elif self.novelView.treeHasFocus():
tHandle, sTitle = self.novelView.getSelectedHandle()
elif self.outlineView.treeHasFocus():
tHandle, sTitle = self.outlineView.getSelectedHandle()
else:
logger.warning("No item selected")
return False
if tHandle and sTitle:
if hItem := SHARED.project.index.getItemHeading(tHandle, sTitle):
tLine = hItem.line
if tHandle:
self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=False)
if tHandle is not None and sTitle is not None:
hItem = SHARED.project.index.getItemHeading(tHandle, sTitle)
if hItem is not None:
tLine = hItem.line
return
if tHandle is not None:
self.openDocument(tHandle, tLine=tLine, changeFocus=False, doScroll=False)
return True
def editItemLabel(self, tHandle: str | None = None) -> bool:
def editItemLabel(self, tHandle: str | None = None) -> None:
"""Open the edit item dialog."""
if not SHARED.hasProject:
logger.error("No project open")
return False
if tHandle is None and (self.docEditor.anyFocus() or self.isFocusMode):
tHandle = self.docEditor.docHandle
self.projView.renameTreeItem(tHandle)
return True
if SHARED.hasProject:
if tHandle is None and (self.docEditor.anyFocus() or SHARED.focusMode):
tHandle = self.docEditor.docHandle
self.projView.renameTreeItem(tHandle)
return
def rebuildTrees(self) -> None:
"""Rebuild the project tree."""
self.projView.populateTree()
return
def rebuildIndex(self, beQuiet: bool = False) -> bool:
def rebuildIndex(self, beQuiet: bool = False) -> None:
"""Rebuild the entire index."""
if not SHARED.hasProject:
logger.error("No project open")
return False
if SHARED.hasProject:
logger.info("Rebuilding index ...")
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
tStart = time()
logger.info("Rebuilding index ...")
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
tStart = time()
self.projView.saveProjectTasks()
SHARED.project.index.rebuildIndex()
self.projView.populateTree()
self.novelView.refreshTree()
self.projView.saveProjectTasks()
SHARED.project.index.rebuildIndex()
self.projView.populateTree()
self.novelView.refreshTree()
tEnd = time()
self.mainStatus.setStatusMessage(
self.tr("Indexing completed in {0} ms").format(f"{(tEnd - tStart)*1000.0:.1f}")
)
self.docEditor.updateTagHighLighting()
self._updateStatusWordCount()
qApp.restoreOverrideCursor()
tEnd = time()
self.mainStatus.setStatusMessage(
self.tr("Indexing completed in {0} ms").format(f"{(tEnd - tStart)*1000.0:.1f}")
)
self.docEditor.updateTagHighLighting()
self._updateStatusWordCount()
qApp.restoreOverrideCursor()
if not beQuiet:
SHARED.info(self.tr("The project index has been successfully rebuilt."))
if not beQuiet:
SHARED.info(self.tr("The project index has been successfully rebuilt."))
return True
return
##
# Main Dialogs
@@ -896,15 +883,14 @@ class GuiMain(QMainWindow):
SHARED.error(self.tr("Could not initialise the dialog."))
return
def reportConfErr(self) -> bool:
def reportConfErr(self) -> None:
"""Checks if the Config module has any errors to report, and let
the user know if this is the case. The Config module caches
errors since it is initialised before the GUI itself.
"""
if CONFIG.hasError:
SHARED.error(CONFIG.errorText())
return True
return False
return
##
# Main Window Actions
@@ -922,7 +908,7 @@ class GuiMain(QMainWindow):
logger.info("Exiting novelWriter")
if not self.isFocusMode:
if not SHARED.focusMode:
CONFIG.setMainPanePos(self.splitMain.sizes())
CONFIG.setOutlinePanePos(self.outlineView.splitSizes())
if self.docViewerPanel.isVisible():
@@ -943,7 +929,7 @@ class GuiMain(QMainWindow):
return True
def closeDocViewer(self, byUser: bool = True) -> bool:
def closeViewerPanel(self, byUser: bool = True) -> bool:
"""Close the document view panel."""
self.docViewer.clearViewer()
if byUser:
@@ -991,32 +977,40 @@ class GuiMain(QMainWindow):
SHARED.project.data.setLastHandle(None, "editor")
return
@pyqtSlot()
def closeDocViewer(self) -> None:
"""Close the document viewer."""
self.closeViewerPanel()
SHARED.project.data.setLastHandle(None, "viewer")
return
@pyqtSlot()
def toggleFocusMode(self) -> None:
"""Handle toggle focus mode. The Main GUI Focus Mode hides tree,
"""Toggle focus mode."""
if self.docEditor.docHandle:
SHARED.setFocusMode(not SHARED.focusMode)
return
@pyqtSlot(bool)
def _focusModeChanged(self, focusMode: bool) -> None:
"""Handle change of focus mode. The Main GUI Focus Mode hides tree,
view, statusbar and menu.
"""
if self.docEditor.docHandle is None:
logger.error("No document open, so not activating Focus Mode")
return
self.isFocusMode = not self.isFocusMode
if self.isFocusMode:
if focusMode:
logger.debug("Activating Focus Mode")
self.switchFocus(nwWidget.EDITOR)
else:
logger.debug("Deactivating Focus Mode")
cursorVisible = self.docEditor.cursorIsVisible()
isVisible = not self.isFocusMode
isVisible = not focusMode
self.treePane.setVisible(isVisible)
self.mainStatus.setVisible(isVisible)
self.mainMenu.setVisible(isVisible)
self.sideBar.setVisible(isVisible)
hideDocFooter = self.isFocusMode and CONFIG.hideFocusFooter
hideDocFooter = focusMode and CONFIG.hideFocusFooter
self.docEditor.docFooter.setVisible(not hideDocFooter)
self.docEditor.docHeader.updateFocusMode()
if self.splitView.isVisible():
self.splitView.setVisible(False)
@@ -1025,7 +1019,6 @@ class GuiMain(QMainWindow):
if cursorVisible:
self.docEditor.ensureCursorVisibleNoCentre()
return
@pyqtSlot(nwWidget)
@@ -1154,6 +1147,15 @@ class GuiMain(QMainWindow):
self.viewDocument(tHandle=tHandle, sTitle=sTitle)
return
@pyqtSlot()
def _reloadViewer(self) -> None:
"""Reload the document in the viewer."""
if self.docEditor.docChanged and self.docEditor.docHandle == self.docViewer.docHandle:
# If the two panels have the same document, save any changes in the editor
self.saveDocument()
self.docViewer.reloadText()
return
@pyqtSlot(nwView)
def _changeView(self, view: nwView) -> None:
"""Handle the requested change of view from the GuiViewBar."""
@@ -1266,8 +1268,8 @@ class GuiMain(QMainWindow):
"""Process escape keypress in the main window."""
if self.docEditor.docSearch.isVisible():
self.docEditor.closeSearch()
elif self.isFocusMode:
self.toggleFocusMode()
elif SHARED.focusMode:
SHARED.setFocusMode(False)
return
@pyqtSlot(int)
+19
View File
@@ -57,6 +57,7 @@ class SharedData(QObject):
projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str)
spellLanguageChanged = pyqtSignal(str, str)
focusModeChanged = pyqtSignal(bool)
indexScannedText = pyqtSignal(str)
indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal()
@@ -76,6 +77,7 @@ class SharedData(QObject):
self._lastAlert = ""
self._idleTime = 0.0
self._idleRefTime = time()
self._focusMode = False
return
@@ -111,6 +113,11 @@ class SharedData(QObject):
raise Exception("SharedData class not fully initialised")
return self._spelling
@property
def focusMode(self) -> bool:
"""Return the Focus Mode state."""
return self._focusMode
@property
def hasProject(self) -> bool:
"""Return True if the project instance is populated."""
@@ -131,6 +138,17 @@ class SharedData(QObject):
"""Return the last alert message."""
return self._lastAlert
##
# Setters
##
def setFocusMode(self, state: bool) -> None:
"""Set focus mode on or off."""
if state is not self._focusMode:
self._focusMode = state
self.focusModeChanged.emit(state)
return
##
# Methods
##
@@ -323,6 +341,7 @@ class SharedData(QObject):
self._project = NWProject()
self._spelling = NWSpellEnchant(self._project)
self.updateSpellCheckLanguage()
self._focusMode = False
return
def _resetIdleTimer(self) -> None:
+10
View File
@@ -705,6 +705,16 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
index.getItemHeading(dHandle, "T0001")
)]
# getItemHeading
# ==============
assert list(index.iterItemHeadings(cHandle)) == [
("T0001", index.getItemHeading(cHandle, "T0001"))
]
assert list(index.iterItemHeadings(dHandle)) == [
("T0001", index.getItemHeading(dHandle, "T0001"))
]
assert list(index.iterItemHeadings(C.hInvalid)) == []
# getSingleTag
# ============
assert index.getSingleTag("jane") == (
+27 -14
View File
@@ -30,7 +30,9 @@ from PyQt5.QtCore import QThreadPool, Qt
from PyQt5.QtWidgets import QAction, QMenu, qApp
from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemLayout, nwTrinary, nwWidget
from novelwriter.enum import (
nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwTrinary, nwWidget
)
from novelwriter.constants import nwKeyWords, nwUnicode
from novelwriter.gui.doceditor import GuiDocEditor, GuiDocToolBar
from novelwriter.text.counting import standardCounter
@@ -47,7 +49,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert nwGUI.openDocument(C.hSceneDoc)
nwGUI.docEditor.setPlainText("### Lorem Ipsum\n\n%s" % ipsumText[0])
assert nwGUI.saveDocument()
nwGUI.saveDocument()
# Check Defaults
qDoc = nwGUI.docEditor.document()
@@ -58,6 +60,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert nwGUI.docEditor.docHeader.itemTitle.text() == (
"Novel \u203a New Chapter \u203a New Scene"
)
assert nwGUI.docEditor.docHeader._docOutline == {0: "### New Scene"}
# Check that editor handles settings
CONFIG.textFont = ""
@@ -84,6 +87,11 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
# Header
# ======
# Go to outline
nwGUI.docEditor.setCursorLine(3)
nwGUI.docEditor.docHeader.outlineMenu.actions()[0].trigger()
assert nwGUI.docEditor.getCursorPosition() == 0
# Select item from header
with qtbot.waitSignal(nwGUI.docEditor.requestProjectItemSelected, timeout=1000) as signal:
qtbot.mouseClick(nwGUI.docEditor.docHeader, Qt.MouseButton.LeftButton)
@@ -116,8 +124,8 @@ def testGuiEditor_LoadText(qtbot, nwGUI, projPath, ipsumText, mockRnd):
longText = "### Lorem Ipsum\n\n%s" % "\n\n".join(ipsumText*20)
nwGUI.docEditor.replaceText(longText)
assert nwGUI.saveDocument() is True
assert nwGUI.closeDocument() is True
nwGUI.saveDocument()
nwGUI.closeDocument()
# Invalid handle
assert nwGUI.docEditor.loadText("abcdefghijklm") is False
@@ -132,7 +140,7 @@ def testGuiEditor_LoadText(qtbot, nwGUI, projPath, ipsumText, mockRnd):
# Load empty document
nwGUI.docEditor.replaceText("")
assert nwGUI.saveDocument() is True
nwGUI.saveDocument()
assert nwGUI.docEditor.loadText(C.hSceneDoc) is True
assert nwGUI.docEditor.toPlainText() == ""
@@ -1478,7 +1486,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
cHandle = SHARED.project.newFile("Jane Doe", C.hCharRoot)
assert nwGUI.openDocument(cHandle) is True
nwGUI.docEditor.replaceText(text)
assert nwGUI.saveDocument() is True
nwGUI.saveDocument()
assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.docEditor.updateTagHighLighting()
@@ -1508,7 +1516,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert nwGUI.docViewer._docHandle is None
assert nwGUI.docEditor._processTag(follow=True) is nwTrinary.POSITIVE
assert nwGUI.docViewer._docHandle == cHandle
assert nwGUI.closeDocViewer() is True
assert nwGUI.closeViewerPanel() is True
assert nwGUI.docViewer._docHandle is None
# On Unknown Tag, Create It
@@ -1521,7 +1529,12 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert "0000000000012" not in SHARED.project.tree
nwGUI.docEditor.setCursorPosition(42)
assert nwGUI.docEditor._processTag(create=True) is nwTrinary.NEGATIVE
assert "0000000000012" not in SHARED.project.tree
oHandle = SHARED.project.tree.findRoot(nwItemClass.OBJECT)
assert oHandle == "0000000000012"
oItem = SHARED.project.tree["0000000000013"]
assert oItem is not None
assert oItem.itemParent == "0000000000012"
nwGUI.docEditor.setCursorPosition(47)
assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL
@@ -1547,7 +1560,7 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd):
cHandle = SHARED.project.newFile("People", C.hCharRoot)
assert nwGUI.openDocument(cHandle) is True
nwGUI.docEditor.replaceText(text)
assert nwGUI.saveDocument() is True
nwGUI.saveDocument()
assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)
docEditor = nwGUI.docEditor
@@ -1686,13 +1699,13 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m
threadPool = MockThreadPool()
monkeypatch.setattr(QThreadPool, "globalInstance", lambda *a: threadPool)
nwGUI.docEditor.wcTimerDoc.blockSignals(True)
nwGUI.docEditor.wcTimerSel.blockSignals(True)
nwGUI.docEditor.timerDoc.blockSignals(True)
nwGUI.docEditor.timerSel.blockSignals(True)
buildTestProject(nwGUI, projPath)
# Run on an empty document
nwGUI.docEditor._runDocCounter()
nwGUI.docEditor._runDocumentTasks()
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
nwGUI.docEditor._updateDocCounts(0, 0, 0)
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
@@ -1714,7 +1727,7 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m
# Check that a busy counter is blocked
with monkeypatch.context() as mp:
mp.setattr(nwGUI.docEditor.wCounterDoc, "isRunning", lambda *a: True)
nwGUI.docEditor._runDocCounter()
nwGUI.docEditor._runDocumentTasks()
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
with monkeypatch.context() as mp:
@@ -1723,7 +1736,7 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m
assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
# Run the full word counter
nwGUI.docEditor._runDocCounter()
nwGUI.docEditor._runDocumentTasks()
assert threadPool.objectID() == id(nwGUI.docEditor.wCounterDoc)
nwGUI.docEditor.wCounterDoc.run()
+2 -2
View File
@@ -185,12 +185,12 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
nwItem.setName("Test Title") # type: ignore
assert nwItem.itemName == "Test Title" # type: ignore
docViewer.updateDocInfo("4c4f28287af27")
assert docViewer.docHeader.docTitle.text() == "Characters \u203a Test Title"
assert docViewer.docHeader.itemTitle.text() == "Characters \u203a Test Title"
# Title without full path
CONFIG.showFullPath = False
docViewer.updateDocInfo("4c4f28287af27")
assert docViewer.docHeader.docTitle.text() == "Test Title"
assert docViewer.docHeader.itemTitle.text() == "Test Title"
CONFIG.showFullPath = True
# Document footer show/hide synopsis
+13 -17
View File
@@ -49,15 +49,10 @@ def testGuiMain_ProjectBlocker(nwGUI):
# Test no-project blocking
assert nwGUI.closeProject() is True
assert nwGUI.saveProject() is False
assert nwGUI.closeDocument() is False
assert nwGUI.openDocument(None) is False
assert nwGUI.openNextDocument(None) is False
assert nwGUI.saveDocument() is False
assert nwGUI.viewDocument(None) is False
assert nwGUI.importDocument() is False
assert nwGUI.openSelectedItem() is False
assert nwGUI.editItemLabel() is False
assert nwGUI.rebuildIndex() is False
# END Test testGuiMain_ProjectBlocker
@@ -109,7 +104,8 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
buildTestProject(nwGUI, projPath)
sHandle = "000000000000f"
assert nwGUI.openSelectedItem() is False
nwGUI.openSelectedItem()
assert nwGUI.docEditor.docHandle is None
# Project Tree has focus
nwGUI._changeView(nwView.PROJECT)
@@ -121,7 +117,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwGUI.projView.projTree._getTreeItem(sHandle).setSelected(True)
nwGUI._keyPressReturn()
assert nwGUI.docEditor.docHandle == sHandle
assert nwGUI.closeDocument() is True
nwGUI.closeDocument()
# Novel Tree has focus
nwGUI._changeView(nwView.NOVEL)
@@ -133,7 +129,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwGUI.novelView.novelTree.setCurrentItem(selItem)
nwGUI._keyPressReturn()
assert nwGUI.docEditor.docHandle == sHandle
assert nwGUI.closeDocument() is True
nwGUI.closeDocument()
# Project Outline has focus
nwGUI._changeView(nwView.OUTLINE)
@@ -145,7 +141,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwGUI.outlineView.outlineTree.setCurrentItem(selItem)
nwGUI._keyPressReturn()
assert nwGUI.docEditor.docHandle == sHandle
assert nwGUI.closeDocument() is True
nwGUI.closeDocument()
# qtbot.stop()
@@ -238,7 +234,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
nwGUI.projView.projTree.clearSelection()
nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
assert nwGUI.openSelectedItem()
nwGUI.openSelectedItem()
# Text Editor
# ===========
@@ -265,7 +261,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
nwGUI.projView.projTree.clearSelection()
nwGUI.projView.projTree._getTreeItem(C.hPlotRoot).setSelected(True)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
assert nwGUI.openSelectedItem()
nwGUI.openSelectedItem()
# Type something into the document
nwGUI.switchFocus(nwWidget.EDITOR)
@@ -287,7 +283,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
nwGUI.projView.projTree.clearSelection()
nwGUI.projView.projTree._getTreeItem(C.hWorldRoot).setSelected(True)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
assert nwGUI.openSelectedItem()
nwGUI.openSelectedItem()
# Add Some Text
docEditor.replaceText("Hello World!")
@@ -319,7 +315,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
nwGUI.projView.projTree._getTreeItem(C.hNovelRoot).setExpanded(True)
nwGUI.projView.projTree._getTreeItem(C.hChapterDir).setExpanded(True)
nwGUI.projView.projTree._getTreeItem(C.hSceneDoc).setSelected(True)
assert nwGUI.openSelectedItem()
nwGUI.openSelectedItem()
# Type something into the document
nwGUI.switchFocus(nwWidget.EDITOR)
@@ -524,8 +520,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
# Save the document
assert docEditor.docChanged
assert nwGUI.saveDocument()
assert not docEditor.docChanged
nwGUI.saveDocument()
assert docEditor.docChanged is False
nwGUI.rebuildIndex()
# Open and view the edited document
@@ -533,7 +529,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
assert nwGUI.openDocument(C.hSceneDoc)
assert nwGUI.viewDocument(C.hSceneDoc)
assert nwGUI.saveProject()
assert nwGUI.closeDocViewer()
assert nwGUI.closeViewerPanel()
# Check the files
projFile = projPath / "nwProject.nwx"
@@ -575,7 +571,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
def testGuiMain_Features(qtbot, nwGUI, projPath, mockRnd):
"""Test various features of the main window."""
buildTestProject(nwGUI, projPath)
assert nwGUI.isFocusMode is False
assert SHARED.focusMode is False
# Focus Mode
# ==========