Rename package from 'nw' to 'novelwriter' (#868)

* Rename nw folder to novelwriter
* Rename nw to novelwriter in auxiliary files
* Rename nw to novelwriter in main app source
* Rename nw to novelwriter in tests
* Make setup script for pdf docs less spammy
This commit is contained in:
Veronica Berglyd Olsen
2021-08-26 17:33:54 +02:00
committed by GitHub
parent 2cf2eb9f55
commit 3403d98c72
396 changed files with 595 additions and 566 deletions
+47
View File
@@ -0,0 +1,47 @@
"""
novelWriter GUI Init
======================
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.gui.docviewer import GuiDocViewer, GuiDocViewDetails
from novelwriter.gui.itemdetails import GuiItemDetails
from novelwriter.gui.mainmenu import GuiMainMenu
from novelwriter.gui.noveltree import GuiNovelTree
from novelwriter.gui.outline import GuiOutline
from novelwriter.gui.outlinedetails import GuiOutlineDetails
from novelwriter.gui.projdetails import GuiProjectDetails
from novelwriter.gui.projtree import GuiProjectTree
from novelwriter.gui.statusbar import GuiMainStatus
from novelwriter.gui.theme import GuiTheme
__all__ = [
"GuiDocEditor",
"GuiDocViewDetails",
"GuiDocViewer",
"GuiItemDetails",
"GuiMainMenu",
"GuiMainStatus",
"GuiNovelTree",
"GuiOutline",
"GuiOutlineDetails",
"GuiProjectDetails",
"GuiProjectTree",
"GuiTheme",
]
+469
View File
@@ -0,0 +1,469 @@
"""
novelWriter Custom Widgets and Layouts
========================================
Various custom widget and layout classes
File History:
Created: 2020-05-03 [0.4.5] QConfigLayout
Created: 2020-05-03 [0.4.5] QSwitch
Created: 2020-05-17 [0.5.1] PagedDialog
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import logging
import novelwriter
from PyQt5.QtGui import QColor, QPalette, QPainter
from PyQt5.QtCore import (
Qt, QRect, QPoint, QRectF, QPropertyAnimation, pyqtProperty
)
from PyQt5.QtWidgets import (
QGridLayout, QLabel, QWidget, QVBoxLayout, QHBoxLayout, QSizePolicy,
QAbstractButton, QDialog, QTabWidget, QTabBar, QStyle, QStylePainter,
QStyleOptionTab, QLineEdit
)
from novelwriter.constants import nwUnicode
logger = logging.getLogger(__name__)
# =============================================================================================== #
# Config Form Layout
# =============================================================================================== #
class QConfigLayout(QGridLayout):
def __init__(self):
super().__init__()
self._nextRow = 0
self._helpCol = QColor(0, 0, 0)
self._fontScale = 0.9
self._itemMap = {}
wSp = novelwriter.CONFIG.pxInt(8)
self.setHorizontalSpacing(wSp)
self.setVerticalSpacing(wSp)
self.setColumnStretch(0, 1)
return
##
# Getters and Setters
##
def setHelpTextStyle(self, helpCol, fontScale=0.9):
"""Set the text color for the help text.
"""
if isinstance(helpCol, QColor):
self._helpCol = helpCol
else:
self._helpCol = QColor(*helpCol)
self._fontScale = fontScale
return
def setHelpText(self, intRow, theText):
"""Set the text for the help label.
"""
if intRow in self._itemMap:
self._itemMap[intRow]["help"].setText(theText)
return
def setLabelText(self, intRow, theText):
"""Set the text for the main label.
"""
if intRow in self._itemMap:
self._itemMap[intRow]["label"].setText(theText)
return
##
# Class Methods
##
def addGroupLabel(self, theLabel):
"""Adds a text label to separate groups of settings.
"""
if isinstance(theLabel, QLabel):
qLabel = theLabel
elif isinstance(theLabel, str):
qLabel = QLabel("<b>%s</b>" % theLabel)
else:
qLabel = None
raise ValueError("theLabel must be a QLabel")
hM = novelwriter.CONFIG.pxInt(4)
qLabel.setContentsMargins(0, hM, 0, hM)
self.addWidget(qLabel, self._nextRow, 0, 1, 2, Qt.AlignLeft)
self.setRowStretch(self._nextRow, 0)
self.setRowStretch(self._nextRow + 1, 1)
self._nextRow += 1
return
def addRow(self, theLabel, theWidget, helpText=None, theUnit=None, theButton=None):
"""Add a label and a widget as a new row of the grid.
"""
thisEntry = {
"label": None,
"help": None,
"widget": None,
}
if isinstance(theLabel, QLabel):
qLabel = theLabel
elif isinstance(theLabel, str):
qLabel = QLabel(theLabel)
else:
qLabel = None
raise ValueError("theLabel must be a QLabel")
if isinstance(theWidget, QWidget):
qWidget = theWidget
else:
qWidget = None
raise ValueError("theWidget must be a QWidget")
wSp = novelwriter.CONFIG.pxInt(8)
qLabel.setIndent(wSp)
if helpText is not None:
qHelp = QHelpLabel(str(helpText), self._helpCol, self._fontScale)
qHelp.setIndent(wSp)
labelBox = QVBoxLayout()
labelBox.addWidget(qLabel)
labelBox.addWidget(qHelp)
labelBox.setSpacing(0)
labelBox.addStretch(1)
thisEntry["help"] = qHelp
self.addLayout(labelBox, self._nextRow, 0, 1, 1, Qt.AlignLeft | Qt.AlignTop)
else:
self.addWidget(qLabel, self._nextRow, 0, 1, 1, Qt.AlignLeft | Qt.AlignTop)
if theUnit is not None:
controlBox = QHBoxLayout()
controlBox.addWidget(qWidget, 0, Qt.AlignVCenter)
controlBox.addWidget(QLabel(theUnit), 0, Qt.AlignVCenter)
controlBox.setSpacing(wSp)
self.addLayout(controlBox, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop)
elif theButton is not None:
controlBox = QHBoxLayout()
controlBox.addWidget(qWidget, 0, Qt.AlignVCenter)
controlBox.addWidget(theButton, 0, Qt.AlignVCenter)
controlBox.setSpacing(wSp)
self.addLayout(controlBox, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop)
else:
if isinstance(theWidget, QLineEdit):
qLayout = QHBoxLayout()
qLayout.addWidget(theWidget)
self.addLayout(qLayout, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop)
else:
self.addWidget(qWidget, self._nextRow, 1, 1, 1, Qt.AlignRight | Qt.AlignTop)
qLabel.setBuddy(qWidget)
self.setRowStretch(self._nextRow, 0)
self.setRowStretch(self._nextRow+1, 1)
thisEntry["label"] = qLabel
thisEntry["widget"] = qWidget
self._itemMap[self._nextRow] = thisEntry
self._nextRow += 1
return self._nextRow - 1
# END Class QConfigLayout
class QHelpLabel(QLabel):
def __init__(self, theText, textCol, fontSize=0.9):
QLabel.__init__(self, theText)
if isinstance(textCol, QColor):
qCol = textCol
else:
qCol = QColor(*textCol)
lblCol = self.palette()
lblCol.setColor(QPalette.WindowText, qCol)
self.setPalette(lblCol)
lblFont = self.font()
lblFont.setPointSizeF(fontSize*lblFont.pointSizeF())
self.setFont(lblFont)
self.setWordWrap(True)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
return
# END Class QHelpLabel
# =============================================================================================== #
# Switch Widget
# =============================================================================================== #
class QSwitch(QAbstractButton):
def __init__(self, parent=None, width=None, height=None):
super().__init__(parent=parent)
if width is None:
self._xW = novelwriter.CONFIG.pxInt(40)
else:
self._xW = width
if height is None:
self._xH = novelwriter.CONFIG.pxInt(20)
else:
self._xH = height
self._xR = int(self._xH*0.5)
self._xT = int(self._xH*0.6)
self._rB = int(novelwriter.CONFIG.guiScale*2)
self._rH = self._xH - 2*self._rB
self._rR = self._xR - self._rB
self.setCheckable(True)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.setFixedWidth(self._xW)
self.setFixedHeight(self._xH)
self._offset = self._xR
return
##
# Properties
##
@pyqtProperty(int)
def offset(self):
return self._offset
@offset.setter
def offset(self, theOffset):
self._offset = theOffset
self.update()
return
##
# Getters and Setters
##
def setChecked(self, isChecked):
"""Overload setChecked to also alter the offset.
"""
super().setChecked(isChecked)
if isChecked:
self.offset = self._xW - self._xR
else:
self.offset = self._xR
return
##
# Events
##
def resizeEvent(self, theEvent):
"""Overload resize to ensure correct offset.
"""
super().resizeEvent(theEvent)
if self.isChecked():
self.offset = self._xW - self._xR
else:
self.offset = self._xR
return
def paintEvent(self, event):
"""Drawing the switch itself.
"""
qPaint = QPainter(self)
qPaint.setRenderHint(QPainter.Antialiasing, True)
qPaint.setPen(Qt.NoPen)
qPalette = self.palette()
if self.isChecked():
trackBrush = qPalette.highlight()
thumbBrush = qPalette.highlightedText()
textColor = qPalette.highlight().color()
thumbText = nwUnicode.U_CHECK
else:
trackBrush = qPalette.dark()
thumbBrush = qPalette.light()
textColor = qPalette.dark().color()
thumbText = nwUnicode.U_CROSS
if self.isEnabled():
trackOpacity = 1.0
else:
trackOpacity = 0.6
trackBrush = qPalette.shadow()
thumbBrush = qPalette.mid()
textColor = qPalette.shadow().color()
qPaint.setBrush(trackBrush)
qPaint.setOpacity(trackOpacity)
qPaint.drawRoundedRect(0, 0, self._xW, self._xH, self._xR, self._xR)
qPaint.setBrush(thumbBrush)
qPaint.drawEllipse(self.offset - self._rR, self._rB, self._rH, self._rH)
theFont = qPaint.font()
theFont.setPixelSize(self._xT)
qPaint.setPen(textColor)
qPaint.setFont(theFont)
qPaint.drawText(
QRectF(self.offset - self._rR, self._rB, self._rH, self._rH),
Qt.AlignCenter, thumbText
)
return
def mouseReleaseEvent(self, event):
"""Animate the switch on mouse release.
"""
super().mouseReleaseEvent(event)
if event.button() == Qt.LeftButton:
doAnim = QPropertyAnimation(self, b"offset", self)
doAnim.setDuration(120)
doAnim.setStartValue(self.offset)
if self.isChecked():
doAnim.setEndValue(self._xW - self._xR)
else:
doAnim.setEndValue(self._xR)
doAnim.start()
return
def enterEvent(self, event):
"""Change the cursor when hovering the button.
"""
self.setCursor(Qt.PointingHandCursor)
super().enterEvent(event)
return
# END Class QSwitch
# =============================================================================================== #
# Paged Dialog w/Custom TabWidget
# =============================================================================================== #
class PagedDialog(QDialog):
def __init__(self, theParent=None):
QDialog.__init__(self, parent=theParent)
self._tabBar = VerticalTabBar(self)
self._tabBar.setExpanding(False)
self._tabBox = QTabWidget()
self._tabBox.setTabBar(self._tabBar)
self._tabBox.setTabPosition(QTabWidget.West)
self._buttonBox = QHBoxLayout()
self._outerBox = QVBoxLayout()
self._outerBox.addWidget(self._tabBox)
self._outerBox.addLayout(self._buttonBox)
# Default Margins
thisStyle = self.style()
mL = thisStyle.pixelMetric(QStyle.PM_LayoutLeftMargin)
mR = thisStyle.pixelMetric(QStyle.PM_LayoutRightMargin)
mT = thisStyle.pixelMetric(QStyle.PM_LayoutLeftMargin)
mB = thisStyle.pixelMetric(QStyle.PM_LayoutBottomMargin)
# Set Margins
self.setContentsMargins(0, 0, 0, 0)
self._outerBox.setContentsMargins(0, 0, 0, mB)
self._buttonBox.setContentsMargins(mL, 0, mR, 0)
self._outerBox.setSpacing(mT)
self.setLayout(self._outerBox)
return
def addTab(self, tabWidget, tabLabel):
"""Forwards the adding of tabs to the QTabWidget.
"""
self._tabBox.addTab(tabWidget, tabLabel)
return
def addControls(self, buttonBar):
"""Adds a button bar to the dialog.
"""
self._buttonBox.addWidget(buttonBar)
return
# END Class PagedDialog
class VerticalTabBar(QTabBar):
def __init__(self, theParent=None):
QTabBar.__init__(self, parent=theParent)
self._mW = novelwriter.CONFIG.pxInt(150)
return
def tabSizeHint(self, theIndex):
"""Returns a transposed size hint for the rotated bar.
"""
tSize = QTabBar.tabSizeHint(self, theIndex)
tSize.transpose()
tSize.setWidth(min(tSize.width(), self._mW))
return tSize
def paintEvent(self, theEvent):
"""Custom implementation of the label painter that rotates the
label 90 degrees.
"""
pObj = QStylePainter(self)
oObj = QStyleOptionTab()
for i in range(self.count()):
self.initStyleOption(oObj, i)
pObj.drawControl(QStyle.CE_TabBarTabShape, oObj)
pObj.save()
oSize = oObj.rect.size()
oSize.transpose()
oRect = QRect(QPoint(), oSize)
oRect.moveCenter(oObj.rect.center())
oObj.rect = oRect
oCenter = self.tabRect(i).center()
pObj.translate(oCenter)
pObj.rotate(90)
pObj.translate(-oCenter)
pObj.drawControl(QStyle.CE_TabBarTabLabel, oObj)
pObj.restore()
return
# END Class VerticalTabBar
File diff suppressed because it is too large Load Diff
+444
View File
@@ -0,0 +1,444 @@
"""
novelWriter GUI Syntax Highlighter
====================================
Class for the main document editor syntax highlighter
File History:
Created: 2019-04-06 [0.0.1]
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import logging
import novelwriter
from time import time
from PyQt5.QtCore import Qt, QRegularExpression
from PyQt5.QtGui import (
QColor, QTextCharFormat, QFont, QSyntaxHighlighter, QBrush
)
from novelwriter.constants import nwRegEx, nwUnicode
from novelwriter.common import checkInt
logger = logging.getLogger(__name__)
class GuiDocHighlighter(QSyntaxHighlighter):
BLOCK_NONE = 0
BLOCK_TEXT = 1
BLOCK_META = 2
BLOCK_TITLE = 4
def __init__(self, theDoc, theParent):
QSyntaxHighlighter.__init__(self, theDoc)
logger.debug("Initialising GuiDocHighlighter ...")
self.mainConf = novelwriter.CONFIG
self.theDoc = theDoc
self.theParent = theParent
self.theTheme = theParent.theTheme
self.theIndex = theParent.theIndex
self.theDict = None
self.theHandle = None
self.spellCheck = False
self.spellRx = None
self.hRules = []
self.hStyles = {}
self.colHead = QColor(0, 0, 0)
self.colHeadH = QColor(0, 0, 0)
self.colEmph = QColor(0, 0, 0)
self.colDialN = QColor(0, 0, 0)
self.colDialD = QColor(0, 0, 0)
self.colDialS = QColor(0, 0, 0)
self.colHidden = QColor(0, 0, 0)
self.colKey = QColor(0, 0, 0)
self.colVal = QColor(0, 0, 0)
self.colSpell = QColor(0, 0, 0)
self.colError = QColor(0, 0, 0)
self.colRepTag = QColor(0, 0, 0)
self.initHighlighter()
logger.debug("GuiDocHighlighter initialisation complete")
return
def initHighlighter(self):
"""Initialise the syntax highlighter, setting all the colour
rules and building the regexes.
"""
logger.debug("Setting up highlighting rules")
self.colHead = QColor(*self.theTheme.colHead)
self.colHeadH = QColor(*self.theTheme.colHeadH)
self.colDialN = QColor(*self.theTheme.colDialN)
self.colDialD = QColor(*self.theTheme.colDialD)
self.colDialS = QColor(*self.theTheme.colDialS)
self.colHidden = QColor(*self.theTheme.colHidden)
self.colKey = QColor(*self.theTheme.colKey)
self.colVal = QColor(*self.theTheme.colVal)
self.colSpell = QColor(*self.theTheme.colSpell)
self.colError = QColor(*self.theTheme.colError)
self.colRepTag = QColor(*self.theTheme.colRepTag)
self.colMod = QColor(*self.theTheme.colMod)
self.colBreak = QColor(*self.theTheme.colEmph)
self.colBreak.setAlpha(64)
self.colEmph = None
if self.mainConf.highlightEmph:
self.colEmph = QColor(*self.theTheme.colEmph)
self.hStyles = {
"header1": self._makeFormat(self.colHead, "bold", 1.8),
"header2": self._makeFormat(self.colHead, "bold", 1.6),
"header3": self._makeFormat(self.colHead, "bold", 1.4),
"header4": self._makeFormat(self.colHead, "bold", 1.2),
"header1h": self._makeFormat(self.colHeadH, "bold", 1.8),
"header2h": self._makeFormat(self.colHeadH, "bold", 1.6),
"header3h": self._makeFormat(self.colHeadH, "bold", 1.4),
"header4h": self._makeFormat(self.colHeadH, "bold", 1.2),
"bold": self._makeFormat(self.colEmph, "bold"),
"italic": self._makeFormat(self.colEmph, "italic"),
"strike": self._makeFormat(self.colHidden, "strike"),
"mspaces": self._makeFormat(self.colError, "errline"),
"nobreak": self._makeFormat(self.colBreak, "background"),
"dialogue1": self._makeFormat(self.colDialN),
"dialogue2": self._makeFormat(self.colDialD),
"dialogue3": self._makeFormat(self.colDialS),
"replace": self._makeFormat(self.colRepTag),
"hidden": self._makeFormat(self.colHidden),
"keyword": self._makeFormat(self.colKey),
"modifier": self._makeFormat(self.colMod),
"value": self._makeFormat(self.colVal, "underline"),
"codevalue": self._makeFormat(self.colVal),
"codeinval": self._makeFormat(None, "errline"),
}
self.hRules = []
# Multiple or Trailing Spaces
if self.mainConf.showMultiSpaces:
self.hRules.append((
r"[ ]{2,}|[ ]*$", {
0: self.hStyles["mspaces"],
}
))
# Non-Breaking Spaces
self.hRules.append((
"[%s%s]+" % (nwUnicode.U_NBSP, nwUnicode.U_THNBSP), {
0: self.hStyles["nobreak"],
}
))
# Quoted Strings
if self.mainConf.highlightQuotes:
fmtDbl = self.mainConf.fmtDoubleQuotes
fmtSng = self.mainConf.fmtSingleQuotes
# Straight Quotes
if fmtDbl != ["\"", "\""]:
self.hRules.append((
"(\\B\")(.*?)(\"\\B)", {
0: self.hStyles["dialogue1"],
}
))
# Double Quotes
dblEnd = "|$" if self.mainConf.allowOpenDQuote else ""
self.hRules.append((
f"(\\B{fmtDbl[0]})(.*?)({fmtDbl[1]}\\B{dblEnd})", {
0: self.hStyles["dialogue2"],
}
))
# Single Quotes
sngEnd = "|$" if self.mainConf.allowOpenSQuote else ""
self.hRules.append((
f"(\\B{fmtSng[0]})(.*?)({fmtSng[1]}\\B{sngEnd})", {
0: self.hStyles["dialogue3"],
}
))
# Markdown Syntax
self.hRules.append((
nwRegEx.FMT_EI, {
1: self.hStyles["hidden"],
2: self.hStyles["italic"],
3: self.hStyles["hidden"],
}
))
self.hRules.append((
nwRegEx.FMT_EB, {
1: self.hStyles["hidden"],
2: self.hStyles["bold"],
3: self.hStyles["hidden"],
}
))
self.hRules.append((
nwRegEx.FMT_ST, {
1: self.hStyles["hidden"],
2: self.hStyles["strike"],
3: self.hStyles["hidden"],
}
))
# Alignment Tags
self.hRules.append((
r"(^>{1,2}|<{1,2}$)", {
1: self.hStyles["hidden"],
}
))
# Auto-Replace Tags
self.hRules.append((
r"<(\S+?)>", {
0: self.hStyles["replace"],
}
))
# Build a QRegExp for each highlight pattern
self.rxRules = []
for regEx, regRules in self.hRules:
hReg = QRegularExpression(regEx)
hReg.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
self.rxRules.append((hReg, regRules))
# Build a QRegExp for spell checker
# Include additional characters that the highlighter should
# consider to be word separators
wordSep = r"\-_\+/"
wordSep += nwUnicode.U_ENDASH
wordSep += nwUnicode.U_EMDASH
self.spellRx = QRegularExpression(r"\b[^\s"+wordSep+r"]+\b")
self.spellRx.setPatternOptions(QRegularExpression.UseUnicodePropertiesOption)
return True
##
# Setters
##
def setDict(self, theDict):
"""Set the dictionary object for spell check underlines lookup.
"""
self.theDict = theDict
return True
def setSpellCheck(self, theMode):
"""Enable/disable the real time spell checker.
"""
self.spellCheck = theMode
return True
def setHandle(self, theHandle):
"""Set the handle of the currently highlighted document. This is
needed for the index lookup for validating tags and references.
"""
self.theHandle = theHandle
return True
##
# Methods
##
def rehighlightByType(self, theType):
"""Loop through all blocks and rehighlight those of a given
content type.
"""
qDoc = self.document()
nBlocks = qDoc.blockCount()
bfTime = time()
for i in range(nBlocks):
theBlock = qDoc.findBlockByNumber(i)
if theBlock.userState() & theType > 0:
self.rehighlightBlock(theBlock)
afTime = time()
logger.debug(
"Document highlighted in %.3f ms" % (1000*(afTime-bfTime))
)
return
##
# Highlight Block
##
def highlightBlock(self, theText):
"""Highlight a single block. Prefer to check first character for
all formats that are defined by their initial characters. This
is significantly faster than running the regex checks used for
text paragraphs.
"""
self.setCurrentBlockState(self.BLOCK_NONE)
if self.theHandle is None or not theText:
return
if theText.startswith("@"): # Keywords and commands
self.setCurrentBlockState(self.BLOCK_META)
tItem = self.theParent.theProject.projTree[self.theHandle]
isValid, theBits, thePos = self.theIndex.scanThis(theText)
isGood = self.theIndex.checkThese(theBits, tItem)
if isValid:
for n, theBit in enumerate(theBits):
xPos = thePos[n]
xLen = len(theBit)
if isGood[n]:
if n == 0:
self.setFormat(xPos, xLen, self.hStyles["keyword"])
else:
self.setFormat(xPos, xLen, self.hStyles["value"])
else:
kwFmt = self.format(xPos)
kwFmt.setUnderlineColor(self.colError)
kwFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
self.setFormat(xPos, xLen, kwFmt)
# We never want to run the spell checker on keyword/values,
# so we force a return here
return
elif theText.startswith(("# ", "#! ", "## ", "##! ", "### ", "#### ")):
self.setCurrentBlockState(self.BLOCK_TITLE)
if theText.startswith("# "): # Header 1
self.setFormat(0, 1, self.hStyles["header1h"])
self.setFormat(1, len(theText), self.hStyles["header1"])
elif theText.startswith("## "): # Header 2
self.setFormat(0, 2, self.hStyles["header2h"])
self.setFormat(2, len(theText), self.hStyles["header2"])
elif theText.startswith("### "): # Header 3
self.setFormat(0, 3, self.hStyles["header3h"])
self.setFormat(3, len(theText), self.hStyles["header3"])
elif theText.startswith("#### "): # Header 4
self.setFormat(0, 4, self.hStyles["header4h"])
self.setFormat(4, len(theText), self.hStyles["header4"])
if theText.startswith("#! "): # Title
self.setFormat(0, 2, self.hStyles["header1h"])
self.setFormat(2, len(theText), self.hStyles["header1"])
elif theText.startswith("##! "): # Unnumbered
self.setFormat(0, 3, self.hStyles["header2h"])
self.setFormat(3, len(theText), self.hStyles["header2"])
elif theText.startswith("%"): # Comments
self.setCurrentBlockState(self.BLOCK_TEXT)
toCheck = theText[1:].lstrip()
synTag = toCheck[:9].lower()
tLen = len(theText)
cLen = len(toCheck)
cOff = tLen - cLen
if synTag == "synopsis:":
self.setFormat(0, cOff+9, self.hStyles["modifier"])
self.setFormat(cOff+9, tLen, self.hStyles["hidden"])
else:
self.setFormat(0, tLen, self.hStyles["hidden"])
else: # Text Paragraph
if theText.startswith("["): # Special Command
sText = theText.rstrip()
if sText in ("[NEWPAGE]", "[NEW PAGE]", "[VSPACE]"):
self.setFormat(0, len(theText), self.hStyles["keyword"])
return
elif sText.startswith("[VSPACE:") and sText.endswith("]"):
tLen = len(sText)
tVal = checkInt(sText[8:-1], 0)
self.setFormat(0, 8, self.hStyles["keyword"])
if tVal > 0:
self.setFormat(8, tLen-9, self.hStyles["codevalue"])
else:
self.setFormat(8, tLen-9, self.hStyles["codeinval"])
self.setFormat(tLen-1, tLen, self.hStyles["keyword"])
return
# Regular text
self.setCurrentBlockState(self.BLOCK_TEXT)
for rX, xFmt in self.rxRules:
rxItt = rX.globalMatch(theText, 0)
while rxItt.hasNext():
rxMatch = rxItt.next()
for xM in xFmt:
xPos = rxMatch.capturedStart(xM)
xLen = rxMatch.capturedLength(xM)
for x in range(xPos, xPos+xLen):
spFmt = self.format(x)
if spFmt != self.hStyles["hidden"]:
spFmt.merge(xFmt[xM])
self.setFormat(x, 1, spFmt)
if self.theDict is None or not self.spellCheck:
return
rxSpell = self.spellRx.globalMatch(theText, 0)
while rxSpell.hasNext():
rxMatch = rxSpell.next()
if not self.theDict.checkWord(rxMatch.captured(0)):
if rxMatch.captured(0).isupper() or rxMatch.captured(0).isnumeric():
continue
xPos = rxMatch.capturedStart(0)
xLen = rxMatch.capturedLength(0)
for x in range(xPos, xPos+xLen):
spFmt = self.format(x)
spFmt.setUnderlineColor(self.colSpell)
spFmt.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
self.setFormat(x, 1, spFmt)
return
##
# Internal Functions
##
def _makeFormat(self, fmtCol=None, fmtStyle=None, fmtSize=None):
"""Generate a valid character format to be applied to the text
that is to be highlighted.
"""
theFormat = QTextCharFormat()
if fmtCol is not None:
theFormat.setForeground(fmtCol)
if fmtStyle is not None:
if "bold" in fmtStyle:
theFormat.setFontWeight(QFont.Bold)
if "italic" in fmtStyle:
theFormat.setFontItalic(True)
if "strike" in fmtStyle:
theFormat.setFontStrikeOut(True)
if "errline" in fmtStyle:
theFormat.setUnderlineColor(self.colError)
theFormat.setUnderlineStyle(QTextCharFormat.SpellCheckUnderline)
if "underline" in fmtStyle:
theFormat.setFontUnderline(True)
if "background" in fmtStyle:
theFormat.setBackground(QBrush(fmtCol, Qt.SolidPattern))
if fmtSize is not None:
theFormat.setFontPointSize(int(round(fmtSize*self.mainConf.textSize)))
return theFormat
# END Class DocHighlighter
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
"""
novelWriter GUI Item Details Panel
====================================
GUI class for the project tree item details panel
File History:
Created: 2019-04-24 [0.0.1]
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import logging
import novelwriter
from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtGui import QFont, QPixmap
from PyQt5.QtWidgets import QWidget, QGridLayout, QLabel
from novelwriter.enum import nwItemClass, nwItemType
from novelwriter.constants import trConst, nwLabels
logger = logging.getLogger(__name__)
class GuiItemDetails(QWidget):
def __init__(self, theParent):
QWidget.__init__(self, theParent)
logger.debug("Initialising GuiItemDetails ...")
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theProject = theParent.theProject
self.theTheme = theParent.theTheme
# Internal Variables
self._itemHandle = None
# Sizes
hSp = self.mainConf.pxInt(6)
vSp = self.mainConf.pxInt(1)
mPx = self.mainConf.pxInt(6)
iPx = self.theTheme.baseIconSize
fPt = self.theTheme.fontPointSize
self._expCheck = self.theTheme.getPixmap("check", (iPx, iPx))
self._expCross = self.theTheme.getPixmap("cross", (iPx, iPx))
fntLabel = QFont()
fntLabel.setBold(True)
fntLabel.setPointSizeF(0.9*fPt)
fntValue = QFont()
fntValue.setPointSizeF(0.9*fPt)
# Label
self.labelName = QLabel(self.tr("Label"))
self.labelName.setFont(fntLabel)
self.labelName.setAlignment(Qt.AlignLeft | Qt.AlignBaseline)
self.labelIcon = QLabel("")
self.labelIcon.setAlignment(Qt.AlignRight | Qt.AlignBaseline)
self.labelData = QLabel("")
self.labelData.setFont(fntValue)
self.labelData.setAlignment(Qt.AlignLeft | Qt.AlignBaseline)
self.labelData.setWordWrap(True)
# Status
self.statusName = QLabel(self.tr("Status"))
self.statusName.setFont(fntLabel)
self.statusName.setAlignment(Qt.AlignLeft)
self.statusIcon = QLabel("")
self.statusIcon.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.statusData = QLabel("")
self.statusData.setFont(fntValue)
self.statusData.setAlignment(Qt.AlignLeft)
# Class
self.className = QLabel(self.tr("Class"))
self.className.setFont(fntLabel)
self.className.setAlignment(Qt.AlignLeft)
self.classIcon = QLabel("")
self.classIcon.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.classData = QLabel("")
self.classData.setFont(fntValue)
self.classData.setAlignment(Qt.AlignLeft)
# Layout
self.usageName = QLabel(self.tr("Usage"))
self.usageName.setFont(fntLabel)
self.usageName.setAlignment(Qt.AlignLeft)
self.usageIcon = QLabel("")
self.usageIcon.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.usageData = QLabel("")
self.usageData.setFont(fntValue)
self.usageData.setAlignment(Qt.AlignLeft)
# Character Count
self.cCountName = QLabel(" "+self.tr("Characters"))
self.cCountName.setFont(fntLabel)
self.cCountName.setAlignment(Qt.AlignRight)
self.cCountData = QLabel("")
self.cCountData.setFont(fntValue)
self.cCountData.setAlignment(Qt.AlignRight)
# Word Count
self.wCountName = QLabel(" "+self.tr("Words"))
self.wCountName.setFont(fntLabel)
self.wCountName.setAlignment(Qt.AlignRight)
self.wCountData = QLabel("")
self.wCountData.setFont(fntValue)
self.wCountData.setAlignment(Qt.AlignRight)
# Paragraph Count
self.pCountName = QLabel(" "+self.tr("Paragraphs"))
self.pCountName.setFont(fntLabel)
self.pCountName.setAlignment(Qt.AlignRight)
self.pCountData = QLabel("")
self.pCountData.setFont(fntValue)
self.pCountData.setAlignment(Qt.AlignRight)
# Assemble
self.mainBox = QGridLayout(self)
self.mainBox.addWidget(self.labelName, 0, 0, 1, 1)
self.mainBox.addWidget(self.labelIcon, 0, 1, 1, 1)
self.mainBox.addWidget(self.labelData, 0, 2, 1, 3)
self.mainBox.addWidget(self.statusName, 1, 0, 1, 1)
self.mainBox.addWidget(self.statusIcon, 1, 1, 1, 1)
self.mainBox.addWidget(self.statusData, 1, 2, 1, 1)
self.mainBox.addWidget(self.cCountName, 1, 3, 1, 1)
self.mainBox.addWidget(self.cCountData, 1, 4, 1, 1)
self.mainBox.addWidget(self.className, 2, 0, 1, 1)
self.mainBox.addWidget(self.classIcon, 2, 1, 1, 1)
self.mainBox.addWidget(self.classData, 2, 2, 1, 1)
self.mainBox.addWidget(self.wCountName, 2, 3, 1, 1)
self.mainBox.addWidget(self.wCountData, 2, 4, 1, 1)
self.mainBox.addWidget(self.usageName, 3, 0, 1, 1)
self.mainBox.addWidget(self.usageIcon, 3, 1, 1, 1)
self.mainBox.addWidget(self.usageData, 3, 2, 1, 1)
self.mainBox.addWidget(self.pCountName, 3, 3, 1, 1)
self.mainBox.addWidget(self.pCountData, 3, 4, 1, 1)
self.mainBox.setColumnStretch(0, 0)
self.mainBox.setColumnStretch(1, 0)
self.mainBox.setColumnStretch(2, 1)
self.mainBox.setColumnStretch(3, 0)
self.mainBox.setColumnStretch(4, 0)
self.mainBox.setHorizontalSpacing(hSp)
self.mainBox.setVerticalSpacing(vSp)
self.mainBox.setContentsMargins(mPx, mPx, mPx, mPx)
self.setLayout(self.mainBox)
# Make sure the columns for flags and counts don't resize too often
flagWidth = self.theTheme.getTextWidth("Mm", fntValue)
countWidth = self.theTheme.getTextWidth("99,999", fntValue)
self.mainBox.setColumnMinimumWidth(1, flagWidth)
self.mainBox.setColumnMinimumWidth(4, countWidth)
logger.debug("GuiItemDetails initialisation complete")
return
###
# Class Methods
##
def clearDetails(self):
"""Clear all the data values.
"""
self._itemHandle = None
self.labelIcon.setPixmap(QPixmap(1, 1))
self.statusIcon.setPixmap(QPixmap(1, 1))
self.classIcon.setText("")
self.usageIcon.setText("")
self.labelData.setText("")
self.statusData.setText("")
self.classData.setText("")
self.usageData.setText("")
self.cCountData.setText("")
self.wCountData.setText("")
self.pCountData.setText("")
return
def updateViewBox(self, tHandle):
"""Populate the details box from a given handle.
"""
if tHandle is None:
self.clearDetails()
return
nwItem = self.theProject.projTree[tHandle]
if nwItem is None:
self.clearDetails()
return
self._itemHandle = tHandle
iPx = int(round(0.8*self.theTheme.baseIconSize))
# Label
# =====
theLabel = nwItem.itemName
if len(theLabel) > 100:
theLabel = theLabel[:96].rstrip()+" ..."
if nwItem.itemType == nwItemType.FILE:
if nwItem.isExported:
self.labelIcon.setPixmap(self._expCheck)
else:
self.labelIcon.setPixmap(self._expCross)
else:
self.labelIcon.setPixmap(QPixmap(1, 1))
self.labelData.setText(theLabel)
# Status
# ======
itStatus = nwItem.itemStatus
if nwItem.itemClass == nwItemClass.NOVEL:
itStatus = self.theProject.statusItems.checkEntry(itStatus) # Make sure it's valid
flagIcon = self.theParent.statusIcons[itStatus]
else:
itStatus = self.theProject.importItems.checkEntry(itStatus) # Make sure it's valid
flagIcon = self.theParent.importIcons[itStatus]
self.statusIcon.setPixmap(flagIcon.pixmap(iPx, iPx))
self.statusData.setText(nwItem.itemStatus)
# Class
# =====
classIcon = self.theTheme.getIcon(nwLabels.CLASS_ICON[nwItem.itemClass])
self.classIcon.setPixmap(classIcon.pixmap(iPx, iPx))
self.classData.setText(trConst(nwLabels.CLASS_NAME[nwItem.itemClass]))
# Layout
# ======
hLevel = self.theParent.theIndex.getHandleHeaderLevel(tHandle)
usageIcon = self.theTheme.getItemIcon(
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
)
self.usageIcon.setPixmap(usageIcon.pixmap(iPx, iPx))
self.usageData.setText(nwItem.describeMe(hLevel))
# Counts
# ======
if nwItem.itemType == nwItemType.FILE:
self.cCountData.setText(f"{nwItem.charCount:n}")
self.wCountData.setText(f"{nwItem.wordCount:n}")
self.pCountData.setText(f"{nwItem.paraCount:n}")
else:
self.cCountData.setText("")
self.wCountData.setText("")
self.pCountData.setText("")
return
##
# Slots
##
@pyqtSlot(str, int, int, int)
def doUpdateCounts(self, tHandle, cC, wC, pC):
"""Update the counts if the handle is the same as the one we're
already showing. Otherwise, do nothing.
"""
if tHandle == self._itemHandle:
self.cCountData.setText(f"{cC:n}")
self.wCountData.setText(f"{wC:n}")
self.pCountData.setText(f"{pC:n}")
return
# END Class GuiItemDetails
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
"""
novelWriter GUI Novel Tree
============================
GUI classe for the main window novel tree
File History:
Created: 2020-12-20 [1.1a0]
This file is a part of novelWriter
Copyright 20182020, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import logging
import novelwriter
from time import time
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem, QAbstractItemView
from novelwriter.common import checkInt
from novelwriter.constants import nwKeyWords
logger = logging.getLogger(__name__)
class GuiNovelTree(QTreeWidget):
C_TITLE = 0
C_WORDS = 1
C_POV = 2
def __init__(self, theParent):
QTreeWidget.__init__(self, theParent)
logger.debug("Initialising GuiNovelTree ...")
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theTheme = theParent.theTheme
self.theProject = theParent.theProject
self.theIndex = theParent.theIndex
# Internal Variables
self._treeMap = {}
self._lastBuild = 0
# Build GUI
iPx = self.theTheme.baseIconSize
self.setIconSize(QSize(iPx, iPx))
self.setIndentation(iPx)
self.setColumnCount(3)
self.setHeaderLabels([
self.tr("Novel Outline"),
self.tr("Words"),
self.tr("POV")
])
self.itemDoubleClicked.connect(self._treeDoubleClick)
self.itemSelectionChanged.connect(self._itemSelected)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSelectionMode(QAbstractItemView.SingleSelection)
self.setExpandsOnDoubleClick(False)
self.setDragEnabled(False)
treeHeadItem = self.headerItem()
treeHeadItem.setTextAlignment(self.C_WORDS, Qt.AlignRight)
treeHeadItem.setToolTip(self.C_TITLE, self.tr("Section title"))
treeHeadItem.setToolTip(self.C_WORDS, self.tr("Word count"))
treeHeadItem.setToolTip(self.C_POV, self.tr("Point-of-view character"))
treeHeader = self.header()
treeHeader.setStretchLastSection(True)
treeHeader.setMinimumSectionSize(iPx + 6)
# Get user's column width preferences for NAME and COUNT
treeColWidth = self.mainConf.getNovelColWidths()
if len(treeColWidth) <= 3:
for colN, colW in enumerate(treeColWidth):
self.setColumnWidth(colN, colW)
# The last column should just auto-scale
self.resizeColumnToContents(self.C_POV)
# Set custom settings
self.initTree()
logger.debug("GuiNovelTree initialisation complete")
return
def initTree(self):
"""Set or update tree widget settings.
"""
# Scroll bars
if self.mainConf.hideVScroll:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
if self.mainConf.hideHScroll:
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
return
##
# Class Methods
##
def clearTree(self):
"""Clear the GUI content and the related maps.
"""
self.clear()
self._treeMap = {}
self._lastBuild = 0
return
def refreshTree(self, overRide=False):
"""Called whenever the Novel tab is activated.
"""
logger.verbose("Requesting refresh of the novel tree")
treeChanged = self.theParent.treeView.changedSince(self._lastBuild)
indexChanged = self.theIndex.novelChangedSince(self._lastBuild)
if not (treeChanged or indexChanged):
logger.verbose("No changes have been made to the novel index")
return
selItem = self.selectedItems()
titleKey = None
if selItem:
titleKey = selItem[0].data(self.C_TITLE, Qt.UserRole)[2]
self.theParent.treeView.flushTreeOrder()
self._populateTree()
if titleKey is not None and titleKey in self._treeMap:
self._treeMap[titleKey].setSelected(True)
return
def updateWordCounts(self, tHandle):
"""Update the word count for a given handle.
"""
tHeaders = self.theIndex.getHandleWordCounts(tHandle)
for titleKey, wCount in tHeaders:
if titleKey in self._treeMap:
self._treeMap[titleKey].setText(self.C_WORDS, f"{wCount:n}")
return
def getColumnSizes(self):
"""Return the column widths for the tree columns.
"""
retVals = [
self.columnWidth(0),
self.columnWidth(1),
]
return retVals
def getSelectedHandle(self):
"""Get the currently selected handle. If multiple items are
selected, return the first.
"""
selItem = self.selectedItems()
if selItem:
return selItem[0].data(self.C_TITLE, Qt.UserRole)[0]
return None
##
# Events
##
def mousePressEvent(self, theEvent):
"""Overload mousePressEvent to clear selection if clicking the
mouse in a blank area of the tree view, and to load a document
for viewing if the user middle-clicked.
"""
QTreeWidget.mousePressEvent(self, theEvent)
if theEvent.button() == Qt.LeftButton:
selItem = self.indexAt(theEvent.pos())
if not selItem.isValid():
self.clearSelection()
elif theEvent.button() == Qt.MiddleButton:
selItem = self.itemAt(theEvent.pos())
if not isinstance(selItem, QTreeWidgetItem):
return
tHandle = self.getSelectedHandle()
if tHandle is None:
return
self.theParent.viewDocument(tHandle)
return
##
# Slots
##
def _treeDoubleClick(self, tItem, tCol):
"""Extract the handle and line number of the title double-
clicked, and send it to the main gui class for opening in the
document editor.
"""
theData = tItem.data(self.C_TITLE, Qt.UserRole)
tHandle = theData[0]
tLine = checkInt(theData[1], 1)
logger.verbose("User selected entry with handle '%s' on line %s", tHandle, tLine)
self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True)
return
def _itemSelected(self):
"""Extract the handle and line number of the currently selected
title, and send it to the tree meta panel.
"""
selItems = self.selectedItems()
if selItems:
tHandle = selItems[0].data(self.C_TITLE, Qt.UserRole)[0]
self.theParent.treeMeta.updateViewBox(tHandle)
return
##
# Internal Functions
##
def _populateTree(self):
"""Build the tree based on the project index.
"""
self.clearTree()
currTitle = None
currChapter = None
currScene = None
for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(skipExcluded=True):
tItem = self._createTreeItem(tHandle, sTitle, tKey, novIdx)
self._treeMap[tKey] = tItem
tLevel = novIdx["level"]
if tLevel == "H1":
self.addTopLevelItem(tItem)
currTitle = tItem
currChapter = None
currScene = None
elif tLevel == "H2":
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
currChapter = tItem
currScene = None
elif tLevel == "H3":
if currChapter is None:
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
else:
currChapter.addChild(tItem)
currScene = tItem
elif tLevel == "H4":
if currScene is None:
if currChapter is None:
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
else:
currChapter.addChild(tItem)
else:
currScene.addChild(tItem)
tItem.setExpanded(True)
self._lastBuild = time()
return
def _createTreeItem(self, tHandle, sTitle, titleKey, novIdx):
"""Populate a tree item with all the column values.
"""
newItem = QTreeWidgetItem()
hIcon = "doc_%s" % novIdx["level"].lower()
theData = (tHandle, sTitle[1:].lstrip("0"), titleKey)
wC = int(novIdx["wCount"])
newItem.setText(self.C_TITLE, novIdx["title"])
newItem.setData(self.C_TITLE, Qt.UserRole, theData)
newItem.setIcon(self.C_TITLE, self.theTheme.getIcon(hIcon))
newItem.setText(self.C_WORDS, f"{wC:n}")
newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight)
theRefs = self.theIndex.getReferences(tHandle, sTitle)
newItem.setText(self.C_POV, ", ".join(theRefs[nwKeyWords.POV_KEY]))
return newItem
# END Class GuiNovelTree
+528
View File
@@ -0,0 +1,528 @@
"""
novelWriter GUI Project Outline
=================================
GUI class for the project outline view
File History:
Created: 2019-11-16 [0.4.1]
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import logging
import novelwriter
from time import time
from PyQt5.QtCore import Qt, QSize, pyqtSlot
from PyQt5.QtWidgets import (
QTreeWidget, QTreeWidgetItem, QMenu, QAction, QAbstractItemView
)
from novelwriter.enum import nwItemLayout, nwItemType, nwOutline
from novelwriter.constants import trConst, nwKeyWords, nwLabels
logger = logging.getLogger(__name__)
class GuiOutline(QTreeWidget):
DEF_WIDTH = {
nwOutline.TITLE: 200,
nwOutline.LEVEL: 40,
nwOutline.LABEL: 150,
nwOutline.LINE: 40,
nwOutline.CCOUNT: 50,
nwOutline.WCOUNT: 50,
nwOutline.PCOUNT: 50,
nwOutline.POV: 100,
nwOutline.FOCUS: 100,
nwOutline.CHAR: 100,
nwOutline.PLOT: 100,
nwOutline.TIME: 100,
nwOutline.WORLD: 100,
nwOutline.OBJECT: 100,
nwOutline.ENTITY: 100,
nwOutline.CUSTOM: 100,
nwOutline.SYNOP: 200,
}
DEF_HIDDEN = {
nwOutline.TITLE: False,
nwOutline.LEVEL: True,
nwOutline.LABEL: False,
nwOutline.LINE: True,
nwOutline.CCOUNT: True,
nwOutline.WCOUNT: False,
nwOutline.PCOUNT: False,
nwOutline.POV: False,
nwOutline.FOCUS: True,
nwOutline.CHAR: False,
nwOutline.PLOT: False,
nwOutline.TIME: True,
nwOutline.WORLD: False,
nwOutline.OBJECT: True,
nwOutline.ENTITY: True,
nwOutline.CUSTOM: True,
nwOutline.SYNOP: False,
}
def __init__(self, theParent):
QTreeWidget.__init__(self, theParent)
logger.debug("Initialising GuiOutline ...")
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theProject = theParent.theProject
self.theTheme = theParent.theTheme
self.theIndex = theParent.theIndex
self.optState = theParent.theProject.optState
self.headerMenu = GuiOutlineHeaderMenu(self)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSelectionMode(QAbstractItemView.SingleSelection)
self.setExpandsOnDoubleClick(False)
self.setDragEnabled(False)
self.itemDoubleClicked.connect(self._treeDoubleClick)
self.itemSelectionChanged.connect(self._itemSelected)
iPx = self.theTheme.baseIconSize
self.setIconSize(QSize(iPx, iPx))
self.setIndentation(iPx)
self.treeHead = self.header()
self.treeHead.setContextMenuPolicy(Qt.CustomContextMenu)
self.treeHead.customContextMenuRequested.connect(self._headerRightClick)
self.treeHead.sectionMoved.connect(self._columnMoved)
# Internals
self._treeOrder = []
self._colWidth = {}
self._colHidden = {}
self._colIdx = {}
self._treeNCols = 0
self._firstView = True
self._lastBuild = 0
self.initOutline()
self.clearOutline()
self.headerMenu.setHiddenState(self._colHidden)
logger.debug("GuiOutline initialisation complete")
return
def initOutline(self):
"""Set or update outline settings.
"""
# Scroll bars
if self.mainConf.hideVScroll:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
if self.mainConf.hideHScroll:
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
return
def clearOutline(self):
"""Clear the tree and header and set the default values for the
columns arrays.
"""
self.clear()
self.setColumnCount(1)
self.setHeaderLabel(trConst(nwLabels.OUTLINE_COLS[nwOutline.TITLE]))
self._treeOrder = []
self._colWidth = {}
self._colHidden = {}
self._colIdx = {}
self._treeNCols = 0
for hItem in nwOutline:
self._treeOrder.append(hItem)
self._colWidth[hItem] = self.DEF_WIDTH[hItem]
self._colHidden[hItem] = self.DEF_HIDDEN[hItem]
self._treeNCols = len(self._treeOrder)
return
def refreshTree(self, overRide=False, novelChanged=False):
"""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:
self._loadHeaderState()
self._populateTree()
self._firstView = False
return
# If the novel index or novel tree has changed since the tree
# was last built, we rebuild the tree from the updated index.
indexChanged = self.theIndex.novelChangedSince(self._lastBuild)
doBuild = (novelChanged or indexChanged) and self.theProject.autoOutline
if doBuild or overRide:
logger.debug("Rebuilding Project Outline")
self._populateTree()
return
def closeOutline(self):
"""Called before a project is closed.
"""
self._saveHeaderState()
self.clearOutline()
self._firstView = True
return
##
# Slots
##
@pyqtSlot("QTreeWidgetItem*", int)
def _treeDoubleClick(self, tItem, tCol):
"""Extract the handle and line number of the title double-
clicked, and send it to the main gui class for opening in the
document editor.
"""
tHandle = tItem.data(self._colIdx[nwOutline.TITLE], Qt.UserRole)
try:
tLine = int(tItem.text(self._colIdx[nwOutline.LINE]))
except Exception:
tLine = 1
logger.verbose("User selected entry with handle '%s' on line %s", tHandle, tLine)
self.theParent.openDocument(tHandle, tLine=tLine-1, doScroll=True)
return
@pyqtSlot()
def _itemSelected(self):
"""Extract the handle and line number of the currently selected
title, and send it to the details panel.
"""
selItems = self.selectedItems()
if selItems:
tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], Qt.UserRole)
sTitle = selItems[0].data(self._colIdx[nwOutline.LINE], Qt.UserRole)
self.theParent.projMeta.showItem(tHandle, sTitle)
self.theParent.treeView.setSelectedHandle(tHandle)
return
@pyqtSlot("QPoint")
def _headerRightClick(self, clickPos):
"""Show the header column menu.
"""
self.headerMenu.exec_(self.mapToGlobal(clickPos))
return
@pyqtSlot(int, int, int)
def _columnMoved(self, logIdx, oldVisualIdx, newVisualIdx):
"""Make sure the order array is up to date with the actual order
of the columns.
"""
self._treeOrder.insert(newVisualIdx, self._treeOrder.pop(oldVisualIdx))
self._saveHeaderState()
return
def _menuColumnToggled(self, isChecked, theItem):
"""Receive the changes to column visibility forwarded by the
header context menu.
"""
logger.verbose("User toggled Outline column '%s'", theItem.name)
if theItem in self._colIdx:
self.setColumnHidden(self._colIdx[theItem], not isChecked)
self._saveHeaderState()
return
##
# Internal Functions
##
def _loadHeaderState(self):
"""Load the state of the main tree header, that is, column order
and column width.
"""
# Load whatever we saved last time, regardless of wether it
# contains the correct names or number of columns. The names
# must be valid though.
tempOrder = self.optState.getValue("GuiOutline", "headerOrder", [])
treeOrder = []
for hName in tempOrder:
try:
treeOrder.append(nwOutline[hName])
except Exception:
logger.warning("Ignored unknown outline column '%s'", str(hName))
# Add columns that was not in the file to the treeOrder array.
for hItem in nwOutline:
if hItem not in treeOrder:
treeOrder.append(hItem)
# Check that we now have a complete list, and only if so, save
# the order loaded from file. Otherwise, we keep the default.
if len(treeOrder) == self._treeNCols:
self._treeOrder = treeOrder
else:
logger.error("Failed to extract outline column order from previous session")
logger.error("Column count doesn't match %d != %d", len(treeOrder), self._treeNCols)
# We load whatever column widths and hidden states we find in
# the file, and leave the rest in their default state.
tmpWidth = self.optState.getValue("GuiOutline", "columnWidth", {})
for hName in tmpWidth:
try:
self._colWidth[nwOutline[hName]] = self.mainConf.pxInt(tmpWidth[hName])
except Exception:
logger.warning("Ignored unknown outline column '%s'", str(hName))
tmpHidden = self.optState.getValue("GuiOutline", "columnHidden", {})
for hName in tmpHidden:
try:
self._colHidden[nwOutline[hName]] = tmpHidden[hName]
except Exception:
logger.warning("Ignored unknown outline column '%s'", str(hName))
self.headerMenu.setHiddenState(self._colHidden)
return
def _saveHeaderState(self):
"""Save the state of the main tree header, that is, column
order, column width and column hidden state. We don't want to
save the current width of hidden columns though. This preserves
the last known width in case they're unhidden again.
"""
# If we haven't built the tree, there is nothing to save.
if self._lastBuild == 0:
return
treeOrder = []
colWidth = {}
colHidden = {}
for hItem in nwOutline:
colWidth[hItem.name] = self.mainConf.rpxInt(self._colWidth[hItem])
colHidden[hItem.name] = self._colHidden[hItem]
for iCol in range(self.columnCount()):
hName = self._treeOrder[iCol].name
treeOrder.append(hName)
iLog = self.treeHead.logicalIndex(iCol)
logWidth = self.mainConf.rpxInt(self.columnWidth(iLog))
logHidden = self.isColumnHidden(iLog)
colHidden[hName] = logHidden
if not logHidden and logWidth > 0:
colWidth[hName] = logWidth
self.optState.setValue("GuiOutline", "headerOrder", treeOrder)
self.optState.setValue("GuiOutline", "columnWidth", colWidth)
self.optState.setValue("GuiOutline", "columnHidden", colHidden)
self.optState.saveSettings()
return
def _populateTree(self):
"""Build the tree based on the project index, and the header
based on the defined constants, default values and user selected
width, order and hidden state. All columns are populated, even
if they are hidden. This ensures that showing and hiding columns
is fast and doesn't require a rebuild of the tree.
"""
self.clear()
if self._firstView:
theLabels = []
for i, hItem in enumerate(self._treeOrder):
theLabels.append(trConst(nwLabels.OUTLINE_COLS[hItem]))
self._colIdx[hItem] = i
self.setHeaderLabels(theLabels)
for hItem in self._treeOrder:
self.setColumnWidth(self._colIdx[hItem], self._colWidth[hItem])
self.setColumnHidden(self._colIdx[hItem], self._colHidden[hItem])
# Make sure title column is always visible,
# and handle column always hidden
self.setColumnHidden(self._colIdx[nwOutline.TITLE], False)
headItem = self.headerItem()
headItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight)
currTitle = None
currChapter = None
currScene = None
for tKey, tHandle, sTitle, novIdx in self.theIndex.novelStructure(skipExcluded=True):
tItem = self._createTreeItem(tHandle, sTitle, novIdx)
tLevel = novIdx["level"]
if tLevel == "H1":
self.addTopLevelItem(tItem)
currTitle = tItem
currChapter = None
currScene = None
elif tLevel == "H2":
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
currChapter = tItem
currScene = None
elif tLevel == "H3":
if currChapter is None:
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
else:
currChapter.addChild(tItem)
currScene = tItem
elif tLevel == "H4":
if currScene is None:
if currChapter is None:
if currTitle is None:
self.addTopLevelItem(tItem)
else:
currTitle.addChild(tItem)
else:
currChapter.addChild(tItem)
else:
currScene.addChild(tItem)
tItem.setExpanded(True)
self._lastBuild = time()
return
def _createTreeItem(self, tHandle, sTitle, novIdx):
"""Populate a tree item with all the column values.
"""
nwItem = self.theProject.projTree[tHandle]
newItem = QTreeWidgetItem()
hIcon = "doc_%s" % novIdx["level"].lower()
hLevel = self.theIndex.getHandleHeaderLevel(tHandle)
dIcon = self.theTheme.getItemIcon(nwItemType.FILE, None, nwItemLayout.DOCUMENT, hLevel)
cC = int(novIdx["cCount"])
wC = int(novIdx["wCount"])
pC = int(novIdx["pCount"])
newItem.setText(self._colIdx[nwOutline.TITLE], novIdx["title"])
newItem.setData(self._colIdx[nwOutline.TITLE], Qt.UserRole, tHandle)
newItem.setIcon(self._colIdx[nwOutline.TITLE], self.theTheme.getIcon(hIcon))
newItem.setText(self._colIdx[nwOutline.LEVEL], novIdx["level"])
newItem.setText(self._colIdx[nwOutline.LABEL], nwItem.itemName)
newItem.setIcon(self._colIdx[nwOutline.LABEL], dIcon)
newItem.setText(self._colIdx[nwOutline.LINE], sTitle[1:].lstrip("0"))
newItem.setData(self._colIdx[nwOutline.LINE], Qt.UserRole, sTitle)
newItem.setText(self._colIdx[nwOutline.SYNOP], novIdx["synopsis"])
newItem.setText(self._colIdx[nwOutline.CCOUNT], f"{cC:n}")
newItem.setText(self._colIdx[nwOutline.WCOUNT], f"{wC:n}")
newItem.setText(self._colIdx[nwOutline.PCOUNT], f"{pC:n}")
newItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], Qt.AlignRight)
newItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight)
newItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight)
theRefs = self.theIndex.getReferences(tHandle, sTitle)
newItem.setText(self._colIdx[nwOutline.POV], ", ".join(theRefs[nwKeyWords.POV_KEY]))
newItem.setText(self._colIdx[nwOutline.FOCUS], ", ".join(theRefs[nwKeyWords.FOCUS_KEY]))
newItem.setText(self._colIdx[nwOutline.CHAR], ", ".join(theRefs[nwKeyWords.CHAR_KEY]))
newItem.setText(self._colIdx[nwOutline.PLOT], ", ".join(theRefs[nwKeyWords.PLOT_KEY]))
newItem.setText(self._colIdx[nwOutline.TIME], ", ".join(theRefs[nwKeyWords.TIME_KEY]))
newItem.setText(self._colIdx[nwOutline.WORLD], ", ".join(theRefs[nwKeyWords.WORLD_KEY]))
newItem.setText(self._colIdx[nwOutline.OBJECT], ", ".join(theRefs[nwKeyWords.OBJECT_KEY]))
newItem.setText(self._colIdx[nwOutline.ENTITY], ", ".join(theRefs[nwKeyWords.ENTITY_KEY]))
newItem.setText(self._colIdx[nwOutline.CUSTOM], ", ".join(theRefs[nwKeyWords.CUSTOM_KEY]))
return newItem
# END Class GuiOutline
class GuiOutlineHeaderMenu(QMenu):
def __init__(self, theParent):
QMenu.__init__(self, theParent)
self.theParent = theParent
self.acceptToggle = True
mnuHead = QAction(self.tr("Select Columns"), self)
self.addAction(mnuHead)
self.addSeparator()
self.actionMap = {}
for hItem in nwOutline:
if hItem == nwOutline.TITLE:
continue
self.actionMap[hItem] = QAction(trConst(nwLabels.OUTLINE_COLS[hItem]), self)
self.actionMap[hItem].setCheckable(True)
self.actionMap[hItem].toggled.connect(
lambda isChecked, tItem=hItem: self._columnToggled(isChecked, tItem)
)
self.addAction(self.actionMap[hItem])
return
def setHiddenState(self, hiddenState):
"""Overwrite the checked state of the columns as the inverse of
the hidden state. Skip the TITLE column as it cannot be hidden.
"""
self.acceptToggle = False
for hItem in nwOutline:
if hItem == nwOutline.TITLE or hItem not in hiddenState:
continue
self.actionMap[hItem].setChecked(not hiddenState[hItem])
self.acceptToggle = True
return
##
# Slots
##
def _columnToggled(self, isChecked, theItem):
"""The user has toggled the visibility of a column. Forward the
event to the parent class only if we're accepting changes.
"""
if self.acceptToggle:
self.theParent._menuColumnToggled(isChecked, theItem)
return
# END Class GuiOutlineHeaderMenu
+349
View File
@@ -0,0 +1,349 @@
"""
novelWriter GUI Project Outline Details
=========================================
GUI class for the project outline details panel
File History:
Created: 2020-06-02 [0.7.0]
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import logging
import novelwriter
from PyQt5.QtCore import Qt, QT_TRANSLATE_NOOP
from PyQt5.QtWidgets import (
QScrollArea, QWidget, QGridLayout, QHBoxLayout, QGroupBox, QLabel
)
from novelwriter.common import checkInt
from novelwriter.constants import trConst, nwKeyWords, nwLabels
logger = logging.getLogger(__name__)
class GuiOutlineDetails(QScrollArea):
LVL_MAP = {
"H1": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Title"),
"H2": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Chapter"),
"H3": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Scene"),
"H4": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Section"),
}
def __init__(self, theParent):
QScrollArea.__init__(self, theParent)
logger.debug("Initialising GuiOutlineDetails ...")
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theProject = theParent.theProject
self.theTheme = theParent.theTheme
self.theIndex = theParent.theIndex
self.optState = theParent.theProject.optState
# Sizes
minTitle = 30*self.theTheme.textNWidth
maxTitle = 40*self.theTheme.textNWidth
wCount = self.theTheme.getTextWidth("999,999")
hSpace = int(self.mainConf.pxInt(10))
vSpace = int(self.mainConf.pxInt(4))
# Details Area
self.titleLabel = QLabel("<b>%s</b>" % self.tr("Title"))
self.fileLabel = QLabel("<b>%s</b>" % self.tr("Document"))
self.itemLabel = QLabel("<b>%s</b>" % self.tr("Status"))
self.titleValue = QLabel("")
self.fileValue = QLabel("")
self.itemValue = QLabel("")
self.titleValue.setMinimumWidth(minTitle)
self.titleValue.setMaximumWidth(maxTitle)
self.fileValue.setMinimumWidth(minTitle)
self.fileValue.setMaximumWidth(maxTitle)
self.itemValue.setMinimumWidth(minTitle)
self.itemValue.setMaximumWidth(maxTitle)
# Stats Area
self.cCLabel = QLabel("<b>%s</b>" % self.tr("Characters"))
self.wCLabel = QLabel("<b>%s</b>" % self.tr("Words"))
self.pCLabel = QLabel("<b>%s</b>" % self.tr("Paragraphs"))
self.cCValue = QLabel("")
self.wCValue = QLabel("")
self.pCValue = QLabel("")
self.cCValue.setMinimumWidth(wCount)
self.wCValue.setMinimumWidth(wCount)
self.pCValue.setMinimumWidth(wCount)
self.cCValue.setAlignment(Qt.AlignRight)
self.wCValue.setAlignment(Qt.AlignRight)
self.pCValue.setAlignment(Qt.AlignRight)
# Synopsis
self.synopLabel = QLabel("<b>%s</b>" % self.tr("Synopsis"))
self.synopValue = QLabel("")
self.synopLWrap = QHBoxLayout()
self.synopValue.setWordWrap(True)
self.synopValue.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.synopLWrap.addWidget(self.synopValue, 1)
# Tags
self.povKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.POV_KEY]))
self.focKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.FOCUS_KEY]))
self.chrKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.CHAR_KEY]))
self.pltKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.PLOT_KEY]))
self.timKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.TIME_KEY]))
self.wldKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.WORLD_KEY]))
self.objKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.OBJECT_KEY]))
self.entKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.ENTITY_KEY]))
self.cstKeyLabel = QLabel("<b>%s</b>" % trConst(nwLabels.KEY_NAME[nwKeyWords.CUSTOM_KEY]))
self.povKeyLWrap = QHBoxLayout()
self.focKeyLWrap = QHBoxLayout()
self.chrKeyLWrap = QHBoxLayout()
self.pltKeyLWrap = QHBoxLayout()
self.timKeyLWrap = QHBoxLayout()
self.wldKeyLWrap = QHBoxLayout()
self.objKeyLWrap = QHBoxLayout()
self.entKeyLWrap = QHBoxLayout()
self.cstKeyLWrap = QHBoxLayout()
self.povKeyValue = QLabel("")
self.focKeyValue = QLabel("")
self.chrKeyValue = QLabel("")
self.pltKeyValue = QLabel("")
self.timKeyValue = QLabel("")
self.wldKeyValue = QLabel("")
self.objKeyValue = QLabel("")
self.entKeyValue = QLabel("")
self.cstKeyValue = QLabel("")
self.povKeyValue.setWordWrap(True)
self.focKeyValue.setWordWrap(True)
self.chrKeyValue.setWordWrap(True)
self.pltKeyValue.setWordWrap(True)
self.timKeyValue.setWordWrap(True)
self.wldKeyValue.setWordWrap(True)
self.objKeyValue.setWordWrap(True)
self.entKeyValue.setWordWrap(True)
self.cstKeyValue.setWordWrap(True)
self.povKeyValue.linkActivated.connect(self._tagClicked)
self.focKeyValue.linkActivated.connect(self._tagClicked)
self.chrKeyValue.linkActivated.connect(self._tagClicked)
self.pltKeyValue.linkActivated.connect(self._tagClicked)
self.timKeyValue.linkActivated.connect(self._tagClicked)
self.wldKeyValue.linkActivated.connect(self._tagClicked)
self.objKeyValue.linkActivated.connect(self._tagClicked)
self.entKeyValue.linkActivated.connect(self._tagClicked)
self.cstKeyValue.linkActivated.connect(self._tagClicked)
self.povKeyLWrap.addWidget(self.povKeyValue, 1)
self.focKeyLWrap.addWidget(self.focKeyValue, 1)
self.chrKeyLWrap.addWidget(self.chrKeyValue, 1)
self.pltKeyLWrap.addWidget(self.pltKeyValue, 1)
self.timKeyLWrap.addWidget(self.timKeyValue, 1)
self.wldKeyLWrap.addWidget(self.wldKeyValue, 1)
self.objKeyLWrap.addWidget(self.objKeyValue, 1)
self.entKeyLWrap.addWidget(self.entKeyValue, 1)
self.cstKeyLWrap.addWidget(self.cstKeyValue, 1)
# Selected Item Details
self.mainGroup = QGroupBox(self.tr("Title Details"), self)
self.mainForm = QGridLayout()
self.mainGroup.setLayout(self.mainForm)
self.mainForm.addWidget(self.titleLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.titleValue, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.cCLabel, 0, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.cCValue, 0, 3, 1, 1, Qt.AlignTop | Qt.AlignRight)
self.mainForm.addWidget(self.fileLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.fileValue, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.wCLabel, 1, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.wCValue, 1, 3, 1, 1, Qt.AlignTop | Qt.AlignRight)
self.mainForm.addWidget(self.itemLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.itemValue, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.pCLabel, 2, 2, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addWidget(self.pCValue, 2, 3, 1, 1, Qt.AlignTop | Qt.AlignRight)
self.mainForm.addWidget(self.synopLabel, 3, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.addLayout(self.synopLWrap, 4, 0, 1, 4, Qt.AlignTop | Qt.AlignLeft)
self.mainForm.setColumnStretch(1, 1)
self.mainForm.setRowStretch(4, 1)
self.mainForm.setHorizontalSpacing(hSpace)
self.mainForm.setVerticalSpacing(vSpace)
# Selected Item Tags
self.tagsGroup = QGroupBox(self.tr("Reference Tags"), self)
self.tagsForm = QGridLayout()
self.tagsGroup.setLayout(self.tagsForm)
self.tagsForm.addWidget(self.povKeyLabel, 0, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.povKeyLWrap, 0, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addWidget(self.focKeyLabel, 1, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.focKeyLWrap, 1, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addWidget(self.chrKeyLabel, 2, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.chrKeyLWrap, 2, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addWidget(self.pltKeyLabel, 3, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.pltKeyLWrap, 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addWidget(self.timKeyLabel, 4, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.timKeyLWrap, 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addWidget(self.wldKeyLabel, 5, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.wldKeyLWrap, 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addWidget(self.objKeyLabel, 6, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.objKeyLWrap, 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addWidget(self.entKeyLabel, 7, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.entKeyLWrap, 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addWidget(self.cstKeyLabel, 8, 0, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.addLayout(self.cstKeyLWrap, 8, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
self.tagsForm.setColumnStretch(1, 1)
self.tagsForm.setRowStretch(8, 1)
self.tagsForm.setHorizontalSpacing(hSpace)
self.tagsForm.setVerticalSpacing(vSpace)
# Assemble
self.outerWidget = QWidget()
self.outerBox = QHBoxLayout()
self.outerBox.addWidget(self.mainGroup, 0)
self.outerBox.addWidget(self.tagsGroup, 1)
self.outerWidget.setLayout(self.outerBox)
self.setWidget(self.outerWidget)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setWidgetResizable(True)
self.initDetails()
logger.debug("GuiOutlineDetails initialisation complete")
return
def initDetails(self):
"""Set or update outline settings.
"""
# Scroll bars
if self.mainConf.hideVScroll:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
if self.mainConf.hideHScroll:
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
else:
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
return
def clearDetails(self):
"""Clear all the data labels.
"""
self.titleLabel.setText("<b>%s</b>" % self.tr("Title"))
self.titleValue.setText("")
self.fileValue.setText("")
self.itemValue.setText("")
self.cCValue.setText("")
self.wCValue.setText("")
self.pCValue.setText("")
self.synopValue.setText("")
self.povKeyValue.setText("")
self.focKeyValue.setText("")
self.chrKeyValue.setText("")
self.pltKeyValue.setText("")
self.timKeyValue.setText("")
self.wldKeyValue.setText("")
self.objKeyValue.setText("")
self.entKeyValue.setText("")
self.cstKeyValue.setText("")
return
def showItem(self, tHandle, sTitle):
"""Update the content of the tree with the given handle and line
number pointing to a header.
"""
nwItem = self.theProject.projTree[tHandle]
novIdx = self.theIndex.getNovelData(tHandle, sTitle)
theRefs = self.theIndex.getReferences(tHandle, sTitle)
if nwItem is None or novIdx is None:
return False
if novIdx["level"] in self.LVL_MAP:
self.titleLabel.setText("<b>%s</b>" % self.tr(self.LVL_MAP[novIdx["level"]]))
else:
self.titleLabel.setText("<b>%s</b>" % self.tr("Title"))
self.titleValue.setText(novIdx["title"])
self.fileValue.setText(nwItem.itemName)
self.itemValue.setText(nwItem.itemStatus)
cC = checkInt(novIdx["cCount"], 0)
wC = checkInt(novIdx["wCount"], 0)
pC = checkInt(novIdx["pCount"], 0)
self.cCValue.setText(f"{cC:n}")
self.wCValue.setText(f"{wC:n}")
self.pCValue.setText(f"{pC:n}")
self.synopValue.setText(novIdx["synopsis"])
self.povKeyValue.setText(self._formatTags(theRefs, nwKeyWords.POV_KEY))
self.focKeyValue.setText(self._formatTags(theRefs, nwKeyWords.FOCUS_KEY))
self.chrKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CHAR_KEY))
self.pltKeyValue.setText(self._formatTags(theRefs, nwKeyWords.PLOT_KEY))
self.timKeyValue.setText(self._formatTags(theRefs, nwKeyWords.TIME_KEY))
self.wldKeyValue.setText(self._formatTags(theRefs, nwKeyWords.WORLD_KEY))
self.objKeyValue.setText(self._formatTags(theRefs, nwKeyWords.OBJECT_KEY))
self.entKeyValue.setText(self._formatTags(theRefs, nwKeyWords.ENTITY_KEY))
self.cstKeyValue.setText(self._formatTags(theRefs, nwKeyWords.CUSTOM_KEY))
return True
##
# Slots
##
def _tagClicked(self, theLink):
"""Capture the click of a tag in the right-most column.
"""
logger.verbose("Clicked link: '%s'", theLink)
if len(theLink) > 0:
theBits = theLink.split("=")
if len(theBits) == 2:
self.theParent.docViewer.loadFromTag(theBits[1])
return
##
# Internal Functions
##
def _formatTags(self, theRefs, theKey):
"""Format the tags as clickable links.
"""
if theKey not in theRefs:
return ""
refTags = []
for tTag in theRefs[theKey]:
refTags.append("<a href='#%s=%s'>%s</a>" % (
theKey[1:], tTag, tTag
))
return ", ".join(refTags)
# END Class GuiOutlineDetails
+477
View File
@@ -0,0 +1,477 @@
"""
novelWriter GUI Project Details
=================================
Class holding the project details dialog
File History:
Created: 2021-01-03 [1.1a0]
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import math
import logging
import novelwriter
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (
QWidget, QDialogButtonBox, QVBoxLayout, QTreeWidget, QTreeWidgetItem,
QLabel, QSpinBox, QGridLayout, QHBoxLayout, QLineEdit, QAbstractItemView
)
from novelwriter.common import numberToRoman
from novelwriter.constants import nwUnicode
from novelwriter.gui.custom import PagedDialog, QSwitch
logger = logging.getLogger(__name__)
class GuiProjectDetails(PagedDialog):
def __init__(self, theParent):
PagedDialog.__init__(self, theParent)
logger.debug("Initialising GuiProjectDetails ...")
self.setObjectName("GuiProjectDetails")
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theProject = theParent.theProject
self.optState = theParent.theProject.optState
self.setWindowTitle(self.tr("Project Details"))
wW = self.mainConf.pxInt(600)
wH = self.mainConf.pxInt(400)
self.setMinimumWidth(wW)
self.setMinimumHeight(wH)
self.resize(
self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "winWidth", wW)),
self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "winHeight", wH))
)
self.tabMain = GuiProjectDetailsMain(self.theParent, self.theProject)
self.tabContents = GuiProjectDetailsContents(self.theParent, self.theProject)
self.addTab(self.tabMain, self.tr("Overview"))
self.addTab(self.tabContents, self.tr("Contents"))
self.buttonBox = QDialogButtonBox(QDialogButtonBox.Close)
self.buttonBox.button(QDialogButtonBox.Close).setText(self.tr("Close"))
self.buttonBox.rejected.connect(self._doClose)
self.addControls(self.buttonBox)
logger.debug("GuiProjectDetails initialisation complete")
return
##
# Slots
##
def _doClose(self):
"""Save settings and close the dialog.
"""
self._saveGuiSettings()
self.close()
return
##
# Internal Functions
##
def _saveGuiSettings(self):
"""Save GUI settings.
"""
winWidth = self.mainConf.rpxInt(self.width())
winHeight = self.mainConf.rpxInt(self.height())
cColWidth = self.tabContents.getColumnSizes()
widthCol0 = self.mainConf.rpxInt(cColWidth[0])
widthCol1 = self.mainConf.rpxInt(cColWidth[1])
widthCol2 = self.mainConf.rpxInt(cColWidth[2])
widthCol3 = self.mainConf.rpxInt(cColWidth[3])
widthCol4 = self.mainConf.rpxInt(cColWidth[4])
wordsPerPage = self.tabContents.wpValue.value()
countFrom = self.tabContents.poValue.value()
clearDouble = self.tabContents.dblValue.isChecked()
self.optState.setValue("GuiProjectDetails", "winWidth", winWidth)
self.optState.setValue("GuiProjectDetails", "winHeight", winHeight)
self.optState.setValue("GuiProjectDetails", "widthCol0", widthCol0)
self.optState.setValue("GuiProjectDetails", "widthCol1", widthCol1)
self.optState.setValue("GuiProjectDetails", "widthCol2", widthCol2)
self.optState.setValue("GuiProjectDetails", "widthCol3", widthCol3)
self.optState.setValue("GuiProjectDetails", "widthCol4", widthCol4)
self.optState.setValue("GuiProjectDetails", "wordsPerPage", wordsPerPage)
self.optState.setValue("GuiProjectDetails", "countFrom", countFrom)
self.optState.setValue("GuiProjectDetails", "clearDouble", clearDouble)
return
# END Class GuiProjectDetails
class GuiProjectDetailsMain(QWidget):
def __init__(self, theParent, theProject):
QWidget.__init__(self, theParent)
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theProject = theProject
self.theTheme = theParent.theTheme
self.theIndex = theParent.theIndex
fPx = self.theTheme.fontPixelSize
fPt = self.theTheme.fontPointSize
vPx = self.mainConf.pxInt(4)
hPx = self.mainConf.pxInt(12)
# Header
# ======
self.bookTitle = QLabel(self.theProject.bookTitle)
bookFont = self.bookTitle.font()
bookFont.setPointSizeF(2.2*fPt)
bookFont.setWeight(QFont.Bold)
self.bookTitle.setFont(bookFont)
self.bookTitle.setAlignment(Qt.AlignHCenter)
self.bookTitle.setWordWrap(True)
self.projName = QLabel(
self.tr("Working Title: {0}").format(self.theProject.projName)
)
workFont = self.projName.font()
workFont.setPointSizeF(0.8*fPt)
workFont.setItalic(True)
self.projName.setFont(workFont)
self.projName.setAlignment(Qt.AlignHCenter)
self.projName.setWordWrap(True)
self.bookAuthors = QLabel(self.tr("By {0}").format(self.theProject.getAuthors()))
authFont = self.bookAuthors.font()
authFont.setPointSizeF(1.2*fPt)
self.bookAuthors.setFont(authFont)
self.bookAuthors.setAlignment(Qt.AlignHCenter)
self.bookAuthors.setWordWrap(True)
# Stats
# =====
hCounts = self.theIndex.getNovelTitleCounts()
nwCount = self.theIndex.getNovelWordCount()
self.wordCountLbl = QLabel("<b>%s:</b>" % self.tr("Words"))
self.wordCountVal = QLabel(f"{nwCount:n}")
self.chapCountLbl = QLabel("<b>%s:</b>" % self.tr("Chapters"))
self.chapCountVal = QLabel(f"{hCounts[2]:n}")
self.sceneCountLbl = QLabel("<b>%s:</b>" % self.tr("Scenes"))
self.sceneCountVal = QLabel(f"{hCounts[3]:n}")
self.revCountLbl = QLabel("<b>%s:</b>" % self.tr("Revisions"))
self.revCountVal = QLabel(f"{self.theProject.saveCount:n}")
edTime = self.theProject.getCurrentEditTime()
self.editTimeLbl = QLabel("<b>%s:</b>" % self.tr("Editing Time"))
self.editTimeVal = QLabel(f"{edTime//3600:02d}:{edTime%3600//60:02d}")
self.statsGrid = QGridLayout()
self.statsGrid.addWidget(self.wordCountLbl, 0, 0, 1, 1, Qt.AlignRight)
self.statsGrid.addWidget(self.wordCountVal, 0, 1, 1, 1, Qt.AlignLeft)
self.statsGrid.addWidget(self.chapCountLbl, 1, 0, 1, 1, Qt.AlignRight)
self.statsGrid.addWidget(self.chapCountVal, 1, 1, 1, 1, Qt.AlignLeft)
self.statsGrid.addWidget(self.sceneCountLbl, 2, 0, 1, 1, Qt.AlignRight)
self.statsGrid.addWidget(self.sceneCountVal, 2, 1, 1, 1, Qt.AlignLeft)
self.statsGrid.addWidget(self.revCountLbl, 3, 0, 1, 1, Qt.AlignRight)
self.statsGrid.addWidget(self.revCountVal, 3, 1, 1, 1, Qt.AlignLeft)
self.statsGrid.addWidget(self.editTimeLbl, 4, 0, 1, 1, Qt.AlignRight)
self.statsGrid.addWidget(self.editTimeVal, 4, 1, 1, 1, Qt.AlignLeft)
self.statsGrid.setHorizontalSpacing(hPx)
self.statsGrid.setVerticalSpacing(vPx)
# Meta
# ====
self.projPathLbl = QLabel("<b>%s:</b>" % self.tr("Path"))
self.projPathVal = QLineEdit()
self.projPathVal.setText(self.theProject.projPath)
self.projPathVal.setReadOnly(True)
self.projPathBox = QHBoxLayout()
self.projPathBox.addWidget(self.projPathLbl)
self.projPathBox.addWidget(self.projPathVal)
self.projPathBox.setSpacing(hPx)
# Assemble
# ========
self.outerBox = QVBoxLayout()
self.outerBox.addSpacing(fPx)
self.outerBox.addWidget(self.bookTitle)
self.outerBox.addWidget(self.projName)
self.outerBox.addWidget(self.bookAuthors)
self.outerBox.addSpacing(2*fPx)
self.outerBox.addLayout(self.statsGrid)
self.outerBox.addSpacing(fPx)
self.outerBox.addStretch(1)
self.outerBox.addLayout(self.projPathBox)
self.setLayout(self.outerBox)
return
# END Class GuiProjectDetailsMain
class GuiProjectDetailsContents(QWidget):
C_TITLE = 0
C_WORDS = 1
C_PAGES = 2
C_PAGE = 3
C_PROG = 4
def __init__(self, theParent, theProject):
QWidget.__init__(self, theParent)
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theProject = theProject
self.theTheme = theParent.theTheme
self.theIndex = theParent.theIndex
self.optState = theProject.optState
# Internal
self._theToC = []
iPx = self.theTheme.baseIconSize
hPx = self.mainConf.pxInt(12)
vPx = self.mainConf.pxInt(4)
# Contents Tree
# =============
self.tocTree = QTreeWidget()
self.tocTree.setIconSize(QSize(iPx, iPx))
self.tocTree.setIndentation(0)
self.tocTree.setColumnCount(6)
self.tocTree.setSelectionMode(QAbstractItemView.NoSelection)
self.tocTree.setHeaderLabels([
self.tr("Title"),
self.tr("Words"),
self.tr("Pages"),
self.tr("Page"),
self.tr("Progress"),
""
])
treeHeadItem = self.tocTree.headerItem()
treeHeadItem.setTextAlignment(self.C_WORDS, Qt.AlignRight)
treeHeadItem.setTextAlignment(self.C_PAGES, Qt.AlignRight)
treeHeadItem.setTextAlignment(self.C_PAGE, Qt.AlignRight)
treeHeadItem.setTextAlignment(self.C_PROG, Qt.AlignRight)
treeHeader = self.tocTree.header()
treeHeader.setStretchLastSection(True)
treeHeader.setMinimumSectionSize(hPx)
wCol0 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol0", 200))
wCol1 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol1", 60))
wCol2 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol2", 60))
wCol3 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol3", 60))
wCol4 = self.mainConf.pxInt(self.optState.getInt("GuiProjectDetails", "widthCol4", 90))
self.tocTree.setColumnWidth(0, wCol0)
self.tocTree.setColumnWidth(1, wCol1)
self.tocTree.setColumnWidth(2, wCol2)
self.tocTree.setColumnWidth(3, wCol3)
self.tocTree.setColumnWidth(4, wCol4)
self.tocTree.setColumnWidth(5, hPx)
# Options
# =======
wordsPerPage = self.optState.getInt("GuiProjectDetails", "wordsPerPage", 350)
countFrom = self.optState.getInt("GuiProjectDetails", "countFrom", 1)
clearDouble = self.optState.getInt("GuiProjectDetails", "clearDouble", True)
wordsHelp = (
self.tr("Typical word count for a 5 by 8 inch book page with 11 pt font is 350.")
)
offsetHelp = (
self.tr("Start counting page numbers from this page.")
)
dblHelp = (
self.tr("Assume a new chapter or partition always start on an odd numbered page.")
)
self.wpLabel = QLabel(self.tr("Words per page"))
self.wpLabel.setToolTip(wordsHelp)
self.wpValue = QSpinBox()
self.wpValue.setMinimum(10)
self.wpValue.setMaximum(1000)
self.wpValue.setSingleStep(10)
self.wpValue.setValue(wordsPerPage)
self.wpValue.setToolTip(wordsHelp)
self.wpValue.valueChanged.connect(self._populateTree)
self.poLabel = QLabel(self.tr("Count pages from"))
self.poLabel.setToolTip(offsetHelp)
self.poValue = QSpinBox()
self.poValue.setMinimum(1)
self.poValue.setMaximum(9999)
self.poValue.setSingleStep(1)
self.poValue.setValue(countFrom)
self.poValue.setToolTip(offsetHelp)
self.poValue.valueChanged.connect(self._populateTree)
self.dblLabel = QLabel(self.tr("Clear double pages"))
self.dblLabel.setToolTip(dblHelp)
self.dblValue = QSwitch(self, 2*iPx, iPx)
self.dblValue.setChecked(clearDouble)
self.dblValue.setToolTip(dblHelp)
self.dblValue.clicked.connect(self._populateTree)
self.optionsBox = QGridLayout()
self.optionsBox.addWidget(self.wpLabel, 0, 0)
self.optionsBox.addWidget(self.wpValue, 0, 1)
self.optionsBox.addWidget(self.dblLabel, 0, 3)
self.optionsBox.addWidget(self.dblValue, 0, 4)
self.optionsBox.addWidget(self.poLabel, 1, 0)
self.optionsBox.addWidget(self.poValue, 1, 1)
self.optionsBox.setHorizontalSpacing(hPx)
self.optionsBox.setVerticalSpacing(vPx)
self.optionsBox.setColumnStretch(2, 1)
# Assemble
# ========
self.outerBox = QVBoxLayout()
self.outerBox.addWidget(QLabel("<b>%s</b>" % self.tr("Table of Contents")))
self.outerBox.addWidget(self.tocTree)
self.outerBox.addLayout(self.optionsBox)
self.setLayout(self.outerBox)
self._prepareData()
self._populateTree()
return
def getColumnSizes(self):
"""Return the column widths for the tree columns.
"""
retVals = [
self.tocTree.columnWidth(0),
self.tocTree.columnWidth(1),
self.tocTree.columnWidth(2),
self.tocTree.columnWidth(3),
self.tocTree.columnWidth(4),
]
return retVals
##
# Internal Functions
##
def _prepareData(self):
"""Extract the data for the tree.
"""
self._theToC = []
self._theToC = self.theIndex.getTableOfContents(2)
self._theToC.append(("", 0, self.tr("END"), 0))
return
##
# Slots
##
def _populateTree(self):
"""Set the content of the chapter/page tree.
"""
dblPages = self.dblValue.isChecked()
wpPage = self.wpValue.value()
fstPage = self.poValue.value() - 1
pTotal = 0
tPages = 1
theList = []
for _, tLevel, tTitle, wCount in self._theToC:
pCount = math.ceil(wCount/wpPage)
if dblPages:
pCount += pCount%2
pTotal += pCount
theList.append((tLevel, tTitle, wCount, pCount))
pMax = pTotal - fstPage
self.tocTree.clear()
for tLevel, tTitle, wCount, pCount in theList:
newItem = QTreeWidgetItem()
if tPages <= fstPage:
progPage = numberToRoman(tPages, True)
progText = ""
else:
cPage = tPages - fstPage
pgProg = 100.0*(cPage - 1)/pMax if pMax > 0 else 0.0
progPage = f"{cPage:n}"
progText = f"{pgProg:.1f}{nwUnicode.U_THSP}%"
if tTitle.strip() == "":
tTitle = self.tr("Untitled")
newItem.setIcon(self.C_TITLE, self.theTheme.getIcon("doc_h%d" % tLevel))
newItem.setText(self.C_TITLE, tTitle)
newItem.setText(self.C_WORDS, f"{wCount:n}")
newItem.setText(self.C_PAGES, f"{pCount:n}")
newItem.setText(self.C_PAGE, progPage)
newItem.setText(self.C_PROG, progText)
newItem.setTextAlignment(self.C_WORDS, Qt.AlignRight)
newItem.setTextAlignment(self.C_PAGES, Qt.AlignRight)
newItem.setTextAlignment(self.C_PAGE, Qt.AlignRight)
newItem.setTextAlignment(self.C_PROG, Qt.AlignRight)
# Make pages and titles/partitions stand out
if tLevel < 2:
bFont = newItem.font(self.C_TITLE)
if tLevel == 0:
bFont.setItalic(True)
else:
bFont.setBold(True)
bFont.setUnderline(True)
newItem.setFont(self.C_TITLE, bFont)
tPages += pCount
self.tocTree.addTopLevelItem(newItem)
return
# END Class GuiProjectDetailsContents
File diff suppressed because it is too large Load Diff
+288
View File
@@ -0,0 +1,288 @@
"""
novelWriter GUI Main Window Status Bar
========================================
GUI class for the main window status bar
File History:
Created: 2019-04-20 [0.0.1]
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import logging
import novelwriter
from time import time
from PyQt5.QtCore import QLocale, pyqtSlot
from PyQt5.QtGui import QColor, QPainter
from PyQt5.QtWidgets import qApp, QStatusBar, QLabel, QAbstractButton
from novelwriter.common import formatTime
from novelwriter.enum import nwState
logger = logging.getLogger(__name__)
class GuiMainStatus(QStatusBar):
def __init__(self, theParent):
QStatusBar.__init__(self, theParent)
logger.debug("Initialising GuiMainStatus ...")
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theTheme = theParent.theTheme
self.refTime = None
self.userIdle = False
colNone = QColor(*self.theTheme.statNone)
colTrue = QColor(*self.theTheme.statUnsaved)
colFalse = QColor(*self.theTheme.statSaved)
iPx = self.theTheme.baseIconSize
# Permanent Widgets
# =================
xM = self.mainConf.pxInt(8)
# The Spell Checker Language
self.langIcon = QLabel("")
self.langText = QLabel(self.tr("None"))
self.langIcon.setPixmap(self.theTheme.getPixmap("status_lang", (iPx, iPx)))
self.langIcon.setContentsMargins(0, 0, 0, 0)
self.langText.setContentsMargins(0, 0, xM, 0)
self.addPermanentWidget(self.langIcon)
self.addPermanentWidget(self.langText)
# The Editor Status
self.docIcon = StatusLED(colNone, colTrue, colFalse, iPx, iPx, self)
self.docText = QLabel(self.tr("Editor"))
self.docIcon.setContentsMargins(0, 0, 0, 0)
self.docText.setContentsMargins(0, 0, xM, 0)
self.addPermanentWidget(self.docIcon)
self.addPermanentWidget(self.docText)
# The Project Status
self.projIcon = StatusLED(colNone, colTrue, colFalse, iPx, iPx, self)
self.projText = QLabel(self.tr("Project"))
self.projIcon.setContentsMargins(0, 0, 0, 0)
self.projText.setContentsMargins(0, 0, xM, 0)
self.addPermanentWidget(self.projIcon)
self.addPermanentWidget(self.projText)
# The Project and Session Stats
self.statsIcon = QLabel()
self.statsText = QLabel("")
self.statsIcon.setPixmap(self.theTheme.getPixmap("status_stats", (iPx, iPx)))
self.statsIcon.setContentsMargins(0, 0, 0, 0)
self.statsText.setContentsMargins(0, 0, xM, 0)
self.addPermanentWidget(self.statsIcon)
self.addPermanentWidget(self.statsText)
# The Session Clock
# Set the mimimum width so the label doesn't rescale every second
self.timePixmap = self.theTheme.getPixmap("status_time", (iPx, iPx))
self.idlePixmap = self.theTheme.getPixmap("status_idle", (iPx, iPx))
self.timeIcon = QLabel()
self.timeText = QLabel("")
self.timeIcon.setPixmap(self.timePixmap)
self.timeText.setToolTip(self.tr("Session Time"))
self.timeText.setMinimumWidth(self.theTheme.getTextWidth("00:00:00:"))
self.timeIcon.setContentsMargins(0, 0, 0, 0)
self.timeText.setContentsMargins(0, 0, 0, 0)
self.addPermanentWidget(self.timeIcon)
self.addPermanentWidget(self.timeText)
# Other Settings
self.setSizeGripEnabled(True)
logger.debug("GuiMainStatus initialisation complete")
self.clearStatus()
return
def clearStatus(self):
"""Reset all widgets on the status bar to default values.
"""
self.setRefTime(None)
self.setLanguage(None, "")
self.doUpdateProjectStats(0, 0)
self.setProjectStatus(nwState.NONE)
self.setDocumentStatus(nwState.NONE)
self.updateTime()
return True
##
# Setters
##
def setRefTime(self, theTime):
"""Set the reference time for the status bar clock.
"""
self.refTime = theTime
return
def setStatus(self, theMessage, timeOut=20.0):
"""Set the status bar message to display for 'timeOut' seconds.
"""
self.showMessage(theMessage, int(timeOut*1000))
qApp.processEvents()
return
def setProjectStatus(self, theState):
"""Set the project status colour icon.
"""
self.projIcon.setState(theState)
return
def setDocumentStatus(self, theState):
"""Set the document status colour icon.
"""
self.docIcon.setState(theState)
return
def setUserIdle(self, userIdle):
"""Change the idle status icon.
"""
if not self.mainConf.stopWhenIdle:
userIdle = False
if self.userIdle != userIdle:
if userIdle:
self.timeIcon.setPixmap(self.idlePixmap)
else:
self.timeIcon.setPixmap(self.timePixmap)
self.userIdle = userIdle
return
def updateTime(self, idleTime=0.0):
"""Update the session clock.
"""
if self.refTime is None:
self.timeText.setText("00:00:00")
else:
if self.mainConf.stopWhenIdle:
sessTime = round(time() - self.refTime - idleTime)
else:
sessTime = round(time() - self.refTime)
self.timeText.setText(formatTime(sessTime))
return
##
# Slots
##
@pyqtSlot(str, str)
def setLanguage(self, theLanguage, theProvider):
"""Set the language code for the spell checker.
"""
if theLanguage == "None":
self.langText.setText(self.tr("None"))
self.langText.setToolTip("")
else:
qLocal = QLocale(theLanguage)
spLang = qLocal.nativeLanguageName().title()
self.langText.setText(spLang)
if theProvider:
self.langText.setToolTip("%s (%s)" % (theLanguage, theProvider))
else:
self.langText.setToolTip(theLanguage)
return
@pyqtSlot(int, int)
def doUpdateProjectStats(self, pWC, sWC):
"""Update the current project statistics.
"""
self.statsText.setText(self.tr("Words: {0} ({1})").format(f"{pWC:n}", f"{sWC:+n}"))
self.statsText.setToolTip(self.tr("Project word count (session change)"))
return
@pyqtSlot(bool)
def doUpdateProjectStatus(self, isChanged):
"""Slot for updating the project status.
"""
self.setProjectStatus(nwState.GOOD if isChanged else nwState.BAD)
return
@pyqtSlot(bool)
def doUpdateDocumentStatus(self, isChanged):
"""Slot for updating the document status.
"""
self.setDocumentStatus(nwState.GOOD if isChanged else nwState.BAD)
return
# END Class GuiMainStatus
class StatusLED(QAbstractButton):
def __init__(self, colNone, colGood, colBad, sW, sH, parent=None):
super().__init__(parent=parent)
self._colNone = colNone
self._colGood = colGood
self._colBad = colBad
self._theCol = colNone
self.setFixedWidth(sW)
self.setFixedHeight(sH)
return
##
# Setters
##
def setState(self, theState):
"""Set the colour state.
"""
if theState == nwState.GOOD:
self._theCol = self._colGood
elif theState == nwState.BAD:
self._theCol = self._colBad
else:
self._theCol = self._colNone
self.update()
return
##
# Events
##
def paintEvent(self, _):
"""Drawing the LED.
"""
qPalette = self.palette()
qPaint = QPainter(self)
qPaint.setRenderHint(QPainter.Antialiasing, True)
qPaint.setPen(qPalette.dark().color())
qPaint.setBrush(self._theCol)
qPaint.setOpacity(1.0)
qPaint.drawEllipse(1, 1, self.width() - 2, self.height() - 2)
return
# END Class StatusLED
+847
View File
@@ -0,0 +1,847 @@
"""
novelWriter Theme and Icons Classes
=====================================
Classes managing and caching themes and icons
File History:
Created: 2019-05-18 [0.1.3] GuiTheme
Created: 2019-11-08 [0.4.0] GuiIcons
This file is a part of novelWriter
Copyright 20182021, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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/>.
"""
import os
import logging
import novelwriter
from math import ceil
from functools import partial
from PyQt5.QtCore import QCoreApplication, Qt
from PyQt5.QtWidgets import QStyle, qApp
from PyQt5.QtGui import (
QPalette, QColor, QIcon, QFont, QFontMetrics, QFontDatabase, QPixmap
)
from novelwriter.enum import nwAlert, nwItemLayout, nwItemType
from novelwriter.common import NWConfigParser
from novelwriter.constants import nwLabels
logger = logging.getLogger(__name__)
# =============================================================================================== #
# Gui Theme Class
# Handles the look and feel of novelWriter
# =============================================================================================== #
class GuiTheme:
def __init__(self, theParent):
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
self.theIcons = GuiIcons(self.theParent)
self.guiPalette = QPalette()
self.guiPath = "gui"
self.fontPath = "fonts"
self.syntaxPath = "syntax"
self.cssName = "style.qss"
self.confName = "theme.conf"
self.themeList = []
self.syntaxList = []
# Loaded Theme Settings
# =====================
# Theme
self.themeName = ""
self.themeDescription = ""
self.themeAuthor = ""
self.themeCredit = ""
self.themeUrl = ""
self.themeLicense = ""
self.themeLicenseUrl = ""
# GUI
self.statNone = [120, 120, 120]
self.statUnsaved = [200, 15, 39]
self.statSaved = [2, 133, 37]
self.helpText = [0, 0, 0]
# Loaded Syntax Settings
# Main
self.syntaxName = ""
self.syntaxDescription = ""
self.syntaxAuthor = ""
self.syntaxCredit = ""
self.syntaxUrl = ""
self.syntaxLicense = ""
self.syntaxLicenseUrl = ""
# Colours
self.colBack = [255, 255, 255]
self.colText = [0, 0, 0]
self.colLink = [0, 0, 0]
self.colHead = [0, 0, 0]
self.colHeadH = [0, 0, 0]
self.colEmph = [0, 0, 0]
self.colDialN = [0, 0, 0]
self.colDialD = [0, 0, 0]
self.colDialS = [0, 0, 0]
self.colHidden = [0, 0, 0]
self.colKey = [0, 0, 0]
self.colVal = [0, 0, 0]
self.colSpell = [0, 0, 0]
self.colError = [0, 0, 0]
self.colRepTag = [0, 0, 0]
self.colMod = [0, 0, 0]
# Changeable Settings
self.guiTheme = None
self.guiSyntax = None
self.themeRoot = None
self.themePath = None
self.syntaxFile = None
self.confFile = None
self.cssFile = None
self.guiFontDB = QFontDatabase()
self.loadFonts()
self.updateFont()
self.updateTheme()
self.theIcons.updateTheme()
# Icon Functions
self.getIcon = self.theIcons.getIcon
self.getPixmap = self.theIcons.getPixmap
self.getItemIcon = self.theIcons.getItemIcon
self.loadDecoration = self.theIcons.loadDecoration
# Extract Other Info
self.guiDPI = qApp.primaryScreen().logicalDotsPerInchX()
self.guiScale = qApp.primaryScreen().logicalDotsPerInchX()/96.0
self.mainConf.guiScale = self.guiScale
logger.verbose("GUI DPI: %.1f", self.guiDPI)
logger.verbose("GUI Scale: %.2f", self.guiScale)
# Fonts
self.guiFont = qApp.font()
qMetric = QFontMetrics(self.guiFont)
self.fontPointSize = self.guiFont.pointSizeF()
self.fontPixelSize = int(round(qMetric.height()))
self.baseIconSize = int(round(qMetric.ascent()))
self.textNHeight = qMetric.boundingRect("N").height()
self.textNWidth = qMetric.boundingRect("N").width()
# Monospace Font
self.guiFontFixed = QFont()
self.guiFontFixed.setPointSizeF(0.95*self.fontPointSize)
self.guiFontFixed.setFamily(QFontDatabase.systemFont(QFontDatabase.FixedFont).family())
logger.verbose("GUI Font Family: %s", self.guiFont.family())
logger.verbose("GUI Font Point Size: %.2f", self.fontPointSize)
logger.verbose("GUI Font Pixel Size: %d", self.fontPixelSize)
logger.verbose("GUI Base Icon Size: %d", self.baseIconSize)
logger.verbose("Text 'N' Height: %d", self.textNHeight)
logger.verbose("Text 'N' Width: %d", self.textNWidth)
# Internal Mapping
self.makeAlert = self.theParent.makeAlert
self.tr = partial(QCoreApplication.translate, "GuiTheme")
return
##
# Methods
##
def getTextWidth(self, theText, theFont=None):
"""Returns the width needed to contain a given piece of text.
"""
if isinstance(theFont, QFont):
qMetrics = QFontMetrics(theFont)
else:
qMetrics = QFontMetrics(self.guiFont)
return int(ceil(qMetrics.boundingRect(theText).width()))
##
# Actions
##
def loadFonts(self):
"""Add the fonts in the assets fonts folder to the app.
"""
logger.debug("Loading additional fonts")
ttfList = []
fontAssets = os.path.join(self.mainConf.assetPath, self.fontPath)
for fontFam in os.listdir(fontAssets):
fontDir = os.path.join(fontAssets, fontFam)
if os.path.isdir(fontDir):
logger.verbose("Found font: %s", fontFam)
if fontFam not in self.guiFontDB.families():
for fontFile in os.listdir(fontDir):
ttfFile = os.path.join(fontDir, fontFile)
if os.path.isfile(ttfFile) and fontFile.endswith(".ttf"):
ttfList.append(ttfFile)
for ttfFile in ttfList:
relPath = os.path.relpath(ttfFile, fontAssets)
logger.verbose("Adding font: %s", relPath)
fontID = self.guiFontDB.addApplicationFont(ttfFile)
if fontID < 0:
logger.error("Failed to add font: %s", relPath)
return
def updateFont(self):
"""Update the GUI's font style from settings.
"""
theFont = QFont()
if self.mainConf.guiFont not in self.guiFontDB.families():
if self.mainConf.osWindows:
if "Arial" in self.guiFontDB.families():
theFont.setFamily("Arial")
else:
# On Windows, fall back to Cantarell provided by novelWriter
theFont.setFamily("Cantarell")
theFont.setPointSize(10)
else:
theFont = self.guiFontDB.systemFont(QFontDatabase.GeneralFont)
self.mainConf.guiFont = theFont.family()
self.mainConf.guiFontSize = theFont.pointSize()
else:
theFont.setFamily(self.mainConf.guiFont)
theFont.setPointSize(self.mainConf.guiFontSize)
qApp.setFont(theFont)
return
def updateTheme(self):
"""Update the GUI theme from theme files.
"""
self.guiTheme = self.mainConf.guiTheme
self.guiSyntax = self.mainConf.guiSyntax
self.themeRoot = self.mainConf.themeRoot
self.themePath = os.path.join(self.mainConf.themeRoot, self.guiPath, self.guiTheme)
self.syntaxFile = os.path.join(self.themeRoot, self.syntaxPath, self.guiSyntax+".conf")
self.confFile = os.path.join(self.themePath, self.confName)
self.cssFile = os.path.join(self.themePath, self.cssName)
self.loadTheme()
self.loadSyntax()
# Update dependant colours
backCol = qApp.palette().window().color()
textCol = qApp.palette().windowText().color()
backLCol = backCol.lightnessF()
textLCol = textCol.lightnessF()
if backLCol > textLCol:
helpLCol = textLCol + 0.65*(backLCol - textLCol)
else:
helpLCol = backLCol + 0.65*(textLCol - backLCol)
self.helpText = [int(255*helpLCol)]*3
return True
def loadTheme(self):
"""Load the currently specified GUI theme.
"""
logger.debug("Loading theme files")
logger.debug("System icon theme is '%s'", str(QIcon.themeName()))
# CSS File
cssData = ""
try:
if os.path.isfile(self.cssFile):
with open(self.cssFile, mode="r", encoding="utf-8") as inFile:
cssData = inFile.read()
except Exception:
logger.error("Could not load theme css file")
novelwriter.logException()
return False
# Config File
confParser = NWConfigParser()
try:
with open(self.confFile, mode="r", encoding="utf-8") as inFile:
confParser.read_file(inFile)
except Exception:
logger.error("Could not load theme settings from: %s", self.confFile)
novelwriter.logException()
return False
# Main
cnfSec = "Main"
if confParser.has_section(cnfSec):
self.themeName = confParser.rdStr(cnfSec, "name", "")
self.themeDescription = confParser.rdStr(cnfSec, "description", "N/A")
self.themeAuthor = confParser.rdStr(cnfSec, "author", "N/A")
self.themeCredit = confParser.rdStr(cnfSec, "credit", "N/A")
self.themeUrl = confParser.rdStr(cnfSec, "url", "")
self.themeLicense = confParser.rdStr(cnfSec, "license", "N/A")
self.themeLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "")
# Palette
cnfSec = "Palette"
if confParser.has_section(cnfSec):
self._setPalette(confParser, cnfSec, "window", QPalette.Window)
self._setPalette(confParser, cnfSec, "windowtext", QPalette.WindowText)
self._setPalette(confParser, cnfSec, "base", QPalette.Base)
self._setPalette(confParser, cnfSec, "alternatebase", QPalette.AlternateBase)
self._setPalette(confParser, cnfSec, "text", QPalette.Text)
self._setPalette(confParser, cnfSec, "tooltipbase", QPalette.ToolTipBase)
self._setPalette(confParser, cnfSec, "tooltiptext", QPalette.ToolTipText)
self._setPalette(confParser, cnfSec, "button", QPalette.Button)
self._setPalette(confParser, cnfSec, "buttontext", QPalette.ButtonText)
self._setPalette(confParser, cnfSec, "brighttext", QPalette.BrightText)
self._setPalette(confParser, cnfSec, "highlight", QPalette.Highlight)
self._setPalette(confParser, cnfSec, "highlightedtext", QPalette.HighlightedText)
self._setPalette(confParser, cnfSec, "link", QPalette.Link)
self._setPalette(confParser, cnfSec, "linkvisited", QPalette.LinkVisited)
# GUI
cnfSec = "GUI"
if confParser.has_section(cnfSec):
self.statNone = self._loadColour(confParser, cnfSec, "statusnone")
self.statUnsaved = self._loadColour(confParser, cnfSec, "statusunsaved")
self.statSaved = self._loadColour(confParser, cnfSec, "statussaved")
# Apply Styles
qApp.setStyleSheet(cssData)
qApp.setPalette(self.guiPalette)
logger.info("Loaded theme '%s'", self.guiTheme)
return True
def loadSyntax(self):
"""Load the currently specified syntax highlighter theme.
"""
logger.debug("Loading syntax theme files")
confParser = NWConfigParser()
try:
with open(self.syntaxFile, mode="r", encoding="utf-8") as inFile:
confParser.read_file(inFile)
except Exception:
logger.error("Could not load syntax colours from: %s", self.syntaxFile)
novelwriter.logException()
return False
# Main
cnfSec = "Main"
if confParser.has_section(cnfSec):
self.syntaxName = confParser.rdStr(cnfSec, "name", "")
self.syntaxDescription = confParser.rdStr(cnfSec, "description", "")
self.syntaxAuthor = confParser.rdStr(cnfSec, "author", "")
self.syntaxCredit = confParser.rdStr(cnfSec, "credit", "")
self.syntaxUrl = confParser.rdStr(cnfSec, "url", "")
self.syntaxLicense = confParser.rdStr(cnfSec, "license", "")
self.syntaxLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "")
# Syntax
cnfSec = "Syntax"
if confParser.has_section(cnfSec):
self.colBack = self._loadColour(confParser, cnfSec, "background")
self.colText = self._loadColour(confParser, cnfSec, "text")
self.colLink = self._loadColour(confParser, cnfSec, "link")
self.colHead = self._loadColour(confParser, cnfSec, "headertext")
self.colHeadH = self._loadColour(confParser, cnfSec, "headertag")
self.colEmph = self._loadColour(confParser, cnfSec, "emphasis")
self.colDialN = self._loadColour(confParser, cnfSec, "straightquotes")
self.colDialD = self._loadColour(confParser, cnfSec, "doublequotes")
self.colDialS = self._loadColour(confParser, cnfSec, "singlequotes")
self.colHidden = self._loadColour(confParser, cnfSec, "hidden")
self.colKey = self._loadColour(confParser, cnfSec, "keyword")
self.colVal = self._loadColour(confParser, cnfSec, "value")
self.colSpell = self._loadColour(confParser, cnfSec, "spellcheckline")
self.colError = self._loadColour(confParser, cnfSec, "errorline")
self.colRepTag = self._loadColour(confParser, cnfSec, "replacetag")
self.colMod = self._loadColour(confParser, cnfSec, "modifier")
logger.info("Loaded syntax theme '%s'", self.guiSyntax)
return True
def listThemes(self):
"""Scan the GUI themes folder and list all themes.
"""
if self.themeList:
return self.themeList
confParser = NWConfigParser()
for themeDir in os.listdir(os.path.join(self.mainConf.themeRoot, self.guiPath)):
themeConf = os.path.join(
self.mainConf.themeRoot, self.guiPath, themeDir, self.confName
)
logger.verbose("Checking theme config for '%s'", themeDir)
try:
with open(themeConf, mode="r", encoding="utf-8") as inFile:
confParser.read_file(inFile)
except Exception as e:
self.makeAlert([
self.tr("Could not load theme config file."), str(e)
], nwAlert.ERROR)
continue
themeName = ""
if confParser.has_section("Main"):
if confParser.has_option("Main", "name"):
themeName = confParser.get("Main", "name")
logger.verbose("Theme name is '%s'", themeName)
if themeName != "":
self.themeList.append((themeDir, themeName))
self.themeList = sorted(self.themeList, key=lambda x: x[1])
return self.themeList
def listSyntax(self):
"""Scan the syntax themes folder and list all themes.
"""
if self.syntaxList:
return self.syntaxList
confParser = NWConfigParser()
syntaxDir = os.path.join(self.mainConf.themeRoot, self.syntaxPath)
for syntaxFile in os.listdir(syntaxDir):
syntaxPath = os.path.join(syntaxDir, syntaxFile)
if not os.path.isfile(syntaxPath):
continue
logger.verbose("Checking theme syntax for '%s'", syntaxFile)
try:
with open(syntaxPath, mode="r", encoding="utf-8") as inFile:
confParser.read_file(inFile)
except Exception as e:
self.makeAlert([
self.tr("Could not load syntax file."), str(e)
], nwAlert.ERROR)
return []
syntaxName = ""
if confParser.has_section("Main"):
if confParser.has_option("Main", "name"):
syntaxName = confParser.get("Main", "name")
if len(syntaxFile) > 5 and syntaxName != "":
self.syntaxList.append((syntaxFile[:-5], syntaxName))
logger.verbose("Syntax name is '%s'", syntaxName)
self.syntaxList = sorted(self.syntaxList, key=lambda x: x[1])
return self.syntaxList
##
# Internal Functions
##
def _loadColour(self, confParser, cnfSec, cnfName):
"""Load a colour value from a config string.
"""
if confParser.has_option(cnfSec, cnfName):
inData = confParser.get(cnfSec, cnfName).split(",")
outData = []
try:
outData.append(int(inData[0]))
outData.append(int(inData[1]))
outData.append(int(inData[2]))
except Exception:
logger.error("Could not load theme colours for '%s' from config file", cnfName)
outData = [0, 0, 0]
else:
logger.warning("Could not find theme colours for '%s' in config file", cnfName)
outData = [0, 0, 0]
return outData
def _setPalette(self, confParser, cnfSec, cnfName, paletteVal):
"""Set a palette colour value from a config string.
"""
readCol = []
if confParser.has_option(cnfSec, cnfName):
inData = confParser.get(cnfSec, cnfName).split(",")
try:
readCol.append(int(inData[0]))
readCol.append(int(inData[1]))
readCol.append(int(inData[2]))
except Exception:
logger.error("Could not load theme colours for '%s' from config file", cnfName)
return
if len(readCol) == 3:
self.guiPalette.setColor(paletteVal, QColor(*readCol))
return
# End Class GuiTheme
# =============================================================================================== #
# Icons Class
# =============================================================================================== #
class GuiIcons:
"""The icon class manages the content of the assets/icons folder,
and provides a simple interface for requesting icons. Only icons
listed in the ICON_MAP are handled.
Icons are loaded on first request, and then cached for further
requests. Each icon key in the ICON_MAP has a series of fallbacks:
* The first lookup is in the key-to-file map for the selected icon
theme. The map is specified in the icons.conf file in the theme
folder. The map makes it possible to preserve the original file
name from the icon theme were the icons were extracted.
* Second, if the icon does not exist in the theme map, the
GuiIcons class will check if there is a QStyle icon specified in
the ICON_MAP data tuple[0]. This will let Qt pull the closest
system icon.
* Third action is to look up the freedesktop icon theme name using
the fromTheme Qt call. This generally produces the same result
as the step above, but has more icons available in other cases.
* Fourth, and finally, the icon is looked up in the fallback
folder. Files in this folder must have the same file name as the
novelWriter internal icon key, with '-dark' appended to them for
the dark background version of the icon. If no dark icon exists,
the non-dark version will be returned.
"""
ICON_MAP = {
# Project and GUI icons
"novelwriter": (None, None),
"cls_none": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_novel": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_plot": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_character": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_world": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_timeline": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_object": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_entity": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_custom": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_archive": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"cls_trash": (QStyle.SP_DriveHDIcon, "drive-harddisk"),
"proj_document": (QStyle.SP_FileIcon, "x-office-document"),
"proj_title": (QStyle.SP_FileIcon, "x-office-document"),
"proj_chapter": (QStyle.SP_FileIcon, "x-office-document"),
"proj_scene": (QStyle.SP_FileIcon, "x-office-document"),
"proj_note": (QStyle.SP_FileIcon, "x-office-document"),
"proj_folder": (QStyle.SP_DirIcon, "folder"),
"proj_nwx": (None, None),
"status_lang": (None, None),
"status_time": (None, None),
"status_idle": (None, None),
"status_stats": (None, None),
"status_lines": (None, None),
"doc_h0": (QStyle.SP_FileIcon, "x-office-document"),
"doc_h1": (QStyle.SP_FileIcon, "x-office-document"),
"doc_h2": (QStyle.SP_FileIcon, "x-office-document"),
"doc_h3": (QStyle.SP_FileIcon, "x-office-document"),
"doc_h4": (QStyle.SP_FileIcon, "x-office-document"),
"search_case": (None, None),
"search_regex": (None, None),
"search_word": (None, None),
"search_loop": (None, None),
"search_project": (None, None),
"search_cancel": (None, None),
"search_preserve": (None, None),
# General Button Icons
"folder-open": (QStyle.SP_DirOpenIcon, "folder-open"),
"delete": (QStyle.SP_DialogDiscardButton, "edit-delete"),
"close": (QStyle.SP_DialogCloseButton, "window-close"),
"done": (QStyle.SP_DialogApplyButton, None),
"clear": (QStyle.SP_LineEditClearButton, "clear_left"),
"save": (QStyle.SP_DialogSaveButton, "document-save"),
"add": (None, "list-add"),
"remove": (None, "list-remove"),
"search": (None, "edit-find"),
"search-replace": (None, "edit-find-replace"),
"edit": (None, None),
"check": (None, None),
"cross": (None, None),
"hash": (None, None),
"maximise": (None, None),
"minimise": (None, None),
"refresh": (None, None),
"reference": (None, None),
"backward": (None, None),
"forward": (None, None),
"settings": (None, None),
# Switches
"sticky-on": (None, None),
"sticky-off": (None, None),
"bullet-on": (None, None),
"bullet-off": (None, None),
}
DECO_MAP = {
"wiz-back": "wizard-back.jpg",
}
def __init__(self, theParent):
self.mainConf = novelwriter.CONFIG
self.theParent = theParent
# Storage
self.qIcons = {}
self.themeMap = {}
self.themeList = []
self.fbackName = "fallback"
self.confName = "icons.conf"
# Icon Theme Path
self.iconPath = None
self.confFile = None
# Icon Theme Meta
self.themeName = ""
self.themeDescription = ""
self.themeAuthor = ""
self.themeCredit = ""
self.themeUrl = ""
self.themeLicense = ""
self.themeLicenseUrl = ""
return
##
# Actions
##
def updateTheme(self):
"""Update the theme map. This is more of an init, since many of
the GUI icons cannot really be replaced without writing specific
update functions for the classes where they're used.
"""
logger.debug("Loading icon theme files")
self.themeMap = {}
checkPath = os.path.join(self.mainConf.iconPath, self.mainConf.guiIcons)
if os.path.isdir(checkPath):
logger.debug("Loading icon theme '%s'", self.mainConf.guiIcons)
self.iconPath = checkPath
self.confFile = os.path.join(checkPath, self.confName)
else:
return False
# Config File
confParser = NWConfigParser()
try:
with open(self.confFile, mode="r", encoding="utf-8") as inFile:
confParser.read_file(inFile)
except Exception:
logger.error("Could not load icon theme settings from: %s", self.confFile)
novelwriter.logException()
return False
# Main
cnfSec = "Main"
if confParser.has_section(cnfSec):
self.themeName = confParser.rdStr(cnfSec, "name", "")
self.themeDescription = confParser.rdStr(cnfSec, "description", "")
self.themeAuthor = confParser.rdStr(cnfSec, "author", "N/A")
self.themeCredit = confParser.rdStr(cnfSec, "credit", "N/A")
self.themeUrl = confParser.rdStr(cnfSec, "url", "")
self.themeLicense = confParser.rdStr(cnfSec, "license", "N/A")
self.themeLicenseUrl = confParser.rdStr(cnfSec, "licenseurl", "")
# Palette
cnfSec = "Map"
if confParser.has_section(cnfSec):
for iconName, iconFile in confParser.items(cnfSec):
if iconName not in self.ICON_MAP:
logger.error("Unknown icon name '%s' in config file", iconName)
else:
iconPath = os.path.join(self.iconPath, iconFile)
if os.path.isfile(iconPath):
self.themeMap[iconName] = iconPath
logger.verbose("Icon slot '%s' using file '%s'", iconName, iconFile)
else:
logger.error("Icon file '%s' not in theme folder", iconFile)
logger.info("Loaded icon theme '%s'", self.mainConf.guiIcons)
return True
##
# Access Functions
##
def loadDecoration(self, decoKey, pxW, pxH):
"""Load graphical decoration element based on the decoration
map. This function always returns a QSwgWidget.
"""
if decoKey not in self.DECO_MAP:
logger.error("Decoration with name '%s' does not exist", decoKey)
return QPixmap()
imgPath = os.path.join(
self.mainConf.assetPath, "images", self.DECO_MAP[decoKey]
)
if not os.path.isfile(imgPath):
logger.error("Decoration file '%s' not in assets folder", self.DECO_MAP[decoKey])
return QPixmap()
theDeco = QPixmap(imgPath)
if pxW is not None and pxH is not None:
return theDeco.scaled(pxW, pxH, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
elif pxW is None and pxH is not None:
return theDeco.scaledToHeight(pxH, Qt.SmoothTransformation)
elif pxW is not None and pxH is None:
return theDeco.scaledToWidth(pxW, Qt.SmoothTransformation)
return theDeco
def getIcon(self, iconKey, iconSize=None):
"""Return an icon from the icon buffer. If it doesn't exist,
return, load it, and if it still doesn't exist, return an empty
icon.
"""
if iconKey in self.qIcons:
return self.qIcons[iconKey]
else:
qIcon = self._loadIcon(iconKey)
self.qIcons[iconKey] = qIcon
return qIcon
def getPixmap(self, iconKey, iconSize):
"""Return an icon from the icon buffer as a QPixmap. If it
doesn't exist, return an empty QPixmap.
"""
qIcon = self.getIcon(iconKey)
return qIcon.pixmap(iconSize[0], iconSize[1], QIcon.Normal)
def getItemIcon(self, tType, tClass, tLayout, hLevel="H0"):
"""Get the correct icon for a project item based on type, class
and header level
"""
iconName = None
if tType == nwItemType.ROOT:
iconName = nwLabels.CLASS_ICON[tClass]
elif tType == nwItemType.FOLDER:
iconName = "proj_folder"
elif tType == nwItemType.FILE:
iconName = "proj_document"
if tLayout == nwItemLayout.DOCUMENT:
if hLevel == "H1":
iconName = "proj_title"
elif hLevel == "H2":
iconName = "proj_chapter"
elif hLevel == "H3":
iconName = "proj_scene"
elif tLayout == nwItemLayout.NOTE:
iconName = "proj_note"
elif tType == nwItemType.TRASH:
iconName = nwLabels.CLASS_ICON[tClass]
if iconName is None:
return QIcon()
return self.getIcon(iconName)
def listThemes(self):
"""Scan the icons themes folder and list all themes.
"""
if self.themeList:
return self.themeList
confParser = NWConfigParser()
for themeDir in os.listdir(self.mainConf.iconPath):
themePath = os.path.join(self.mainConf.iconPath, themeDir)
if not os.path.isdir(themePath) or themeDir == self.fbackName:
continue
themeConf = os.path.join(themePath, self.confName)
logger.verbose("Checking icon theme config for '%s'", themeDir)
try:
with open(themeConf, mode="r", encoding="utf-8") as inFile:
confParser.read_file(inFile)
except Exception as e:
self.makeAlert([
self.tr("Could not load theme config file."), str(e)
], nwAlert.ERROR)
continue
themeName = ""
if confParser.has_section("Main"):
if confParser.has_option("Main", "name"):
themeName = confParser.get("Main", "name")
logger.verbose("Theme name is '%s'", themeName)
if themeName != "":
self.themeList.append((themeDir, themeName))
self.themeList = sorted(self.themeList, key=lambda x: x[1])
return self.themeList
##
# Internal Functions
##
def _loadIcon(self, iconKey):
"""Load an icon from the assets or themes folder, with a
preference for dark/light icons depending on theme type, if such
an icon exists. Prefer svg files over png files. Always returns
a QIcon.
"""
if iconKey not in self.ICON_MAP:
logger.error("Requested unknown icon name '%s'", iconKey)
return QIcon()
# If we just want the app icon, return it right away
if iconKey == "novelwriter":
return QIcon(os.path.join(self.mainConf.iconPath, "novelwriter.svg"))
# Otherwise, we start looking for it
# First in the theme folder
if iconKey in self.themeMap:
relPath = os.path.relpath(self.themeMap[iconKey], self.mainConf.iconPath)
logger.verbose("Loading: %s", relPath)
return QIcon(self.themeMap[iconKey])
# Next, we try to load the Qt style icons
if self.ICON_MAP[iconKey][0] is not None:
logger.verbose("Loading icon '%s' from Qt QStyle.standardIcon", iconKey)
return qApp.style().standardIcon(self.ICON_MAP[iconKey][0])
# If we're still here, try to set from system theme
if self.ICON_MAP[iconKey][1] is not None:
logger.verbose("Loading icon '%s' from system theme", iconKey)
if QIcon().hasThemeIcon(self.ICON_MAP[iconKey][1]):
return QIcon().fromTheme(self.ICON_MAP[iconKey][1])
# Finally. we check if we have a fallback icon
if self.mainConf.guiDark:
fbackIcon = os.path.join(
self.mainConf.iconPath, self.fbackName, "%s-dark.svg" % iconKey
)
if os.path.isfile(fbackIcon):
logger.verbose("Loading icon '%s' from fallback theme (dark mode)", iconKey)
return QIcon(fbackIcon)
fbackIcon = os.path.join(self.mainConf.iconPath, self.fbackName, "%s.svg" % iconKey)
if os.path.isfile(fbackIcon):
logger.verbose("Loading icon '%s' from fallback theme (light mode)", iconKey)
return QIcon(fbackIcon)
# Give up and return an empty icon
logger.warning("Did not load an icon for '%s'", iconKey)
return QIcon()
# END Class GuiIcons