Merge release 2.7.5

This commit is contained in:
Veronica Berglyd Olsen
2025-09-14 19:37:15 +02:00
323 changed files with 10078 additions and 4645 deletions
+108 -180
View File
@@ -29,7 +29,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import bisect
@@ -46,11 +46,11 @@ from PyQt6.QtGui import (
QAction, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent,
QInputMethodEvent, QKeyEvent, QKeySequence, QMouseEvent, QPalette, QPixmap,
QResizeEvent, QShortcut, QTextBlock, QTextCursor, QTextDocument,
QTextOption
QTextFormat, QTextOption
)
from PyQt6.QtWidgets import (
QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMenu,
QPlainTextEdit, QToolBar, QVBoxLayout, QWidget
QPlainTextEdit, QTextEdit, QToolBar, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
@@ -77,7 +77,7 @@ from novelwriter.types import (
QtAlignCenterTop, QtAlignJustify, QtAlignLeft, QtAlignLeftTop,
QtAlignRight, QtImCursorRectangle, QtKeepAnchor, QtModCtrl, QtModNone,
QtModShift, QtMouseLeft, QtMoveAnchor, QtMoveLeft, QtMoveRight,
QtScrollAlwaysOff, QtScrollAsNeeded
QtScrollAlwaysOff, QtScrollAsNeeded, QtTransparent
)
logger = logging.getLogger(__name__)
@@ -99,7 +99,7 @@ class _TagAction(IntFlag):
class GuiDocEditor(QPlainTextEdit):
"""Gui Widget: Main Document Editor"""
"""Gui Widget: Main Document Editor."""
__slots__ = (
"_autoReplace", "_completer", "_doReplace", "_docChanged", "_docHandle", "_followTag1",
@@ -147,6 +147,8 @@ class GuiDocEditor(QPlainTextEdit):
self._lastActive = 0.0 # Timestamp of last activity
self._lastFind = None # Position of the last found search word
self._doReplace = False # Switch to temporarily disable auto-replace
self._lineColor = QtTransparent
self._selection = QTextEdit.ExtraSelection()
# Auto-Replace
self._autoReplace = TextAutoReplace()
@@ -235,8 +237,6 @@ class GuiDocEditor(QPlainTextEdit):
logger.debug("Ready: GuiDocEditor")
return
##
# Properties
##
@@ -285,18 +285,16 @@ class GuiDocEditor(QPlainTextEdit):
self.docHeader.clearHeader()
self.docFooter.setHandle(self._docHandle)
self.docToolBar.setVisible(False)
self.setExtraSelections([])
self.itemHandleChanged.emit("")
return
def updateTheme(self) -> None:
"""Update theme elements."""
self.docSearch.updateTheme()
self.docHeader.updateTheme()
self.docFooter.updateTheme()
self.docToolBar.updateTheme()
return
def updateSyntaxColors(self) -> None:
"""Update the syntax highlighting theme."""
@@ -317,7 +315,9 @@ class GuiDocEditor(QPlainTextEdit):
self.docHeader.matchColors()
self.docFooter.matchColors()
return
self._lineColor = syntax.line
self._selection.format.setBackground(self._lineColor)
self._selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
def initEditor(self) -> None:
"""Initialise or re-initialise the editor with the user's
@@ -374,6 +374,8 @@ class GuiDocEditor(QPlainTextEdit):
# Refresh sizes
self.setTabStopDistance(CONFIG.tabWidth)
self.setCursorWidth(CONFIG.cursorWidth)
self.setExtraSelections([])
self._cursorMoved()
# If we have a document open, we should refresh it in case the
# font changed, otherwise we just clear the editor entirely,
@@ -384,8 +386,6 @@ class GuiDocEditor(QPlainTextEdit):
else:
self.clearEditor()
return
def loadText(self, tHandle: str, tLine: int | None = None) -> bool:
"""Load text from a document into the editor. If we have an I/O
error, we must handle this and clear the editor so that we don't
@@ -463,7 +463,6 @@ class GuiDocEditor(QPlainTextEdit):
self.updateDocMargins()
self.setDocumentChanged(True)
QApplication.restoreOverrideCursor()
return
def saveText(self) -> bool:
"""Save the text currently in the editor to the NWDocument
@@ -531,7 +530,6 @@ class GuiDocEditor(QPlainTextEdit):
vBar.setValue(vBar.value() + 1)
count += 1
QApplication.processEvents()
return
def updateDocMargins(self) -> None:
"""Automatically adjust the margins so the text is centred if
@@ -572,8 +570,6 @@ class GuiDocEditor(QPlainTextEdit):
lM = max(self._vpMargin, fH)
self.setViewportMargins(tM, uM, tM, lM)
return
##
# Getters
##
@@ -583,20 +579,19 @@ class GuiDocEditor(QPlainTextEdit):
QTextDocument->toRawText instead of toPlainText. The former preserves
non-breaking spaces, the latter does not. We still want to get rid of
paragraph and line separators though.
See: https://doc.qt.io/qt-6/qtextdocument.html#toPlainText
"""
text = self._qDocument.toRawText()
text = text.replace(nwUnicode.U_LSEP, "\n") # Line separators
text = text.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators
return text
return text.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators
def getSelectedText(self) -> str:
"""Get currently selected text."""
if (cursor := self.textCursor()).hasSelection():
text = cursor.selectedText()
text = text.replace(nwUnicode.U_LSEP, "\n") # Line separators
text = text.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators
return text
return text.replace(nwUnicode.U_PSEP, "\n") # Paragraph separators
return ""
def getCursorPosition(self) -> int:
@@ -617,7 +612,6 @@ class GuiDocEditor(QPlainTextEdit):
logger.debug("Document changed status is '%s'", state)
self._docChanged = state
self.editedStatusChanged.emit(self._docChanged)
return
def setCursorPosition(self, position: int) -> None:
"""Move the cursor to a given position in the document."""
@@ -626,14 +620,12 @@ class GuiDocEditor(QPlainTextEdit):
cursor.setPosition(minmax(position, 0, chars-1))
self.setTextCursor(cursor)
self.centerCursor()
return
def saveCursorPosition(self) -> None:
"""Save the cursor position to the current project item."""
if self._nwItem is not None:
cursPos = self.getCursorPosition()
self._nwItem.setCursorPos(cursPos)
return
def setCursorLine(self, line: int | None) -> None:
"""Move the cursor to a given line in the document."""
@@ -642,7 +634,6 @@ class GuiDocEditor(QPlainTextEdit):
if block:
self.setCursorPosition(block.position())
logger.debug("Cursor moved to line %d", line)
return
def setCursorSelection(self, start: int, length: int) -> None:
"""Make a text selection."""
@@ -651,14 +642,15 @@ class GuiDocEditor(QPlainTextEdit):
cursor.setPosition(start, QtMoveAnchor)
cursor.setPosition(start + length, QtKeepAnchor)
self.setTextCursor(cursor)
return
##
# Spell Checking
##
def toggleSpellCheck(self, state: bool | None) -> None:
"""This is the main spell check setting function, and this one
"""Toggle spell checking.
This is the main spell check setting function, and this one
should call all other setSpellCheck functions in other classes.
If the spell check state is not defined (None), then toggle the
current status saved in this class.
@@ -682,8 +674,6 @@ class GuiDocEditor(QPlainTextEdit):
logger.debug("Spell check is set to '%s'", str(state))
return
def spellCheckDocument(self) -> None:
"""Rerun the highlighter to update spell checking status of the
currently loaded text.
@@ -695,7 +685,6 @@ class GuiDocEditor(QPlainTextEdit):
QApplication.restoreOverrideCursor()
logger.debug("Document highlighted in %.3f ms", 1000*(time() - start))
self.updateStatusMessage.emit(self.tr("Spell check complete"))
return
##
# General Class Methods
@@ -735,6 +724,8 @@ class GuiDocEditor(QPlainTextEdit):
self._toggleFormat(2, "*")
elif action == nwDocAction.MD_STRIKE:
self._toggleFormat(2, "~")
elif action == nwDocAction.MD_MARK:
self._toggleFormat(2, "=")
elif action == nwDocAction.S_QUOTE:
self._wrapSelection(CONFIG.fmtSQuoteOpen, CONFIG.fmtSQuoteClose)
elif action == nwDocAction.D_QUOTE:
@@ -822,7 +813,6 @@ class GuiDocEditor(QPlainTextEdit):
details=self.tr("File Location: {0}").format(self._nwDocument.fileLocation),
log=False
)
return
def insertText(self, insert: str | nwDocInsert) -> None:
"""Insert a specific type of text at the cursor position."""
@@ -964,7 +954,6 @@ class GuiDocEditor(QPlainTextEdit):
event.acceptProposedAction()
else:
super().dragEnterEvent(event)
return
def dragMoveEvent(self, event: QDragMoveEvent) -> None:
"""Overload drag move event to handle dragged items."""
@@ -972,7 +961,6 @@ class GuiDocEditor(QPlainTextEdit):
event.acceptProposedAction()
else:
super().dragMoveEvent(event)
return
def dropEvent(self, event: QDropEvent) -> None:
"""Overload drop event to handle dragged items."""
@@ -982,7 +970,6 @@ class GuiDocEditor(QPlainTextEdit):
self.openDocumentRequest.emit(handles[0], nwDocMode.EDIT, "", True)
else:
super().dropEvent(event)
return
def focusNextPrevChild(self, _next: bool) -> bool:
"""Capture the focus request from the tab key on the text
@@ -1009,7 +996,6 @@ class GuiDocEditor(QPlainTextEdit):
else:
self._processTag(cursor)
super().mouseReleaseEvent(event)
return
def resizeEvent(self, event: QResizeEvent) -> None:
"""If the text editor is resized, we must make sure the document
@@ -1017,7 +1003,6 @@ class GuiDocEditor(QPlainTextEdit):
"""
self.updateDocMargins()
super().resizeEvent(event)
return
def inputMethodEvent(self, event: QInputMethodEvent) -> None:
"""Handle text being input from CJK input methods."""
@@ -1045,14 +1030,13 @@ class GuiDocEditor(QPlainTextEdit):
@pyqtSlot(str, Enum)
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Called when an item label is changed to check if the document
title bar needs updating,
"""Process project item change. Called when an item label is
changed to check if the document title bar needs updating.
"""
if tHandle == self._docHandle and change == nwChange.UPDATE:
self.docHeader.setHandle(tHandle)
self.docFooter.updateInfo()
self.updateDocMargins()
return
@pyqtSlot(str)
def insertKeyWord(self, keyword: str) -> bool:
@@ -1063,8 +1047,7 @@ class GuiDocEditor(QPlainTextEdit):
logger.error("Invalid keyword '%s'", keyword)
return False
logger.debug("Inserting keyword '%s'", keyword)
state = self.insertNewBlock(f"{keyword}: ")
return state
return self.insertNewBlock(f"{keyword}: ")
@pyqtSlot()
def toggleSearch(self) -> None:
@@ -1073,14 +1056,12 @@ class GuiDocEditor(QPlainTextEdit):
self.closeSearch()
else:
self.beginSearch()
return
@pyqtSlot(list, list)
def updateChangedTags(self, updated: list[str], deleted: list[str]) -> None:
"""Tags have changed, so just in case we rehighlight them."""
if updated or deleted:
self._qDocument.syntaxHighlighter.rehighlightByType(BLOCK_META)
return
##
# Private Slots
@@ -1122,13 +1103,14 @@ class GuiDocEditor(QPlainTextEdit):
if self._autoReplace.process(text, cursor):
self._qDocument.syntaxHighlighter.rehighlightBlock(cursor.block())
return
@pyqtSlot()
def _cursorMoved(self) -> None:
"""Triggered when the cursor moved in the editor."""
self.docFooter.updateLineCount(self.textCursor())
return
if CONFIG.lineHighlight:
self._selection.cursor = self.textCursor()
self._selection.cursor.clearSelection()
self.setExtraSelections([self._selection])
@pyqtSlot(int, int, str)
def _insertCompletion(self, pos: int, length: int, text: str) -> None:
@@ -1140,13 +1122,11 @@ class GuiDocEditor(QPlainTextEdit):
cursor.setPosition(check + length, QtKeepAnchor)
cursor.insertText(text)
self._completer.close()
return
@pyqtSlot()
def _openContextFromCursor(self) -> None:
"""Open the spell check context menu at the cursor."""
self._openContextMenu(self.cursorRect().center())
return
@pyqtSlot("QPoint")
def _openContextMenu(self, pos: QPoint) -> None:
@@ -1204,13 +1184,13 @@ class GuiDocEditor(QPlainTextEdit):
# Spell Checking
if SHARED.project.data.spellCheck:
word, cPos, cLen, suggest = self._qDocument.spellErrorAtPos(pCursor.position())
if word and cPos >= 0 and cLen > 0:
word, offset, suggest = self._qDocument.spellErrorAtPos(pCursor.position())
if word and offset >= 0:
logger.debug("Word '%s' is misspelled", word)
block = pCursor.block()
sCursor = self.textCursor()
sCursor.setPosition(block.position() + cPos)
sCursor.movePosition(QtMoveRight, QtKeepAnchor, cLen)
sCursor.setPosition(block.position() + offset)
sCursor.movePosition(QtMoveRight, QtKeepAnchor, len(word))
if suggest:
ctxMenu.addSeparator()
qtAddAction(ctxMenu, self.tr("Spelling Suggestion(s)"))
@@ -1233,8 +1213,6 @@ class GuiDocEditor(QPlainTextEdit):
ctxMenu.setParent(None)
return
@pyqtSlot()
def _runDocumentTasks(self) -> None:
"""Run timer document tasks."""
@@ -1271,11 +1249,10 @@ class GuiDocEditor(QPlainTextEdit):
if not self.textCursor().hasSelection():
# Selection counter should take precedence (#2155)
self.docFooter.updateMainCount(mCount, False)
return
@pyqtSlot()
def _updateSelectedStatus(self) -> None:
"""The user made a change in text selection. Forward this
"""Process user change in text selection. Forward this
information to the footer, and start the selection word counter.
"""
if self.textCursor().hasSelection():
@@ -1284,7 +1261,6 @@ class GuiDocEditor(QPlainTextEdit):
else:
self._timerSel.stop()
self.docFooter.updateMainCount(0, False)
return
@pyqtSlot()
def _runSelCounter(self) -> None:
@@ -1302,14 +1278,12 @@ class GuiDocEditor(QPlainTextEdit):
if self._docHandle and self._nwItem:
self.docFooter.updateMainCount(cCount if CONFIG.useCharCount else wCount, True)
self._timerSel.stop()
return
@pyqtSlot()
def _closeCurrentDocument(self) -> None:
"""Close the document. Forwarded to the main Gui."""
self.closeEditorRequest.emit()
self.docToolBar.setVisible(False)
return
@pyqtSlot()
def _toggleToolBarVisibility(self) -> None:
@@ -1317,7 +1291,6 @@ class GuiDocEditor(QPlainTextEdit):
state = not self.docToolBar.isVisible()
self.docToolBar.setVisible(state)
CONFIG.showEditToolBar = state
return
##
# Search & Replace
@@ -1328,14 +1301,12 @@ class GuiDocEditor(QPlainTextEdit):
self.docSearch.setSearchText(self.getSelectedText() or None)
resS, _ = self.findAllOccurences()
self.docSearch.setResultCount(None, len(resS))
return
def beginReplace(self) -> None:
"""Initialise the search box and reset the replace text box."""
self.beginSearch()
self.docSearch.setReplaceText("")
self.updateDocMargins()
return
def findNext(self, goBack: bool = False) -> None:
"""Search for the next or previous occurrence of the search bar
@@ -1623,8 +1594,6 @@ class GuiDocEditor(QPlainTextEdit):
self.setTextCursor(cursor)
return
def _replaceQuotes(self, sQuote: str, oQuote: str, cQuote: str) -> None:
"""Replace all straight quotes in the selected text."""
cursor = self.textCursor()
@@ -1884,8 +1853,6 @@ class GuiDocEditor(QPlainTextEdit):
cursor.insertText(cleanText.rstrip() + "\n")
cursor.endEditBlock()
return
def _insertCommentStructure(self, style: nwComment) -> None:
"""Insert a shortcut/comment combo."""
if self._docHandle and style == nwComment.FOOTNOTE:
@@ -1933,7 +1900,6 @@ class GuiDocEditor(QPlainTextEdit):
cursor.endEditBlock()
cursor.setPosition(pos)
self.setTextCursor(cursor)
return
def _addWord(self, word: str, block: QTextBlock, save: bool) -> None:
"""Slot for the spell check context menu triggered when the user
@@ -1942,7 +1908,6 @@ class GuiDocEditor(QPlainTextEdit):
logger.debug("Added '%s' to project dictionary, %s", word, "saved" if save else "unsaved")
SHARED.spelling.addWord(word, save=save)
self._qDocument.syntaxHighlighter.rehighlightBlock(block)
return
def _processTag(
self, cursor: QTextCursor | None = None, follow: bool = True, create: bool = False
@@ -2016,7 +1981,6 @@ class GuiDocEditor(QPlainTextEdit):
if self._docHandle:
text = block.text().lstrip("#").lstrip("!").strip()
self.requestProjectItemRenamed.emit(self._docHandle, text)
return
def _autoSelect(self) -> QTextCursor:
"""Return a cursor which may or may not have a selection based
@@ -2086,14 +2050,11 @@ class GuiDocEditor(QPlainTextEdit):
self.setTextCursor(cursor)
return
def _makePosSelection(self, mode: QTextCursor.SelectionType, pos: QPoint) -> None:
"""Select text based on selection mode, but first move cursor."""
cursor = self.cursorForPosition(pos)
self.setTextCursor(cursor)
self._makeSelection(mode)
return
def _allowAutoReplace(self, state: bool) -> None:
"""Enable/disable the auto-replace feature temporarily."""
@@ -2101,11 +2062,10 @@ class GuiDocEditor(QPlainTextEdit):
self._doReplace = CONFIG.doReplace
else:
self._doReplace = False
return
class CommandCompleter(QMenu):
"""GuiWidget: Command Completer Menu
"""GuiWidget: Command Completer Menu.
This is a context menu with options populated from the user's
defined tags and keys. It also helps to type the meta data keyword
@@ -2119,8 +2079,6 @@ class CommandCompleter(QMenu):
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
self._parent = parent
return
def updateMetaText(self, text: str, pos: int) -> bool:
"""Update the menu options based on the line of text."""
@@ -2214,7 +2172,6 @@ class CommandCompleter(QMenu):
else:
self.close() # Close to release the event lock before forwarding the key press (#2510)
self._parent.keyPressEvent(event)
return
##
# Internal Functions
@@ -2223,11 +2180,10 @@ class CommandCompleter(QMenu):
def _emitComplete(self, pos: int, length: int, value: str) -> None:
"""Emit the signal to indicate a selection has been made."""
self.insertText.emit(pos, length, value)
return
class BackgroundWordCounter(QRunnable):
"""The Off-GUI Thread Word Counter
"""The Off-GUI Thread Word Counter.
A runnable for the word counter to be run in the thread pool off the
main GUI thread.
@@ -2239,9 +2195,9 @@ class BackgroundWordCounter(QRunnable):
self._forSelection = forSelection
self._isRunning = False
self.signals = BackgroundWordCounterSignals()
return
def isRunning(self) -> bool:
"""Return True if the word counter is already running."""
return self._isRunning
@pyqtSlot()
@@ -2259,17 +2215,17 @@ class BackgroundWordCounter(QRunnable):
self.signals.countsReady.emit(cC, wC, pC)
self._isRunning = False
return
class BackgroundWordCounterSignals(QObject):
"""The QRunnable cannot emit a signal, so we need a simple QObject
to hold the word counter signal.
"""
countsReady = pyqtSignal(int, int, int)
class TextAutoReplace:
"""Encapsulates the editor auto replace feature."""
__slots__ = (
"_doPadAfter", "_doPadBefore", "_padAfter", "_padBefore", "_padChar",
@@ -2279,7 +2235,6 @@ class TextAutoReplace:
def __init__(self) -> None:
self.initSettings()
return
def initSettings(self) -> None:
"""Initialise the auto-replace settings from config."""
@@ -2298,27 +2253,28 @@ class TextAutoReplace:
self._padAfter = CONFIG.fmtPadAfter
self._doPadBefore = bool(CONFIG.fmtPadBefore)
self._doPadAfter = bool(CONFIG.fmtPadAfter)
return
def process(self, text: str, cursor: QTextCursor) -> bool:
"""Auto-replace text elements based on main configuration.
Returns True if anything was changed.
"""
pos = cursor.positionInBlock()
length = len(text)
if length < 1 or pos-1 > length:
aPos = cursor.position()
bPos = cursor.positionInBlock()
block = cursor.block()
length = block.length() - 1
if length < 1 or bPos-1 > length:
return False
delete, insert = self._determine(text, pos)
if insert == "":
return False
cursor.movePosition(QtMoveLeft, QtKeepAnchor, min(4, bPos))
last = cursor.selectedText()
delete, insert = self._determine(last, bPos)
check = insert
if self._doPadBefore and check in self._padBefore:
if not (check == ":" and length > 1 and text[0] == "@"):
delete = max(delete, 1)
chkPos = pos - delete - 1
if chkPos >= 0 and text[chkPos].isspace():
chkPos = len(last) - delete - 1
if chkPos >= 0 and last[chkPos].isspace():
# Strip existing space before inserting a new (#1061)
delete += 1
insert = self._padChar + insert
@@ -2329,6 +2285,7 @@ class TextAutoReplace:
insert = insert + self._padChar
if delete > 0:
cursor.setPosition(aPos)
cursor.movePosition(QtMoveLeft, QtKeepAnchor, delete)
cursor.insertText(insert)
return True
@@ -2337,42 +2294,55 @@ class TextAutoReplace:
def _determine(self, text: str, pos: int) -> tuple[int, str]:
"""Determine what to replace, if anything."""
t1 = text[pos-1:pos]
t2 = text[pos-2:pos]
t3 = text[pos-3:pos]
t4 = text[pos-4:pos]
if t1 == "":
# Return early if there is nothing to check
return 0, ""
t1 = text[-1:]
t2 = text[-2:]
t3 = text[-3:]
t4 = text[-4:]
leading = t2[:1].isspace()
if self._replaceDQuote:
if leading and t2.endswith('"'):
if self._replaceDQuote and t1 == '"':
# Process Double Quote
if pos == 1:
return 1, self._quoteDO
elif t1 == '"':
if pos == 1:
return 1, self._quoteDO
elif pos == 2 and t2 == '>"':
return 1, self._quoteDO
elif pos == 3 and t3 == '>>"':
return 1, self._quoteDO
else:
return 1, self._quoteDC
elif t2[:1].isspace() and t2.endswith('"'):
return 1, self._quoteDO
elif pos == 2 and t2 == '>"':
return 1, self._quoteDO
elif pos == 3 and t3 == '>>"':
return 1, self._quoteDO
elif pos == 2 and t2 == '_"':
return 1, self._quoteDO
elif t3[:1].isspace() and t3.endswith('_"'):
return 1, self._quoteDO
elif pos == 3 and t3 in ('**"', '=="', '~~"'):
return 1, self._quoteDO
elif t4[:1].isspace() and t4.endswith(('**"', '=="', '~~"')):
return 1, self._quoteDO
else:
return 1, self._quoteDC
if self._replaceSQuote:
if leading and t2.endswith("'"):
if self._replaceSQuote and t1 == "'":
# Process Single Quote
if pos == 1:
return 1, self._quoteSO
elif t1 == "'":
if pos == 1:
return 1, self._quoteSO
elif pos == 2 and t2 == ">'":
return 1, self._quoteSO
elif pos == 3 and t3 == ">>'":
return 1, self._quoteSO
else:
return 1, self._quoteSC
elif t2[:1].isspace() and t2.endswith("'"):
return 1, self._quoteSO
elif pos == 2 and t2 == ">'":
return 1, self._quoteSO
elif pos == 3 and t3 == ">>'":
return 1, self._quoteSO
elif pos == 2 and t2 == "_'":
return 1, self._quoteSO
elif t3[:1].isspace() and t3.endswith("_'"):
return 1, self._quoteSO
elif pos == 3 and t3 in ("**'", "=='", "~~'"):
return 1, self._quoteSO
elif t4[:1].isspace() and t4.endswith(("**'", "=='", "~~'")):
return 1, self._quoteSO
else:
return 1, self._quoteSC
if self._replaceDash:
if self._replaceDash and t1 == "-":
# Process Dashes
if t4 == "----":
return 4, "\u2015" # Horizontal bar
elif t3 == "---":
@@ -2385,6 +2355,7 @@ class TextAutoReplace:
return 2, "\u2015" # Horizontal bar
if self._replaceDots and t3 == "...":
# Process Dots
return 3, "\u2026" # Ellipsis
if t1 == "\u2028": # Line separator
@@ -2395,7 +2366,7 @@ class TextAutoReplace:
class GuiDocToolBar(QWidget):
"""The Formatting and Options Fold Out Menu
"""The Formatting and Options Fold Out Menu.
Only used by DocEditor, and is opened by the first button in the
header.
@@ -2432,6 +2403,12 @@ class GuiDocToolBar(QWidget):
qtLambda(self.requestDocAction.emit, nwDocAction.MD_STRIKE)
)
self.tbMarkMD = NIconToolButton(self, iSz)
self.tbMarkMD.setToolTip(self.tr("Markdown Highlight"))
self.tbMarkMD.clicked.connect(
qtLambda(self.requestDocAction.emit, nwDocAction.MD_MARK)
)
self.tbBold = NIconToolButton(self, iSz)
self.tbBold.setToolTip(self.tr("Shortcode Bold"))
self.tbBold.clicked.connect(
@@ -2481,6 +2458,7 @@ class GuiDocToolBar(QWidget):
self.outerBox.addWidget(self.tbBoldMD)
self.outerBox.addWidget(self.tbItalicMD)
self.outerBox.addWidget(self.tbStrikeMD)
self.outerBox.addWidget(self.tbMarkMD)
self.outerBox.addSpacing(4)
self.outerBox.addWidget(self.tbBold)
self.outerBox.addWidget(self.tbItalic)
@@ -2500,8 +2478,6 @@ class GuiDocToolBar(QWidget):
logger.debug("Ready: GuiDocToolBar")
return
def updateTheme(self) -> None:
"""Initialise GUI elements that depend on specific settings."""
syntax = SHARED.theme.syntaxTheme
@@ -2515,6 +2491,7 @@ class GuiDocToolBar(QWidget):
self.tbBoldMD.setThemeIcon("fmt_bold", "orange")
self.tbItalicMD.setThemeIcon("fmt_italic", "orange")
self.tbStrikeMD.setThemeIcon("fmt_strike", "orange")
self.tbMarkMD.setThemeIcon("fmt_mark", "orange")
self.tbBold.setThemeIcon("fmt_bold")
self.tbItalic.setThemeIcon("fmt_italic")
self.tbStrike.setThemeIcon("fmt_strike")
@@ -2523,11 +2500,9 @@ class GuiDocToolBar(QWidget):
self.tbSuperscript.setThemeIcon("fmt_superscript")
self.tbSubscript.setThemeIcon("fmt_subscript")
return
class GuiDocEditSearch(QFrame):
"""The Embedded Document Search/Replace Feature
"""The Embedded Document Search/Replace Feature.
Only used by DocEditor, and is at a fixed position in the
QTextEdit's viewport.
@@ -2657,8 +2632,6 @@ class GuiDocEditSearch(QFrame):
logger.debug("Ready: GuiDocEditSearch")
return
##
# Properties
##
@@ -2707,14 +2680,12 @@ class GuiDocEditSearch(QFrame):
self.searchBox.selectAll()
if CONFIG.searchRegEx:
self._alertSearchValid(True)
return
def setReplaceText(self, text: str) -> None:
"""Set the replace text."""
self.showReplace.setChecked(True)
self.replaceBox.setFocus()
self.replaceBox.setText(text)
return
def setResultCount(self, currRes: int | None, resCount: int | None) -> None:
"""Set the count values for the current search."""
@@ -2729,7 +2700,6 @@ class GuiDocEditSearch(QFrame):
self.resultLabel.setMinimumWidth(minWidth)
self.adjustSize()
self.docEditor.updateDocMargins()
return
##
# Methods
@@ -2745,7 +2715,6 @@ class GuiDocEditSearch(QFrame):
self.resultLabel.setMinimumWidth(
SHARED.theme.getTextWidth("?/?", SHARED.theme.guiFontSmall)
)
return
def updateTheme(self) -> None:
"""Update theme elements."""
@@ -2770,11 +2739,9 @@ class GuiDocEditSearch(QFrame):
self.searchOpt.setStyleSheet("QToolBar {padding: 0;}")
self.showReplace.setStyleSheet("QToolButton {border: none; background: transparent;}")
return
def cycleFocus(self) -> bool:
"""The tab key just alternates focus between the two input
boxes, if the replace box is visible.
"""Cycle focus on tab key press. This just alternates focus
between the two input boxes, if the replace box is visible.
"""
if self.searchBox.hasFocus():
self.replaceBox.setFocus()
@@ -2799,7 +2766,6 @@ class GuiDocEditSearch(QFrame):
self.setVisible(False)
self.docEditor.updateDocMargins()
self.docEditor.setFocus()
return
##
# Private Slots
@@ -2809,13 +2775,11 @@ class GuiDocEditSearch(QFrame):
def _doSearch(self) -> None:
"""Call the search action function for the document editor."""
self.docEditor.findNext(goBack=(QApplication.keyboardModifiers() == QtModShift))
return
@pyqtSlot()
def _doReplace(self) -> None:
"""Call the replace action function for the document editor."""
self.docEditor.replaceNext()
return
@pyqtSlot(bool)
def _doToggleReplace(self, state: bool) -> None:
@@ -2824,43 +2788,36 @@ class GuiDocEditSearch(QFrame):
self.replaceButton.setVisible(state)
self.adjustSize()
self.docEditor.updateDocMargins()
return
@pyqtSlot(bool)
def _doToggleCase(self, state: bool) -> None:
"""Enable/disable case sensitive mode."""
CONFIG.searchCase = state
return
@pyqtSlot(bool)
def _doToggleWord(self, state: bool) -> None:
"""Enable/disable whole word search mode."""
CONFIG.searchWord = state
return
@pyqtSlot(bool)
def _doToggleRegEx(self, state: bool) -> None:
"""Enable/disable regular expression search mode."""
CONFIG.searchRegEx = state
return
@pyqtSlot(bool)
def _doToggleLoop(self, state: bool) -> None:
"""Enable/disable looping the search."""
CONFIG.searchLoop = state
return
@pyqtSlot(bool)
def _doToggleProject(self, state: bool) -> None:
"""Enable/disable continuing search in next project file."""
CONFIG.searchNextFile = state
return
@pyqtSlot(bool)
def _doToggleMatchCap(self, state: bool) -> None:
"""Enable/disable preserving capitalisation when replacing."""
CONFIG.searchMatchCap = state
return
##
# Internal Functions
@@ -2876,11 +2833,10 @@ class GuiDocEditSearch(QFrame):
palette.text().color() if isValid else SHARED.theme.errorText
)
self.searchBox.setPalette(palette)
return
class GuiDocEditHeader(QWidget):
"""The Embedded Document Header
"""The Embedded Document Header.
Only used by DocEditor, and is at a fixed position in the
QTextEdit's viewport.
@@ -2971,8 +2927,6 @@ class GuiDocEditHeader(QWidget):
logger.debug("Ready: GuiDocEditHeader")
return
##
# Methods
##
@@ -2989,7 +2943,6 @@ class GuiDocEditHeader(QWidget):
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."""
@@ -3001,13 +2954,11 @@ class GuiDocEditHeader(QWidget):
action.triggered.connect(qtLambda(self._gotoBlock, number))
self._docOutline = data
logger.debug("Document outline updated in %.3f ms", 1000*(time() - tStart))
return
def updateFont(self) -> None:
"""Update the font settings."""
self.setFont(SHARED.theme.guiFont)
self.itemTitle.setFont(SHARED.theme.guiFontSmall)
return
def updateTheme(self) -> None:
"""Update theme elements."""
@@ -3026,8 +2977,6 @@ class GuiDocEditHeader(QWidget):
self.matchColors()
return
def matchColors(self) -> None:
"""Update the colours of the widget to match those of the syntax
theme rather than the main GUI.
@@ -3041,12 +2990,10 @@ class GuiDocEditHeader(QWidget):
self.itemTitle.setTextColors(
color=palette.windowText().color(), faded=SHARED.theme.fadedText
)
return
def changeFocusState(self, state: bool) -> None:
"""Toggle focus state."""
self.itemTitle.setColorState(state)
return
def setHandle(self, tHandle: str) -> None:
"""Set the document title from the handle, or alternatively, set
@@ -3067,8 +3014,6 @@ class GuiDocEditHeader(QWidget):
self.closeButton.setVisible(True)
self.minmaxButton.setVisible(True)
return
##
# Private Slots
##
@@ -3078,19 +3023,16 @@ class GuiDocEditHeader(QWidget):
"""Trigger the close editor on the main window."""
self.clearHeader()
self.closeDocumentRequest.emit()
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.setThemeIcon("minimise" if focusMode else "maximise", "blue")
return
##
# Events
@@ -3102,11 +3044,10 @@ class GuiDocEditHeader(QWidget):
"""
if event.button() == QtMouseLeft:
self.docEditor.requestProjectItemSelected.emit(self._docHandle or "", True)
return
class GuiDocEditFooter(QWidget):
"""The Embedded Document Footer
"""The Embedded Document Footer.
Only used by DocEditor, and is at a fixed position in the
QTextEdit's viewport.
@@ -3191,8 +3132,6 @@ class GuiDocEditFooter(QWidget):
logger.debug("Ready: GuiDocEditFooter")
return
##
# Methods
##
@@ -3202,7 +3141,6 @@ class GuiDocEditFooter(QWidget):
self._trMainCount = trStats(nwLabels.STATS_DISPLAY[
nwStats.CHARS if CONFIG.useCharCount else nwStats.WORDS
])
return
def updateFont(self) -> None:
"""Update the font settings."""
@@ -3210,7 +3148,6 @@ class GuiDocEditFooter(QWidget):
self.statusText.setFont(SHARED.theme.guiFontSmall)
self.linesText.setFont(SHARED.theme.guiFontSmall)
self.wordsText.setFont(SHARED.theme.guiFontSmall)
return
def updateTheme(self) -> None:
"""Update theme elements."""
@@ -3218,7 +3155,6 @@ class GuiDocEditFooter(QWidget):
self.linesIcon.setPixmap(SHARED.theme.getPixmap("lines", (iPx, iPx)))
self.wordsIcon.setPixmap(SHARED.theme.getPixmap("stats", (iPx, iPx)))
self.matchColors()
return
def matchColors(self) -> None:
"""Update the colours of the widget to match those of the syntax
@@ -3236,8 +3172,6 @@ class GuiDocEditFooter(QWidget):
self.linesText.setPalette(palette)
self.wordsText.setPalette(palette)
return
def setHandle(self, tHandle: str | None) -> None:
"""Set the handle that will populate the footer's data."""
self._docHandle = tHandle
@@ -3250,8 +3184,6 @@ class GuiDocEditFooter(QWidget):
self.updateInfo()
self.updateMainCount(0, False)
return
def updateInfo(self) -> None:
"""Update the content of text labels."""
if self._tItem is None:
@@ -3266,8 +3198,6 @@ class GuiDocEditFooter(QWidget):
self.statusIcon.setPixmap(sIcon)
self.statusText.setText(sText)
return
def updateLineCount(self, cursor: QTextCursor) -> None:
"""Update the line and document position counter."""
if document := cursor.document():
@@ -3277,7 +3207,6 @@ class GuiDocEditFooter(QWidget):
self.linesText.setText(
self._trLineCount.format(f"{cLine:n}", f"{100*cPos//cCount:d} %")
)
return
def updateMainCount(self, count: int, selection: bool) -> None:
"""Update main counter information."""
@@ -3290,4 +3219,3 @@ class GuiDocEditFooter(QWidget):
else:
text = self._trMainCount.format("0", "+0")
self.wordsText.setText(text)
return
+139 -96
View File
@@ -21,7 +21,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -36,11 +36,12 @@ from PyQt6.QtGui import (
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt
from novelwriter.common import checkInt, utf16CharMap
from novelwriter.constants import nwStyles, nwUnicode
from novelwriter.enum import nwComment
from novelwriter.text.comments import processComment
from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
from novelwriter.types import QtTextUserProperty
logger = logging.getLogger(__name__)
@@ -56,6 +57,7 @@ BLOCK_TITLE = 4
class GuiDocHighlighter(QSyntaxHighlighter):
"""GUI: Editor Syntax Highlighter."""
__slots__ = (
"_cmnRules", "_dialogParser", "_hStyles", "_isInactive", "_isNovel",
@@ -84,8 +86,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
logger.debug("Ready: GuiDocHighlighter")
return
def initHighlighter(self) -> None:
"""Initialise the syntax highlighter, setting all the colour
rules and building the RegExes.
@@ -110,6 +110,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._addCharFormat("bold", colEmph, "b")
self._addCharFormat("italic", colEmph, "i")
self._addCharFormat("strike", syntax.hidden, "s")
self._addCharFormat("mark", syntax.mark, "bg")
self._addCharFormat("mspaces", syntax.error, "err")
self._addCharFormat("nobreak", colBreak, "bg")
self._addCharFormat("altdialog", syntax.dialA)
@@ -139,7 +140,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
# Multiple or Trailing Spaces
if CONFIG.showMultiSpaces:
rxRule = re.compile(r"[ ]{2,}|[ ]*$", re.UNICODE)
rxRule = re.compile(r"\s{2,}")
hlRule = {
0: self._hStyles["mspaces"],
}
@@ -148,7 +149,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._cmnRules.append((rxRule, hlRule))
# Non-Breaking Spaces
rxRule = re.compile(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+", re.UNICODE)
rxRule = re.compile(f"[{nwUnicode.U_NBSP}{nwUnicode.U_THNBSP}]+")
hlRule = {
0: self._hStyles["nobreak"],
}
@@ -196,6 +197,17 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules.append((rxRule, hlRule))
self._cmnRules.append((rxRule, hlRule))
# Markdown Highlight
rxRule = REGEX_PATTERNS.markdownMark
hlRule = {
1: self._hStyles["markup"],
2: self._hStyles["mark"],
3: self._hStyles["markup"],
}
self._minRules.append((rxRule, hlRule))
self._txtRules.append((rxRule, hlRule))
self._cmnRules.append((rxRule, hlRule))
# Shortcodes
rxRule = REGEX_PATTERNS.shortcodePlain
hlRule = {
@@ -226,7 +238,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._cmnRules.append((rxRule, hlRule))
# Alignment Tags
rxRule = re.compile(r"(^>{1,2}|<{1,2}$)", re.UNICODE)
rxRule = re.compile(r"(^>{1,2}|<{1,2}$)")
hlRule = {
1: self._hStyles["markup"],
}
@@ -234,7 +246,7 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules.append((rxRule, hlRule))
# Auto-Replace Tags
rxRule = re.compile(r"<(\S+?)>", re.UNICODE)
rxRule = re.compile(r"<(\S+?)>")
hlRule = {
0: self._hStyles["replace"],
}
@@ -242,8 +254,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._txtRules.append((rxRule, hlRule))
self._cmnRules.append((rxRule, hlRule))
return
##
# Setters
##
@@ -251,7 +261,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
def setSpellCheck(self, state: bool) -> None:
"""Enable/disable the real time spell checker."""
self._spellCheck = state
return
def setHandle(self, tHandle: str) -> None:
"""Set the handle of the currently highlighted document."""
@@ -262,7 +271,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
self._isNovel = item.isDocumentLayout()
self._isInactive = item.isInactiveClass()
logger.debug("Syntax highlighter enabled for item '%s'", tHandle)
return
##
# Methods
@@ -280,7 +288,6 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if block.userState() & cType > 0:
self.rehighlightBlock(block)
logger.debug("Document highlighted in %.3f ms" % (1000*(time() - tStart)))
return
##
# Highlight Block
@@ -296,27 +303,37 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if self._tHandle is None or not text:
return
xOff = 0
hRules = None
blockLen = self.currentBlock().length()
utf16Map = None
if blockLen > len(text) + 1:
# If the lengths are different, the line contains 4 byte
# Unicode characters, and we must use a map between Python
# string indices and the UTF-16 indices used by Qt, where a
# 4 byte character occupies two slots. See #2449.
utf16Map = utf16CharMap(text)
offset = 0
rules = None
if text.startswith("@"): # Keywords and commands
self.setCurrentBlockState(BLOCK_META)
index = SHARED.project.index
isValid, bits, pos = index.scanThis(text)
isValid, bits, loc = index.scanThis(text)
isGood = index.checkThese(bits, self._tHandle)
if isValid:
for n, bit in enumerate(bits):
xPos = pos[n]
xLen = len(bit)
pos = utf16Map[loc[n]] if utf16Map else loc[n]
length = utf16Map[loc[n] + len(bit)] - pos if utf16Map else len(bit)
if n == 0 and isGood[n]:
self.setFormat(xPos, xLen, self._hStyles["keyword"])
self.setFormat(pos, length, self._hStyles["keyword"])
elif isGood[n] and not self._isInactive:
one, two = index.parseValue(bit)
self.setFormat(xPos, len(one), self._hStyles["tag"])
if two:
yPos = xPos + len(bit) - len(two)
self.setFormat(yPos, len(two), self._hStyles["optional"])
a, b = index.parseValue(bit)
aLen = utf16Map[loc[n] + len(a)] - pos if utf16Map else len(a)
self.setFormat(pos, aLen, self._hStyles["tag"])
if b:
bLen = utf16Map[loc[n] + len(b)] - pos if utf16Map else len(b)
self.setFormat(pos + length - bLen, bLen, self._hStyles["optional"])
elif not self._isInactive:
self.setFormat(xPos, xLen, self._hStyles["invalid"])
self.setFormat(pos, length, self._hStyles["invalid"])
# We never want to run the spell checker on keyword/values,
# so we force a return here
@@ -327,98 +344,118 @@ class GuiDocHighlighter(QSyntaxHighlighter):
if text.startswith("# "): # Heading 1
self.setFormat(0, 1, self._hStyles["head1h"])
self.setFormat(1, len(text), self._hStyles["header1"])
self.setFormat(1, blockLen, self._hStyles["header1"])
elif text.startswith("## "): # Heading 2
self.setFormat(0, 2, self._hStyles["head2h"])
self.setFormat(2, len(text), self._hStyles["header2"])
self.setFormat(2, blockLen, self._hStyles["header2"])
elif text.startswith("### "): # Heading 3
self.setFormat(0, 3, self._hStyles["head3h"])
self.setFormat(3, len(text), self._hStyles["header3"])
self.setFormat(3, blockLen, self._hStyles["header3"])
elif text.startswith("#### "): # Heading 4
self.setFormat(0, 4, self._hStyles["head4h"])
self.setFormat(4, len(text), self._hStyles["header4"])
self.setFormat(4, blockLen, self._hStyles["header4"])
elif text.startswith("#! "): # Title
self.setFormat(0, 2, self._hStyles["head1h"])
self.setFormat(2, len(text), self._hStyles["header1"])
self.setFormat(2, blockLen, self._hStyles["header1"])
elif text.startswith("##! "): # Unnumbered
self.setFormat(0, 3, self._hStyles["head2h"])
self.setFormat(3, len(text), self._hStyles["header2"])
self.setFormat(3, blockLen, self._hStyles["header2"])
elif text.startswith("###! "): # Alternative Scene
self.setFormat(0, 4, self._hStyles["head3h"])
self.setFormat(4, len(text), self._hStyles["header3"])
self.setFormat(4, blockLen, self._hStyles["header3"])
elif text.startswith("%"): # Comments
self.setCurrentBlockState(BLOCK_TEXT)
hRules = self._cmnRules
rules = self._cmnRules
cStyle, cMod, _, cDot, cPos = processComment(text)
cLen = len(text) - cPos
xOff = cPos
if cStyle == nwComment.PLAIN:
self.setFormat(0, cLen, self._hStyles["hidden"])
elif cStyle == nwComment.IGNORE:
self.setFormat(0, cLen, self._hStyles["strike"])
style, mod, _, dot, pos = processComment(text)
offset = pos
if utf16Map:
dot = utf16Map[dot]
pos = utf16Map[pos]
length = blockLen - pos
if style == nwComment.PLAIN:
self.setFormat(0, length, self._hStyles["hidden"])
elif style == nwComment.IGNORE:
self.setFormat(0, length, self._hStyles["strike"])
return # No more processing for these
elif cMod:
self.setFormat(0, cDot, self._hStyles["modifier"])
self.setFormat(cDot, cPos - cDot, self._hStyles["value"])
self.setFormat(cPos, cLen, self._hStyles["note"])
elif mod:
self.setFormat(0, dot, self._hStyles["modifier"])
self.setFormat(dot, pos - dot, self._hStyles["value"])
self.setFormat(pos, length, self._hStyles["note"])
else:
self.setFormat(0, cPos, self._hStyles["modifier"])
self.setFormat(cPos, cLen, self._hStyles["note"])
self.setFormat(0, pos, self._hStyles["modifier"])
self.setFormat(pos, length, self._hStyles["note"])
elif text.startswith("["): # Special Command
self.setCurrentBlockState(BLOCK_TEXT)
hRules = self._txtRules if self._isNovel else self._minRules
rules = self._txtRules if self._isNovel else self._minRules
sText = text.rstrip().lower()
if sText in ("[newpage]", "[new page]", "[vspace]"):
self.setFormat(0, len(text), self._hStyles["code"])
check = text.rstrip().lower()
if check in ("[newpage]", "[new page]", "[vspace]"):
self.setFormat(0, blockLen, self._hStyles["code"])
return
elif sText.startswith("[vspace:") and sText.endswith("]"):
tLen = len(sText)
tVal = checkInt(sText[8:-1], 0)
cVal = "value" if tVal > 0 else "invalid"
elif check.startswith("[vspace:") and check.endswith("]"):
value = checkInt(check[8:-1], 0)
style = "value" if value > 0 else "invalid"
self.setFormat(0, 8, self._hStyles["code"])
self.setFormat(8, tLen-9, self._hStyles[cVal])
self.setFormat(tLen-1, tLen, self._hStyles["code"])
self.setFormat(8, blockLen-10, self._hStyles[style])
self.setFormat(blockLen-2, blockLen, self._hStyles["code"])
return
else: # Text Paragraph
self.setCurrentBlockState(BLOCK_TEXT)
hRules = self._txtRules if self._isNovel else self._minRules
rules = self._txtRules if self._isNovel else self._minRules
if self._isNovel and self._dialogParser.enabled:
for pos, end in self._dialogParser(text):
length = end - pos
self.setFormat(pos, length, self._hStyles["dialog"])
if utf16Map:
for pos, end in self._dialogParser(text):
pos = utf16Map[pos]
end = utf16Map[end]
self.setFormat(pos, end - pos, self._hStyles["dialog"])
else:
for pos, end in self._dialogParser(text):
self.setFormat(pos, end - pos, self._hStyles["dialog"])
if hRules:
for rX, hRule in hRules:
for res in re.finditer(rX, text[xOff:]):
for xM, hFmt in hRule.items():
xPos = res.start(xM) + xOff
xEnd = res.end(xM) + xOff
for x in range(xPos, xEnd):
cFmt = self.format(x)
if cFmt.fontStyleName() != "markup":
cFmt.merge(hFmt)
self.setFormat(x, 1, cFmt)
if rules:
if utf16Map:
for rX, hRule in rules:
for res in re.finditer(rX, text[offset:]):
for x, hFmt in hRule.items():
pos = res.start(x) + offset
end = res.end(x) + offset
for x in range(pos, end):
m = utf16Map[x]
cFmt = self.format(m)
if not cFmt.property(QtTextUserProperty):
cFmt.merge(hFmt)
self.setFormat(m, utf16Map[x+1] - m, cFmt)
else:
for rX, hRule in rules:
for res in re.finditer(rX, text[offset:]):
for x, hFmt in hRule.items():
pos = res.start(x) + offset
end = res.end(x) + offset
for x in range(pos, end):
cFmt = self.format(x)
if not cFmt.property(QtTextUserProperty):
cFmt.merge(hFmt)
self.setFormat(x, 1, cFmt)
data = self.currentBlockUserData()
if not isinstance(data, TextBlockData):
data = TextBlockData()
self.setCurrentBlockUserData(data)
data.processText(text, xOff)
data.processText(text, offset)
if self._spellCheck:
for xPos, xEnd in data.spellCheck():
for x in range(xPos, xEnd):
for pos, end, _ in data.spellCheck(utf16Map):
for x in range(pos, end):
cFmt = self.format(x)
cFmt.merge(self._spellErr)
self.setFormat(x, 1, cFmt)
@@ -435,10 +472,8 @@ class GuiDocHighlighter(QSyntaxHighlighter):
) -> None:
"""Generate a highlighter character format."""
charFormat = QTextCharFormat()
charFormat.setFontStyleName(name)
if color:
charFormat.setForeground(color)
blockMerge = name == "markup"
charFormat.setProperty(QtTextUserProperty, blockMerge)
if style:
styles = style.split(",")
@@ -455,16 +490,23 @@ class GuiDocHighlighter(QSyntaxHighlighter):
charFormat.setUnderlineStyle(QTextCharFormat.UnderlineStyle.SpellCheckUnderline)
if "bg" in styles and color is not None:
charFormat.setBackground(QBrush(color, Qt.BrushStyle.SolidPattern))
color = None
if color:
charFormat.setForeground(color)
if size:
charFormat.setFontPointSize(round(size*CONFIG.textFont.pointSize()))
self._hStyles[name] = charFormat
return
class TextBlockData(QTextBlockUserData):
"""Custom QTextBlock Data.
Custom data stored in a single text block. The spell check state is
cached here and used when correcting misspelled text.
"""
__slots__ = ("_metaData", "_offset", "_spellErrors", "_text")
@@ -473,8 +515,7 @@ class TextBlockData(QTextBlockUserData):
self._text = ""
self._offset = 0
self._metaData: list[tuple[int, int, str, str]] = []
self._spellErrors: list[tuple[int, int]] = []
return
self._spellErrors: list[tuple[int, int, str]] = []
@property
def metaData(self) -> list[tuple[int, int, str, str]]:
@@ -482,7 +523,7 @@ class TextBlockData(QTextBlockUserData):
return self._metaData
@property
def spellErrors(self) -> list[tuple[int, int]]:
def spellErrors(self) -> list[tuple[int, int, str]]:
"""Return spell error data from last check."""
return self._spellErrors
@@ -505,22 +546,24 @@ class TextBlockData(QTextBlockUserData):
text = f"{text[:s]}{pad}{text[e:]}"
self._metaData.append((s, e, res.group(0), "url"))
self._text = text.replace("\u02bc", "'")
self._text = text.replace("\u02bc", "'").replace("_", " ")
self._offset = offset
return
def spellCheck(self) -> list[tuple[int, int]]:
def spellCheck(self, utf16Map: list[int] | None) -> list[tuple[int, int, str]]:
"""Run the spell checker and cache the result, and return the
list of spell check errors.
"""
self._spellErrors = []
checker = SHARED.spelling
for res in RX_WORDS.finditer(self._text.replace("_", " "), self._offset):
if (
(word := res.group(0))
and not (word.isnumeric() or word.isupper() or checker.checkWord(word))
):
self._spellErrors.append((res.start(0), res.end(0)))
spell = SHARED.spelling
if utf16Map:
self._spellErrors = [
(utf16Map[r.start(0)], utf16Map[r.end(0)], w)
for r in RX_WORDS.finditer(self._text, self._offset)
if (w := r.group(0)) and not (w.isnumeric() or w.isupper() or spell.checkWord(w))
]
else:
self._spellErrors = [
(r.start(0), r.end(0), w)
for r in RX_WORDS.finditer(self._text, self._offset)
if (w := r.group(0)) and not (w.isnumeric() or w.isupper() or spell.checkWord(w))
]
return self._spellErrors
+32 -64
View File
@@ -23,7 +23,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -60,6 +60,7 @@ logger = logging.getLogger(__name__)
class GuiDocViewer(QTextBrowser):
"""GUI: Document Viewer."""
closeDocumentRequest = pyqtSignal()
documentLoaded = pyqtSignal(str)
@@ -110,8 +111,6 @@ class GuiDocViewer(QTextBrowser):
logger.debug("Ready: GuiDocViewer")
return
##
# Properties
##
@@ -138,13 +137,11 @@ class GuiDocViewer(QTextBrowser):
self.setSearchPaths([""])
self._docHandle = None
self.docHeader.clearHeader()
return
def updateTheme(self) -> None:
"""Update theme elements."""
self.docHeader.updateTheme()
self.docFooter.updateTheme()
return
def initViewer(self) -> None:
"""Set editor settings from main config."""
@@ -206,8 +203,6 @@ class GuiDocViewer(QTextBrowser):
# If we have a document open, we should reload it in case the font changed
self.reloadText()
return
def loadText(self, tHandle: str, updateHistory: bool = True) -> bool:
"""Load text into the viewer from an item handle."""
if not SHARED.project.tree.checkType(tHandle, nwItemType.FILE):
@@ -228,11 +223,11 @@ class GuiDocViewer(QTextBrowser):
qDoc.setTheme(self._docTheme)
qDoc.initDocument()
qDoc.setKeywords(True)
qDoc.setCommentType(nwComment.NOTE, CONFIG.viewComments)
qDoc.setCommentType(nwComment.STORY, CONFIG.viewComments)
qDoc.setCommentType(nwComment.PLAIN, CONFIG.viewComments)
qDoc.setCommentType(nwComment.SYNOPSIS, CONFIG.viewSynopsis)
qDoc.setCommentType(nwComment.SHORT, CONFIG.viewSynopsis)
qDoc.setCommentType(nwComment.STORY, CONFIG.viewNotes)
qDoc.setCommentType(nwComment.NOTE, CONFIG.viewNotes)
# Be extra careful here to prevent crashes when first opening a
# project as a crash here leaves no way of recovering.
@@ -280,7 +275,6 @@ class GuiDocViewer(QTextBrowser):
"""Reload the text in the current document."""
if self._docHandle:
self.loadText(self._docHandle, updateHistory=False)
return
def docAction(self, action: nwDocAction) -> bool:
"""Process document actions on the current document."""
@@ -308,7 +302,6 @@ class GuiDocViewer(QTextBrowser):
def clearNavHistory(self) -> None:
"""Clear the navigation history."""
self.docHistory.clear()
return
def updateDocMargins(self) -> None:
"""Automatically adjust the margins so the text is centred."""
@@ -337,8 +330,6 @@ class GuiDocViewer(QTextBrowser):
self.docFooter.setGeometry(tB, fY, tW, fH)
self.setViewportMargins(tM, max(cM, tH), tM, max(cM, fH))
return
##
# Setters
##
@@ -347,7 +338,6 @@ class GuiDocViewer(QTextBrowser):
"""Set the scrollbar position."""
if (vBar := self.verticalScrollBar()) and vBar.isVisible():
vBar.setValue(pos)
return
##
# Public Slots
@@ -359,7 +349,6 @@ class GuiDocViewer(QTextBrowser):
if tHandle == self._docHandle and change == nwChange.UPDATE:
self.docHeader.setHandle(tHandle)
self.updateDocMargins()
return
@pyqtSlot(str)
def navigateTo(self, anchor: str) -> None:
@@ -367,7 +356,6 @@ class GuiDocViewer(QTextBrowser):
if isinstance(anchor, str) and anchor.startswith("#"):
logger.debug("Moving to anchor '%s'", anchor)
self.setSource(QUrl(anchor))
return
##
# Private Slots
@@ -377,13 +365,11 @@ class GuiDocViewer(QTextBrowser):
def navBackward(self) -> None:
"""Navigate backwards in the document view history."""
self.docHistory.backward()
return
@pyqtSlot()
def navForward(self) -> None:
"""Navigate forwards in the document view history."""
self.docHistory.forward()
return
@pyqtSlot("QUrl")
def _linkClicked(self, url: QUrl) -> None:
@@ -396,7 +382,6 @@ class GuiDocViewer(QTextBrowser):
self.navigateTo(link)
elif link.startswith("http"):
QDesktopServices.openUrl(QUrl(url))
return
@pyqtSlot("QPoint")
def _openContextMenu(self, point: QPoint) -> None:
@@ -430,8 +415,6 @@ class GuiDocViewer(QTextBrowser):
ctxMenu.setParent(None)
return
##
# Events
##
@@ -440,7 +423,6 @@ class GuiDocViewer(QTextBrowser):
"""Update document margins when widget is resized."""
self.updateDocMargins()
super().resizeEvent(event)
return
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
"""Capture mouse click events on the document."""
@@ -450,7 +432,6 @@ class GuiDocViewer(QTextBrowser):
self.navForward()
else:
super().mouseReleaseEvent(event)
return
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
"""Overload drag enter event to handle dragged items."""
@@ -458,7 +439,6 @@ class GuiDocViewer(QTextBrowser):
event.acceptProposedAction()
else:
super().dragEnterEvent(event)
return
def dragMoveEvent(self, event: QDragMoveEvent) -> None:
"""Overload drag move event to handle dragged items."""
@@ -466,7 +446,6 @@ class GuiDocViewer(QTextBrowser):
event.acceptProposedAction()
else:
super().dragMoveEvent(event)
return
def dropEvent(self, event: QDropEvent) -> None:
"""Overload drop event to handle dragged items."""
@@ -476,7 +455,6 @@ class GuiDocViewer(QTextBrowser):
self.openDocumentRequest.emit(handles[0], nwDocMode.VIEW, "", True)
else:
super().dropEvent(event)
return
##
# Internal Functions
@@ -500,16 +478,18 @@ class GuiDocViewer(QTextBrowser):
self.setTextCursor(cursor)
return
def _makePosSelection(self, selType: QTextCursor.SelectionType, pos: QPoint) -> None:
"""Handle text selection at a given location."""
self.setTextCursor(self.cursorForPosition(pos))
self._makeSelection(selType)
return
class GuiDocViewHistory:
"""GUI: Document Viewer History.
This class holds the navigation history for the viewer panel, which
is used for backward/forward navigation.
"""
def __init__(self, docViewer: GuiDocViewer) -> None:
self.docViewer = docViewer
@@ -517,7 +497,6 @@ class GuiDocViewHistory:
self._posHistory = []
self._currPos = -1
self._prevPos = -1
return
def clear(self) -> None:
"""Clear the view history."""
@@ -526,7 +505,6 @@ class GuiDocViewHistory:
self._posHistory = []
self._currPos = -1
self._prevPos = -1
return
def append(self, tHandle: str) -> bool:
"""Append a document handle and its scroll bar position to the
@@ -566,7 +544,6 @@ class GuiDocViewHistory:
self._currPos = newPos
self._updateNavButtons()
self._dumpHistory()
return
def backward(self) -> None:
"""Navigate to the previous entry in the view history."""
@@ -580,7 +557,6 @@ class GuiDocViewHistory:
self._currPos = newPos
self._updateNavButtons()
self._dumpHistory()
return
##
# Internal Functions
@@ -590,12 +566,10 @@ class GuiDocViewHistory:
"""Update the scrollbar position of the previous entry."""
if self._prevPos >= 0 and self._prevPos < len(self._posHistory):
self._posHistory[self._prevPos] = self.docViewer.scrollPosition
return
def _updateNavButtons(self) -> None:
"""Update the navigation buttons in the document header."""
self.docViewer.docHeader.updateNavButtons(0, len(self._navHistory) - 1, self._currPos)
return
def _truncateHistory(self, atPos: int) -> None:
"""Truncate the navigation history to the given position. Also
@@ -606,7 +580,6 @@ class GuiDocViewHistory:
self._posHistory = self._posHistory[nSkip:atPos + 1]
self._currPos -= nSkip
self._prevPos -= nSkip
return
def _dumpHistory(self) -> None:
"""Debug function to dump history to the logger. Since it is a
@@ -616,11 +589,10 @@ class GuiDocViewHistory:
for i, (h, p) in enumerate(zip(self._navHistory, self._posHistory, strict=False)):
a = ">" if i == self._currPos else " "
logger.debug(f"History {i + 1:02d}: {a} {h:13s} [x:{p}]")
return
class GuiDocViewHeader(QWidget):
"""The Embedded Document Header
"""The Embedded Document Header.
Only used by DocViewer, and is at a fixed position in the
QTextBrowser's viewport.
@@ -711,8 +683,6 @@ class GuiDocViewHeader(QWidget):
logger.debug("Ready: GuiDocViewHeader")
return
##
# Methods
##
@@ -730,7 +700,6 @@ class GuiDocViewHeader(QWidget):
self.editButton.setVisible(False)
self.refreshButton.setVisible(False)
self.closeButton.setVisible(False)
return
def setOutline(self, data: dict[str, tuple[str, int]]) -> None:
"""Set the document outline dataset."""
@@ -750,13 +719,11 @@ class GuiDocViewHeader(QWidget):
lambda _, title=title: self.docViewer.navigateTo(f"#{tHandle}:{title}")
)
self._docOutline = data
return
def updateFont(self) -> None:
"""Update the font settings."""
self.setFont(SHARED.theme.guiFont)
self.itemTitle.setFont(SHARED.theme.guiFontSmall)
return
def updateTheme(self) -> None:
"""Update theme elements."""
@@ -777,8 +744,6 @@ class GuiDocViewHeader(QWidget):
self.matchColors()
return
def matchColors(self) -> None:
"""Update the colours of the widget to match those of the syntax
theme rather than the main GUI.
@@ -792,15 +757,13 @@ class GuiDocViewHeader(QWidget):
self.itemTitle.setTextColors(
color=palette.windowText().color(), faded=SHARED.theme.fadedText
)
return
def changeFocusState(self, state: bool) -> None:
"""Toggle focus state."""
self.itemTitle.setColorState(state)
return
def setHandle(self, tHandle: str) -> None:
"""Sets the document title from the handle, or alternatively,
"""Set the document title from the handle, or alternatively,
set the whole document path.
"""
self._docHandle = tHandle
@@ -819,13 +782,10 @@ class GuiDocViewHeader(QWidget):
self.refreshButton.setVisible(True)
self.closeButton.setVisible(True)
return
def updateNavButtons(self, firstIdx: int, lastIdx: int, currIdx: int) -> None:
"""Enable and disable nav buttons based on index in history."""
self.backButton.setEnabled(currIdx > firstIdx)
self.forwardButton.setEnabled(currIdx < lastIdx)
return
##
# Private Slots
@@ -836,20 +796,17 @@ class GuiDocViewHeader(QWidget):
"""Trigger the close editor/viewer on the main window."""
self.clearHeader()
self.docViewer.closeDocumentRequest.emit()
return
@pyqtSlot()
def _refreshDocument(self) -> None:
"""Reload the content of the document."""
self.docViewer.reloadDocumentRequest.emit()
return
@pyqtSlot()
def _editDocument(self) -> None:
"""Open the document in the editor."""
if tHandle := self._docHandle:
self.docViewer.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True)
return
##
# Events
@@ -861,11 +818,10 @@ class GuiDocViewHeader(QWidget):
"""
if event.button() == QtMouseLeft:
self.docViewer.requestProjectItemSelected.emit(self._docHandle, True)
return
class GuiDocViewFooter(QWidget):
"""The Embedded Document Footer
"""The Embedded Document Footer.
Only used by DocViewer, and is at a fixed position in the
QTextBrowser's viewport.
@@ -913,12 +869,23 @@ class GuiDocViewFooter(QWidget):
self.showSynopsis.toggled.connect(self._doToggleSynopsis)
self.showSynopsis.setToolTip(self.tr("Show Synopsis Comments"))
# Show Notes
self.showNotes = QToolButton(self)
self.showNotes.setText(self.tr("Notes"))
self.showNotes.setCheckable(True)
self.showNotes.setChecked(CONFIG.viewNotes)
self.showNotes.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
self.showNotes.setIconSize(iSz)
self.showNotes.toggled.connect(self._doToggleNotes)
self.showNotes.setToolTip(self.tr("Show Notes"))
# Assemble Layout
self.outerBox = QHBoxLayout()
self.outerBox.addWidget(self.showHide, 0)
self.outerBox.addStretch(1)
self.outerBox.addWidget(self.showComments, 0)
self.outerBox.addWidget(self.showSynopsis, 0)
self.outerBox.addWidget(self.showNotes, 0)
self.outerBox.setSpacing(4)
self.setLayout(self.outerBox)
@@ -933,8 +900,6 @@ class GuiDocViewFooter(QWidget):
logger.debug("Ready: GuiDocViewFooter")
return
##
# Methods
##
@@ -944,7 +909,7 @@ class GuiDocViewFooter(QWidget):
self.setFont(SHARED.theme.guiFont)
self.showComments.setFont(SHARED.theme.guiFontSmall)
self.showSynopsis.setFont(SHARED.theme.guiFontSmall)
return
self.showNotes.setFont(SHARED.theme.guiFontSmall)
def updateTheme(self) -> None:
"""Update theme elements."""
@@ -955,16 +920,16 @@ class GuiDocViewFooter(QWidget):
self.showHide.setThemeIcon("panel")
self.showComments.setIcon(bulletIcon)
self.showSynopsis.setIcon(bulletIcon)
self.showNotes.setIcon(bulletIcon)
buttonStyle = SHARED.theme.getStyleSheet(STYLES_MIN_TOOLBUTTON)
self.showHide.setStyleSheet(buttonStyle)
self.showComments.setStyleSheet(buttonStyle)
self.showSynopsis.setStyleSheet(buttonStyle)
self.showNotes.setStyleSheet(buttonStyle)
self.matchColors()
return
def matchColors(self) -> None:
"""Update the colours of the widget to match those of the syntax
theme rather than the main GUI.
@@ -975,7 +940,6 @@ class GuiDocViewFooter(QWidget):
palette.setColor(QPalette.ColorRole.WindowText, syntax.text)
palette.setColor(QPalette.ColorRole.Text, syntax.text)
self.setPalette(palette)
return
##
# Private Slots
@@ -986,11 +950,15 @@ class GuiDocViewFooter(QWidget):
"""Toggle the view comment button and reload the document."""
CONFIG.viewComments = state
self.docViewer.reloadText()
return
@pyqtSlot(bool)
def _doToggleSynopsis(self, state: bool) -> None:
"""Toggle the view synopsis button and reload the document."""
CONFIG.viewSynopsis = state
self.docViewer.reloadText()
return
@pyqtSlot(bool)
def _doToggleNotes(self, state: bool) -> None:
"""Toggle the view notes button and reload the document."""
CONFIG.viewNotes = state
self.docViewer.reloadText()
+5 -34
View File
@@ -20,7 +20,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -49,6 +49,10 @@ logger = logging.getLogger(__name__)
class GuiDocViewerPanel(QWidget):
"""GUI: Document Viewer Panel.
The panel of project meta data below the viewer.
"""
openDocumentRequest = pyqtSignal(str, Enum, str, bool)
loadDocumentTagRequest = pyqtSignal(str, Enum)
@@ -96,8 +100,6 @@ class GuiDocViewerPanel(QWidget):
logger.debug("Ready: GuiDocViewerPanel")
return
##
# Methods
##
@@ -113,7 +115,6 @@ class GuiDocViewerPanel(QWidget):
for tab in self.kwTabs.values():
tab.updateTheme()
self._loadAllTags()
return
def openProjectTasks(self) -> None:
"""Run open project tasks."""
@@ -124,7 +125,6 @@ class GuiDocViewerPanel(QWidget):
for key, value in colWidths.items():
if key in self.kwTabs and isinstance(value, list):
self.kwTabs[key].setColumnWidths(value)
return
def closeProjectTasks(self) -> None:
"""Run close project tasks."""
@@ -133,7 +133,6 @@ class GuiDocViewerPanel(QWidget):
hideInactive = self.aInactive.isChecked()
SHARED.project.options.setValue("GuiDocViewerPanel", "colWidths", colWidths)
SHARED.project.options.setValue("GuiDocViewerPanel", "hideInactive", hideInactive)
return
##
# Public Slots
@@ -145,7 +144,6 @@ class GuiDocViewerPanel(QWidget):
self.tabBackRefs.clearContent()
for cTab in self.kwTabs.values():
cTab.clearContent()
return
@pyqtSlot()
def indexHasAppeared(self) -> None:
@@ -153,7 +151,6 @@ class GuiDocViewerPanel(QWidget):
self._loadAllTags()
self._updateTabVisibility()
self.updateHandle(self._lastHandle)
return
@pyqtSlot(str, Enum)
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
@@ -168,14 +165,12 @@ class GuiDocViewerPanel(QWidget):
else:
self.kwTabs[tClass].removeEntry(key)
self._updateTabVisibility()
return
@pyqtSlot(str)
def updateHandle(self, tHandle: str | None) -> None:
"""Update the document handle."""
self._lastHandle = tHandle
self.tabBackRefs.refreshContent(tHandle or None)
return
@pyqtSlot(list, list)
def updateChangedTags(self, updated: list[str], deleted: list[str]) -> None:
@@ -191,14 +186,12 @@ class GuiDocViewerPanel(QWidget):
else:
logger.warning("Could not remove tag '%s' from view panel", key)
self._updateTabVisibility()
return
@pyqtSlot(str)
def updateStatusLabels(self, kind: str) -> None:
"""Update the importance labels."""
if kind == "i":
self._loadAllTags()
return
##
# Private Slots
@@ -212,7 +205,6 @@ class GuiDocViewerPanel(QWidget):
cTab.clearContent()
self._loadAllTags()
self._updateTabVisibility()
return
##
# Internal Functions
@@ -222,7 +214,6 @@ class GuiDocViewerPanel(QWidget):
"""Hide class tabs with no content."""
for tClass, cTab in self.kwTabs.items():
self.mainTabs.setTabVisible(self.idTabs[tClass], cTab.countEntries() > 0)
return
def _loadAllTags(self) -> None:
"""Load all tags into the tabs."""
@@ -230,7 +221,6 @@ class GuiDocViewerPanel(QWidget):
for key, name, tClass, iItem, hItem in data:
if tClass in self.kwTabs and iItem and hItem:
self.kwTabs[tClass].addUpdateEntry(key, name, iItem, hItem)
return
class _ViewPanelBackRefs(QTreeWidget):
@@ -278,8 +268,6 @@ class _ViewPanelBackRefs(QTreeWidget):
self.clicked.connect(self._treeItemClicked)
self.doubleClicked.connect(self._treeItemDoubleClicked)
return
def updateTheme(self) -> None:
"""Update theme elements."""
self._editIcon = SHARED.theme.getIcon("edit", "green")
@@ -288,13 +276,11 @@ class _ViewPanelBackRefs(QTreeWidget):
if item := self.topLevelItem(i):
item.setIcon(self.C_EDIT, self._editIcon)
item.setIcon(self.C_VIEW, self._viewIcon)
return
def clearContent(self) -> None:
"""Clear the widget."""
self.clear()
self._treeMap = {}
return
def refreshContent(self, dHandle: str | None) -> None:
"""Update the content."""
@@ -303,7 +289,6 @@ class _ViewPanelBackRefs(QTreeWidget):
refs = SHARED.project.index.getBackReferenceList(dHandle)
for tHandle, (sTitle, hItem) in refs.items():
self._setTreeItemValues(tHandle, sTitle, hItem)
return
def refreshDocument(self, tHandle: str) -> None:
"""Refresh document meta data."""
@@ -311,7 +296,6 @@ class _ViewPanelBackRefs(QTreeWidget):
for sTitle, hItem in iItem.items():
if f"{tHandle}:{sTitle}" in self._treeMap:
self._setTreeItemValues(tHandle, sTitle, hItem)
return
##
# Private Slots
@@ -325,7 +309,6 @@ class _ViewPanelBackRefs(QTreeWidget):
self._parent.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, "", True)
elif index.column() == self.C_VIEW:
self._parent.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", True)
return
@pyqtSlot("QModelIndex")
def _treeItemDoubleClicked(self, index: QModelIndex) -> None:
@@ -333,7 +316,6 @@ class _ViewPanelBackRefs(QTreeWidget):
tHandle = index.siblingAtColumn(self.C_DATA).data(self.D_HANDLE)
if index.column() not in (self.C_EDIT, self.C_VIEW):
self._parent.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, "", True)
return
##
# Internal Functions
@@ -362,8 +344,6 @@ class _ViewPanelBackRefs(QTreeWidget):
self.addTopLevelItem(trItem)
self._treeMap[tKey] = trItem
return
class _ViewPanelKeyWords(QTreeWidget):
@@ -418,14 +398,11 @@ class _ViewPanelKeyWords(QTreeWidget):
self.clicked.connect(self._treeItemClicked)
self.doubleClicked.connect(self._treeItemDoubleClicked)
return
def updateTheme(self) -> None:
"""Update theme elements."""
self._classIcon = SHARED.theme.getIcon(nwLabels.CLASS_ICON[self._class], "root")
self._editIcon = SHARED.theme.getIcon("edit", "green")
self._viewIcon = SHARED.theme.getIcon("view", "blue")
return
def countEntries(self) -> int:
"""Return the number of items in the list."""
@@ -435,7 +412,6 @@ class _ViewPanelKeyWords(QTreeWidget):
"""Clear the list."""
self._treeMap = {}
self.clear()
return
def addUpdateEntry(self, tag: str, name: str, iItem: IndexNode, hItem: IndexHeading) -> None:
"""Add a new entry, or update an existing one."""
@@ -470,8 +446,6 @@ class _ViewPanelKeyWords(QTreeWidget):
self.addTopLevelItem(trItem)
self._treeMap[tag] = trItem
return
def removeEntry(self, tag: str) -> bool:
"""Remove a tag from the list."""
if tag in self._treeMap:
@@ -487,7 +461,6 @@ class _ViewPanelKeyWords(QTreeWidget):
self.setColumnWidth(self.C_IMPORT, checkInt(widths[1], 100))
self.setColumnWidth(self.C_DOC, checkInt(widths[2], 100))
self.setColumnWidth(self.C_TITLE, checkInt(widths[3], 100))
return
def getColumnWidths(self) -> list[int]:
"""Get the widths of the user-adjustable columns."""
@@ -510,7 +483,6 @@ class _ViewPanelKeyWords(QTreeWidget):
self._parent.loadDocumentTagRequest.emit(tag, nwDocMode.EDIT)
elif index.column() == self.C_VIEW:
self._parent.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW)
return
@pyqtSlot("QModelIndex")
def _treeItemDoubleClicked(self, index: QModelIndex) -> None:
@@ -518,4 +490,3 @@ class _ViewPanelKeyWords(QTreeWidget):
tag = index.siblingAtColumn(self.C_DATA).data(self.D_TAG)
if index.column() not in (self.C_EDIT, self.C_VIEW):
self._parent.loadDocumentTagRequest.emit(tag, nwDocMode.VIEW)
return
+12 -17
View File
@@ -20,7 +20,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -42,6 +42,11 @@ logger = logging.getLogger(__name__)
class GuiTextDocument(QTextDocument):
"""Custom: Modified QTextDocument.
A special text document format that incorporates a few additional
features including spell checking.
"""
def __init__(self, parent: QObject) -> None:
super().__init__(parent=parent)
@@ -52,11 +57,8 @@ class GuiTextDocument(QTextDocument):
logger.debug("Ready: GuiTextDocument")
return
def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: GuiTextDocument")
return
##
# Properties
@@ -96,8 +98,6 @@ class GuiTextDocument(QTextDocument):
logger.debug("Loaded %d text blocks in %.3f ms", count, 1000*(tMid - tStart))
logger.debug("Highlighted document in %.3f ms", 1000*(tEnd - tMid))
return
def metaDataAtPos(self, pos: int) -> tuple[str, str]:
"""Check if there is meta data available at a given position in
the document, and if so, return it.
@@ -113,7 +113,7 @@ class GuiTextDocument(QTextDocument):
return cData, cType
return "", ""
def spellErrorAtPos(self, pos: int) -> tuple[str, int, int, list[str]]:
def spellErrorAtPos(self, pos: int) -> tuple[str, int, list[str]]:
"""Check if there is a misspelled word at a given position in
the document, and if so, return it.
"""
@@ -122,15 +122,11 @@ class GuiTextDocument(QTextDocument):
block = cursor.block()
data = block.userData()
if block.isValid() and isinstance(data, TextBlockData):
text = block.text()
check = pos - block.position()
if check >= 0:
for cPos, cEnd in data.spellErrors:
cLen = cEnd - cPos
if cPos <= check <= cEnd:
word = text[cPos:cEnd]
return word, cPos, cLen, SHARED.spelling.suggestWords(word)
return "", -1, -1, []
if (check := pos - block.position()) >= 0:
for start, end, word in data.spellErrors:
if start <= check <= end:
return word, start, SHARED.spelling.suggestWords(word)
return "", -1, []
def iterBlockByType(self, cType: int, maxCount: int = 1000) -> Iterable[QTextBlock]:
"""Iterate over all text blocks of a given type."""
@@ -150,4 +146,3 @@ class GuiTextDocument(QTextDocument):
def setSpellCheckState(self, state: bool) -> None:
"""Set the spell check state of the syntax highlighter."""
self._syntax.setSpellCheck(state)
return
+4 -14
View File
@@ -20,7 +20,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
class GuiItemDetails(QWidget):
"""GUI: Project Item Details Panel."""
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
@@ -190,8 +191,6 @@ class GuiItemDetails(QWidget):
logger.debug("Ready: GuiItemDetails")
return
###
# Class Methods
##
@@ -210,7 +209,6 @@ class GuiItemDetails(QWidget):
self.cCountData.clear()
self.wCountData.clear()
self.pCountData.clear()
return
def refreshDetails(self) -> None:
"""Reload the content of the details panel."""
@@ -219,7 +217,6 @@ class GuiItemDetails(QWidget):
def updateTheme(self) -> None:
"""Update theme elements."""
self.updateViewBox(self._handle)
return
def updateViewBox(self, tHandle: str | None) -> None:
"""Populate the details box from a given handle."""
@@ -233,14 +230,8 @@ class GuiItemDetails(QWidget):
# Label
# =====
if nwItem.isFileType():
if nwItem.isActive:
self.labelIcon.setPixmap(SHARED.theme.getPixmap("checked", (iPx, iPx), "green"))
else:
self.labelIcon.setPixmap(SHARED.theme.getPixmap("unchecked", (iPx, iPx), "red"))
else:
self.labelIcon.setPixmap(SHARED.theme.getPixmap("noncheckable", (iPx, iPx), "faded"))
_, icon = nwItem.getActiveStatus()
self.labelIcon.setPixmap(icon.pixmap(iPx, iPx))
self.labelData.setText(elide(nwItem.itemName, 100))
# Status
@@ -289,4 +280,3 @@ class GuiItemDetails(QWidget):
self.updateViewBox(tHandle)
elif change == nwChange.DELETE:
self.updateViewBox(None)
return
+11 -25
View File
@@ -20,7 +20,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -82,8 +82,6 @@ class GuiMainMenu(QMenuBar):
logger.debug("Ready: GuiMainMenu")
return
##
# Public Slots
##
@@ -92,7 +90,6 @@ class GuiMainMenu(QMenuBar):
def setSpellCheckState(self, state: bool) -> None:
"""Forward spell check check state to its action."""
self.aSpellCheck.setChecked(state)
return
##
# Private Slots
@@ -105,21 +102,18 @@ class GuiMainMenu(QMenuBar):
decision, just pass a None to the function and let it decide.
"""
self.mainGui.docEditor.toggleSpellCheck(None)
return
@pyqtSlot()
def _openUserManualFile(self) -> None:
"""Open the documentation in PDF format."""
if isinstance(CONFIG.pdfDocs, Path):
openExternalPath(CONFIG.pdfDocs)
return
@pyqtSlot(str)
def _changeSpelling(self, language: str) -> None:
"""Change the spell check language."""
SHARED.project.data.setSpellLang(language)
SHARED.updateSpellCheckLanguage()
return
##
# Internal Functions
@@ -188,8 +182,6 @@ class GuiMainMenu(QMenuBar):
self.aExitNW.triggered.connect(qtLambda(self.mainGui.closeMain))
self.mainGui.addAction(self.aExitNW)
return
def _buildDocumentMenu(self) -> None:
"""Assemble the Document menu."""
# Document
@@ -236,8 +228,6 @@ class GuiMainMenu(QMenuBar):
self.aImportFile = qtAddAction(self.docuMenu, self.tr("Import Text from File"))
self.aImportFile.triggered.connect(qtLambda(self.mainGui.importDocument))
return
def _buildEditMenu(self) -> None:
"""Assemble the Edit menu."""
# Edit
@@ -305,8 +295,6 @@ class GuiMainMenu(QMenuBar):
)
self.mainGui.addAction(self.aSelectPar)
return
def _buildViewMenu(self) -> None:
"""Assemble the View menu."""
# View
@@ -318,6 +306,7 @@ class GuiMainMenu(QMenuBar):
self.aFocusTree.triggered.connect(
lambda: self.requestFocusChange.emit(nwFocus.TREE)
)
self.mainGui.addAction(self.aFocusTree)
# View > Document Editor
self.aFocusDocument = qtAddAction(self.viewMenu, self.tr("Go to Document"))
@@ -332,6 +321,7 @@ class GuiMainMenu(QMenuBar):
self.aFocusOutline.triggered.connect(
lambda: self.requestFocusChange.emit(nwFocus.OUTLINE)
)
self.mainGui.addAction(self.aFocusOutline)
# View > Separator
self.viewMenu.addSeparator()
@@ -365,8 +355,6 @@ class GuiMainMenu(QMenuBar):
self.aFullScreen.triggered.connect(self.mainGui.toggleFullScreenMode)
self.mainGui.addAction(self.aFullScreen)
return
def _buildInsertMenu(self) -> None:
"""Assemble the Insert menu."""
# Insert
@@ -644,8 +632,6 @@ class GuiMainMenu(QMenuBar):
lambda: self.requestDocInsert.emit(nwDocInsert.FOOTNOTE)
)
return
def _buildFormatMenu(self) -> None:
"""Assemble the Format menu."""
# Format
@@ -675,6 +661,14 @@ class GuiMainMenu(QMenuBar):
)
self.mainGui.addAction(self.aFmtStrike)
# Format > Highlight
self.aFmtMark = qtAddAction(self.fmtMenu, self.tr("Highlight"))
self.aFmtMark.setShortcut("Ctrl+M")
self.aFmtMark.triggered.connect(
lambda: self.requestDocAction.emit(nwDocAction.MD_MARK)
)
self.mainGui.addAction(self.aFmtStrike)
# Edit > Separator
self.fmtMenu.addSeparator()
@@ -891,8 +885,6 @@ class GuiMainMenu(QMenuBar):
lambda: self.requestDocAction.emit(nwDocAction.RM_BREAKS)
)
return
def _buildSearchMenu(self) -> None:
"""Assemble the Search menu."""
# Search
@@ -938,8 +930,6 @@ class GuiMainMenu(QMenuBar):
self.aFindProj.setShortcut("Ctrl+Shift+F")
self.aFindProj.triggered.connect(qtLambda(self.requestViewChange.emit, nwView.SEARCH))
return
def _buildToolsMenu(self) -> None:
"""Assemble the Tools menu."""
# Tools
@@ -1009,8 +999,6 @@ class GuiMainMenu(QMenuBar):
self.aPreferences.triggered.connect(self.mainGui.showPreferencesDialog)
self.mainGui.addAction(self.aPreferences)
return
def _buildHelpMenu(self) -> None:
"""Assemble the Help menu."""
# Help
@@ -1056,5 +1044,3 @@ class GuiMainMenu(QMenuBar):
# Document > Main Website
self.aWebsite = qtAddAction(self.helpMenu, self.tr("The novelWriter Website"))
self.aWebsite.triggered.connect(qtLambda(SHARED.openWebsite, nwConst.URL_WEB))
return
+7 -46
View File
@@ -24,7 +24,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -55,6 +55,7 @@ logger = logging.getLogger(__name__)
class GuiNovelView(QWidget):
"""GUI: Novel View Panel."""
# Signals for user interaction with the novel tree
selectedItemChanged = pyqtSignal(str)
@@ -82,8 +83,6 @@ class GuiNovelView(QWidget):
self.getSelectedHandle = self.novelTree.getSelectedHandle
self.refreshCurrentTree = self.novelBar.forceRefreshNovelTree
return
##
# Methods
##
@@ -91,19 +90,16 @@ class GuiNovelView(QWidget):
def updateTheme(self) -> None:
"""Update theme elements."""
self.novelBar.updateTheme()
return
def initSettings(self) -> None:
"""Initialise GUI elements that depend on specific settings."""
self.novelTree.initSettings()
return
def clearNovelView(self) -> None:
"""Clear project-related GUI content."""
self.novelBar.clearContent()
self.novelBar.setEnabled(False)
self.novelTree.clearContent()
return
def openProjectTasks(self) -> None:
"""Run open project tasks."""
@@ -125,8 +121,6 @@ class GuiNovelView(QWidget):
self.novelTree.setLastColSize(lastColSize)
return
def closeProjectTasks(self) -> None:
"""Run closing project tasks."""
logger.debug("Saving State: GuiNovelView")
@@ -140,12 +134,9 @@ class GuiNovelView(QWidget):
self.clearNovelView()
return
def setTreeFocus(self) -> None:
"""Set the focus to the tree widget."""
self.novelTree.setFocus()
return
def treeHasFocus(self) -> bool:
"""Check if the novel tree has focus."""
@@ -159,22 +150,20 @@ class GuiNovelView(QWidget):
def setCurrentNovel(self, rootHandle: str | None) -> None:
"""Set the current novel to display."""
self.novelTree.setNovelModel(rootHandle)
return
@pyqtSlot(str)
def setActiveHandle(self, tHandle: str) -> None:
"""Highlight the rows associated with a given handle."""
self.novelTree.setActiveHandle(tHandle)
return
@pyqtSlot(str, Enum)
def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""If any root item changes, rebuild the novel root menu."""
self.novelBar.buildNovelRootMenu()
return
class GuiNovelToolBar(QWidget):
"""GUI: Novel View Panel ToolBar."""
def __init__(self, novelView: GuiNovelView) -> None:
super().__init__(parent=novelView)
@@ -249,8 +238,6 @@ class GuiNovelToolBar(QWidget):
logger.debug("Ready: GuiNovelToolBar")
return
##
# Methods
##
@@ -277,20 +264,16 @@ class GuiNovelToolBar(QWidget):
self.forceRefreshNovelTree()
return
def clearContent(self) -> None:
"""Run clearing project tasks."""
self.novelValue.clear()
self.novelValue.setToolTip("")
return
def buildNovelRootMenu(self) -> None:
"""Build the novel root menu."""
self.novelValue.refreshNovelList()
self.novelView.setCurrentNovel(self.novelValue.handle)
self.tbNovel.setVisible(self.novelValue.count() > 1)
return
def setCurrentRoot(self, rootHandle: str | None) -> None:
"""Set the current active root handle."""
@@ -300,7 +283,6 @@ class GuiNovelToolBar(QWidget):
SHARED.project.data.setLastHandle(rootHandle, "novel")
self.novelView.setCurrentNovel(rootHandle)
self.novelView.novelTree.setAccessibleName(self.novelValue.currentText())
return
def setLastColType(self, colType: nwNovelExtra, doRefresh: bool = True) -> None:
"""Set the last column type."""
@@ -309,7 +291,6 @@ class GuiNovelToolBar(QWidget):
if doRefresh:
self.forceRefreshNovelTree()
self.novelView.novelTree.resizeColumns()
return
def setActive(self, state: bool) -> None:
"""Set the widget active state, which enables automatic tree
@@ -322,7 +303,6 @@ class GuiNovelToolBar(QWidget):
and self._refresh.get(handle, False)
):
self._refreshNovelTree(self.novelValue.handle)
return
##
# Public Slots
@@ -335,7 +315,6 @@ class GuiNovelToolBar(QWidget):
self.novelView.setCurrentNovel(tHandle)
SHARED.project.index.refreshNovelModel(tHandle)
self._refresh[tHandle] = False
return
##
# Private Slots
@@ -349,7 +328,6 @@ class GuiNovelToolBar(QWidget):
self._refresh[tHandle] = False
else:
self._refresh[tHandle] = True
return
@pyqtSlot()
def _selectLastColumnSize(self) -> None:
@@ -361,7 +339,6 @@ class GuiNovelToolBar(QWidget):
if isOk:
self.novelView.novelTree.setLastColSize(newSize)
self.novelView.novelTree.resizeColumns()
return
##
# Internal Functions
@@ -374,10 +351,10 @@ class GuiNovelToolBar(QWidget):
aLast.setActionGroup(self.gLastCol)
aLast.triggered.connect(qtLambda(self.setLastColType, colType))
self.aLastCol[colType] = aLast
return
class GuiNovelTree(NTreeView):
"""GUI: Novel View Panel Tree."""
def __init__(self, novelView: GuiNovelView) -> None:
super().__init__(parent=novelView)
@@ -414,8 +391,6 @@ class GuiNovelTree(NTreeView):
logger.debug("Ready: GuiNovelTree")
return
def initSettings(self) -> None:
"""Set or update tree widget settings."""
if CONFIG.hideVScroll:
@@ -426,7 +401,6 @@ class GuiNovelTree(NTreeView):
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
else:
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
return
##
# Properties
@@ -466,25 +440,21 @@ class GuiNovelTree(NTreeView):
self.resizeColumns()
else:
self.clearContent()
return
def setActiveHandle(self, tHandle: str | None) -> None:
"""Set the handle to be highlighted."""
self._actHandle = tHandle
if viewport := self.viewport():
viewport.repaint()
return
def setLastColType(self, colType: nwNovelExtra) -> None:
"""Set the extra column type."""
self._lastColType = colType
SHARED.project.index.setNovelModelExtraColumn(colType)
return
def setLastColSize(self, colSize: int) -> None:
"""Set the extra column size between 15% and 75%."""
self._lastColSize = minmax(colSize, 15, 75)/100.0
return
##
# Class Methods
@@ -493,7 +463,6 @@ class GuiNovelTree(NTreeView):
def clearContent(self) -> None:
"""Clear the tree view."""
self.setModel(None)
return
def resizeColumns(self) -> None:
"""Set the correct column sizes."""
@@ -506,7 +475,6 @@ class GuiNovelTree(NTreeView):
if model.columns == 4:
header.setSectionResizeMode(3, QtHeaderToContents)
header.setMaximumSectionSize(int(self._lastColSize * vp.width()))
return
##
# Overloads
@@ -517,7 +485,6 @@ class GuiNovelTree(NTreeView):
if (model := self._getModel()) and model.handle(index) == self._actHandle:
painter.fillRect(opt.rect, self.palette().alternateBase())
super().drawRow(painter, opt, index)
return
##
# Events
@@ -527,7 +494,6 @@ class GuiNovelTree(NTreeView):
"""Process size changed."""
super().resizeEvent(event)
self.resizeColumns()
return
##
# Private Slots
@@ -535,36 +501,33 @@ class GuiNovelTree(NTreeView):
@pyqtSlot(QModelIndex)
def _onSingleClick(self, index: QModelIndex) -> None:
"""The user single-clicked an index."""
"""Process user single-click on an index."""
if index.isValid() and (model := self._getModel()):
if (tHandle := model.handle(index)) and (sTitle := model.key(index)):
self.novelView.selectedItemChanged.emit(tHandle)
if index.column() == model.columnCount(index) - 1:
pos = self.mapToGlobal(self.visualRect(index).topRight())
self._popMetaBox(pos, tHandle, sTitle)
return
@pyqtSlot(QModelIndex)
def _onDoubleClick(self, index: QModelIndex) -> None:
"""The user double-clicked an index."""
"""Process user double-click on an index."""
if (
(model := self._getModel())
and (tHandle := model.handle(index))
and (sTitle := model.key(index))
):
self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle, False)
return
@pyqtSlot(QModelIndex)
def _onMiddleClick(self, index: QModelIndex) -> None:
"""The user middle-clicked an index."""
"""Process user middle-click on an index."""
if (
(model := self._getModel())
and (tHandle := model.handle(index))
and (sTitle := model.key(index))
):
self.novelView.openDocumentRequest.emit(tHandle, nwDocMode.VIEW, sTitle, False)
return
##
# Internal Functions
@@ -582,7 +545,6 @@ class GuiNovelTree(NTreeView):
"""Generate a reference list for a given reference key."""
if tags := ", ".join(refs.get(key, [])):
lines.append(f"<b>{trConst(nwLabels.KEY_NAME[key])}:</b> {tags}")
return
if head := SHARED.project.index.getItemHeading(tHandle, sTitle):
logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle)
@@ -610,4 +572,3 @@ class GuiNovelTree(NTreeView):
text = f"<p>{refs}</p>"
if tooltip := (text + synopsis or self.tr("No meta data")):
QToolTip.showText(qPos, tooltip)
return
+10 -57
View File
@@ -24,7 +24,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import csv
@@ -58,6 +58,7 @@ logger = logging.getLogger(__name__)
class GuiOutlineView(QWidget):
"""GUI: Project Outline Panel."""
loadDocumentTagRequest = pyqtSignal(str, Enum)
openDocumentRequest = pyqtSignal(str, Enum, str, bool)
@@ -96,8 +97,6 @@ class GuiOutlineView(QWidget):
# Function Mappings
self.getSelectedHandle = self.outlineTree.getSelectedHandle
return
##
# Methods
##
@@ -109,24 +108,20 @@ class GuiOutlineView(QWidget):
self.outlineTree.refreshTree(
rootHandle=SHARED.project.data.getLastHandle("outline"), overRide=True
)
return
def initSettings(self) -> None:
"""Initialise GUI elements that depend on specific settings."""
self.outlineTree.initSettings()
self.outlineData.initSettings()
return
def refreshTree(self) -> None:
"""Refresh the current tree."""
self.outlineTree.refreshTree(rootHandle=SHARED.project.data.getLastHandle("outline"))
return
def clearOutline(self) -> None:
"""Clear project-related GUI content."""
self.outlineData.clearDetails()
self.outlineBar.setEnabled(False)
return
def openProjectTasks(self) -> None:
"""Run open project tasks."""
@@ -142,8 +137,6 @@ class GuiOutlineView(QWidget):
self.outlineBar.setEnabled(True)
self.outlineData.loadGuiSettings()
return
def closeProjectTasks(self) -> None:
"""Run closing project tasks."""
if self.outlineTree.wasRendered:
@@ -152,7 +145,6 @@ class GuiOutlineView(QWidget):
self.outlineTree.closeProjectTasks()
self.outlineData.updateClasses()
self.clearOutline()
return
def splitSizes(self) -> list[int]:
"""Get the sizes of the splitter widget."""
@@ -175,7 +167,6 @@ class GuiOutlineView(QWidget):
"""Handle tasks whenever a root folders changes."""
self.outlineBar.populateNovelList()
self.outlineData.updateClasses()
return
##
# Private Slots
@@ -188,23 +179,21 @@ class GuiOutlineView(QWidget):
of columns has changed.
"""
self.outlineBar.setColumnHiddenState(self.outlineTree.hiddenColumns)
return
@pyqtSlot(str)
def _tagClicked(self, link: str) -> None:
"""Capture the click of a tag in the details panel."""
if link:
self.loadDocumentTagRequest.emit(link, nwDocMode.VIEW)
return
@pyqtSlot(str)
def _rootItemChanged(self, tHandle: str) -> None:
"""Handle root novel changed or needs to be refreshed."""
self.outlineTree.refreshTree(rootHandle=(tHandle or None), overRide=True)
return
class GuiOutlineToolBar(QToolBar):
"""GUI: Project Outline Panel ToolBar."""
loadNovelRootRequest = pyqtSignal(str)
outlineExportRequest = pyqtSignal()
@@ -263,8 +252,6 @@ class GuiOutlineToolBar(QToolBar):
logger.debug("Ready: GuiOutlineToolBar")
return
##
# Methods
##
@@ -278,22 +265,18 @@ class GuiOutlineToolBar(QToolBar):
self.tbColumns.setIcon(SHARED.theme.getIcon("more_vertical"))
self.tbColumns.setStyleSheet("QToolButton::menu-indicator {image: none;}")
self.novelLabel.setTextColors(color=self.palette().windowText().color())
return
def populateNovelList(self) -> None:
"""Reload the content of the novel list."""
self.novelValue.refreshNovelList()
return
def setCurrentRoot(self, rootHandle: str | None) -> None:
"""Set the current active root handle."""
self.novelValue.setHandle(rootHandle)
return
def setColumnHiddenState(self, hiddenState: dict[nwOutline, bool]) -> None:
"""Forward the change of column hidden states to the menu."""
self.mColumns.setHiddenState(hiddenState)
return
##
# Private Slots
@@ -303,22 +286,20 @@ class GuiOutlineToolBar(QToolBar):
def _novelValueChanged(self, tHandle: str) -> None:
"""Emit a signal containing the handle of the selected item."""
self.loadNovelRootRequest.emit(tHandle)
return
@pyqtSlot()
def _refreshRequested(self) -> None:
"""Emit a signal containing the handle of the selected item."""
self.loadNovelRootRequest.emit(self.novelValue.handle)
return
@pyqtSlot()
def _exportRequested(self) -> None:
"""Emit a signal that an export of the outline was requested."""
self.outlineExportRequest.emit()
return
class GuiOutlineTree(QTreeWidget):
"""GUI: Project Outline Panel Tree."""
DEF_WIDTH: Final[dict[nwOutline, int]] = {
nwOutline.TITLE: 200,
@@ -422,8 +403,6 @@ class GuiOutlineTree(QTreeWidget):
logger.debug("Ready: GuiOutlineTree")
return
##
# Properties
##
@@ -451,7 +430,6 @@ class GuiOutlineTree(QTreeWidget):
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
else:
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
return
def clearContent(self) -> None:
"""Clear the tree and header and set the default values for the
@@ -474,8 +452,6 @@ class GuiOutlineTree(QTreeWidget):
self._treeNCols = len(self._treeOrder)
return
def updateTheme(self) -> None:
"""Update theme elements."""
iType = nwItemType.FILE
@@ -488,15 +464,14 @@ class GuiOutlineTree(QTreeWidget):
"H3": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H3"),
"H4": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H4"),
}
return
def refreshTree(
self, rootHandle: str | None = None,
overRide: bool = False, novelChanged: bool = False
) -> None:
"""Called whenever the Outline tab is activated and controls
what data to load, and if necessary, force a rebuild of the
tree.
"""Refresh the outline tree. Called whenever the Outline tab is
activated and controls what data to load, and if necessary,
force a rebuild of the tree.
"""
# If it's the first time, we always build
if self._firstView or (self._firstView and overRide):
@@ -518,11 +493,10 @@ class GuiOutlineTree(QTreeWidget):
return
def closeProjectTasks(self) -> None:
"""Called before a project is closed."""
"""Call before a project is closed."""
self._saveHeaderState()
self.clearContent()
self._firstView = True
return
def getSelectedHandle(self) -> tuple[str | None, str | None]:
"""Get the currently selected handle. If multiple items are
@@ -546,7 +520,6 @@ class GuiOutlineTree(QTreeWidget):
if hItem in self._colIdx:
self.setColumnHidden(self._colIdx[hItem], not isChecked)
self._saveHeaderState()
return
@pyqtSlot()
def exportOutline(self) -> None:
@@ -562,7 +535,6 @@ class GuiOutlineTree(QTreeWidget):
writer.writerows(
self._dumpNovelData(self.outlineView.outlineBar.novelValue.handle)
)
return
##
# Private Slots
@@ -577,7 +549,6 @@ class GuiOutlineTree(QTreeWidget):
tHandle, sTitle = self.getSelectedHandle()
if tHandle:
self.outlineView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", True)
return
@pyqtSlot()
def _onItemSelectionChanged(self) -> None:
@@ -588,7 +559,6 @@ class GuiOutlineTree(QTreeWidget):
tHandle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
sTitle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
self.activeItemChanged.emit(tHandle, sTitle)
return
@pyqtSlot(int, int, int)
def _columnMoved(self, logIdx: int, oldVisualIdx: int, newVisualIdx: int) -> None:
@@ -597,7 +567,6 @@ class GuiOutlineTree(QTreeWidget):
"""
self._treeOrder.insert(newVisualIdx, self._treeOrder.pop(oldVisualIdx))
self._saveHeaderState()
return
##
# Internal Functions
@@ -637,8 +606,6 @@ class GuiOutlineTree(QTreeWidget):
self.hiddenStateChanged.emit()
return
def _saveHeaderState(self) -> None:
"""Save the state of the main tree header, that is, column
order, column width and column hidden state. We don't want to
@@ -661,7 +628,6 @@ class GuiOutlineTree(QTreeWidget):
pOptions = SHARED.project.options
pOptions.setValue("GuiOutline", "columnState", colState)
pOptions.saveSettings()
return
def _populateTree(self, rootHandle: str | None) -> None:
"""Build the tree based on the project index, and the header
@@ -746,8 +712,6 @@ class GuiOutlineTree(QTreeWidget):
self._lastBuild = time()
logger.debug("Project outline built in %.3f ms", 1000.0*(time() - tStart))
return
def _dumpNovelData(self, rootHandle: str | None) -> list[list[str | int]]:
"""Dump all novel data into a table."""
sLabel = SHARED.project.localLookup("Story Structure")
@@ -821,6 +785,7 @@ class GuiOutlineTree(QTreeWidget):
class GuiOutlineHeaderMenu(QMenu):
"""GUI: Project Outline Panel Header Selection Menu."""
columnToggled = pyqtSignal(bool, Enum)
@@ -844,8 +809,6 @@ class GuiOutlineHeaderMenu(QMenu):
)
self.addAction(self.actionMap[hItem])
return
def setHiddenState(self, hiddenState: dict[nwOutline, bool]) -> None:
"""Overwrite the checked state of the columns as the inverse of
the hidden state. Skip the TITLE column as it cannot be hidden.
@@ -859,10 +822,9 @@ class GuiOutlineHeaderMenu(QMenu):
self.acceptToggle = True
return
class GuiOutlineDetails(QScrollArea):
"""GUI: Project Outline Panel Details View."""
LVL_MAP: Final[dict[str, str]] = {
"H1": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Title"),
@@ -1007,8 +969,6 @@ class GuiOutlineDetails(QScrollArea):
logger.debug("Ready: GuiOutlineDetails")
return
def initSettings(self) -> None:
"""Set or update outline settings."""
if CONFIG.hideVScroll:
@@ -1020,7 +980,6 @@ class GuiOutlineDetails(QScrollArea):
else:
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
self.updateClasses()
return
def loadGuiSettings(self) -> None:
"""Run open project tasks."""
@@ -1031,7 +990,6 @@ class GuiOutlineDetails(QScrollArea):
pOptions.getInt("GuiOutlineDetails", "detailsWidth", width//3),
pOptions.getInt("GuiOutlineDetails", "tagsWidth", 2*width//3),
])
return
def saveGuiSettings(self) -> None:
"""Run close project tasks."""
@@ -1040,7 +998,6 @@ class GuiOutlineDetails(QScrollArea):
pOptions = SHARED.project.options
pOptions.setValue("GuiOutlineDetails", "detailsWidth", mainSplit[0])
pOptions.setValue("GuiOutlineDetails", "tagsWidth", mainSplit[1])
return
def clearDetails(self) -> None:
"""Clear all the data labels."""
@@ -1057,7 +1014,6 @@ class GuiOutlineDetails(QScrollArea):
value.clear()
self.updateClasses()
return
##
# Slots
@@ -1090,8 +1046,6 @@ class GuiOutlineDetails(QScrollArea):
for key, (_, value) in self.tagValues.items():
value.setText(self._formatTags(novRefs, key))
return
@pyqtSlot()
def updateClasses(self) -> None:
"""Update the visibility status of class details."""
@@ -1102,7 +1056,6 @@ class GuiOutlineDetails(QScrollArea):
label, value = self.tagValues[key]
label.setVisible(visible)
value.setVisible(visible)
return
@staticmethod
def _formatTags(refs: dict[str, list[str]], key: str) -> str:
+30 -89
View File
@@ -25,7 +25,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -61,7 +61,9 @@ logger = logging.getLogger(__name__)
class GuiProjectView(QWidget):
"""This is a wrapper class holding all the elements of the project
"""GUI: Project View.
This is a wrapper class holding all the elements of the project
tree. The core object is the project tree itself. Most methods
available are mapped through to the project tree class.
"""
@@ -130,8 +132,6 @@ class GuiProjectView(QWidget):
# Function Mappings
self.getSelectedHandle = self.projTree.getSelectedHandle
return
##
# Methods
##
@@ -139,19 +139,16 @@ class GuiProjectView(QWidget):
def updateTheme(self) -> None:
"""Update theme elements."""
self.projBar.updateTheme()
return
def initSettings(self) -> None:
"""Initialise GUI elements that depend on specific settings."""
self.projTree.initSettings()
return
def closeProjectTasks(self) -> None:
"""Clear project-related GUI content."""
self.projBar.clearContent()
self.projBar.setEnabled(False)
self.projTree.clearTree()
return
def openProjectTasks(self) -> None:
"""Run open project tasks."""
@@ -159,26 +156,23 @@ class GuiProjectView(QWidget):
self.projBar.buildTemplatesMenu()
self.projBar.buildQuickLinksMenu()
self.projBar.setEnabled(True)
return
def setTreeFocus(self) -> None:
"""Forward the set focus call to the tree widget."""
self.projTree.setFocus()
return
def treeHasFocus(self) -> bool:
"""Check if the project tree has focus."""
return self.projTree.hasFocus()
def connectMenuActions(self, rename: QAction, delete: QAction, trash: QAction) -> None:
"""Main menu actions passed to the project tree."""
"""Connect main menu actions passed to the project tree."""
self.projTree.addAction(rename)
self.projTree.addAction(delete)
self.projTree.addAction(trash)
rename.triggered.connect(self.renameTreeItem)
delete.triggered.connect(self.projTree.processDeleteRequest)
trash.triggered.connect(self.projTree.emptyTrash)
return
##
# Public Slots
@@ -197,41 +191,36 @@ class GuiProjectView(QWidget):
if dlgOk:
nwItem.setName(newLabel)
nwItem.notifyToRefresh()
return
@pyqtSlot(str, bool)
def setSelectedHandle(self, tHandle: str, doScroll: bool = False) -> None:
"""Select an item and optionally scroll it into view."""
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, Enum)
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Refresh other content when project item changed."""
self.projBar.processTemplateDocuments(tHandle)
return
@pyqtSlot(str)
def createFileFromTemplate(self, tHandle: str) -> None:
"""Create a new document from a template."""
logger.debug("Template selected: '%s'", tHandle)
self.projTree.newTreeItem(nwItemType.FILE, copyDoc=tHandle)
return
@pyqtSlot(str, Enum)
def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""Process root item changes."""
self.projBar.buildQuickLinksMenu()
return
class GuiProjectToolBar(QWidget):
"""GUI> Project View ToolBar."""
newDocumentFromTemplate = pyqtSignal(str)
@@ -351,8 +340,6 @@ class GuiProjectToolBar(QWidget):
logger.debug("Ready: GuiProjectToolBar")
return
##
# Methods
##
@@ -383,13 +370,10 @@ class GuiProjectToolBar(QWidget):
self.buildQuickLinksMenu()
self._buildRootMenu()
return
def clearContent(self) -> None:
"""Clear dynamic content on the tool bar."""
self.mQuick.clear()
self.mTemplates.clearMenu()
return
def buildQuickLinksMenu(self) -> None:
"""Build the quick link menu."""
@@ -402,14 +386,12 @@ class GuiProjectToolBar(QWidget):
action.triggered.connect(
qtLambda(self.projView.setSelectedHandle, tHandle, doScroll=True)
)
return
def buildTemplatesMenu(self) -> None:
"""Build the templates menu."""
for tHandle, _ in SHARED.project.tree.iterRoots(nwItemClass.TEMPLATE):
for dHandle in SHARED.project.tree.subTree(tHandle):
self.processTemplateDocuments(dHandle)
return
def processTemplateDocuments(self, tHandle: str) -> None:
"""Process change in tree items to update menu content."""
@@ -418,7 +400,6 @@ class GuiProjectToolBar(QWidget):
self.mTemplates.addUpdate(tHandle, item.itemName, item.getMainIcon())
elif tHandle in self.mTemplates:
self.mTemplates.remove(tHandle)
return
##
# Public Slots
@@ -436,7 +417,6 @@ class GuiProjectToolBar(QWidget):
self.aAddChap.setVisible(allowDoc)
self.aAddPart.setVisible(allowDoc)
self.aAddEmpty.setVisible(allowDoc)
return
##
# Internal Functions
@@ -451,7 +431,6 @@ class GuiProjectToolBar(QWidget):
qtLambda(self.projTree.newTreeItem, nwItemType.ROOT, itemClass)
)
self.mAddRoot.addAction(aNew)
return
self.mAddRoot.clear()
addClass(nwItemClass.NOVEL)
@@ -467,10 +446,9 @@ class GuiProjectToolBar(QWidget):
addClass(nwItemClass.ARCHIVE)
addClass(nwItemClass.TEMPLATE)
return
class GuiProjectTree(QTreeView):
"""GUI: Project View Tree."""
def __init__(self, projView: GuiProjectView) -> None:
super().__init__(parent=projView)
@@ -520,8 +498,6 @@ class GuiProjectTree(QTreeView):
logger.debug("Ready: GuiProjectTree")
return
def initSettings(self) -> None:
"""Set or update tree widget settings."""
if CONFIG.hideVScroll:
@@ -532,7 +508,6 @@ class GuiProjectTree(QTreeView):
self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
else:
self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
return
##
# External Methods
@@ -541,7 +516,6 @@ class GuiProjectTree(QTreeView):
def setActiveHandle(self, tHandle: str | None) -> None:
"""Set the handle to be highlighted."""
self._actHandle = tHandle
return
def getSelectedHandle(self) -> str | None:
"""Get the currently selected handle."""
@@ -556,7 +530,6 @@ class GuiProjectTree(QTreeView):
def clearTree(self) -> None:
"""Clear the tree view."""
self.setModel(None)
return
def loadModel(self) -> None:
"""Load and prepare a new project model."""
@@ -583,8 +556,6 @@ class GuiProjectTree(QTreeView):
self.restoreExpandedState()
return
def restoreExpandedState(self) -> None:
"""Expand all nodes that were previously expanded."""
if model := self._getModel():
@@ -592,7 +563,6 @@ class GuiProjectTree(QTreeView):
for index in model.allExpanded():
self.setExpanded(index, True)
self.blockSignals(False)
return
def setSelectedHandle(self, tHandle: str | None, doScroll: bool = False) -> None:
"""Set a specific handle as the selected item."""
@@ -601,7 +571,6 @@ class GuiProjectTree(QTreeView):
if doScroll:
self.scrollTo(index, QAbstractItemView.ScrollHint.PositionAtCenter)
self.projView.selectedItemChanged.emit(tHandle)
return
def newTreeItem(
self, itemType: nwItemType, itemClass: nwItemClass | None = None,
@@ -667,7 +636,7 @@ class GuiProjectTree(QTreeView):
if itemType == nwItemType.FILE:
if tHandle := SHARED.project.newFile(newLabel, sHandle, sPos):
if copyDoc:
SHARED.project.copyFileContent(tHandle, copyDoc)
SHARED.project.copyFileContent(tHandle, copyDoc, newLabel)
elif hLevel > 0:
SHARED.project.writeNewFile(tHandle, hLevel, not nNote)
SHARED.project.index.reIndexHandle(tHandle)
@@ -718,9 +687,15 @@ class GuiProjectTree(QTreeView):
else:
return False
SHARED.initMainProgress(len(items))
self.setEnabled(False)
for sHandle in items:
SHARED.incMainProgress()
docMerger.appendText(sHandle, True, mLabel)
self.setEnabled(True)
SHARED.clearMainProgress()
if not docMerger.writeTargetDoc():
SHARED.error(
self.tr("Could not write document content."),
@@ -764,7 +739,10 @@ class GuiProjectTree(QTreeView):
docSplit.setParentItem(tItem.itemParent)
docSplit.splitDocument(headerList, text)
SHARED.initMainProgress(len(docSplit))
self.setEnabled(False)
for writeOk in docSplit.writeDocuments(docHierarchy):
SHARED.incMainProgress()
if not writeOk:
SHARED.error(
self.tr("Could not write document content."),
@@ -774,6 +752,9 @@ class GuiProjectTree(QTreeView):
if data.get("moveToTrash", False):
self.processDeleteRequest([tHandle], False)
self.setEnabled(True)
SHARED.clearMainProgress()
return True
def duplicateFromHandle(self, tHandle: str) -> None:
@@ -786,12 +767,13 @@ class GuiProjectTree(QTreeView):
else:
question = self.tr("Do you want to duplicate this item and all child items?")
if SHARED.question(question):
self.setEnabled(False)
docDup = DocDuplicator(SHARED.project)
dHandles = docDup.duplicate(itemTree)
if len(dHandles) != len(itemTree):
SHARED.warn(self.tr("Could not duplicate all items."))
self.setEnabled(True)
self.restoreExpandedState()
return
##
# Events and Overloads
@@ -811,14 +793,12 @@ class GuiProjectTree(QTreeView):
self.projView.openDocumentRequest.emit(
node.item.itemHandle, nwDocMode.VIEW, "", False
)
return
def drawRow(self, painter: QPainter, opt: QStyleOptionViewItem, index: QModelIndex) -> None:
"""Draw a box on the active row."""
if (node := self._getNode(index)) and node.item.itemHandle == self._actHandle:
painter.fillRect(opt.rect, self.palette().alternateBase())
super().drawRow(painter, opt, index)
return
##
# Public Slots
@@ -829,14 +809,12 @@ class GuiProjectTree(QTreeView):
"""Move an item up in the tree."""
if model := self._getModel():
model.internalMove(self.currentIndex(), -1)
return
@pyqtSlot()
def moveItemDown(self) -> None:
"""Move an item down in the tree."""
if model := self._getModel():
model.internalMove(self.currentIndex(), 1)
return
@pyqtSlot()
def goToSiblingUp(self) -> None:
@@ -844,7 +822,6 @@ class GuiProjectTree(QTreeView):
if (node := self._getNode(self.currentIndex())) and (parent := node.parent()):
if (move := parent.child(node.row() - 1)) and (model := self._getModel()):
self.setCurrentIndex(model.indexFromNode(move))
return
@pyqtSlot()
def goToSiblingDown(self) -> None:
@@ -852,7 +829,6 @@ class GuiProjectTree(QTreeView):
if (node := self._getNode(self.currentIndex())) and (parent := node.parent()):
if (move := parent.child(node.row() + 1)) and (model := self._getModel()):
self.setCurrentIndex(model.indexFromNode(move))
return
@pyqtSlot()
def goToParent(self) -> None:
@@ -863,7 +839,6 @@ class GuiProjectTree(QTreeView):
and (parent := node.parent())
):
self.setCurrentIndex(model.indexFromNode(parent))
return
@pyqtSlot()
def goToFirstChild(self) -> None:
@@ -874,13 +849,11 @@ class GuiProjectTree(QTreeView):
and (child := node.child(0))
):
self.setCurrentIndex(model.indexFromNode(child))
return
@pyqtSlot(QModelIndex)
def expandFromIndex(self, index: QModelIndex) -> None:
"""Expand all nodes from index."""
self.expandRecursively(index)
return
@pyqtSlot(QModelIndex)
def collapseFromIndex(self, index: QModelIndex) -> None:
@@ -888,7 +861,6 @@ class GuiProjectTree(QTreeView):
if (model := self._getModel()) and (node := model.node(index)):
for child in node.allChildren():
self.setExpanded(model.indexFromNode(child), False)
return
@pyqtSlot()
def processDeleteRequest(
@@ -912,17 +884,23 @@ class GuiProjectTree(QTreeView):
if not SHARED.question(self.tr("Permanently delete selected item(s)?")):
logger.info("Action cancelled by user")
return
self.setEnabled(False)
for index in indices:
if node := model.node(index):
for child in reversed(node.allChildren()):
SHARED.project.removeItem(child.item.itemHandle)
SHARED.project.removeItem(node.item.itemHandle)
self.setEnabled(True)
elif trashNode := SHARED.project.tree.trash:
if askFirst and not SHARED.question(self.tr("Move selected item(s) to Trash?")):
logger.info("Action cancelled by user")
return
self.setEnabled(False)
model.multiMove(indices, model.indexFromNode(trashNode))
self.setEnabled(True)
return
@@ -948,9 +926,7 @@ class GuiProjectTree(QTreeView):
@pyqtSlot()
@pyqtSlot("QPoint")
def openContextMenu(self, point: QPoint | None = None) -> None:
"""The user right clicked an element in the project tree, so we
open a context menu in-place.
"""
"""Open a context menu in-place where the user clicked."""
if model := self._getModel():
if point is None:
point = self.visualRect(self.currentIndex()).center()
@@ -967,7 +943,6 @@ class GuiProjectTree(QTreeView):
if viewport := self.viewport():
ctxMenu.exec(viewport.mapToGlobal(point))
ctxMenu.setParent(None)
return
##
# Private Slots
@@ -975,10 +950,9 @@ class GuiProjectTree(QTreeView):
@pyqtSlot(QModelIndex, QModelIndex)
def _onSelectionChange(self, current: QModelIndex, previous: QModelIndex) -> None:
"""The user changed which item is selected."""
"""Process user changing which item is selected."""
if node := self._getNode(current):
self.projView.selectedItemChanged.emit(node.item.itemHandle)
return
@pyqtSlot(QModelIndex)
def _onDoubleClick(self, index: QModelIndex) -> None:
@@ -992,21 +966,18 @@ class GuiProjectTree(QTreeView):
)
else:
self.setExpanded(index, not self.isExpanded(index))
return
@pyqtSlot(QModelIndex)
def _onNodeCollapsed(self, index: QModelIndex) -> None:
"""Capture a node collapse, and pass it to the model."""
if node := self._getNode(index):
node.setExpanded(False)
return
@pyqtSlot(QModelIndex)
def _onNodeExpanded(self, index: QModelIndex) -> None:
"""Capture a node expand, and pass it to the model."""
if node := self._getNode(index):
node.setExpanded(True)
return
##
# Internal Functions
@@ -1018,7 +989,6 @@ class GuiProjectTree(QTreeView):
if model := self.selectionModel():
# Selection model can be None (#2173)
model.clearCurrentIndex()
return
def _selectedRows(self) -> list[QModelIndex]:
"""Return all column 0 indexes."""
@@ -1046,7 +1016,6 @@ class _UpdatableMenu(QMenu):
self._map: dict[str, QAction] = {}
self.setTitle(self.tr("From Template"))
self.triggered.connect(self._actionTriggered)
return
def __contains__(self, tHandle: str) -> bool:
"""Look up a handle in the menu."""
@@ -1068,7 +1037,6 @@ class _UpdatableMenu(QMenu):
self.addAction(action)
self._map[tHandle] = action
self.setActionsVisible(True)
return
def remove(self, tHandle: str) -> None:
"""Remove a template item."""
@@ -1076,19 +1044,16 @@ class _UpdatableMenu(QMenu):
self.removeAction(action)
if not self._map:
self.setActionsVisible(False)
return
def clearMenu(self) -> None:
"""Clear all menu content."""
self._map.clear()
self.clear()
return
def setActionsVisible(self, value: bool) -> None:
"""Set the visibility of root action."""
if action := self.menuAction():
action.setVisible(value)
return
##
# Private Slots
@@ -1098,7 +1063,6 @@ class _UpdatableMenu(QMenu):
def _actionTriggered(self, action: QAction) -> None:
"""Translate the menu trigger into an item trigger."""
self.menuItemTriggered.emit(str(action.data()))
return
class _TreeContextMenu(QMenu):
@@ -1119,11 +1083,9 @@ class _TreeContextMenu(QMenu):
self._indices = indices
self._children = node.childCount() > 0
logger.debug("Ready: _TreeContextMenu")
return
def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: _TreeContextMenu")
return
##
# Methods
@@ -1135,7 +1097,6 @@ class _TreeContextMenu(QMenu):
action.triggered.connect(self._tree.emptyTrash)
if self._children:
self._expandCollapse()
return
def buildSingleSelectMenu(self) -> None:
"""Build the single-select menu."""
@@ -1171,15 +1132,12 @@ class _TreeContextMenu(QMenu):
action.triggered.connect(qtLambda(self._tree.duplicateFromHandle, self._handle))
self._deleteOrTrash()
return
def buildMultiSelectMenu(self) -> None:
"""Build the multi-select menu."""
self._itemActive()
self._itemStatusImport(True)
self.addSeparator()
self._deleteOrTrash()
return
##
# Menu Builders
@@ -1197,7 +1155,6 @@ class _TreeContextMenu(QMenu):
self._view.openDocumentRequest.emit,
self._handle, nwDocMode.VIEW, "", False
))
return
def _itemCreation(self) -> None:
"""Add create item actions."""
@@ -1208,7 +1165,6 @@ class _TreeContextMenu(QMenu):
menu.addAction(self._view.projBar.aAddEmpty)
menu.addAction(self._view.projBar.aAddNote)
menu.addAction(self._view.projBar.aAddFolder)
return
def _itemHeader(self) -> None:
"""Check if there is a header that can be used for rename."""
@@ -1218,7 +1174,6 @@ class _TreeContextMenu(QMenu):
action.triggered.connect(
qtLambda(self._view.renameTreeItem, self._handle, hItem.title)
)
return
def _itemActive(self) -> None:
"""Add Active/Inactive actions."""
@@ -1233,7 +1188,6 @@ class _TreeContextMenu(QMenu):
else:
action = qtAddAction(self, self.tr("Toggle Active"))
action.triggered.connect(self._toggleItemActive)
return
def _itemStatusImport(self, multi: bool) -> None:
"""Add actions for changing status or importance."""
@@ -1275,7 +1229,6 @@ class _TreeContextMenu(QMenu):
self._view.projectSettingsRequest.emit,
GuiProjectSettings.PAGE_IMPORT
))
return
def _itemTransform(self, isFile: bool, isFolder: bool) -> None:
"""Add actions for the Transform menu."""
@@ -1318,15 +1271,12 @@ class _TreeContextMenu(QMenu):
action = qtAddAction(menu, self.tr("Split Document by Headings"))
action.triggered.connect(qtLambda(self._tree.splitDocument, self._handle))
return
def _expandCollapse(self) -> None:
"""Add actions for expand and collapse."""
action = qtAddAction(self, self.tr("Expand All"))
action.triggered.connect(qtLambda(self._tree.expandFromIndex, self._indices[0]))
action = qtAddAction(self, self.tr("Collapse All"))
action.triggered.connect(qtLambda(self._tree.collapseFromIndex, self._indices[0]))
return
def _deleteOrTrash(self) -> None:
"""Add move to Trash action."""
@@ -1339,7 +1289,6 @@ class _TreeContextMenu(QMenu):
text = self.tr("Move to Trash")
action = qtAddAction(self, text)
action.triggered.connect(self._tree.processDeleteRequest)
return
##
# Private Slots
@@ -1351,7 +1300,6 @@ class _TreeContextMenu(QMenu):
if self._item.isFileType():
self._item.setActive(not self._item.isActive)
self._item.notifyToRefresh()
return
##
# Internal Functions
@@ -1365,13 +1313,11 @@ class _TreeContextMenu(QMenu):
node.item.setActive(state)
refresh.append(node.item.itemHandle)
SHARED.project.tree.refreshItems(refresh)
return
def _changeItemStatus(self, key: str) -> None:
"""Set a new status value of an item."""
self._item.setStatus(key)
self._item.notifyToRefresh()
return
def _iterSetItemStatus(self, key: str) -> None:
"""Change the status value for multiple items."""
@@ -1381,13 +1327,11 @@ class _TreeContextMenu(QMenu):
node.item.setStatus(key)
refresh.append(node.item.itemHandle)
SHARED.project.tree.refreshItems(refresh)
return
def _changeItemImport(self, key: str) -> None:
"""Set a new importance value of an item."""
self._item.setImport(key)
self._item.notifyToRefresh()
return
def _iterSetItemImport(self, key: str) -> None:
"""Change the status value for multiple items."""
@@ -1397,7 +1341,6 @@ class _TreeContextMenu(QMenu):
node.item.setImport(key)
refresh.append(node.item.itemHandle)
SHARED.project.tree.refreshItems(refresh)
return
def _changeItemLayout(self, itemLayout: nwItemLayout) -> None:
"""Set a new item layout value of an item."""
@@ -1408,7 +1351,6 @@ class _TreeContextMenu(QMenu):
elif itemLayout == nwItemLayout.NOTE:
self._item.setLayout(nwItemLayout.NOTE)
self._item.notifyToRefresh()
return
def _convertFolderToFile(self, itemLayout: nwItemLayout) -> None:
"""Convert a folder to a note or document."""
@@ -1428,4 +1370,3 @@ class _TreeContextMenu(QMenu):
self._item.notifyToRefresh()
else:
logger.info("Folder conversion cancelled")
return
+2 -19
View File
@@ -20,7 +20,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -50,6 +50,7 @@ logger = logging.getLogger(__name__)
class GuiProjectSearch(QWidget):
"""GUI: Project Search Panel."""
C_NAME = 0
C_RESULT = 0
@@ -151,8 +152,6 @@ class GuiProjectSearch(QWidget):
logger.debug("Ready: GuiProjectSearch")
return
##
# Methods
##
@@ -174,8 +173,6 @@ class GuiProjectSearch(QWidget):
self.toggleWord.setIcon(SHARED.theme.getIcon("search_word"))
self.toggleRegEx.setIcon(SHARED.theme.getIcon("search_regex"))
return
def processReturn(self) -> None:
"""Process a return keypress forwarded from the main GUI."""
if self.searchText.hasFocus():
@@ -189,7 +186,6 @@ class GuiProjectSearch(QWidget):
self.openDocumentSelectRequest.emit(
str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), False
)
return
def beginSearch(self, text: str = "") -> None:
"""Focus the search box and select its text, if any."""
@@ -198,20 +194,17 @@ class GuiProjectSearch(QWidget):
if text:
self.searchText.setText(text.partition("\n")[0])
self.searchText.selectAll()
return
def closeProjectTasks(self) -> None:
"""Run close project tasks."""
self._map = {}
self.searchText.clear()
self.searchResult.clear()
return
def refreshCurrentSearch(self) -> None:
"""Refresh the search if there is one."""
if self.searchResult.topLevelItemCount() > 0:
self._processSearch()
return
##
# Events
@@ -238,7 +231,6 @@ class GuiProjectSearch(QWidget):
self.searchText.setFocus()
else:
super().keyPressEvent(event)
return
##
# Public Slots
@@ -252,7 +244,6 @@ class GuiProjectSearch(QWidget):
results, capped = self._search.searchText(SHARED.mainGui.docEditor.getText())
self._displayResultSet(SHARED.project.tree[tHandle], results, capped)
logger.debug("Updated search for '%s' in %.3f ms", tHandle, 1000*(time() - start))
return
##
# Private Slots
@@ -278,7 +269,6 @@ class GuiProjectSearch(QWidget):
self._time = time()
QApplication.restoreOverrideCursor()
self._blocked = False
return
@pyqtSlot()
def _searchResultSelected(self) -> None:
@@ -288,7 +278,6 @@ class GuiProjectSearch(QWidget):
self.selectedItemChanged.emit(str(data[0]))
elif data := items[0].data(0, self.D_HANDLE):
self.selectedItemChanged.emit(str(data))
return
@pyqtSlot("QTreeWidgetItem*", int)
def _searchResultDoubleClicked(self, item: QTreeWidgetItem, column: int) -> None:
@@ -297,28 +286,24 @@ class GuiProjectSearch(QWidget):
self.openDocumentSelectRequest.emit(
str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), True
)
return
@pyqtSlot(bool)
def _toggleCase(self, state: bool) -> None:
"""Enable/disable case sensitive mode."""
CONFIG.searchProjCase = state
self.refreshCurrentSearch()
return
@pyqtSlot(bool)
def _toggleWord(self, state: bool) -> None:
"""Enable/disable whole word search mode."""
CONFIG.searchProjWord = state
self.refreshCurrentSearch()
return
@pyqtSlot(bool)
def _toggleRegEx(self, state: bool) -> None:
"""Enable/disable regular expression search mode."""
CONFIG.searchProjRegEx = state
self.refreshCurrentSearch()
return
##
# Internal Functions
@@ -360,5 +345,3 @@ class GuiProjectSearch(QWidget):
self.searchResult.setFirstColumnSpanned(i, parent, True)
QApplication.processEvents()
return
+42 -10
View File
@@ -20,19 +20,20 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from PyQt6.QtCore import QEvent, QPoint, QSize, pyqtSignal
from PyQt6.QtCore import QEvent, QPoint, QSize, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import QMenu, QVBoxLayout, QWidget
from novelwriter import SHARED
from novelwriter import CONFIG, SHARED
from novelwriter.common import qtLambda
from novelwriter.enum import nwView
from novelwriter.constants import nwLabels, trConst
from novelwriter.enum import nwTheme, nwView
from novelwriter.extensions.eventfilters import StatusTipFilter
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_BIG_TOOLBUTTON
@@ -44,6 +45,7 @@ logger = logging.getLogger(__name__)
class GuiSideBar(QWidget):
"""GUI: Main Window SideBar."""
requestViewChange = pyqtSignal(nwView)
@@ -77,9 +79,9 @@ class GuiSideBar(QWidget):
self.tbOutline.setToolTip("{0} [Ctrl+Shift+T]".format(self.tr("Novel Outline View")))
self.tbOutline.clicked.connect(qtLambda(self.requestViewChange.emit, nwView.OUTLINE))
self.tbBuild = NIconToolButton(self, iSz)
self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript")))
self.tbBuild.clicked.connect(self.mainGui.showBuildManuscriptDialog)
self.tbTheme = NIconToolButton(self, iSz)
self.tbTheme.setToolTip(self.tr("Switch Colour Theme"))
self.tbTheme.clicked.connect(self._cycleColurTheme)
self.tbDetails = NIconToolButton(self, iSz)
self.tbDetails.setToolTip("{0} [Shift+F6]".format(self.tr("Novel Details")))
@@ -89,6 +91,10 @@ class GuiSideBar(QWidget):
self.tbStats.setToolTip("{0} [F6]".format(self.tr("Writing Statistics")))
self.tbStats.clicked.connect(self.mainGui.showWritingStatsDialog)
self.tbBuild = NIconToolButton(self, iSz)
self.tbBuild.setToolTip("{0} [F5]".format(self.tr("Build Manuscript")))
self.tbBuild.clicked.connect(self.mainGui.showBuildManuscriptDialog)
# Settings Menu
self.tbSettings = NIconToolButton(self, iSz)
self.tbSettings.setToolTip(self.tr("Settings"))
@@ -111,6 +117,7 @@ class GuiSideBar(QWidget):
self.outerBox.addStretch(1)
self.outerBox.addWidget(self.tbDetails)
self.outerBox.addWidget(self.tbStats)
self.outerBox.addWidget(self.tbTheme)
self.outerBox.addWidget(self.tbSettings)
self.outerBox.setContentsMargins(0, 0, 0, 0)
self.outerBox.setSpacing(6)
@@ -120,8 +127,6 @@ class GuiSideBar(QWidget):
logger.debug("Ready: GuiSideBar")
return
def updateTheme(self) -> None:
"""Initialise GUI elements that depend on specific settings."""
buttonStyle = SHARED.theme.getStyleSheet(STYLES_BIG_TOOLBUTTON)
@@ -133,6 +138,7 @@ class GuiSideBar(QWidget):
self.tbBuild.setStyleSheet(buttonStyle)
self.tbDetails.setStyleSheet(buttonStyle)
self.tbStats.setStyleSheet(buttonStyle)
self.tbTheme.setStyleSheet(buttonStyle)
self.tbSettings.setStyleSheet(buttonStyle)
self.tbProject.setThemeIcon("sb_project")
@@ -144,7 +150,33 @@ class GuiSideBar(QWidget):
self.tbStats.setThemeIcon("sb_stats")
self.tbSettings.setThemeIcon("settings")
return
self._setThemeModeIcon()
##
# Private Slots
##
@pyqtSlot()
def _cycleColurTheme(self) -> None:
"""Go to nex colour theme."""
match CONFIG.themeMode:
case nwTheme.AUTO:
CONFIG.themeMode = nwTheme.LIGHT
case nwTheme.LIGHT:
CONFIG.themeMode = nwTheme.DARK
case nwTheme.DARK:
CONFIG.themeMode = nwTheme.AUTO
self.mainGui.checkThemeUpdate()
self._setThemeModeIcon()
##
# Internal Functions
##
def _setThemeModeIcon(self) -> None:
"""Set the theme button icon."""
self.tbTheme.setThemeIcon(nwLabels.THEME_MODE_ICON[CONFIG.themeMode])
self.tbTheme.setToolTip(trConst(nwLabels.THEME_MODE_LABEL[CONFIG.themeMode]))
class _PopRightMenu(QMenu):
+5 -22
View File
@@ -20,7 +20,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
""" # noqa
from __future__ import annotations
import logging
@@ -41,6 +41,7 @@ logger = logging.getLogger(__name__)
class GuiMainStatus(QStatusBar):
"""GUI: Main Window Status Bar."""
def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent)
@@ -112,8 +113,6 @@ class GuiMainStatus(QStatusBar):
self.updateTheme()
self.clearStatus()
return
def initSettings(self) -> None:
"""Apply user settings."""
if CONFIG.useCharCount:
@@ -122,7 +121,6 @@ class GuiMainStatus(QStatusBar):
else:
self._trStatsCount = trStats(nwLabels.STATS_DISPLAY[nwStats.WORDS])
self._trStatsTip = self.tr("Total word count (session change)")
return
def clearStatus(self) -> None:
"""Reset all widgets on the status bar to default values."""
@@ -132,7 +130,6 @@ class GuiMainStatus(QStatusBar):
self.setProjectStatus(None)
self.setDocumentStatus(None)
self.updateTime()
return
def updateTheme(self) -> None:
"""Update theme elements."""
@@ -143,14 +140,12 @@ class GuiMainStatus(QStatusBar):
self.idlePixmap = SHARED.theme.getPixmap("timer_off", (iPx, iPx))
self.timeIcon.setPixmap(self.timePixmap)
colNone = SHARED.theme.getIconColor("default").darker(150)
colSaved = SHARED.theme.getIconColor("green").darker(150)
colUnsaved = SHARED.theme.getIconColor("red").darker(150)
colNone = SHARED.theme.getBaseColor("default").darker(150)
colSaved = SHARED.theme.getBaseColor("green").darker(150)
colUnsaved = SHARED.theme.getBaseColor("red").darker(150)
self.docIcon.setColors(colNone, colSaved, colUnsaved)
self.projIcon.setColors(colNone, colSaved, colUnsaved)
return
##
# Setters
##
@@ -158,17 +153,14 @@ class GuiMainStatus(QStatusBar):
def setRefTime(self, refTime: float) -> None:
"""Set the reference time for the status bar clock."""
self._refTime = refTime
return
def setProjectStatus(self, state: bool | None) -> None:
"""Set the project status colour icon."""
self.projIcon.setState(state)
return
def setDocumentStatus(self, state: bool | None) -> None:
"""Set the document status colour icon."""
self.docIcon.setState(state)
return
def setUserIdle(self, idle: bool) -> None:
"""Change the idle status icon."""
@@ -180,13 +172,11 @@ class GuiMainStatus(QStatusBar):
else:
self.timeIcon.setPixmap(self.timePixmap)
self._userIdle = idle
return
def setProjectStats(self, pWC: int, sWC: int) -> None:
"""Update the current project statistics."""
self.statsText.setText(self._trStatsCount.format(f"{pWC:n}", f"{sWC:+n}"))
self.statsText.setToolTip(self._trStatsTip)
return
def updateTime(self, idleTime: float = 0.0) -> None:
"""Update the session clock."""
@@ -198,7 +188,6 @@ class GuiMainStatus(QStatusBar):
else:
sessTime = round(time() - self._refTime)
self.timeText.setText(formatTime(sessTime))
return
##
# Public Slots
@@ -209,7 +198,6 @@ class GuiMainStatus(QStatusBar):
"""Set the status bar message to display."""
self.showMessage(message, nwConst.STATUS_MSG_TIMEOUT)
QApplication.processEvents()
return
@pyqtSlot(str, str)
def setLanguage(self, language: str, provider: str) -> None:
@@ -220,19 +208,16 @@ class GuiMainStatus(QStatusBar):
else:
self.langText.setText(QLocale(language).nativeLanguageName().title())
self.langText.setToolTip(f"{language} ({provider})" if provider else language)
return
@pyqtSlot(bool)
def updateProjectStatus(self, status: bool) -> None:
"""Update the project status."""
self.setProjectStatus(not status)
return
@pyqtSlot(bool)
def updateDocumentStatus(self, status: bool) -> None:
"""Update the document status."""
self.setDocumentStatus(not status)
return
##
# Private Slots
@@ -244,7 +229,6 @@ class GuiMainStatus(QStatusBar):
state = not CONFIG.showSessionTime
self.timeText.setVisible(state)
CONFIG.showSessionTime = state
return
##
# Debug
@@ -279,4 +263,3 @@ class GuiMainStatus(QStatusBar):
)
self.showMessage(f"Debug [{stamp}] {message}", 6000)
logger.debug("[MEMINFO] %s", message)
return
+363 -344
View File
File diff suppressed because it is too large Load Diff