Add document outline generator for editor
This commit is contained in:
@@ -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"))
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
##
|
##
|
||||||
|
|||||||
Reference in New Issue
Block a user