Add document outline generator for editor

This commit is contained in:
Veronica Berglyd Olsen
2024-03-17 12:26:25 +01:00
parent c3aa8fec56
commit 5453d2ada1
3 changed files with 55 additions and 34 deletions
+35 -23
View File
@@ -59,7 +59,7 @@ from novelwriter.constants import nwKeyWords, nwLabels, nwShortcode, nwUnicode,
from novelwriter.tools.lipsum import GuiLipsum from novelwriter.tools.lipsum import GuiLipsum
from novelwriter.core.document import NWDocument from novelwriter.core.document import NWDocument
from novelwriter.text.counting import standardCounter 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.gui.editordocument import GuiTextDocument
from novelwriter.extensions.eventfilters import WheelEventFilter from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -185,18 +185,18 @@ class GuiDocEditor(QPlainTextEdit):
self.followTag2.activated.connect(self._processTag) self.followTag2.activated.connect(self._processTag)
# Set Up Document Word Counter # Set Up Document Word Counter
self.wcTimerDoc = QTimer() self.timerDoc = QTimer(self)
self.wcTimerDoc.timeout.connect(self._runDocCounter) self.timerDoc.timeout.connect(self._runDocumentTasks)
self.wcTimerDoc.setInterval(5000) self.timerDoc.setInterval(5000)
self.wCounterDoc = BackgroundWordCounter(self) self.wCounterDoc = BackgroundWordCounter(self)
self.wCounterDoc.setAutoDelete(False) self.wCounterDoc.setAutoDelete(False)
self.wCounterDoc.signals.countsReady.connect(self._updateDocCounts) self.wCounterDoc.signals.countsReady.connect(self._updateDocCounts)
# Set Up Selection Word Counter # Set Up Selection Word Counter
self.wcTimerSel = QTimer() self.timerSel = QTimer(self)
self.wcTimerSel.timeout.connect(self._runSelCounter) self.timerSel.timeout.connect(self._runSelCounter)
self.wcTimerSel.setInterval(500) self.timerSel.setInterval(500)
self.wCounterSel = BackgroundWordCounter(self, forSelection=True) self.wCounterSel = BackgroundWordCounter(self, forSelection=True)
self.wCounterSel.setAutoDelete(False) self.wCounterSel.setAutoDelete(False)
@@ -249,8 +249,8 @@ class GuiDocEditor(QPlainTextEdit):
self._nwDocument = None self._nwDocument = None
self.setReadOnly(True) self.setReadOnly(True)
self.clear() self.clear()
self.wcTimerDoc.stop() self.timerDoc.stop()
self.wcTimerSel.stop() self.timerSel.stop()
self._docHandle = None self._docHandle = None
self._lastEdit = 0.0 self._lastEdit = 0.0
@@ -397,8 +397,8 @@ class GuiDocEditor(QPlainTextEdit):
self._lastEdit = time() self._lastEdit = time()
self._lastActive = time() self._lastActive = time()
self._runDocCounter() self._runDocumentTasks()
self.wcTimerDoc.start() self.timerDoc.start()
self.setReadOnly(False) self.setReadOnly(False)
self.updateDocMargins() self.updateDocMargins()
@@ -432,7 +432,7 @@ class GuiDocEditor(QPlainTextEdit):
def updateTagHighLighting(self) -> None: def updateTagHighLighting(self) -> None:
"""Rerun the syntax highlighter on all meta data lines.""" """Rerun the syntax highlighter on all meta data lines."""
self._qDocument.syntaxHighlighter.rehighlightByType(GuiDocHighlighter.BLOCK_META) self._qDocument.syntaxHighlighter.rehighlightByType(BLOCK_META)
return return
def replaceText(self, text: str) -> None: def replaceText(self, text: str) -> None:
@@ -1033,8 +1033,8 @@ class GuiDocEditor(QPlainTextEdit):
if not self._docChanged: if not self._docChanged:
self.setDocumentChanged(removed != 0 or added != 0) self.setDocumentChanged(removed != 0 or added != 0)
if not self.wcTimerDoc.isActive(): if not self.timerDoc.isActive():
self.wcTimerDoc.start() self.timerDoc.start()
if (block := self._qDocument.findBlock(pos)).isValid(): if (block := self._qDocument.findBlock(pos)).isValid():
text = block.text() text = block.text()
@@ -1084,7 +1084,7 @@ class GuiDocEditor(QPlainTextEdit):
ctxMenu = QMenu(self) ctxMenu = QMenu(self)
ctxMenu.setObjectName("ContextMenu") ctxMenu.setObjectName("ContextMenu")
if pBlock.userState() == GuiDocHighlighter.BLOCK_TITLE: if pBlock.userState() == BLOCK_TITLE:
action = ctxMenu.addAction(self.tr("Set as Document Name")) action = ctxMenu.addAction(self.tr("Set as Document Name"))
action.triggered.connect(lambda: self._emitRenameItem(pBlock)) action.triggered.connect(lambda: self._emitRenameItem(pBlock))
@@ -1179,10 +1179,8 @@ class GuiDocEditor(QPlainTextEdit):
return return
@pyqtSlot() @pyqtSlot()
def _runDocCounter(self) -> None: def _runDocumentTasks(self) -> None:
"""Decide whether to run the word counter, or not due to """Run timer document tasks."""
inactivity.
"""
if self._docHandle is None: if self._docHandle is None:
return return
@@ -1193,6 +1191,7 @@ class GuiDocEditor(QPlainTextEdit):
if time() - self._lastEdit < 25.0: if time() - self._lastEdit < 25.0:
logger.debug("Running word counter") logger.debug("Running word counter")
SHARED.runInThreadPool(self.wCounterDoc) SHARED.runInThreadPool(self.wCounterDoc)
self._updateOutline()
return return
@@ -1214,10 +1213,10 @@ class GuiDocEditor(QPlainTextEdit):
information to the footer, and start the selection word counter. information to the footer, and start the selection word counter.
""" """
if self.textCursor().hasSelection(): if self.textCursor().hasSelection():
if not self.wcTimerSel.isActive(): if not self.timerSel.isActive():
self.wcTimerSel.start() self.timerSel.start()
else: else:
self.wcTimerSel.stop() self.timerSel.stop()
self.docFooter.updateWordCount(0, False) self.docFooter.updateWordCount(0, False)
return return
@@ -1241,7 +1240,7 @@ class GuiDocEditor(QPlainTextEdit):
if self._docHandle and self._nwItem: if self._docHandle and self._nwItem:
logger.debug("User selected %d words", wCount) logger.debug("User selected %d words", wCount)
self.docFooter.updateWordCount(wCount, True) self.docFooter.updateWordCount(wCount, True)
self.wcTimerSel.stop() self.timerSel.stop()
return return
@pyqtSlot() @pyqtSlot()
@@ -1837,6 +1836,13 @@ class GuiDocEditor(QPlainTextEdit):
# Internal Functions # Internal Functions
## ##
def _updateOutline(self) -> None:
"""Scan the text for headings and update the outline."""
data = [(b.blockNumber(), b.text()) for b in self._qDocument.iterBlockByType(BLOCK_TITLE)]
logger.debug("Document contains %d heading(s)", len(data))
self.docHeader.setOutline(data)
return
def _processTag(self, cursor: QTextCursor | None = None, def _processTag(self, cursor: QTextCursor | None = None,
follow: bool = True, create: bool = False) -> nwTrinary: follow: bool = True, create: bool = False) -> nwTrinary:
"""Activated by Ctrl+Enter. Checks that we're in a block """Activated by Ctrl+Enter. Checks that we're in a block
@@ -2802,6 +2808,7 @@ class GuiDocEditHeader(QWidget):
self.mainGui = docEditor.mainGui self.mainGui = docEditor.mainGui
self._docHandle = None self._docHandle = None
self._docOutline: list[tuple[int, str]] = []
fPx = int(0.9*SHARED.theme.fontPixelSize) fPx = int(0.9*SHARED.theme.fontPixelSize)
mPx = CONFIG.pxInt(8) mPx = CONFIG.pxInt(8)
@@ -2888,6 +2895,11 @@ class GuiDocEditHeader(QWidget):
# Methods # Methods
## ##
def setOutline(self, data: list[tuple[int, str]]) -> None:
"""Set the document outline dataset."""
self._docOutline = data
return
def updateTheme(self) -> None: def updateTheme(self) -> None:
"""Update theme elements.""" """Update theme elements."""
self.tbButton.setIcon(SHARED.theme.getIcon("menu")) self.tbButton.setIcon(SHARED.theme.getIcon("menu"))
+10 -10
View File
@@ -45,16 +45,16 @@ logger = logging.getLogger(__name__)
SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b") SPELLRX = QRegularExpression(r"\b[^\s\-\+\/–—\[\]:]+\b")
SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption) SPELLRX.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
BLOCK_NONE = 0
BLOCK_TEXT = 1
BLOCK_META = 2
BLOCK_TITLE = 4
class GuiDocHighlighter(QSyntaxHighlighter): class GuiDocHighlighter(QSyntaxHighlighter):
__slots__ = ("_tItem", "_tHandle", "_spellCheck", "_spellErr", "_hRules", "_hStyles") __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: def __init__(self, document: QTextDocument) -> None:
super().__init__(document) super().__init__(document)
@@ -272,12 +272,12 @@ class GuiDocHighlighter(QSyntaxHighlighter):
is significantly faster than running the regex checks used for is significantly faster than running the regex checks used for
text paragraphs. text paragraphs.
""" """
self.setCurrentBlockState(self.BLOCK_NONE) self.setCurrentBlockState(BLOCK_NONE)
if self._tHandle is None or not text: if self._tHandle is None or not text:
return return
if text.startswith("@"): # Keywords and commands if text.startswith("@"): # Keywords and commands
self.setCurrentBlockState(self.BLOCK_META) self.setCurrentBlockState(BLOCK_META)
index = SHARED.project.index index = SHARED.project.index
isValid, bits, pos = index.scanThis(text) isValid, bits, pos = index.scanThis(text)
isGood = index.checkThese(bits, self._tHandle) isGood = index.checkThese(bits, self._tHandle)
@@ -301,7 +301,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
return return
elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "###! ", "#### ")): elif text.startswith(("# ", "#! ", "## ", "##! ", "### ", "###! ", "#### ")):
self.setCurrentBlockState(self.BLOCK_TITLE) self.setCurrentBlockState(BLOCK_TITLE)
if text.startswith("# "): # Heading 1 if text.startswith("# "): # Heading 1
self.setFormat(0, 1, self._hStyles["head1h"]) self.setFormat(0, 1, self._hStyles["head1h"])
@@ -332,7 +332,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self.setFormat(4, len(text), self._hStyles["header3"]) self.setFormat(4, len(text), self._hStyles["header3"])
elif text.startswith("%"): # Comments elif text.startswith("%"): # Comments
self.setCurrentBlockState(self.BLOCK_TEXT) self.setCurrentBlockState(BLOCK_TEXT)
cStyle, _, cPos = processComment(text) cStyle, _, cPos = processComment(text)
if cStyle == nwComment.PLAIN: if cStyle == nwComment.PLAIN:
self.setFormat(0, len(text), self._hStyles["hidden"]) self.setFormat(0, len(text), self._hStyles["hidden"])
@@ -357,7 +357,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
return return
# Regular Text # Regular Text
self.setCurrentBlockState(self.BLOCK_TEXT) self.setCurrentBlockState(BLOCK_TEXT)
for rX, xFmt in self.rxRules: for rX, xFmt in self.rxRules:
rxItt = rX.globalMatch(text, 0) rxItt = rX.globalMatch(text, 0)
while rxItt.hasNext(): while rxItt.hasNext():
+10 -1
View File
@@ -23,11 +23,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator
import logging import logging
from time import time 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.QtCore import QObject, pyqtSlot
from PyQt5.QtWidgets import QPlainTextDocumentLayout, qApp from PyQt5.QtWidgets import QPlainTextDocumentLayout, qApp
from novelwriter import SHARED from novelwriter import SHARED
@@ -113,6 +114,14 @@ class GuiTextDocument(QTextDocument):
return word, cPos, cLen, SHARED.spelling.suggestWords(word) return word, cPos, cLen, SHARED.spelling.suggestWords(word)
return "", -1, -1, [] return "", -1, -1, []
def iterBlockByType(self, cType: int) -> Generator[QTextBlock]:
"""Iterate over all text blocks of a given type."""
for i in range(self.blockCount()):
block = self.findBlockByNumber(i)
if block.isValid() and block.userState() & cType > 0:
yield block
return None
## ##
# Public Slots # Public Slots
## ##